systemprompt_config/bootstrap/secrets/
mod.rs1mod 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::{
23 MANIFEST_SIGNING_SEED_BYTES, decode_seed, dir_is_writable, generate_seed, persist_seed,
24};
25use super::profile::ProfileBootstrap;
26use crate::error::{ConfigError, ConfigResult};
27
28pub use io::load_secrets_from_path;
29pub use logging::{
30 build_loaded_secrets_message, log_secrets_issue, log_secrets_skip, log_secrets_warn,
31};
32
33static SECRETS: OnceLock<Secrets> = OnceLock::new();
34
35#[derive(Debug, Clone, Copy)]
36pub struct SecretsBootstrap;
37
38#[derive(Debug, thiserror::Error)]
39#[non_exhaustive]
40pub enum SecretsBootstrapError {
41 #[error(
42 "Secrets not initialized. Call SecretsBootstrap::init() after ProfileBootstrap::init()"
43 )]
44 NotInitialized,
45
46 #[error("Secrets already initialized")]
47 AlreadyInitialized,
48
49 #[error("Profile not initialized. Call ProfileBootstrap::init() first")]
50 ProfileNotInitialized,
51
52 #[error("Secrets file not found: {path}")]
53 FileNotFound { path: String },
54
55 #[error("Invalid secrets file: {message}")]
56 InvalidSecretsFile { message: String },
57
58 #[error("No secrets configured. Create a secrets.json file.")]
59 NoSecretsConfigured,
60
61 #[error(
62 "OAuth at-rest pepper is required. Add 'oauth_at_rest_pepper' (>= 32 chars) to your \
63 secrets file or set OAUTH_AT_REST_PEPPER environment variable."
64 )]
65 OauthAtRestPepperRequired,
66
67 #[error(
68 "Database URL is required. Add 'database_url' to your secrets.json or set DATABASE_URL \
69 environment variable."
70 )]
71 DatabaseUrlRequired,
72
73 #[error(
74 "manifest_signing_secret_seed is missing from the secrets file and the bootstrap path is \
75 not writable. Run `systemprompt admin bridge rotate-signing-key` against a writable \
76 secrets file, or add a base64-encoded 32-byte value under `manifest_signing_secret_seed`."
77 )]
78 ManifestSeedUnavailable,
79
80 #[error("manifest_signing_secret_seed is invalid: {message}")]
81 ManifestSeedInvalid { message: String },
82
83 #[error("signing_key_pem secret is invalid: {message}")]
84 SigningKeyPemInvalid { message: String },
85
86 #[error(
87 "manifest_signing_secret_seed missing in subprocess env — parent must propagate \
88 MANIFEST_SIGNING_SECRET_SEED so subprocesses don't regenerate and clobber the secrets \
89 file"
90 )]
91 SubprocessSeedMissing,
92}
93
94impl SecretsBootstrap {
95 pub fn init() -> ConfigResult<&'static Secrets> {
96 if SECRETS.get().is_some() {
97 return Err(SecretsBootstrapError::AlreadyInitialized.into());
98 }
99
100 let mut secrets = loader::load_from_profile_config()?;
101 Self::ensure_manifest_signing_seed(&mut secrets)?;
102
103 Self::log_loaded_secrets(&secrets);
104
105 SECRETS
106 .set(secrets)
107 .map_err(|_e| SecretsBootstrapError::AlreadyInitialized)?;
108
109 SECRETS
110 .get()
111 .ok_or_else(|| SecretsBootstrapError::NotInitialized.into())
112 }
113
114 pub fn oauth_at_rest_pepper() -> Result<&'static str, SecretsBootstrapError> {
115 Ok(&Self::get()?.oauth_at_rest_pepper)
116 }
117
118 pub fn signing_key_pem() -> Result<Option<String>, SecretsBootstrapError> {
119 let Some(encoded) = Self::get()?.signing_key_pem.as_deref() else {
120 return Ok(None);
121 };
122 let bytes = base64::engine::general_purpose::STANDARD
123 .decode(encoded)
124 .map_err(|e| SecretsBootstrapError::SigningKeyPemInvalid {
125 message: e.to_string(),
126 })?;
127 let pem =
128 String::from_utf8(bytes).map_err(|e| SecretsBootstrapError::SigningKeyPemInvalid {
129 message: e.to_string(),
130 })?;
131 Ok(Some(pem))
132 }
133
134 pub fn manifest_signing_secret_seed()
135 -> Result<[u8; MANIFEST_SIGNING_SEED_BYTES], SecretsBootstrapError> {
136 let encoded = Self::get()?
137 .manifest_signing_secret_seed
138 .as_deref()
139 .ok_or(SecretsBootstrapError::ManifestSeedUnavailable)?;
140 decode_seed(encoded)
141 }
142
143 pub fn rotate_manifest_signing_seed() -> ConfigResult<[u8; MANIFEST_SIGNING_SEED_BYTES]> {
144 let path = Self::resolved_secrets_file_path()?;
145 let seed = generate_seed();
146 persist_seed(&path, &seed)?;
147 Ok(seed)
148 }
149
150 fn ensure_manifest_signing_seed(secrets: &mut Secrets) -> ConfigResult<()> {
151 if let Some(encoded) = secrets.manifest_signing_secret_seed.as_deref() {
152 decode_seed(encoded)?;
153 return Ok(());
154 }
155 if std::env::var("SYSTEMPROMPT_SUBPROCESS").is_ok() {
156 return Err(SecretsBootstrapError::SubprocessSeedMissing.into());
157 }
158 let Ok(path) = Self::resolved_secrets_file_path() else {
159 tracing::warn!(
160 "manifest_signing_secret_seed missing and no writable secrets file is configured"
161 );
162 return Ok(());
163 };
164 if !path.exists() {
165 tracing::warn!(
166 path = %path.display(),
167 "manifest_signing_secret_seed missing and secrets file does not exist on disk"
168 );
169 return Ok(());
170 }
171 let seed = generate_seed();
172 secrets.manifest_signing_secret_seed =
173 Some(base64::engine::general_purpose::STANDARD.encode(seed));
174
175 let profile_dir = path.parent().unwrap_or_else(|| Path::new("."));
182 if !dir_is_writable(profile_dir) {
183 tracing::warn!(
184 path = %path.display(),
185 "profile dir is read-only — using an ephemeral manifest signing seed for this \
186 boot; set MANIFEST_SIGNING_SECRET_SEED or use a writable dir to persist it"
187 );
188 return Ok(());
189 }
190 if let Err(err) = persist_seed(&path, &seed) {
191 tracing::warn!(
192 path = %path.display(),
193 error = %err,
194 "could not persist manifest_signing_secret_seed — using an ephemeral seed for \
195 this boot; set MANIFEST_SIGNING_SECRET_SEED to make it stable"
196 );
197 return Ok(());
198 }
199 tracing::info!(
200 path = %path.display(),
201 "Generated and persisted fresh manifest_signing_secret_seed"
202 );
203 Ok(())
204 }
205
206 fn resolved_secrets_file_path() -> ConfigResult<PathBuf> {
207 let profile =
208 ProfileBootstrap::get().map_err(|_e| SecretsBootstrapError::ProfileNotInitialized)?;
209 let secrets_config = profile
210 .secrets
211 .as_ref()
212 .ok_or(SecretsBootstrapError::NoSecretsConfigured)?;
213 let profile_path = ProfileBootstrap::get_path()
214 .map_err(|_e| SecretsBootstrapError::ProfileNotInitialized)?;
215 let profile_dir = Path::new(profile_path)
216 .parent()
217 .ok_or_else(|| ConfigError::other("Invalid profile path - no parent directory"))?;
218 Ok(resolve_with_home(profile_dir, &secrets_config.secrets_path))
219 }
220
221 pub fn database_url() -> Result<&'static str, SecretsBootstrapError> {
222 Ok(&Self::get()?.database_url)
223 }
224
225 pub fn database_write_url() -> Result<Option<&'static str>, SecretsBootstrapError> {
226 Ok(Self::get()?.database_write_url.as_deref())
227 }
228
229 pub fn get() -> Result<&'static Secrets, SecretsBootstrapError> {
230 SECRETS.get().ok_or(SecretsBootstrapError::NotInitialized)
231 }
232
233 pub fn require() -> Result<&'static Secrets, SecretsBootstrapError> {
234 Self::get()
235 }
236
237 #[must_use]
238 pub fn is_initialized() -> bool {
239 SECRETS.get().is_some()
240 }
241
242 pub fn try_init() -> ConfigResult<&'static Secrets> {
243 if SECRETS.get().is_some() {
244 return Self::get().map_err(Into::into);
245 }
246 Self::init()
247 }
248
249 fn log_loaded_secrets(secrets: &Secrets) {
250 let message = build_loaded_secrets_message(secrets);
251 tracing::debug!("{message}");
252 }
253}