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, validates
4//! required fields, and exposes typed accessors for the manifest signing seed
5//! and database URLs.
6//!
7//! Source precedence, highest first:
8//!
9//! | # | Condition | Source |
10//! |---|-----------|--------|
11//! | 1 | `SYSTEMPROMPT_SUBPROCESS` set and a valid pepper is in the environment | environment |
12//! | 2 | `secrets.source: vault` | Vault KV v2, on deployment hosts too |
13//! | 3 | deployment host, with a valid pepper or `secrets.source: env` | environment |
14//! | 4 | `secrets.source: env` running locally | file, then environment |
15//! | 5 | `secrets.source: file` | file |
16//!
17//! Vault is fail-closed under every
18//! [`systemprompt_models::profile::SecretsValidationMode`]: a failed fetch
19//! aborts the boot instead of falling back to the environment,
20//! because a fallback would start the process on whatever stale credentials the
21//! host happens to carry.
22//!
23//! A Vault token is never written into profile YAML — `${VAULT_TOKEN}`
24//! interpolation in `profile.yaml` is forbidden. The token comes from the
25//! process environment, a file, or an `AppRole` / Kubernetes login.
26//!
27//! Copyright (c) systemprompt.io — Business Source License 1.1.
28//! See <https://systemprompt.io> for licensing details.
29
30mod io;
31mod loader;
32mod logging;
33mod provider;
34mod resolve;
35mod sources;
36mod vault;
37
38use std::path::{Path, PathBuf};
39use std::sync::OnceLock;
40
41use base64::Engine;
42use systemprompt_models::profile::resolve_with_home;
43use systemprompt_models::secrets::Secrets;
44
45use super::manifest::{MANIFEST_SIGNING_SEED_BYTES, decode_seed, generate_seed, persist_seed};
46use super::master_key::decode_master_key;
47use super::profile::ProfileBootstrap;
48use crate::error::{ConfigError, ConfigResult};
49
50pub use io::load_secrets_from_path;
51pub use logging::{
52    build_loaded_secrets_message, log_secrets_issue, log_secrets_skip, log_secrets_warn,
53};
54pub use provider::{SecretsDocument, SecretsProvider};
55pub use resolve::{ResolvedSource, resolve_source};
56pub use vault::{VaultError, VaultKvProvider};
57
58static SECRETS: OnceLock<Secrets> = OnceLock::new();
59
60#[derive(Debug, Clone, Copy)]
61pub struct SecretsBootstrap;
62
63#[derive(Debug, thiserror::Error)]
64#[non_exhaustive]
65pub enum SecretsBootstrapError {
66    #[error(
67        "Secrets not initialized. Call SecretsBootstrap::init() after ProfileBootstrap::init()"
68    )]
69    NotInitialized,
70
71    #[error("Secrets already initialized")]
72    AlreadyInitialized,
73
74    #[error("Profile not initialized. Call ProfileBootstrap::init() first")]
75    ProfileNotInitialized,
76
77    #[error("Secrets file not found: {path}")]
78    FileNotFound { path: String },
79
80    #[error("Invalid secrets file: {message}")]
81    InvalidSecretsFile { message: String },
82
83    #[error("No secrets configured. Create a secrets.json file.")]
84    NoSecretsConfigured,
85
86    #[error("Invalid secrets configuration in profile: {message}")]
87    SecretsConfigInvalid { message: String },
88
89    #[error(
90        "secrets.source is 'vault' but the profile has no secrets.vault block. Add one or switch \
91         secrets.source to 'file' or 'env'."
92    )]
93    VaultBlockMissing,
94
95    #[error(transparent)]
96    Vault(#[from] VaultError),
97
98    #[error(
99        "OAuth at-rest pepper is required. Add 'oauth_at_rest_pepper' (>= 32 chars) to your \
100         secrets file or set OAUTH_AT_REST_PEPPER environment variable."
101    )]
102    OauthAtRestPepperRequired,
103
104    #[error(
105        "Database URL is required. Add 'database_url' to your secrets.json or set DATABASE_URL \
106         environment variable."
107    )]
108    DatabaseUrlRequired,
109
110    #[error(
111        "manifest_signing_secret_seed is required: every replica must share one seed, so it is \
112         never generated at boot. Run `systemprompt admin identity generate --json` once and \
113         distribute the value (secrets file or MANIFEST_SIGNING_SECRET_SEED)."
114    )]
115    ManifestSeedRequired,
116
117    #[error(
118        "signing_key_pem is required on cloud and deployment-host boots: every replica must \
119         sign with one key, so it is never read from a file beside the binary there. Run \
120         `systemprompt admin identity generate --json` once and distribute the value (secrets \
121         file or SIGNING_KEY_PEM)."
122    )]
123    SigningKeyPemRequired,
124
125    #[error("manifest_signing_secret_seed is invalid: {message}")]
126    ManifestSeedInvalid { message: String },
127
128    #[error(
129        "encryption_master_key is required: it seals at-rest secrets and the gateway accounting \
130         journal. Add 64 hex characters (`openssl rand -hex 32`) as 'encryption_master_key' in \
131         secrets.json, or take the value from `systemprompt admin identity generate`. With \
132         `secrets.source: env`, also list it in SYSTEMPROMPT_CUSTOM_SECRETS."
133    )]
134    EncryptionMasterKeyRequired,
135
136    #[error(
137        "encryption_master_key is invalid ({message}): it must be 32 bytes as 64 hex characters \
138         (`openssl rand -hex 32`)"
139    )]
140    EncryptionMasterKeyInvalid { message: String },
141
142    #[error("signing_key_pem secret is invalid: {message}")]
143    SigningKeyPemInvalid { message: String },
144}
145
146impl SecretsBootstrap {
147    pub async fn init() -> ConfigResult<&'static Secrets> {
148        if SECRETS.get().is_some() {
149            return Err(SecretsBootstrapError::AlreadyInitialized.into());
150        }
151
152        let secrets = loader::load_from_profile_config().await?;
153        Self::validate_identity(&secrets)?;
154
155        Self::log_loaded_secrets(&secrets);
156
157        SECRETS
158            .set(secrets)
159            .map_err(|_e| SecretsBootstrapError::AlreadyInitialized)?;
160
161        SECRETS
162            .get()
163            .ok_or_else(|| SecretsBootstrapError::NotInitialized.into())
164    }
165
166    pub fn oauth_at_rest_pepper() -> Result<&'static str, SecretsBootstrapError> {
167        Ok(&Self::get()?.oauth_at_rest_pepper)
168    }
169
170    pub fn signing_key_pem() -> Result<Option<String>, SecretsBootstrapError> {
171        let Some(encoded) = Self::get()?.signing_key_pem.as_deref() else {
172            return Ok(None);
173        };
174        let bytes = base64::engine::general_purpose::STANDARD
175            .decode(encoded)
176            .map_err(|e| SecretsBootstrapError::SigningKeyPemInvalid {
177                message: e.to_string(),
178            })?;
179        let pem =
180            String::from_utf8(bytes).map_err(|e| SecretsBootstrapError::SigningKeyPemInvalid {
181                message: e.to_string(),
182            })?;
183        Ok(Some(pem))
184    }
185
186    pub fn manifest_signing_secret_seed()
187    -> Result<[u8; MANIFEST_SIGNING_SEED_BYTES], SecretsBootstrapError> {
188        let encoded = Self::get()?
189            .manifest_signing_secret_seed
190            .as_deref()
191            .ok_or(SecretsBootstrapError::ManifestSeedRequired)?;
192        decode_seed(encoded)
193    }
194
195    pub fn rotate_manifest_signing_seed() -> ConfigResult<[u8; MANIFEST_SIGNING_SEED_BYTES]> {
196        let path = Self::resolved_secrets_file_path()?;
197        let seed = generate_seed();
198        persist_seed(&path, &seed)?;
199        Ok(seed)
200    }
201
202    fn validate_identity(secrets: &Secrets) -> ConfigResult<()> {
203        let encoded = secrets
204            .manifest_signing_secret_seed
205            .as_deref()
206            .ok_or(SecretsBootstrapError::ManifestSeedRequired)?;
207        decode_seed(encoded)?;
208
209        let master_key = secrets
210            .get("encryption_master_key")
211            .ok_or(SecretsBootstrapError::EncryptionMasterKeyRequired)?;
212        decode_master_key(master_key)?;
213
214        let is_deployment_host =
215            systemprompt_models::subprocess::is_deployment_host(|name| std::env::var(name).ok());
216        let is_cloud = ProfileBootstrap::get().is_ok_and(|profile| profile.target.is_cloud());
217        if (is_deployment_host || is_cloud) && secrets.signing_key_pem.is_none() {
218            return Err(SecretsBootstrapError::SigningKeyPemRequired.into());
219        }
220        Ok(())
221    }
222
223    fn resolved_secrets_file_path() -> ConfigResult<PathBuf> {
224        let profile =
225            ProfileBootstrap::get().map_err(|_e| SecretsBootstrapError::ProfileNotInitialized)?;
226        let secrets_config = profile
227            .secrets
228            .as_ref()
229            .ok_or(SecretsBootstrapError::NoSecretsConfigured)?;
230        let profile_path = ProfileBootstrap::get_path()
231            .map_err(|_e| SecretsBootstrapError::ProfileNotInitialized)?;
232        let profile_dir = Path::new(profile_path)
233            .parent()
234            .ok_or_else(|| ConfigError::other("Invalid profile path - no parent directory"))?;
235        let secrets_path = secrets_config.secrets_path().map_err(|e| {
236            SecretsBootstrapError::SecretsConfigInvalid {
237                message: e.to_string(),
238            }
239        })?;
240        Ok(resolve_with_home(profile_dir, secrets_path))
241    }
242
243    pub fn database_url() -> Result<&'static str, SecretsBootstrapError> {
244        Ok(&Self::get()?.database_url)
245    }
246
247    pub fn database_write_url() -> Result<Option<&'static str>, SecretsBootstrapError> {
248        Ok(Self::get()?.database_write_url.as_deref())
249    }
250
251    pub fn get() -> Result<&'static Secrets, SecretsBootstrapError> {
252        SECRETS.get().ok_or(SecretsBootstrapError::NotInitialized)
253    }
254
255    #[must_use]
256    pub fn is_initialized() -> bool {
257        SECRETS.get().is_some()
258    }
259
260    pub async fn try_init() -> ConfigResult<&'static Secrets> {
261        if SECRETS.get().is_some() {
262            return Self::get().map_err(Into::into);
263        }
264        Self::init().await
265    }
266
267    fn log_loaded_secrets(secrets: &Secrets) {
268        let message = build_loaded_secrets_message(secrets);
269        tracing::debug!("{message}");
270    }
271}