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::{
20    BootstrapRequest, SealedPayloadV1, armor, bundle_digest, ed25519_seed_to_x25519_secret,
21    generate_ed25519_keypair, open_bundle,
22};
23
24const SECRETS_SUBDIR: &str = "bootstrap-secrets";
25
26/// Resolve the per-config bootstrap secrets directory, creating it on first
27/// use with owner-only permissions (0700 on Unix, user-only DACL on
28/// Windows via `icacls`). See [`crate::secure_file::restrict_dir_to_owner`].
29pub fn secrets_dir(config_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
30    let dir = config_dir.join(SECRETS_SUBDIR);
31    if !dir.exists() {
32        fs::create_dir_all(&dir)?;
33        if let Err(e) = crate::secure_file::restrict_dir_to_owner(&dir) {
34            eprintln!(
35                "warning: could not restrict {} to owner ({e}) — contents may be \
36                 accessible to other local users",
37                dir.display()
38            );
39        }
40    }
41    Ok(dir)
42}
43
44fn secret_path(
45    config_dir: &Path,
46    bundle_id_hex: &str,
47) -> Result<PathBuf, Box<dyn std::error::Error>> {
48    Ok(secrets_dir(config_dir)?.join(format!("{bundle_id_hex}.key")))
49}
50
51fn write_secret(path: &Path, secret: &[u8; 32]) -> Result<(), Box<dyn std::error::Error>> {
52    let mut opts = fs::OpenOptions::new();
53    opts.create(true).write(true).truncate(true);
54    // Unix: open with 0600 atomically so the file is never publicly
55    // readable between create and chmod. Windows: we can't set a DACL
56    // at open time via `OpenOptions`, so the file briefly exists with
57    // the directory's inherited ACL (already owner-only courtesy of
58    // `secrets_dir`). Post-open we tighten via `restrict_file_to_owner`.
59    #[cfg(unix)]
60    opts.mode(0o600);
61    let mut file = opts.open(path)?;
62    file.write_all(secret)?;
63    drop(file);
64    if let Err(e) = crate::secure_file::restrict_file_to_owner(path) {
65        eprintln!(
66            "warning: could not restrict {} to owner ({e}) — secret may be readable by \
67             other local users",
68            path.display()
69        );
70    }
71    Ok(())
72}
73
74fn read_secret(path: &Path) -> Result<[u8; 32], Box<dyn std::error::Error>> {
75    let bytes = fs::read(path)?;
76    bytes
77        .as_slice()
78        .try_into()
79        .map_err(|_| format!("secret file {} is not 32 bytes", path.display()).into())
80}
81
82/// Overwrite a file's bytes with zeros, fsync, then unlink.
83///
84/// This is a best-effort forensic-resistance measure: on rotating media
85/// it overwrites the sectors that held the secret before we forget
86/// where they are. On modern SSDs with wear-levelling the write may be
87/// remapped rather than overwriting the physical cells — still no worse
88/// than plain unlink, and meaningfully better on the platforms where
89/// direct overwrite wins (HDDs, ramdisk, most filesystems on older
90/// kernels). Defence-in-depth, not a hard guarantee.
91///
92/// Errors at any step are non-fatal for the surrounding flow: the
93/// caller gets a `Result` so it can log, but the bundle has already
94/// been consumed. Callers typically print a warning and continue.
95pub fn zero_overwrite_and_remove(path: &Path) -> std::io::Result<()> {
96    // Stat for size *before* we open to write — truncating via OpenOptions
97    // would drop the old bytes before we get a chance to overwrite them.
98    let metadata = fs::metadata(path)?;
99    let len = metadata.len() as usize;
100
101    if len > 0 {
102        let mut file = fs::OpenOptions::new()
103            .write(true)
104            .truncate(false)
105            .open(path)?;
106        // Stream zeros rather than allocating a `vec![0u8; len]` — a
107        // single page buffer handles keys (32 B) and armored bundles
108        // (~KB) without surprises on tiny embedded targets.
109        const ZEROS: [u8; 4096] = [0u8; 4096];
110        let mut remaining = len;
111        while remaining > 0 {
112            let chunk = remaining.min(ZEROS.len());
113            file.write_all(&ZEROS[..chunk])?;
114            remaining -= chunk;
115        }
116        file.flush()?;
117        file.sync_all()?;
118    }
119
120    fs::remove_file(path)
121}
122
123/// The outcome of [`create_bootstrap_request`]: the serialized request body
124/// and the bundle id (for the `secret stored at <path>` banner).
125pub struct CreatedRequest {
126    pub request: BootstrapRequest,
127    pub bundle_id_hex: String,
128    pub secret_path: PathBuf,
129}
130
131/// Generate a fresh Ed25519 keypair + nonce, persist the **seed** (not the
132/// derived X25519 secret) under `config_dir`, and return a
133/// [`BootstrapRequest`] ready to hand to the producer.
134///
135/// Persisting the Ed25519 seed (rather than the X25519 secret) means the
136/// same stored material can later be reused as a signing identity without
137/// regenerating.
138pub fn create_bootstrap_request(
139    config_dir: &Path,
140    label: Option<String>,
141) -> Result<CreatedRequest, Box<dyn std::error::Error>> {
142    let (seed, public) = generate_ed25519_keypair();
143    let nonce: [u8; 16] = rand::random();
144    let bundle_id_hex = hex_lower(&nonce);
145    let sp = secret_path(config_dir, &bundle_id_hex)?;
146    write_secret(&sp, &seed)?;
147    let request = BootstrapRequest::new(public, nonce, label);
148    Ok(CreatedRequest {
149        request,
150        bundle_id_hex,
151        secret_path: sp,
152    })
153}
154
155/// The result of [`open_armored_bundle`] — the full sealed payload plus the
156/// producer assertion, ready for caller-specific trust verification.
157#[derive(Debug)]
158pub struct OpenedArmored {
159    pub payload: SealedPayloadV1,
160    pub producer: vta_sdk::sealed_transfer::ProducerAssertion,
161    pub bundle_id: [u8; 16],
162    pub bundle_id_hex: String,
163    pub digest: String,
164    /// Consumer's X25519 public key — the `client_x25519_pub` the
165    /// producer signed over in a `DidSigned` assertion. Derived from the
166    /// stored Ed25519 seed that opened the bundle
167    /// (`ed25519_pub_to_x25519_bytes(ed25519_pub)`), captured here
168    /// because the seed file is zeroized+removed on successful open.
169    ///
170    /// Downstream verification of the producer assertion feeds this
171    /// into
172    /// [`vta_sdk::sealed_transfer::verify::verify_producer_assertion_with_pubkey`].
173    pub client_x25519_pub: [u8; 32],
174}
175
176/// Read an armored sealed bundle from `bundle_path`, load the corresponding
177/// secret from `config_dir`, open and verify. The caller is responsible for
178/// passing an `expect_digest` unless `no_verify_digest` is set.
179///
180/// Best-effort removal of the used secret file on success — the bundle id is
181/// single-use, and keeping the secret around only widens blast radius.
182pub fn open_armored_bundle(
183    bundle_path: &Path,
184    config_dir: &Path,
185    expect_digest: Option<&str>,
186    no_verify_digest: bool,
187) -> Result<OpenedArmored, Box<dyn std::error::Error>> {
188    if expect_digest.is_none() && !no_verify_digest {
189        return Err(
190            "--expect-digest <hex> is required (or pass --no-verify-digest to opt out)".into(),
191        );
192    }
193
194    let armored = fs::read_to_string(bundle_path)
195        .map_err(|e| format!("read {}: {e}", bundle_path.display()))?;
196    let bundles = armor::decode(&armored)?;
197    if bundles.len() != 1 {
198        return Err(format!(
199            "expected exactly one bundle in {}, found {}",
200            bundle_path.display(),
201            bundles.len()
202        )
203        .into());
204    }
205    let bundle = &bundles[0];
206    let bundle_id_hex = hex_lower(&bundle.bundle_id);
207
208    let sp = secret_path(config_dir, &bundle_id_hex)?;
209    if !sp.exists() {
210        return Err(format!(
211            "no stored secret for bundle_id {bundle_id_hex} (expected at {}). \
212             Did you run `bootstrap request` on this host?",
213            sp.display()
214        )
215        .into());
216    }
217    let ed_seed = read_secret(&sp)?;
218    let x_secret = ed25519_seed_to_x25519_secret(&ed_seed);
219
220    // Derive the consumer's X25519 pubkey — the producer signed over
221    // this in its DidSigned assertion. Derived here (while we still
222    // have the seed) rather than forcing the CLI caller to re-read
223    // the secret file, which we're about to delete.
224    let client_x25519_pub = {
225        let signing = ed25519_dalek::SigningKey::from_bytes(&ed_seed);
226        let ed_pub = signing.verifying_key().to_bytes();
227        affinidi_crypto::did_key::ed25519_pub_to_x25519_bytes(&ed_pub).map_err(
228            |e| -> Box<dyn std::error::Error> {
229                format!("derive consumer X25519 pubkey from seed: {e}").into()
230            },
231        )?
232    };
233
234    let digest = bundle_digest(bundle);
235    let opened = open_bundle(&x_secret, bundle, expect_digest)?;
236
237    // Best-effort cleanup. If the caller later fails, the secret is gone —
238    // that's fine because the bundle id is single-use anyway; a retry would
239    // need a fresh request. Overwrite-then-unlink so the old bytes aren't
240    // left sitting on disk after unlink (see `zero_overwrite_and_remove`).
241    if let Err(e) = zero_overwrite_and_remove(&sp) {
242        eprintln!(
243            "warning: could not remove used secret {}: {e}",
244            sp.display()
245        );
246    }
247
248    Ok(OpenedArmored {
249        payload: opened.payload,
250        producer: opened.producer,
251        bundle_id: opened.bundle_id,
252        bundle_id_hex,
253        digest,
254        client_x25519_pub,
255    })
256}
257
258/// The outcome of [`create_provision_request`]: the signed VP plus the
259/// bookkeeping fields callers need to hand to the operator / match the
260/// returned sealed bundle.
261pub struct CreatedProvisionRequest {
262    /// Signed VP (VC Data Model 2.0 `VerifiablePresentation` +
263    /// `BootstrapRequest` types) — serialize and hand to the VTA
264    /// operator for `vta bootstrap provision-integration --request ...`.
265    pub request: vta_sdk::provision_integration::BootstrapRequest,
266    /// `did:key:z6Mk...` derived from the ephemeral keypair; mirrors
267    /// `request.holder`.
268    pub client_did: String,
269    /// Hex-encoded 16-byte bundle id (== the VP's `nonce`). Also the
270    /// filename stem under which the seed was persisted.
271    pub bundle_id_hex: String,
272    /// Absolute path to the persisted Ed25519 seed. Read-restricted to
273    /// the owner (0600 on Unix).
274    pub secret_path: PathBuf,
275}
276
277/// Generate a fresh ephemeral Ed25519 keypair, persist the seed under
278/// `<config_dir>/bootstrap-secrets/<bundle_id_hex>.key`, and return a
279/// signed VP-framed [`vta_sdk::provision_integration::BootstrapRequest`]
280/// ready to hand to the VTA operator's
281/// `vta bootstrap provision-integration` CLI.
282///
283/// Thin wrapper over
284/// [`vta_sdk::provision_integration::ProvisionRequestBuilder::sign_ephemeral`]
285/// that adds the CLI-common seed-persistence convention — matching the
286/// layout used by the v1 [`create_bootstrap_request`] path, so the same
287/// `<config_dir>` lets [`open_armored_bundle`] find the secret at
288/// open-time regardless of which request flavour produced it.
289pub async fn create_provision_request(
290    config_dir: &Path,
291    builder: vta_sdk::provision_integration::ProvisionRequestBuilder,
292) -> Result<CreatedProvisionRequest, Box<dyn std::error::Error>> {
293    let signed = builder.sign_ephemeral().await?;
294    let bundle_id_hex = hex_lower(&signed.bundle_id);
295    let sp = secret_path(config_dir, &bundle_id_hex)?;
296    write_secret(&sp, &signed.seed)?;
297    Ok(CreatedProvisionRequest {
298        request: signed.request,
299        client_did: signed.client_did,
300        bundle_id_hex,
301        secret_path: sp,
302    })
303}
304
305/// Extract the [`CredentialBundle`] from an opened payload.
306///
307/// Accepts `AdminCredential` directly and `ContextProvision` (unwrapping the
308/// inner admin credential) — both are "install an admin identity" flows for a
309/// consumer. Other variants are rejected with a descriptive error.
310pub fn extract_admin_credential(
311    payload: SealedPayloadV1,
312) -> Result<CredentialBundle, Box<dyn std::error::Error>> {
313    match payload {
314        SealedPayloadV1::AdminCredential(c) => Ok(*c),
315        SealedPayloadV1::ContextProvision(p) => Ok(p.credential),
316        SealedPayloadV1::DidSecrets(_) => Err(
317            "cannot install a DidSecrets bundle as an admin credential — use `bootstrap open` to inspect it"
318                .into(),
319        ),
320        SealedPayloadV1::AdminKeySet(_) => Err(
321            "cannot install an AdminKeySet bundle as an admin credential — use `bootstrap open` to inspect it"
322                .into(),
323        ),
324        SealedPayloadV1::RawPrivateKey(_) => Err(
325            "cannot install a RawPrivateKey bundle as an admin credential".into(),
326        ),
327        SealedPayloadV1::TemplateBootstrap(_) => Err(
328            "TemplateBootstrap payloads carry a VC-issued admin authorization, not a \
329             CredentialBundle — open via `pnm bootstrap open` and use the provision-integration \
330             flow to install"
331                .into(),
332        ),
333        SealedPayloadV1::AdminRotation(_) => Err(
334            "AdminRotation payloads carry a VC-issued admin authorization, not a \
335             CredentialBundle — open via `pnm bootstrap open` and use the provision-integration \
336             flow to install"
337                .into(),
338        ),
339    }
340}
341
342pub use vta_sdk::hex::lower as hex_lower;
343
344/// Emit the canonical `--no-verify-digest` warning to stderr.
345///
346/// Single source of truth for the wording — every CLI surface that
347/// accepts `--no-verify-digest` should call this so a future tweak to
348/// the message lands everywhere at once. Per CLAUDE.md, digest pinning
349/// is mandatory at the CLI; this helper is what the opt-out fires when
350/// the operator explicitly chose to disable it.
351pub fn warn_no_verify_digest() {
352    eprintln!(
353        "WARNING: --no-verify-digest disables out-of-band integrity verification.\n\
354         You are trusting the producer pubkey embedded in the bundle without\n\
355         any external anchor. Use only for testing."
356    );
357}
358
359/// Validate the `(--expect-digest, --no-verify-digest)` combination and
360/// fire the opt-out warning when applicable.
361///
362/// Rules:
363/// - One of the two must be supplied (no silent TOFU).
364/// - They cannot both be supplied — that's an operator error.
365/// - On `--no-verify-digest`, the warning is printed.
366///
367/// Returns `Ok(())` when the flags are coherent; otherwise an error
368/// message suitable for surfacing to the operator verbatim.
369pub fn validate_digest_flags(
370    expect_digest: Option<&str>,
371    no_verify_digest: bool,
372) -> Result<(), Box<dyn std::error::Error>> {
373    match (expect_digest, no_verify_digest) {
374        (Some(_), false) => Ok(()),
375        (None, true) => {
376            warn_no_verify_digest();
377            Ok(())
378        }
379        (Some(_), true) => {
380            Err("--no-verify-digest may not be combined with --expect-digest; pick one".into())
381        }
382        (None, false) => Err(
383            "--expect-digest <hex> is required (or pass --no-verify-digest to opt out \
384             with a warning)"
385                .into(),
386        ),
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use crate::sealed_producer::{SealedRecipient, seal_for_recipient};
394
395    #[test]
396    fn secrets_dir_creates_when_missing() {
397        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
398        let dir = secrets_dir(&tmp).unwrap();
399        assert!(dir.exists());
400        assert!(dir.ends_with("bootstrap-secrets"));
401        // Clean up — test only uses the dir once.
402        let _ = fs::remove_dir_all(&tmp);
403    }
404
405    #[test]
406    fn create_request_persists_secret() {
407        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
408        let req = create_bootstrap_request(&tmp, Some("unit-test".into())).unwrap();
409        assert!(req.secret_path.exists());
410        let bytes = fs::read(&req.secret_path).unwrap();
411        assert_eq!(bytes.len(), 32);
412        let _ = fs::remove_dir_all(&tmp);
413    }
414
415    #[tokio::test]
416    async fn request_seal_open_round_trip() {
417        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
418
419        // Consumer: create request + persist secret.
420        let created = create_bootstrap_request(&tmp, None).unwrap();
421
422        // Producer: seal to the request's pubkey.
423        let recipient =
424            SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
425                .unwrap();
426        let payload = SealedPayloadV1::AdminCredential(Box::new(
427            vta_sdk::credentials::CredentialBundle::new(
428                "did:key:z6Mk123",
429                "z1234567890",
430                "did:key:z6MkVTA",
431            ),
432        ));
433        let sealed = seal_for_recipient(&recipient, &payload).await.unwrap();
434
435        // Write armored to file.
436        let bundle_path = tmp.join("bundle.armor");
437        fs::write(&bundle_path, sealed.armored.as_bytes()).unwrap();
438
439        // Consumer: open.
440        let opened = open_armored_bundle(&bundle_path, &tmp, Some(&sealed.digest), false).unwrap();
441        assert_eq!(opened.bundle_id, created.request.decode_nonce().unwrap());
442
443        let cred = extract_admin_credential(opened.payload).unwrap();
444        assert_eq!(cred.did, "did:key:z6Mk123");
445
446        // Secret file is removed after successful open.
447        assert!(!created.secret_path.exists());
448
449        let _ = fs::remove_dir_all(&tmp);
450    }
451
452    #[tokio::test]
453    async fn create_provision_request_persists_seed_and_signs() {
454        use vta_sdk::provision_integration::{BootstrapAsk, ProvisionRequestBuilder};
455
456        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
457
458        let builder = ProvisionRequestBuilder::new("didcomm-mediator")
459            .var("URL", "https://mediator.example.com")
460            .context_hint("mediator-prod")
461            .admin_template("vta-admin")
462            .label("cli-common-test");
463
464        let created = create_provision_request(&tmp, builder).await.unwrap();
465
466        // Seed persisted under bootstrap-secrets/<bundle_id>.key, 32 bytes.
467        assert!(created.secret_path.exists(), "secret must be persisted");
468        let stem = created.secret_path.file_stem().unwrap().to_str().unwrap();
469        assert_eq!(stem, created.bundle_id_hex);
470        let bytes = fs::read(&created.secret_path).unwrap();
471        assert_eq!(bytes.len(), 32);
472
473        // Bundle id matches the VP nonce (what the producer will use as
474        // the sealed-bundle id).
475        let verified = created.request.clone().verify().expect("verify VP");
476        assert_eq!(
477            hex_lower(&verified.decode_nonce().unwrap()),
478            created.bundle_id_hex
479        );
480
481        // Ask shape preserved through the SDK builder.
482        match verified.ask() {
483            BootstrapAsk::TemplateBootstrap(ask) => {
484                assert_eq!(ask.template.name, "didcomm-mediator");
485                assert_eq!(
486                    ask.template.vars.get("URL").and_then(|v| v.as_str()),
487                    Some("https://mediator.example.com")
488                );
489                assert_eq!(ask.context_hint.as_deref(), Some("mediator-prod"));
490                assert_eq!(
491                    ask.admin_template.as_ref().map(|t| t.name.as_str()),
492                    Some("vta-admin")
493                );
494            }
495            other => panic!("expected TemplateBootstrap, got {other:?}"),
496        }
497
498        // client_did returned matches the VP holder.
499        assert_eq!(created.client_did, verified.holder());
500
501        let _ = fs::remove_dir_all(&tmp);
502    }
503
504    #[cfg(unix)]
505    #[tokio::test]
506    async fn create_provision_request_seed_file_is_owner_only() {
507        use std::os::unix::fs::PermissionsExt;
508        use vta_sdk::provision_integration::ProvisionRequestBuilder;
509
510        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
511        let builder =
512            ProvisionRequestBuilder::new("didcomm-mediator").var("URL", "https://m.example.com");
513        let created = create_provision_request(&tmp, builder).await.unwrap();
514
515        let mode = fs::metadata(&created.secret_path)
516            .unwrap()
517            .permissions()
518            .mode();
519        // mode & 0o777 isolates the permission bits; must be 0o600.
520        assert_eq!(
521            mode & 0o777,
522            0o600,
523            "seed file must be 0600, got {:o}",
524            mode & 0o777
525        );
526
527        let _ = fs::remove_dir_all(&tmp);
528    }
529
530    #[test]
531    fn zero_overwrite_removes_file_and_scrubs_bytes() {
532        // Write some non-zero contents, stat the backing storage, run
533        // the scrub-then-unlink, confirm the file is gone. We can't
534        // reliably probe unlinked blocks from user-space, so the test
535        // checks the observable invariant: file is removed. The
536        // "bytes zeroed first" property is what item 21 actually
537        // wants — proven structurally by the helper's source.
538        let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
539        fs::create_dir_all(&tmp).unwrap();
540        let f = tmp.join("secret.bin");
541        let original: Vec<u8> = (0u8..32).collect();
542        fs::write(&f, &original).unwrap();
543        assert!(f.exists());
544
545        zero_overwrite_and_remove(&f).expect("remove succeeds");
546        assert!(!f.exists(), "file must be removed");
547        let _ = fs::remove_dir_all(&tmp);
548    }
549
550    #[test]
551    fn zero_overwrite_errors_on_missing_file() {
552        let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
553        let missing = tmp.join("does-not-exist");
554        let err = zero_overwrite_and_remove(&missing).unwrap_err();
555        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
556    }
557
558    #[test]
559    fn zero_overwrite_handles_empty_file() {
560        // A zero-byte file triggers the `len > 0` short-circuit — no
561        // write pass, but the unlink must still succeed.
562        let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
563        fs::create_dir_all(&tmp).unwrap();
564        let f = tmp.join("empty.bin");
565        fs::write(&f, b"").unwrap();
566        zero_overwrite_and_remove(&f).expect("remove succeeds");
567        assert!(!f.exists());
568        let _ = fs::remove_dir_all(&tmp);
569    }
570
571    #[test]
572    fn open_rejects_missing_digest_without_opt_out() {
573        let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
574        fs::create_dir_all(&tmp).unwrap();
575        let bundle_path = tmp.join("bundle.armor");
576        fs::write(&bundle_path, b"armor placeholder").unwrap();
577        let err = open_armored_bundle(&bundle_path, &tmp, None, false).unwrap_err();
578        assert!(err.to_string().contains("expect-digest"));
579        let _ = fs::remove_dir_all(&tmp);
580    }
581
582    #[test]
583    fn extract_rejects_did_secrets() {
584        let payload =
585            SealedPayloadV1::DidSecrets(Box::new(vta_sdk::did_secrets::DidSecretsBundle {
586                did: "did:key:z6Mk".into(),
587                secrets: vec![],
588            }));
589        let err = extract_admin_credential(payload).unwrap_err();
590        assert!(err.to_string().contains("DidSecrets"));
591    }
592
593    #[test]
594    fn extract_accepts_context_provision() {
595        let payload = SealedPayloadV1::ContextProvision(Box::new(
596            vta_sdk::context_provision::ContextProvisionBundle {
597                context_id: "app".into(),
598                context_name: "App".into(),
599                vta_url: None,
600                vta_did: None,
601                credential: vta_sdk::credentials::CredentialBundle::new(
602                    "did:key:z6Mk123",
603                    "z1234567890",
604                    "did:key:z6MkVTA",
605                ),
606                admin_did: "did:key:z6Mk123".into(),
607                did: None,
608            },
609        ));
610        let cred = extract_admin_credential(payload).unwrap();
611        assert_eq!(cred.did, "did:key:z6Mk123");
612    }
613}