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 once it has accepted the bundle.
204fn open_armored_bundle_keeping_secret(
205    bundle_path: &Path,
206    config_dir: &Path,
207    expect_digest: Option<&str>,
208    no_verify_digest: bool,
209) -> Result<(OpenedArmored, PathBuf), Box<dyn std::error::Error>> {
210    if expect_digest.is_none() && !no_verify_digest {
211        return Err(
212            "--expect-digest <hex> is required (or pass --no-verify-digest to opt out)".into(),
213        );
214    }
215
216    let armored = fs::read_to_string(bundle_path)
217        .map_err(|e| format!("read {}: {e}", bundle_path.display()))?;
218    let bundles = armor::decode(&armored)?;
219    if bundles.len() != 1 {
220        return Err(format!(
221            "expected exactly one bundle in {}, found {}",
222            bundle_path.display(),
223            bundles.len()
224        )
225        .into());
226    }
227    let bundle = &bundles[0];
228    let bundle_id_hex = hex_lower(&bundle.bundle_id);
229
230    let sp = secret_path(config_dir, &bundle_id_hex)?;
231    if !sp.exists() {
232        return Err(format!(
233            "no stored secret for bundle_id {bundle_id_hex} (expected at {}). \
234             Did you run `bootstrap request` on this host?",
235            sp.display()
236        )
237        .into());
238    }
239    let ed_seed = read_secret(&sp)?;
240    let x_secret = ed25519_seed_to_x25519_secret(&ed_seed);
241
242    // Derive the consumer's X25519 pubkey — the producer signed over
243    // this in its DidSigned assertion. Derived here (while we still
244    // have the seed) rather than forcing the CLI caller to re-read
245    // the secret file, which we're about to delete.
246    let client_x25519_pub = {
247        let signing = ed25519_dalek::SigningKey::from_bytes(&ed_seed);
248        let ed_pub = signing.verifying_key().to_bytes();
249        affinidi_crypto::did_key::ed25519_pub_to_x25519_bytes(&ed_pub).map_err(
250            |e| -> Box<dyn std::error::Error> {
251                format!("derive consumer X25519 pubkey from seed: {e}").into()
252            },
253        )?
254    };
255
256    let digest = bundle_digest(bundle);
257    let opened = open_bundle(&x_secret, bundle, expect_digest)?;
258
259    Ok((
260        OpenedArmored {
261            payload: opened.payload,
262            producer: opened.producer,
263            bundle_id: opened.bundle_id,
264            bundle_id_hex,
265            digest,
266            client_x25519_pub,
267        },
268        sp,
269    ))
270}
271
272/// Remove a used request secret.
273///
274/// Best-effort: if a later step fails, the secret is gone, which is fine
275/// because the bundle id is single-use anyway and a retry needs a fresh
276/// request. Overwrite-then-unlink so the old bytes aren't left sitting on
277/// disk after unlink (see `zero_overwrite_and_remove`).
278fn consume_secret(path: &Path) {
279    if let Err(e) = zero_overwrite_and_remove(path) {
280        eprintln!(
281            "warning: could not remove used secret {}: {e}",
282            path.display()
283        );
284    }
285}
286
287/// The outcome of [`create_provision_request`]: the signed VP plus the
288/// bookkeeping fields callers need to hand to the operator / match the
289/// returned sealed bundle.
290pub struct CreatedProvisionRequest {
291    /// Signed VP (VC Data Model 2.0 `VerifiablePresentation` +
292    /// `BootstrapRequest` types) — serialize and hand to the VTA
293    /// operator for `vta bootstrap provision-integration --request ...`.
294    pub request: vta_sdk::provision_integration::BootstrapRequest,
295    /// `did:key:z6Mk...` derived from the ephemeral keypair; mirrors
296    /// `request.holder`.
297    pub client_did: String,
298    /// Hex-encoded 16-byte bundle id (== the VP's `nonce`). Also the
299    /// filename stem under which the seed was persisted.
300    pub bundle_id_hex: String,
301    /// Absolute path to the persisted Ed25519 seed. Read-restricted to
302    /// the owner (0600 on Unix).
303    pub secret_path: PathBuf,
304}
305
306/// Generate a fresh ephemeral Ed25519 keypair, persist the seed under
307/// `<config_dir>/bootstrap-secrets/<bundle_id_hex>.key`, and return a
308/// signed VP-framed [`vta_sdk::provision_integration::BootstrapRequest`]
309/// ready to hand to the VTA operator's
310/// `vta bootstrap provision-integration` CLI.
311///
312/// Thin wrapper over
313/// [`vta_sdk::provision_integration::ProvisionRequestBuilder::sign_ephemeral`]
314/// that adds the CLI-common seed-persistence convention — matching the
315/// layout used by the v1 [`create_bootstrap_request`] path, so the same
316/// `<config_dir>` lets [`open_armored_bundle`] find the secret at
317/// open-time regardless of which request flavour produced it.
318pub async fn create_provision_request(
319    config_dir: &Path,
320    builder: vta_sdk::provision_integration::ProvisionRequestBuilder,
321) -> Result<CreatedProvisionRequest, Box<dyn std::error::Error>> {
322    let signed = builder.sign_ephemeral().await?;
323    let bundle_id_hex = hex_lower(&signed.bundle_id);
324    let sp = secret_path(config_dir, &bundle_id_hex)?;
325    write_secret(&sp, &signed.seed)?;
326    Ok(CreatedProvisionRequest {
327        request: signed.request,
328        client_did: signed.client_did,
329        bundle_id_hex,
330        secret_path: sp,
331    })
332}
333
334/// Extract the [`CredentialBundle`] from an opened payload.
335///
336/// Accepts `AdminCredential` directly and `ContextProvision` (unwrapping the
337/// inner admin credential) — both are "install an admin identity" flows for a
338/// consumer. Other variants are rejected with a descriptive error.
339pub fn extract_admin_credential(
340    payload: SealedPayloadV1,
341) -> Result<CredentialBundle, Box<dyn std::error::Error>> {
342    match payload {
343        SealedPayloadV1::AdminCredential(c) => Ok(*c),
344        SealedPayloadV1::ContextProvision(p) => Ok(p.credential),
345        SealedPayloadV1::DidSecrets(_) => Err(
346            "cannot install a DidSecrets bundle as an admin credential — use `bootstrap open` to inspect it"
347                .into(),
348        ),
349        SealedPayloadV1::AdminKeySet(_) => Err(
350            "cannot install an AdminKeySet bundle as an admin credential — use `bootstrap open` to inspect it"
351                .into(),
352        ),
353        SealedPayloadV1::RawPrivateKey(_) => Err(
354            "cannot install a RawPrivateKey bundle as an admin credential".into(),
355        ),
356        SealedPayloadV1::TemplateBootstrap(_) => Err(
357            "TemplateBootstrap payloads carry a VC-issued admin authorization, not a \
358             CredentialBundle — open via `pnm bootstrap open` and use the provision-integration \
359             flow to install"
360                .into(),
361        ),
362        SealedPayloadV1::TemplateBootstrapV2(_) => Err(
363            "TemplateBootstrapV2 payloads carry a VC-issued admin authorization, not a \
364             CredentialBundle — open via `pnm bootstrap open` and use the provision-integration \
365             flow to install"
366                .into(),
367        ),
368        SealedPayloadV1::AdminRotation(_) => Err(
369            "AdminRotation payloads carry a VC-issued admin authorization, not a \
370             CredentialBundle — open via `pnm bootstrap open` and use the provision-integration \
371             flow to install"
372                .into(),
373        ),
374        SealedPayloadV1::IssuedCredential(_) => Err(
375            "IssuedCredential payloads carry a holder credential, not an admin CredentialBundle \
376             — receive it into the holder vault via the credential-exchange flow"
377                .into(),
378        ),
379        SealedPayloadV1::MessagingBridgeCredentials(_) => Err(
380            "MessagingBridgeCredentials payloads carry a connector's platform secrets, not an \
381             admin CredentialBundle — open via `pnm bootstrap open` and load them into the \
382             connector's secret store"
383                .into(),
384        ),
385    }
386}
387
388pub use vta_sdk::hex::lower as hex_lower;
389
390/// Emit the canonical `--no-verify-digest` warning to stderr.
391///
392/// Single source of truth for the wording — every CLI surface that
393/// accepts `--no-verify-digest` should call this so a future tweak to
394/// the message lands everywhere at once. Per CLAUDE.md, digest pinning
395/// is mandatory at the CLI; this helper is what the opt-out fires when
396/// the operator explicitly chose to disable it.
397pub fn warn_no_verify_digest() {
398    eprintln!(
399        "WARNING: --no-verify-digest disables out-of-band integrity verification.\n\
400         You are trusting the producer pubkey embedded in the bundle without\n\
401         any external anchor. Use only for testing."
402    );
403}
404
405/// Validate the `(--expect-digest, --no-verify-digest)` combination and
406/// fire the opt-out warning when applicable.
407///
408/// Rules:
409/// - One of the two must be supplied (no silent TOFU).
410/// - They cannot both be supplied — that's an operator error.
411/// - On `--no-verify-digest`, the warning is printed.
412///
413/// Returns `Ok(())` when the flags are coherent; otherwise an error
414/// message suitable for surfacing to the operator verbatim.
415pub fn validate_digest_flags(
416    expect_digest: Option<&str>,
417    no_verify_digest: bool,
418) -> Result<(), Box<dyn std::error::Error>> {
419    match (expect_digest, no_verify_digest) {
420        (Some(_), false) => Ok(()),
421        (None, true) => {
422            warn_no_verify_digest();
423            Ok(())
424        }
425        (Some(_), true) => {
426            Err("--no-verify-digest may not be combined with --expect-digest; pick one".into())
427        }
428        (None, false) => Err(
429            "--expect-digest <hex> is required (or pass --no-verify-digest to opt out \
430             with a warning)"
431                .into(),
432        ),
433    }
434}
435
436/// Parse an operator-supplied SHA-256 bundle digest.
437///
438/// Accepts exactly 64 hex characters, ignoring surrounding whitespace, and
439/// returns them lower-cased: the form [`bundle_digest`] produces and
440/// `open_bundle` compares against. Empty input is an error; there is no
441/// "skip" value.
442pub fn normalize_expected_digest(input: &str) -> Result<String, String> {
443    let digest = input.trim();
444    if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) {
445        return Err(
446            "enter the 64-character hex SHA-256 digest the producer gave you out-of-band".into(),
447        );
448    }
449    Ok(digest.to_ascii_lowercase())
450}
451
452/// Open an armored admin-credential bundle and verify where it came from
453/// before anything is installed.
454///
455/// Opens the bundle like [`open_armored_bundle`], then applies
456/// [`verify_admin_bundle`]. `expect_digest` of `None` is the
457/// `--no-verify-digest` case: only a bundle `DidSigned` by
458/// `expected_vta_did` is then accepted.
459///
460/// The single-use request secret is removed only once the bundle has been
461/// accepted, so a rejected bundle does not use up the pending request.
462pub fn open_admin_credential(
463    bundle_path: &Path,
464    config_dir: &Path,
465    expect_digest: Option<&str>,
466    expected_vta_did: Option<&str>,
467) -> Result<CredentialBundle, Box<dyn std::error::Error>> {
468    let (opened, secret) = open_armored_bundle_keeping_secret(
469        bundle_path,
470        config_dir,
471        expect_digest,
472        expect_digest.is_none(),
473    )?;
474    let credential = verify_admin_bundle(opened, expect_digest, expected_vta_did)?;
475    consume_secret(&secret);
476    Ok(credential)
477}
478
479/// Check that an opened bundle is anchored to the expected producer, then
480/// extract its admin credential.
481///
482/// HPKE sealing gives confidentiality, not authenticity: anyone who has seen
483/// the consumer's bootstrap request can seal a bundle to it. A credential is
484/// only installed when something the operator trusts vouches for the bundle:
485///
486/// - `PinnedOnly`: the out-of-band digest, `expect_digest`. The admin
487///   credentials that `pnm`, `cnm` and `vta` seal today all take this form,
488///   with a throwaway producer `did:key`.
489/// - `DidSigned`: a signature by `expected_vta_did` itself. A valid signature
490///   from any other DID proves nothing, since anyone can mint a `did:key` and
491///   sign, so the producer DID must equal `expected_vta_did`, with or without
492///   a digest. Only `did:key` producers can be verified here; other methods
493///   would need DID resolution.
494/// - `Attested`: refused. Attestation quotes are verified by
495///   `pnm bootstrap connect`, not on this path.
496///
497/// When `expected_vta_did` is given, the credential's `vta_did` must also
498/// equal it, so a bundle cannot point the session at a different VTA from the
499/// one the operator named.
500pub fn verify_admin_bundle(
501    opened: OpenedArmored,
502    expect_digest: Option<&str>,
503    expected_vta_did: Option<&str>,
504) -> Result<CredentialBundle, Box<dyn std::error::Error>> {
505    if let Some(expected) = expect_digest
506        && expected != opened.digest
507    {
508        return Err(format!(
509            "bundle digest {} does not match the expected digest {expected}",
510            opened.digest
511        )
512        .into());
513    }
514
515    let producer = &opened.producer;
516    let producer_pubkey = match &producer.proof {
517        AssertionProof::DidSigned(_) => {
518            let expected = expected_vta_did.ok_or_else(|| {
519                format!(
520                    "the bundle is signed by {}, but there is no expected VTA DID to check \
521                     that against; refusing to install it",
522                    producer.producer_did
523                )
524            })?;
525            if producer.producer_did != expected {
526                return Err(format!(
527                    "the bundle is signed by {}, not by the expected VTA {expected}; refusing \
528                     to install it",
529                    producer.producer_did
530                )
531                .into());
532            }
533            let pubkey = affinidi_crypto::did_key::did_key_to_ed25519_pub(&producer.producer_did)
534                .map_err(|e| {
535                format!(
536                    "cannot verify the producer signature: {} is not an Ed25519 did:key \
537                         ({e}); use --expect-digest instead",
538                    producer.producer_did
539                )
540            })?;
541            Some(pubkey)
542        }
543        _ => None,
544    };
545
546    let verdict = verify_producer_assertion_with_pubkey(
547        producer,
548        &opened.client_x25519_pub,
549        &opened.bundle_id,
550        producer_pubkey.as_ref(),
551    )?;
552
553    // Exhaustive, so a new assertion kind has to be decided on here rather
554    // than being accepted by default.
555    match verdict {
556        VerifiedAssertion::DidSignedVerified(_) => {}
557        VerifiedAssertion::PinnedOnlyAcknowledged(_) => {
558            if expect_digest.is_none() {
559                return Err(
560                    "the bundle carries no producer signature, so only its out-of-band \
561                     SHA-256 digest can show where it came from; pass --expect-digest <hex>"
562                        .into(),
563                );
564            }
565        }
566        VerifiedAssertion::AttestedNeedsNitroCheck(_) => {
567            return Err(
568                "the bundle carries a TEE attestation, which is not verified when installing \
569                 a credential from a file; use `pnm bootstrap connect` for attested bootstrap"
570                    .into(),
571            );
572        }
573    }
574
575    let credential = extract_admin_credential(opened.payload)?;
576    if let Some(expected) = expected_vta_did
577        && credential.vta_did != expected
578    {
579        return Err(format!(
580            "the credential is for VTA {}, not the expected {expected}; refusing to install it",
581            credential.vta_did
582        )
583        .into());
584    }
585    Ok(credential)
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591    use crate::sealed_producer::{SealedRecipient, seal_for_recipient};
592
593    #[test]
594    fn secrets_dir_creates_when_missing() {
595        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
596        let dir = secrets_dir(&tmp).unwrap();
597        assert!(dir.exists());
598        assert!(dir.ends_with("bootstrap-secrets"));
599        // Clean up — test only uses the dir once.
600        let _ = fs::remove_dir_all(&tmp);
601    }
602
603    #[test]
604    fn create_request_persists_secret() {
605        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
606        let req = create_bootstrap_request(&tmp, Some("unit-test".into())).unwrap();
607        assert!(req.secret_path.exists());
608        let bytes = fs::read(&req.secret_path).unwrap();
609        assert_eq!(bytes.len(), 32);
610        let _ = fs::remove_dir_all(&tmp);
611    }
612
613    #[tokio::test]
614    async fn request_seal_open_round_trip() {
615        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
616
617        // Consumer: create request + persist secret.
618        let created = create_bootstrap_request(&tmp, None).unwrap();
619
620        // Producer: seal to the request's pubkey.
621        let recipient =
622            SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
623                .unwrap();
624        let payload = SealedPayloadV1::AdminCredential(Box::new(
625            vta_sdk::credentials::CredentialBundle::new(
626                "did:key:z6Mk123",
627                "z1234567890",
628                "did:key:z6MkVTA",
629            ),
630        ));
631        let sealed = seal_for_recipient(&recipient, &payload).await.unwrap();
632
633        // Write armored to file.
634        let bundle_path = tmp.join("bundle.armor");
635        fs::write(&bundle_path, sealed.armored.as_bytes()).unwrap();
636
637        // Consumer: open.
638        let opened = open_armored_bundle(&bundle_path, &tmp, Some(&sealed.digest), false).unwrap();
639        assert_eq!(opened.bundle_id, created.request.decode_nonce().unwrap());
640
641        let cred = extract_admin_credential(opened.payload).unwrap();
642        assert_eq!(cred.did, "did:key:z6Mk123");
643
644        // Secret file is removed after successful open.
645        assert!(!created.secret_path.exists());
646
647        let _ = fs::remove_dir_all(&tmp);
648    }
649
650    #[tokio::test]
651    async fn create_provision_request_persists_seed_and_signs() {
652        use vta_sdk::provision_integration::{BootstrapAsk, ProvisionRequestBuilder};
653
654        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
655
656        let builder = ProvisionRequestBuilder::new("didcomm-mediator")
657            .var("URL", "https://mediator.example.com")
658            .context_hint("mediator-prod")
659            .admin_template("vta-admin")
660            .label("cli-common-test");
661
662        let created = create_provision_request(&tmp, builder).await.unwrap();
663
664        // Seed persisted under bootstrap-secrets/<bundle_id>.key, 32 bytes.
665        assert!(created.secret_path.exists(), "secret must be persisted");
666        let stem = created.secret_path.file_stem().unwrap().to_str().unwrap();
667        assert_eq!(stem, created.bundle_id_hex);
668        let bytes = fs::read(&created.secret_path).unwrap();
669        assert_eq!(bytes.len(), 32);
670
671        // Bundle id matches the VP nonce (what the producer will use as
672        // the sealed-bundle id).
673        let verified = created.request.clone().verify().expect("verify VP");
674        assert_eq!(
675            hex_lower(&verified.decode_nonce().unwrap()),
676            created.bundle_id_hex
677        );
678
679        // Ask shape preserved through the SDK builder.
680        match verified.ask() {
681            BootstrapAsk::TemplateBootstrap(ask) => {
682                assert_eq!(ask.template.name, "didcomm-mediator");
683                assert_eq!(
684                    ask.template.vars.get("URL").and_then(|v| v.as_str()),
685                    Some("https://mediator.example.com")
686                );
687                assert_eq!(ask.context_hint.as_deref(), Some("mediator-prod"));
688                assert_eq!(
689                    ask.admin_template.as_ref().map(|t| t.name.as_str()),
690                    Some("vta-admin")
691                );
692            }
693            other => panic!("expected TemplateBootstrap, got {other:?}"),
694        }
695
696        // client_did returned matches the VP holder.
697        assert_eq!(created.client_did, verified.holder());
698
699        let _ = fs::remove_dir_all(&tmp);
700    }
701
702    #[cfg(unix)]
703    #[tokio::test]
704    async fn create_provision_request_seed_file_is_owner_only() {
705        use std::os::unix::fs::PermissionsExt;
706        use vta_sdk::provision_integration::ProvisionRequestBuilder;
707
708        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
709        let builder =
710            ProvisionRequestBuilder::new("didcomm-mediator").var("URL", "https://m.example.com");
711        let created = create_provision_request(&tmp, builder).await.unwrap();
712
713        let mode = fs::metadata(&created.secret_path)
714            .unwrap()
715            .permissions()
716            .mode();
717        // mode & 0o777 isolates the permission bits; must be 0o600.
718        assert_eq!(
719            mode & 0o777,
720            0o600,
721            "seed file must be 0600, got {:o}",
722            mode & 0o777
723        );
724
725        let _ = fs::remove_dir_all(&tmp);
726    }
727
728    #[test]
729    fn zero_overwrite_removes_file_and_scrubs_bytes() {
730        // Write some non-zero contents, stat the backing storage, run
731        // the scrub-then-unlink, confirm the file is gone. We can't
732        // reliably probe unlinked blocks from user-space, so the test
733        // checks the observable invariant: file is removed. The
734        // "bytes zeroed first" property is what item 21 actually
735        // wants — proven structurally by the helper's source.
736        let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
737        fs::create_dir_all(&tmp).unwrap();
738        let f = tmp.join("secret.bin");
739        let original: Vec<u8> = (0u8..32).collect();
740        fs::write(&f, &original).unwrap();
741        assert!(f.exists());
742
743        zero_overwrite_and_remove(&f).expect("remove succeeds");
744        assert!(!f.exists(), "file must be removed");
745        let _ = fs::remove_dir_all(&tmp);
746    }
747
748    #[test]
749    fn zero_overwrite_errors_on_missing_file() {
750        let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
751        let missing = tmp.join("does-not-exist");
752        let err = zero_overwrite_and_remove(&missing).unwrap_err();
753        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
754    }
755
756    #[test]
757    fn zero_overwrite_handles_empty_file() {
758        // A zero-byte file triggers the `len > 0` short-circuit — no
759        // write pass, but the unlink must still succeed.
760        let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
761        fs::create_dir_all(&tmp).unwrap();
762        let f = tmp.join("empty.bin");
763        fs::write(&f, b"").unwrap();
764        zero_overwrite_and_remove(&f).expect("remove succeeds");
765        assert!(!f.exists());
766        let _ = fs::remove_dir_all(&tmp);
767    }
768
769    #[test]
770    fn open_rejects_missing_digest_without_opt_out() {
771        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
772        fs::create_dir_all(&tmp).unwrap();
773        let bundle_path = tmp.join("bundle.armor");
774        fs::write(&bundle_path, b"armor placeholder").unwrap();
775        let err = open_armored_bundle(&bundle_path, &tmp, None, false).unwrap_err();
776        assert!(err.to_string().contains("expect-digest"));
777        let _ = fs::remove_dir_all(&tmp);
778    }
779
780    #[test]
781    fn extract_rejects_did_secrets() {
782        let payload =
783            SealedPayloadV1::DidSecrets(Box::new(vta_sdk::did_secrets::DidSecretsBundle {
784                did: "did:key:z6Mk".into(),
785                secrets: vec![],
786            }));
787        let err = extract_admin_credential(payload).unwrap_err();
788        assert!(err.to_string().contains("DidSecrets"));
789    }
790
791    #[test]
792    fn extract_accepts_context_provision() {
793        let payload = SealedPayloadV1::ContextProvision(Box::new(
794            vta_sdk::context_provision::ContextProvisionBundle {
795                context_id: "app".into(),
796                context_name: "App".into(),
797                vta_url: None,
798                vta_did: None,
799                credential: vta_sdk::credentials::CredentialBundle::new(
800                    "did:key:z6Mk123",
801                    "z1234567890",
802                    "did:key:z6MkVTA",
803                ),
804                admin_did: "did:key:z6Mk123".into(),
805                did: None,
806            },
807        ));
808        let cred = extract_admin_credential(payload).unwrap();
809        assert_eq!(cred.did, "did:key:z6Mk123");
810    }
811
812    // ── open_admin_credential ──────────────────────────────────────
813
814    use vta_sdk::sealed_transfer::{
815        AttestationQuoteAssertion, DidSignedAssertion, ProducerAssertion,
816    };
817
818    const VTA_DID: &str = "did:key:z6MkVTA";
819
820    fn tmp_dir() -> PathBuf {
821        std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()))
822    }
823
824    fn admin_payload(vta_did: &str) -> SealedPayloadV1 {
825        SealedPayloadV1::AdminCredential(Box::new(vta_sdk::credentials::CredentialBundle::new(
826            "did:key:z6Mk123",
827            "z1234567890",
828            vta_did,
829        )))
830    }
831
832    /// Seal `payload` to `created`'s request under the given producer
833    /// assertion and write the armor into `dir`. Returns the path and digest.
834    async fn seal_to_request(
835        dir: &Path,
836        created: &CreatedRequest,
837        producer: ProducerAssertion,
838        payload: &SealedPayloadV1,
839    ) -> (PathBuf, String) {
840        let client_x = created.request.decode_client_x25519_pub().unwrap();
841        let bundle_id = created.request.decode_nonce().unwrap();
842        let store = vta_sdk::sealed_transfer::InMemoryNonceStore::new();
843        let bundle =
844            vta_sdk::sealed_transfer::seal_payload(&client_x, bundle_id, producer, payload, &store)
845                .await
846                .unwrap();
847        let path = dir.join("bundle.armor");
848        fs::write(&path, armor::encode(&bundle)).unwrap();
849        (path, bundle_digest(&bundle))
850    }
851
852    /// A correctly signed `DidSigned` assertion over `created`'s key and
853    /// nonce, made with a freshly generated key. It names `producer_did`,
854    /// which defaults to that key's own did:key. Returns the assertion and
855    /// the key's did:key.
856    fn did_signed(
857        created: &CreatedRequest,
858        producer_did: Option<&str>,
859    ) -> (ProducerAssertion, String) {
860        use base64::Engine;
861        use ed25519_dalek::Signer;
862
863        let (seed, public) = generate_ed25519_keypair();
864        let key_did = affinidi_crypto::did_key::ed25519_pub_to_did_key(&public);
865        let did = producer_did.unwrap_or(&key_did).to_string();
866
867        let mut msg = vta_sdk::sealed_transfer::verify::DID_SIGNED_DOMAIN_TAG.to_vec();
868        msg.extend_from_slice(&created.request.decode_client_x25519_pub().unwrap());
869        msg.extend_from_slice(&created.request.decode_nonce().unwrap());
870        let sig = ed25519_dalek::SigningKey::from_bytes(&seed).sign(&msg);
871
872        let assertion = ProducerAssertion {
873            producer_did: did.clone(),
874            proof: AssertionProof::DidSigned(DidSignedAssertion {
875                did: did.clone(),
876                signature_b64: base64::engine::general_purpose::URL_SAFE_NO_PAD
877                    .encode(sig.to_bytes()),
878                verification_method: format!("{did}#key-0"),
879            }),
880        };
881        (assertion, key_did)
882    }
883
884    #[tokio::test]
885    async fn admin_credential_signed_by_an_unexpected_did_key_is_rejected() {
886        let tmp = tmp_dir();
887        let created = create_bootstrap_request(&tmp, None).unwrap();
888        // Anyone who has seen the request can mint a did:key, sign a valid
889        // assertion with it, and point the credential at a VTA of their own.
890        let (assertion, attacker_did) = did_signed(&created, None);
891        let (path, digest) =
892            seal_to_request(&tmp, &created, assertion, &admin_payload(&attacker_did)).await;
893
894        let err = open_admin_credential(&path, &tmp, None, Some("did:webvh:victim")).unwrap_err();
895        assert!(err.to_string().contains("not by the expected VTA"), "{err}");
896        // Still refused alongside a digest (which would come from the same
897        // sender), and when there is no expected VTA DID at all.
898        assert!(
899            open_admin_credential(&path, &tmp, Some(&digest), Some("did:webvh:victim")).is_err()
900        );
901        assert!(open_admin_credential(&path, &tmp, None, None).is_err());
902
903        // A rejected bundle does not use up the pending request.
904        assert!(created.secret_path.exists());
905        let _ = fs::remove_dir_all(&tmp);
906    }
907
908    #[tokio::test]
909    async fn admin_credential_signed_by_the_expected_vta_is_accepted() {
910        let tmp = tmp_dir();
911        let created = create_bootstrap_request(&tmp, None).unwrap();
912        let (assertion, vta_did) = did_signed(&created, None);
913        let (path, _) = seal_to_request(&tmp, &created, assertion, &admin_payload(&vta_did)).await;
914
915        let cred = open_admin_credential(&path, &tmp, None, Some(&vta_did)).unwrap();
916        assert_eq!(cred.vta_did, vta_did);
917        assert!(
918            !created.secret_path.exists(),
919            "an accepted bundle consumes the request secret"
920        );
921        let _ = fs::remove_dir_all(&tmp);
922    }
923
924    #[tokio::test]
925    async fn did_signed_by_another_key_in_the_vta_name_is_rejected() {
926        let tmp = tmp_dir();
927        let created = create_bootstrap_request(&tmp, None).unwrap();
928        // Names the expected VTA as the producer, but signs with another key.
929        let (_, vta_did) = did_signed(&created, None);
930        let (forged, _) = did_signed(&created, Some(&vta_did));
931        let (path, _) = seal_to_request(&tmp, &created, forged, &admin_payload(&vta_did)).await;
932
933        assert!(open_admin_credential(&path, &tmp, None, Some(&vta_did)).is_err());
934        let _ = fs::remove_dir_all(&tmp);
935    }
936
937    #[tokio::test]
938    async fn pinned_only_admin_credential_needs_the_digest() {
939        let tmp = tmp_dir();
940        let created = create_bootstrap_request(&tmp, None).unwrap();
941        let recipient =
942            SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
943                .unwrap();
944        let sealed = seal_for_recipient(&recipient, &admin_payload(VTA_DID))
945            .await
946            .unwrap();
947        let path = tmp.join("bundle.armor");
948        fs::write(&path, sealed.armored.as_bytes()).unwrap();
949
950        assert!(open_admin_credential(&path, &tmp, None, Some(VTA_DID)).is_err());
951        assert!(created.secret_path.exists());
952
953        // The digest as an operator might type it: upper-case, with whitespace.
954        let typed =
955            normalize_expected_digest(&format!(" {}\n", sealed.digest.to_uppercase())).unwrap();
956        let cred = open_admin_credential(&path, &tmp, Some(&typed), Some(VTA_DID)).unwrap();
957        assert_eq!(cred.vta_did, VTA_DID);
958        let _ = fs::remove_dir_all(&tmp);
959    }
960
961    #[tokio::test]
962    async fn admin_credential_for_a_different_vta_is_rejected() {
963        let tmp = tmp_dir();
964        let created = create_bootstrap_request(&tmp, None).unwrap();
965        let recipient =
966            SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
967                .unwrap();
968        let sealed = seal_for_recipient(&recipient, &admin_payload("did:key:z6MkOtherVTA"))
969            .await
970            .unwrap();
971        let path = tmp.join("bundle.armor");
972        fs::write(&path, sealed.armored.as_bytes()).unwrap();
973
974        let err =
975            open_admin_credential(&path, &tmp, Some(&sealed.digest), Some(VTA_DID)).unwrap_err();
976        assert!(err.to_string().contains("did:key:z6MkOtherVTA"), "{err}");
977        let _ = fs::remove_dir_all(&tmp);
978    }
979
980    #[tokio::test]
981    async fn attested_admin_credential_is_rejected() {
982        let tmp = tmp_dir();
983        let created = create_bootstrap_request(&tmp, None).unwrap();
984        let producer = ProducerAssertion {
985            producer_did: "did:key:z6MkTee".into(),
986            proof: AssertionProof::Attested(AttestationQuoteAssertion {
987                format: "aws-nitro-v1".into(),
988                quote_b64: "AAAA".into(),
989            }),
990        };
991        let (path, digest) =
992            seal_to_request(&tmp, &created, producer, &admin_payload(VTA_DID)).await;
993
994        let err = open_admin_credential(&path, &tmp, Some(&digest), Some(VTA_DID)).unwrap_err();
995        assert!(err.to_string().contains("attestation"), "{err}");
996        let _ = fs::remove_dir_all(&tmp);
997    }
998
999    #[test]
1000    fn expected_digest_must_be_64_hex_characters() {
1001        assert!(normalize_expected_digest("").is_err());
1002        assert!(normalize_expected_digest("   ").is_err());
1003        assert!(normalize_expected_digest(&"a".repeat(63)).is_err());
1004        assert!(normalize_expected_digest(&"a".repeat(65)).is_err());
1005        assert!(normalize_expected_digest(&"g".repeat(64)).is_err());
1006        assert_eq!(
1007            normalize_expected_digest(&"AB".repeat(32)).unwrap(),
1008            "ab".repeat(32)
1009        );
1010    }
1011}