1use std::fmt::Write as _;
16use std::fs::File;
17use std::io::Read;
18use std::path::Path;
19
20use anyhow::{Result, anyhow};
21use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
22use greentic_secrets_lib::core::Error as SecretError;
23use greentic_secrets_lib::{SecretFormat, SecretsStore};
24use greentic_types::{ExtensionInline, decode_pack_manifest};
25use rand::RngExt as _;
26use serde::Deserialize;
27use zip::{ZipArchive, result::ZipError};
28
29const EXT_GENERATED_SECRETS_V1: &str = "greentic.generated-secrets.v1";
30
31#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct GeneratedRequirement {
34 pub key: String,
35 pub aliases: Vec<String>,
36 pub required: bool,
40 pub spec: GeneratedSecretSpec,
41}
42
43#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct GeneratedSecretSpec {
46 pub policy: String,
47 pub length: usize,
48 pub encoding: String,
49 pub scope_level: String,
50 pub scope_team: Option<String>,
51 pub regenerate_if_present: bool,
52}
53
54pub fn load_generated_requirements_from_pack(
59 pack_path: &Path,
60) -> Result<Vec<GeneratedRequirement>> {
61 let file = match File::open(pack_path) {
62 Ok(file) => file,
63 Err(_) => return Ok(Vec::new()),
64 };
65 let mut archive = match ZipArchive::new(file) {
66 Ok(archive) => archive,
67 Err(_) => return Ok(Vec::new()),
68 };
69
70 if let Some(reqs) = load_from_json_manifest(&mut archive)? {
71 return Ok(reqs);
72 }
73 load_from_cbor_manifest(&mut archive)
74}
75
76fn load_from_json_manifest(
77 archive: &mut ZipArchive<File>,
78) -> Result<Option<Vec<GeneratedRequirement>>> {
79 let mut contents = String::new();
80 match archive.by_name("pack.manifest.json") {
81 Ok(mut entry) => {
82 entry.read_to_string(&mut contents)?;
83 }
84 Err(ZipError::FileNotFound) => return Ok(None),
85 Err(err) => return Err(err.into()),
86 }
87 let manifest: serde_json::Value = serde_json::from_str(&contents)?;
88 let Some(inline) = manifest
89 .get("extensions")
90 .and_then(|extensions| extensions.get(EXT_GENERATED_SECRETS_V1))
91 .and_then(|extension| extension.get("inline"))
92 else {
93 return Ok(Some(Vec::new()));
94 };
95 let ext: GeneratedSecretsExtension = serde_json::from_value(inline.clone())?;
96 Ok(Some(ext.into_requirements()))
97}
98
99fn load_from_cbor_manifest(archive: &mut ZipArchive<File>) -> Result<Vec<GeneratedRequirement>> {
100 let mut bytes = Vec::new();
101 match archive.by_name("manifest.cbor") {
102 Ok(mut entry) => {
103 entry.read_to_end(&mut bytes)?;
104 }
105 Err(ZipError::FileNotFound) => return Ok(Vec::new()),
106 Err(err) => return Err(err.into()),
107 }
108 let manifest = match decode_pack_manifest(&bytes) {
111 Ok(manifest) => manifest,
112 Err(_) => return Ok(Vec::new()),
113 };
114 let Some(extension) = manifest
115 .extensions
116 .as_ref()
117 .and_then(|extensions| extensions.get(EXT_GENERATED_SECRETS_V1))
118 else {
119 return Ok(Vec::new());
120 };
121 let Some(ExtensionInline::Other(value)) = extension.inline.as_ref() else {
122 return Ok(Vec::new());
123 };
124 let ext: GeneratedSecretsExtension = serde_json::from_value(value.clone())?;
125 Ok(ext.into_requirements())
126}
127
128pub fn generate_secret_value(spec: &GeneratedSecretSpec) -> Result<String> {
130 if !spec.policy.eq_ignore_ascii_case("random") {
131 return Err(anyhow!(
132 "unsupported generated secret policy `{}`",
133 spec.policy
134 ));
135 }
136 let length = spec.length.max(1);
137 match spec.encoding.as_str() {
138 "raw_text" => Ok(random_ascii(length)),
139 "base64url" => {
140 let mut bytes = vec![0u8; length];
141 rand::rng().fill(&mut bytes[..]);
142 Ok(URL_SAFE_NO_PAD.encode(bytes))
143 }
144 "hex" => {
145 let mut bytes = vec![0u8; length];
146 rand::rng().fill(&mut bytes[..]);
147 let mut out = String::with_capacity(bytes.len() * 2);
148 for byte in bytes {
149 let _ = write!(out, "{byte:02x}");
150 }
151 Ok(out)
152 }
153 other => Err(anyhow!("unsupported generated secret encoding `{other}`")),
154 }
155}
156
157fn random_ascii(length: usize) -> String {
158 const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
159 let mut bytes = vec![0u8; length];
160 rand::rng().fill(&mut bytes[..]);
161 bytes
162 .into_iter()
163 .map(|byte| ALPHABET[usize::from(byte) % ALPHABET.len()] as char)
164 .collect()
165}
166
167pub fn scope_team<'a>(
173 spec: &'a GeneratedSecretSpec,
174 default_team: Option<&'a str>,
175) -> Option<&'a str> {
176 if spec.scope_level.eq_ignore_ascii_case("tenant") || spec.scope_team.as_deref() == Some("_") {
177 return None;
178 }
179 spec.scope_team.as_deref().or(default_team)
180}
181
182pub async fn introduce_into_store<S: SecretsStore + ?Sized>(
195 store: &S,
196 env: &str,
197 tenant: &str,
198 team: Option<&str>,
199 provider_id: &str,
200 pack_path: &Path,
201) -> Result<Vec<String>> {
202 let mut introduced = Vec::new();
203 for req in load_generated_requirements_from_pack(pack_path)? {
204 let team = scope_team(&req.spec, team);
205 let uri = crate::canonical_secret_uri(env, tenant, team, provider_id, &req.key);
206
207 if !req.spec.regenerate_if_present
208 && generated_present(store, env, tenant, team, provider_id, &req).await?
209 {
210 continue;
211 }
212 if !req.required {
213 tracing::warn!(
214 uri = %uri,
215 provider = %provider_id,
216 key = %req.key,
217 "optional generated secret not introduced at setup; leaving for runtime resolution"
218 );
219 continue;
220 }
221 let value = generate_secret_value(&req.spec)?;
222 store
223 .put(&uri, SecretFormat::Text, value.as_bytes())
224 .await
225 .map_err(|err| anyhow!("failed to store generated secret {uri}: {err}"))?;
226 tracing::info!(
227 uri = %uri,
228 provider = %provider_id,
229 key = %req.key,
230 "setup: generated and introduced secret into the local store"
231 );
232 introduced.push(req.key.clone());
233 }
234 Ok(introduced)
235}
236
237async fn generated_present<S: SecretsStore + ?Sized>(
240 store: &S,
241 env: &str,
242 tenant: &str,
243 team: Option<&str>,
244 provider_id: &str,
245 req: &GeneratedRequirement,
246) -> Result<bool> {
247 for key in std::iter::once(req.key.as_str()).chain(req.aliases.iter().map(String::as_str)) {
248 let uri = crate::canonical_secret_uri(env, tenant, team, provider_id, key);
249 match store.get(&uri).await {
250 Ok(_) => return Ok(true),
251 Err(SecretError::NotFound { .. }) => continue,
252 Err(err) => return Err(anyhow!("failed to read secret {uri}: {err}")),
253 }
254 }
255 Ok(false)
256}
257
258#[derive(Deserialize)]
259struct GeneratedSecretsExtension {
260 #[serde(default)]
261 secrets: Vec<GeneratedSecretEntry>,
262}
263
264impl GeneratedSecretsExtension {
265 fn into_requirements(self) -> Vec<GeneratedRequirement> {
266 self.secrets
267 .into_iter()
268 .map(|secret| GeneratedRequirement {
269 key: secret.key.to_lowercase(),
270 aliases: secret.aliases,
271 required: secret.required.unwrap_or(true),
272 spec: GeneratedSecretSpec {
273 policy: secret.policy.unwrap_or_else(|| "random".to_string()),
274 length: secret.length.unwrap_or(20),
275 encoding: secret.encoding.unwrap_or_else(|| "raw_text".to_string()),
276 scope_level: secret
277 .scope
278 .as_ref()
279 .and_then(|scope| scope.level.clone())
280 .unwrap_or_else(|| "tenant".to_string()),
281 scope_team: secret.scope.and_then(|scope| scope.team),
282 regenerate_if_present: secret.regenerate_if_present.unwrap_or(false),
283 },
284 })
285 .collect()
286 }
287}
288
289#[derive(Deserialize)]
290struct GeneratedSecretEntry {
291 key: String,
292 #[serde(default)]
293 aliases: Vec<String>,
294 #[serde(default)]
295 required: Option<bool>,
296 policy: Option<String>,
297 length: Option<usize>,
298 encoding: Option<String>,
299 scope: Option<GeneratedSecretScope>,
300 #[serde(default)]
301 regenerate_if_present: Option<bool>,
302}
303
304#[derive(Deserialize)]
305struct GeneratedSecretScope {
306 level: Option<String>,
307 team: Option<String>,
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use std::io::Write;
314 use zip::write::FileOptions;
315
316 fn write_pack(path: &Path, entries: &[(&str, Vec<u8>)]) {
317 let file = File::create(path).expect("create pack");
318 let mut zip = zip::ZipWriter::new(file);
319 for (name, bytes) in entries {
320 zip.start_file(*name, FileOptions::<()>::default())
321 .expect("start file");
322 zip.write_all(bytes).expect("write file");
323 }
324 zip.finish().expect("finish pack");
325 }
326
327 fn jwt_manifest() -> serde_json::Value {
328 serde_json::json!({
329 "extensions": {
330 "greentic.generated-secrets.v1": {
331 "inline": { "secrets": [{
332 "key": "jwt_signing_key",
333 "required": true,
334 "policy": "random",
335 "length": 20,
336 "encoding": "raw_text",
337 "scope": {"level": "tenant", "team": "_"},
338 "regenerate_if_present": false
339 }] }
340 }
341 }
342 })
343 }
344
345 #[tokio::test]
346 async fn introduce_into_store_generates_persists_and_is_idempotent() {
347 use greentic_secrets_lib::DevStore;
348 let dir = tempfile::tempdir().expect("tempdir");
349 let pack = dir.path().join("messaging-webchat-gui.gtpack");
350 write_pack(
351 &pack,
352 &[(
353 "pack.manifest.json",
354 serde_json::to_vec(&jwt_manifest()).unwrap(),
355 )],
356 );
357 let store = DevStore::with_path(dir.path().join(".dev.secrets.env")).expect("store");
358
359 let introduced = introduce_into_store(
360 &store,
361 "dev",
362 "demo",
363 Some("default"),
364 "messaging-webchat-gui",
365 &pack,
366 )
367 .await
368 .expect("introduce");
369 assert_eq!(introduced, vec!["jwt_signing_key".to_string()]);
370
371 let uri = crate::canonical_secret_uri(
373 "dev",
374 "demo",
375 None,
376 "messaging-webchat-gui",
377 "jwt_signing_key",
378 );
379 let value = store.get(&uri).await.expect("stored");
380 assert_eq!(value.len(), 20);
381
382 let again = introduce_into_store(
384 &store,
385 "dev",
386 "demo",
387 Some("default"),
388 "messaging-webchat-gui",
389 &pack,
390 )
391 .await
392 .expect("introduce again");
393 assert!(again.is_empty());
394 assert_eq!(store.get(&uri).await.expect("still"), value);
395 }
396
397 #[test]
398 fn reads_generated_requirement_from_json_manifest() {
399 let dir = tempfile::tempdir().expect("tempdir");
400 let pack = dir.path().join("messaging-webchat-gui.gtpack");
401 let manifest = serde_json::json!({
402 "extensions": {
403 "greentic.generated-secrets.v1": {
404 "kind": "greentic.generated-secrets.v1",
405 "inline": {
406 "secrets": [{
407 "key": "jwt_signing_key",
408 "aliases": ["JWT_SIGNING_KEY"],
409 "required": true,
410 "policy": "random",
411 "length": 20,
412 "encoding": "raw_text",
413 "scope": {"level": "tenant", "team": "_"},
414 "regenerate_if_present": false
415 }]
416 }
417 }
418 }
419 });
420 write_pack(
421 &pack,
422 &[(
423 "pack.manifest.json",
424 serde_json::to_vec(&manifest).expect("manifest json"),
425 )],
426 );
427
428 let reqs = load_generated_requirements_from_pack(&pack).expect("load");
429 assert_eq!(reqs.len(), 1);
430 assert_eq!(reqs[0].key, "jwt_signing_key");
431 assert_eq!(reqs[0].aliases, vec!["JWT_SIGNING_KEY"]);
432 assert_eq!(reqs[0].spec.length, 20);
433 assert_eq!(reqs[0].spec.scope_level, "tenant");
434 assert!(!reqs[0].spec.regenerate_if_present);
435 }
436
437 #[test]
438 fn empty_pack_yields_no_generated_requirements() {
439 let dir = tempfile::tempdir().expect("tempdir");
440 let pack = dir.path().join("empty.gtpack");
441 write_pack(&pack, &[("pack.manifest.json", b"{}".to_vec())]);
442 assert!(
443 load_generated_requirements_from_pack(&pack)
444 .expect("load")
445 .is_empty()
446 );
447 }
448
449 #[test]
450 fn generated_value_honours_length_and_encoding() {
451 let raw = generate_secret_value(&GeneratedSecretSpec {
452 policy: "random".into(),
453 length: 20,
454 encoding: "raw_text".into(),
455 scope_level: "tenant".into(),
456 scope_team: Some("_".into()),
457 regenerate_if_present: false,
458 })
459 .expect("raw");
460 assert_eq!(raw.len(), 20);
461 assert!(!raw.contains("placeholder"));
462
463 let hex = generate_secret_value(&GeneratedSecretSpec {
464 policy: "random".into(),
465 length: 16,
466 encoding: "hex".into(),
467 scope_level: "tenant".into(),
468 scope_team: None,
469 regenerate_if_present: false,
470 })
471 .expect("hex");
472 assert_eq!(hex.len(), 32);
473 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
474 }
475
476 #[test]
477 fn unsupported_policy_is_rejected() {
478 let err = generate_secret_value(&GeneratedSecretSpec {
479 policy: "fixed".into(),
480 length: 20,
481 encoding: "raw_text".into(),
482 scope_level: "tenant".into(),
483 scope_team: None,
484 regenerate_if_present: false,
485 })
486 .unwrap_err();
487 assert!(
488 err.to_string()
489 .contains("unsupported generated secret policy")
490 );
491 }
492
493 #[test]
494 fn tenant_scope_collapses_team_to_none() {
495 let spec = GeneratedSecretSpec {
496 policy: "random".into(),
497 length: 20,
498 encoding: "raw_text".into(),
499 scope_level: "tenant".into(),
500 scope_team: Some("ops".into()),
501 regenerate_if_present: false,
502 };
503 assert_eq!(scope_team(&spec, Some("default")), None);
504
505 let team_scoped = GeneratedSecretSpec {
506 scope_level: "team".into(),
507 scope_team: Some("ops".into()),
508 ..spec
509 };
510 assert_eq!(scope_team(&team_scoped, Some("default")), Some("ops"));
511 }
512}