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::{
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
26pub 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 #[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
82pub fn zero_overwrite_and_remove(path: &Path) -> std::io::Result<()> {
96 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 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
123pub struct CreatedRequest {
126 pub request: BootstrapRequest,
127 pub bundle_id_hex: String,
128 pub secret_path: PathBuf,
129}
130
131pub 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#[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 pub client_x25519_pub: [u8; 32],
174}
175
176pub 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 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 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
258pub struct CreatedProvisionRequest {
262 pub request: vta_sdk::provision_integration::BootstrapRequest,
266 pub client_did: String,
269 pub bundle_id_hex: String,
272 pub secret_path: PathBuf,
275}
276
277pub 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
305pub 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 SealedPayloadV1::IssuedCredential(_) => Err(
340 "IssuedCredential payloads carry a holder credential, not an admin CredentialBundle \
341 — receive it into the holder vault via the credential-exchange flow"
342 .into(),
343 ),
344 SealedPayloadV1::MessagingBridgeCredentials(_) => Err(
345 "MessagingBridgeCredentials payloads carry a connector's platform secrets, not an \
346 admin CredentialBundle — open via `pnm bootstrap open` and load them into the \
347 connector's secret store"
348 .into(),
349 ),
350 }
351}
352
353pub use vta_sdk::hex::lower as hex_lower;
354
355pub fn warn_no_verify_digest() {
363 eprintln!(
364 "WARNING: --no-verify-digest disables out-of-band integrity verification.\n\
365 You are trusting the producer pubkey embedded in the bundle without\n\
366 any external anchor. Use only for testing."
367 );
368}
369
370pub fn validate_digest_flags(
381 expect_digest: Option<&str>,
382 no_verify_digest: bool,
383) -> Result<(), Box<dyn std::error::Error>> {
384 match (expect_digest, no_verify_digest) {
385 (Some(_), false) => Ok(()),
386 (None, true) => {
387 warn_no_verify_digest();
388 Ok(())
389 }
390 (Some(_), true) => {
391 Err("--no-verify-digest may not be combined with --expect-digest; pick one".into())
392 }
393 (None, false) => Err(
394 "--expect-digest <hex> is required (or pass --no-verify-digest to opt out \
395 with a warning)"
396 .into(),
397 ),
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::sealed_producer::{SealedRecipient, seal_for_recipient};
405
406 #[test]
407 fn secrets_dir_creates_when_missing() {
408 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
409 let dir = secrets_dir(&tmp).unwrap();
410 assert!(dir.exists());
411 assert!(dir.ends_with("bootstrap-secrets"));
412 let _ = fs::remove_dir_all(&tmp);
414 }
415
416 #[test]
417 fn create_request_persists_secret() {
418 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
419 let req = create_bootstrap_request(&tmp, Some("unit-test".into())).unwrap();
420 assert!(req.secret_path.exists());
421 let bytes = fs::read(&req.secret_path).unwrap();
422 assert_eq!(bytes.len(), 32);
423 let _ = fs::remove_dir_all(&tmp);
424 }
425
426 #[tokio::test]
427 async fn request_seal_open_round_trip() {
428 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
429
430 let created = create_bootstrap_request(&tmp, None).unwrap();
432
433 let recipient =
435 SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
436 .unwrap();
437 let payload = SealedPayloadV1::AdminCredential(Box::new(
438 vta_sdk::credentials::CredentialBundle::new(
439 "did:key:z6Mk123",
440 "z1234567890",
441 "did:key:z6MkVTA",
442 ),
443 ));
444 let sealed = seal_for_recipient(&recipient, &payload).await.unwrap();
445
446 let bundle_path = tmp.join("bundle.armor");
448 fs::write(&bundle_path, sealed.armored.as_bytes()).unwrap();
449
450 let opened = open_armored_bundle(&bundle_path, &tmp, Some(&sealed.digest), false).unwrap();
452 assert_eq!(opened.bundle_id, created.request.decode_nonce().unwrap());
453
454 let cred = extract_admin_credential(opened.payload).unwrap();
455 assert_eq!(cred.did, "did:key:z6Mk123");
456
457 assert!(!created.secret_path.exists());
459
460 let _ = fs::remove_dir_all(&tmp);
461 }
462
463 #[tokio::test]
464 async fn create_provision_request_persists_seed_and_signs() {
465 use vta_sdk::provision_integration::{BootstrapAsk, ProvisionRequestBuilder};
466
467 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
468
469 let builder = ProvisionRequestBuilder::new("didcomm-mediator")
470 .var("URL", "https://mediator.example.com")
471 .context_hint("mediator-prod")
472 .admin_template("vta-admin")
473 .label("cli-common-test");
474
475 let created = create_provision_request(&tmp, builder).await.unwrap();
476
477 assert!(created.secret_path.exists(), "secret must be persisted");
479 let stem = created.secret_path.file_stem().unwrap().to_str().unwrap();
480 assert_eq!(stem, created.bundle_id_hex);
481 let bytes = fs::read(&created.secret_path).unwrap();
482 assert_eq!(bytes.len(), 32);
483
484 let verified = created.request.clone().verify().expect("verify VP");
487 assert_eq!(
488 hex_lower(&verified.decode_nonce().unwrap()),
489 created.bundle_id_hex
490 );
491
492 match verified.ask() {
494 BootstrapAsk::TemplateBootstrap(ask) => {
495 assert_eq!(ask.template.name, "didcomm-mediator");
496 assert_eq!(
497 ask.template.vars.get("URL").and_then(|v| v.as_str()),
498 Some("https://mediator.example.com")
499 );
500 assert_eq!(ask.context_hint.as_deref(), Some("mediator-prod"));
501 assert_eq!(
502 ask.admin_template.as_ref().map(|t| t.name.as_str()),
503 Some("vta-admin")
504 );
505 }
506 other => panic!("expected TemplateBootstrap, got {other:?}"),
507 }
508
509 assert_eq!(created.client_did, verified.holder());
511
512 let _ = fs::remove_dir_all(&tmp);
513 }
514
515 #[cfg(unix)]
516 #[tokio::test]
517 async fn create_provision_request_seed_file_is_owner_only() {
518 use std::os::unix::fs::PermissionsExt;
519 use vta_sdk::provision_integration::ProvisionRequestBuilder;
520
521 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
522 let builder =
523 ProvisionRequestBuilder::new("didcomm-mediator").var("URL", "https://m.example.com");
524 let created = create_provision_request(&tmp, builder).await.unwrap();
525
526 let mode = fs::metadata(&created.secret_path)
527 .unwrap()
528 .permissions()
529 .mode();
530 assert_eq!(
532 mode & 0o777,
533 0o600,
534 "seed file must be 0600, got {:o}",
535 mode & 0o777
536 );
537
538 let _ = fs::remove_dir_all(&tmp);
539 }
540
541 #[test]
542 fn zero_overwrite_removes_file_and_scrubs_bytes() {
543 let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
550 fs::create_dir_all(&tmp).unwrap();
551 let f = tmp.join("secret.bin");
552 let original: Vec<u8> = (0u8..32).collect();
553 fs::write(&f, &original).unwrap();
554 assert!(f.exists());
555
556 zero_overwrite_and_remove(&f).expect("remove succeeds");
557 assert!(!f.exists(), "file must be removed");
558 let _ = fs::remove_dir_all(&tmp);
559 }
560
561 #[test]
562 fn zero_overwrite_errors_on_missing_file() {
563 let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
564 let missing = tmp.join("does-not-exist");
565 let err = zero_overwrite_and_remove(&missing).unwrap_err();
566 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
567 }
568
569 #[test]
570 fn zero_overwrite_handles_empty_file() {
571 let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
574 fs::create_dir_all(&tmp).unwrap();
575 let f = tmp.join("empty.bin");
576 fs::write(&f, b"").unwrap();
577 zero_overwrite_and_remove(&f).expect("remove succeeds");
578 assert!(!f.exists());
579 let _ = fs::remove_dir_all(&tmp);
580 }
581
582 #[test]
583 fn open_rejects_missing_digest_without_opt_out() {
584 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
585 fs::create_dir_all(&tmp).unwrap();
586 let bundle_path = tmp.join("bundle.armor");
587 fs::write(&bundle_path, b"armor placeholder").unwrap();
588 let err = open_armored_bundle(&bundle_path, &tmp, None, false).unwrap_err();
589 assert!(err.to_string().contains("expect-digest"));
590 let _ = fs::remove_dir_all(&tmp);
591 }
592
593 #[test]
594 fn extract_rejects_did_secrets() {
595 let payload =
596 SealedPayloadV1::DidSecrets(Box::new(vta_sdk::did_secrets::DidSecretsBundle {
597 did: "did:key:z6Mk".into(),
598 secrets: vec![],
599 }));
600 let err = extract_admin_credential(payload).unwrap_err();
601 assert!(err.to_string().contains("DidSecrets"));
602 }
603
604 #[test]
605 fn extract_accepts_context_provision() {
606 let payload = SealedPayloadV1::ContextProvision(Box::new(
607 vta_sdk::context_provision::ContextProvisionBundle {
608 context_id: "app".into(),
609 context_name: "App".into(),
610 vta_url: None,
611 vta_did: None,
612 credential: vta_sdk::credentials::CredentialBundle::new(
613 "did:key:z6Mk123",
614 "z1234567890",
615 "did:key:z6MkVTA",
616 ),
617 admin_did: "did:key:z6Mk123".into(),
618 did: None,
619 },
620 ));
621 let cred = extract_admin_credential(payload).unwrap();
622 assert_eq!(cred.did, "did:key:z6Mk123");
623 }
624}