Skip to main content

systemprompt_config/bootstrap/secrets/
mod.rs

1//! Process-wide secrets bootstrap.
2//!
3//! Loads the secrets document referenced by the active profile (or
4//! the equivalent environment variables in subprocess/Fly.io modes),
5//! validates required fields, and exposes typed accessors for the
6//! manifest signing seed and database URLs.
7
8mod io;
9mod loader;
10mod logging;
11
12use std::path::{Path, PathBuf};
13use std::sync::OnceLock;
14
15use base64::Engine;
16use systemprompt_models::profile::resolve_with_home;
17use systemprompt_models::secrets::Secrets;
18
19use super::manifest::{
20    MANIFEST_SIGNING_SEED_BYTES, decode_seed, dir_is_writable, generate_seed, persist_seed,
21};
22use super::profile::ProfileBootstrap;
23use crate::error::{ConfigError, ConfigResult};
24
25pub use io::load_secrets_from_path;
26pub use logging::{
27    build_loaded_secrets_message, log_secrets_issue, log_secrets_skip, log_secrets_warn,
28};
29
30static SECRETS: OnceLock<Secrets> = OnceLock::new();
31
32#[derive(Debug, Clone, Copy)]
33pub struct SecretsBootstrap;
34
35#[derive(Debug, thiserror::Error)]
36#[non_exhaustive]
37pub enum SecretsBootstrapError {
38    #[error(
39        "Secrets not initialized. Call SecretsBootstrap::init() after ProfileBootstrap::init()"
40    )]
41    NotInitialized,
42
43    #[error("Secrets already initialized")]
44    AlreadyInitialized,
45
46    #[error("Profile not initialized. Call ProfileBootstrap::init() first")]
47    ProfileNotInitialized,
48
49    #[error("Secrets file not found: {path}")]
50    FileNotFound { path: String },
51
52    #[error("Invalid secrets file: {message}")]
53    InvalidSecretsFile { message: String },
54
55    #[error("No secrets configured. Create a secrets.json file.")]
56    NoSecretsConfigured,
57
58    #[error(
59        "OAuth at-rest pepper is required. Add 'oauth_at_rest_pepper' (>= 32 chars) to your \
60         secrets file or set OAUTH_AT_REST_PEPPER environment variable."
61    )]
62    OauthAtRestPepperRequired,
63
64    #[error(
65        "Database URL is required. Add 'database_url' to your secrets.json or set DATABASE_URL \
66         environment variable."
67    )]
68    DatabaseUrlRequired,
69
70    #[error(
71        "manifest_signing_secret_seed is missing from the secrets file and the bootstrap path is \
72         not writable. Run `systemprompt admin bridge rotate-signing-key` against a writable \
73         secrets file, or add a base64-encoded 32-byte value under `manifest_signing_secret_seed`."
74    )]
75    ManifestSeedUnavailable,
76
77    #[error("manifest_signing_secret_seed is invalid: {message}")]
78    ManifestSeedInvalid { message: String },
79
80    #[error("signing_key_pem secret is invalid: {message}")]
81    SigningKeyPemInvalid { message: String },
82
83    #[error(
84        "manifest_signing_secret_seed missing in subprocess env — parent must propagate \
85         MANIFEST_SIGNING_SECRET_SEED so subprocesses don't regenerate and clobber the secrets \
86         file"
87    )]
88    SubprocessSeedMissing,
89}
90
91impl SecretsBootstrap {
92    pub fn init() -> ConfigResult<&'static Secrets> {
93        if SECRETS.get().is_some() {
94            return Err(SecretsBootstrapError::AlreadyInitialized.into());
95        }
96
97        let mut secrets = loader::load_from_profile_config()?;
98        Self::ensure_manifest_signing_seed(&mut secrets)?;
99
100        Self::log_loaded_secrets(&secrets);
101
102        SECRETS
103            .set(secrets)
104            .map_err(|_e| SecretsBootstrapError::AlreadyInitialized)?;
105
106        SECRETS
107            .get()
108            .ok_or_else(|| SecretsBootstrapError::NotInitialized.into())
109    }
110
111    pub fn oauth_at_rest_pepper() -> Result<&'static str, SecretsBootstrapError> {
112        Ok(&Self::get()?.oauth_at_rest_pepper)
113    }
114
115    pub fn signing_key_pem() -> Result<Option<String>, SecretsBootstrapError> {
116        let Some(encoded) = Self::get()?.signing_key_pem.as_deref() else {
117            return Ok(None);
118        };
119        let bytes = base64::engine::general_purpose::STANDARD
120            .decode(encoded)
121            .map_err(|e| SecretsBootstrapError::SigningKeyPemInvalid {
122                message: e.to_string(),
123            })?;
124        let pem =
125            String::from_utf8(bytes).map_err(|e| SecretsBootstrapError::SigningKeyPemInvalid {
126                message: e.to_string(),
127            })?;
128        Ok(Some(pem))
129    }
130
131    pub fn manifest_signing_secret_seed()
132    -> Result<[u8; MANIFEST_SIGNING_SEED_BYTES], SecretsBootstrapError> {
133        let encoded = Self::get()?
134            .manifest_signing_secret_seed
135            .as_deref()
136            .ok_or(SecretsBootstrapError::ManifestSeedUnavailable)?;
137        decode_seed(encoded)
138    }
139
140    pub fn rotate_manifest_signing_seed() -> ConfigResult<[u8; MANIFEST_SIGNING_SEED_BYTES]> {
141        let path = Self::resolved_secrets_file_path()?;
142        let seed = generate_seed();
143        persist_seed(&path, &seed)?;
144        Ok(seed)
145    }
146
147    fn ensure_manifest_signing_seed(secrets: &mut Secrets) -> ConfigResult<()> {
148        if let Some(encoded) = secrets.manifest_signing_secret_seed.as_deref() {
149            decode_seed(encoded)?;
150            return Ok(());
151        }
152        if std::env::var("SYSTEMPROMPT_SUBPROCESS").is_ok() {
153            return Err(SecretsBootstrapError::SubprocessSeedMissing.into());
154        }
155        let Ok(path) = Self::resolved_secrets_file_path() else {
156            tracing::warn!(
157                "manifest_signing_secret_seed missing and no writable secrets file is configured"
158            );
159            return Ok(());
160        };
161        if !path.exists() {
162            tracing::warn!(
163                path = %path.display(),
164                "manifest_signing_secret_seed missing and secrets file does not exist on disk"
165            );
166            return Ok(());
167        }
168        let seed = generate_seed();
169        secrets.manifest_signing_secret_seed =
170            Some(base64::engine::general_purpose::STANDARD.encode(seed));
171
172        // The profile directory may be mounted read-only (e.g. an air-gapped
173        // deployment with a `:ro` profile mount). The seed is only needed for
174        // manifest signing within this process, so a failed persist is a
175        // warning, not a fatal error: the in-memory seed above keeps signing
176        // working for this boot. Operators wanting a stable seed across boots
177        // should set `MANIFEST_SIGNING_SECRET_SEED` or use a writable dir.
178        let profile_dir = path.parent().unwrap_or_else(|| Path::new("."));
179        if !dir_is_writable(profile_dir) {
180            tracing::warn!(
181                path = %path.display(),
182                "profile dir is read-only — using an ephemeral manifest signing seed for this \
183                 boot; set MANIFEST_SIGNING_SECRET_SEED or use a writable dir to persist it"
184            );
185            return Ok(());
186        }
187        if let Err(err) = persist_seed(&path, &seed) {
188            tracing::warn!(
189                path = %path.display(),
190                error = %err,
191                "could not persist manifest_signing_secret_seed — using an ephemeral seed for \
192                 this boot; set MANIFEST_SIGNING_SECRET_SEED to make it stable"
193            );
194            return Ok(());
195        }
196        tracing::info!(
197            path = %path.display(),
198            "Generated and persisted fresh manifest_signing_secret_seed"
199        );
200        Ok(())
201    }
202
203    fn resolved_secrets_file_path() -> ConfigResult<PathBuf> {
204        let profile =
205            ProfileBootstrap::get().map_err(|_e| SecretsBootstrapError::ProfileNotInitialized)?;
206        let secrets_config = profile
207            .secrets
208            .as_ref()
209            .ok_or(SecretsBootstrapError::NoSecretsConfigured)?;
210        let profile_path = ProfileBootstrap::get_path()
211            .map_err(|_e| SecretsBootstrapError::ProfileNotInitialized)?;
212        let profile_dir = Path::new(profile_path)
213            .parent()
214            .ok_or_else(|| ConfigError::other("Invalid profile path - no parent directory"))?;
215        Ok(resolve_with_home(profile_dir, &secrets_config.secrets_path))
216    }
217
218    pub fn database_url() -> Result<&'static str, SecretsBootstrapError> {
219        Ok(&Self::get()?.database_url)
220    }
221
222    pub fn database_write_url() -> Result<Option<&'static str>, SecretsBootstrapError> {
223        Ok(Self::get()?.database_write_url.as_deref())
224    }
225
226    pub fn get() -> Result<&'static Secrets, SecretsBootstrapError> {
227        SECRETS.get().ok_or(SecretsBootstrapError::NotInitialized)
228    }
229
230    pub fn require() -> Result<&'static Secrets, SecretsBootstrapError> {
231        Self::get()
232    }
233
234    #[must_use]
235    pub fn is_initialized() -> bool {
236        SECRETS.get().is_some()
237    }
238
239    pub fn try_init() -> ConfigResult<&'static Secrets> {
240        if SECRETS.get().is_some() {
241            return Self::get().map_err(Into::into);
242        }
243        Self::init()
244    }
245
246    fn log_loaded_secrets(secrets: &Secrets) {
247        let message = build_loaded_secrets_message(secrets);
248        tracing::debug!("{message}");
249    }
250}