Fail closed when persistent session signing material is unavailable

This commit is contained in:
archipelago
2026-10-06 01:30:04 -04:00
parent 5492080526
commit aa10bd1247
6 changed files with 316 additions and 50 deletions
+3 -3
View File
@@ -317,14 +317,14 @@ mod sanitize_tests {
/// Deterministic: same session token always produces the same CSRF token.
/// Survives backend restarts because it depends only on the session token
/// and the on-disk remember secret (not ephemeral state).
pub(crate) async fn derive_csrf_token(session_token: &str) -> String {
pub(crate) async fn derive_csrf_token(session_token: &str) -> std::io::Result<String> {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let secret = SessionStore::load_or_create_remember_secret().await;
let secret = SessionStore::load_or_create_remember_secret().await?;
let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key");
mac.update(format!("csrf:{}", session_token).as_bytes());
hex::encode(mac.finalize().into_bytes())
Ok(hex::encode(mac.finalize().into_bytes()))
}
/// Extract a named cookie value from headers.
+24 -13
View File
@@ -347,6 +347,16 @@ impl RpcHandler {
// Enforce authentication for non-allowlisted methods
let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str());
if !is_unauthenticated || rpc_req.method.starts_with("auth.") {
if let Err(error) = SessionStore::load_or_create_remember_secret().await {
tracing::error!(%error, "Persistent session signing key unavailable");
return Ok(self.error_response(
503,
"Sign-in temporarily unavailable. Check server session storage.",
StatusCode::SERVICE_UNAVAILABLE,
));
}
}
let mut new_session_cookies: Option<(String, String)> = None;
if !is_unauthenticated {
let mut authenticated = match &session_token {
@@ -359,7 +369,7 @@ impl RpcHandler {
if let Some(remember) = extract_cookie(&parts.headers, "remember") {
if crate::session::SessionStore::validate_remember_token(&remember).await {
let new_token = self.session_store.create().await;
let new_csrf = derive_csrf_token(&new_token).await;
let new_csrf = derive_csrf_token(&new_token).await?;
tracing::info!("Auto-restored session from remember-me token");
new_session_cookies = Some((new_token, new_csrf));
authenticated = true;
@@ -407,7 +417,7 @@ impl RpcHandler {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let secret = SessionStore::load_or_create_remember_secret().await;
let secret = SessionStore::load_or_create_remember_secret().await?;
let mut mac = match HmacSha256::new_from_slice(&secret) {
Ok(m) => m,
Err(_) => {
@@ -438,7 +448,7 @@ impl RpcHandler {
if let Some(token) = &session_token {
self.set_csrf_cookie(
&mut response,
&derive_csrf_token(token).await,
&derive_csrf_token(token).await?,
secure_suffix,
);
}
@@ -550,7 +560,7 @@ impl RpcHandler {
client_ip,
secure_suffix,
)
.await;
.await?;
Ok(response)
}
@@ -602,7 +612,7 @@ impl RpcHandler {
new_session_cookies: &Option<(String, String)>,
client_ip: std::net::IpAddr,
secure_suffix: &str,
) {
) -> Result<()> {
// Track failed login attempts for rate limiting
if method == "auth.login" && rpc_resp.error.is_some() {
self.login_rate_limiter.record_failure(client_ip).await;
@@ -642,7 +652,7 @@ impl RpcHandler {
if let Ok(Some(totp_data)) = self.auth_manager.get_totp_data().await {
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, password) {
let token = self.session_store.create_pending(secret).await;
let csrf_token = derive_csrf_token(&token).await;
let csrf_token = derive_csrf_token(&token).await?;
self.set_session_cookie(response, &token, secure_suffix);
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
let totp_body = serde_json::json!({
@@ -655,8 +665,8 @@ impl RpcHandler {
}
} else {
let token = self.session_store.create().await;
let csrf_token = derive_csrf_token(&token).await;
let remember_token = self.session_store.create_remember_token().await;
let csrf_token = derive_csrf_token(&token).await?;
let remember_token = self.session_store.create_remember_token().await?;
self.set_session_cookie(response, &token, secure_suffix);
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
self.set_remember_cookie(response, &remember_token, secure_suffix);
@@ -675,8 +685,8 @@ impl RpcHandler {
.map(|s| s.to_string());
if let Some(new_token) = new_token_opt {
let csrf_token = derive_csrf_token(&new_token).await;
let remember_token = self.session_store.create_remember_token().await;
let csrf_token = derive_csrf_token(&new_token).await?;
let remember_token = self.session_store.create_remember_token().await?;
self.set_session_cookie(response, &new_token, secure_suffix);
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
self.set_remember_cookie(response, &remember_token, secure_suffix);
@@ -695,7 +705,7 @@ impl RpcHandler {
if method == "auth.changePassword" && rpc_resp.error.is_none() {
if let Some(token) = session_token {
let new_token = self.session_store.rotate(token).await;
let csrf_token = derive_csrf_token(&new_token).await;
let csrf_token = derive_csrf_token(&new_token).await?;
self.set_session_cookie(response, &new_token, secure_suffix);
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
}
@@ -727,6 +737,7 @@ impl RpcHandler {
self.set_session_cookie(response, new_session, secure_suffix);
self.set_csrf_cookie(response, new_csrf, secure_suffix);
}
Ok(())
}
fn set_session_cookie(
@@ -891,7 +902,7 @@ mod csrf_recovery_tests {
.map(|v| v.to_str().unwrap())
.collect();
assert_eq!(cookies.len(), 1);
let expected = derive_csrf_token(&token).await;
let expected = derive_csrf_token(&token).await.unwrap();
assert_eq!(
cookies[0],
format!("csrf_token={expected}; SameSite=Lax; Path=/; Secure")
@@ -906,7 +917,7 @@ mod csrf_recovery_tests {
assert_eq!(stranger.status(), StatusCode::UNAUTHORIZED);
assert!(!stranger.headers().contains_key("set-cookie"));
assert!(!settings.exists());
let valid = derive_csrf_token(&token).await;
let valid = derive_csrf_token(&token).await.unwrap();
let response = handler
.handle(request(&token, Some(&valid), false))
.await
+25 -10
View File
@@ -700,6 +700,14 @@ fn strip_gate_cookies(headers: &mut hyper::HeaderMap) {
}
async fn set_session_cookie(resp: &mut Response<Body>, token: &str) {
// The dashboard RPC layer requires a readable CSRF cookie as well as the
// HttpOnly session cookie. An app-gate login is a complete node login, so
// it must establish the same pair as auth.login; otherwise a fresh browser
// can open the signer broker but every identity/signing RPC is rejected
// with `has_session=true, has_header=false`.
if !set_csrf_cookie(resp, token).await {
return;
}
// No Domain attribute, so the cookie is host-only. Cookies ignore port,
// which is what makes one sign-in cover the dashboard and every app port
// on the same host — and equally why an app on a *different* host (its
@@ -709,22 +717,29 @@ async fn set_session_cookie(resp: &mut Response<Body>, token: &str) {
{
resp.headers_mut().append(header::SET_COOKIE, value);
}
// The dashboard RPC layer requires a readable CSRF cookie as well as the
// HttpOnly session cookie. An app-gate login is a complete node login, so
// it must establish the same pair as auth.login; otherwise a fresh browser
// can open the signer broker but every identity/signing RPC is rejected
// with `has_session=true, has_header=false`.
set_csrf_cookie(resp, token).await;
}
async fn set_csrf_cookie(resp: &mut Response<Body>, token: &str) {
let csrf = crate::api::rpc::derive_csrf_token(token).await;
async fn set_csrf_cookie(resp: &mut Response<Body>, token: &str) -> bool {
let csrf = match crate::api::rpc::derive_csrf_token(token).await {
Ok(csrf) => csrf,
Err(error) => {
tracing::error!(%error, "App login could not load persistent session signing key");
*resp = Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.header(header::CACHE_CONTROL, "no-store")
.body(Body::from(
"Sign-in temporarily unavailable. Check server session storage.",
))
.expect("static error response");
return false;
}
};
if let Ok(value) =
header::HeaderValue::from_str(&format!("csrf_token={csrf}; SameSite=Lax; Path=/"))
{
resp.headers_mut().append(header::SET_COOKIE, value);
}
true
}
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
@@ -1691,7 +1706,7 @@ mod tests {
.iter()
.filter_map(|value| value.to_str().ok())
.collect();
let expected_csrf = crate::api::rpc::derive_csrf_token(token).await;
let expected_csrf = crate::api::rpc::derive_csrf_token(token).await.unwrap();
assert!(cookies
.iter()
.any(|cookie| cookie.starts_with(&format!("session={token};"))));
+19 -24
View File
@@ -1,5 +1,6 @@
#[path = "session_secret.rs"]
mod secret_file;
use hmac::{Hmac, Mac};
use rand::RngCore;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -398,8 +399,8 @@ impl SessionStore {
// Format: "timestamp_hex:hmac_hex"
/// Create a remember-me token. Returns the cookie value.
pub async fn create_remember_token(&self) -> String {
let secret = Self::load_or_create_remember_secret().await;
pub async fn create_remember_token(&self) -> std::io::Result<String> {
let secret = Self::load_or_create_remember_secret().await?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
@@ -408,13 +409,17 @@ impl SessionStore {
let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key");
mac.update(format!("remember:{}", ts_hex).as_bytes());
let sig = hex::encode(mac.finalize().into_bytes());
format!("{}:{}", ts_hex, sig)
Ok(format!("{}:{}", ts_hex, sig))
}
/// Validate a remember-me token. Returns true if valid and not expired.
pub async fn validate_remember_token(token: &str) -> bool {
let secret = match tokio::fs::read(REMEMBER_SECRET_FILE).await {
Ok(s) if s.len() == 32 => s,
let secret = match tokio::task::spawn_blocking(|| {
secret_file::read_existing(Path::new(REMEMBER_SECRET_FILE))
})
.await
{
Ok(Ok(s)) => s,
_ => return false,
};
let parts: Vec<&str> = token.splitn(2, ':').collect();
@@ -454,27 +459,17 @@ impl SessionStore {
now.saturating_sub(ts_bytes) < REMEMBER_TTL
}
pub async fn load_or_create_remember_secret() -> Vec<u8> {
pub async fn load_or_create_remember_secret() -> std::io::Result<Vec<u8>> {
REMEMBER_SECRET
.get_or_init(|| async {
// Try existing secret file first
if let Ok(secret) = tokio::fs::read(REMEMBER_SECRET_FILE).await {
if secret.len() == 32 {
return secret;
}
}
// Generate a cryptographically random 32-byte secret on first boot
let mut secret = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut secret);
// Ensure parent directory exists
if let Some(parent) = std::path::Path::new(REMEMBER_SECRET_FILE).parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let _ = tokio::fs::write(REMEMBER_SECRET_FILE, &secret).await;
secret.to_vec()
.get_or_try_init(|| async {
tokio::task::spawn_blocking(|| {
secret_file::load_or_create(Path::new(REMEMBER_SECRET_FILE))
})
.await
.clone()
.map_err(std::io::Error::other)?
})
.await
.cloned()
}
}
+219
View File
@@ -0,0 +1,219 @@
//! Persistent signing material must never fall back to an ephemeral key.
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
pub(super) fn read_existing(path: &Path) -> io::Result<Vec<u8>> {
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() || metadata.len() != 32 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Session key must be a regular 32-byte file; existing data was preserved",
));
}
// Tighten legacy modes on the opened inode. Permission failures are errors,
// never permission to replace a valid key or start with a temporary one.
if metadata.permissions().mode() & 0o777 != 0o600 {
file.set_permissions(fs::Permissions::from_mode(0o600))?;
file.sync_all()?;
}
let mut bytes = Vec::with_capacity(32);
file.take(33).read_to_end(&mut bytes)?;
if bytes.len() != 32 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Session key changed while reading",
));
}
Ok(bytes)
}
struct TemporaryKey(PathBuf);
impl Drop for TemporaryKey {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
pub(super) fn load_or_create(path: &Path) -> io::Result<Vec<u8>> {
match read_existing(path) {
Ok(key) => return Ok(key),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
let parent = path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"Session key has no parent directory",
)
})?;
fs::create_dir_all(parent)?;
let mut key = [0u8; 32];
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut key)
.map_err(|_| io::Error::other("Session key entropy unavailable"))?;
// Publish only a fully written, synced private inode. A hard link provides
// no-replace semantics: concurrent creators all read the one winning key.
// The temporary filename is independent of the secret.
let temporary_path = parent.join(format!(".session-key-{}", uuid::Uuid::new_v4()));
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temporary_path)?;
let temporary = TemporaryKey(temporary_path);
file.write_all(&key)?;
file.sync_all()?;
match fs::hard_link(&temporary.0, path) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error),
}
drop(temporary);
File::open(parent)?.sync_all()?;
read_existing(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
#[test]
fn durable_private_key_survives_reload_without_rotation() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("key");
let key = load_or_create(&path).unwrap();
assert_eq!(key.len(), 32);
assert_eq!(key, load_or_create(&path).unwrap());
assert_eq!(
fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn malformed_existing_key_is_preserved_and_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("key");
fs::write(&path, b"partial key").unwrap();
assert_eq!(
load_or_create(&path).unwrap_err().kind(),
io::ErrorKind::InvalidData
);
assert_eq!(fs::read(path).unwrap(), b"partial key");
}
#[test]
fn legacy_mode_is_tightened_without_changing_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("key");
fs::write(&path, [42; 32]).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(load_or_create(&path).unwrap(), vec![42; 32]);
assert_eq!(
fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn symlinks_and_non_files_are_rejected_without_replacement() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
fs::write(&target, [42; 32]).unwrap();
let link = dir.path().join("link");
symlink(&target, &link).unwrap();
assert!(load_or_create(&link).is_err());
assert!(load_or_create(dir.path()).is_err());
assert_eq!(fs::read(target).unwrap(), vec![42; 32]);
assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink());
}
#[test]
fn failed_storage_never_returns_an_ephemeral_key() {
let dir = tempfile::tempdir().unwrap();
let parent = dir.path().join("not-a-directory");
fs::write(&parent, b"preserve").unwrap();
assert!(load_or_create(&parent.join("key")).is_err());
assert_eq!(fs::read(parent).unwrap(), b"preserve");
}
#[test]
fn unreadable_existing_key_is_not_replaced() {
use std::os::unix::process::CommandExt;
let dir = tempfile::tempdir().unwrap();
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap();
let path = dir.path().join("key");
fs::write(&path, [42; 32]).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
if unsafe { libc::geteuid() } == 0 {
// The isolated runner is root. Probe as an unprivileged child so
// DAC_OVERRIDE cannot hide the exact production failure.
let status = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--ignored",
"--exact",
"session::secret_file::tests::permission_denied_child_probe",
])
.env("ARCHY_SESSION_KEY_PERMISSION_PROBE", &path)
.uid(65534)
.gid(65534)
.status()
.unwrap();
assert!(status.success());
} else {
fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).unwrap();
assert_eq!(
load_or_create(&path).unwrap_err().kind(),
io::ErrorKind::PermissionDenied
);
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
}
assert_eq!(fs::read(path).unwrap(), vec![42; 32]);
}
#[test]
#[ignore = "Executed by unreadable_existing_key_is_not_replaced as an unprivileged child"]
fn permission_denied_child_probe() {
let path = PathBuf::from(
std::env::var_os("ARCHY_SESSION_KEY_PERMISSION_PROBE")
.expect("parent provides private fixture path"),
);
assert_eq!(
load_or_create(&path).unwrap_err().kind(),
io::ErrorKind::PermissionDenied
);
}
#[test]
fn concurrent_first_boot_creators_agree_on_one_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("key");
let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
let workers: Vec<_> = (0..8)
.map(|_| {
let path = path.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
load_or_create(&path).unwrap()
})
})
.collect();
let keys: Vec<_> = workers
.into_iter()
.map(|worker| worker.join().unwrap())
.collect();
for key in &keys {
assert_eq!(key, &keys[0]);
}
assert_eq!(fs::read(path).unwrap(), keys[0]);
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
}
}
+26
View File
@@ -51,3 +51,29 @@ Retain follow-ups: durable remember-secret creation must be private and atomic;
unreadable/unpersistable keys must not silently become ephemeral successful
configuration; logout must expire/revoke remember credentials as intended.
Those broader hardening changes are not claimed by this recovery patch.
## Persistent key follow-up (qualification pending)
The previous loader silently generated an in-memory signing key after any read
failure and ignored write failures. It now returns storage errors, so RPC auth
fails before dispatch and app login returns an uncached service-unavailable
response rather than issuing mismatched cookies. Existing valid bytes are never
rotated as a permissions repair. Remember-token validation still reads the
current on-disk key, preserving the existing rotation/revocation behavior.
Creation writes and syncs a private temporary inode, publishes it without
replacement, syncs the directory, and reads the winning key. Concurrent creators
therefore agree. Existing symlinks, non-files, malformed files and unreadable
files are rejected. Legacy readable files have their permissions tightened on
the opened inode. Storage failures are not cached as successful initialization.
Regression cases cover reload, permissions, malformed keys, links/non-files,
failed storage, concurrent creation, and the actual root-owned 0600 failure
using an unprivileged child inside the isolated backend runner. This section is
implementation scope, not a claim that compilation or live acceptance passed.
Candidate frontend browser checks passed at 390 and 1440 pixels for stale-CSRF
recovery and interface-fetch failure/retry, with real Yaya interfaces on retry.
The initial failures were injected. Evidence:
`/tmp/archy-session-recovery-browser.log`. Physical companion and deployed
backend recovery acceptance remain separate gates.