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