use crate::ux::format::is_json_mode;
use anyhow::{Context, Result, anyhow};
use auths_core::trust::{PinnedIdentity, PinnedIdentityStore, RootsFile, TrustLevel, TrustPolicy};
use auths_verifier::Capability;
use auths_verifier::core::Attestation;
use auths_verifier::verify::{
verify_chain_with_witnesses, verify_with_capability, verify_with_keys,
};
use auths_verifier::witness::{WitnessReceipt, 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 = "issuer-pk", value_parser)]
pub issuer_pk: Option<String>,
#[arg(long = "issuer-did", visible_alias = "issuer", 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 = "require-capability")]
pub require_capability: Option<String>,
#[arg(long)]
pub witness_receipts: Option<PathBuf>,
#[arg(long, 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")]
required_capability: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
available_capabilities: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
witness_quorum: Option<auths_verifier::witness::WitnessQuorum>,
}
pub async fn handle_verify(cmd: VerifyCommand) -> Result<()> {
let result = run_verify(&cmd).await;
match result {
Ok(verify_result) => {
if is_json_mode() {
println!("{}", serde_json::to_string(&verify_result).unwrap());
}
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,
required_capability: cmd.require_capability.clone(),
available_capabilities: None,
witness_quorum: None,
};
println!("{}", serde_json::to_string(&error_result).unwrap());
} 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 resolve_issuer_key(cmd: &VerifyCommand, att: &Attestation) -> Result<Vec<u8>> {
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")?;
if pk_bytes.len() != 32 {
return Err(anyhow!(
"Issuer public key must be 32 bytes (64 hex chars), got {} bytes",
pk_bytes.len()
));
}
return Ok(pk_bytes);
}
let did = cmd.issuer_did.as_deref().unwrap_or(att.issuer.as_str());
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 Ok(pin.public_key_bytes()?);
}
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(),
kel_tip_said: root.kel_tip_said.clone(),
kel_sequence: None,
first_seen: Utc::now(),
origin: format!("roots.json:{}", roots_path.display()),
trust_level: TrustLevel::OrgPolicy,
};
store.pin(pin)?;
return Ok(root.public_key_bytes()?);
}
}
match policy {
TrustPolicy::Tofu => {
anyhow::bail!(
"Unknown identity '{}'. Provide --issuer-pk 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 --issuer-pk <hex> to bypass trust resolution",
did,
did
);
}
}
}
use crate::commands::verify_helpers::parse_witness_keys;
async fn run_verify(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_bytes = resolve_issuer_key(cmd, &att)?;
let required_capability: Option<Capability> = cmd.require_capability.as_ref().map(|cap| {
cap.parse::<Capability>().unwrap_or_else(|e| {
eprintln!("error: {e}");
std::process::exit(2);
})
});
let verify_result = if let Some(ref cap) = required_capability {
verify_with_capability(&att, cap, &issuer_pk_bytes).await
} else {
verify_with_keys(&att, &issuer_pk_bytes).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<WitnessReceipt> = 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_bytes,
&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()),
required_capability: cmd.require_capability.clone(),
available_capabilities: None,
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.");
if required_capability.is_some() {
println!(
"Required capability '{}' is present.",
cmd.require_capability.as_ref().unwrap()
);
}
}
Ok(VerifyResult {
valid: true,
error: None,
issuer: Some(att.issuer.to_string()),
subject: Some(att.subject.to_string()),
required_capability: cmd.require_capability.clone(),
available_capabilities: None,
witness_quorum,
})
}
Err(auths_verifier::error::AttestationError::MissingCapability {
required,
available,
}) => {
let available_strs: Vec<String> =
available.iter().map(|c| format!("{:?}", c)).collect();
Ok(VerifyResult {
valid: false,
error: Some(format!(
"Missing required capability: {:?}. Available: {:?}",
required, available
)),
issuer: Some(att.issuer.to_string()),
subject: Some(att.subject.to_string()),
required_capability: Some(format!("{:?}", required)),
available_capabilities: Some(available_strs),
witness_quorum: None,
})
}
Err(e) => Ok(VerifyResult {
valid: false,
error: Some(e.to_string()),
issuer: Some(att.issuer.to_string()),
subject: Some(att.subject.to_string()),
required_capability: cmd.require_capability.clone(),
available_capabilities: None,
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")?;
if issuer_pk_bytes.len() != 32 {
return Err(anyhow!(
"Issuer public key must be 32 bytes (64 hex chars), got {} bytes",
issuer_pk_bytes.len()
));
}
match verify_with_keys(&att, &issuer_pk_bytes).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()),
required_capability: None,
available_capabilities: None,
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,
required_capability: None,
available_capabilities: None,
witness_quorum: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"valid\":false"));
assert!(json.contains("\"error\":\"signature mismatch\""));
}
#[test]
fn verify_result_with_capability_serializes_correctly() {
let result = VerifyResult {
valid: false,
error: Some("Missing capability".to_string()),
issuer: Some("did:key:issuer".to_string()),
subject: Some("did:key:subject".to_string()),
required_capability: Some("SignRelease".to_string()),
available_capabilities: Some(vec!["SignCommit".to_string()]),
witness_quorum: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"required_capability\":\"SignRelease\""));
assert!(json.contains("\"available_capabilities\":[\"SignCommit\"]"));
}
}