1use 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
27pub 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 #[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
83pub fn zero_overwrite_and_remove(path: &Path) -> std::io::Result<()> {
97 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 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
124pub struct CreatedRequest {
127 pub request: BootstrapRequest,
128 pub bundle_id_hex: String,
129 pub secret_path: PathBuf,
130}
131
132pub 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#[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 pub client_x25519_pub: [u8; 32],
175}
176
177pub 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
202pub fn open_armored_bundle_keeping_secret(
211 bundle_path: &Path,
212 config_dir: &Path,
213 expect_digest: Option<&str>,
214 no_verify_digest: bool,
215) -> Result<(OpenedArmored, PathBuf), Box<dyn std::error::Error>> {
216 if expect_digest.is_none() && !no_verify_digest {
217 return Err(
218 "--expect-digest <hex> is required (or pass --no-verify-digest to opt out)".into(),
219 );
220 }
221
222 let armored = fs::read_to_string(bundle_path)
223 .map_err(|e| format!("read {}: {e}", bundle_path.display()))?;
224 let bundles = armor::decode(&armored)?;
225 if bundles.len() != 1 {
226 return Err(format!(
227 "expected exactly one bundle in {}, found {}",
228 bundle_path.display(),
229 bundles.len()
230 )
231 .into());
232 }
233 let bundle = &bundles[0];
234 let bundle_id_hex = hex_lower(&bundle.bundle_id);
235
236 let sp = secret_path(config_dir, &bundle_id_hex)?;
237 if !sp.exists() {
238 return Err(format!(
239 "no stored secret for bundle_id {bundle_id_hex} (expected at {}). \
240 Did you run `bootstrap request` on this host?",
241 sp.display()
242 )
243 .into());
244 }
245 let ed_seed = read_secret(&sp)?;
246 let x_secret = ed25519_seed_to_x25519_secret(&ed_seed);
247
248 let client_x25519_pub = {
253 let signing = ed25519_dalek::SigningKey::from_bytes(&ed_seed);
254 let ed_pub = signing.verifying_key().to_bytes();
255 affinidi_crypto::did_key::ed25519_pub_to_x25519_bytes(&ed_pub).map_err(
256 |e| -> Box<dyn std::error::Error> {
257 format!("derive consumer X25519 pubkey from seed: {e}").into()
258 },
259 )?
260 };
261
262 let digest = bundle_digest(bundle);
263 let opened = open_bundle(&x_secret, bundle, expect_digest)?;
264
265 Ok((
266 OpenedArmored {
267 payload: opened.payload,
268 producer: opened.producer,
269 bundle_id: opened.bundle_id,
270 bundle_id_hex,
271 digest,
272 client_x25519_pub,
273 },
274 sp,
275 ))
276}
277
278pub fn consume_request_secret(path: &Path) {
288 consume_secret(path);
289}
290
291fn consume_secret(path: &Path) {
292 if let Err(e) = zero_overwrite_and_remove(path) {
293 eprintln!(
294 "warning: could not remove used secret {}: {e}",
295 path.display()
296 );
297 }
298}
299
300pub struct CreatedProvisionRequest {
304 pub request: vta_sdk::provision_integration::BootstrapRequest,
308 pub client_did: String,
311 pub bundle_id_hex: String,
314 pub secret_path: PathBuf,
317}
318
319pub async fn create_provision_request(
332 config_dir: &Path,
333 builder: vta_sdk::provision_integration::ProvisionRequestBuilder,
334) -> Result<CreatedProvisionRequest, Box<dyn std::error::Error>> {
335 let signed = builder.sign_ephemeral().await?;
336 let bundle_id_hex = hex_lower(&signed.bundle_id);
337 let sp = secret_path(config_dir, &bundle_id_hex)?;
338 write_secret(&sp, &signed.seed)?;
339 Ok(CreatedProvisionRequest {
340 request: signed.request,
341 client_did: signed.client_did,
342 bundle_id_hex,
343 secret_path: sp,
344 })
345}
346
347pub fn extract_admin_credential(
353 payload: SealedPayloadV1,
354) -> Result<CredentialBundle, Box<dyn std::error::Error>> {
355 match payload {
356 SealedPayloadV1::AdminCredential(c) => Ok(*c),
357 SealedPayloadV1::ContextProvision(p) => Ok(p.credential),
358 SealedPayloadV1::DidSecrets(_) => Err(
359 "cannot install a DidSecrets bundle as an admin credential — use `bootstrap open` to inspect it"
360 .into(),
361 ),
362 SealedPayloadV1::AdminKeySet(_) => Err(
363 "cannot install an AdminKeySet bundle as an admin credential — use `bootstrap open` to inspect it"
364 .into(),
365 ),
366 SealedPayloadV1::RawPrivateKey(_) => Err(
367 "cannot install a RawPrivateKey bundle as an admin credential".into(),
368 ),
369 SealedPayloadV1::TemplateBootstrap(_) => Err(
370 "TemplateBootstrap payloads carry a VC-issued admin authorization, not a \
371 CredentialBundle — open via `pnm bootstrap open` and use the provision-integration \
372 flow to install"
373 .into(),
374 ),
375 SealedPayloadV1::TemplateBootstrapV2(_) => Err(
376 "TemplateBootstrapV2 payloads carry a VC-issued admin authorization, not a \
377 CredentialBundle — open via `pnm bootstrap open` and use the provision-integration \
378 flow to install"
379 .into(),
380 ),
381 SealedPayloadV1::AdminRotation(_) => Err(
382 "AdminRotation payloads carry a VC-issued admin authorization, not a \
383 CredentialBundle — open via `pnm bootstrap open` and use the provision-integration \
384 flow to install"
385 .into(),
386 ),
387 SealedPayloadV1::IssuedCredential(_) => Err(
388 "IssuedCredential payloads carry a holder credential, not an admin CredentialBundle \
389 — receive it into the holder vault via the credential-exchange flow"
390 .into(),
391 ),
392 SealedPayloadV1::MessagingBridgeCredentials(_) => Err(
393 "MessagingBridgeCredentials payloads carry a connector's platform secrets, not an \
394 admin CredentialBundle — open via `pnm bootstrap open` and load them into the \
395 connector's secret store"
396 .into(),
397 ),
398 SealedPayloadV1::SeedMnemonic(_) => Err(
399 "SeedMnemonic payloads carry a VTA's seed mnemonic for an offline backup, not an \
400 admin CredentialBundle — open via `pnm bootstrap open` without --out and write the \
401 words down offline"
402 .into(),
403 ),
404 SealedPayloadV1::ExternalSecret(_) => Err(
405 "ExternalSecret payloads carry an external account's provider secret on its way to \
406 a VTA (external/accounts/secret/set), not an admin CredentialBundle"
407 .into(),
408 ),
409 SealedPayloadV1::ExternalCredential(_) => Err(
410 "ExternalCredential payloads carry a short-lived provider credential for the \
411 integration that asked for it (external/credentials/issue), not an admin \
412 CredentialBundle"
413 .into(),
414 ),
415 _ => Err("this sealed payload is not an admin credential bundle".into()),
418 }
419}
420
421pub use vta_sdk::hex::lower as hex_lower;
422
423pub fn warn_no_verify_digest() {
431 eprintln!(
432 "WARNING: --no-verify-digest disables out-of-band integrity verification.\n\
433 You are trusting the producer pubkey embedded in the bundle without\n\
434 any external anchor. Use only for testing."
435 );
436}
437
438pub fn validate_digest_flags(
449 expect_digest: Option<&str>,
450 no_verify_digest: bool,
451) -> Result<(), Box<dyn std::error::Error>> {
452 match (expect_digest, no_verify_digest) {
453 (Some(_), false) => Ok(()),
454 (None, true) => {
455 warn_no_verify_digest();
456 Ok(())
457 }
458 (Some(_), true) => {
459 Err("--no-verify-digest may not be combined with --expect-digest; pick one".into())
460 }
461 (None, false) => Err(
462 "--expect-digest <hex> is required (or pass --no-verify-digest to opt out \
463 with a warning)"
464 .into(),
465 ),
466 }
467}
468
469pub fn normalize_expected_digest(input: &str) -> Result<String, String> {
476 let digest = input.trim();
477 if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) {
478 return Err(
479 "enter the 64-character hex SHA-256 digest the producer gave you out-of-band".into(),
480 );
481 }
482 Ok(digest.to_ascii_lowercase())
483}
484
485pub fn open_admin_credential(
496 bundle_path: &Path,
497 config_dir: &Path,
498 expect_digest: Option<&str>,
499 expected_vta_did: Option<&str>,
500) -> Result<CredentialBundle, Box<dyn std::error::Error>> {
501 let (opened, secret) = open_armored_bundle_keeping_secret(
502 bundle_path,
503 config_dir,
504 expect_digest,
505 expect_digest.is_none(),
506 )?;
507 let credential = verify_admin_bundle(opened, expect_digest, expected_vta_did)?;
508 consume_secret(&secret);
509 Ok(credential)
510}
511
512pub fn verify_admin_bundle(
534 opened: OpenedArmored,
535 expect_digest: Option<&str>,
536 expected_vta_did: Option<&str>,
537) -> Result<CredentialBundle, Box<dyn std::error::Error>> {
538 if let Some(expected) = expect_digest
539 && expected != opened.digest
540 {
541 return Err(format!(
542 "bundle digest {} does not match the expected digest {expected}",
543 opened.digest
544 )
545 .into());
546 }
547
548 let producer = &opened.producer;
549 let producer_pubkey = match &producer.proof {
550 AssertionProof::DidSigned(_) => {
551 let expected = expected_vta_did.ok_or_else(|| {
552 format!(
553 "the bundle is signed by {}, but there is no expected VTA DID to check \
554 that against; refusing to install it",
555 producer.producer_did
556 )
557 })?;
558 if producer.producer_did != expected {
559 return Err(format!(
560 "the bundle is signed by {}, not by the expected VTA {expected}; refusing \
561 to install it",
562 producer.producer_did
563 )
564 .into());
565 }
566 let pubkey = affinidi_crypto::did_key::did_key_to_ed25519_pub(&producer.producer_did)
567 .map_err(|e| {
568 format!(
569 "cannot verify the producer signature: {} is not an Ed25519 did:key \
570 ({e}); use --expect-digest instead",
571 producer.producer_did
572 )
573 })?;
574 Some(pubkey)
575 }
576 _ => None,
577 };
578
579 let verdict = verify_producer_assertion_with_pubkey(
580 producer,
581 &opened.client_x25519_pub,
582 &opened.bundle_id,
583 producer_pubkey.as_ref(),
584 )?;
585
586 match verdict {
589 VerifiedAssertion::DidSignedVerified(_) => {}
590 VerifiedAssertion::PinnedOnlyAcknowledged(_) => {
591 if expect_digest.is_none() {
592 return Err(
593 "the bundle carries no producer signature, so only its out-of-band \
594 SHA-256 digest can show where it came from; pass --expect-digest <hex>"
595 .into(),
596 );
597 }
598 }
599 VerifiedAssertion::AttestedNeedsNitroCheck(_) => {
600 return Err(
601 "the bundle carries a TEE attestation, which is not verified when installing \
602 a credential from a file; use `pnm bootstrap connect` for attested bootstrap"
603 .into(),
604 );
605 }
606 }
607
608 let credential = extract_admin_credential(opened.payload)?;
609 if let Some(expected) = expected_vta_did
610 && credential.vta_did != expected
611 {
612 return Err(format!(
613 "the credential is for VTA {}, not the expected {expected}; refusing to install it",
614 credential.vta_did
615 )
616 .into());
617 }
618 Ok(credential)
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624 use crate::sealed_producer::{SealedRecipient, seal_for_recipient};
625
626 #[test]
627 fn secrets_dir_creates_when_missing() {
628 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
629 let dir = secrets_dir(&tmp).unwrap();
630 assert!(dir.exists());
631 assert!(dir.ends_with("bootstrap-secrets"));
632 let _ = fs::remove_dir_all(&tmp);
634 }
635
636 #[test]
637 fn create_request_persists_secret() {
638 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
639 let req = create_bootstrap_request(&tmp, Some("unit-test".into())).unwrap();
640 assert!(req.secret_path.exists());
641 let bytes = fs::read(&req.secret_path).unwrap();
642 assert_eq!(bytes.len(), 32);
643 let _ = fs::remove_dir_all(&tmp);
644 }
645
646 #[tokio::test]
647 async fn request_seal_open_round_trip() {
648 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
649
650 let created = create_bootstrap_request(&tmp, None).unwrap();
652
653 let recipient =
655 SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
656 .unwrap();
657 let payload = SealedPayloadV1::AdminCredential(Box::new(
658 vta_sdk::credentials::CredentialBundle::new(
659 "did:key:z6Mk123",
660 "z1234567890",
661 "did:key:z6MkVTA",
662 ),
663 ));
664 let sealed = seal_for_recipient(&recipient, &payload).await.unwrap();
665
666 let bundle_path = tmp.join("bundle.armor");
668 fs::write(&bundle_path, sealed.armored.as_bytes()).unwrap();
669
670 let opened = open_armored_bundle(&bundle_path, &tmp, Some(&sealed.digest), false).unwrap();
672 assert_eq!(opened.bundle_id, created.request.decode_nonce().unwrap());
673
674 let cred = extract_admin_credential(opened.payload).unwrap();
675 assert_eq!(cred.did, "did:key:z6Mk123");
676
677 assert!(!created.secret_path.exists());
679
680 let _ = fs::remove_dir_all(&tmp);
681 }
682
683 #[tokio::test]
687 async fn vti_53_inspecting_a_bundle_keeps_its_seed() {
688 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
689 let created = create_bootstrap_request(&tmp, None).unwrap();
690 let recipient =
691 SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
692 .unwrap();
693 let payload = SealedPayloadV1::AdminCredential(Box::new(
694 vta_sdk::credentials::CredentialBundle::new(
695 "did:key:z6Mk123",
696 "z1234567890",
697 "did:key:z6MkVTA",
698 ),
699 ));
700 let sealed = seal_for_recipient(&recipient, &payload).await.unwrap();
701 let bundle_path = tmp.join("bundle.armor");
702 fs::write(&bundle_path, sealed.armored.as_bytes()).unwrap();
703
704 for _ in 0..2 {
705 let (_, secret) =
706 open_armored_bundle_keeping_secret(&bundle_path, &tmp, Some(&sealed.digest), false)
707 .unwrap();
708 assert_eq!(secret, created.secret_path);
709 assert!(secret.exists(), "inspection must keep the seed");
710 }
711
712 consume_request_secret(&created.secret_path);
713 assert!(!created.secret_path.exists());
714 assert!(
715 open_armored_bundle_keeping_secret(&bundle_path, &tmp, Some(&sealed.digest), false)
716 .is_err(),
717 "a consumed seed opens nothing"
718 );
719
720 let _ = fs::remove_dir_all(&tmp);
721 }
722
723 #[tokio::test]
724 async fn create_provision_request_persists_seed_and_signs() {
725 use vta_sdk::provision_integration::{BootstrapAsk, ProvisionRequestBuilder};
726
727 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
728
729 let builder = ProvisionRequestBuilder::new("didcomm-mediator")
730 .var("URL", "https://mediator.example.com")
731 .context_hint("mediator-prod")
732 .admin_template("vta-admin")
733 .label("cli-common-test");
734
735 let created = create_provision_request(&tmp, builder).await.unwrap();
736
737 assert!(created.secret_path.exists(), "secret must be persisted");
739 let stem = created.secret_path.file_stem().unwrap().to_str().unwrap();
740 assert_eq!(stem, created.bundle_id_hex);
741 let bytes = fs::read(&created.secret_path).unwrap();
742 assert_eq!(bytes.len(), 32);
743
744 let verified = created.request.clone().verify().expect("verify VP");
747 assert_eq!(
748 hex_lower(&verified.decode_nonce().unwrap()),
749 created.bundle_id_hex
750 );
751
752 match verified.ask() {
754 BootstrapAsk::TemplateBootstrap(ask) => {
755 assert_eq!(ask.template.name, "didcomm-mediator");
756 assert_eq!(
757 ask.template.vars.get("URL").and_then(|v| v.as_str()),
758 Some("https://mediator.example.com")
759 );
760 assert_eq!(ask.context_hint.as_deref(), Some("mediator-prod"));
761 assert_eq!(
762 ask.admin_template.as_ref().map(|t| t.name.as_str()),
763 Some("vta-admin")
764 );
765 }
766 other => panic!("expected TemplateBootstrap, got {other:?}"),
767 }
768
769 assert_eq!(created.client_did, verified.holder());
771
772 let _ = fs::remove_dir_all(&tmp);
773 }
774
775 #[cfg(unix)]
776 #[tokio::test]
777 async fn create_provision_request_seed_file_is_owner_only() {
778 use std::os::unix::fs::PermissionsExt;
779 use vta_sdk::provision_integration::ProvisionRequestBuilder;
780
781 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
782 let builder =
783 ProvisionRequestBuilder::new("didcomm-mediator").var("URL", "https://m.example.com");
784 let created = create_provision_request(&tmp, builder).await.unwrap();
785
786 let mode = fs::metadata(&created.secret_path)
787 .unwrap()
788 .permissions()
789 .mode();
790 assert_eq!(
792 mode & 0o777,
793 0o600,
794 "seed file must be 0600, got {:o}",
795 mode & 0o777
796 );
797
798 let _ = fs::remove_dir_all(&tmp);
799 }
800
801 #[test]
802 fn zero_overwrite_removes_file_and_scrubs_bytes() {
803 let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
810 fs::create_dir_all(&tmp).unwrap();
811 let f = tmp.join("secret.bin");
812 let original: Vec<u8> = (0u8..32).collect();
813 fs::write(&f, &original).unwrap();
814 assert!(f.exists());
815
816 zero_overwrite_and_remove(&f).expect("remove succeeds");
817 assert!(!f.exists(), "file must be removed");
818 let _ = fs::remove_dir_all(&tmp);
819 }
820
821 #[test]
822 fn zero_overwrite_errors_on_missing_file() {
823 let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
824 let missing = tmp.join("does-not-exist");
825 let err = zero_overwrite_and_remove(&missing).unwrap_err();
826 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
827 }
828
829 #[test]
830 fn zero_overwrite_handles_empty_file() {
831 let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
834 fs::create_dir_all(&tmp).unwrap();
835 let f = tmp.join("empty.bin");
836 fs::write(&f, b"").unwrap();
837 zero_overwrite_and_remove(&f).expect("remove succeeds");
838 assert!(!f.exists());
839 let _ = fs::remove_dir_all(&tmp);
840 }
841
842 #[test]
843 fn open_rejects_missing_digest_without_opt_out() {
844 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
845 fs::create_dir_all(&tmp).unwrap();
846 let bundle_path = tmp.join("bundle.armor");
847 fs::write(&bundle_path, b"armor placeholder").unwrap();
848 let err = open_armored_bundle(&bundle_path, &tmp, None, false).unwrap_err();
849 assert!(err.to_string().contains("expect-digest"));
850 let _ = fs::remove_dir_all(&tmp);
851 }
852
853 #[test]
854 fn extract_rejects_did_secrets() {
855 let payload =
856 SealedPayloadV1::DidSecrets(Box::new(vta_sdk::did_secrets::DidSecretsBundle {
857 did: "did:key:z6Mk".into(),
858 secrets: vec![],
859 }));
860 let err = extract_admin_credential(payload).unwrap_err();
861 assert!(err.to_string().contains("DidSecrets"));
862 }
863
864 #[test]
865 fn extract_accepts_context_provision() {
866 let payload = SealedPayloadV1::ContextProvision(Box::new(
867 vta_sdk::context_provision::ContextProvisionBundle {
868 context_id: "app".into(),
869 context_name: "App".into(),
870 vta_url: None,
871 vta_did: None,
872 credential: vta_sdk::credentials::CredentialBundle::new(
873 "did:key:z6Mk123",
874 "z1234567890",
875 "did:key:z6MkVTA",
876 ),
877 admin_did: "did:key:z6Mk123".into(),
878 did: None,
879 },
880 ));
881 let cred = extract_admin_credential(payload).unwrap();
882 assert_eq!(cred.did, "did:key:z6Mk123");
883 }
884
885 use vta_sdk::sealed_transfer::{
888 AttestationQuoteAssertion, DidSignedAssertion, ProducerAssertion,
889 };
890
891 const VTA_DID: &str = "did:key:z6MkVTA";
892
893 fn tmp_dir() -> PathBuf {
894 std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()))
895 }
896
897 fn admin_payload(vta_did: &str) -> SealedPayloadV1 {
898 SealedPayloadV1::AdminCredential(Box::new(vta_sdk::credentials::CredentialBundle::new(
899 "did:key:z6Mk123",
900 "z1234567890",
901 vta_did,
902 )))
903 }
904
905 async fn seal_to_request(
908 dir: &Path,
909 created: &CreatedRequest,
910 producer: ProducerAssertion,
911 payload: &SealedPayloadV1,
912 ) -> (PathBuf, String) {
913 let client_x = created.request.decode_client_x25519_pub().unwrap();
914 let bundle_id = created.request.decode_nonce().unwrap();
915 let store = vta_sdk::sealed_transfer::InMemoryNonceStore::new();
916 let bundle =
917 vta_sdk::sealed_transfer::seal_payload(&client_x, bundle_id, producer, payload, &store)
918 .await
919 .unwrap();
920 let path = dir.join("bundle.armor");
921 fs::write(&path, armor::encode(&bundle)).unwrap();
922 (path, bundle_digest(&bundle))
923 }
924
925 fn did_signed(
930 created: &CreatedRequest,
931 producer_did: Option<&str>,
932 ) -> (ProducerAssertion, String) {
933 use base64::Engine;
934 use ed25519_dalek::Signer;
935
936 let (seed, public) = generate_ed25519_keypair();
937 let key_did = affinidi_crypto::did_key::ed25519_pub_to_did_key(&public);
938 let did = producer_did.unwrap_or(&key_did).to_string();
939
940 let mut msg = vta_sdk::sealed_transfer::verify::DID_SIGNED_DOMAIN_TAG.to_vec();
941 msg.extend_from_slice(&created.request.decode_client_x25519_pub().unwrap());
942 msg.extend_from_slice(&created.request.decode_nonce().unwrap());
943 let sig = ed25519_dalek::SigningKey::from_bytes(&seed).sign(&msg);
944
945 let assertion = ProducerAssertion {
946 producer_did: did.clone(),
947 proof: AssertionProof::DidSigned(DidSignedAssertion {
948 did: did.clone(),
949 signature_b64: base64::engine::general_purpose::URL_SAFE_NO_PAD
950 .encode(sig.to_bytes()),
951 verification_method: format!("{did}#key-0"),
952 }),
953 };
954 (assertion, key_did)
955 }
956
957 #[tokio::test]
958 async fn admin_credential_signed_by_an_unexpected_did_key_is_rejected() {
959 let tmp = tmp_dir();
960 let created = create_bootstrap_request(&tmp, None).unwrap();
961 let (assertion, attacker_did) = did_signed(&created, None);
964 let (path, digest) =
965 seal_to_request(&tmp, &created, assertion, &admin_payload(&attacker_did)).await;
966
967 let err = open_admin_credential(&path, &tmp, None, Some("did:webvh:victim")).unwrap_err();
968 assert!(err.to_string().contains("not by the expected VTA"), "{err}");
969 assert!(
972 open_admin_credential(&path, &tmp, Some(&digest), Some("did:webvh:victim")).is_err()
973 );
974 assert!(open_admin_credential(&path, &tmp, None, None).is_err());
975
976 assert!(created.secret_path.exists());
978 let _ = fs::remove_dir_all(&tmp);
979 }
980
981 #[tokio::test]
982 async fn admin_credential_signed_by_the_expected_vta_is_accepted() {
983 let tmp = tmp_dir();
984 let created = create_bootstrap_request(&tmp, None).unwrap();
985 let (assertion, vta_did) = did_signed(&created, None);
986 let (path, _) = seal_to_request(&tmp, &created, assertion, &admin_payload(&vta_did)).await;
987
988 let cred = open_admin_credential(&path, &tmp, None, Some(&vta_did)).unwrap();
989 assert_eq!(cred.vta_did, vta_did);
990 assert!(
991 !created.secret_path.exists(),
992 "an accepted bundle consumes the request secret"
993 );
994 let _ = fs::remove_dir_all(&tmp);
995 }
996
997 #[tokio::test]
998 async fn did_signed_by_another_key_in_the_vta_name_is_rejected() {
999 let tmp = tmp_dir();
1000 let created = create_bootstrap_request(&tmp, None).unwrap();
1001 let (_, vta_did) = did_signed(&created, None);
1003 let (forged, _) = did_signed(&created, Some(&vta_did));
1004 let (path, _) = seal_to_request(&tmp, &created, forged, &admin_payload(&vta_did)).await;
1005
1006 assert!(open_admin_credential(&path, &tmp, None, Some(&vta_did)).is_err());
1007 let _ = fs::remove_dir_all(&tmp);
1008 }
1009
1010 #[tokio::test]
1011 async fn pinned_only_admin_credential_needs_the_digest() {
1012 let tmp = tmp_dir();
1013 let created = create_bootstrap_request(&tmp, None).unwrap();
1014 let recipient =
1015 SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
1016 .unwrap();
1017 let sealed = seal_for_recipient(&recipient, &admin_payload(VTA_DID))
1018 .await
1019 .unwrap();
1020 let path = tmp.join("bundle.armor");
1021 fs::write(&path, sealed.armored.as_bytes()).unwrap();
1022
1023 assert!(open_admin_credential(&path, &tmp, None, Some(VTA_DID)).is_err());
1024 assert!(created.secret_path.exists());
1025
1026 let typed =
1028 normalize_expected_digest(&format!(" {}\n", sealed.digest.to_uppercase())).unwrap();
1029 let cred = open_admin_credential(&path, &tmp, Some(&typed), Some(VTA_DID)).unwrap();
1030 assert_eq!(cred.vta_did, VTA_DID);
1031 let _ = fs::remove_dir_all(&tmp);
1032 }
1033
1034 #[tokio::test]
1035 async fn admin_credential_for_a_different_vta_is_rejected() {
1036 let tmp = tmp_dir();
1037 let created = create_bootstrap_request(&tmp, None).unwrap();
1038 let recipient =
1039 SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
1040 .unwrap();
1041 let sealed = seal_for_recipient(&recipient, &admin_payload("did:key:z6MkOtherVTA"))
1042 .await
1043 .unwrap();
1044 let path = tmp.join("bundle.armor");
1045 fs::write(&path, sealed.armored.as_bytes()).unwrap();
1046
1047 let err =
1048 open_admin_credential(&path, &tmp, Some(&sealed.digest), Some(VTA_DID)).unwrap_err();
1049 assert!(err.to_string().contains("did:key:z6MkOtherVTA"), "{err}");
1050 let _ = fs::remove_dir_all(&tmp);
1051 }
1052
1053 #[tokio::test]
1054 async fn attested_admin_credential_is_rejected() {
1055 let tmp = tmp_dir();
1056 let created = create_bootstrap_request(&tmp, None).unwrap();
1057 let producer = ProducerAssertion {
1058 producer_did: "did:key:z6MkTee".into(),
1059 proof: AssertionProof::Attested(AttestationQuoteAssertion {
1060 format: "aws-nitro-v1".into(),
1061 quote_b64: "AAAA".into(),
1062 }),
1063 };
1064 let (path, digest) =
1065 seal_to_request(&tmp, &created, producer, &admin_payload(VTA_DID)).await;
1066
1067 let err = open_admin_credential(&path, &tmp, Some(&digest), Some(VTA_DID)).unwrap_err();
1068 assert!(err.to_string().contains("attestation"), "{err}");
1069 let _ = fs::remove_dir_all(&tmp);
1070 }
1071
1072 #[test]
1073 fn expected_digest_must_be_64_hex_characters() {
1074 assert!(normalize_expected_digest("").is_err());
1075 assert!(normalize_expected_digest(" ").is_err());
1076 assert!(normalize_expected_digest(&"a".repeat(63)).is_err());
1077 assert!(normalize_expected_digest(&"a".repeat(65)).is_err());
1078 assert!(normalize_expected_digest(&"g".repeat(64)).is_err());
1079 assert_eq!(
1080 normalize_expected_digest(&"AB".repeat(32)).unwrap(),
1081 "ab".repeat(32)
1082 );
1083 }
1084}