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