Skip to main content

vta_cli_common/
sealed_consumer.rs

1//! CLI-side consumer helpers for `vta_sdk::sealed_transfer`.
2//!
3//! Generates ephemeral Ed25519 keypairs (exposed as `did:key` on the wire)
4//! and persists the seed under `<config_dir>/bootstrap-secrets/<bundle_id>.key`
5//! (mode 0600 on Unix) so a subsequent open call can retrieve it. At open
6//! time the X25519 HPKE secret is derived from the seed via
7//! [`vta_sdk::sealed_transfer::ed25519_seed_to_x25519_secret`].
8//!
9//! The pnm-cli and cnm-cli bootstrap subcommands both route through this
10//! module — the only per-CLI concern is which `config_dir` to use.
11
12use std::fs;
13use std::io::Write;
14#[cfg(unix)]
15use std::os::unix::fs::OpenOptionsExt;
16use std::path::{Path, PathBuf};
17
18use vta_sdk::credentials::CredentialBundle;
19use vta_sdk::sealed_transfer::verify::{VerifiedAssertion, verify_producer_assertion_with_pubkey};
20use vta_sdk::sealed_transfer::{
21    AssertionProof, BootstrapRequest, SealedPayloadV1, armor, bundle_digest,
22    ed25519_seed_to_x25519_secret, generate_ed25519_keypair, open_bundle,
23};
24
25const SECRETS_SUBDIR: &str = "bootstrap-secrets";
26
27/// Resolve the per-config bootstrap secrets directory, creating it on first
28/// use with owner-only permissions (0700 on Unix, user-only DACL on
29/// Windows via `icacls`). See [`crate::secure_file::restrict_dir_to_owner`].
30pub fn secrets_dir(config_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
31    let dir = config_dir.join(SECRETS_SUBDIR);
32    if !dir.exists() {
33        fs::create_dir_all(&dir)?;
34        if let Err(e) = crate::secure_file::restrict_dir_to_owner(&dir) {
35            eprintln!(
36                "warning: could not restrict {} to owner ({e}) — contents may be \
37                 accessible to other local users",
38                dir.display()
39            );
40        }
41    }
42    Ok(dir)
43}
44
45fn secret_path(
46    config_dir: &Path,
47    bundle_id_hex: &str,
48) -> Result<PathBuf, Box<dyn std::error::Error>> {
49    Ok(secrets_dir(config_dir)?.join(format!("{bundle_id_hex}.key")))
50}
51
52fn write_secret(path: &Path, secret: &[u8; 32]) -> Result<(), Box<dyn std::error::Error>> {
53    let mut opts = fs::OpenOptions::new();
54    opts.create(true).write(true).truncate(true);
55    // Unix: open with 0600 atomically so the file is never publicly
56    // readable between create and chmod. Windows: we can't set a DACL
57    // at open time via `OpenOptions`, so the file briefly exists with
58    // the directory's inherited ACL (already owner-only courtesy of
59    // `secrets_dir`). Post-open we tighten via `restrict_file_to_owner`.
60    #[cfg(unix)]
61    opts.mode(0o600);
62    let mut file = opts.open(path)?;
63    file.write_all(secret)?;
64    drop(file);
65    if let Err(e) = crate::secure_file::restrict_file_to_owner(path) {
66        eprintln!(
67            "warning: could not restrict {} to owner ({e}) — secret may be readable by \
68             other local users",
69            path.display()
70        );
71    }
72    Ok(())
73}
74
75fn read_secret(path: &Path) -> Result<[u8; 32], Box<dyn std::error::Error>> {
76    let bytes = fs::read(path)?;
77    bytes
78        .as_slice()
79        .try_into()
80        .map_err(|_| format!("secret file {} is not 32 bytes", path.display()).into())
81}
82
83/// Overwrite a file's bytes with zeros, fsync, then unlink.
84///
85/// This is a best-effort forensic-resistance measure: on rotating media
86/// it overwrites the sectors that held the secret before we forget
87/// where they are. On modern SSDs with wear-levelling the write may be
88/// remapped rather than overwriting the physical cells — still no worse
89/// than plain unlink, and meaningfully better on the platforms where
90/// direct overwrite wins (HDDs, ramdisk, most filesystems on older
91/// kernels). Defence-in-depth, not a hard guarantee.
92///
93/// Errors at any step are non-fatal for the surrounding flow: the
94/// caller gets a `Result` so it can log, but the bundle has already
95/// been consumed. Callers typically print a warning and continue.
96pub fn zero_overwrite_and_remove(path: &Path) -> std::io::Result<()> {
97    // Stat for size *before* we open to write — truncating via OpenOptions
98    // would drop the old bytes before we get a chance to overwrite them.
99    let metadata = fs::metadata(path)?;
100    let len = metadata.len() as usize;
101
102    if len > 0 {
103        let mut file = fs::OpenOptions::new()
104            .write(true)
105            .truncate(false)
106            .open(path)?;
107        // Stream zeros rather than allocating a `vec![0u8; len]` — a
108        // single page buffer handles keys (32 B) and armored bundles
109        // (~KB) without surprises on tiny embedded targets.
110        const ZEROS: [u8; 4096] = [0u8; 4096];
111        let mut remaining = len;
112        while remaining > 0 {
113            let chunk = remaining.min(ZEROS.len());
114            file.write_all(&ZEROS[..chunk])?;
115            remaining -= chunk;
116        }
117        file.flush()?;
118        file.sync_all()?;
119    }
120
121    fs::remove_file(path)
122}
123
124/// The outcome of [`create_bootstrap_request`]: the serialized request body
125/// and the bundle id (for the `secret stored at <path>` banner).
126pub struct CreatedRequest {
127    pub request: BootstrapRequest,
128    pub bundle_id_hex: String,
129    pub secret_path: PathBuf,
130}
131
132/// Generate a fresh Ed25519 keypair + nonce, persist the **seed** (not the
133/// derived X25519 secret) under `config_dir`, and return a
134/// [`BootstrapRequest`] ready to hand to the producer.
135///
136/// Persisting the Ed25519 seed (rather than the X25519 secret) means the
137/// same stored material can later be reused as a signing identity without
138/// regenerating.
139pub fn create_bootstrap_request(
140    config_dir: &Path,
141    label: Option<String>,
142) -> Result<CreatedRequest, Box<dyn std::error::Error>> {
143    let (seed, public) = generate_ed25519_keypair();
144    let nonce: [u8; 16] = rand::random();
145    let bundle_id_hex = hex_lower(&nonce);
146    let sp = secret_path(config_dir, &bundle_id_hex)?;
147    write_secret(&sp, &seed)?;
148    let request = BootstrapRequest::new(public, nonce, label);
149    Ok(CreatedRequest {
150        request,
151        bundle_id_hex,
152        secret_path: sp,
153    })
154}
155
156/// The result of [`open_armored_bundle`] — the full sealed payload plus the
157/// producer assertion, ready for caller-specific trust verification.
158#[derive(Debug)]
159pub struct OpenedArmored {
160    pub payload: SealedPayloadV1,
161    pub producer: vta_sdk::sealed_transfer::ProducerAssertion,
162    pub bundle_id: [u8; 16],
163    pub bundle_id_hex: String,
164    pub digest: String,
165    /// Consumer's X25519 public key — the `client_x25519_pub` the
166    /// producer signed over in a `DidSigned` assertion. Derived from the
167    /// stored Ed25519 seed that opened the bundle
168    /// (`ed25519_pub_to_x25519_bytes(ed25519_pub)`), captured here
169    /// because the seed file is zeroized+removed on successful open.
170    ///
171    /// Downstream verification of the producer assertion feeds this
172    /// into
173    /// [`vta_sdk::sealed_transfer::verify::verify_producer_assertion_with_pubkey`].
174    pub client_x25519_pub: [u8; 32],
175}
176
177/// Read an armored sealed bundle from `bundle_path`, load the corresponding
178/// secret from `config_dir`, open and verify. The caller is responsible for
179/// passing an `expect_digest` unless `no_verify_digest` is set.
180///
181/// Best-effort removal of the used secret file on success — the bundle id is
182/// single-use, and keeping the secret around only widens blast radius.
183///
184/// This checks the digest when one is given, but not who produced the
185/// bundle. To install an admin credential, use [`open_admin_credential`].
186pub fn open_armored_bundle(
187    bundle_path: &Path,
188    config_dir: &Path,
189    expect_digest: Option<&str>,
190    no_verify_digest: bool,
191) -> Result<OpenedArmored, Box<dyn std::error::Error>> {
192    let (opened, secret) = open_armored_bundle_keeping_secret(
193        bundle_path,
194        config_dir,
195        expect_digest,
196        no_verify_digest,
197    )?;
198    consume_secret(&secret);
199    Ok(opened)
200}
201
202/// [`open_armored_bundle`] without the secret cleanup. Returns the secret's
203/// path so the caller can remove it ([`consume_request_secret`]) once it has
204/// installed what the bundle carries.
205///
206/// A command that only *shows* a bundle must use this, not
207/// [`open_armored_bundle`]: the seed is the only key that opens the bundle,
208/// so consuming it when nothing was written destroys the payload — for a
209/// template bundle, the integration's keys (VTI-53).
210pub fn open_armored_bundle_keeping_secret(
211    bundle_path: &Path,
212    config_dir: &Path,
213    expect_digest: Option<&str>,
214    no_verify_digest: bool,
215) -> Result<(OpenedArmored, PathBuf), Box<dyn std::error::Error>> {
216    if expect_digest.is_none() && !no_verify_digest {
217        return Err(
218            "--expect-digest <hex> is required (or pass --no-verify-digest to opt out)".into(),
219        );
220    }
221
222    let armored = fs::read_to_string(bundle_path)
223        .map_err(|e| format!("read {}: {e}", bundle_path.display()))?;
224    let bundles = armor::decode(&armored)?;
225    if bundles.len() != 1 {
226        return Err(format!(
227            "expected exactly one bundle in {}, found {}",
228            bundle_path.display(),
229            bundles.len()
230        )
231        .into());
232    }
233    let bundle = &bundles[0];
234    let bundle_id_hex = hex_lower(&bundle.bundle_id);
235
236    let sp = secret_path(config_dir, &bundle_id_hex)?;
237    if !sp.exists() {
238        return Err(format!(
239            "no stored secret for bundle_id {bundle_id_hex} (expected at {}). \
240             Did you run `bootstrap request` on this host?",
241            sp.display()
242        )
243        .into());
244    }
245    let ed_seed = read_secret(&sp)?;
246    let x_secret = ed25519_seed_to_x25519_secret(&ed_seed);
247
248    // Derive the consumer's X25519 pubkey — the producer signed over
249    // this in its DidSigned assertion. Derived here (while we still
250    // have the seed) rather than forcing the CLI caller to re-read
251    // the secret file, which we're about to delete.
252    let client_x25519_pub = {
253        let signing = ed25519_dalek::SigningKey::from_bytes(&ed_seed);
254        let ed_pub = signing.verifying_key().to_bytes();
255        affinidi_crypto::did_key::ed25519_pub_to_x25519_bytes(&ed_pub).map_err(
256            |e| -> Box<dyn std::error::Error> {
257                format!("derive consumer X25519 pubkey from seed: {e}").into()
258            },
259        )?
260    };
261
262    let digest = bundle_digest(bundle);
263    let opened = open_bundle(&x_secret, bundle, expect_digest)?;
264
265    Ok((
266        OpenedArmored {
267            payload: opened.payload,
268            producer: opened.producer,
269            bundle_id: opened.bundle_id,
270            bundle_id_hex,
271            digest,
272            client_x25519_pub,
273        },
274        sp,
275    ))
276}
277
278/// Remove a used request secret.
279///
280/// Best-effort: if a later step fails, the secret is gone, which is fine
281/// because the bundle id is single-use anyway and a retry needs a fresh
282/// request. Overwrite-then-unlink so the old bytes aren't left sitting on
283/// disk after unlink (see `zero_overwrite_and_remove`).
284///
285/// Call it only once the bundle's contents are installed somewhere: after
286/// this, nothing can open the bundle again.
287pub fn consume_request_secret(path: &Path) {
288    consume_secret(path);
289}
290
291fn consume_secret(path: &Path) {
292    if let Err(e) = zero_overwrite_and_remove(path) {
293        eprintln!(
294            "warning: could not remove used secret {}: {e}",
295            path.display()
296        );
297    }
298}
299
300/// The outcome of [`create_provision_request`]: the signed VP plus the
301/// bookkeeping fields callers need to hand to the operator / match the
302/// returned sealed bundle.
303pub struct CreatedProvisionRequest {
304    /// Signed VP (VC Data Model 2.0 `VerifiablePresentation` +
305    /// `BootstrapRequest` types) — serialize and hand to the VTA
306    /// operator for `vta bootstrap provision-integration --request ...`.
307    pub request: vta_sdk::provision_integration::BootstrapRequest,
308    /// `did:key:z6Mk...` derived from the ephemeral keypair; mirrors
309    /// `request.holder`.
310    pub client_did: String,
311    /// Hex-encoded 16-byte bundle id (== the VP's `nonce`). Also the
312    /// filename stem under which the seed was persisted.
313    pub bundle_id_hex: String,
314    /// Absolute path to the persisted Ed25519 seed. Read-restricted to
315    /// the owner (0600 on Unix).
316    pub secret_path: PathBuf,
317}
318
319/// Generate a fresh ephemeral Ed25519 keypair, persist the seed under
320/// `<config_dir>/bootstrap-secrets/<bundle_id_hex>.key`, and return a
321/// signed VP-framed [`vta_sdk::provision_integration::BootstrapRequest`]
322/// ready to hand to the VTA operator's
323/// `vta bootstrap provision-integration` CLI.
324///
325/// Thin wrapper over
326/// [`vta_sdk::provision_integration::ProvisionRequestBuilder::sign_ephemeral`]
327/// that adds the CLI-common seed-persistence convention — matching the
328/// layout used by the v1 [`create_bootstrap_request`] path, so the same
329/// `<config_dir>` lets [`open_armored_bundle`] find the secret at
330/// open-time regardless of which request flavour produced it.
331pub async fn create_provision_request(
332    config_dir: &Path,
333    builder: vta_sdk::provision_integration::ProvisionRequestBuilder,
334) -> Result<CreatedProvisionRequest, Box<dyn std::error::Error>> {
335    let signed = builder.sign_ephemeral().await?;
336    let bundle_id_hex = hex_lower(&signed.bundle_id);
337    let sp = secret_path(config_dir, &bundle_id_hex)?;
338    write_secret(&sp, &signed.seed)?;
339    Ok(CreatedProvisionRequest {
340        request: signed.request,
341        client_did: signed.client_did,
342        bundle_id_hex,
343        secret_path: sp,
344    })
345}
346
347/// Extract the [`CredentialBundle`] from an opened payload.
348///
349/// Accepts `AdminCredential` directly and `ContextProvision` (unwrapping the
350/// inner admin credential) — both are "install an admin identity" flows for a
351/// consumer. Other variants are rejected with a descriptive error.
352pub fn extract_admin_credential(
353    payload: SealedPayloadV1,
354) -> Result<CredentialBundle, Box<dyn std::error::Error>> {
355    match payload {
356        SealedPayloadV1::AdminCredential(c) => Ok(*c),
357        SealedPayloadV1::ContextProvision(p) => Ok(p.credential),
358        SealedPayloadV1::DidSecrets(_) => Err(
359            "cannot install a DidSecrets bundle as an admin credential — use `bootstrap open` to inspect it"
360                .into(),
361        ),
362        SealedPayloadV1::AdminKeySet(_) => Err(
363            "cannot install an AdminKeySet bundle as an admin credential — use `bootstrap open` to inspect it"
364                .into(),
365        ),
366        SealedPayloadV1::RawPrivateKey(_) => Err(
367            "cannot install a RawPrivateKey bundle as an admin credential".into(),
368        ),
369        SealedPayloadV1::TemplateBootstrap(_) => Err(
370            "TemplateBootstrap payloads carry a VC-issued admin authorization, not a \
371             CredentialBundle — open via `pnm bootstrap open` and use the provision-integration \
372             flow to install"
373                .into(),
374        ),
375        SealedPayloadV1::TemplateBootstrapV2(_) => Err(
376            "TemplateBootstrapV2 payloads carry a VC-issued admin authorization, not a \
377             CredentialBundle — open via `pnm bootstrap open` and use the provision-integration \
378             flow to install"
379                .into(),
380        ),
381        SealedPayloadV1::AdminRotation(_) => Err(
382            "AdminRotation payloads carry a VC-issued admin authorization, not a \
383             CredentialBundle — open via `pnm bootstrap open` and use the provision-integration \
384             flow to install"
385                .into(),
386        ),
387        SealedPayloadV1::IssuedCredential(_) => Err(
388            "IssuedCredential payloads carry a holder credential, not an admin CredentialBundle \
389             — receive it into the holder vault via the credential-exchange flow"
390                .into(),
391        ),
392        SealedPayloadV1::MessagingBridgeCredentials(_) => Err(
393            "MessagingBridgeCredentials payloads carry a connector's platform secrets, not an \
394             admin CredentialBundle — open via `pnm bootstrap open` and load them into the \
395             connector's secret store"
396                .into(),
397        ),
398        SealedPayloadV1::SeedMnemonic(_) => Err(
399            "SeedMnemonic payloads carry a VTA's seed mnemonic for an offline backup, not an \
400             admin CredentialBundle — open via `pnm bootstrap open` without --out and write the \
401             words down offline"
402                .into(),
403        ),
404        SealedPayloadV1::ExternalSecret(_) => Err(
405            "ExternalSecret payloads carry an external account's provider secret on its way to \
406             a VTA (external/accounts/secret/set), not an admin CredentialBundle"
407                .into(),
408        ),
409        SealedPayloadV1::ExternalCredential(_) => Err(
410            "ExternalCredential payloads carry a short-lived provider credential for the \
411             integration that asked for it (external/credentials/issue), not an admin \
412             CredentialBundle"
413                .into(),
414        ),
415        // `SealedPayloadV1` is `#[non_exhaustive]`: only the two variants above
416        // carry an admin credential.
417        _ => Err("this sealed payload is not an admin credential bundle".into()),
418    }
419}
420
421pub use vta_sdk::hex::lower as hex_lower;
422
423/// Emit the canonical `--no-verify-digest` warning to stderr.
424///
425/// Single source of truth for the wording — every CLI surface that
426/// accepts `--no-verify-digest` should call this so a future tweak to
427/// the message lands everywhere at once. Per CLAUDE.md, digest pinning
428/// is mandatory at the CLI; this helper is what the opt-out fires when
429/// the operator explicitly chose to disable it.
430pub fn warn_no_verify_digest() {
431    eprintln!(
432        "WARNING: --no-verify-digest disables out-of-band integrity verification.\n\
433         You are trusting the producer pubkey embedded in the bundle without\n\
434         any external anchor. Use only for testing."
435    );
436}
437
438/// Validate the `(--expect-digest, --no-verify-digest)` combination and
439/// fire the opt-out warning when applicable.
440///
441/// Rules:
442/// - One of the two must be supplied (no silent TOFU).
443/// - They cannot both be supplied — that's an operator error.
444/// - On `--no-verify-digest`, the warning is printed.
445///
446/// Returns `Ok(())` when the flags are coherent; otherwise an error
447/// message suitable for surfacing to the operator verbatim.
448pub fn validate_digest_flags(
449    expect_digest: Option<&str>,
450    no_verify_digest: bool,
451) -> Result<(), Box<dyn std::error::Error>> {
452    match (expect_digest, no_verify_digest) {
453        (Some(_), false) => Ok(()),
454        (None, true) => {
455            warn_no_verify_digest();
456            Ok(())
457        }
458        (Some(_), true) => {
459            Err("--no-verify-digest may not be combined with --expect-digest; pick one".into())
460        }
461        (None, false) => Err(
462            "--expect-digest <hex> is required (or pass --no-verify-digest to opt out \
463             with a warning)"
464                .into(),
465        ),
466    }
467}
468
469/// Parse an operator-supplied SHA-256 bundle digest.
470///
471/// Accepts exactly 64 hex characters, ignoring surrounding whitespace, and
472/// returns them lower-cased: the form [`bundle_digest`] produces and
473/// `open_bundle` compares against. Empty input is an error; there is no
474/// "skip" value.
475pub fn normalize_expected_digest(input: &str) -> Result<String, String> {
476    let digest = input.trim();
477    if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) {
478        return Err(
479            "enter the 64-character hex SHA-256 digest the producer gave you out-of-band".into(),
480        );
481    }
482    Ok(digest.to_ascii_lowercase())
483}
484
485/// Open an armored admin-credential bundle and verify where it came from
486/// before anything is installed.
487///
488/// Opens the bundle like [`open_armored_bundle`], then applies
489/// [`verify_admin_bundle`]. `expect_digest` of `None` is the
490/// `--no-verify-digest` case: only a bundle `DidSigned` by
491/// `expected_vta_did` is then accepted.
492///
493/// The single-use request secret is removed only once the bundle has been
494/// accepted, so a rejected bundle does not use up the pending request.
495pub fn open_admin_credential(
496    bundle_path: &Path,
497    config_dir: &Path,
498    expect_digest: Option<&str>,
499    expected_vta_did: Option<&str>,
500) -> Result<CredentialBundle, Box<dyn std::error::Error>> {
501    let (opened, secret) = open_armored_bundle_keeping_secret(
502        bundle_path,
503        config_dir,
504        expect_digest,
505        expect_digest.is_none(),
506    )?;
507    let credential = verify_admin_bundle(opened, expect_digest, expected_vta_did)?;
508    consume_secret(&secret);
509    Ok(credential)
510}
511
512/// Check that an opened bundle is anchored to the expected producer, then
513/// extract its admin credential.
514///
515/// HPKE sealing gives confidentiality, not authenticity: anyone who has seen
516/// the consumer's bootstrap request can seal a bundle to it. A credential is
517/// only installed when something the operator trusts vouches for the bundle:
518///
519/// - `PinnedOnly`: the out-of-band digest, `expect_digest`. The admin
520///   credentials that `pnm`, `cnm` and `vta` seal today all take this form,
521///   with a throwaway producer `did:key`.
522/// - `DidSigned`: a signature by `expected_vta_did` itself. A valid signature
523///   from any other DID proves nothing, since anyone can mint a `did:key` and
524///   sign, so the producer DID must equal `expected_vta_did`, with or without
525///   a digest. Only `did:key` producers can be verified here; other methods
526///   would need DID resolution.
527/// - `Attested`: refused. Attestation quotes are verified by
528///   `pnm bootstrap connect`, not on this path.
529///
530/// When `expected_vta_did` is given, the credential's `vta_did` must also
531/// equal it, so a bundle cannot point the session at a different VTA from the
532/// one the operator named.
533pub fn verify_admin_bundle(
534    opened: OpenedArmored,
535    expect_digest: Option<&str>,
536    expected_vta_did: Option<&str>,
537) -> Result<CredentialBundle, Box<dyn std::error::Error>> {
538    if let Some(expected) = expect_digest
539        && expected != opened.digest
540    {
541        return Err(format!(
542            "bundle digest {} does not match the expected digest {expected}",
543            opened.digest
544        )
545        .into());
546    }
547
548    let producer = &opened.producer;
549    let producer_pubkey = match &producer.proof {
550        AssertionProof::DidSigned(_) => {
551            let expected = expected_vta_did.ok_or_else(|| {
552                format!(
553                    "the bundle is signed by {}, but there is no expected VTA DID to check \
554                     that against; refusing to install it",
555                    producer.producer_did
556                )
557            })?;
558            if producer.producer_did != expected {
559                return Err(format!(
560                    "the bundle is signed by {}, not by the expected VTA {expected}; refusing \
561                     to install it",
562                    producer.producer_did
563                )
564                .into());
565            }
566            let pubkey = affinidi_crypto::did_key::did_key_to_ed25519_pub(&producer.producer_did)
567                .map_err(|e| {
568                format!(
569                    "cannot verify the producer signature: {} is not an Ed25519 did:key \
570                         ({e}); use --expect-digest instead",
571                    producer.producer_did
572                )
573            })?;
574            Some(pubkey)
575        }
576        _ => None,
577    };
578
579    let verdict = verify_producer_assertion_with_pubkey(
580        producer,
581        &opened.client_x25519_pub,
582        &opened.bundle_id,
583        producer_pubkey.as_ref(),
584    )?;
585
586    // Exhaustive, so a new assertion kind has to be decided on here rather
587    // than being accepted by default.
588    match verdict {
589        VerifiedAssertion::DidSignedVerified(_) => {}
590        VerifiedAssertion::PinnedOnlyAcknowledged(_) => {
591            if expect_digest.is_none() {
592                return Err(
593                    "the bundle carries no producer signature, so only its out-of-band \
594                     SHA-256 digest can show where it came from; pass --expect-digest <hex>"
595                        .into(),
596                );
597            }
598        }
599        VerifiedAssertion::AttestedNeedsNitroCheck(_) => {
600            return Err(
601                "the bundle carries a TEE attestation, which is not verified when installing \
602                 a credential from a file; use `pnm bootstrap connect` for attested bootstrap"
603                    .into(),
604            );
605        }
606    }
607
608    let credential = extract_admin_credential(opened.payload)?;
609    if let Some(expected) = expected_vta_did
610        && credential.vta_did != expected
611    {
612        return Err(format!(
613            "the credential is for VTA {}, not the expected {expected}; refusing to install it",
614            credential.vta_did
615        )
616        .into());
617    }
618    Ok(credential)
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624    use crate::sealed_producer::{SealedRecipient, seal_for_recipient};
625
626    #[test]
627    fn secrets_dir_creates_when_missing() {
628        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
629        let dir = secrets_dir(&tmp).unwrap();
630        assert!(dir.exists());
631        assert!(dir.ends_with("bootstrap-secrets"));
632        // Clean up — test only uses the dir once.
633        let _ = fs::remove_dir_all(&tmp);
634    }
635
636    #[test]
637    fn create_request_persists_secret() {
638        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
639        let req = create_bootstrap_request(&tmp, Some("unit-test".into())).unwrap();
640        assert!(req.secret_path.exists());
641        let bytes = fs::read(&req.secret_path).unwrap();
642        assert_eq!(bytes.len(), 32);
643        let _ = fs::remove_dir_all(&tmp);
644    }
645
646    #[tokio::test]
647    async fn request_seal_open_round_trip() {
648        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
649
650        // Consumer: create request + persist secret.
651        let created = create_bootstrap_request(&tmp, None).unwrap();
652
653        // Producer: seal to the request's pubkey.
654        let recipient =
655            SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
656                .unwrap();
657        let payload = SealedPayloadV1::AdminCredential(Box::new(
658            vta_sdk::credentials::CredentialBundle::new(
659                "did:key:z6Mk123",
660                "z1234567890",
661                "did:key:z6MkVTA",
662            ),
663        ));
664        let sealed = seal_for_recipient(&recipient, &payload).await.unwrap();
665
666        // Write armored to file.
667        let bundle_path = tmp.join("bundle.armor");
668        fs::write(&bundle_path, sealed.armored.as_bytes()).unwrap();
669
670        // Consumer: open.
671        let opened = open_armored_bundle(&bundle_path, &tmp, Some(&sealed.digest), false).unwrap();
672        assert_eq!(opened.bundle_id, created.request.decode_nonce().unwrap());
673
674        let cred = extract_admin_credential(opened.payload).unwrap();
675        assert_eq!(cred.did, "did:key:z6Mk123");
676
677        // Secret file is removed after successful open.
678        assert!(!created.secret_path.exists());
679
680        let _ = fs::remove_dir_all(&tmp);
681    }
682
683    /// VTI-53: inspecting a bundle must not destroy the only key that opens
684    /// it. Opening with the seed kept leaves it in place, the bundle opens a
685    /// second time, and only `consume_request_secret` removes the seed.
686    #[tokio::test]
687    async fn vti_53_inspecting_a_bundle_keeps_its_seed() {
688        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
689        let created = create_bootstrap_request(&tmp, None).unwrap();
690        let recipient =
691            SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
692                .unwrap();
693        let payload = SealedPayloadV1::AdminCredential(Box::new(
694            vta_sdk::credentials::CredentialBundle::new(
695                "did:key:z6Mk123",
696                "z1234567890",
697                "did:key:z6MkVTA",
698            ),
699        ));
700        let sealed = seal_for_recipient(&recipient, &payload).await.unwrap();
701        let bundle_path = tmp.join("bundle.armor");
702        fs::write(&bundle_path, sealed.armored.as_bytes()).unwrap();
703
704        for _ in 0..2 {
705            let (_, secret) =
706                open_armored_bundle_keeping_secret(&bundle_path, &tmp, Some(&sealed.digest), false)
707                    .unwrap();
708            assert_eq!(secret, created.secret_path);
709            assert!(secret.exists(), "inspection must keep the seed");
710        }
711
712        consume_request_secret(&created.secret_path);
713        assert!(!created.secret_path.exists());
714        assert!(
715            open_armored_bundle_keeping_secret(&bundle_path, &tmp, Some(&sealed.digest), false)
716                .is_err(),
717            "a consumed seed opens nothing"
718        );
719
720        let _ = fs::remove_dir_all(&tmp);
721    }
722
723    #[tokio::test]
724    async fn create_provision_request_persists_seed_and_signs() {
725        use vta_sdk::provision_integration::{BootstrapAsk, ProvisionRequestBuilder};
726
727        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
728
729        let builder = ProvisionRequestBuilder::new("didcomm-mediator")
730            .var("URL", "https://mediator.example.com")
731            .context_hint("mediator-prod")
732            .admin_template("vta-admin")
733            .label("cli-common-test");
734
735        let created = create_provision_request(&tmp, builder).await.unwrap();
736
737        // Seed persisted under bootstrap-secrets/<bundle_id>.key, 32 bytes.
738        assert!(created.secret_path.exists(), "secret must be persisted");
739        let stem = created.secret_path.file_stem().unwrap().to_str().unwrap();
740        assert_eq!(stem, created.bundle_id_hex);
741        let bytes = fs::read(&created.secret_path).unwrap();
742        assert_eq!(bytes.len(), 32);
743
744        // Bundle id matches the VP nonce (what the producer will use as
745        // the sealed-bundle id).
746        let verified = created.request.clone().verify().expect("verify VP");
747        assert_eq!(
748            hex_lower(&verified.decode_nonce().unwrap()),
749            created.bundle_id_hex
750        );
751
752        // Ask shape preserved through the SDK builder.
753        match verified.ask() {
754            BootstrapAsk::TemplateBootstrap(ask) => {
755                assert_eq!(ask.template.name, "didcomm-mediator");
756                assert_eq!(
757                    ask.template.vars.get("URL").and_then(|v| v.as_str()),
758                    Some("https://mediator.example.com")
759                );
760                assert_eq!(ask.context_hint.as_deref(), Some("mediator-prod"));
761                assert_eq!(
762                    ask.admin_template.as_ref().map(|t| t.name.as_str()),
763                    Some("vta-admin")
764                );
765            }
766            other => panic!("expected TemplateBootstrap, got {other:?}"),
767        }
768
769        // client_did returned matches the VP holder.
770        assert_eq!(created.client_did, verified.holder());
771
772        let _ = fs::remove_dir_all(&tmp);
773    }
774
775    #[cfg(unix)]
776    #[tokio::test]
777    async fn create_provision_request_seed_file_is_owner_only() {
778        use std::os::unix::fs::PermissionsExt;
779        use vta_sdk::provision_integration::ProvisionRequestBuilder;
780
781        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
782        let builder =
783            ProvisionRequestBuilder::new("didcomm-mediator").var("URL", "https://m.example.com");
784        let created = create_provision_request(&tmp, builder).await.unwrap();
785
786        let mode = fs::metadata(&created.secret_path)
787            .unwrap()
788            .permissions()
789            .mode();
790        // mode & 0o777 isolates the permission bits; must be 0o600.
791        assert_eq!(
792            mode & 0o777,
793            0o600,
794            "seed file must be 0600, got {:o}",
795            mode & 0o777
796        );
797
798        let _ = fs::remove_dir_all(&tmp);
799    }
800
801    #[test]
802    fn zero_overwrite_removes_file_and_scrubs_bytes() {
803        // Write some non-zero contents, stat the backing storage, run
804        // the scrub-then-unlink, confirm the file is gone. We can't
805        // reliably probe unlinked blocks from user-space, so the test
806        // checks the observable invariant: file is removed. The
807        // "bytes zeroed first" property is what item 21 actually
808        // wants — proven structurally by the helper's source.
809        let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
810        fs::create_dir_all(&tmp).unwrap();
811        let f = tmp.join("secret.bin");
812        let original: Vec<u8> = (0u8..32).collect();
813        fs::write(&f, &original).unwrap();
814        assert!(f.exists());
815
816        zero_overwrite_and_remove(&f).expect("remove succeeds");
817        assert!(!f.exists(), "file must be removed");
818        let _ = fs::remove_dir_all(&tmp);
819    }
820
821    #[test]
822    fn zero_overwrite_errors_on_missing_file() {
823        let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
824        let missing = tmp.join("does-not-exist");
825        let err = zero_overwrite_and_remove(&missing).unwrap_err();
826        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
827    }
828
829    #[test]
830    fn zero_overwrite_handles_empty_file() {
831        // A zero-byte file triggers the `len > 0` short-circuit — no
832        // write pass, but the unlink must still succeed.
833        let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
834        fs::create_dir_all(&tmp).unwrap();
835        let f = tmp.join("empty.bin");
836        fs::write(&f, b"").unwrap();
837        zero_overwrite_and_remove(&f).expect("remove succeeds");
838        assert!(!f.exists());
839        let _ = fs::remove_dir_all(&tmp);
840    }
841
842    #[test]
843    fn open_rejects_missing_digest_without_opt_out() {
844        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
845        fs::create_dir_all(&tmp).unwrap();
846        let bundle_path = tmp.join("bundle.armor");
847        fs::write(&bundle_path, b"armor placeholder").unwrap();
848        let err = open_armored_bundle(&bundle_path, &tmp, None, false).unwrap_err();
849        assert!(err.to_string().contains("expect-digest"));
850        let _ = fs::remove_dir_all(&tmp);
851    }
852
853    #[test]
854    fn extract_rejects_did_secrets() {
855        let payload =
856            SealedPayloadV1::DidSecrets(Box::new(vta_sdk::did_secrets::DidSecretsBundle {
857                did: "did:key:z6Mk".into(),
858                secrets: vec![],
859            }));
860        let err = extract_admin_credential(payload).unwrap_err();
861        assert!(err.to_string().contains("DidSecrets"));
862    }
863
864    #[test]
865    fn extract_accepts_context_provision() {
866        let payload = SealedPayloadV1::ContextProvision(Box::new(
867            vta_sdk::context_provision::ContextProvisionBundle {
868                context_id: "app".into(),
869                context_name: "App".into(),
870                vta_url: None,
871                vta_did: None,
872                credential: vta_sdk::credentials::CredentialBundle::new(
873                    "did:key:z6Mk123",
874                    "z1234567890",
875                    "did:key:z6MkVTA",
876                ),
877                admin_did: "did:key:z6Mk123".into(),
878                did: None,
879            },
880        ));
881        let cred = extract_admin_credential(payload).unwrap();
882        assert_eq!(cred.did, "did:key:z6Mk123");
883    }
884
885    // ── open_admin_credential ──────────────────────────────────────
886
887    use vta_sdk::sealed_transfer::{
888        AttestationQuoteAssertion, DidSignedAssertion, ProducerAssertion,
889    };
890
891    const VTA_DID: &str = "did:key:z6MkVTA";
892
893    fn tmp_dir() -> PathBuf {
894        std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()))
895    }
896
897    fn admin_payload(vta_did: &str) -> SealedPayloadV1 {
898        SealedPayloadV1::AdminCredential(Box::new(vta_sdk::credentials::CredentialBundle::new(
899            "did:key:z6Mk123",
900            "z1234567890",
901            vta_did,
902        )))
903    }
904
905    /// Seal `payload` to `created`'s request under the given producer
906    /// assertion and write the armor into `dir`. Returns the path and digest.
907    async fn seal_to_request(
908        dir: &Path,
909        created: &CreatedRequest,
910        producer: ProducerAssertion,
911        payload: &SealedPayloadV1,
912    ) -> (PathBuf, String) {
913        let client_x = created.request.decode_client_x25519_pub().unwrap();
914        let bundle_id = created.request.decode_nonce().unwrap();
915        let store = vta_sdk::sealed_transfer::InMemoryNonceStore::new();
916        let bundle =
917            vta_sdk::sealed_transfer::seal_payload(&client_x, bundle_id, producer, payload, &store)
918                .await
919                .unwrap();
920        let path = dir.join("bundle.armor");
921        fs::write(&path, armor::encode(&bundle)).unwrap();
922        (path, bundle_digest(&bundle))
923    }
924
925    /// A correctly signed `DidSigned` assertion over `created`'s key and
926    /// nonce, made with a freshly generated key. It names `producer_did`,
927    /// which defaults to that key's own did:key. Returns the assertion and
928    /// the key's did:key.
929    fn did_signed(
930        created: &CreatedRequest,
931        producer_did: Option<&str>,
932    ) -> (ProducerAssertion, String) {
933        use base64::Engine;
934        use ed25519_dalek::Signer;
935
936        let (seed, public) = generate_ed25519_keypair();
937        let key_did = affinidi_crypto::did_key::ed25519_pub_to_did_key(&public);
938        let did = producer_did.unwrap_or(&key_did).to_string();
939
940        let mut msg = vta_sdk::sealed_transfer::verify::DID_SIGNED_DOMAIN_TAG.to_vec();
941        msg.extend_from_slice(&created.request.decode_client_x25519_pub().unwrap());
942        msg.extend_from_slice(&created.request.decode_nonce().unwrap());
943        let sig = ed25519_dalek::SigningKey::from_bytes(&seed).sign(&msg);
944
945        let assertion = ProducerAssertion {
946            producer_did: did.clone(),
947            proof: AssertionProof::DidSigned(DidSignedAssertion {
948                did: did.clone(),
949                signature_b64: base64::engine::general_purpose::URL_SAFE_NO_PAD
950                    .encode(sig.to_bytes()),
951                verification_method: format!("{did}#key-0"),
952            }),
953        };
954        (assertion, key_did)
955    }
956
957    #[tokio::test]
958    async fn admin_credential_signed_by_an_unexpected_did_key_is_rejected() {
959        let tmp = tmp_dir();
960        let created = create_bootstrap_request(&tmp, None).unwrap();
961        // Anyone who has seen the request can mint a did:key, sign a valid
962        // assertion with it, and point the credential at a VTA of their own.
963        let (assertion, attacker_did) = did_signed(&created, None);
964        let (path, digest) =
965            seal_to_request(&tmp, &created, assertion, &admin_payload(&attacker_did)).await;
966
967        let err = open_admin_credential(&path, &tmp, None, Some("did:webvh:victim")).unwrap_err();
968        assert!(err.to_string().contains("not by the expected VTA"), "{err}");
969        // Still refused alongside a digest (which would come from the same
970        // sender), and when there is no expected VTA DID at all.
971        assert!(
972            open_admin_credential(&path, &tmp, Some(&digest), Some("did:webvh:victim")).is_err()
973        );
974        assert!(open_admin_credential(&path, &tmp, None, None).is_err());
975
976        // A rejected bundle does not use up the pending request.
977        assert!(created.secret_path.exists());
978        let _ = fs::remove_dir_all(&tmp);
979    }
980
981    #[tokio::test]
982    async fn admin_credential_signed_by_the_expected_vta_is_accepted() {
983        let tmp = tmp_dir();
984        let created = create_bootstrap_request(&tmp, None).unwrap();
985        let (assertion, vta_did) = did_signed(&created, None);
986        let (path, _) = seal_to_request(&tmp, &created, assertion, &admin_payload(&vta_did)).await;
987
988        let cred = open_admin_credential(&path, &tmp, None, Some(&vta_did)).unwrap();
989        assert_eq!(cred.vta_did, vta_did);
990        assert!(
991            !created.secret_path.exists(),
992            "an accepted bundle consumes the request secret"
993        );
994        let _ = fs::remove_dir_all(&tmp);
995    }
996
997    #[tokio::test]
998    async fn did_signed_by_another_key_in_the_vta_name_is_rejected() {
999        let tmp = tmp_dir();
1000        let created = create_bootstrap_request(&tmp, None).unwrap();
1001        // Names the expected VTA as the producer, but signs with another key.
1002        let (_, vta_did) = did_signed(&created, None);
1003        let (forged, _) = did_signed(&created, Some(&vta_did));
1004        let (path, _) = seal_to_request(&tmp, &created, forged, &admin_payload(&vta_did)).await;
1005
1006        assert!(open_admin_credential(&path, &tmp, None, Some(&vta_did)).is_err());
1007        let _ = fs::remove_dir_all(&tmp);
1008    }
1009
1010    #[tokio::test]
1011    async fn pinned_only_admin_credential_needs_the_digest() {
1012        let tmp = tmp_dir();
1013        let created = create_bootstrap_request(&tmp, None).unwrap();
1014        let recipient =
1015            SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
1016                .unwrap();
1017        let sealed = seal_for_recipient(&recipient, &admin_payload(VTA_DID))
1018            .await
1019            .unwrap();
1020        let path = tmp.join("bundle.armor");
1021        fs::write(&path, sealed.armored.as_bytes()).unwrap();
1022
1023        assert!(open_admin_credential(&path, &tmp, None, Some(VTA_DID)).is_err());
1024        assert!(created.secret_path.exists());
1025
1026        // The digest as an operator might type it: upper-case, with whitespace.
1027        let typed =
1028            normalize_expected_digest(&format!(" {}\n", sealed.digest.to_uppercase())).unwrap();
1029        let cred = open_admin_credential(&path, &tmp, Some(&typed), Some(VTA_DID)).unwrap();
1030        assert_eq!(cred.vta_did, VTA_DID);
1031        let _ = fs::remove_dir_all(&tmp);
1032    }
1033
1034    #[tokio::test]
1035    async fn admin_credential_for_a_different_vta_is_rejected() {
1036        let tmp = tmp_dir();
1037        let created = create_bootstrap_request(&tmp, None).unwrap();
1038        let recipient =
1039            SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
1040                .unwrap();
1041        let sealed = seal_for_recipient(&recipient, &admin_payload("did:key:z6MkOtherVTA"))
1042            .await
1043            .unwrap();
1044        let path = tmp.join("bundle.armor");
1045        fs::write(&path, sealed.armored.as_bytes()).unwrap();
1046
1047        let err =
1048            open_admin_credential(&path, &tmp, Some(&sealed.digest), Some(VTA_DID)).unwrap_err();
1049        assert!(err.to_string().contains("did:key:z6MkOtherVTA"), "{err}");
1050        let _ = fs::remove_dir_all(&tmp);
1051    }
1052
1053    #[tokio::test]
1054    async fn attested_admin_credential_is_rejected() {
1055        let tmp = tmp_dir();
1056        let created = create_bootstrap_request(&tmp, None).unwrap();
1057        let producer = ProducerAssertion {
1058            producer_did: "did:key:z6MkTee".into(),
1059            proof: AssertionProof::Attested(AttestationQuoteAssertion {
1060                format: "aws-nitro-v1".into(),
1061                quote_b64: "AAAA".into(),
1062            }),
1063        };
1064        let (path, digest) =
1065            seal_to_request(&tmp, &created, producer, &admin_payload(VTA_DID)).await;
1066
1067        let err = open_admin_credential(&path, &tmp, Some(&digest), Some(VTA_DID)).unwrap_err();
1068        assert!(err.to_string().contains("attestation"), "{err}");
1069        let _ = fs::remove_dir_all(&tmp);
1070    }
1071
1072    #[test]
1073    fn expected_digest_must_be_64_hex_characters() {
1074        assert!(normalize_expected_digest("").is_err());
1075        assert!(normalize_expected_digest("   ").is_err());
1076        assert!(normalize_expected_digest(&"a".repeat(63)).is_err());
1077        assert!(normalize_expected_digest(&"a".repeat(65)).is_err());
1078        assert!(normalize_expected_digest(&"g".repeat(64)).is_err());
1079        assert_eq!(
1080            normalize_expected_digest(&"AB".repeat(32)).unwrap(),
1081            "ab".repeat(32)
1082        );
1083    }
1084}