Skip to main content

kovra_core/
record.rs

1//! Secret records and the vault format (spec §1.3, §10.1; ADR-0001).
2//!
3//! A secret is **literal** (value lives encrypted in the vault) or **reference**
4//! (the vault holds only a pointer to an external provider; the value is
5//! materialized at run time and never stored — I8).
6//!
7//! Per ADR-0001 the vault is not a single JSON blob: each secret is an
8//! independently AEAD-sealed record (see [`crate::crypto`]) sealing **metadata +
9//! value together**. The [`Vault`] persisted format therefore maps an opaque
10//! record id to a [`SealedRecord`](crate::crypto::SealedRecord); the plaintext
11//! [`SecretRecord`] is what `seal`/`open` convert to and from.
12
13use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17use crate::crypto::SealedRecord;
18use crate::keypair::KeyAlgorithm;
19use crate::secret::SecretValue;
20use crate::sensitivity::Sensitivity;
21use crate::totp::TotpAlgorithm;
22
23/// Current vault schema version.
24pub const SCHEMA_VERSION: u32 = 1;
25
26/// A single secret record, in one of four modalities. Internally tagged by
27/// `mode` to mirror the spec §10.1 on-the-wire shape.
28///
29/// `Debug` is safe: the only secret-bearing fields are `value` (literal),
30/// `private` (keypair), and `seed` (totp) — all [`SecretValue`]s whose own
31/// `Debug` is redacted (I12). The `public` half of a keypair and the TOTP
32/// parameters (algorithm/digits/period) are not secrets.
33#[derive(Debug, PartialEq, Serialize, Deserialize)]
34#[serde(tag = "mode", rename_all = "lowercase")]
35pub enum SecretRecord {
36    /// The value lives (encrypted) in the vault.
37    Literal {
38        /// The secret value.
39        value: SecretValue,
40        /// Sensitivity level (spec §3.1).
41        sensitivity: Sensitivity,
42        /// Whether the secret is opted into reveal (the §3.1 "revealable" flag).
43        ///
44        /// Sourced into [`crate::AccessRequest::revealable`] so the policy
45        /// funnel (I11) reads it from the **stored secret**, never from caller
46        /// intent. Defaults to `false` so pre-L9 vaults (and any record that
47        /// never opted in) are non-revealable — the safe default.
48        #[serde(default)]
49        revealable: bool,
50        /// Environment segment, e.g. `prod`.
51        environment: String,
52        /// Component segment.
53        component: String,
54        /// Key segment.
55        key: String,
56        /// Optional human description.
57        #[serde(default, skip_serializing_if = "Option::is_none")]
58        description: Option<String>,
59        /// Creation timestamp (RFC 3339; a `Clock` trait arrives in a later layer).
60        created: String,
61        /// Last-update timestamp.
62        updated: String,
63    },
64    /// The vault holds only a pointer to an external secret manager.
65    Reference {
66        /// Provider URI, e.g. `azure-kv://corp-kv/db-url`.
67        #[serde(rename = "ref")]
68        reference: String,
69        /// Sensitivity level.
70        sensitivity: Sensitivity,
71        /// Whether the secret is opted into reveal (see the `Literal` variant).
72        #[serde(default)]
73        revealable: bool,
74        /// Environment segment.
75        environment: String,
76        /// Component segment.
77        component: String,
78        /// Key segment.
79        key: String,
80        /// Optional human description.
81        #[serde(default, skip_serializing_if = "Option::is_none")]
82        description: Option<String>,
83        /// Creation timestamp.
84        created: String,
85        /// Last-update timestamp.
86        updated: String,
87    },
88    /// An asymmetric keypair (KOV-12). The **private** half (when present) is a
89    /// sealed [`SecretValue`] custodied exactly like a literal — never exported,
90    /// used only *through* operations (sign / decrypt / ssh-add), mirroring
91    /// injection. The **public** half is not a secret and is shown freely. A
92    /// `private: None` record is a *public-only* entry: a peer's/recipient's
93    /// public key for `encrypt`/`verify`.
94    Keypair {
95        /// The key algorithm (ed25519 or RSA).
96        algorithm: KeyAlgorithm,
97        /// The OpenSSH-format private key, sealed. `None` for a public-only
98        /// entry. Born non-revealable by default (I11), like a `high` secret.
99        #[serde(default, skip_serializing_if = "Option::is_none")]
100        private: Option<SecretValue>,
101        /// The OpenSSH-format public key (`ssh-ed25519 …` / `ssh-rsa …`). Public
102        /// material — safe to serialize and display.
103        public: String,
104        /// Sensitivity level. A keypair *with* a private half is born `high`
105        /// when its environment is `prod` (I5), like any other secret; a
106        /// public-only entry is typically `low` (it holds no secret).
107        sensitivity: Sensitivity,
108        /// Whether the secret is opted into reveal (see the `Literal` variant).
109        /// A keypair's private half is never returned to a model regardless;
110        /// this only governs whether the CLI/UI may show it (I11).
111        #[serde(default)]
112        revealable: bool,
113        /// Environment segment.
114        environment: String,
115        /// Component segment.
116        component: String,
117        /// Key segment.
118        key: String,
119        /// Optional human description.
120        #[serde(default, skip_serializing_if = "Option::is_none")]
121        description: Option<String>,
122        /// Creation timestamp.
123        created: String,
124        /// Last-update timestamp.
125        updated: String,
126    },
127    /// A TOTP enrollment (KOV-11). The **seed** (the shared secret) is a sealed
128    /// [`SecretValue`] custodied exactly like a literal — never exported, used
129    /// only *through* deriving a short-lived RFC-6238 code (`kovra code`),
130    /// mirroring how a keypair's private half is used only through sign/decrypt.
131    /// The seed is **never** returned to a model (I11/I14) regardless of the
132    /// `revealable` flag; only the derived code is produced, on demand.
133    Totp {
134        /// The base32-decoded shared-secret seed, sealed. Born non-revealable by
135        /// default (I11), like a `high` secret.
136        seed: SecretValue,
137        /// The HMAC hash algorithm (SHA1 default). Not a secret.
138        #[serde(default)]
139        algorithm: TotpAlgorithm,
140        /// Code length in digits (typically 6). Not a secret.
141        digits: u8,
142        /// Time step in seconds (typically 30). Not a secret.
143        period: u8,
144        /// Sensitivity level. A TOTP enrollment is born `high` when its
145        /// environment is `prod` (I5), like any other secret.
146        sensitivity: Sensitivity,
147        /// Whether the secret is opted into reveal (see the `Literal` variant).
148        /// A TOTP seed is never returned to a model regardless; this only governs
149        /// whether the CLI/UI may show it (I11) — and even the CLI shows the
150        /// derived code, never the seed.
151        #[serde(default)]
152        revealable: bool,
153        /// Environment segment.
154        environment: String,
155        /// Component segment.
156        component: String,
157        /// Key segment.
158        key: String,
159        /// Optional human description.
160        #[serde(default, skip_serializing_if = "Option::is_none")]
161        description: Option<String>,
162        /// Creation timestamp.
163        created: String,
164        /// Last-update timestamp.
165        updated: String,
166    },
167}
168
169impl SecretRecord {
170    /// The secret's sensitivity, regardless of modality.
171    pub fn sensitivity(&self) -> Sensitivity {
172        match self {
173            SecretRecord::Literal { sensitivity, .. }
174            | SecretRecord::Reference { sensitivity, .. }
175            | SecretRecord::Keypair { sensitivity, .. }
176            | SecretRecord::Totp { sensitivity, .. } => *sensitivity,
177        }
178    }
179
180    /// Whether the secret is opted into reveal (the §3.1 "revealable" flag).
181    ///
182    /// Faces that build a [`crate::AccessRequest`] read it from here so the
183    /// I11 reveal gate is sourced from the stored record, never caller intent.
184    pub fn revealable(&self) -> bool {
185        match self {
186            SecretRecord::Literal { revealable, .. }
187            | SecretRecord::Reference { revealable, .. }
188            | SecretRecord::Keypair { revealable, .. }
189            | SecretRecord::Totp { revealable, .. } => *revealable,
190        }
191    }
192
193    /// The environment segment, regardless of modality.
194    pub fn environment(&self) -> &str {
195        match self {
196            SecretRecord::Literal { environment, .. }
197            | SecretRecord::Reference { environment, .. }
198            | SecretRecord::Keypair { environment, .. }
199            | SecretRecord::Totp { environment, .. } => environment,
200        }
201    }
202
203    /// The component segment, regardless of modality.
204    pub fn component(&self) -> &str {
205        match self {
206            SecretRecord::Literal { component, .. }
207            | SecretRecord::Reference { component, .. }
208            | SecretRecord::Keypair { component, .. }
209            | SecretRecord::Totp { component, .. } => component,
210        }
211    }
212
213    /// The key segment, regardless of modality.
214    pub fn key(&self) -> &str {
215        match self {
216            SecretRecord::Literal { key, .. }
217            | SecretRecord::Reference { key, .. }
218            | SecretRecord::Keypair { key, .. }
219            | SecretRecord::Totp { key, .. } => key,
220        }
221    }
222
223    /// The canonical `<env>/<component>/<key>` path this record files under.
224    pub fn canonical_path(&self) -> String {
225        format!("{}/{}/{}", self.environment(), self.component(), self.key())
226    }
227
228    /// The external reference URI for a `Reference` record (e.g.
229    /// `azure-kv://vault/name`), or `None` for any other modality. Carries an
230    /// address, never a value.
231    pub fn reference(&self) -> Option<&str> {
232        match self {
233            SecretRecord::Reference { reference, .. } => Some(reference),
234            _ => None,
235        }
236    }
237}
238
239/// The persisted vault: a versioned map of record id → sealed record.
240///
241/// In L2 the id is `BLAKE3(coordinate)`; at this layer it is any opaque string.
242/// Every value lives inside a [`SealedRecord`], so this structure can be
243/// serialized freely without exposing plaintext.
244#[derive(Debug, PartialEq, Serialize, Deserialize)]
245pub struct Vault {
246    /// Schema version of this vault file.
247    pub schema_version: u32,
248    /// Sealed records keyed by opaque id.
249    pub secrets: BTreeMap<String, SealedRecord>,
250}
251
252impl Default for Vault {
253    fn default() -> Self {
254        Self {
255            schema_version: SCHEMA_VERSION,
256            secrets: BTreeMap::new(),
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    fn literal() -> SecretRecord {
266        SecretRecord::Literal {
267            value: SecretValue::from("hunter2"),
268            sensitivity: Sensitivity::High,
269            revealable: false,
270            environment: "prod".to_string(),
271            component: "db".to_string(),
272            key: "password".to_string(),
273            description: Some("primary db".to_string()),
274            created: "2026-05-30T00:00:00Z".to_string(),
275            updated: "2026-05-30T00:00:00Z".to_string(),
276        }
277    }
278
279    #[test]
280    fn literal_debug_is_redacted() {
281        let dbg = format!("{:?}", literal());
282        assert!(dbg.contains("REDACTED"));
283        assert!(!dbg.contains("hunter2"));
284    }
285
286    #[test]
287    fn reference_carries_no_value() {
288        let r = SecretRecord::Reference {
289            reference: "azure-kv://corp-kv/db-url".to_string(),
290            sensitivity: Sensitivity::High,
291            revealable: false,
292            environment: "prod".to_string(),
293            component: "db".to_string(),
294            key: "url".to_string(),
295            description: None,
296            created: "2026-05-30T00:00:00Z".to_string(),
297            updated: "2026-05-30T00:00:00Z".to_string(),
298        };
299        // A reference is a pointer; serializing it (it holds no value) is safe.
300        let json = serde_json::to_string(&r).unwrap();
301        assert!(json.contains("\"mode\":\"reference\""));
302        assert!(json.contains("azure-kv://corp-kv/db-url"));
303        assert!(!json.contains("\"value\""));
304    }
305
306    #[test]
307    fn revealable_defaults_false_on_legacy_records() {
308        // A pre-L9 vault record has no `revealable` key; it must deserialize to
309        // the safe default (`false`) rather than failing — additive schema.
310        let legacy = r#"{
311            "mode":"literal","value":[104,111,108,97],
312            "sensitivity":"medium","environment":"dev",
313            "component":"app","key":"token",
314            "created":"2026-05-30T00:00:00Z","updated":"2026-05-30T00:00:00Z"
315        }"#;
316        let rec: SecretRecord = serde_json::from_str(legacy).unwrap();
317        assert!(!rec.revealable());
318        assert_eq!(rec.sensitivity(), Sensitivity::Medium);
319        assert_eq!(rec.environment(), "dev");
320    }
321
322    #[test]
323    fn revealable_round_trips_when_set() {
324        let rec = SecretRecord::Literal {
325            value: SecretValue::from("v"),
326            sensitivity: Sensitivity::Medium,
327            revealable: true,
328            environment: "dev".to_string(),
329            component: "app".to_string(),
330            key: "token".to_string(),
331            description: None,
332            created: "2026-05-30T00:00:00Z".to_string(),
333            updated: "2026-05-30T00:00:00Z".to_string(),
334        };
335        let json = serde_json::to_string(&rec).unwrap();
336        assert!(json.contains("\"revealable\":true"));
337        let back: SecretRecord = serde_json::from_str(&json).unwrap();
338        assert!(back.revealable());
339    }
340
341    fn keypair(private: Option<&str>) -> SecretRecord {
342        SecretRecord::Keypair {
343            algorithm: KeyAlgorithm::Ed25519,
344            private: private.map(SecretValue::from),
345            public: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 test".to_string(),
346            sensitivity: Sensitivity::High,
347            revealable: false,
348            environment: "prod".to_string(),
349            component: "ssh".to_string(),
350            key: "deploy".to_string(),
351            description: None,
352            created: "2026-06-01T00:00:00Z".to_string(),
353            updated: "2026-06-01T00:00:00Z".to_string(),
354        }
355    }
356
357    #[test]
358    fn keypair_private_debug_is_redacted() {
359        // The private half is a SecretValue, so its Debug never prints the bytes.
360        let dbg = format!("{:?}", keypair(Some("PRIVATE-KEY-MATERIAL")));
361        assert!(dbg.contains("REDACTED"));
362        assert!(!dbg.contains("PRIVATE-KEY-MATERIAL"));
363        // The public half is not a secret and is shown.
364        assert!(dbg.contains("ssh-ed25519"));
365    }
366
367    #[test]
368    fn keypair_accessors_and_public_only_round_trip() {
369        let full = keypair(Some("priv"));
370        assert_eq!(full.sensitivity(), Sensitivity::High);
371        assert_eq!(full.environment(), "prod");
372        assert!(!full.revealable());
373
374        // A public-only entry serializes without a `private` field.
375        let public_only = keypair(None);
376        let json = serde_json::to_string(&public_only).unwrap();
377        assert!(json.contains("\"mode\":\"keypair\""));
378        assert!(!json.contains("\"private\""));
379        let back: SecretRecord = serde_json::from_str(&json).unwrap();
380        assert_eq!(back, public_only);
381    }
382
383    fn totp() -> SecretRecord {
384        SecretRecord::Totp {
385            seed: SecretValue::from("TOTP-SEED-MATERIAL"),
386            algorithm: TotpAlgorithm::Sha1,
387            digits: 6,
388            period: 30,
389            sensitivity: Sensitivity::High,
390            revealable: false,
391            environment: "prod".to_string(),
392            component: "auth".to_string(),
393            key: "mfa".to_string(),
394            description: None,
395            created: "2026-06-01T00:00:00Z".to_string(),
396            updated: "2026-06-01T00:00:00Z".to_string(),
397        }
398    }
399
400    // I12 — the seed is a SecretValue, so its Debug never prints the bytes; the
401    // non-secret params (algorithm/digits/period) are shown.
402    #[test]
403    fn totp_seed_debug_is_redacted() {
404        let dbg = format!("{:?}", totp());
405        assert!(dbg.contains("REDACTED"));
406        assert!(!dbg.contains("TOTP-SEED-MATERIAL"));
407        assert!(dbg.contains("Sha1"));
408    }
409
410    #[test]
411    fn totp_accessors_and_round_trip() {
412        let t = totp();
413        assert_eq!(t.sensitivity(), Sensitivity::High);
414        assert_eq!(t.environment(), "prod");
415        assert!(!t.revealable());
416        let json = serde_json::to_string(&t).unwrap();
417        assert!(json.contains("\"mode\":\"totp\""));
418        // The seed serializes (only ever into the buffer that is AEAD-sealed);
419        // the params are present as plain numbers.
420        assert!(json.contains("\"digits\":6"));
421        assert!(json.contains("\"period\":30"));
422        let back: SecretRecord = serde_json::from_str(&json).unwrap();
423        assert_eq!(back, t);
424    }
425
426    #[test]
427    fn default_vault_is_empty_and_versioned() {
428        let v = Vault::default();
429        assert_eq!(v.schema_version, SCHEMA_VERSION);
430        assert!(v.secrets.is_empty());
431    }
432}