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