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_manifest::profile::{ProfileError, resolve_with_home};
43use systemprompt_manifest::secrets::Secrets;
44use systemprompt_models::errors::SecretsError;
45
46use super::key_material::KeyMaterialError;
47use super::manifest::{MANIFEST_SIGNING_SEED_BYTES, decode_seed, generate_seed, persist_seed};
48use super::master_key::decode_master_key;
49use super::profile::ProfileBootstrap;
50use crate::error::{ConfigError, ConfigResult};
51
52pub use io::load_secrets_from_path;
53pub use logging::build_loaded_secrets_message;
54pub use provider::{SecretsDocument, SecretsProvider};
55pub use resolve::{ResolvedSource, resolve_source};
56pub use vault::{VaultAttemptFailure, 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: {0}")]
81 InvalidSecretsFile(#[source] SecretsError),
82
83 #[error("Secrets document could not be encoded: {0}")]
84 DocumentEncode(#[source] serde_json::Error),
85
86 #[error("No secrets configured. Create a secrets.json file.")]
87 NoSecretsConfigured,
88
89 #[error("Invalid secrets configuration in profile: {0}")]
90 SecretsConfigInvalid(#[source] ProfileError),
91
92 #[error(
93 "secrets.source is 'vault' but the profile has no secrets.vault block. Add one or switch \
94 secrets.source to 'file' or 'env'."
95 )]
96 VaultBlockMissing,
97
98 #[error(transparent)]
99 Vault(#[from] VaultError),
100
101 #[error(
102 "OAuth at-rest pepper is required. Add 'oauth_at_rest_pepper' (>= 32 chars) to your \
103 secrets file or set OAUTH_AT_REST_PEPPER environment variable."
104 )]
105 OauthAtRestPepperRequired,
106
107 #[error(
108 "Database URL is required. Add 'database_url' to your secrets.json or set DATABASE_URL \
109 environment variable."
110 )]
111 DatabaseUrlRequired,
112
113 #[error(
114 "manifest_signing_secret_seed is required: every replica must share one seed, so it is \
115 never generated at boot. Run `systemprompt admin identity generate --json` once and \
116 distribute the value (secrets file or MANIFEST_SIGNING_SECRET_SEED)."
117 )]
118 ManifestSeedRequired,
119
120 #[error(
121 "signing_key_pem is required on cloud and deployment-host boots: every replica must \
122 sign with one key, so it is never read from a file beside the binary there. Run \
123 `systemprompt admin identity generate --json` once and distribute the value (secrets \
124 file or SIGNING_KEY_PEM)."
125 )]
126 SigningKeyPemRequired,
127
128 #[error("manifest_signing_secret_seed is invalid: {0}")]
129 ManifestSeedInvalid(#[source] KeyMaterialError),
130
131 #[error(
132 "encryption_master_key is required: it seals at-rest secrets and the gateway accounting \
133 journal. Add 64 hex characters (`openssl rand -hex 32`) as 'encryption_master_key' in \
134 secrets.json, or take the value from `systemprompt admin identity generate`. With \
135 `secrets.source: env`, also list it in SYSTEMPROMPT_CUSTOM_SECRETS."
136 )]
137 EncryptionMasterKeyRequired,
138
139 #[error(
140 "encryption_master_key is invalid ({0}): it must be 32 bytes as 64 hex characters \
141 (`openssl rand -hex 32`)"
142 )]
143 EncryptionMasterKeyInvalid(#[source] KeyMaterialError),
144
145 #[error("signing_key_pem secret is invalid: {0}")]
146 SigningKeyPemInvalid(#[source] KeyMaterialError),
147}
148
149impl SecretsBootstrap {
150 pub async fn init() -> ConfigResult<&'static Secrets> {
151 if SECRETS.get().is_some() {
152 return Err(SecretsBootstrapError::AlreadyInitialized.into());
153 }
154
155 let secrets = loader::load_from_profile_config().await?;
156 Self::validate_identity(&secrets)?;
157
158 Self::log_loaded_secrets(&secrets);
159
160 SECRETS
161 .set(secrets)
162 .map_err(|_e| SecretsBootstrapError::AlreadyInitialized)?;
163
164 SECRETS
165 .get()
166 .ok_or_else(|| SecretsBootstrapError::NotInitialized.into())
167 }
168
169 pub fn oauth_at_rest_pepper() -> Result<&'static str, SecretsBootstrapError> {
170 Ok(&Self::get()?.oauth_at_rest_pepper)
171 }
172
173 pub fn signing_key_pem() -> Result<Option<String>, SecretsBootstrapError> {
174 let Some(encoded) = Self::get()?.signing_key_pem.as_deref() else {
175 return Ok(None);
176 };
177 let bytes = base64::engine::general_purpose::STANDARD
178 .decode(encoded)
179 .map_err(|e| SecretsBootstrapError::SigningKeyPemInvalid(e.into()))?;
180 let pem = String::from_utf8(bytes)
181 .map_err(|e| SecretsBootstrapError::SigningKeyPemInvalid(e.into()))?;
182 Ok(Some(pem))
183 }
184
185 pub fn manifest_signing_secret_seed()
186 -> Result<[u8; MANIFEST_SIGNING_SEED_BYTES], SecretsBootstrapError> {
187 let encoded = Self::get()?
188 .manifest_signing_secret_seed
189 .as_deref()
190 .ok_or(SecretsBootstrapError::ManifestSeedRequired)?;
191 decode_seed(encoded)
192 }
193
194 pub fn rotate_manifest_signing_seed() -> ConfigResult<[u8; MANIFEST_SIGNING_SEED_BYTES]> {
195 let path = Self::resolved_secrets_file_path()?;
196 let seed = generate_seed();
197 persist_seed(&path, &seed)?;
198 Ok(seed)
199 }
200
201 fn validate_identity(secrets: &Secrets) -> ConfigResult<()> {
202 let encoded = secrets
203 .manifest_signing_secret_seed
204 .as_deref()
205 .ok_or(SecretsBootstrapError::ManifestSeedRequired)?;
206 decode_seed(encoded)?;
207
208 let master_key = secrets
209 .get("encryption_master_key")
210 .ok_or(SecretsBootstrapError::EncryptionMasterKeyRequired)?;
211 decode_master_key(master_key)?;
212
213 let is_deployment_host =
214 systemprompt_models::subprocess::is_deployment_host(|name| std::env::var(name).ok());
215 let is_cloud = ProfileBootstrap::get().is_ok_and(|profile| profile.target.is_cloud());
216 if (is_deployment_host || is_cloud) && secrets.signing_key_pem.is_none() {
217 return Err(SecretsBootstrapError::SigningKeyPemRequired.into());
218 }
219 Ok(())
220 }
221
222 fn resolved_secrets_file_path() -> ConfigResult<PathBuf> {
223 let profile =
224 ProfileBootstrap::get().map_err(|_e| SecretsBootstrapError::ProfileNotInitialized)?;
225 let secrets_config = profile
226 .secrets
227 .as_ref()
228 .ok_or(SecretsBootstrapError::NoSecretsConfigured)?;
229 let profile_path = ProfileBootstrap::get_path()
230 .map_err(|_e| SecretsBootstrapError::ProfileNotInitialized)?;
231 let profile_dir = Path::new(profile_path).parent().ok_or_else(|| {
232 ConfigError::ProfilePathWithoutParent {
233 path: PathBuf::from(profile_path),
234 }
235 })?;
236 let secrets_path = secrets_config
237 .secrets_path()
238 .map_err(SecretsBootstrapError::SecretsConfigInvalid)?;
239 Ok(resolve_with_home(profile_dir, secrets_path))
240 }
241
242 pub fn database_url() -> Result<&'static str, SecretsBootstrapError> {
243 Ok(&Self::get()?.database_url)
244 }
245
246 pub fn database_write_url() -> Result<Option<&'static str>, SecretsBootstrapError> {
247 Ok(Self::get()?.database_write_url.as_deref())
248 }
249
250 pub fn get() -> Result<&'static Secrets, SecretsBootstrapError> {
251 SECRETS.get().ok_or(SecretsBootstrapError::NotInitialized)
252 }
253
254 #[must_use]
255 pub fn is_initialized() -> bool {
256 SECRETS.get().is_some()
257 }
258
259 pub async fn try_init() -> ConfigResult<&'static Secrets> {
260 if SECRETS.get().is_some() {
261 return Self::get().map_err(Into::into);
262 }
263 Self::init().await
264 }
265
266 fn log_loaded_secrets(secrets: &Secrets) {
267 let summary = build_loaded_secrets_message(secrets);
268 tracing::debug!(summary = %summary, "Secrets loaded");
269 }
270}