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