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