pub use auths_keri::witness::{Receipt, ReceiptTag, SignedReceipt};
use auths_crypto::CryptoProvider;
use auths_crypto::did_key::{DecodedDidKey, did_key_decode};
use auths_keri::{KeriPublicKey, Said};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "result", rename_all = "kebab-case")]
pub enum OfflineReceiptVerdict {
Verified {
witness: String,
},
UnreadableIdentity {
reason: String,
},
SignatureFailed {
witness: String,
},
}
impl OfflineReceiptVerdict {
pub fn is_verified(&self) -> bool {
matches!(self, OfflineReceiptVerdict::Verified { .. })
}
}
pub fn verify_receipt_offline(
signed: &SignedReceipt,
witness_identity: &str,
) -> OfflineReceiptVerdict {
let decoded = match did_key_decode(witness_identity) {
Ok(d) => d,
Err(e) => {
return OfflineReceiptVerdict::UnreadableIdentity {
reason: e.to_string(),
};
}
};
let key = match &decoded {
DecodedDidKey::Ed25519(bytes) => KeriPublicKey::from_verkey_bytes(bytes, decoded.curve()),
DecodedDidKey::P256(bytes) => KeriPublicKey::from_verkey_bytes(bytes, decoded.curve()),
};
let key = match key {
Ok(k) => k,
Err(e) => {
return OfflineReceiptVerdict::UnreadableIdentity {
reason: e.to_string(),
};
}
};
let payload = match serde_json::to_vec(&signed.receipt) {
Ok(p) => p,
Err(e) => {
return OfflineReceiptVerdict::UnreadableIdentity {
reason: format!("receipt is not serializable to its signing bytes: {e}"),
};
}
};
match key.verify_signature(&payload, &signed.signature) {
Ok(()) => OfflineReceiptVerdict::Verified {
witness: witness_identity.to_string(),
},
Err(_) => OfflineReceiptVerdict::SignatureFailed {
witness: witness_identity.to_string(),
},
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "result", rename_all = "kebab-case")]
pub enum OfflineBuildVerdict {
Verified {
digest: String,
},
Unreadable {
reason: String,
},
SignatureFailed {
issuer: String,
},
DigestMismatch {
attested: String,
running: String,
},
}
impl OfflineBuildVerdict {
pub fn is_verified(&self) -> bool {
matches!(self, OfflineBuildVerdict::Verified { .. })
}
}
#[cfg(feature = "native")]
pub async fn verify_build_attestation_offline(
attestation: &crate::core::Attestation,
running_digest: &str,
) -> OfflineBuildVerdict {
let attested = match attestation
.payload
.as_ref()
.and_then(|p| p.get("digest"))
.and_then(|d| d.get("hex"))
.and_then(|h| h.as_str())
{
Some(h) => h.to_string(),
None => {
return OfflineBuildVerdict::Unreadable {
reason:
"attestation carries no artifact digest to check against the running binary"
.to_string(),
};
}
};
let issuer = attestation.issuer.as_str();
let decoded = match did_key_decode(issuer) {
Ok(d) => d,
Err(e) => {
return OfflineBuildVerdict::Unreadable {
reason: format!("attestation issuer is not a readable did:key: {e}"),
};
}
};
let issuer_pk = match crate::core::DevicePublicKey::try_new(decoded.curve(), decoded.bytes()) {
Ok(pk) => pk,
Err(e) => {
return OfflineBuildVerdict::Unreadable {
reason: format!("attestation issuer key is unusable: {e}"),
};
}
};
if crate::verify_with_keys(attestation, &issuer_pk)
.await
.is_err()
{
return OfflineBuildVerdict::SignatureFailed {
issuer: issuer.to_string(),
};
}
if attested != running_digest {
return OfflineBuildVerdict::DigestMismatch {
attested,
running: running_digest.to_string(),
};
}
OfflineBuildVerdict::Verified { digest: attested }
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WitnessQuorum {
pub required: usize,
pub verified: usize,
pub receipts: Vec<WitnessReceiptResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WitnessReceiptResult {
pub witness_id: String,
pub receipt_said: Said,
pub verified: bool,
}
pub struct WitnessVerifyConfig<'a> {
pub receipts: &'a [SignedReceipt],
pub witness_keys: &'a [(String, Vec<u8>)],
pub threshold: usize,
}
pub async fn verify_receipt_signature(
signed: &SignedReceipt,
pk: &[u8],
provider: &dyn CryptoProvider,
) -> bool {
let payload = match serde_json::to_vec(&signed.receipt) {
Ok(p) => p,
Err(_) => return false,
};
provider
.verify_ed25519(pk, &payload, &signed.signature)
.await
.is_ok()
}
pub async fn verify_witness_receipts(
config: &WitnessVerifyConfig<'_>,
provider: &dyn CryptoProvider,
) -> WitnessQuorum {
let mut results = Vec::with_capacity(config.receipts.len());
let mut verified_count = 0;
for signed in config.receipts {
let matching_key = config
.witness_keys
.iter()
.find(|(did, _)| did.as_str() == signed.receipt.i.as_str());
let verified = match matching_key {
Some((_, pk)) => verify_receipt_signature(signed, pk, provider).await,
None => false,
};
if verified {
verified_count += 1;
}
results.push(WitnessReceiptResult {
witness_id: signed.receipt.i.to_string(),
receipt_said: signed.receipt.d.clone(),
verified,
});
}
WitnessQuorum {
required: config.threshold,
verified: verified_count,
receipts: results,
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use auths_crypto::RingCryptoProvider;
use auths_crypto::testing::create_test_keypair;
use ring::signature::Ed25519KeyPair;
fn provider() -> RingCryptoProvider {
RingCryptoProvider
}
use auths_keri::{KeriSequence, Prefix, VersionString};
fn create_signed_receipt(
kp: &Ed25519KeyPair,
witness_did: &str,
event_said: &str,
seq: u128,
) -> SignedReceipt {
let receipt = Receipt {
v: VersionString::placeholder(),
t: ReceiptTag,
d: Said::new_unchecked(event_said.to_string()),
i: Prefix::new_unchecked(witness_did.to_string()),
s: KeriSequence::new(seq),
};
let payload = serde_json::to_vec(&receipt).unwrap();
let sig = kp.sign(&payload).as_ref().to_vec();
SignedReceipt {
receipt,
signature: sig,
}
}
#[test]
fn receipt_body_has_no_sig_field() {
let receipt = Receipt {
v: VersionString::placeholder(),
t: ReceiptTag,
d: Said::new_unchecked("EEvent456".into()),
i: Prefix::new_unchecked("did:key:z6MkWitness".to_string()),
s: KeriSequence::new(5),
};
let payload = serde_json::to_vec(&receipt).unwrap();
let payload_str = String::from_utf8(payload).unwrap();
assert!(!payload_str.contains("sig"));
assert!(payload_str.contains("EEvent456"));
assert!(payload_str.contains("did:key:z6MkWitness"));
}
#[tokio::test]
async fn verify_receipt_valid_signature() {
let (kp, pk) = create_test_keypair(&[10u8; 32]);
let receipt = create_signed_receipt(&kp, "did:key:z6MkW1", "EEvent1", 1);
assert!(verify_receipt_signature(&receipt, &pk, &provider()).await);
}
#[tokio::test]
async fn verify_receipt_invalid_signature() {
let (kp, _pk) = create_test_keypair(&[10u8; 32]);
let (_kp2, pk2) = create_test_keypair(&[20u8; 32]);
let receipt = create_signed_receipt(&kp, "did:key:z6MkW1", "EEvent1", 1);
assert!(!verify_receipt_signature(&receipt, &pk2, &provider()).await);
}
#[tokio::test]
async fn verify_receipt_tampered_signature() {
let (kp, pk) = create_test_keypair(&[10u8; 32]);
let mut receipt = create_signed_receipt(&kp, "did:key:z6MkW1", "EEvent1", 1);
receipt.signature[0] ^= 0xFF;
assert!(!verify_receipt_signature(&receipt, &pk, &provider()).await);
}
#[tokio::test]
async fn quorum_met() {
let (kp1, pk1) = create_test_keypair(&[10u8; 32]);
let (kp2, pk2) = create_test_keypair(&[20u8; 32]);
let (_kp3, pk3) = create_test_keypair(&[30u8; 32]);
let r1 = create_signed_receipt(&kp1, "did:key:w1", "EEvent1", 1);
let r2 = create_signed_receipt(&kp2, "did:key:w2", "EEvent1", 1);
let config = WitnessVerifyConfig {
receipts: &[r1, r2],
witness_keys: &[
("did:key:w1".into(), pk1.to_vec()),
("did:key:w2".into(), pk2.to_vec()),
("did:key:w3".into(), pk3.to_vec()),
],
threshold: 2,
};
let quorum = verify_witness_receipts(&config, &provider()).await;
assert_eq!(quorum.required, 2);
assert_eq!(quorum.verified, 2);
assert_eq!(quorum.receipts.len(), 2);
assert!(quorum.receipts[0].verified);
assert!(quorum.receipts[1].verified);
}
#[tokio::test]
async fn quorum_not_met() {
let (kp1, pk1) = create_test_keypair(&[10u8; 32]);
let (kp2, _pk2) = create_test_keypair(&[20u8; 32]);
let (_kp3, pk3) = create_test_keypair(&[30u8; 32]);
let r1 = create_signed_receipt(&kp1, "did:key:w1", "EEvent1", 1);
let r2 = create_signed_receipt(&kp2, "did:key:w2", "EEvent1", 1);
let config = WitnessVerifyConfig {
receipts: &[r1, r2],
witness_keys: &[
("did:key:w1".into(), pk1.to_vec()),
("did:key:w2".into(), pk3.to_vec()),
],
threshold: 2,
};
let quorum = verify_witness_receipts(&config, &provider()).await;
assert_eq!(quorum.required, 2);
assert_eq!(quorum.verified, 1);
assert!(quorum.receipts[0].verified);
assert!(!quorum.receipts[1].verified);
}
#[tokio::test]
async fn unknown_witness_ignored() {
let (kp1, _pk1) = create_test_keypair(&[10u8; 32]);
let (kp2, pk2) = create_test_keypair(&[20u8; 32]);
let r1 = create_signed_receipt(&kp1, "did:key:unknown", "EEvent1", 1);
let r2 = create_signed_receipt(&kp2, "did:key:w2", "EEvent1", 1);
let config = WitnessVerifyConfig {
receipts: &[r1, r2],
witness_keys: &[("did:key:w2".into(), pk2.to_vec())],
threshold: 1,
};
let quorum = verify_witness_receipts(&config, &provider()).await;
assert_eq!(quorum.verified, 1);
assert!(!quorum.receipts[0].verified);
assert!(quorum.receipts[1].verified);
}
#[test]
fn wire_compat_with_core_receipt() {
let json = r#"{"v": "KERI10JSON000000_", "t": "rct", "d": "EEvent456", "i": "did:key:z6MkWitness", "s": "5"}"#;
let receipt: Receipt = serde_json::from_str(json).unwrap();
assert_eq!(receipt.t, "rct");
assert_eq!(receipt.d, "EEvent456");
assert_eq!(receipt.i.as_str(), "did:key:z6MkWitness");
assert_eq!(receipt.s.value(), 5);
let json_out = serde_json::to_string(&receipt).unwrap();
let parsed: Receipt = serde_json::from_str(&json_out).unwrap();
assert_eq!(receipt, parsed);
}
fn signed_with_published_identity(event_said: &str) -> (SignedReceipt, String) {
use auths_crypto::CurveType;
let (kp, pk) = create_test_keypair(&[42u8; 32]);
let signed = create_signed_receipt(&kp, "EController", event_said, 0);
let identity = auths_verifier_did_key(&pk, CurveType::Ed25519);
(signed, identity)
}
fn auths_verifier_did_key(pk: &[u8], curve: auths_crypto::CurveType) -> String {
crate::types::CanonicalDid::from_public_key_did_key(pk, curve).into_inner()
}
#[test]
fn offline_verify_accepts_a_genuine_receipt_with_no_key_table() {
let (signed, identity) = signed_with_published_identity("EEventOffline");
let verdict = verify_receipt_offline(&signed, &identity);
assert!(
verdict.is_verified(),
"a genuine receipt must verify against the published identity alone: {verdict:?}"
);
}
#[test]
fn offline_verify_rejects_a_bit_flipped_signature() {
let (mut signed, identity) = signed_with_published_identity("EEventOffline");
signed.signature[0] ^= 0x01;
let verdict = verify_receipt_offline(&signed, &identity);
assert_eq!(
verdict,
OfflineReceiptVerdict::SignatureFailed {
witness: identity.clone()
},
"a tampered signature must be a distinct SignatureFailed verdict"
);
assert!(!verdict.is_verified());
}
#[test]
fn offline_verify_rejects_a_tampered_receipt_body() {
let (mut signed, identity) = signed_with_published_identity("EEventOffline");
signed.receipt.d = Said::new_unchecked("EEventTampered".into());
let verdict = verify_receipt_offline(&signed, &identity);
assert!(matches!(
verdict,
OfflineReceiptVerdict::SignatureFailed { .. }
));
}
#[test]
fn offline_verify_rejects_a_foreign_identity() {
let (signed, _genuine) = signed_with_published_identity("EEventOffline");
let (_other_kp, other_pk) = create_test_keypair(&[7u8; 32]);
let foreign = auths_verifier_did_key(&other_pk, auths_crypto::CurveType::Ed25519);
let verdict = verify_receipt_offline(&signed, &foreign);
assert!(matches!(
verdict,
OfflineReceiptVerdict::SignatureFailed { .. }
));
}
#[test]
fn offline_verify_flags_an_unreadable_identity_distinctly() {
let (signed, _identity) = signed_with_published_identity("EEventOffline");
let verdict = verify_receipt_offline(&signed, "not-a-did-key");
assert!(matches!(
verdict,
OfflineReceiptVerdict::UnreadableIdentity { .. }
));
}
use crate::testing::AttestationBuilder;
#[tokio::test]
async fn build_verify_no_digest_is_unreadable() {
let att = AttestationBuilder::default()
.issuer("did:key:z6MkfooNoDigest")
.payload(Some(serde_json::json!({ "artifact_type": "file" })))
.build();
let verdict = verify_build_attestation_offline(&att, "deadbeef").await;
assert!(matches!(verdict, OfflineBuildVerdict::Unreadable { .. }));
}
#[tokio::test]
async fn build_verify_unreadable_issuer_is_unreadable() {
let att = AttestationBuilder::default()
.issuer("did:keri:ENotADidKey")
.payload(Some(serde_json::json!({
"digest": { "algorithm": "sha256", "hex": "abc123" }
})))
.build();
let verdict = verify_build_attestation_offline(&att, "abc123").await;
assert!(matches!(verdict, OfflineBuildVerdict::Unreadable { .. }));
}
#[test]
fn build_verdict_verified_is_the_only_success_arm() {
assert!(
OfflineBuildVerdict::Verified {
digest: "abc".into()
}
.is_verified()
);
assert!(
!OfflineBuildVerdict::DigestMismatch {
attested: "a".into(),
running: "b".into(),
}
.is_verified()
);
}
}