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}