use crate::ux::format::is_json_mode;
use anyhow::{Context, Result, anyhow};
use auths_keri::witness::SignedReceipt;
use auths_sdk::ports::IdentityStorage;
use auths_sdk::trust::{PinnedIdentity, PinnedIdentityStore, RootsFile, TrustLevel, TrustPolicy};
use auths_verifier::core::Attestation;
use auths_verifier::verify::{verify_chain_with_witnesses, verify_with_keys};
use auths_verifier::witness::WitnessVerifyConfig;
use chrono::Utc;
use clap::{Parser, ValueEnum};
use serde::Serialize;
use std::fs;
use std::io::{self, IsTerminal, Read};
use std::path::PathBuf;
use std::process;
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub enum CliTrustPolicy {
#[default]
Tofu,
Explicit,
}
#[derive(Parser, Debug, Clone)]
#[command(about = "Verify device authorization signatures.")]
pub struct VerifyCommand {
#[arg(long, value_parser, required = true)]
pub attestation: String,
#[arg(long = "signer-key", value_parser)]
pub issuer_pk: Option<String>,
#[arg(long = "signer", visible_alias = "issuer-did", value_parser)]
pub issuer_did: Option<String>,
#[arg(long, value_enum)]
pub trust: Option<CliTrustPolicy>,
#[arg(long = "roots-file", value_parser)]
pub roots_file: Option<PathBuf>,
#[arg(long = "witness-signatures")]
pub witness_receipts: Option<PathBuf>,
#[arg(long = "witnesses-required", default_value = "1")]
pub witness_threshold: usize,
#[arg(long, num_args = 1..)]
pub witness_keys: Vec<String>,
}
#[derive(Serialize)]
struct VerifyResult {
valid: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
issuer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
subject: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
witness_quorum: Option<auths_verifier::witness::WitnessQuorum>,
}
pub async fn handle_verify(cmd: VerifyCommand) -> Result<()> {
#[allow(clippy::disallowed_methods)]
let now = Utc::now();
let result = run_verify(now, &cmd).await;
match result {
Ok(verify_result) => {
if is_json_mode() {
println!("{}", serde_json::to_string(&verify_result)?);
}
if verify_result.valid {
Ok(())
} else {
if !is_json_mode() {
eprintln!(
"Attestation verification failed: {}",
verify_result.error.as_deref().unwrap_or("unknown error")
);
}
process::exit(1);
}
}
Err(e) => {
if is_json_mode() {
let error_result = VerifyResult {
valid: false,
error: Some(e.to_string()),
issuer: None,
subject: None,
witness_quorum: None,
};
println!("{}", serde_json::to_string(&error_result)?);
} else {
eprintln!("Error: {}", e);
}
process::exit(2);
}
}
}
fn effective_trust_policy(cmd: &VerifyCommand) -> TrustPolicy {
match cmd.trust {
Some(CliTrustPolicy::Tofu) => TrustPolicy::Tofu,
Some(CliTrustPolicy::Explicit) => TrustPolicy::Explicit,
None => {
if io::stdin().is_terminal() {
TrustPolicy::Tofu
} else {
TrustPolicy::Explicit
}
}
}
}
fn bytes_to_device_public_key(
bytes: &[u8],
curve: auths_crypto::CurveType,
source: &str,
) -> Result<auths_verifier::DevicePublicKey> {
auths_verifier::DevicePublicKey::try_new(curve, bytes)
.map_err(|e| anyhow!("Invalid {} public key: {e}", source))
}
fn resolve_self_issuer_key(did: &str) -> Option<auths_verifier::DevicePublicKey> {
let auths_home = auths_sdk::paths::auths_home().ok()?;
let local = auths_sdk::storage::RegistryIdentityStorage::new(auths_home.clone())
.load_identity()
.ok()?;
if local.controller_did.as_str() != did {
return None;
}
let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
);
let (pk, curve) = auths_sdk::keri::resolve_current_public_key(®istry, did).ok()?;
auths_verifier::DevicePublicKey::try_new(curve, &pk).ok()
}
fn resolve_issuer_key(
now: chrono::DateTime<Utc>,
cmd: &VerifyCommand,
att: &Attestation,
) -> Result<auths_verifier::DevicePublicKey> {
if let Some(ref pk_hex) = cmd.issuer_pk {
let pk_bytes =
hex::decode(pk_hex).context("Invalid hex string provided for issuer public key")?;
let issuer_did = cmd.issuer_did.as_deref().unwrap_or(att.issuer.as_str());
let curve = auths_crypto::did_key_decode(issuer_did)
.map(|d| d.curve())
.unwrap_or_default();
return auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
.map_err(|e| anyhow!("Invalid issuer public key: {e}"));
}
let did = cmd.issuer_did.as_deref().unwrap_or(att.issuer.as_str());
if let Some(pk) = resolve_self_issuer_key(did) {
if !is_json_mode() {
println!("Using local identity (self-trust): {}", did);
}
return Ok(pk);
}
let policy = effective_trust_policy(cmd);
let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path());
if let Some(pin) = store.lookup(did)? {
if !is_json_mode() {
println!("Using pinned identity: {}", did);
}
return bytes_to_device_public_key(&pin.public_key_bytes()?, pin.curve, "pinned identity");
}
let roots_path = cmd.roots_file.clone().unwrap_or_else(|| {
std::env::current_dir()
.unwrap_or_default()
.join(".auths/roots.json")
});
if roots_path.exists() {
let roots = RootsFile::load(&roots_path)?;
if let Some(root) = roots.find(did) {
if !is_json_mode() {
println!(
"Using root from {}: {}",
roots_path.display(),
root.note.as_deref().unwrap_or(did)
);
}
let pin = PinnedIdentity {
did: did.to_string(),
public_key_hex: root.public_key_hex.clone(),
curve: root.curve,
kel_tip_said: root.kel_tip_said.clone(),
kel_sequence: None,
first_seen: now,
origin: format!("roots.json:{}", roots_path.display()),
trust_level: TrustLevel::OrgPolicy,
};
store.pin(pin)?;
return bytes_to_device_public_key(&root.public_key_bytes()?, root.curve, "roots.json");
}
}
match policy {
TrustPolicy::Tofu => {
anyhow::bail!(
"Unknown identity '{}'. Provide --signer-key to trust on first use, \
or add to .auths/roots.json for explicit trust.",
did
);
}
TrustPolicy::Explicit => {
anyhow::bail!(
"Unknown identity '{}' and trust policy is 'explicit'.\n\
Options:\n \
1. Add to .auths/roots.json in the repository\n \
2. Pin manually: auths trust pin --did {} --key <hex>\n \
3. Provide --signer-key <hex> to bypass trust resolution",
did,
did
);
}
}
}
use crate::commands::verify_helpers::parse_witness_keys;
async fn run_verify(now: chrono::DateTime<Utc>, cmd: &VerifyCommand) -> Result<VerifyResult> {
let attestation_bytes = if cmd.attestation == "-" {
let mut buffer = Vec::new();
io::stdin()
.read_to_end(&mut buffer)
.context("Failed to read attestation from stdin")?;
buffer
} else {
let path = PathBuf::from(&cmd.attestation);
fs::read(&path).with_context(|| format!("Failed to read attestation file: {:?}", path))?
};
let att: Attestation =
serde_json::from_slice(&attestation_bytes).context("Failed to parse JSON attestation")?;
if !is_json_mode() {
println!(
"Verifying attestation: issuer={}, subject={}",
att.issuer, att.subject
);
}
let issuer_pk = resolve_issuer_key(now, cmd, &att)?;
let verify_result = verify_with_keys(&att, &issuer_pk).await;
match verify_result {
Ok(_) => {
let witness_quorum = if let Some(ref receipts_path) = cmd.witness_receipts {
let receipts_bytes = fs::read(receipts_path).with_context(|| {
format!("Failed to read witness receipts: {:?}", receipts_path)
})?;
let receipts: Vec<SignedReceipt> = serde_json::from_slice(&receipts_bytes)
.context("Failed to parse witness receipts JSON")?;
let witness_keys = parse_witness_keys(&cmd.witness_keys)?;
let config = WitnessVerifyConfig {
receipts: &receipts,
witness_keys: &witness_keys,
threshold: cmd.witness_threshold,
};
let report =
verify_chain_with_witnesses(std::slice::from_ref(&att), &issuer_pk, &config)
.await
.context("Witness chain verification failed")?;
if !report.is_valid() {
if !is_json_mode()
&& let auths_verifier::VerificationStatus::InsufficientWitnesses {
required,
verified,
} = &report.status
{
eprintln!("Witness quorum not met: {}/{} verified", verified, required);
}
return Ok(VerifyResult {
valid: false,
error: Some(format!(
"Witness quorum not met: {}/{} verified",
report.witness_quorum.as_ref().map_or(0, |q| q.verified),
cmd.witness_threshold
)),
issuer: Some(att.issuer.to_string()),
subject: Some(att.subject.to_string()),
witness_quorum: report.witness_quorum,
});
}
if !is_json_mode()
&& let Some(ref q) = report.witness_quorum
{
println!("Witness quorum met: {}/{} verified", q.verified, q.required);
}
report.witness_quorum
} else {
None
};
if !is_json_mode() {
println!("Attestation verified successfully.");
}
Ok(VerifyResult {
valid: true,
error: None,
issuer: Some(att.issuer.to_string()),
subject: Some(att.subject.to_string()),
witness_quorum,
})
}
Err(e) => Ok(VerifyResult {
valid: false,
error: Some(e.to_string()),
issuer: Some(att.issuer.to_string()),
subject: Some(att.subject.to_string()),
witness_quorum: None,
}),
}
}
pub async fn handle_verify_attestation(
attestation_path: &PathBuf,
issuer_pubkey_hex: &str,
) -> Result<()> {
println!("Verifying attestation from file: {:?}", attestation_path);
println!(
" Using issuer public key (hex): {}...",
&issuer_pubkey_hex[..8.min(issuer_pubkey_hex.len())]
);
let attestation_bytes = fs::read(attestation_path)
.with_context(|| format!("Failed to read attestation file: {:?}", attestation_path))?;
let att: Attestation = serde_json::from_slice(&attestation_bytes).with_context(|| {
format!(
"Failed to parse JSON attestation from file: {:?}",
attestation_path
)
})?;
println!(
" Attestation loaded successfully. Issuer: {}, Subject: {}",
att.issuer, att.subject
);
let issuer_pk_bytes = hex::decode(issuer_pubkey_hex)
.context("Invalid hex string provided for issuer public key")?;
let issuer_curve = auths_crypto::did_key_decode(att.issuer.as_str())
.map(|d| d.curve())
.unwrap_or_default();
let issuer_pk = bytes_to_device_public_key(&issuer_pk_bytes, issuer_curve, "issuer")?;
match verify_with_keys(&att, &issuer_pk).await {
Ok(_) => {
println!("Attestation verified successfully.");
Ok(())
}
Err(e) => Err(anyhow!("Attestation verification failed: {}", e)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verify_result_serializes_correctly() {
let result = VerifyResult {
valid: true,
error: None,
issuer: Some("did:key:issuer".to_string()),
subject: Some("did:key:subject".to_string()),
witness_quorum: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"valid\":true"));
assert!(json.contains("\"issuer\":\"did:key:issuer\""));
}
#[test]
fn verify_result_error_serializes_correctly() {
let result = VerifyResult {
valid: false,
error: Some("signature mismatch".to_string()),
issuer: None,
subject: None,
witness_quorum: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"valid\":false"));
assert!(json.contains("\"error\":\"signature mismatch\""));
}
}