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::{MANIFEST_SIGNING_SEED_BYTES, decode_seed, generate_seed, persist_seed};
23use super::profile::ProfileBootstrap;
24use crate::error::{ConfigError, ConfigResult};
25
26pub use io::load_secrets_from_path;
27pub use logging::{
28    build_loaded_secrets_message, log_secrets_issue, log_secrets_skip, log_secrets_warn,
29};
30
31static SECRETS: OnceLock<Secrets> = OnceLock::new();
32
33#[derive(Debug, Clone, Copy)]
34pub struct SecretsBootstrap;
35
36#[derive(Debug, thiserror::Error)]
37#[non_exhaustive]
38pub enum SecretsBootstrapError {
39    #[error(
40        "Secrets not initialized. Call SecretsBootstrap::init() after ProfileBootstrap::init()"
41    )]
42    NotInitialized,
43
44    #[error("Secrets already initialized")]
45    AlreadyInitialized,
46
47    #[error("Profile not initialized. Call ProfileBootstrap::init() first")]
48    ProfileNotInitialized,
49
50    #[error("Secrets file not found: {path}")]
51    FileNotFound { path: String },
52
53    #[error("Invalid secrets file: {message}")]
54    InvalidSecretsFile { message: String },
55
56    #[error("No secrets configured. Create a secrets.json file.")]
57    NoSecretsConfigured,
58
59    #[error(
60        "OAuth at-rest pepper is required. Add 'oauth_at_rest_pepper' (>= 32 chars) to your \
61         secrets file or set OAUTH_AT_REST_PEPPER environment variable."
62    )]
63    OauthAtRestPepperRequired,
64
65    #[error(
66        "Database URL is required. Add 'database_url' to your secrets.json or set DATABASE_URL \
67         environment variable."
68    )]
69    DatabaseUrlRequired,
70
71    #[error(
72        "manifest_signing_secret_seed is required: every replica must share one seed, so it is \
73         never generated at boot. Run `systemprompt admin identity generate --json` once and \
74         distribute the value (secrets file or MANIFEST_SIGNING_SECRET_SEED)."
75    )]
76    ManifestSeedRequired,
77
78    #[error(
79        "signing_key_pem is required on cloud and deployment-host boots: every replica must \
80         sign with one key, so it is never read from a file beside the binary there. Run \
81         `systemprompt admin identity generate --json` once and distribute the value (secrets \
82         file or SIGNING_KEY_PEM)."
83    )]
84    SigningKeyPemRequired,
85
86    #[error("manifest_signing_secret_seed is invalid: {message}")]
87    ManifestSeedInvalid { message: String },
88
89    #[error("signing_key_pem secret is invalid: {message}")]
90    SigningKeyPemInvalid { message: String },
91}
92
93impl SecretsBootstrap {
94    pub fn init() -> ConfigResult<&'static Secrets> {
95        if SECRETS.get().is_some() {
96            return Err(SecretsBootstrapError::AlreadyInitialized.into());
97        }
98
99        let secrets = loader::load_from_profile_config()?;
100        Self::validate_identity(&secrets)?;
101
102        Self::log_loaded_secrets(&secrets);
103
104        SECRETS
105            .set(secrets)
106            .map_err(|_e| SecretsBootstrapError::AlreadyInitialized)?;
107
108        SECRETS
109            .get()
110            .ok_or_else(|| SecretsBootstrapError::NotInitialized.into())
111    }
112
113    pub fn oauth_at_rest_pepper() -> Result<&'static str, SecretsBootstrapError> {
114        Ok(&Self::get()?.oauth_at_rest_pepper)
115    }
116
117    pub fn signing_key_pem() -> Result<Option<String>, SecretsBootstrapError> {
118        let Some(encoded) = Self::get()?.signing_key_pem.as_deref() else {
119            return Ok(None);
120        };
121        let bytes = base64::engine::general_purpose::STANDARD
122            .decode(encoded)
123            .map_err(|e| SecretsBootstrapError::SigningKeyPemInvalid {
124                message: e.to_string(),
125            })?;
126        let pem =
127            String::from_utf8(bytes).map_err(|e| SecretsBootstrapError::SigningKeyPemInvalid {
128                message: e.to_string(),
129            })?;
130        Ok(Some(pem))
131    }
132
133    pub fn manifest_signing_secret_seed()
134    -> Result<[u8; MANIFEST_SIGNING_SEED_BYTES], SecretsBootstrapError> {
135        let encoded = Self::get()?
136            .manifest_signing_secret_seed
137            .as_deref()
138            .ok_or(SecretsBootstrapError::ManifestSeedRequired)?;
139        decode_seed(encoded)
140    }
141
142    pub fn rotate_manifest_signing_seed() -> ConfigResult<[u8; MANIFEST_SIGNING_SEED_BYTES]> {
143        let path = Self::resolved_secrets_file_path()?;
144        let seed = generate_seed();
145        persist_seed(&path, &seed)?;
146        Ok(seed)
147    }
148
149    // Why: identity secrets are inputs. A seed minted at boot gives every
150    // replica its own manifest identity, and a key read from a file beside the
151    // binary is regenerated per container; both were found by boot failures on
152    // a second node. Local profiles may still keep the RSA key at
153    // `signing_key_path`, which is why the PEM is only demanded where a file
154    // beside the binary cannot be shared.
155    fn validate_identity(secrets: &Secrets) -> ConfigResult<()> {
156        let encoded = secrets
157            .manifest_signing_secret_seed
158            .as_deref()
159            .ok_or(SecretsBootstrapError::ManifestSeedRequired)?;
160        decode_seed(encoded)?;
161
162        let is_deployment_host =
163            systemprompt_models::subprocess::is_deployment_host(|name| std::env::var(name).ok());
164        let is_cloud = ProfileBootstrap::get().is_ok_and(|profile| profile.target.is_cloud());
165        if (is_deployment_host || is_cloud) && secrets.signing_key_pem.is_none() {
166            return Err(SecretsBootstrapError::SigningKeyPemRequired.into());
167        }
168        Ok(())
169    }
170
171    fn resolved_secrets_file_path() -> ConfigResult<PathBuf> {
172        let profile =
173            ProfileBootstrap::get().map_err(|_e| SecretsBootstrapError::ProfileNotInitialized)?;
174        let secrets_config = profile
175            .secrets
176            .as_ref()
177            .ok_or(SecretsBootstrapError::NoSecretsConfigured)?;
178        let profile_path = ProfileBootstrap::get_path()
179            .map_err(|_e| SecretsBootstrapError::ProfileNotInitialized)?;
180        let profile_dir = Path::new(profile_path)
181            .parent()
182            .ok_or_else(|| ConfigError::other("Invalid profile path - no parent directory"))?;
183        Ok(resolve_with_home(profile_dir, &secrets_config.secrets_path))
184    }
185
186    pub fn database_url() -> Result<&'static str, SecretsBootstrapError> {
187        Ok(&Self::get()?.database_url)
188    }
189
190    pub fn database_write_url() -> Result<Option<&'static str>, SecretsBootstrapError> {
191        Ok(Self::get()?.database_write_url.as_deref())
192    }
193
194    pub fn get() -> Result<&'static Secrets, SecretsBootstrapError> {
195        SECRETS.get().ok_or(SecretsBootstrapError::NotInitialized)
196    }
197
198    pub fn require() -> Result<&'static Secrets, SecretsBootstrapError> {
199        Self::get()
200    }
201
202    #[must_use]
203    pub fn is_initialized() -> bool {
204        SECRETS.get().is_some()
205    }
206
207    pub fn try_init() -> ConfigResult<&'static Secrets> {
208        if SECRETS.get().is_some() {
209            return Self::get().map_err(Into::into);
210        }
211        Self::init()
212    }
213
214    fn log_loaded_secrets(secrets: &Secrets) {
215        let message = build_loaded_secrets_message(secrets);
216        tracing::debug!("{message}");
217    }
218}