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    fn validate_identity(secrets: &Secrets) -> ConfigResult<()> {
150        let encoded = secrets
151            .manifest_signing_secret_seed
152            .as_deref()
153            .ok_or(SecretsBootstrapError::ManifestSeedRequired)?;
154        decode_seed(encoded)?;
155
156        let is_deployment_host =
157            systemprompt_models::subprocess::is_deployment_host(|name| std::env::var(name).ok());
158        let is_cloud = ProfileBootstrap::get().is_ok_and(|profile| profile.target.is_cloud());
159        if (is_deployment_host || is_cloud) && secrets.signing_key_pem.is_none() {
160            return Err(SecretsBootstrapError::SigningKeyPemRequired.into());
161        }
162        Ok(())
163    }
164
165    fn resolved_secrets_file_path() -> ConfigResult<PathBuf> {
166        let profile =
167            ProfileBootstrap::get().map_err(|_e| SecretsBootstrapError::ProfileNotInitialized)?;
168        let secrets_config = profile
169            .secrets
170            .as_ref()
171            .ok_or(SecretsBootstrapError::NoSecretsConfigured)?;
172        let profile_path = ProfileBootstrap::get_path()
173            .map_err(|_e| SecretsBootstrapError::ProfileNotInitialized)?;
174        let profile_dir = Path::new(profile_path)
175            .parent()
176            .ok_or_else(|| ConfigError::other("Invalid profile path - no parent directory"))?;
177        Ok(resolve_with_home(profile_dir, &secrets_config.secrets_path))
178    }
179
180    pub fn database_url() -> Result<&'static str, SecretsBootstrapError> {
181        Ok(&Self::get()?.database_url)
182    }
183
184    pub fn database_write_url() -> Result<Option<&'static str>, SecretsBootstrapError> {
185        Ok(Self::get()?.database_write_url.as_deref())
186    }
187
188    pub fn get() -> Result<&'static Secrets, SecretsBootstrapError> {
189        SECRETS.get().ok_or(SecretsBootstrapError::NotInitialized)
190    }
191
192    pub fn require() -> Result<&'static Secrets, SecretsBootstrapError> {
193        Self::get()
194    }
195
196    #[must_use]
197    pub fn is_initialized() -> bool {
198        SECRETS.get().is_some()
199    }
200
201    pub fn try_init() -> ConfigResult<&'static Secrets> {
202        if SECRETS.get().is_some() {
203            return Self::get().map_err(Into::into);
204        }
205        Self::init()
206    }
207
208    fn log_loaded_secrets(secrets: &Secrets) {
209        let message = build_loaded_secrets_message(secrets);
210        tracing::debug!("{message}");
211    }
212}