use auths_crypto::CryptoProvider;
use auths_keri::witness::{NoWitnessReceipts, WitnessReceiptLookup};
use auths_keri::{
CesrKey, DelegatorKelLookup, Event, KeriPublicKey, Prefix, Said, Seal, SourceSeal,
WitnessedReplay, validate_delegation, validate_kel_with_lookup, validate_kel_with_receipts,
};
use crate::commit::{extract_ssh_signature, verify_commit_signature};
use crate::commit_error::CommitVerificationError;
use crate::core::DevicePublicKey;
use crate::duplicity::{KelEventRef, detect_duplicity};
use crate::ssh_sig::parse_sshsig_pem;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommitVerdict {
Valid {
signer_did: String,
root_did: String,
duplicitous_root: bool,
},
Unsigned,
SshSignatureInvalid,
GpgUnsupported,
DeviceKelInvalid(String),
RootKelInvalid(String),
RootNotPinned(String),
RootAbandoned,
NotDelegatedByClaimedRoot {
device_did: String,
root_did: String,
},
DelegationSealNotFound,
DeviceRevoked,
SignedAfterRevocation {
signer_did: String,
signed_at: u128,
revoked_at: u128,
},
OutsideAgentScope {
signer_did: String,
capability: String,
},
AgentExpired {
signer_did: String,
expired_at: i64,
signed_at: i64,
},
SignerKeyMismatch,
SignedBySupersededKey,
WitnessQuorumNotMet {
root_did: String,
collected: usize,
required: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VerifierWitnessPolicy {
#[default]
Warn,
RequireWitnesses,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WitnessGateStatus {
NotRequired,
Met,
UnderQuorum {
collected: usize,
required: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WitnessedVerdict {
pub verdict: CommitVerdict,
pub witness: WitnessGateStatus,
}
impl CommitVerdict {
pub fn is_valid(&self) -> bool {
matches!(self, CommitVerdict::Valid { .. })
}
}
struct RootKelLookup<'a> {
root_kel: &'a [Event],
}
impl DelegatorKelLookup for RootKelLookup<'_> {
fn find_seal(&self, _delegator_aid: &Prefix, seal_said: &Said) -> Option<SourceSeal> {
for event in self.root_kel {
for seal in event.anchors() {
if let Seal::KeyEvent { d, .. } = seal
&& d == seal_said
{
return Some(SourceSeal {
s: event.sequence(),
d: event.said().clone(),
});
}
}
}
None
}
}
fn revocation_position(root_kel: &[Event], device_prefix: &Prefix) -> Option<u128> {
root_kel.iter().find_map(|event| {
let revokes = event
.anchors()
.iter()
.any(|seal| matches!(seal, Seal::Digest { d } if d.as_str() == device_prefix.as_str()));
revokes.then(|| event.sequence().value())
})
}
pub const ANCHOR_SEQ_TRAILER: &str = "Auths-Anchor-Seq";
pub fn anchor_seq_trailer(seq: u128) -> String {
format!("{ANCHOR_SEQ_TRAILER}: {seq}")
}
fn parse_anchor_seq(commit_bytes: &[u8]) -> Option<u128> {
let text = std::str::from_utf8(commit_bytes).ok()?;
text.lines().find_map(|line| {
let rest = line.trim().strip_prefix(ANCHOR_SEQ_TRAILER)?;
rest.trim_start()
.strip_prefix(':')?
.trim()
.parse::<u128>()
.ok()
})
}
pub const SCOPE_TRAILER: &str = "Auths-Scope";
pub fn scope_trailer(capabilities: &[String]) -> String {
format!("{SCOPE_TRAILER}: {}", capabilities.join(","))
}
fn parse_scope_claim(commit_bytes: &[u8]) -> Vec<String> {
let Ok(text) = std::str::from_utf8(commit_bytes) else {
return Vec::new();
};
text.lines()
.find_map(|line| {
line.trim()
.strip_prefix(SCOPE_TRAILER)?
.trim_start()
.strip_prefix(':')
.map(|rest| {
rest.trim()
.split(',')
.filter(|c| !c.is_empty())
.map(|c| c.trim().to_string())
.collect::<Vec<_>>()
})
})
.unwrap_or_default()
}
fn read_agent_scope_from_kel(
root_kel: &[Event],
agent_prefix: &Prefix,
) -> Option<auths_keri::AgentScope> {
let mut found = None;
for event in root_kel {
for seal in event.anchors() {
if let Seal::Digest { d } = seal
&& let Some((prefix, scope)) = auths_keri::decode_agent_scope(d.as_str())
&& prefix == agent_prefix.as_str()
{
found = Some(scope);
}
}
}
found
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RevocationOrdering {
NotRevoked,
SignedBefore,
SignedAfter {
signed_at: u128,
revoked_at: u128,
},
RevokedUnknownPosition,
}
fn classify_revocation(
signing_anchor: Option<u128>,
revocation: Option<u128>,
) -> RevocationOrdering {
match (revocation, signing_anchor) {
(None, _) => RevocationOrdering::NotRevoked,
(Some(_), None) => RevocationOrdering::RevokedUnknownPosition,
(Some(rev), Some(sign)) if sign < rev => RevocationOrdering::SignedBefore,
(Some(rev), Some(sign)) => RevocationOrdering::SignedAfter {
signed_at: sign,
revoked_at: rev,
},
}
}
fn establishment_keys(device_kel: &[Event]) -> Vec<DevicePublicKey> {
device_kel
.iter()
.filter_map(|event| match event {
Event::Icp(e) => Some(&e.k),
Event::Dip(e) => Some(&e.k),
Event::Rot(e) => Some(&e.k),
Event::Drt(e) => Some(&e.k),
_ => None,
})
.flatten()
.filter_map(cesr_to_device_pk)
.collect()
}
fn cesr_to_device_pk(cesr: &CesrKey) -> Option<DevicePublicKey> {
let keri = KeriPublicKey::parse(cesr.as_str()).ok()?;
let curve = keri.curve();
let bytes = keri.into_bytes().to_vec();
DevicePublicKey::try_new(curve, &bytes).ok()
}
pub async fn verify_commit_against_kel(
commit_bytes: &[u8],
device_kel: &[Event],
root_kel: &[Event],
pinned_roots: &[String],
provider: &dyn CryptoProvider,
) -> CommitVerdict {
verify_commit_against_kel_witnessed(
commit_bytes,
device_kel,
root_kel,
pinned_roots,
provider,
&NoWitnessReceipts,
VerifierWitnessPolicy::Warn,
)
.await
.verdict
}
pub async fn verify_commit_against_kel_witnessed(
commit_bytes: &[u8],
device_kel: &[Event],
root_kel: &[Event],
pinned_roots: &[String],
provider: &dyn CryptoProvider,
receipt_lookup: &dyn WitnessReceiptLookup,
policy: VerifierWitnessPolicy,
) -> WitnessedVerdict {
let replay = match validate_kel_with_receipts(root_kel, None, receipt_lookup) {
Ok(r) => r,
Err(e) => {
return WitnessedVerdict {
verdict: CommitVerdict::RootKelInvalid(e.to_string()),
witness: WitnessGateStatus::NotRequired,
};
}
};
let root_state = replay.state().clone();
let root_did = format!("did:keri:{}", root_state.prefix);
let witness = match &replay {
WitnessedReplay::Accepted(s) => {
if s.backers.is_empty() {
WitnessGateStatus::NotRequired
} else {
WitnessGateStatus::Met
}
}
WitnessedReplay::Pending {
collected,
required,
state,
..
} => {
let required = required
.simple_value()
.map(|v| v as usize)
.unwrap_or(state.backers.len());
let status = WitnessGateStatus::UnderQuorum {
collected: *collected,
required,
};
if matches!(policy, VerifierWitnessPolicy::RequireWitnesses) {
return WitnessedVerdict {
verdict: CommitVerdict::WitnessQuorumNotMet {
root_did,
collected: *collected,
required,
},
witness: status,
};
}
status
}
};
let verdict = authorize_commit(
commit_bytes,
device_kel,
root_kel,
pinned_roots,
provider,
root_state,
None,
)
.await;
WitnessedVerdict { verdict, witness }
}
pub async fn verify_commit_against_kel_scoped(
commit_bytes: &[u8],
device_kel: &[Event],
root_kel: &[Event],
pinned_roots: &[String],
provider: &dyn CryptoProvider,
now: i64,
) -> CommitVerdict {
let root_state = match auths_keri::validate_kel(root_kel) {
Ok(state) => state,
Err(e) => return CommitVerdict::RootKelInvalid(e.to_string()),
};
authorize_commit(
commit_bytes,
device_kel,
root_kel,
pinned_roots,
provider,
root_state,
Some(now),
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn authorize_commit(
commit_bytes: &[u8],
device_kel: &[Event],
root_kel: &[Event],
pinned_roots: &[String],
provider: &dyn CryptoProvider,
root_state: auths_keri::KeyState,
now: Option<i64>,
) -> CommitVerdict {
let root_prefix = root_state.prefix.clone();
let root_did = format!("did:keri:{root_prefix}");
if !pinned_roots.contains(&root_did) {
return CommitVerdict::RootNotPinned(root_did);
}
if root_state.is_abandoned {
return CommitVerdict::RootAbandoned;
}
let lookup = RootKelLookup { root_kel };
let device_state = match validate_kel_with_lookup(device_kel, Some(&lookup)) {
Ok(s) => s,
Err(e) => {
if let Some(first @ Event::Dip(_)) = device_kel.first()
&& validate_delegation(first, root_kel).is_err()
{
return CommitVerdict::DelegationSealNotFound;
}
return CommitVerdict::DeviceKelInvalid(e.to_string());
}
};
let device_prefix = device_state.prefix.clone();
let device_did = format!("did:keri:{device_prefix}");
if let Some(verdict) = reject_unauthorized_delegate(
commit_bytes,
root_kel,
&root_prefix,
&device_state,
&device_did,
&root_did,
now,
) {
return verdict;
}
let refs: Vec<KelEventRef> = root_kel
.iter()
.map(|e| KelEventRef {
prefix: root_prefix.as_str(),
seq: e.sequence().value() as u64,
said: e.said().as_str(),
})
.collect();
let duplicitous_root = !matches!(
detect_duplicity(&refs),
crate::duplicity::DuplicityReport::Clean
);
let Some(current_cesr) = device_state.current_keys.first() else {
return CommitVerdict::DeviceKelInvalid("device KEL has no current key".to_string());
};
let Some(current_pk) = cesr_to_device_pk(current_cesr) else {
return CommitVerdict::DeviceKelInvalid("device current key is undecodable".to_string());
};
match verify_commit_signature(
commit_bytes,
std::slice::from_ref(¤t_pk),
provider,
None,
)
.await
{
Ok(_) => CommitVerdict::Valid {
signer_did: device_did,
root_did,
duplicitous_root,
},
Err(CommitVerificationError::UnsignedCommit) => CommitVerdict::Unsigned,
Err(CommitVerificationError::GpgNotSupported) => CommitVerdict::GpgUnsupported,
Err(CommitVerificationError::SignatureInvalid) => CommitVerdict::SshSignatureInvalid,
Err(CommitVerificationError::NamespaceMismatch { .. }) => {
CommitVerdict::SshSignatureInvalid
}
Err(CommitVerificationError::UnknownSigner) => {
classify_unknown_signer(commit_bytes, device_kel, ¤t_pk)
}
Err(_) => CommitVerdict::SshSignatureInvalid,
}
}
fn reject_unauthorized_delegate(
commit_bytes: &[u8],
root_kel: &[Event],
root_prefix: &Prefix,
device_state: &auths_keri::KeyState,
device_did: &str,
root_did: &str,
now: Option<i64>,
) -> Option<CommitVerdict> {
let device_prefix = device_state.prefix.clone();
let root_signs_directly = device_prefix == *root_prefix && device_state.delegator.is_none();
if root_signs_directly {
return None;
}
match &device_state.delegator {
Some(delegator) if *delegator == *root_prefix => {}
_ => {
return Some(CommitVerdict::NotDelegatedByClaimedRoot {
device_did: device_did.to_string(),
root_did: root_did.to_string(),
});
}
}
let revocation = revocation_position(root_kel, &device_prefix);
match classify_revocation(parse_anchor_seq(commit_bytes), revocation) {
RevocationOrdering::NotRevoked | RevocationOrdering::SignedBefore => {}
RevocationOrdering::RevokedUnknownPosition => return Some(CommitVerdict::DeviceRevoked),
RevocationOrdering::SignedAfter {
signed_at,
revoked_at,
} => {
return Some(CommitVerdict::SignedAfterRevocation {
signer_did: device_did.to_string(),
signed_at,
revoked_at,
});
}
}
if let Some(now) = now
&& let Some(scope) = read_agent_scope_from_kel(root_kel, &device_prefix)
{
if let Some(expires_at) = scope.expires_at
&& now >= expires_at
{
return Some(CommitVerdict::AgentExpired {
signer_did: device_did.to_string(),
expired_at: expires_at,
signed_at: now,
});
}
if !scope.capabilities.is_empty() {
for claimed in parse_scope_claim(commit_bytes) {
if !scope.capabilities.contains(&claimed) {
return Some(CommitVerdict::OutsideAgentScope {
signer_did: device_did.to_string(),
capability: claimed,
});
}
}
}
}
None
}
fn classify_unknown_signer(
commit_bytes: &[u8],
device_kel: &[Event],
current_pk: &DevicePublicKey,
) -> CommitVerdict {
let Ok(content) = std::str::from_utf8(commit_bytes) else {
return CommitVerdict::SignerKeyMismatch;
};
let Ok(extracted) = extract_ssh_signature(content) else {
return CommitVerdict::SignerKeyMismatch;
};
let Ok(envelope) = parse_sshsig_pem(&extracted.signature_pem) else {
return CommitVerdict::SignerKeyMismatch;
};
if envelope.public_key != *current_pk
&& establishment_keys(device_kel).contains(&envelope.public_key)
{
return CommitVerdict::SignedBySupersededKey;
}
CommitVerdict::SignerKeyMismatch
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn trailer_round_trips_signing_sequence() {
assert_eq!(anchor_seq_trailer(7), "Auths-Anchor-Seq: 7");
let commit =
"fix: a thing\n\nbody line\n\nAuths-Id: did:keri:Eroot\nAuths-Anchor-Seq: 42\n";
assert_eq!(parse_anchor_seq(commit.as_bytes()), Some(42));
assert_eq!(parse_anchor_seq(b"no trailer here"), None);
}
#[test]
fn commit_before_revocation_still_valid() {
assert_eq!(
classify_revocation(Some(1), Some(2)),
RevocationOrdering::SignedBefore
);
}
#[test]
fn commit_after_revocation_rejected_by_position() {
assert_eq!(
classify_revocation(Some(3), Some(2)),
RevocationOrdering::SignedAfter {
signed_at: 3,
revoked_at: 2
}
);
assert!(matches!(
classify_revocation(Some(2), Some(2)),
RevocationOrdering::SignedAfter { .. }
));
}
#[test]
fn revocation_ordering_is_kel_position_not_wallclock() {
assert_eq!(
classify_revocation(Some(99), None),
RevocationOrdering::NotRevoked
);
assert_eq!(
classify_revocation(None, Some(5)),
RevocationOrdering::RevokedUnknownPosition
);
assert_eq!(
classify_revocation(Some(4), Some(5)),
RevocationOrdering::SignedBefore
);
assert!(matches!(
classify_revocation(Some(6), Some(5)),
RevocationOrdering::SignedAfter { .. }
));
}
}