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::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
384pub 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
399pub 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
430pub 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
446pub 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
473pub 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 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 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 let created = create_bootstrap_request(&tmp, None).unwrap();
613
614 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 let bundle_path = tmp.join("bundle.armor");
629 fs::write(&bundle_path, sealed.armored.as_bytes()).unwrap();
630
631 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}