use chrono::Utc;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::acl::{self, Role};
use crate::config::StoreConfig;
use crate::error::AppError;
use crate::store::{KeyspaceHandle, Store};
const SEAL_KEY: &str = "vta:sealed";
fn format_local_datetime(dt: chrono::DateTime<Utc>) -> String {
dt.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S %:z")
.to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SealRecord {
pub sealed_by: String,
pub sealed_at: chrono::DateTime<Utc>,
pub reason: String,
}
pub async fn get_seal(acl_ks: &KeyspaceHandle) -> Result<Option<SealRecord>, AppError> {
acl_ks.get(SEAL_KEY).await
}
pub async fn require_unsealed(store: &Store) -> Result<(), AppError> {
let acl_ks = store.keyspace(crate::keyspaces::ACL)?;
if let Some(seal) = get_seal(&acl_ks).await? {
return Err(AppError::Config(format!(
"VTA is sealed (by {} on {}). \
Offline CLI commands are disabled. \
Manage the VTA via the REST API or DIDComm.\n\
\n\
To unseal (requires super admin key): vta unseal",
seal.sealed_by,
format_local_datetime(seal.sealed_at),
)));
}
Ok(())
}
pub async fn seal(acl_ks: &KeyspaceHandle, admin_did: &str) -> Result<SealRecord, AppError> {
if let Some(existing) = get_seal(acl_ks).await? {
return Err(AppError::Conflict(format!(
"VTA is already sealed (by {} on {})",
existing.sealed_by,
format_local_datetime(existing.sealed_at),
)));
}
let record = SealRecord {
sealed_by: admin_did.to_string(),
sealed_at: Utc::now(),
reason: "bootstrap-admin completed".to_string(),
};
acl_ks.insert(SEAL_KEY, &record).await?;
Ok(record)
}
#[derive(Debug)]
pub(crate) struct UnsealChallenge {
pub seal: SealRecord,
pub super_admins: Vec<acl::AclEntry>,
pub challenge_bytes: [u8; 32],
}
pub(crate) async fn read_unseal_state(
store_config: &StoreConfig,
) -> Result<UnsealChallenge, AppError> {
let store = Store::open(store_config)?;
let acl_ks = store.keyspace(crate::keyspaces::ACL)?;
let seal = get_seal(&acl_ks)
.await?
.ok_or_else(|| AppError::Config("VTA is not sealed — nothing to unseal".into()))?;
let entries = acl::list_acl_entries(&acl_ks).await?;
let super_admins: Vec<acl::AclEntry> = entries
.into_iter()
.filter(|e| e.role == Role::Admin && e.allowed_contexts.is_empty())
.collect();
if super_admins.is_empty() {
return Err(AppError::Config(
"no super admin ACL entries found — cannot unseal".into(),
));
}
let mut challenge_bytes = [0u8; 32];
rand::fill(&mut challenge_bytes);
Ok(UnsealChallenge {
seal,
super_admins,
challenge_bytes,
})
}
pub(crate) async fn remove_seal_marker(store_config: &StoreConfig) -> Result<bool, AppError> {
let store = Store::open(store_config)?;
let acl_ks = store.keyspace(crate::keyspaces::ACL)?;
if get_seal(&acl_ks).await?.is_none() {
return Ok(false);
}
acl_ks.remove(SEAL_KEY).await?;
store.persist().await?;
Ok(true)
}
pub async fn run_unseal_challenge(store_config: &StoreConfig) -> Result<(), AppError> {
let UnsealChallenge {
seal,
super_admins,
challenge_bytes,
} = read_unseal_state(store_config).await?;
let challenge_hex = hex::encode(challenge_bytes);
eprintln!();
eprintln!("=== VTA Unseal Challenge ===");
eprintln!();
eprintln!(" Sealed by: {}", seal.sealed_by);
eprintln!(" Sealed at: {}", format_local_datetime(seal.sealed_at));
eprintln!();
eprintln!(" Authorized super admin DIDs:");
for admin in &super_admins {
eprintln!(
" - {} ({})",
admin.did,
admin.label.as_deref().unwrap_or("no label")
);
}
eprintln!();
eprintln!(" Challenge (hex):");
eprintln!(" {challenge_hex}");
eprintln!();
eprintln!(" Sign this challenge with your super admin key. Either:");
eprintln!();
eprintln!(
" pnm auth sign-challenge {challenge_hex} # online: \
signs with PNM's stored admin key"
);
eprintln!(
" vta auth sign-challenge --did <admin-did> --challenge {challenge_hex} \
# offline: signs from this VTA's local keystore"
);
eprintln!();
eprintln!(" Then paste the signature (hex) and your DID below.");
eprintln!();
eprint!(" Admin DID: ");
let mut did_input = String::new();
std::io::stdin()
.read_line(&mut did_input)
.map_err(|e| AppError::Internal(format!("failed to read input: {e}")))?;
let admin_did = did_input.trim();
let admin_entry = super_admins
.iter()
.find(|e| e.did == admin_did)
.ok_or_else(|| AppError::Forbidden(format!("DID is not a super admin: {admin_did}")))?;
eprint!(" Signature (hex): ");
let mut sig_input = String::new();
std::io::stdin()
.read_line(&mut sig_input)
.map_err(|e| AppError::Internal(format!("failed to read input: {e}")))?;
let sig_hex = sig_input.trim();
verify_challenge_signature(admin_did, &challenge_bytes, sig_hex)?;
let removed = remove_seal_marker(store_config).await?;
if !removed {
eprintln!();
eprintln!(" VTA was unsealed concurrently — nothing to do.");
eprintln!();
return Ok(());
}
info!(admin = %admin_did, "VTA unsealed via challenge-response");
eprintln!();
eprintln!(" VTA unsealed successfully.");
eprintln!(
" Authenticated as: {} ({})",
admin_did,
admin_entry.label.as_deref().unwrap_or("no label")
);
eprintln!();
eprintln!(" WARNING: Offline CLI commands are now re-enabled.");
eprintln!(" Re-seal when done: vta bootstrap-admin --did {admin_did}");
eprintln!();
Ok(())
}
fn verify_challenge_signature(
did: &str,
challenge: &[u8; 32],
signature_hex: &str,
) -> Result<(), AppError> {
let multibase_key = if did.starts_with("did:key:") {
did.strip_prefix("did:key:").unwrap()
} else {
return Err(AppError::Validation(format!(
"unseal challenge-response only supports did:key DIDs (got: {did}). \
For other DID methods, unseal via the REST API with a running VTA."
)));
};
let (_, key_bytes) = multibase::decode(multibase_key)
.map_err(|e| AppError::Validation(format!("invalid multibase in DID: {e}")))?;
if key_bytes.len() < 2 || key_bytes[0] != 0xed || key_bytes[1] != 0x01 {
return Err(AppError::Validation(
"DID public key is not Ed25519 (expected multicodec prefix 0xed01)".into(),
));
}
let raw_pubkey = &key_bytes[2..];
if raw_pubkey.len() != 32 {
return Err(AppError::Validation(format!(
"Ed25519 public key must be 32 bytes, got {}",
raw_pubkey.len()
)));
}
let pubkey_bytes: [u8; 32] = raw_pubkey.try_into().unwrap();
let verifying_key = VerifyingKey::from_bytes(&pubkey_bytes)
.map_err(|e| AppError::Validation(format!("invalid Ed25519 public key: {e}")))?;
let sig_bytes = hex::decode(signature_hex)
.map_err(|e| AppError::Validation(format!("invalid signature hex: {e}")))?;
if sig_bytes.len() != 64 {
return Err(AppError::Validation(format!(
"Ed25519 signature must be 64 bytes, got {}",
sig_bytes.len()
)));
}
let signature = Signature::from_slice(&sig_bytes)
.map_err(|e| AppError::Validation(format!("invalid Ed25519 signature: {e}")))?;
verifying_key.verify(challenge, &signature).map_err(|_| {
AppError::Forbidden(
"signature verification failed — challenge was not signed by this DID's private key"
.into(),
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::AclEntry;
async fn seal_fresh_store(data_dir: &std::path::Path, admin_did: &str) {
let config = StoreConfig {
data_dir: data_dir.to_path_buf(),
};
let store = Store::open(&config).expect("open store");
let acl_ks = store.keyspace(crate::keyspaces::ACL).expect("acl keyspace");
let entry = AclEntry::new(admin_did, Role::Admin, "test")
.with_label(Some("test-super-admin".into()));
acl::store_acl_entry(&acl_ks, &entry).await.expect("acl");
seal(&acl_ks, admin_did).await.expect("seal");
store.persist().await.expect("persist");
}
#[tokio::test]
async fn read_unseal_state_releases_lock() {
let dir = tempfile::tempdir().expect("tempdir");
seal_fresh_store(dir.path(), "did:key:zTestAdmin").await;
let config = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let challenge = read_unseal_state(&config).await.expect("read_unseal_state");
assert_eq!(challenge.seal.sealed_by, "did:key:zTestAdmin");
assert_eq!(challenge.super_admins.len(), 1);
assert_eq!(challenge.challenge_bytes.len(), 32);
let _sibling = Store::open(&config).expect(
"sibling Store::open after read_unseal_state must succeed — \
the fjall lock from phase 1 should have been released on drop",
);
}
#[tokio::test]
async fn remove_seal_marker_is_idempotent() {
let dir = tempfile::tempdir().expect("tempdir");
seal_fresh_store(dir.path(), "did:key:zTestAdmin").await;
let config = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let first = remove_seal_marker(&config).await.expect("first call");
assert!(first, "first call should report seal removed");
let second = remove_seal_marker(&config).await.expect("second call");
assert!(
!second,
"second call should report no-op (seal already gone)"
);
let _after = Store::open(&config).expect("reopen after remove");
}
#[tokio::test]
async fn read_unseal_state_rejects_unsealed_vta() {
let dir = tempfile::tempdir().expect("tempdir");
let config = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
{
let store = Store::open(&config).expect("open");
store.persist().await.expect("persist");
}
let err = read_unseal_state(&config)
.await
.expect_err("must reject unsealed VTA");
assert!(matches!(err, AppError::Config(_)), "got {err:?}");
}
#[tokio::test]
async fn read_unseal_state_rejects_missing_super_admin() {
let dir = tempfile::tempdir().expect("tempdir");
let config = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
{
let store = Store::open(&config).expect("open");
let acl_ks = store.keyspace(crate::keyspaces::ACL).expect("acl");
seal(&acl_ks, "did:key:zPhantom").await.expect("seal");
store.persist().await.expect("persist");
}
let err = read_unseal_state(&config)
.await
.expect_err("must reject missing super-admin");
assert!(matches!(err, AppError::Config(_)), "got {err:?}");
}
}