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
202fn 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 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
272fn 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
287pub struct CreatedProvisionRequest {
291 pub request: vta_sdk::provision_integration::BootstrapRequest,
295 pub client_did: String,
298 pub bundle_id_hex: String,
301 pub secret_path: PathBuf,
304}
305
306pub 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
334pub fn extract_admin_credential(
340 payload: SealedPayloadV1,
341) -> Result<CredentialBundle, Box<dyn std::error::Error>> {
342 match payload {
343 SealedPayloadV1::AdminCredential(c) => Ok(*c),
344 SealedPayloadV1::ContextProvision(p) => Ok(p.credential),
345 SealedPayloadV1::DidSecrets(_) => Err(
346 "cannot install a DidSecrets bundle as an admin credential — use `bootstrap open` to inspect it"
347 .into(),
348 ),
349 SealedPayloadV1::AdminKeySet(_) => Err(
350 "cannot install an AdminKeySet bundle as an admin credential — use `bootstrap open` to inspect it"
351 .into(),
352 ),
353 SealedPayloadV1::RawPrivateKey(_) => Err(
354 "cannot install a RawPrivateKey bundle as an admin credential".into(),
355 ),
356 SealedPayloadV1::TemplateBootstrap(_) => Err(
357 "TemplateBootstrap payloads carry a VC-issued admin authorization, not a \
358 CredentialBundle — open via `pnm bootstrap open` and use the provision-integration \
359 flow to install"
360 .into(),
361 ),
362 SealedPayloadV1::TemplateBootstrapV2(_) => Err(
363 "TemplateBootstrapV2 payloads carry a VC-issued admin authorization, not a \
364 CredentialBundle — open via `pnm bootstrap open` and use the provision-integration \
365 flow to install"
366 .into(),
367 ),
368 SealedPayloadV1::AdminRotation(_) => Err(
369 "AdminRotation payloads carry a VC-issued admin authorization, not a \
370 CredentialBundle — open via `pnm bootstrap open` and use the provision-integration \
371 flow to install"
372 .into(),
373 ),
374 SealedPayloadV1::IssuedCredential(_) => Err(
375 "IssuedCredential payloads carry a holder credential, not an admin CredentialBundle \
376 — receive it into the holder vault via the credential-exchange flow"
377 .into(),
378 ),
379 SealedPayloadV1::MessagingBridgeCredentials(_) => Err(
380 "MessagingBridgeCredentials payloads carry a connector's platform secrets, not an \
381 admin CredentialBundle — open via `pnm bootstrap open` and load them into the \
382 connector's secret store"
383 .into(),
384 ),
385 }
386}
387
388pub use vta_sdk::hex::lower as hex_lower;
389
390pub fn warn_no_verify_digest() {
398 eprintln!(
399 "WARNING: --no-verify-digest disables out-of-band integrity verification.\n\
400 You are trusting the producer pubkey embedded in the bundle without\n\
401 any external anchor. Use only for testing."
402 );
403}
404
405pub fn validate_digest_flags(
416 expect_digest: Option<&str>,
417 no_verify_digest: bool,
418) -> Result<(), Box<dyn std::error::Error>> {
419 match (expect_digest, no_verify_digest) {
420 (Some(_), false) => Ok(()),
421 (None, true) => {
422 warn_no_verify_digest();
423 Ok(())
424 }
425 (Some(_), true) => {
426 Err("--no-verify-digest may not be combined with --expect-digest; pick one".into())
427 }
428 (None, false) => Err(
429 "--expect-digest <hex> is required (or pass --no-verify-digest to opt out \
430 with a warning)"
431 .into(),
432 ),
433 }
434}
435
436pub fn normalize_expected_digest(input: &str) -> Result<String, String> {
443 let digest = input.trim();
444 if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) {
445 return Err(
446 "enter the 64-character hex SHA-256 digest the producer gave you out-of-band".into(),
447 );
448 }
449 Ok(digest.to_ascii_lowercase())
450}
451
452pub fn open_admin_credential(
463 bundle_path: &Path,
464 config_dir: &Path,
465 expect_digest: Option<&str>,
466 expected_vta_did: Option<&str>,
467) -> Result<CredentialBundle, Box<dyn std::error::Error>> {
468 let (opened, secret) = open_armored_bundle_keeping_secret(
469 bundle_path,
470 config_dir,
471 expect_digest,
472 expect_digest.is_none(),
473 )?;
474 let credential = verify_admin_bundle(opened, expect_digest, expected_vta_did)?;
475 consume_secret(&secret);
476 Ok(credential)
477}
478
479pub fn verify_admin_bundle(
501 opened: OpenedArmored,
502 expect_digest: Option<&str>,
503 expected_vta_did: Option<&str>,
504) -> Result<CredentialBundle, Box<dyn std::error::Error>> {
505 if let Some(expected) = expect_digest
506 && expected != opened.digest
507 {
508 return Err(format!(
509 "bundle digest {} does not match the expected digest {expected}",
510 opened.digest
511 )
512 .into());
513 }
514
515 let producer = &opened.producer;
516 let producer_pubkey = match &producer.proof {
517 AssertionProof::DidSigned(_) => {
518 let expected = expected_vta_did.ok_or_else(|| {
519 format!(
520 "the bundle is signed by {}, but there is no expected VTA DID to check \
521 that against; refusing to install it",
522 producer.producer_did
523 )
524 })?;
525 if producer.producer_did != expected {
526 return Err(format!(
527 "the bundle is signed by {}, not by the expected VTA {expected}; refusing \
528 to install it",
529 producer.producer_did
530 )
531 .into());
532 }
533 let pubkey = affinidi_crypto::did_key::did_key_to_ed25519_pub(&producer.producer_did)
534 .map_err(|e| {
535 format!(
536 "cannot verify the producer signature: {} is not an Ed25519 did:key \
537 ({e}); use --expect-digest instead",
538 producer.producer_did
539 )
540 })?;
541 Some(pubkey)
542 }
543 _ => None,
544 };
545
546 let verdict = verify_producer_assertion_with_pubkey(
547 producer,
548 &opened.client_x25519_pub,
549 &opened.bundle_id,
550 producer_pubkey.as_ref(),
551 )?;
552
553 match verdict {
556 VerifiedAssertion::DidSignedVerified(_) => {}
557 VerifiedAssertion::PinnedOnlyAcknowledged(_) => {
558 if expect_digest.is_none() {
559 return Err(
560 "the bundle carries no producer signature, so only its out-of-band \
561 SHA-256 digest can show where it came from; pass --expect-digest <hex>"
562 .into(),
563 );
564 }
565 }
566 VerifiedAssertion::AttestedNeedsNitroCheck(_) => {
567 return Err(
568 "the bundle carries a TEE attestation, which is not verified when installing \
569 a credential from a file; use `pnm bootstrap connect` for attested bootstrap"
570 .into(),
571 );
572 }
573 }
574
575 let credential = extract_admin_credential(opened.payload)?;
576 if let Some(expected) = expected_vta_did
577 && credential.vta_did != expected
578 {
579 return Err(format!(
580 "the credential is for VTA {}, not the expected {expected}; refusing to install it",
581 credential.vta_did
582 )
583 .into());
584 }
585 Ok(credential)
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591 use crate::sealed_producer::{SealedRecipient, seal_for_recipient};
592
593 #[test]
594 fn secrets_dir_creates_when_missing() {
595 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
596 let dir = secrets_dir(&tmp).unwrap();
597 assert!(dir.exists());
598 assert!(dir.ends_with("bootstrap-secrets"));
599 let _ = fs::remove_dir_all(&tmp);
601 }
602
603 #[test]
604 fn create_request_persists_secret() {
605 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
606 let req = create_bootstrap_request(&tmp, Some("unit-test".into())).unwrap();
607 assert!(req.secret_path.exists());
608 let bytes = fs::read(&req.secret_path).unwrap();
609 assert_eq!(bytes.len(), 32);
610 let _ = fs::remove_dir_all(&tmp);
611 }
612
613 #[tokio::test]
614 async fn request_seal_open_round_trip() {
615 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
616
617 let created = create_bootstrap_request(&tmp, None).unwrap();
619
620 let recipient =
622 SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
623 .unwrap();
624 let payload = SealedPayloadV1::AdminCredential(Box::new(
625 vta_sdk::credentials::CredentialBundle::new(
626 "did:key:z6Mk123",
627 "z1234567890",
628 "did:key:z6MkVTA",
629 ),
630 ));
631 let sealed = seal_for_recipient(&recipient, &payload).await.unwrap();
632
633 let bundle_path = tmp.join("bundle.armor");
635 fs::write(&bundle_path, sealed.armored.as_bytes()).unwrap();
636
637 let opened = open_armored_bundle(&bundle_path, &tmp, Some(&sealed.digest), false).unwrap();
639 assert_eq!(opened.bundle_id, created.request.decode_nonce().unwrap());
640
641 let cred = extract_admin_credential(opened.payload).unwrap();
642 assert_eq!(cred.did, "did:key:z6Mk123");
643
644 assert!(!created.secret_path.exists());
646
647 let _ = fs::remove_dir_all(&tmp);
648 }
649
650 #[tokio::test]
651 async fn create_provision_request_persists_seed_and_signs() {
652 use vta_sdk::provision_integration::{BootstrapAsk, ProvisionRequestBuilder};
653
654 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
655
656 let builder = ProvisionRequestBuilder::new("didcomm-mediator")
657 .var("URL", "https://mediator.example.com")
658 .context_hint("mediator-prod")
659 .admin_template("vta-admin")
660 .label("cli-common-test");
661
662 let created = create_provision_request(&tmp, builder).await.unwrap();
663
664 assert!(created.secret_path.exists(), "secret must be persisted");
666 let stem = created.secret_path.file_stem().unwrap().to_str().unwrap();
667 assert_eq!(stem, created.bundle_id_hex);
668 let bytes = fs::read(&created.secret_path).unwrap();
669 assert_eq!(bytes.len(), 32);
670
671 let verified = created.request.clone().verify().expect("verify VP");
674 assert_eq!(
675 hex_lower(&verified.decode_nonce().unwrap()),
676 created.bundle_id_hex
677 );
678
679 match verified.ask() {
681 BootstrapAsk::TemplateBootstrap(ask) => {
682 assert_eq!(ask.template.name, "didcomm-mediator");
683 assert_eq!(
684 ask.template.vars.get("URL").and_then(|v| v.as_str()),
685 Some("https://mediator.example.com")
686 );
687 assert_eq!(ask.context_hint.as_deref(), Some("mediator-prod"));
688 assert_eq!(
689 ask.admin_template.as_ref().map(|t| t.name.as_str()),
690 Some("vta-admin")
691 );
692 }
693 other => panic!("expected TemplateBootstrap, got {other:?}"),
694 }
695
696 assert_eq!(created.client_did, verified.holder());
698
699 let _ = fs::remove_dir_all(&tmp);
700 }
701
702 #[cfg(unix)]
703 #[tokio::test]
704 async fn create_provision_request_seed_file_is_owner_only() {
705 use std::os::unix::fs::PermissionsExt;
706 use vta_sdk::provision_integration::ProvisionRequestBuilder;
707
708 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
709 let builder =
710 ProvisionRequestBuilder::new("didcomm-mediator").var("URL", "https://m.example.com");
711 let created = create_provision_request(&tmp, builder).await.unwrap();
712
713 let mode = fs::metadata(&created.secret_path)
714 .unwrap()
715 .permissions()
716 .mode();
717 assert_eq!(
719 mode & 0o777,
720 0o600,
721 "seed file must be 0600, got {:o}",
722 mode & 0o777
723 );
724
725 let _ = fs::remove_dir_all(&tmp);
726 }
727
728 #[test]
729 fn zero_overwrite_removes_file_and_scrubs_bytes() {
730 let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
737 fs::create_dir_all(&tmp).unwrap();
738 let f = tmp.join("secret.bin");
739 let original: Vec<u8> = (0u8..32).collect();
740 fs::write(&f, &original).unwrap();
741 assert!(f.exists());
742
743 zero_overwrite_and_remove(&f).expect("remove succeeds");
744 assert!(!f.exists(), "file must be removed");
745 let _ = fs::remove_dir_all(&tmp);
746 }
747
748 #[test]
749 fn zero_overwrite_errors_on_missing_file() {
750 let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
751 let missing = tmp.join("does-not-exist");
752 let err = zero_overwrite_and_remove(&missing).unwrap_err();
753 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
754 }
755
756 #[test]
757 fn zero_overwrite_handles_empty_file() {
758 let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
761 fs::create_dir_all(&tmp).unwrap();
762 let f = tmp.join("empty.bin");
763 fs::write(&f, b"").unwrap();
764 zero_overwrite_and_remove(&f).expect("remove succeeds");
765 assert!(!f.exists());
766 let _ = fs::remove_dir_all(&tmp);
767 }
768
769 #[test]
770 fn open_rejects_missing_digest_without_opt_out() {
771 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
772 fs::create_dir_all(&tmp).unwrap();
773 let bundle_path = tmp.join("bundle.armor");
774 fs::write(&bundle_path, b"armor placeholder").unwrap();
775 let err = open_armored_bundle(&bundle_path, &tmp, None, false).unwrap_err();
776 assert!(err.to_string().contains("expect-digest"));
777 let _ = fs::remove_dir_all(&tmp);
778 }
779
780 #[test]
781 fn extract_rejects_did_secrets() {
782 let payload =
783 SealedPayloadV1::DidSecrets(Box::new(vta_sdk::did_secrets::DidSecretsBundle {
784 did: "did:key:z6Mk".into(),
785 secrets: vec![],
786 }));
787 let err = extract_admin_credential(payload).unwrap_err();
788 assert!(err.to_string().contains("DidSecrets"));
789 }
790
791 #[test]
792 fn extract_accepts_context_provision() {
793 let payload = SealedPayloadV1::ContextProvision(Box::new(
794 vta_sdk::context_provision::ContextProvisionBundle {
795 context_id: "app".into(),
796 context_name: "App".into(),
797 vta_url: None,
798 vta_did: None,
799 credential: vta_sdk::credentials::CredentialBundle::new(
800 "did:key:z6Mk123",
801 "z1234567890",
802 "did:key:z6MkVTA",
803 ),
804 admin_did: "did:key:z6Mk123".into(),
805 did: None,
806 },
807 ));
808 let cred = extract_admin_credential(payload).unwrap();
809 assert_eq!(cred.did, "did:key:z6Mk123");
810 }
811
812 use vta_sdk::sealed_transfer::{
815 AttestationQuoteAssertion, DidSignedAssertion, ProducerAssertion,
816 };
817
818 const VTA_DID: &str = "did:key:z6MkVTA";
819
820 fn tmp_dir() -> PathBuf {
821 std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()))
822 }
823
824 fn admin_payload(vta_did: &str) -> SealedPayloadV1 {
825 SealedPayloadV1::AdminCredential(Box::new(vta_sdk::credentials::CredentialBundle::new(
826 "did:key:z6Mk123",
827 "z1234567890",
828 vta_did,
829 )))
830 }
831
832 async fn seal_to_request(
835 dir: &Path,
836 created: &CreatedRequest,
837 producer: ProducerAssertion,
838 payload: &SealedPayloadV1,
839 ) -> (PathBuf, String) {
840 let client_x = created.request.decode_client_x25519_pub().unwrap();
841 let bundle_id = created.request.decode_nonce().unwrap();
842 let store = vta_sdk::sealed_transfer::InMemoryNonceStore::new();
843 let bundle =
844 vta_sdk::sealed_transfer::seal_payload(&client_x, bundle_id, producer, payload, &store)
845 .await
846 .unwrap();
847 let path = dir.join("bundle.armor");
848 fs::write(&path, armor::encode(&bundle)).unwrap();
849 (path, bundle_digest(&bundle))
850 }
851
852 fn did_signed(
857 created: &CreatedRequest,
858 producer_did: Option<&str>,
859 ) -> (ProducerAssertion, String) {
860 use base64::Engine;
861 use ed25519_dalek::Signer;
862
863 let (seed, public) = generate_ed25519_keypair();
864 let key_did = affinidi_crypto::did_key::ed25519_pub_to_did_key(&public);
865 let did = producer_did.unwrap_or(&key_did).to_string();
866
867 let mut msg = vta_sdk::sealed_transfer::verify::DID_SIGNED_DOMAIN_TAG.to_vec();
868 msg.extend_from_slice(&created.request.decode_client_x25519_pub().unwrap());
869 msg.extend_from_slice(&created.request.decode_nonce().unwrap());
870 let sig = ed25519_dalek::SigningKey::from_bytes(&seed).sign(&msg);
871
872 let assertion = ProducerAssertion {
873 producer_did: did.clone(),
874 proof: AssertionProof::DidSigned(DidSignedAssertion {
875 did: did.clone(),
876 signature_b64: base64::engine::general_purpose::URL_SAFE_NO_PAD
877 .encode(sig.to_bytes()),
878 verification_method: format!("{did}#key-0"),
879 }),
880 };
881 (assertion, key_did)
882 }
883
884 #[tokio::test]
885 async fn admin_credential_signed_by_an_unexpected_did_key_is_rejected() {
886 let tmp = tmp_dir();
887 let created = create_bootstrap_request(&tmp, None).unwrap();
888 let (assertion, attacker_did) = did_signed(&created, None);
891 let (path, digest) =
892 seal_to_request(&tmp, &created, assertion, &admin_payload(&attacker_did)).await;
893
894 let err = open_admin_credential(&path, &tmp, None, Some("did:webvh:victim")).unwrap_err();
895 assert!(err.to_string().contains("not by the expected VTA"), "{err}");
896 assert!(
899 open_admin_credential(&path, &tmp, Some(&digest), Some("did:webvh:victim")).is_err()
900 );
901 assert!(open_admin_credential(&path, &tmp, None, None).is_err());
902
903 assert!(created.secret_path.exists());
905 let _ = fs::remove_dir_all(&tmp);
906 }
907
908 #[tokio::test]
909 async fn admin_credential_signed_by_the_expected_vta_is_accepted() {
910 let tmp = tmp_dir();
911 let created = create_bootstrap_request(&tmp, None).unwrap();
912 let (assertion, vta_did) = did_signed(&created, None);
913 let (path, _) = seal_to_request(&tmp, &created, assertion, &admin_payload(&vta_did)).await;
914
915 let cred = open_admin_credential(&path, &tmp, None, Some(&vta_did)).unwrap();
916 assert_eq!(cred.vta_did, vta_did);
917 assert!(
918 !created.secret_path.exists(),
919 "an accepted bundle consumes the request secret"
920 );
921 let _ = fs::remove_dir_all(&tmp);
922 }
923
924 #[tokio::test]
925 async fn did_signed_by_another_key_in_the_vta_name_is_rejected() {
926 let tmp = tmp_dir();
927 let created = create_bootstrap_request(&tmp, None).unwrap();
928 let (_, vta_did) = did_signed(&created, None);
930 let (forged, _) = did_signed(&created, Some(&vta_did));
931 let (path, _) = seal_to_request(&tmp, &created, forged, &admin_payload(&vta_did)).await;
932
933 assert!(open_admin_credential(&path, &tmp, None, Some(&vta_did)).is_err());
934 let _ = fs::remove_dir_all(&tmp);
935 }
936
937 #[tokio::test]
938 async fn pinned_only_admin_credential_needs_the_digest() {
939 let tmp = tmp_dir();
940 let created = create_bootstrap_request(&tmp, None).unwrap();
941 let recipient =
942 SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
943 .unwrap();
944 let sealed = seal_for_recipient(&recipient, &admin_payload(VTA_DID))
945 .await
946 .unwrap();
947 let path = tmp.join("bundle.armor");
948 fs::write(&path, sealed.armored.as_bytes()).unwrap();
949
950 assert!(open_admin_credential(&path, &tmp, None, Some(VTA_DID)).is_err());
951 assert!(created.secret_path.exists());
952
953 let typed =
955 normalize_expected_digest(&format!(" {}\n", sealed.digest.to_uppercase())).unwrap();
956 let cred = open_admin_credential(&path, &tmp, Some(&typed), Some(VTA_DID)).unwrap();
957 assert_eq!(cred.vta_did, VTA_DID);
958 let _ = fs::remove_dir_all(&tmp);
959 }
960
961 #[tokio::test]
962 async fn admin_credential_for_a_different_vta_is_rejected() {
963 let tmp = tmp_dir();
964 let created = create_bootstrap_request(&tmp, None).unwrap();
965 let recipient =
966 SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
967 .unwrap();
968 let sealed = seal_for_recipient(&recipient, &admin_payload("did:key:z6MkOtherVTA"))
969 .await
970 .unwrap();
971 let path = tmp.join("bundle.armor");
972 fs::write(&path, sealed.armored.as_bytes()).unwrap();
973
974 let err =
975 open_admin_credential(&path, &tmp, Some(&sealed.digest), Some(VTA_DID)).unwrap_err();
976 assert!(err.to_string().contains("did:key:z6MkOtherVTA"), "{err}");
977 let _ = fs::remove_dir_all(&tmp);
978 }
979
980 #[tokio::test]
981 async fn attested_admin_credential_is_rejected() {
982 let tmp = tmp_dir();
983 let created = create_bootstrap_request(&tmp, None).unwrap();
984 let producer = ProducerAssertion {
985 producer_did: "did:key:z6MkTee".into(),
986 proof: AssertionProof::Attested(AttestationQuoteAssertion {
987 format: "aws-nitro-v1".into(),
988 quote_b64: "AAAA".into(),
989 }),
990 };
991 let (path, digest) =
992 seal_to_request(&tmp, &created, producer, &admin_payload(VTA_DID)).await;
993
994 let err = open_admin_credential(&path, &tmp, Some(&digest), Some(VTA_DID)).unwrap_err();
995 assert!(err.to_string().contains("attestation"), "{err}");
996 let _ = fs::remove_dir_all(&tmp);
997 }
998
999 #[test]
1000 fn expected_digest_must_be_64_hex_characters() {
1001 assert!(normalize_expected_digest("").is_err());
1002 assert!(normalize_expected_digest(" ").is_err());
1003 assert!(normalize_expected_digest(&"a".repeat(63)).is_err());
1004 assert!(normalize_expected_digest(&"a".repeat(65)).is_err());
1005 assert!(normalize_expected_digest(&"g".repeat(64)).is_err());
1006 assert_eq!(
1007 normalize_expected_digest(&"AB".repeat(32)).unwrap(),
1008 "ab".repeat(32)
1009 );
1010 }
1011}