Skip to main content

greentic_setup/
generated_secrets.rs

1//! Generation of provider runtime secrets declared via the
2//! `greentic.generated-secrets.v1` pack extension.
3//!
4//! Setup is the single place that *introduces* secrets into the local dev
5//! secrets store. Generated secrets (for example a provider JWT signing key)
6//! are materialised here so that `gtc start` can simply move them into the
7//! deployment target's secrets manager without re-generating divergent values.
8//!
9//! The parsing mirrors `greentic-start`'s contract: a secret is declared under
10//! `extensions["greentic.generated-secrets.v1"].inline.secrets[]`. We prefer the
11//! JSON manifest (`pack.manifest.json`) because it is free of the CBOR
12//! symbol-interning used by `manifest.cbor`, and fall back to a best-effort CBOR
13//! navigation when only the binary manifest is present.
14
15use 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/// A pack-declared secret that setup must generate and introduce locally.
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct GeneratedRequirement {
34    pub key: String,
35    pub aliases: Vec<String>,
36    /// Whether the secret is mandatory. Optional generated secrets are not
37    /// force-introduced at setup — they are warned about and left for runtime
38    /// resolution (mirroring runtime-acquired secrets such as OAuth tokens).
39    pub required: bool,
40    pub spec: GeneratedSecretSpec,
41}
42
43/// Generation policy for a declared secret.
44#[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
54/// Load the generated-secret requirements declared by a `.gtpack`.
55///
56/// Returns an empty vector when the pack declares none (or is not a readable
57/// zip archive); discovery failures elsewhere must not block setup.
58pub 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    // The pack manifest CBOR uses a symbol table to intern keys, so it must be
109    // decoded through `decode_pack_manifest` rather than navigated as raw CBOR.
110    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
128/// Generate the value for a declared secret according to its policy.
129pub 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
167/// Resolve the team segment for a generated secret's canonical URI.
168///
169/// Tenant-scoped secrets (or those that pin `team: "_"`) collapse to the
170/// tenant-wide `_` placeholder; otherwise the declared team wins, falling back
171/// to the active setup team.
172pub 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
182/// Generate and introduce a pack's declared generated secrets into `store`.
183///
184/// This is the single place that materialises generated secrets at setup time
185/// so `gtc start` can move the already-resolved value into the deployment
186/// target's secrets manager rather than regenerating a divergent one.
187///
188/// - Existing values are preserved unless the pack opts into
189///   `regenerate_if_present`.
190/// - Optional secrets that are absent are warned about and left for runtime
191///   resolution (mirroring runtime-acquired secrets such as OAuth tokens).
192///
193/// Returns the keys actually introduced (for logging).
194pub 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
237/// Whether a generated secret already exists under its canonical key or any
238/// declared alias.
239async 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        // Tenant-scoped secret collapses the team segment to `_`.
372        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        // regenerate_if_present=false -> a second pass preserves the value.
383        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}