use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use crate::acl::{VtcRole, delete_acl_entry, list_acl_entries};
use async_trait::async_trait;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tracing::{info, warn};
use vti_common::config::StoreConfig;
use vti_common::error::AppError;
use vti_common::store::Store;
use vta_sdk::provision_client::{
EphemeralSetupKey, OperatorMessages, ProvisionAsk, ProvisionError, VtaIntent, VtaReply,
run_provision,
};
use crate::acl::admin::list_admin_entries;
use crate::config::AppConfig;
use crate::install::{
INSTALL_TOKEN_DEFAULT_TTL_SECS, InstallTokenSigner, InstallTokenStore,
PendingEmergencyBootstrap, mint_install_token,
};
use crate::keys::seed_store::{SecretStore, create_secret_store};
use crate::setup::VtcKeyBundle;
pub struct EmergencyBootstrapArgs {
pub config_path: Option<PathBuf>,
pub context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmergencyBootstrapOutcome {
pub install_url: String,
pub claim_code: String,
pub admin_entries_cleared: usize,
pub admin_records_cleared: usize,
}
#[async_trait]
pub trait VtaRecoveryProver: Send + Sync {
async fn prove(
&self,
vta_did: &str,
ephemeral_did: &str,
ephemeral_privkey_mb: &str,
context: &str,
) -> Result<(), AppError>;
}
pub struct RunProvisionProver;
#[async_trait]
impl VtaRecoveryProver for RunProvisionProver {
async fn prove(
&self,
vta_did: &str,
ephemeral_did: &str,
ephemeral_privkey_mb: &str,
context: &str,
) -> Result<(), AppError> {
let ask = ProvisionAsk::vta_admin_rotated(context).with_label("vtc emergency-bootstrap");
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
let drain = tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
let reply = run_provision(
VtaIntent::AdminRotated,
vta_did.to_string(),
ephemeral_did.to_string(),
ephemeral_privkey_mb.to_string(),
ask,
None,
Arc::new(VtcRecoveryMessages),
event_tx,
)
.await
.map_err(translate_provision_err)?;
drain.abort();
match reply {
VtaReply::AdminOnly(_) => Ok(()),
VtaReply::Full(_) => Err(AppError::Internal(
"VTA returned a Full reply but emergency-bootstrap asked for AdminRotated".into(),
)),
}
}
}
fn translate_provision_err(e: ProvisionError) -> AppError {
let msg = e.to_string();
if msg.to_ascii_lowercase().contains("auth") || msg.to_ascii_lowercase().contains("forbidden") {
AppError::Unauthorized(format!(
"VTA rejected the recovery DID: {msg}. \
Make sure `pnm acl create` ran for this context against this VTA."
))
} else {
AppError::Internal(format!("VTA recovery call failed: {msg}"))
}
}
struct VtcRecoveryMessages;
impl OperatorMessages for VtcRecoveryMessages {
fn integration_label(&self) -> &str {
"VTC"
}
fn integration_label_lower(&self) -> &str {
"vtc"
}
fn pnm_admin_command_hint(&self, context_id: &str, setup_did: &str) -> String {
format!(
"pnm acl create --did {setup_did} --role admin --contexts {context_id} \\\n \
--expires 1h"
)
}
}
pub async fn run_emergency_bootstrap(
args: EmergencyBootstrapArgs,
) -> Result<EmergencyBootstrapOutcome, AppError> {
let config = AppConfig::load(args.config_path)?;
let store = Store::open(&StoreConfig {
data_dir: config.store.data_dir.clone(),
})
.map_err(|e| {
AppError::Config(format!(
"failed to open fjall store at '{}': {e}. Is the daemon still running? \
Stop it before running emergency-bootstrap.",
config.store.data_dir.display()
))
})?;
let secret_store = create_secret_store(&config)
.map_err(|e| AppError::Config(format!("failed to construct secret store: {e}")))?;
let setup_key = EphemeralSetupKey::generate()
.map_err(|e| AppError::Internal(format!("ephemeral key gen: {e}")))?;
let prover = RunProvisionProver;
run_emergency_bootstrap_with_store(
&config,
&store,
secret_store.as_ref(),
&setup_key,
&prover,
args.context,
)
.await
}
pub async fn run_emergency_bootstrap_with_store(
config: &AppConfig,
store: &Store,
secret_store: &dyn SecretStore,
setup_key: &EphemeralSetupKey,
prover: &dyn VtaRecoveryProver,
context_override: Option<String>,
) -> Result<EmergencyBootstrapOutcome, AppError> {
let bundle_bytes = secret_store
.get()
.await
.map_err(|e| AppError::SecretStore(e.to_string()))?
.ok_or_else(|| {
AppError::Config(
"no key material in the secret store — has this VTC ever been set up?".into(),
)
})?;
let bundle = VtcKeyBundle::from_secret_store_bytes(&bundle_bytes)?;
let vta_did = config.vta_did.as_deref().ok_or_else(|| {
AppError::Config(
"config.vta_did not set — emergency-bootstrap recovery requires the VTA's DID. \
Re-run `vtc setup` against the same VTA to populate it."
.into(),
)
})?;
let context = context_override
.or_else(|| derive_context_from_config(config))
.unwrap_or_else(|| "default".to_string());
prover
.prove(
vta_did,
&setup_key.did,
setup_key.private_key_multibase(),
&context,
)
.await?;
let acl_ks = store.keyspace("acl")?;
let passkey_ks = store.keyspace("passkey")?;
let install_ks = store.keyspace("install")?;
let install_store = InstallTokenStore::new(install_ks);
let mut admin_entries_cleared = 0;
for entry in list_acl_entries(&acl_ks).await? {
if entry.role == VtcRole::Admin {
delete_acl_entry(&acl_ks, &entry.did).await?;
admin_entries_cleared += 1;
}
}
let admin_records = list_admin_entries(&passkey_ks).await?;
let admin_records_cleared = admin_records.len();
for entry in admin_records {
passkey_ks
.remove(format!("admin:{}", entry.did).into_bytes())
.await?;
if let Some(user) =
vti_common::auth::passkey::store::get_passkey_user_by_did(&passkey_ks, &entry.did)
.await?
{
passkey_ks
.remove(format!("pk_user:{}", user.user_uuid).into_bytes())
.await?;
passkey_ks
.remove(format!("pk_did:{}", entry.did).into_bytes())
.await?;
for cred in user.credentials {
let cred_id_hex = hex::encode(<_ as AsRef<[u8]>>::as_ref(cred.cred_id()));
passkey_ks
.remove(format!("pk_cred:{cred_id_hex}").into_bytes())
.await?;
}
}
}
let ed25519 = bundle.ed25519_private_bytes()?;
let signer = InstallTokenSigner::from_master_seed(&*ed25519)?;
let issuer = bundle.integration_did.clone();
let minted = mint_install_token(
&signer,
&issuer,
&setup_key.did,
INSTALL_TOKEN_DEFAULT_TTL_SECS,
)?;
let claim_code = crate::install::claim_secret::generate();
let claim_code_hash = crate::install::claim_secret::hash(&claim_code)?;
let exp = Utc::now() + chrono::Duration::seconds(INSTALL_TOKEN_DEFAULT_TTL_SECS as i64);
install_store
.record_issued(
&minted.jti,
minted.cnonce_bytes,
*minted.ephemeral_signing_key,
exp,
Some(claim_code_hash),
Some(setup_key.did.clone()),
)
.await?;
let operator_hostname = gethostname::gethostname().to_string_lossy().into_owned();
install_store
.mark_emergency_pending(PendingEmergencyBootstrap {
operator_hostname: operator_hostname.clone(),
invoked_at: Utc::now(),
})
.await?;
let install_url = match &config.public_url {
Some(base) => format!(
"{}/admin/install?token={}",
base.trim_end_matches('/'),
minted.jwt
),
None => format!("vtc://install?token={}", minted.jwt),
};
info!(
operator_hostname = %operator_hostname,
admin_entries_cleared,
admin_records_cleared,
"emergency bootstrap completed; install URL minted"
);
if admin_entries_cleared == 0 {
warn!(
"emergency bootstrap cleared no admin entries — was the daemon already in a \
fresh-install state?"
);
}
Ok(EmergencyBootstrapOutcome {
install_url,
claim_code,
admin_entries_cleared,
admin_records_cleared,
})
}
fn derive_context_from_config(_config: &AppConfig) -> Option<String> {
None
}
pub fn emergency_bootstrap_unavailable() -> AppError {
AppError::Internal("vtc admin emergency-bootstrap requires the `setup` feature".into())
}
pub type RecoveryProverFuture<'a> =
Pin<Box<dyn std::future::Future<Output = Result<(), AppError>> + Send + 'a>>;