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::profile::ProfileBootstrap;
47use crate::error::{ConfigError, ConfigResult};
48
49pub use io::load_secrets_from_path;
50pub use logging::{
51 build_loaded_secrets_message, log_secrets_issue, log_secrets_skip, log_secrets_warn,
52};
53pub use provider::{SecretsDocument, SecretsProvider};
54pub use resolve::{ResolvedSource, resolve_source};
55pub use vault::{VaultError, VaultKvProvider};
56
57static SECRETS: OnceLock<Secrets> = OnceLock::new();
58
59#[derive(Debug, Clone, Copy)]
60pub struct SecretsBootstrap;
61
62#[derive(Debug, thiserror::Error)]
63#[non_exhaustive]
64pub enum SecretsBootstrapError {
65 #[error(
66 "Secrets not initialized. Call SecretsBootstrap::init() after ProfileBootstrap::init()"
67 )]
68 NotInitialized,
69
70 #[error("Secrets already initialized")]
71 AlreadyInitialized,
72
73 #[error("Profile not initialized. Call ProfileBootstrap::init() first")]
74 ProfileNotInitialized,
75
76 #[error("Secrets file not found: {path}")]
77 FileNotFound { path: String },
78
79 #[error("Invalid secrets file: {message}")]
80 InvalidSecretsFile { message: String },
81
82 #[error("No secrets configured. Create a secrets.json file.")]
83 NoSecretsConfigured,
84
85 #[error("Invalid secrets configuration in profile: {message}")]
86 SecretsConfigInvalid { message: String },
87
88 #[error(
89 "secrets.source is 'vault' but the profile has no secrets.vault block. Add one or switch \
90 secrets.source to 'file' or 'env'."
91 )]
92 VaultBlockMissing,
93
94 #[error(transparent)]
95 Vault(#[from] VaultError),
96
97 #[error(
98 "OAuth at-rest pepper is required. Add 'oauth_at_rest_pepper' (>= 32 chars) to your \
99 secrets file or set OAUTH_AT_REST_PEPPER environment variable."
100 )]
101 OauthAtRestPepperRequired,
102
103 #[error(
104 "Database URL is required. Add 'database_url' to your secrets.json or set DATABASE_URL \
105 environment variable."
106 )]
107 DatabaseUrlRequired,
108
109 #[error(
110 "manifest_signing_secret_seed is required: every replica must share one seed, so it is \
111 never generated at boot. Run `systemprompt admin identity generate --json` once and \
112 distribute the value (secrets file or MANIFEST_SIGNING_SECRET_SEED)."
113 )]
114 ManifestSeedRequired,
115
116 #[error(
117 "signing_key_pem is required on cloud and deployment-host boots: every replica must \
118 sign with one key, so it is never read from a file beside the binary there. Run \
119 `systemprompt admin identity generate --json` once and distribute the value (secrets \
120 file or SIGNING_KEY_PEM)."
121 )]
122 SigningKeyPemRequired,
123
124 #[error("manifest_signing_secret_seed is invalid: {message}")]
125 ManifestSeedInvalid { message: String },
126
127 #[error("signing_key_pem secret is invalid: {message}")]
128 SigningKeyPemInvalid { message: String },
129}
130
131impl SecretsBootstrap {
132 pub async fn init() -> ConfigResult<&'static Secrets> {
133 if SECRETS.get().is_some() {
134 return Err(SecretsBootstrapError::AlreadyInitialized.into());
135 }
136
137 let secrets = loader::load_from_profile_config().await?;
138 Self::validate_identity(&secrets)?;
139
140 Self::log_loaded_secrets(&secrets);
141
142 SECRETS
143 .set(secrets)
144 .map_err(|_e| SecretsBootstrapError::AlreadyInitialized)?;
145
146 SECRETS
147 .get()
148 .ok_or_else(|| SecretsBootstrapError::NotInitialized.into())
149 }
150
151 pub fn oauth_at_rest_pepper() -> Result<&'static str, SecretsBootstrapError> {
152 Ok(&Self::get()?.oauth_at_rest_pepper)
153 }
154
155 pub fn signing_key_pem() -> Result<Option<String>, SecretsBootstrapError> {
156 let Some(encoded) = Self::get()?.signing_key_pem.as_deref() else {
157 return Ok(None);
158 };
159 let bytes = base64::engine::general_purpose::STANDARD
160 .decode(encoded)
161 .map_err(|e| SecretsBootstrapError::SigningKeyPemInvalid {
162 message: e.to_string(),
163 })?;
164 let pem =
165 String::from_utf8(bytes).map_err(|e| SecretsBootstrapError::SigningKeyPemInvalid {
166 message: e.to_string(),
167 })?;
168 Ok(Some(pem))
169 }
170
171 pub fn manifest_signing_secret_seed()
172 -> Result<[u8; MANIFEST_SIGNING_SEED_BYTES], SecretsBootstrapError> {
173 let encoded = Self::get()?
174 .manifest_signing_secret_seed
175 .as_deref()
176 .ok_or(SecretsBootstrapError::ManifestSeedRequired)?;
177 decode_seed(encoded)
178 }
179
180 pub fn rotate_manifest_signing_seed() -> ConfigResult<[u8; MANIFEST_SIGNING_SEED_BYTES]> {
181 let path = Self::resolved_secrets_file_path()?;
182 let seed = generate_seed();
183 persist_seed(&path, &seed)?;
184 Ok(seed)
185 }
186
187 fn validate_identity(secrets: &Secrets) -> ConfigResult<()> {
188 let encoded = secrets
189 .manifest_signing_secret_seed
190 .as_deref()
191 .ok_or(SecretsBootstrapError::ManifestSeedRequired)?;
192 decode_seed(encoded)?;
193
194 let is_deployment_host =
195 systemprompt_models::subprocess::is_deployment_host(|name| std::env::var(name).ok());
196 let is_cloud = ProfileBootstrap::get().is_ok_and(|profile| profile.target.is_cloud());
197 if (is_deployment_host || is_cloud) && secrets.signing_key_pem.is_none() {
198 return Err(SecretsBootstrapError::SigningKeyPemRequired.into());
199 }
200 Ok(())
201 }
202
203 fn resolved_secrets_file_path() -> ConfigResult<PathBuf> {
204 let profile =
205 ProfileBootstrap::get().map_err(|_e| SecretsBootstrapError::ProfileNotInitialized)?;
206 let secrets_config = profile
207 .secrets
208 .as_ref()
209 .ok_or(SecretsBootstrapError::NoSecretsConfigured)?;
210 let profile_path = ProfileBootstrap::get_path()
211 .map_err(|_e| SecretsBootstrapError::ProfileNotInitialized)?;
212 let profile_dir = Path::new(profile_path)
213 .parent()
214 .ok_or_else(|| ConfigError::other("Invalid profile path - no parent directory"))?;
215 let secrets_path = secrets_config.secrets_path().map_err(|e| {
216 SecretsBootstrapError::SecretsConfigInvalid {
217 message: e.to_string(),
218 }
219 })?;
220 Ok(resolve_with_home(profile_dir, secrets_path))
221 }
222
223 pub fn database_url() -> Result<&'static str, SecretsBootstrapError> {
224 Ok(&Self::get()?.database_url)
225 }
226
227 pub fn database_write_url() -> Result<Option<&'static str>, SecretsBootstrapError> {
228 Ok(Self::get()?.database_write_url.as_deref())
229 }
230
231 pub fn get() -> Result<&'static Secrets, SecretsBootstrapError> {
232 SECRETS.get().ok_or(SecretsBootstrapError::NotInitialized)
233 }
234
235 #[must_use]
236 pub fn is_initialized() -> bool {
237 SECRETS.get().is_some()
238 }
239
240 pub async fn try_init() -> ConfigResult<&'static Secrets> {
241 if SECRETS.get().is_some() {
242 return Self::get().map_err(Into::into);
243 }
244 Self::init().await
245 }
246
247 fn log_loaded_secrets(secrets: &Secrets) {
248 let message = build_loaded_secrets_message(secrets);
249 tracing::debug!("{message}");
250 }
251}