systemprompt_config/bootstrap/secrets/
mod.rs1mod 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}