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 RFC-3339 creation timestamp, regardless of modality.
229    pub fn created(&self) -> &str {
230        match self {
231            SecretRecord::Literal { created, .. }
232            | SecretRecord::Reference { created, .. }
233            | SecretRecord::Keypair { created, .. }
234            | SecretRecord::Totp { created, .. } => created,
235        }
236    }
237
238    /// The RFC-3339 last-updated timestamp, regardless of modality.
239    pub fn updated(&self) -> &str {
240        match self {
241            SecretRecord::Literal { updated, .. }
242            | SecretRecord::Reference { updated, .. }
243            | SecretRecord::Keypair { updated, .. }
244            | SecretRecord::Totp { updated, .. } => updated,
245        }
246    }
247
248    /// The external reference URI for a `Reference` record (e.g.
249    /// `azure-kv://vault/name`), or `None` for any other modality. Carries an
250    /// address, never a value.
251    pub fn reference(&self) -> Option<&str> {
252        match self {
253            SecretRecord::Reference { reference, .. } => Some(reference),
254            _ => None,
255        }
256    }
257}
258
259/// The persisted vault: a versioned map of record id → sealed record.
260///
261/// In L2 the id is `BLAKE3(coordinate)`; at this layer it is any opaque string.
262/// Every value lives inside a [`SealedRecord`], so this structure can be
263/// serialized freely without exposing plaintext.
264#[derive(Debug, PartialEq, Serialize, Deserialize)]
265pub struct Vault {
266    /// Schema version of this vault file.
267    pub schema_version: u32,
268    /// Sealed records keyed by opaque id.
269    pub secrets: BTreeMap<String, SealedRecord>,
270}
271
272impl Default for Vault {
273    fn default() -> Self {
274        Self {
275            schema_version: SCHEMA_VERSION,
276            secrets: BTreeMap::new(),
277        }
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn literal() -> SecretRecord {
286        SecretRecord::Literal {
287            value: SecretValue::from("hunter2"),
288            sensitivity: Sensitivity::High,
289            revealable: false,
290            environment: "prod".to_string(),
291            component: "db".to_string(),
292            key: "password".to_string(),
293            description: Some("primary db".to_string()),
294            created: "2026-05-30T00:00:00Z".to_string(),
295            updated: "2026-05-30T00:00:00Z".to_string(),
296        }
297    }
298
299    #[test]
300    fn literal_debug_is_redacted() {
301        let dbg = format!("{:?}", literal());
302        assert!(dbg.contains("REDACTED"));
303        assert!(!dbg.contains("hunter2"));
304    }
305
306    #[test]
307    fn reference_carries_no_value() {
308        let r = SecretRecord::Reference {
309            reference: "azure-kv://corp-kv/db-url".to_string(),
310            sensitivity: Sensitivity::High,
311            revealable: false,
312            environment: "prod".to_string(),
313            component: "db".to_string(),
314            key: "url".to_string(),
315            description: None,
316            created: "2026-05-30T00:00:00Z".to_string(),
317            updated: "2026-05-30T00:00:00Z".to_string(),
318        };
319        // A reference is a pointer; serializing it (it holds no value) is safe.
320        let json = serde_json::to_string(&r).unwrap();
321        assert!(json.contains("\"mode\":\"reference\""));
322        assert!(json.contains("azure-kv://corp-kv/db-url"));
323        assert!(!json.contains("\"value\""));
324    }
325
326    #[test]
327    fn revealable_defaults_false_on_legacy_records() {
328        // A pre-L9 vault record has no `revealable` key; it must deserialize to
329        // the safe default (`false`) rather than failing — additive schema.
330        let legacy = r#"{
331            "mode":"literal","value":[104,111,108,97],
332            "sensitivity":"medium","environment":"dev",
333            "component":"app","key":"token",
334            "created":"2026-05-30T00:00:00Z","updated":"2026-05-30T00:00:00Z"
335        }"#;
336        let rec: SecretRecord = serde_json::from_str(legacy).unwrap();
337        assert!(!rec.revealable());
338        assert_eq!(rec.sensitivity(), Sensitivity::Medium);
339        assert_eq!(rec.environment(), "dev");
340    }
341
342    #[test]
343    fn revealable_round_trips_when_set() {
344        let rec = SecretRecord::Literal {
345            value: SecretValue::from("v"),
346            sensitivity: Sensitivity::Medium,
347            revealable: true,
348            environment: "dev".to_string(),
349            component: "app".to_string(),
350            key: "token".to_string(),
351            description: None,
352            created: "2026-05-30T00:00:00Z".to_string(),
353            updated: "2026-05-30T00:00:00Z".to_string(),
354        };
355        let json = serde_json::to_string(&rec).unwrap();
356        assert!(json.contains("\"revealable\":true"));
357        let back: SecretRecord = serde_json::from_str(&json).unwrap();
358        assert!(back.revealable());
359    }
360
361    fn keypair(private: Option<&str>) -> SecretRecord {
362        SecretRecord::Keypair {
363            algorithm: KeyAlgorithm::Ed25519,
364            private: private.map(SecretValue::from),
365            public: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 test".to_string(),
366            sensitivity: Sensitivity::High,
367            revealable: false,
368            environment: "prod".to_string(),
369            component: "ssh".to_string(),
370            key: "deploy".to_string(),
371            description: None,
372            created: "2026-06-01T00:00:00Z".to_string(),
373            updated: "2026-06-01T00:00:00Z".to_string(),
374        }
375    }
376
377    #[test]
378    fn keypair_private_debug_is_redacted() {
379        // The private half is a SecretValue, so its Debug never prints the bytes.
380        let dbg = format!("{:?}", keypair(Some("PRIVATE-KEY-MATERIAL")));
381        assert!(dbg.contains("REDACTED"));
382        assert!(!dbg.contains("PRIVATE-KEY-MATERIAL"));
383        // The public half is not a secret and is shown.
384        assert!(dbg.contains("ssh-ed25519"));
385    }
386
387    #[test]
388    fn keypair_accessors_and_public_only_round_trip() {
389        let full = keypair(Some("priv"));
390        assert_eq!(full.sensitivity(), Sensitivity::High);
391        assert_eq!(full.environment(), "prod");
392        assert!(!full.revealable());
393
394        // A public-only entry serializes without a `private` field.
395        let public_only = keypair(None);
396        let json = serde_json::to_string(&public_only).unwrap();
397        assert!(json.contains("\"mode\":\"keypair\""));
398        assert!(!json.contains("\"private\""));
399        let back: SecretRecord = serde_json::from_str(&json).unwrap();
400        assert_eq!(back, public_only);
401    }
402
403    fn totp() -> SecretRecord {
404        SecretRecord::Totp {
405            seed: SecretValue::from("TOTP-SEED-MATERIAL"),
406            algorithm: TotpAlgorithm::Sha1,
407            digits: 6,
408            period: 30,
409            sensitivity: Sensitivity::High,
410            revealable: false,
411            environment: "prod".to_string(),
412            component: "auth".to_string(),
413            key: "mfa".to_string(),
414            description: None,
415            created: "2026-06-01T00:00:00Z".to_string(),
416            updated: "2026-06-01T00:00:00Z".to_string(),
417        }
418    }
419
420    // I12 — the seed is a SecretValue, so its Debug never prints the bytes; the
421    // non-secret params (algorithm/digits/period) are shown.
422    #[test]
423    fn totp_seed_debug_is_redacted() {
424        let dbg = format!("{:?}", totp());
425        assert!(dbg.contains("REDACTED"));
426        assert!(!dbg.contains("TOTP-SEED-MATERIAL"));
427        assert!(dbg.contains("Sha1"));
428    }
429
430    #[test]
431    fn totp_accessors_and_round_trip() {
432        let t = totp();
433        assert_eq!(t.sensitivity(), Sensitivity::High);
434        assert_eq!(t.environment(), "prod");
435        assert!(!t.revealable());
436        let json = serde_json::to_string(&t).unwrap();
437        assert!(json.contains("\"mode\":\"totp\""));
438        // The seed serializes (only ever into the buffer that is AEAD-sealed);
439        // the params are present as plain numbers.
440        assert!(json.contains("\"digits\":6"));
441        assert!(json.contains("\"period\":30"));
442        let back: SecretRecord = serde_json::from_str(&json).unwrap();
443        assert_eq!(back, t);
444    }
445
446    #[test]
447    fn default_vault_is_empty_and_versioned() {
448        let v = Vault::default();
449        assert_eq!(v.schema_version, SCHEMA_VERSION);
450        assert!(v.secrets.is_empty());
451    }
452}