Skip to main content

kovra_core/
package.rs

1//! Encrypted package + access token — offline non-prod secret sharing (L7,
2//! KOV-21; spec §7, §17 L7; invariants I4a/I4b/I8/I12).
3//!
4//! A **package** lets a developer hand a bundle of non-production secrets to a
5//! peer (or to another of their own machines) without putting a vault master
6//! key on the recipient. It is a sealed `age` box (X25519 under the hood, via
7//! the existing [`crate::keypair::encrypt_to`] ed25519-recipient path) plus a
8//! small cleartext header carrying the package's `expires_at`. The sealed
9//! payload is a list of [`SecretRecord`]s — **every** modality (literal,
10//! reference, keypair, totp) travels, each sealed exactly as it lives in the
11//! vault. A `Reference` carries only its pointer URI; its value is **never**
12//! resolved at packaging time and is materialized by the recipient's own
13//! provider identity or not at all (I8).
14//!
15//! A separate **access token** authorizes *unattended* consumption of the
16//! package's `high` entries on a machine with no human to confirm (§7.2). It is
17//! a distinct artifact, delivered over a second channel. Possession of the
18//! package alone is **not** enough to mint a token: `seal` embeds a
19//! `token_commitment = BLAKE3(token_secret)` *inside the sealed payload*, so an
20//! unattended open requires **both** the recipient identity (factor 1 — to
21//! decrypt and read the commitment) **and** the separately delivered token
22//! secret (factor 2 — the preimage of that commitment). The token's TTL equals
23//! the package's `expires_at` (one clock, one expiry — rotation/expiry are a
24//! single event; regenerating the package mints a new token).
25//!
26//! ## What this module enforces
27//! - **I4a** — a `prod` secret may not be packaged. [`seal`] refuses a payload
28//!   containing any `prod` entry with an explicit error naming the coordinate;
29//!   it is never silently omitted.
30//! - **I4b** — a `prod` secret may not be delivered via the unattended token.
31//!   [`open_unattended`] re-checks every decrypted entry (defense in depth: even
32//!   a forged package that bypassed I4a cannot yield `prod` under a token).
33//! - **I8** — references travel as pointers; the provider is never invoked here.
34//! - **I12** — no plaintext value is ever logged or placed in an error. Crypto
35//!   failures are opaque ([`CoreError::Package`]); the secret-bearing types defer
36//!   their `Debug` to [`SecretValue`]/[`SecretRecord`], which redact.
37//!
38//! ## Honest limits (ADR-07)
39//! The package is **confidentiality only**. The `age` AEAD tag gives integrity
40//! (a tampered package fails to open), but there is **no signing** — a package
41//! carries no proof of origin. Recipient identities are **ed25519** (the closed
42//! encryption decision; [`crate::keypair::encrypt_to`] rejects RSA recipients).
43
44use rand::RngCore;
45use serde::{Deserialize, Serialize};
46use zeroize::Zeroizing;
47
48use crate::clock::Clock;
49use crate::confirm::{ConfirmOutcome, ConfirmRequest, Confirmer};
50use crate::error::CoreError;
51use crate::keypair;
52use crate::policy;
53use crate::record::SecretRecord;
54use crate::secret::SecretValue;
55
56/// Current package payload / on-the-wire schema version.
57pub const PACKAGE_SCHEMA_VERSION: u32 = 1;
58
59/// Magic prefixing a serialized [`Package`] frame. Lets a reader reject a
60/// foreign/garbage file before attempting decryption.
61pub const PACKAGE_MAGIC: &[u8; 4] = b"KVPK";
62
63/// Bytes of the random token secret minted per package.
64const TOKEN_SECRET_LEN: usize = 32;
65
66const HEADER_LEN: usize = 4 + 4 + 8; // magic + u32 version + u64 expires_at
67
68/// The plaintext that gets sealed into a [`Package`].
69///
70/// `Debug` is safe: the only secret-bearing material lives inside the `entries`
71/// (a literal's `value`, a keypair's `private`, a totp's `seed`), all
72/// [`SecretValue`]s whose `Debug` is redacted (I12). The `token_commitment` is a
73/// BLAKE3 **hash** of the token secret — not the secret itself.
74#[derive(Debug, Serialize, Deserialize)]
75pub struct PackagePayload {
76    /// Schema version of this payload.
77    pub schema_version: u32,
78    /// The environment scope the package was cut for (e.g. `dev`). All entries
79    /// share it; never `prod` (I4a).
80    pub environment: String,
81    /// RFC-3339 creation timestamp (provenance metadata, not a secret).
82    pub created: String,
83    /// Expiry as Unix seconds. Mirrored in the cleartext [`Package`] header so a
84    /// reader can reject an expired package before attempting decryption.
85    pub expires_at: u64,
86    /// `BLAKE3(token_secret)` — the commitment an unattended open checks the
87    /// presented token secret against (factor 2). Set by [`seal`].
88    pub token_commitment: String,
89    /// The packaged records, each in its stored modality. Literals carry their
90    /// value; references carry **only** the pointer URI (I8); keypair/totp carry
91    /// their sealed private half / seed.
92    pub entries: Vec<SecretRecord>,
93}
94
95impl PackagePayload {
96    /// Build a payload for `environment`, expiring at `expires_at` (Unix secs),
97    /// over `entries`. `token_commitment` is left empty; [`seal`] fills it.
98    pub fn new(
99        environment: impl Into<String>,
100        created: impl Into<String>,
101        expires_at: u64,
102        entries: Vec<SecretRecord>,
103    ) -> Self {
104        Self {
105            schema_version: PACKAGE_SCHEMA_VERSION,
106            environment: environment.into(),
107            created: created.into(),
108            expires_at,
109            token_commitment: String::new(),
110            entries,
111        }
112    }
113}
114
115/// The on-the-wire package: a cleartext header + the `age`-sealed payload bytes.
116///
117/// The sealed bytes are an `age` ciphertext of the JSON-serialized
118/// [`PackagePayload`]; only the recipient's ed25519 private key opens them. The
119/// header (magic, version, `expires_at`) is cleartext so a reader can reject an
120/// expired or foreign package without the recipient key.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct Package {
123    /// Schema version.
124    pub version: u32,
125    /// Expiry (Unix seconds), mirrored from the sealed payload.
126    pub expires_at: u64,
127    /// The `age`-sealed payload bytes (ciphertext; never plaintext).
128    sealed: Vec<u8>,
129}
130
131impl Package {
132    /// Construct a package from already-sealed bytes (used by [`seal`] and by
133    /// readers reconstructing from [`Package::from_bytes`]).
134    fn new(version: u32, expires_at: u64, sealed: Vec<u8>) -> Self {
135        Self {
136            version,
137            expires_at,
138            sealed,
139        }
140    }
141
142    /// The package fingerprint: a full BLAKE3 hex of the sealed ciphertext. Used
143    /// to bind an [`AccessToken`] to exactly this package. The input is
144    /// ciphertext, not a value, so the full hash is safe to surface (I12).
145    pub fn fingerprint(&self) -> String {
146        blake3::hash(&self.sealed).to_hex().to_string()
147    }
148
149    /// Serialize to the on-disk frame: magic + version + `expires_at` + sealed.
150    pub fn to_bytes(&self) -> Vec<u8> {
151        let mut out = Vec::with_capacity(HEADER_LEN + self.sealed.len());
152        out.extend_from_slice(PACKAGE_MAGIC);
153        out.extend_from_slice(&self.version.to_le_bytes());
154        out.extend_from_slice(&self.expires_at.to_le_bytes());
155        out.extend_from_slice(&self.sealed);
156        out
157    }
158
159    /// Parse an on-disk frame back into a package, validating the header. Does
160    /// **not** decrypt — that needs the recipient identity (see [`open_attended`]).
161    pub fn from_bytes(bytes: &[u8]) -> Result<Self, CoreError> {
162        if bytes.len() < HEADER_LEN || &bytes[..4] != PACKAGE_MAGIC {
163            return Err(CoreError::Package("not a kovra package frame".to_string()));
164        }
165        let version = u32::from_le_bytes(bytes[4..8].try_into().expect("checked length"));
166        if version != PACKAGE_SCHEMA_VERSION {
167            return Err(CoreError::Package(format!(
168                "unsupported package version {version}"
169            )));
170        }
171        let expires_at = u64::from_le_bytes(bytes[8..16].try_into().expect("checked length"));
172        Ok(Self::new(version, expires_at, bytes[HEADER_LEN..].to_vec()))
173    }
174}
175
176/// A bearer access token authorizing unattended consumption of one package
177/// (§7.2). Bound to its package by `package_fingerprint`, time-boxed by
178/// `expires_at`, and proven by `secret` (whose BLAKE3 matches the
179/// `token_commitment` sealed inside the package payload).
180///
181/// `Debug` is safe: `secret` is a [`SecretValue`] (redacted); the fingerprint
182/// and expiry are not secrets.
183#[derive(Debug, Serialize, Deserialize)]
184pub struct AccessToken {
185    /// Schema version.
186    pub version: u32,
187    /// Full BLAKE3 hex of the package's sealed bytes — binds this token to
188    /// exactly one package.
189    pub package_fingerprint: String,
190    /// Expiry (Unix seconds) — equals the package `expires_at`.
191    pub expires_at: u64,
192    /// The random token secret (factor 2). Serialized into the token artifact
193    /// (which IS this credential), never into the package.
194    pub secret: SecretValue,
195}
196
197impl AccessToken {
198    /// Serialize the token to its artifact bytes (JSON). This file IS the
199    /// bearer credential — deliver it over a channel separate from the package.
200    pub fn to_bytes(&self) -> Result<Vec<u8>, CoreError> {
201        serde_json::to_vec(self).map_err(|e| CoreError::Serialization(e.to_string()))
202    }
203
204    /// Parse a token artifact produced by [`AccessToken::to_bytes`].
205    pub fn from_bytes(bytes: &[u8]) -> Result<Self, CoreError> {
206        serde_json::from_slice(bytes).map_err(|e| CoreError::Serialization(e.to_string()))
207    }
208}
209
210/// Seal `payload` to an ed25519 recipient, returning the package and a freshly
211/// minted [`AccessToken`] bound to it.
212///
213/// **I4a**: refuses (with an explicit error naming the coordinate) if any entry
214/// is a `prod` secret — a `prod` value never reaches the sealed bytes.
215///
216/// `recipient_public_openssh` must be an **ed25519** OpenSSH public key;
217/// [`crate::keypair::encrypt_to`] rejects RSA (encryption is ed25519-only).
218pub fn seal(
219    mut payload: PackagePayload,
220    recipient_public_openssh: &str,
221) -> Result<(Package, AccessToken), CoreError> {
222    // I4a — refuse to package any prod secret. Checked here in core so no face
223    // can bypass it; the value never enters the sealed buffer.
224    for entry in &payload.entries {
225        if policy::prod_not_packageable(entry.environment()) {
226            return Err(CoreError::Package(format!(
227                "refusing to package prod secret `{}` (I4a: prod is never packaged)",
228                entry.canonical_path()
229            )));
230        }
231    }
232
233    // Mint the token secret and commit to it inside the (sealed) payload, so an
234    // unattended open requires the separately-delivered secret (factor 2).
235    let mut secret = Zeroizing::new(vec![0u8; TOKEN_SECRET_LEN]);
236    rand::rngs::OsRng.fill_bytes(&mut secret);
237    payload.token_commitment = blake3::hash(&secret).to_hex().to_string();
238    payload.schema_version = PACKAGE_SCHEMA_VERSION;
239
240    let expires_at = payload.expires_at;
241    // The serialized payload is plaintext (it contains the secret values) and is
242    // wiped as soon as it has been sealed — it is only ever fed to `encrypt_to`.
243    let plaintext = Zeroizing::new(
244        serde_json::to_vec(&payload).map_err(|e| CoreError::Serialization(e.to_string()))?,
245    );
246    let sealed = keypair::encrypt_to(recipient_public_openssh, &plaintext)?;
247    let package = Package::new(PACKAGE_SCHEMA_VERSION, expires_at, sealed);
248
249    let token = AccessToken {
250        version: PACKAGE_SCHEMA_VERSION,
251        package_fingerprint: package.fingerprint(),
252        expires_at,
253        secret: SecretValue::new(secret.to_vec()),
254    };
255    Ok((package, token))
256}
257
258/// Open a package **attended** — decrypt with the recipient ed25519 private key
259/// after checking the package has not expired.
260///
261/// Returns the full payload (literals as values, references as pointers the
262/// recipient must still materialize with its own identity — I8). This is the
263/// path a human drives; `high` entries are gated by the caller's broker.
264pub fn open_attended(
265    package: &Package,
266    recipient_private_openssh: &str,
267    clock: &dyn Clock,
268) -> Result<PackagePayload, CoreError> {
269    if clock.unix_secs() > package.expires_at {
270        return Err(CoreError::Package("package has expired".to_string()));
271    }
272    let plaintext = keypair::decrypt(recipient_private_openssh, &package.sealed)?;
273    let payload: PackagePayload =
274        serde_json::from_slice(&plaintext).map_err(|e| CoreError::Serialization(e.to_string()))?;
275    Ok(payload)
276}
277
278/// Open a package **unattended** — decrypt with the recipient identity (the
279/// first factor) and authorize delivery with a valid `token` (the second
280/// factor). This is the only path that yields `high` entries on a machine with
281/// no human to confirm (§7.2).
282///
283/// Rejects when: the package or token has expired; the token is not bound to
284/// this package; the token secret does not match the sealed commitment; or —
285/// **I4b**, defense in depth — any decrypted entry is a `prod` secret (a forged
286/// package that bypassed I4a still cannot yield `prod` under a token).
287pub fn open_unattended(
288    package: &Package,
289    token: &AccessToken,
290    recipient_private_openssh: &str,
291    clock: &dyn Clock,
292) -> Result<PackagePayload, CoreError> {
293    let payload = open_attended(package, recipient_private_openssh, clock)?;
294    verify_token(package, &payload, token, clock)?;
295    enforce_no_prod_unattended(&payload)?;
296    Ok(payload)
297}
298
299/// Verify a token against a decrypted package: not expired, bound to this
300/// package, and the secret matches the sealed commitment. Errors are opaque
301/// (I12) — they name *why* the token is rejected, never any value.
302pub fn verify_token(
303    package: &Package,
304    payload: &PackagePayload,
305    token: &AccessToken,
306    clock: &dyn Clock,
307) -> Result<(), CoreError> {
308    if clock.unix_secs() > token.expires_at {
309        return Err(CoreError::Package("access token has expired".to_string()));
310    }
311    if token.package_fingerprint != package.fingerprint() {
312        return Err(CoreError::Package(
313            "access token does not match this package".to_string(),
314        ));
315    }
316    let presented = blake3::hash(token.secret.expose()).to_hex().to_string();
317    if presented != payload.token_commitment {
318        return Err(CoreError::Package(
319            "access token secret is not valid for this package".to_string(),
320        ));
321    }
322    Ok(())
323}
324
325/// I4b — refuse if any entry is a `prod` secret. Used by [`open_unattended`] and
326/// exposed so a face can pre-check a decrypted payload before unattended use.
327pub fn enforce_no_prod_unattended(payload: &PackagePayload) -> Result<(), CoreError> {
328    for entry in &payload.entries {
329        if policy::prod_blocks_unattended(entry.environment()) {
330            return Err(CoreError::Package(format!(
331                "prod secret `{}` cannot be delivered unattended (I4b)",
332                entry.canonical_path()
333            )));
334        }
335    }
336    Ok(())
337}
338
339/// A [`Confirmer`] backed by an access token — the third broker impl beside
340/// `CliApproveConfirmer`/`BiometricConfirmer`. It lets the unattended consume
341/// path funnel `high` entries through the **same** broker abstraction the
342/// attended path uses: a valid, unexpired, package-bound token approves; an
343/// invalid one denies. It never blocks and never prompts.
344pub struct TokenConfirmer {
345    approved: bool,
346}
347
348impl TokenConfirmer {
349    /// Build a confirmer that approves iff `token` is valid for `package`/
350    /// `payload` at `clock` (see [`verify_token`]).
351    pub fn new(
352        package: &Package,
353        payload: &PackagePayload,
354        token: &AccessToken,
355        clock: &dyn Clock,
356    ) -> Self {
357        Self {
358            approved: verify_token(package, payload, token, clock).is_ok(),
359        }
360    }
361}
362
363impl Confirmer for TokenConfirmer {
364    fn confirm(&self, _req: &ConfirmRequest, _timeout: std::time::Duration) -> ConfirmOutcome {
365        if self.approved {
366            ConfirmOutcome::Approved
367        } else {
368            ConfirmOutcome::Denied
369        }
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use crate::clock::MockClock;
377    use crate::keypair::{KeyAlgorithm, generate};
378    use crate::sensitivity::Sensitivity;
379
380    const HOUR: u64 = 3600;
381
382    fn now() -> u64 {
383        MockClock::default().unix_secs()
384    }
385
386    fn literal(env: &str, key: &str, value: &str) -> SecretRecord {
387        SecretRecord::Literal {
388            value: SecretValue::from(value),
389            sensitivity: Sensitivity::Medium,
390            revealable: false,
391            environment: env.to_string(),
392            component: "app".to_string(),
393            key: key.to_string(),
394            description: None,
395            created: "2026-05-30T00:00:00Z".to_string(),
396            updated: "2026-05-30T00:00:00Z".to_string(),
397        }
398    }
399
400    fn reference(env: &str, key: &str, uri: &str) -> SecretRecord {
401        SecretRecord::Reference {
402            reference: uri.to_string(),
403            sensitivity: Sensitivity::Medium,
404            revealable: false,
405            environment: env.to_string(),
406            component: "app".to_string(),
407            key: key.to_string(),
408            description: None,
409            created: "2026-05-30T00:00:00Z".to_string(),
410            updated: "2026-05-30T00:00:00Z".to_string(),
411        }
412    }
413
414    fn payload(entries: Vec<SecretRecord>) -> PackagePayload {
415        PackagePayload::new("dev", "2026-05-30T00:00:00Z", now() + HOUR, entries)
416    }
417
418    // Round-trip (exit criterion): seal then open_attended with the matching
419    // identity returns the same literal values; a wrong identity fails to open.
420    #[test]
421    fn seal_open_round_trips_and_wrong_identity_fails() {
422        let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
423        let clock = MockClock::default();
424        let (package, _token) = seal(
425            payload(vec![literal("dev", "token", "s3cr3t-dev-value")]),
426            &recipient.public_openssh,
427        )
428        .unwrap();
429
430        let opened = open_attended(&package, &recipient.private_openssh, &clock).unwrap();
431        match &opened.entries[0] {
432            SecretRecord::Literal { value, .. } => assert_eq!(value.expose(), b"s3cr3t-dev-value"),
433            other => panic!("expected literal, got {other:?}"),
434        }
435
436        // A different recipient cannot open it (no plaintext leak).
437        let other = generate(KeyAlgorithm::Ed25519).unwrap();
438        assert!(open_attended(&package, &other.private_openssh, &clock).is_err());
439    }
440
441    // All four modalities survive the round-trip — keypair/totp included.
442    #[test]
443    fn all_modalities_round_trip() {
444        let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
445        let clock = MockClock::default();
446        let shared = generate(KeyAlgorithm::Ed25519).unwrap();
447        let entries = vec![
448            literal("dev", "db", "db-pass"),
449            reference("dev", "api", "azure-kv://corp-kv/api-key"),
450            SecretRecord::Keypair {
451                algorithm: KeyAlgorithm::Ed25519,
452                private: Some(SecretValue::from(shared.private_openssh.as_str())),
453                public: shared.public_openssh.clone(),
454                sensitivity: Sensitivity::High,
455                revealable: false,
456                environment: "dev".to_string(),
457                component: "ssh".to_string(),
458                key: "deploy".to_string(),
459                description: None,
460                created: "2026-05-30T00:00:00Z".to_string(),
461                updated: "2026-05-30T00:00:00Z".to_string(),
462            },
463            SecretRecord::Totp {
464                seed: SecretValue::from("totp-seed-bytes"),
465                algorithm: crate::totp::TotpAlgorithm::Sha1,
466                digits: 6,
467                period: 30,
468                sensitivity: Sensitivity::High,
469                revealable: false,
470                environment: "dev".to_string(),
471                component: "auth".to_string(),
472                key: "mfa".to_string(),
473                description: None,
474                created: "2026-05-30T00:00:00Z".to_string(),
475                updated: "2026-05-30T00:00:00Z".to_string(),
476            },
477        ];
478        let (package, _token) = seal(payload(entries), &recipient.public_openssh).unwrap();
479        let opened = open_attended(&package, &recipient.private_openssh, &clock).unwrap();
480        assert_eq!(opened.entries.len(), 4);
481        // The keypair private half survived sealed.
482        match &opened.entries[2] {
483            SecretRecord::Keypair { private, .. } => {
484                assert_eq!(
485                    private.as_ref().unwrap().expose(),
486                    shared.private_openssh.as_bytes()
487                );
488            }
489            other => panic!("expected keypair, got {other:?}"),
490        }
491        // The totp seed survived sealed.
492        match &opened.entries[3] {
493            SecretRecord::Totp { seed, .. } => assert_eq!(seed.expose(), b"totp-seed-bytes"),
494            other => panic!("expected totp, got {other:?}"),
495        }
496    }
497
498    // I4a — packaging a prod secret fails with an explicit error naming the
499    // coordinate, and the prod value never reaches the sealed bytes.
500    #[test]
501    fn i4a_packaging_a_prod_secret_is_refused() {
502        let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
503        let entries = vec![
504            literal("dev", "ok", "fine"),
505            literal("prod", "db", "prod-only-value"),
506        ];
507        let err = seal(payload(entries), &recipient.public_openssh).unwrap_err();
508        match err {
509            CoreError::Package(msg) => {
510                assert!(msg.contains("prod/app/db"), "names the coordinate: {msg}");
511                assert!(msg.contains("I4a"));
512                assert!(
513                    !msg.contains("prod-only-value"),
514                    "error must not carry the value"
515                );
516            }
517            other => panic!("expected Package error, got {other:?}"),
518        }
519    }
520
521    // I4b — a (forged) package containing a prod entry cannot be consumed via a
522    // valid token. We seal it bypassing the I4a check to simulate a hand-crafted
523    // package, then assert open_unattended refuses on I4b even with a good token.
524    #[test]
525    fn i4b_prod_entry_refused_under_a_valid_token() {
526        let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
527        let clock = MockClock::default();
528        // Forge a package with a prod entry by sealing the payload directly,
529        // bypassing `seal`'s I4a gate (simulating a maliciously crafted package).
530        let (package, token) =
531            seal_forged_with_prod(&recipient.public_openssh, now() + HOUR).unwrap();
532
533        // The token is valid (right package, right secret, unexpired)…
534        let payload = open_attended(&package, &recipient.private_openssh, &clock).unwrap();
535        assert!(verify_token(&package, &payload, &token, &clock).is_ok());
536
537        // …yet unattended open still refuses, on I4b.
538        let err =
539            open_unattended(&package, &token, &recipient.private_openssh, &clock).unwrap_err();
540        match err {
541            CoreError::Package(msg) => {
542                assert!(msg.contains("I4b"), "I4b denial: {msg}");
543                assert!(msg.contains("prod/app/secret"));
544            }
545            other => panic!("expected Package error, got {other:?}"),
546        }
547    }
548
549    /// Seal a payload containing a prod entry, bypassing the I4a gate in `seal`.
550    /// Test-only: simulates a package a non-kovra (or compromised) tool produced.
551    fn seal_forged_with_prod(
552        recipient_public_openssh: &str,
553        expires_at: u64,
554    ) -> Result<(Package, AccessToken), CoreError> {
555        let mut p = PackagePayload::new(
556            "prod",
557            "2026-05-30T00:00:00Z",
558            expires_at,
559            vec![literal("prod", "secret", "forged-prod-value")],
560        );
561        let mut secret = vec![0u8; TOKEN_SECRET_LEN];
562        rand::rngs::OsRng.fill_bytes(&mut secret);
563        p.token_commitment = blake3::hash(&secret).to_hex().to_string();
564        let plaintext = serde_json::to_vec(&p).unwrap();
565        let sealed = keypair::encrypt_to(recipient_public_openssh, &plaintext)?;
566        let package = Package::new(PACKAGE_SCHEMA_VERSION, expires_at, sealed);
567        let token = AccessToken {
568            version: PACKAGE_SCHEMA_VERSION,
569            package_fingerprint: package.fingerprint(),
570            expires_at,
571            secret: SecretValue::new(secret),
572        };
573        Ok((package, token))
574    }
575
576    // I8 — a reference is packaged as its pointer only: the sealed payload holds
577    // the URI, never a materialized value, and `seal` invokes no provider (it
578    // takes none — there is no path to one).
579    #[test]
580    fn i8_reference_travels_as_pointer_only() {
581        let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
582        let clock = MockClock::default();
583        let (package, _token) = seal(
584            payload(vec![reference("dev", "api", "azure-kv://corp-kv/api-key")]),
585            &recipient.public_openssh,
586        )
587        .unwrap();
588        let opened = open_attended(&package, &recipient.private_openssh, &clock).unwrap();
589        match &opened.entries[0] {
590            SecretRecord::Reference { reference, .. } => {
591                assert_eq!(reference, "azure-kv://corp-kv/api-key");
592            }
593            other => panic!("expected reference, got {other:?}"),
594        }
595        // The pointer is the address; there is no value field anywhere.
596        assert_eq!(
597            opened.entries[0].reference(),
598            Some("azure-kv://corp-kv/api-key")
599        );
600    }
601
602    // Token TTL: past expires_at, both attended and unattended opens reject, and
603    // a fingerprint-mismatched token is refused.
604    #[test]
605    fn token_ttl_and_fingerprint_binding() {
606        let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
607        let (package, token) = seal(
608            payload(vec![literal("dev", "k", "v")]),
609            &recipient.public_openssh,
610        )
611        .unwrap();
612
613        // Before expiry: the token verifies.
614        let early = MockClock::default();
615        let payload = open_attended(&package, &recipient.private_openssh, &early).unwrap();
616        assert!(verify_token(&package, &payload, &token, &early).is_ok());
617
618        // After expiry: package + token both reject.
619        let late = MockClock::at(now() + 2 * HOUR);
620        assert!(open_attended(&package, &recipient.private_openssh, &late).is_err());
621        assert!(verify_token(&package, &payload, &token, &late).is_err());
622
623        // A token minted for a different package does not match this one.
624        let (_other_pkg, other_token) =
625            seal(payload_for_other(), &recipient.public_openssh).unwrap();
626        assert!(verify_token(&package, &payload, &other_token, &early).is_err());
627    }
628
629    fn payload_for_other() -> PackagePayload {
630        PackagePayload::new(
631            "dev",
632            "2026-05-30T00:00:00Z",
633            now() + HOUR,
634            vec![literal("dev", "other", "other")],
635        )
636    }
637
638    // Two-factor: TokenConfirmer approves only with a valid token; a bad secret
639    // is denied (the attended path would instead prompt a human).
640    #[test]
641    fn token_confirmer_is_two_factor() {
642        let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
643        let clock = MockClock::default();
644        let (package, token) = seal(
645            payload(vec![literal("dev", "k", "v")]),
646            &recipient.public_openssh,
647        )
648        .unwrap();
649        let payload = open_attended(&package, &recipient.private_openssh, &clock).unwrap();
650
651        let good = TokenConfirmer::new(&package, &payload, &token, &clock);
652        assert!(
653            good.confirm(
654                &ConfirmRequest::new(
655                    "dev/app/k",
656                    Sensitivity::High,
657                    "dev",
658                    crate::scope::Origin::Human
659                ),
660                std::time::Duration::ZERO
661            )
662            .is_approved()
663        );
664
665        // A token whose secret does not match the sealed commitment is denied.
666        let forged = AccessToken {
667            version: PACKAGE_SCHEMA_VERSION,
668            package_fingerprint: package.fingerprint(),
669            expires_at: token.expires_at,
670            secret: SecretValue::from("not-the-real-secret"),
671        };
672        let bad = TokenConfirmer::new(&package, &payload, &forged, &clock);
673        assert_eq!(
674            bad.confirm(
675                &ConfirmRequest::new(
676                    "dev/app/k",
677                    Sensitivity::High,
678                    "dev",
679                    crate::scope::Origin::Human
680                ),
681                std::time::Duration::ZERO
682            ),
683            ConfirmOutcome::Denied
684        );
685    }
686
687    // Tamper: flipping a byte of the sealed ciphertext makes open fail (AEAD
688    // integrity — no signing needed).
689    #[test]
690    fn tampered_package_fails_to_open() {
691        let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
692        let clock = MockClock::default();
693        let (package, _token) = seal(
694            payload(vec![literal("dev", "k", "v")]),
695            &recipient.public_openssh,
696        )
697        .unwrap();
698        let mut bytes = package.to_bytes();
699        let last = bytes.len() - 1;
700        bytes[last] ^= 0xff;
701        let tampered = Package::from_bytes(&bytes).unwrap();
702        assert!(open_attended(&tampered, &recipient.private_openssh, &clock).is_err());
703    }
704
705    // I12 — neither the payload nor the token leak a secret through Debug, and
706    // the on-the-wire frame round-trips.
707    #[test]
708    fn debug_is_redacted_and_frame_round_trips() {
709        let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
710        let (package, token) = seal(
711            payload(vec![literal("dev", "k", "top-secret-literal")]),
712            &recipient.public_openssh,
713        )
714        .unwrap();
715
716        let opened = {
717            let clock = MockClock::default();
718            open_attended(&package, &recipient.private_openssh, &clock).unwrap()
719        };
720        let dbg = format!("{opened:?}");
721        assert!(dbg.contains("REDACTED"));
722        assert!(!dbg.contains("top-secret-literal"));
723
724        let token_dbg = format!("{token:?}");
725        assert!(token_dbg.contains("REDACTED"));
726
727        // The serialized package frame round-trips through from_bytes.
728        let back = Package::from_bytes(&package.to_bytes()).unwrap();
729        assert_eq!(back, package);
730
731        // The token artifact round-trips and preserves the secret.
732        let token2 = AccessToken::from_bytes(&token.to_bytes().unwrap()).unwrap();
733        assert_eq!(token2.secret.expose(), token.secret.expose());
734        assert_eq!(token2.package_fingerprint, token.package_fingerprint);
735    }
736
737    // A non-kovra/garbage file is rejected by the frame parser before any
738    // decryption is attempted.
739    #[test]
740    fn foreign_frame_is_rejected() {
741        assert!(Package::from_bytes(b"not a package").is_err());
742    }
743}