Skip to main content

vault_core/
catalogue.rs

1//! The provider catalogue: a signed, public table of issuer key prefixes that
2//! `unv enrich` consults before the table compiled into the binary.
3//!
4//! The compiled table can only be updated by a release. This one is published
5//! as a static file, fetched **whole** (a per-provider request would tell the
6//! host which providers you hold credentials for), cached locally and verified
7//! on **every load**, not only on download.
8//!
9//! Enrichment writes into the vault, so a tampered catalogue could mislabel
10//! secret types or point a card at an attacker's URL. Hence:
11//!
12//! - an Ed25519 signature over the exact payload bytes, checked against
13//!   [`PINNED_KEY_HEX`], which lives in the binary;
14//! - `generated_at` may never go backwards (replaying an old, valid file);
15//! - every field is shape-checked after the signature, because a correctly
16//!   signed typo is still a typo: prefixes under 3 characters would match
17//!   nearly every secret, and non-`https` URLs never reach a card.
18//!
19//! The catalogue holds no secrets and no user data. See ADR-0139.
20
21use base64::Engine;
22use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
23use serde::{Deserialize, Serialize};
24use std::path::PathBuf;
25
26pub const SCHEMA: u32 = 1;
27
28/// Where the scheduled `docs.yml` run publishes the signed catalogue. Not yet
29/// verified against a live Pages deployment.
30pub const DEFAULT_URL: &str = "https://darthdemono.github.io/EnvVault/catalogue/catalogue.json";
31
32/// Ed25519 public key the catalogue must be signed with. The private half is
33/// the `CATALOGUE_SIGNING_KEY` Actions secret; rotating it means a release.
34pub const PINNED_KEY_HEX: &str = "12d80743bd61b75e2a355ff299e64a8cfb46da855ad1be3f669156d6cd89ba9a";
35
36fn pinned_key() -> [u8; 32] {
37    let mut k = [0u8; 32];
38    hex::decode_to_slice(PINNED_KEY_HEX, &mut k).expect("PINNED_KEY_HEX is 64 hex digits");
39    k
40}
41
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct Provider {
44    pub prefix: String,
45    pub issuer: String,
46    pub secret_type: String,
47    pub icon: String,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub api_url: Option<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub environment: Option<String>,
52    /// Taxonomy axes (Phase 24.5), applied by `enrich` as suggestions only.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub acts_as: Option<String>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub exposure: Option<String>,
57    /// Where to revoke or rotate it: proposed into the entry's `console_url`.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub console_url: Option<String>,
60    /// Reference links. Carried for the catalogue's readers; not applied to entries.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub docs_url: Option<String>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub rotate_url: Option<String>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub revoke_url: Option<String>,
67    /// Where the prefix is published and when somebody last checked it there
68    /// (Phase 31.3). Optional so older clients and older entries still parse.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub verified_on: Option<String>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub source_url: Option<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct Catalogue {
77    pub schema: u32,
78    /// RFC 3339 UTC, fixed width, so string order is time order.
79    pub generated_at: String,
80    pub providers: Vec<Provider>,
81}
82
83/// On the wire: the payload is a JSON **string**, so the signature covers the
84/// exact bytes the publisher signed and no re-serialisation can disturb it.
85#[derive(Serialize, Deserialize)]
86struct Signed {
87    payload: String,
88    signature: String,
89}
90
91fn https(u: &Option<String>) -> bool {
92    u.as_deref()
93        .is_none_or(|s| s.starts_with("https://") && s.len() < 300)
94}
95
96fn validate(c: &Catalogue) -> Result<(), String> {
97    if c.schema != SCHEMA {
98        return Err(format!("unsupported catalogue schema {}", c.schema));
99    }
100    if c.generated_at.len() != 20 || !c.generated_at.ends_with('Z') {
101        return Err("generated_at must be YYYY-MM-DDTHH:MM:SSZ".into());
102    }
103    if c.providers.is_empty() || c.providers.len() > 20_000 {
104        return Err("provider count out of range".into());
105    }
106    for p in &c.providers {
107        let ok_prefix = p.prefix.len() >= 3
108            && p.prefix.len() <= 64
109            && p.prefix.bytes().all(|b| b.is_ascii_graphic());
110        if !ok_prefix {
111            return Err(format!("unusable prefix `{}`", p.prefix.escape_debug()));
112        }
113        if p.issuer.is_empty() || p.issuer.len() > 80 || p.icon.len() > 64 {
114            return Err(format!("bad issuer/icon for `{}`", p.prefix));
115        }
116        if crate::secret_types::find(&p.secret_type).is_none() {
117            return Err(format!("unknown secret type `{}`", p.secret_type));
118        }
119        if !(https(&p.api_url)
120            && https(&p.docs_url)
121            && https(&p.rotate_url)
122            && https(&p.revoke_url))
123        {
124            return Err(format!("non-https URL for `{}`", p.prefix));
125        }
126        if !(https(&p.console_url)) {
127            return Err(format!("non-https console URL for `{}`", p.prefix));
128        }
129        const ACTS: [&str; 6] = [
130            "anonymous",
131            "user",
132            "service",
133            "bot",
134            "installation",
135            "admin",
136        ];
137        const EXPO: [&str; 3] = ["publishable", "server_only", "verify_only"];
138        if p.acts_as.as_deref().is_some_and(|v| !ACTS.contains(&v))
139            || p.exposure.as_deref().is_some_and(|v| !EXPO.contains(&v))
140        {
141            return Err(format!("unknown axis value for `{}`", p.prefix));
142        }
143        if p.environment.as_deref().is_some_and(|e| e.len() > 24) {
144            return Err(format!("bad environment for `{}`", p.prefix));
145        }
146    }
147    Ok(())
148}
149
150/// Check signature and shape. Does not look at the cache.
151pub fn verify(raw: &[u8], key: &[u8; 32]) -> Result<Catalogue, String> {
152    let signed: Signed =
153        serde_json::from_slice(raw).map_err(|e| format!("not a catalogue: {e}"))?;
154    let sig = base64::engine::general_purpose::STANDARD
155        .decode(signed.signature.trim())
156        .map_err(|_| "signature is not base64".to_string())?;
157    let sig =
158        Signature::from_slice(&sig).map_err(|_| "signature has the wrong length".to_string())?;
159    let vk = VerifyingKey::from_bytes(key).map_err(|e| e.to_string())?;
160    vk.verify(signed.payload.as_bytes(), &sig)
161        .map_err(|_| "signature does not match the pinned key".to_string())?;
162    let c: Catalogue = serde_json::from_str(&signed.payload).map_err(|e| e.to_string())?;
163    validate(&c)?;
164    Ok(c)
165}
166
167/// Publisher side: sign `c` with a 32-byte Ed25519 seed. Used by
168/// `unv catalogue sign` in the scheduled workflow.
169pub fn sign(c: &Catalogue, seed: &[u8; 32]) -> Result<String, String> {
170    validate(c)?;
171    let payload = serde_json::to_string(c).map_err(|e| e.to_string())?;
172    let sig = SigningKey::from_bytes(seed).sign(payload.as_bytes());
173    let signature = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
174    serde_json::to_string_pretty(&Signed { payload, signature }).map_err(|e| e.to_string())
175}
176
177pub fn cache_path() -> Option<PathBuf> {
178    if let Some(p) = std::env::var_os("UNV_CATALOGUE_FILE") {
179        return Some(PathBuf::from(p));
180    }
181    dirs::data_dir().map(|d| d.join("io.unenverse").join("catalogue.json"))
182}
183
184/// The cached catalogue, re-verified now. A bad or missing file is `None`, so
185/// enrichment falls back to the compiled table instead of failing.
186pub fn load_cached() -> Option<Catalogue> {
187    let raw = std::fs::read(cache_path()?).ok()?;
188    verify(&raw, &pinned_key()).ok()
189}
190
191/// Verify `raw` against the pinned key, refuse a rollback, and cache it
192/// atomically (0600). Returns the accepted catalogue.
193pub fn store(raw: &[u8]) -> Result<Catalogue, String> {
194    store_with(raw, &pinned_key())
195}
196
197fn store_with(raw: &[u8], key: &[u8; 32]) -> Result<Catalogue, String> {
198    let c = verify(raw, key)?;
199    if let Some(old) = load_cached_with(key) {
200        if c.generated_at < old.generated_at {
201            return Err(format!(
202                "refusing to roll back: cached catalogue is {}, offered one is {}",
203                old.generated_at, c.generated_at
204            ));
205        }
206    }
207    let path = cache_path().ok_or("no data directory")?;
208    if let Some(dir) = path.parent() {
209        std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
210    }
211    let tmp = path.with_extension("tmp");
212    std::fs::write(&tmp, raw).map_err(|e| e.to_string())?;
213    #[cfg(unix)]
214    {
215        use std::os::unix::fs::PermissionsExt;
216        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))
217            .map_err(|e| e.to_string())?;
218    }
219    std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
220    Ok(c)
221}
222
223fn load_cached_with(key: &[u8; 32]) -> Option<Catalogue> {
224    verify(&std::fs::read(cache_path()?).ok()?, key).ok()
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    const SEED: [u8; 32] = [7; 32];
232
233    fn key() -> [u8; 32] {
234        SigningKey::from_bytes(&SEED).verifying_key().to_bytes()
235    }
236
237    fn cat(at: &str) -> Catalogue {
238        Catalogue {
239            schema: 1,
240            generated_at: at.into(),
241            providers: vec![Provider {
242                prefix: "zz_".into(),
243                issuer: "Zed".into(),
244                secret_type: "api_key".into(),
245                icon: "zed".into(),
246                api_url: Some("https://api.zed.example".into()),
247                environment: None,
248                acts_as: None,
249                exposure: None,
250                console_url: None,
251                docs_url: None,
252                rotate_url: None,
253                revoke_url: None,
254                verified_on: None,
255                source_url: None,
256            }],
257        }
258    }
259
260    #[test]
261    fn the_pinned_key_is_a_valid_point() {
262        assert!(VerifyingKey::from_bytes(&pinned_key()).is_ok());
263    }
264
265    #[test]
266    fn signed_catalogue_verifies_and_tampering_does_not() {
267        let good = sign(&cat("2026-10-08T00:00:00Z"), &SEED).unwrap();
268        assert!(verify(good.as_bytes(), &key()).is_ok());
269        let bad = good.replace("Zed", "Zex");
270        assert!(verify(bad.as_bytes(), &key()).is_err());
271        assert!(verify(good.as_bytes(), &pinned_key()).is_err(), "wrong key");
272    }
273
274    #[test]
275    fn a_signed_but_overbroad_prefix_is_refused() {
276        let mut c = cat("2026-10-08T00:00:00Z");
277        c.providers[0].prefix = "s".into();
278        assert!(sign(&c, &SEED).is_err());
279        let mut c = cat("2026-10-08T00:00:00Z");
280        c.providers[0].api_url = Some("http://evil.example".into());
281        assert!(sign(&c, &SEED).is_err());
282    }
283
284    #[test]
285    fn an_unknown_axis_value_is_refused() {
286        let mut c = cat("2026-10-08T00:00:00Z");
287        c.providers[0].exposure = Some("public_ish".into());
288        assert!(sign(&c, &SEED).is_err());
289        c.providers[0].exposure = Some("publishable".into());
290        c.providers[0].acts_as = Some("bot".into());
291        assert!(sign(&c, &SEED).is_ok());
292    }
293
294    #[test]
295    fn rollback_is_refused() {
296        let dir = std::env::temp_dir().join(format!("unv-cat-{}", std::process::id()));
297        std::fs::create_dir_all(&dir).unwrap();
298        std::env::set_var("UNV_CATALOGUE_FILE", dir.join("c.json"));
299        let new = sign(&cat("2026-10-08T00:00:00Z"), &SEED).unwrap();
300        let old = sign(&cat("2026-10-07T00:00:00Z"), &SEED).unwrap();
301        store_with(new.as_bytes(), &key()).unwrap();
302        assert!(store_with(old.as_bytes(), &key())
303            .unwrap_err()
304            .contains("roll back"));
305        store_with(new.as_bytes(), &key()).unwrap();
306        std::env::remove_var("UNV_CATALOGUE_FILE");
307        std::fs::remove_dir_all(dir).ok();
308    }
309    #[test]
310    fn the_shipped_catalogue_file_parses_and_every_sourced_entry_is_complete() {
311        let list: Vec<Provider> =
312            serde_json::from_str(include_str!("../../catalogue/providers.json")).unwrap();
313        let mut seen = std::collections::HashSet::new();
314        for p in &list {
315            assert!(
316                seen.insert(p.prefix.clone()),
317                "duplicate prefix {}",
318                p.prefix
319            );
320            assert_eq!(
321                p.verified_on.is_some(),
322                p.source_url.is_some(),
323                "{}: verified_on and source_url go together",
324                p.prefix
325            );
326            if let Some(u) = &p.source_url {
327                assert!(
328                    u.starts_with("https://"),
329                    "{}: source must be https",
330                    p.prefix
331                );
332                assert!(p.prefix.len() >= 4, "{}: too short to be safe", p.prefix);
333            }
334        }
335    }
336}