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 }
345}
346
347pub use vta_sdk::hex::lower as hex_lower;
348
349pub fn warn_no_verify_digest() {
357 eprintln!(
358 "WARNING: --no-verify-digest disables out-of-band integrity verification.\n\
359 You are trusting the producer pubkey embedded in the bundle without\n\
360 any external anchor. Use only for testing."
361 );
362}
363
364pub fn validate_digest_flags(
375 expect_digest: Option<&str>,
376 no_verify_digest: bool,
377) -> Result<(), Box<dyn std::error::Error>> {
378 match (expect_digest, no_verify_digest) {
379 (Some(_), false) => Ok(()),
380 (None, true) => {
381 warn_no_verify_digest();
382 Ok(())
383 }
384 (Some(_), true) => {
385 Err("--no-verify-digest may not be combined with --expect-digest; pick one".into())
386 }
387 (None, false) => Err(
388 "--expect-digest <hex> is required (or pass --no-verify-digest to opt out \
389 with a warning)"
390 .into(),
391 ),
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use crate::sealed_producer::{SealedRecipient, seal_for_recipient};
399
400 #[test]
401 fn secrets_dir_creates_when_missing() {
402 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
403 let dir = secrets_dir(&tmp).unwrap();
404 assert!(dir.exists());
405 assert!(dir.ends_with("bootstrap-secrets"));
406 let _ = fs::remove_dir_all(&tmp);
408 }
409
410 #[test]
411 fn create_request_persists_secret() {
412 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
413 let req = create_bootstrap_request(&tmp, Some("unit-test".into())).unwrap();
414 assert!(req.secret_path.exists());
415 let bytes = fs::read(&req.secret_path).unwrap();
416 assert_eq!(bytes.len(), 32);
417 let _ = fs::remove_dir_all(&tmp);
418 }
419
420 #[tokio::test]
421 async fn request_seal_open_round_trip() {
422 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
423
424 let created = create_bootstrap_request(&tmp, None).unwrap();
426
427 let recipient =
429 SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
430 .unwrap();
431 let payload = SealedPayloadV1::AdminCredential(Box::new(
432 vta_sdk::credentials::CredentialBundle::new(
433 "did:key:z6Mk123",
434 "z1234567890",
435 "did:key:z6MkVTA",
436 ),
437 ));
438 let sealed = seal_for_recipient(&recipient, &payload).await.unwrap();
439
440 let bundle_path = tmp.join("bundle.armor");
442 fs::write(&bundle_path, sealed.armored.as_bytes()).unwrap();
443
444 let opened = open_armored_bundle(&bundle_path, &tmp, Some(&sealed.digest), false).unwrap();
446 assert_eq!(opened.bundle_id, created.request.decode_nonce().unwrap());
447
448 let cred = extract_admin_credential(opened.payload).unwrap();
449 assert_eq!(cred.did, "did:key:z6Mk123");
450
451 assert!(!created.secret_path.exists());
453
454 let _ = fs::remove_dir_all(&tmp);
455 }
456
457 #[tokio::test]
458 async fn create_provision_request_persists_seed_and_signs() {
459 use vta_sdk::provision_integration::{BootstrapAsk, ProvisionRequestBuilder};
460
461 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
462
463 let builder = ProvisionRequestBuilder::new("didcomm-mediator")
464 .var("URL", "https://mediator.example.com")
465 .context_hint("mediator-prod")
466 .admin_template("vta-admin")
467 .label("cli-common-test");
468
469 let created = create_provision_request(&tmp, builder).await.unwrap();
470
471 assert!(created.secret_path.exists(), "secret must be persisted");
473 let stem = created.secret_path.file_stem().unwrap().to_str().unwrap();
474 assert_eq!(stem, created.bundle_id_hex);
475 let bytes = fs::read(&created.secret_path).unwrap();
476 assert_eq!(bytes.len(), 32);
477
478 let verified = created.request.clone().verify().expect("verify VP");
481 assert_eq!(
482 hex_lower(&verified.decode_nonce().unwrap()),
483 created.bundle_id_hex
484 );
485
486 match verified.ask() {
488 BootstrapAsk::TemplateBootstrap(ask) => {
489 assert_eq!(ask.template.name, "didcomm-mediator");
490 assert_eq!(
491 ask.template.vars.get("URL").and_then(|v| v.as_str()),
492 Some("https://mediator.example.com")
493 );
494 assert_eq!(ask.context_hint.as_deref(), Some("mediator-prod"));
495 assert_eq!(
496 ask.admin_template.as_ref().map(|t| t.name.as_str()),
497 Some("vta-admin")
498 );
499 }
500 other => panic!("expected TemplateBootstrap, got {other:?}"),
501 }
502
503 assert_eq!(created.client_did, verified.holder());
505
506 let _ = fs::remove_dir_all(&tmp);
507 }
508
509 #[cfg(unix)]
510 #[tokio::test]
511 async fn create_provision_request_seed_file_is_owner_only() {
512 use std::os::unix::fs::PermissionsExt;
513 use vta_sdk::provision_integration::ProvisionRequestBuilder;
514
515 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
516 let builder =
517 ProvisionRequestBuilder::new("didcomm-mediator").var("URL", "https://m.example.com");
518 let created = create_provision_request(&tmp, builder).await.unwrap();
519
520 let mode = fs::metadata(&created.secret_path)
521 .unwrap()
522 .permissions()
523 .mode();
524 assert_eq!(
526 mode & 0o777,
527 0o600,
528 "seed file must be 0600, got {:o}",
529 mode & 0o777
530 );
531
532 let _ = fs::remove_dir_all(&tmp);
533 }
534
535 #[test]
536 fn zero_overwrite_removes_file_and_scrubs_bytes() {
537 let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
544 fs::create_dir_all(&tmp).unwrap();
545 let f = tmp.join("secret.bin");
546 let original: Vec<u8> = (0u8..32).collect();
547 fs::write(&f, &original).unwrap();
548 assert!(f.exists());
549
550 zero_overwrite_and_remove(&f).expect("remove succeeds");
551 assert!(!f.exists(), "file must be removed");
552 let _ = fs::remove_dir_all(&tmp);
553 }
554
555 #[test]
556 fn zero_overwrite_errors_on_missing_file() {
557 let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
558 let missing = tmp.join("does-not-exist");
559 let err = zero_overwrite_and_remove(&missing).unwrap_err();
560 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
561 }
562
563 #[test]
564 fn zero_overwrite_handles_empty_file() {
565 let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
568 fs::create_dir_all(&tmp).unwrap();
569 let f = tmp.join("empty.bin");
570 fs::write(&f, b"").unwrap();
571 zero_overwrite_and_remove(&f).expect("remove succeeds");
572 assert!(!f.exists());
573 let _ = fs::remove_dir_all(&tmp);
574 }
575
576 #[test]
577 fn open_rejects_missing_digest_without_opt_out() {
578 let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
579 fs::create_dir_all(&tmp).unwrap();
580 let bundle_path = tmp.join("bundle.armor");
581 fs::write(&bundle_path, b"armor placeholder").unwrap();
582 let err = open_armored_bundle(&bundle_path, &tmp, None, false).unwrap_err();
583 assert!(err.to_string().contains("expect-digest"));
584 let _ = fs::remove_dir_all(&tmp);
585 }
586
587 #[test]
588 fn extract_rejects_did_secrets() {
589 let payload =
590 SealedPayloadV1::DidSecrets(Box::new(vta_sdk::did_secrets::DidSecretsBundle {
591 did: "did:key:z6Mk".into(),
592 secrets: vec![],
593 }));
594 let err = extract_admin_credential(payload).unwrap_err();
595 assert!(err.to_string().contains("DidSecrets"));
596 }
597
598 #[test]
599 fn extract_accepts_context_provision() {
600 let payload = SealedPayloadV1::ContextProvision(Box::new(
601 vta_sdk::context_provision::ContextProvisionBundle {
602 context_id: "app".into(),
603 context_name: "App".into(),
604 vta_url: None,
605 vta_did: None,
606 credential: vta_sdk::credentials::CredentialBundle::new(
607 "did:key:z6Mk123",
608 "z1234567890",
609 "did:key:z6MkVTA",
610 ),
611 admin_did: "did:key:z6Mk123".into(),
612 did: None,
613 },
614 ));
615 let cred = extract_admin_credential(payload).unwrap();
616 assert_eq!(cred.did, "did:key:z6Mk123");
617 }
618}