From 30ee664e36a903433e7ae7c49127ea49889deb21 Mon Sep 17 00:00:00 2001 From: Alex Bates Date: Fri, 7 Aug 2026 11:10:11 +0100 Subject: [PATCH] persist secrets in secrets.json --- flake.nix | 10 ----- src/config.rs | 97 +++++++------------------------------------ src/main.rs | 1 + src/oauth/callback.rs | 9 +--- src/oauth/login.rs | 7 +--- src/oauth/metadata.rs | 12 +++--- src/oauth/refresh.rs | 11 ++--- src/secrets.rs | 70 +++++++++++++++++++++++++++++++ src/state.rs | 15 ++++--- 9 files changed, 108 insertions(+), 124 deletions(-) create mode 100644 src/secrets.rs diff --git a/flake.nix b/flake.nix index 71a80c9..49c8a53 100644 --- a/flake.nix +++ b/flake.nix @@ -116,15 +116,6 @@ example = "https://starhaven.dev"; description = "External base URL (with scheme)."; }; - - environmentFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - description = '' - Path to an EnvironmentFile providing OAUTH_PRIVATE_KEYS and - COOKIE_SECRET. - ''; - }; }; config = lib.mkIf cfg.enable { @@ -141,7 +132,6 @@ serviceConfig = { ExecStart = lib.getExe cfg.package; - EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile; DynamicUser = true; StateDirectory = "starhaven"; Restart = "on-failure"; diff --git a/src/config.rs b/src/config.rs index 0f86115..18b27e8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,4 @@ -//! Configuration loaded from environment variables. - -use atproto_identity::key::{generate_key, identify_key, to_public, KeyData, KeyType}; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use base64::Engine as _; -use rand::RngCore as _; +//! Configuration and on-disk paths. /// The OAuth scope requested for every session. pub const OAUTH_SCOPE: &str = "atproto transition:generic"; @@ -12,10 +7,6 @@ pub const OAUTH_SCOPE: &str = "atproto transition:generic"; pub struct Config { /// External base URL (with scheme), e.g. `https://starhaven.dev`. pub external_base: String, - /// OAuth private signing keys (P-256). First is the active signer. - pub oauth_private_keys: Vec, - /// AES-256-GCM cookie secret (32 bytes). - pub cookie_secret: [u8; 32], } impl Config { @@ -24,59 +15,19 @@ impl Config { let external_base = std::env::var("HTTP_EXTERNAL_BASE") .unwrap_or_else(|_| "http://127.0.0.1:3000".to_string()); - let oauth_private_keys = match std::env::var("OAUTH_PRIVATE_KEYS") { - Ok(raw) => raw - .split(',') - .map(|s| identify_key(s.trim()).expect("invalid OAUTH_PRIVATE_KEYS entry")) - .collect(), - Err(_) => { - eprintln!("====================================================="); - eprintln!("WARNING: OAUTH_PRIVATE_KEYS not set."); - eprintln!("Generating an EPHEMERAL signing key for this run only."); - eprintln!("This key will NOT persist across restarts."); - eprintln!("To pin it, set OAUTH_PRIVATE_KEYS in your environment."); - let key = generate_key(KeyType::P256Private).expect("key generation failed"); - eprintln!("Generated key: {key}"); - eprintln!("====================================================="); - vec![key] - } - }; - - let cookie_secret = match std::env::var("COOKIE_SECRET") { - Ok(raw) => BASE64_STANDARD - .decode(raw.trim()) - .expect("invalid COOKIE_SECRET base64") - .try_into() - .expect("COOKIE_SECRET must decode to exactly 32 bytes"), - Err(_) => { - let mut secret = [0u8; 32]; - rand::rng().fill_bytes(&mut secret); - eprintln!("====================================================="); - eprintln!("WARNING: COOKIE_SECRET not set."); - eprintln!("Generating an EPHEMERAL cookie secret for this run only."); - eprintln!("Existing sessions will be invalidated on every restart."); - eprintln!("To pin it, set COOKIE_SECRET in your environment."); - eprintln!("Generated secret: {}", BASE64_STANDARD.encode(secret)); - eprintln!("====================================================="); - secret - } - }; - - Self { - external_base, - oauth_private_keys, - cookie_secret, - } + Self { external_base } } - /// Path to the SQLite database file, under the platform's local data - /// directory (e.g. `~/.local/share/starhaven/database.db` on Linux, or - /// `$XDG_DATA_HOME` if set). Not user-configurable: in production this - /// is expected to be pointed elsewhere by setting `XDG_DATA_HOME` (e.g. - /// to a systemd `StateDirectory`), not an app-specific env var. - pub fn database_path() -> std::path::PathBuf { + /// The app's data directory (e.g. `~/.local/share/starhaven`). Relocated + /// by setting `XDG_DATA_HOME`, not an app-specific env var. + pub fn state_dir() -> std::path::PathBuf { let base = dirs::data_local_dir().expect("no local data directory for this platform"); - base.join("starhaven").join("database.db") + base.join("starhaven") + } + + /// Path to the SQLite database file. Created on startup if missing. + pub fn database_path() -> std::path::PathBuf { + Self::state_dir().join("database.db") } /// The OAuth client id (the `client-metadata.json` URL). @@ -103,14 +54,9 @@ impl Config { .unwrap_or(false) } - /// The `client_id` for login/callback: the fetchable `client-metadata. - /// json` URL normally, or atproto OAuth's spec-defined loopback form - /// (`http://localhost?redirect_uri=...&scope=...`, no metadata fetch -- - /// mirrors tangled.sh's `oauth.NewLocalhostConfig`) for local dev. - /// - /// `atproto-oauth` 0.14.5 always sends a `client_assertion` regardless, - /// which a loopback client isn't supposed to present -- see - /// `oauth::public_client` for the token-exchange workaround this forces. + /// The `client_id` for login/callback: the `client-metadata.json` URL, + /// or atproto OAuth's loopback form for local dev (which forces the + /// token-exchange workaround in `oauth::public_client`). pub fn oauth_client_id_for_login(&self) -> String { if self.is_loopback() { format!( @@ -132,19 +78,6 @@ impl Config { pub fn jwks_uri(&self) -> String { format!("{}/jwks.json", self.external_base) } - - /// The public half of every configured signing key, for `/jwks.json`. - /// - /// `atproto_oauth::jwk::generate` embeds whatever key material it is - /// given verbatim into the returned JWK -- passing a private `KeyData` - /// through would publish the private scalar (`d`). Always convert to - /// public first. - pub fn oauth_public_keys(&self) -> Vec { - self.oauth_private_keys - .iter() - .filter_map(|k| to_public(k).ok()) - .collect() - } } #[cfg(test)] @@ -154,8 +87,6 @@ mod tests { fn config_with_base(external_base: &str) -> Config { Config { external_base: external_base.to_string(), - oauth_private_keys: vec![generate_key(KeyType::P256Private).unwrap()], - cookie_secret: [0u8; 32], } } diff --git a/src/main.rs b/src/main.rs index c5a4aba..4174bfb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod config; mod error; mod oauth; +mod secrets; mod state; use axum::{ diff --git a/src/oauth/callback.rs b/src/oauth/callback.rs index 8a53bec..c240e78 100644 --- a/src/oauth/callback.rs +++ b/src/oauth/callback.rs @@ -120,12 +120,7 @@ pub async fn callback( let dpop_key = identify_key(&persisted.dpop_private_key) .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid stored DPoP key: {e}")))?; - let signing_key = state - .config - .oauth_private_keys - .first() - .cloned() - .ok_or_else(|| AppError::Internal(anyhow::anyhow!("no OAuth signing key configured")))?; + let signing_key = state.secrets.oauth_private_key.clone(); let client_id = state.config.oauth_client_id_for_login(); let redirect_uri = state.config.oauth_redirect_uri(); @@ -213,7 +208,7 @@ pub async fn callback( pds_url: Some(pds_endpoint), }; - let session_value = encode_session_cookie(&state.config.cookie_secret, &session) + let session_value = encode_session_cookie(&state.secrets.cookie_secret, &session) .map_err(|e| AppError::Internal(anyhow::anyhow!("session encode failed: {e}")))?; let identity_value = encode_identity_cookie(&identity) .map_err(|e| AppError::Internal(anyhow::anyhow!("identity encode failed: {e}")))?; diff --git a/src/oauth/login.rs b/src/oauth/login.rs index 3d3f5d5..95a965a 100644 --- a/src/oauth/login.rs +++ b/src/oauth/login.rs @@ -76,12 +76,7 @@ pub async fn login( let (authorization_server, resolved_subject) = resolve_login_hint(&state, login_hint).await?; - let signing_key = state - .config - .oauth_private_keys - .first() - .cloned() - .ok_or_else(|| AppError::Internal(anyhow::anyhow!("no OAuth signing key configured")))?; + let signing_key = state.secrets.oauth_private_key.clone(); let dpop_key = generate_key(KeyType::P256Private) .map_err(|e| AppError::Internal(anyhow::anyhow!("failed to generate DPoP key: {e}")))?; diff --git a/src/oauth/metadata.rs b/src/oauth/metadata.rs index 1320541..caaa953 100644 --- a/src/oauth/metadata.rs +++ b/src/oauth/metadata.rs @@ -4,6 +4,7 @@ //! `jwks.json` publishes the public half of its signing keys so PDSes can //! verify `private_key_jwt` client assertions. +use atproto_identity::key::to_public; use axum::extract::State; use axum::http::{header, HeaderValue}; use axum::response::{IntoResponse, Response}; @@ -35,11 +36,12 @@ pub async fn client_metadata(State(state): State) -> Response { /// `GET /jwks.json` -- public keys for client-assertion verification. pub async fn jwks(State(state): State) -> Response { - let keys: Vec<_> = state - .config - .oauth_public_keys() - .iter() - .filter_map(|k| atproto_oauth::jwk::generate(k).ok()) + // `jwk::generate` embeds key material verbatim, so never hand it the + // private key -- that would publish the private scalar (`d`). + let keys: Vec<_> = to_public(&state.secrets.oauth_private_key) + .ok() + .and_then(|key| atproto_oauth::jwk::generate(&key).ok()) + .into_iter() .collect(); json_cors(Json(json!({ "keys": keys }))) } diff --git a/src/oauth/refresh.rs b/src/oauth/refresh.rs index 2138237..2464650 100644 --- a/src/oauth/refresh.rs +++ b/src/oauth/refresh.rs @@ -21,7 +21,7 @@ pub async fn refresh( State(state): State, headers: HeaderMap, ) -> Result { - let session = get_session_from_headers(&state.config.cookie_secret, &headers) + let session = get_session_from_headers(&state.secrets.cookie_secret, &headers) .ok_or(AppError::Unauthorized)?; let (_session, set_cookie) = try_refresh_session(&state, session).await?; @@ -56,12 +56,7 @@ pub async fn try_refresh_session( return Ok((session, None)); }; - let signing_key = state - .config - .oauth_private_keys - .first() - .cloned() - .ok_or_else(|| AppError::Internal(anyhow::anyhow!("no OAuth signing key configured")))?; + let signing_key = state.secrets.oauth_private_key.clone(); let dpop_key = identify_key(&session.dpop_private_key) .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid stored DPoP key: {e}")))?; @@ -130,7 +125,7 @@ pub async fn try_refresh_session( dpop_private_key: session.dpop_private_key.clone(), }; - let encoded = encode_session_cookie(&state.config.cookie_secret, &new_session) + let encoded = encode_session_cookie(&state.secrets.cookie_secret, &new_session) .map_err(|e| AppError::Internal(anyhow::anyhow!("session encode failed: {e}")))?; let max_age = Duration::days(30).num_seconds(); diff --git a/src/secrets.rs b/src/secrets.rs new file mode 100644 index 0000000..0357ae9 --- /dev/null +++ b/src/secrets.rs @@ -0,0 +1,70 @@ +//! OAuth signing key and cookie secret, persisted as JSON next to the +//! database so no configuration is required. Generated on first run. + +use atproto_identity::key::{generate_key, identify_key, KeyData, KeyType}; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine as _; +use rand::RngCore as _; +use serde::{Deserialize, Serialize}; + +use crate::config::Config; + +/// Persistent secrets, loaded once at startup. +pub struct Secrets { + /// OAuth private signing key (P-256). + pub oauth_private_key: KeyData, + /// AES-256-GCM cookie secret. + pub cookie_secret: [u8; 32], +} + +/// On-disk form. `KeyData` isn't `Serialize`, so it round-trips through its +/// `did:key:...` string; the secret is base64 to keep the file readable. +#[derive(Serialize, Deserialize)] +struct SecretsFile { + oauth_private_key: String, + cookie_secret: String, +} + +impl Secrets { + /// Load secrets from the state directory, generating and persisting them + /// on first run. + pub fn load_or_generate() -> anyhow::Result { + let path = Config::state_dir().join("secrets.json"); + + if let Ok(contents) = std::fs::read_to_string(&path) { + let file: SecretsFile = serde_json::from_str(&contents)?; + return Ok(Self { + oauth_private_key: identify_key(&file.oauth_private_key)?, + cookie_secret: BASE64_STANDARD + .decode(&file.cookie_secret)? + .try_into() + .map_err(|_| anyhow::anyhow!("cookie_secret must be 32 bytes"))?, + }); + } + + let mut cookie_secret = [0u8; 32]; + rand::rng().fill_bytes(&mut cookie_secret); + let secrets = Self { + oauth_private_key: generate_key(KeyType::P256Private)?, + cookie_secret, + }; + secrets.save(&path)?; + Ok(secrets) + } + + fn save(&self, path: &std::path::Path) -> anyhow::Result<()> { + let file = SecretsFile { + oauth_private_key: self.oauth_private_key.to_string(), + cookie_secret: BASE64_STANDARD.encode(self.cookie_secret), + }; + std::fs::write(path, serde_json::to_string_pretty(&file)?)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + + Ok(()) + } +} diff --git a/src/state.rs b/src/state.rs index c8e1ba8..cb0f0ce 100644 --- a/src/state.rs +++ b/src/state.rs @@ -12,6 +12,7 @@ use sqlx::SqlitePool; use crate::config::Config; use crate::oauth::login::PersistedOAuthRequest; +use crate::secrets::Secrets; /// Shared, cheaply-cloneable application state passed to axum handlers. #[derive(Clone)] @@ -21,6 +22,8 @@ pub struct AppState(pub Arc); pub struct Inner { /// Application configuration. pub config: Config, + /// OAuth signing keys and the cookie secret. + pub secrets: Secrets, /// Shared HTTP client for calling out to PDSes/authorization servers. pub http_client: reqwest::Client, /// DID/handle resolver. @@ -37,6 +40,11 @@ pub struct Inner { impl AppState { /// Build application state from configuration. pub async fn new(config: Config) -> anyhow::Result { + // Holds both the secrets file and the database. + std::fs::create_dir_all(Config::state_dir())?; + + let secrets = Secrets::load_or_generate()?; + let http_client = reqwest::Client::new(); let dns_resolver = Arc::new(HickoryDnsResolver::create_resolver(&[])); let identity_resolver = SharedIdentityResolver(Arc::new(InnerIdentityResolver { @@ -46,12 +54,8 @@ impl AppState { })); // Create and migrate db if necessary. - let database_path = Config::database_path(); - if let Some(parent) = database_path.parent() { - std::fs::create_dir_all(parent)?; - } let connect_options = SqliteConnectOptions::new() - .filename(&database_path) + .filename(Config::database_path()) .create_if_missing(true); let db = SqlitePoolOptions::new() .connect_with(connect_options) @@ -60,6 +64,7 @@ impl AppState { Ok(AppState(Arc::new(Inner { config, + secrets, http_client, identity_resolver, oauth_requests: Mutex::new(HashMap::new()), -- 2.51.2