use std::fs;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use vta_sdk::credentials::CredentialBundle;
use vta_sdk::sealed_transfer::{
BootstrapRequest, SealedPayloadV1, armor, bundle_digest, ed25519_seed_to_x25519_secret,
generate_ed25519_keypair, open_bundle,
};
const SECRETS_SUBDIR: &str = "bootstrap-secrets";
pub fn secrets_dir(config_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
let dir = config_dir.join(SECRETS_SUBDIR);
if !dir.exists() {
fs::create_dir_all(&dir)?;
if let Err(e) = crate::secure_file::restrict_dir_to_owner(&dir) {
eprintln!(
"warning: could not restrict {} to owner ({e}) — contents may be \
accessible to other local users",
dir.display()
);
}
}
Ok(dir)
}
fn secret_path(
config_dir: &Path,
bundle_id_hex: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
Ok(secrets_dir(config_dir)?.join(format!("{bundle_id_hex}.key")))
}
fn write_secret(path: &Path, secret: &[u8; 32]) -> Result<(), Box<dyn std::error::Error>> {
let mut opts = fs::OpenOptions::new();
opts.create(true).write(true).truncate(true);
#[cfg(unix)]
opts.mode(0o600);
let mut file = opts.open(path)?;
file.write_all(secret)?;
drop(file);
if let Err(e) = crate::secure_file::restrict_file_to_owner(path) {
eprintln!(
"warning: could not restrict {} to owner ({e}) — secret may be readable by \
other local users",
path.display()
);
}
Ok(())
}
fn read_secret(path: &Path) -> Result<[u8; 32], Box<dyn std::error::Error>> {
let bytes = fs::read(path)?;
bytes
.as_slice()
.try_into()
.map_err(|_| format!("secret file {} is not 32 bytes", path.display()).into())
}
pub fn zero_overwrite_and_remove(path: &Path) -> std::io::Result<()> {
let metadata = fs::metadata(path)?;
let len = metadata.len() as usize;
if len > 0 {
let mut file = fs::OpenOptions::new()
.write(true)
.truncate(false)
.open(path)?;
const ZEROS: [u8; 4096] = [0u8; 4096];
let mut remaining = len;
while remaining > 0 {
let chunk = remaining.min(ZEROS.len());
file.write_all(&ZEROS[..chunk])?;
remaining -= chunk;
}
file.flush()?;
file.sync_all()?;
}
fs::remove_file(path)
}
pub struct CreatedRequest {
pub request: BootstrapRequest,
pub bundle_id_hex: String,
pub secret_path: PathBuf,
}
pub fn create_bootstrap_request(
config_dir: &Path,
label: Option<String>,
) -> Result<CreatedRequest, Box<dyn std::error::Error>> {
let (seed, public) = generate_ed25519_keypair();
let nonce: [u8; 16] = rand::random();
let bundle_id_hex = hex_lower(&nonce);
let sp = secret_path(config_dir, &bundle_id_hex)?;
write_secret(&sp, &seed)?;
let request = BootstrapRequest::new(public, nonce, label);
Ok(CreatedRequest {
request,
bundle_id_hex,
secret_path: sp,
})
}
#[derive(Debug)]
pub struct OpenedArmored {
pub payload: SealedPayloadV1,
pub producer: vta_sdk::sealed_transfer::ProducerAssertion,
pub bundle_id: [u8; 16],
pub bundle_id_hex: String,
pub digest: String,
pub client_x25519_pub: [u8; 32],
}
pub fn open_armored_bundle(
bundle_path: &Path,
config_dir: &Path,
expect_digest: Option<&str>,
no_verify_digest: bool,
) -> Result<OpenedArmored, Box<dyn std::error::Error>> {
if expect_digest.is_none() && !no_verify_digest {
return Err(
"--expect-digest <hex> is required (or pass --no-verify-digest to opt out)".into(),
);
}
let armored = fs::read_to_string(bundle_path)
.map_err(|e| format!("read {}: {e}", bundle_path.display()))?;
let bundles = armor::decode(&armored)?;
if bundles.len() != 1 {
return Err(format!(
"expected exactly one bundle in {}, found {}",
bundle_path.display(),
bundles.len()
)
.into());
}
let bundle = &bundles[0];
let bundle_id_hex = hex_lower(&bundle.bundle_id);
let sp = secret_path(config_dir, &bundle_id_hex)?;
if !sp.exists() {
return Err(format!(
"no stored secret for bundle_id {bundle_id_hex} (expected at {}). \
Did you run `bootstrap request` on this host?",
sp.display()
)
.into());
}
let ed_seed = read_secret(&sp)?;
let x_secret = ed25519_seed_to_x25519_secret(&ed_seed);
let client_x25519_pub = {
let signing = ed25519_dalek::SigningKey::from_bytes(&ed_seed);
let ed_pub = signing.verifying_key().to_bytes();
affinidi_crypto::did_key::ed25519_pub_to_x25519_bytes(&ed_pub).map_err(
|e| -> Box<dyn std::error::Error> {
format!("derive consumer X25519 pubkey from seed: {e}").into()
},
)?
};
let digest = bundle_digest(bundle);
let opened = open_bundle(&x_secret, bundle, expect_digest)?;
if let Err(e) = zero_overwrite_and_remove(&sp) {
eprintln!(
"warning: could not remove used secret {}: {e}",
sp.display()
);
}
Ok(OpenedArmored {
payload: opened.payload,
producer: opened.producer,
bundle_id: opened.bundle_id,
bundle_id_hex,
digest,
client_x25519_pub,
})
}
pub struct CreatedProvisionRequest {
pub request: vta_sdk::provision_integration::BootstrapRequest,
pub client_did: String,
pub bundle_id_hex: String,
pub secret_path: PathBuf,
}
pub async fn create_provision_request(
config_dir: &Path,
builder: vta_sdk::provision_integration::ProvisionRequestBuilder,
) -> Result<CreatedProvisionRequest, Box<dyn std::error::Error>> {
let signed = builder.sign_ephemeral().await?;
let bundle_id_hex = hex_lower(&signed.bundle_id);
let sp = secret_path(config_dir, &bundle_id_hex)?;
write_secret(&sp, &signed.seed)?;
Ok(CreatedProvisionRequest {
request: signed.request,
client_did: signed.client_did,
bundle_id_hex,
secret_path: sp,
})
}
pub fn extract_admin_credential(
payload: SealedPayloadV1,
) -> Result<CredentialBundle, Box<dyn std::error::Error>> {
match payload {
SealedPayloadV1::AdminCredential(c) => Ok(*c),
SealedPayloadV1::ContextProvision(p) => Ok(p.credential),
SealedPayloadV1::DidSecrets(_) => Err(
"cannot install a DidSecrets bundle as an admin credential — use `bootstrap open` to inspect it"
.into(),
),
SealedPayloadV1::AdminKeySet(_) => Err(
"cannot install an AdminKeySet bundle as an admin credential — use `bootstrap open` to inspect it"
.into(),
),
SealedPayloadV1::RawPrivateKey(_) => Err(
"cannot install a RawPrivateKey bundle as an admin credential".into(),
),
SealedPayloadV1::TemplateBootstrap(_) => Err(
"TemplateBootstrap payloads carry a VC-issued admin authorization, not a \
CredentialBundle — open via `pnm bootstrap open` and use the provision-integration \
flow to install"
.into(),
),
SealedPayloadV1::AdminRotation(_) => Err(
"AdminRotation payloads carry a VC-issued admin authorization, not a \
CredentialBundle — open via `pnm bootstrap open` and use the provision-integration \
flow to install"
.into(),
),
SealedPayloadV1::IssuedCredential(_) => Err(
"IssuedCredential payloads carry a holder credential, not an admin CredentialBundle \
— receive it into the holder vault via the credential-exchange flow"
.into(),
),
SealedPayloadV1::MessagingBridgeCredentials(_) => Err(
"MessagingBridgeCredentials payloads carry a connector's platform secrets, not an \
admin CredentialBundle — open via `pnm bootstrap open` and load them into the \
connector's secret store"
.into(),
),
}
}
pub use vta_sdk::hex::lower as hex_lower;
pub fn warn_no_verify_digest() {
eprintln!(
"WARNING: --no-verify-digest disables out-of-band integrity verification.\n\
You are trusting the producer pubkey embedded in the bundle without\n\
any external anchor. Use only for testing."
);
}
pub fn validate_digest_flags(
expect_digest: Option<&str>,
no_verify_digest: bool,
) -> Result<(), Box<dyn std::error::Error>> {
match (expect_digest, no_verify_digest) {
(Some(_), false) => Ok(()),
(None, true) => {
warn_no_verify_digest();
Ok(())
}
(Some(_), true) => {
Err("--no-verify-digest may not be combined with --expect-digest; pick one".into())
}
(None, false) => Err(
"--expect-digest <hex> is required (or pass --no-verify-digest to opt out \
with a warning)"
.into(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sealed_producer::{SealedRecipient, seal_for_recipient};
#[test]
fn secrets_dir_creates_when_missing() {
let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
let dir = secrets_dir(&tmp).unwrap();
assert!(dir.exists());
assert!(dir.ends_with("bootstrap-secrets"));
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn create_request_persists_secret() {
let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
let req = create_bootstrap_request(&tmp, Some("unit-test".into())).unwrap();
assert!(req.secret_path.exists());
let bytes = fs::read(&req.secret_path).unwrap();
assert_eq!(bytes.len(), 32);
let _ = fs::remove_dir_all(&tmp);
}
#[tokio::test]
async fn request_seal_open_round_trip() {
let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
let created = create_bootstrap_request(&tmp, None).unwrap();
let recipient =
SealedRecipient::from_json_str(&serde_json::to_string(&created.request).unwrap())
.unwrap();
let payload = SealedPayloadV1::AdminCredential(Box::new(
vta_sdk::credentials::CredentialBundle::new(
"did:key:z6Mk123",
"z1234567890",
"did:key:z6MkVTA",
),
));
let sealed = seal_for_recipient(&recipient, &payload).await.unwrap();
let bundle_path = tmp.join("bundle.armor");
fs::write(&bundle_path, sealed.armored.as_bytes()).unwrap();
let opened = open_armored_bundle(&bundle_path, &tmp, Some(&sealed.digest), false).unwrap();
assert_eq!(opened.bundle_id, created.request.decode_nonce().unwrap());
let cred = extract_admin_credential(opened.payload).unwrap();
assert_eq!(cred.did, "did:key:z6Mk123");
assert!(!created.secret_path.exists());
let _ = fs::remove_dir_all(&tmp);
}
#[tokio::test]
async fn create_provision_request_persists_seed_and_signs() {
use vta_sdk::provision_integration::{BootstrapAsk, ProvisionRequestBuilder};
let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
let builder = ProvisionRequestBuilder::new("didcomm-mediator")
.var("URL", "https://mediator.example.com")
.context_hint("mediator-prod")
.admin_template("vta-admin")
.label("cli-common-test");
let created = create_provision_request(&tmp, builder).await.unwrap();
assert!(created.secret_path.exists(), "secret must be persisted");
let stem = created.secret_path.file_stem().unwrap().to_str().unwrap();
assert_eq!(stem, created.bundle_id_hex);
let bytes = fs::read(&created.secret_path).unwrap();
assert_eq!(bytes.len(), 32);
let verified = created.request.clone().verify().expect("verify VP");
assert_eq!(
hex_lower(&verified.decode_nonce().unwrap()),
created.bundle_id_hex
);
match verified.ask() {
BootstrapAsk::TemplateBootstrap(ask) => {
assert_eq!(ask.template.name, "didcomm-mediator");
assert_eq!(
ask.template.vars.get("URL").and_then(|v| v.as_str()),
Some("https://mediator.example.com")
);
assert_eq!(ask.context_hint.as_deref(), Some("mediator-prod"));
assert_eq!(
ask.admin_template.as_ref().map(|t| t.name.as_str()),
Some("vta-admin")
);
}
other => panic!("expected TemplateBootstrap, got {other:?}"),
}
assert_eq!(created.client_did, verified.holder());
let _ = fs::remove_dir_all(&tmp);
}
#[cfg(unix)]
#[tokio::test]
async fn create_provision_request_seed_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
use vta_sdk::provision_integration::ProvisionRequestBuilder;
let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
let builder =
ProvisionRequestBuilder::new("didcomm-mediator").var("URL", "https://m.example.com");
let created = create_provision_request(&tmp, builder).await.unwrap();
let mode = fs::metadata(&created.secret_path)
.unwrap()
.permissions()
.mode();
assert_eq!(
mode & 0o777,
0o600,
"seed file must be 0600, got {:o}",
mode & 0o777
);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn zero_overwrite_removes_file_and_scrubs_bytes() {
let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
fs::create_dir_all(&tmp).unwrap();
let f = tmp.join("secret.bin");
let original: Vec<u8> = (0u8..32).collect();
fs::write(&f, &original).unwrap();
assert!(f.exists());
zero_overwrite_and_remove(&f).expect("remove succeeds");
assert!(!f.exists(), "file must be removed");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn zero_overwrite_errors_on_missing_file() {
let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
let missing = tmp.join("does-not-exist");
let err = zero_overwrite_and_remove(&missing).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
#[test]
fn zero_overwrite_handles_empty_file() {
let tmp = std::env::temp_dir().join(format!("vta-test-zero-{}", rand::random::<u32>()));
fs::create_dir_all(&tmp).unwrap();
let f = tmp.join("empty.bin");
fs::write(&f, b"").unwrap();
zero_overwrite_and_remove(&f).expect("remove succeeds");
assert!(!f.exists());
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn open_rejects_missing_digest_without_opt_out() {
let tmp = std::env::temp_dir().join(format!("vta-test-{}", rand::random::<u32>()));
fs::create_dir_all(&tmp).unwrap();
let bundle_path = tmp.join("bundle.armor");
fs::write(&bundle_path, b"armor placeholder").unwrap();
let err = open_armored_bundle(&bundle_path, &tmp, None, false).unwrap_err();
assert!(err.to_string().contains("expect-digest"));
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn extract_rejects_did_secrets() {
let payload =
SealedPayloadV1::DidSecrets(Box::new(vta_sdk::did_secrets::DidSecretsBundle {
did: "did:key:z6Mk".into(),
secrets: vec![],
}));
let err = extract_admin_credential(payload).unwrap_err();
assert!(err.to_string().contains("DidSecrets"));
}
#[test]
fn extract_accepts_context_provision() {
let payload = SealedPayloadV1::ContextProvision(Box::new(
vta_sdk::context_provision::ContextProvisionBundle {
context_id: "app".into(),
context_name: "App".into(),
vta_url: None,
vta_did: None,
credential: vta_sdk::credentials::CredentialBundle::new(
"did:key:z6Mk123",
"z1234567890",
"did:key:z6MkVTA",
),
admin_did: "did:key:z6Mk123".into(),
did: None,
},
));
let cred = extract_admin_credential(payload).unwrap();
assert_eq!(cred.did, "did:key:z6Mk123");
}
}