openpay/ (+3 crates)
op-bnpl/ Affirm + Klarna + Afterpay/Clearpay rails. Full lifecycle
typestate (Initiated→Approved→Captured→Settled→Refunded).
Per-provider sandbox+production base URLs, regions (NA/EU/OC
for Klarna; US/AuNz/Gb/Ca/Eu for Afterpay). HMAC-SHA256
webhook verification with constant-time compare. 61 tests.
op-batch/ NACHA ACH (94-char records, PPD/CCD/WEB/TEL/CTX, R01-R99
return codes, Same-Day ACH cutoffs); SEPA pain.001.001.09
(CT) + pain.008.001.08 (DD) with SCT-Inst flagging; Fedwire
MT103/MT202/CHIPS + pacs.008.001.12 / pacs.009.001.10 +
UETR; Bacs Standard 18. Per-rail cutoff scheduler. Exception
taxonomy unified across rails. 51 tests.
op-screening/ OFAC SDN + EU consolidated + UN consolidated + HMT UK
consolidated + Australia DFAT + Canada SEMA + Japan MOF.
Levenshtein + Jaro-Winkler + Soundex + Metaphone + token
Jaccard scoring. Unicode NFKD normalisation + Cyrillic /
Arabic transliteration. Ed25519-signed BLAKE3-chained
audit log. BSA helpers: CTR + SAR pattern detection
(structuring, round-dollar, rapid-in-out). 43 tests.
smart-byte/smart-byte-rs/ (+3 crates)
smart-byte-bbs/ BBS+ signatures (IETF draft-irtf-cfrg-bbs-signatures-08).
BLS12-381 keys, signatures, disclosure proofs with
cryptographic unlinkability. bbs-2023 Data Integrity
cryptosuite. AnonCreds-shaped link secrets +
predicate proofs. SecretKey zeroize-on-drop. 49 tests.
smart-byte-didcomm/ DIDComm v2 messaging per DIF spec. Pack-encrypted
(ECDH-1PU + ECDH-ES via X25519), pack-signed (JWS),
pack-plaintext. Aries protocols: issue-credential v3,
present-proof v3, basic-message, trust-ping, discover-
features, out-of-band, coordinate-mediation, message-
pickup. Mediator role with pluggable storage. 23 tests.
smart-byte-mdl/ ISO/IEC 18013-5 mobile driver's licence + 18013-7
online presentment. mDoc + MSO with salted-hash
selective disclosure (per item; SHA-256 digest tree).
org.iso.18013.5.1 namespace + AAMVA add-on. ES256
device-auth + issuer-auth. QR-code device-engagement
scaffolding. 28 tests.
eoc/eoc-rs/ (+3 crates)
eoc-local/ Local-inference backends behind one NeuralBackend:
llama.cpp (GGUF, Q4/Q5/Q6/Q8/F16/F32 quantisation),
MLX (Apple Silicon, feature-gated), MLC-LLM (TVM, feature-
gated marker), ONNX Runtime. GGUF file-format reader.
HuggingFace tokenizers. Greedy / top-k / top-p / temperature /
Mirostat samplers. Model registry scanning ~/.eoc/models/.
Joule attribution via eoc-meter. 39 tests.
eoc-rerank/ BM25 sparse + HNSW dense + RRF + min-max hybrid fusion.
Cohere v3.5 + Voyage v2 + bge-reranker v2-m3 + ms-marco
MiniLM + ColBERT v2 rerankers. Two-stage pipeline:
retrieve top-K hybrid → cross-encoder rerank → top-N.
KV-stage recovery shim into the cascade. 31 tests.
eoc-eval/ 15 canonical eval harnesses: MMLU, MMLU-Pro, GPQA,
HumanEval (pass@1 with python3 sandbox), BBH, IFEval (25
verifiable constraints), AlpacaEval, AGIEval, HellaSwag,
ARC (Easy + Challenge), TruthfulQA (MC1+MC2), BoolQ,
GSM8K, MATH (boxed-answer equivalence), Winogrande. Real
questions in BuiltinSamples. EvalRunner emits joules-per-
correct alongside accuracy. 49 tests.
Quality:
- cargo check across all three workspaces: clean
- cargo clippy --all-targets -- -D warnings on every new crate: clean
- 374 new tests, all passing
- #![forbid(unsafe_code)] across every new crate
- No unwrap/expect outside #[cfg(test)]
Rust 1.95.0 edition 2024 (inherited via root rust-toolchain.toml).
440 lines
15 KiB
Rust
440 lines
15 KiB
Rust
//! mDL / mDOC verifier.
|
|
//!
|
|
//! Verification steps (ISO 18013-5 §9.1.3):
|
|
//!
|
|
//! 1. Parse the `issuerAuth` COSE_Sign1 and re-derive its
|
|
//! `Sig_structure`. Verify the signature against either an embedded
|
|
//! `x5chain` certificate's subject public key or a trust anchor's
|
|
//! raw public key.
|
|
//! 2. Decode the MSO from the COSE_Sign1 payload. Confirm
|
|
//! `validityInfo` brackets the verifier's current time.
|
|
//! 3. For each revealed `IssuerSignedItem`, hash its tag-24 encoding
|
|
//! and compare against the MSO digest under the matching `digestID`.
|
|
//! Any mismatch fails the verification.
|
|
//! 4. If a `device_signed` half is present, reconstruct the
|
|
//! `DeviceAuthentication` bytes from the supplied session transcript
|
|
//! and verify the device COSE_Sign1 against the device public key
|
|
//! listed in the MSO `deviceKeyInfo`.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use ciborium::value::Value as CborValue;
|
|
use p256::ecdsa::{
|
|
Signature as P256Signature, VerifyingKey as P256VerifyingKey,
|
|
signature::Verifier as _,
|
|
};
|
|
use sha2::{Digest, Sha256};
|
|
|
|
use crate::error::MdlError;
|
|
use crate::issuer::{COSE_ALG_ES256, MobileSecurityObject};
|
|
use crate::mdoc::{
|
|
DeviceAuth, MobileDoc, decode_cbor, encode_cbor,
|
|
};
|
|
use crate::session_transcript::SessionTranscript;
|
|
|
|
/// Wall-clock provider — abstracted so tests can drive the verifier
|
|
/// at deterministic instants.
|
|
pub trait TimeProvider: Send + Sync {
|
|
/// Current time, in UTC.
|
|
fn now(&self) -> DateTime<Utc>;
|
|
}
|
|
|
|
/// `TimeProvider` backed by `chrono::Utc::now()`.
|
|
#[derive(Default, Debug)]
|
|
pub struct SystemTime;
|
|
|
|
impl TimeProvider for SystemTime {
|
|
fn now(&self) -> DateTime<Utc> {
|
|
Utc::now()
|
|
}
|
|
}
|
|
|
|
/// A fixed time for deterministic tests.
|
|
#[derive(Debug)]
|
|
pub struct FixedTime(pub DateTime<Utc>);
|
|
|
|
impl TimeProvider for FixedTime {
|
|
fn now(&self) -> DateTime<Utc> {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
/// Trust anchor — an issuer public key the verifier trusts directly.
|
|
///
|
|
/// In a full deployment a verifier would walk an X.509 chain; this
|
|
/// crate models trust as a flat list of accepted public keys keyed by
|
|
/// COSE algorithm. Production deployments can wrap a chain validator
|
|
/// around this primitive.
|
|
#[derive(Clone, Debug)]
|
|
pub struct TrustAnchor {
|
|
/// COSE algorithm identifier (e.g. `-7` for ES256).
|
|
pub alg: i64,
|
|
/// Raw subject public key in SEC1 uncompressed form (P-256: 65 bytes
|
|
/// starting with `0x04`).
|
|
pub public_key_sec1: Vec<u8>,
|
|
/// Optional name (free text) for logging.
|
|
pub name: Option<String>,
|
|
}
|
|
|
|
impl TrustAnchor {
|
|
/// Build a trust anchor for an ES256 raw P-256 verifying key.
|
|
pub fn es256(vk: &P256VerifyingKey, name: Option<String>) -> Self {
|
|
let sec1 = vk.to_sec1_bytes();
|
|
Self {
|
|
alg: COSE_ALG_ES256,
|
|
public_key_sec1: sec1.to_vec(),
|
|
name,
|
|
}
|
|
}
|
|
|
|
/// Recover a P-256 VerifyingKey from this anchor.
|
|
pub fn as_p256(&self) -> Result<P256VerifyingKey, MdlError> {
|
|
if self.alg != COSE_ALG_ES256 {
|
|
return Err(MdlError::UnsupportedAlg(format!(
|
|
"trust anchor alg {} not ES256",
|
|
self.alg
|
|
)));
|
|
}
|
|
P256VerifyingKey::from_sec1_bytes(&self.public_key_sec1)
|
|
.map_err(|e| MdlError::Signature(format!("anchor sec1: {e}")))
|
|
}
|
|
}
|
|
|
|
/// Output of a successful verification: per-namespace revealed claims
|
|
/// keyed by `elementIdentifier`.
|
|
#[derive(Clone, Debug)]
|
|
pub struct VerifiedClaims {
|
|
/// `docType` from the mdoc.
|
|
pub doc_type: String,
|
|
/// `namespace -> { element -> value }` — only the revealed items.
|
|
pub claims: BTreeMap<String, BTreeMap<String, CborValue>>,
|
|
/// Time at which the verifier accepted the credential.
|
|
pub verified_at: DateTime<Utc>,
|
|
/// Validity-info echoed from the MSO.
|
|
pub validity_signed: DateTime<Utc>,
|
|
/// Validity-info echoed from the MSO.
|
|
pub validity_until: DateTime<Utc>,
|
|
}
|
|
|
|
/// Verifier handle.
|
|
pub struct Verifier {
|
|
/// Trust anchors accepted for issuer COSE_Sign1.
|
|
pub trusted_issuers: Vec<TrustAnchor>,
|
|
/// Current-time source.
|
|
pub time_provider: Arc<dyn TimeProvider>,
|
|
/// Optional session transcript. Required if the mdoc carries a
|
|
/// `device_signed` half; ignored otherwise.
|
|
pub session_transcript: Option<SessionTranscript>,
|
|
}
|
|
|
|
impl Verifier {
|
|
/// Build a verifier with explicit trust anchors and time source.
|
|
pub fn new(
|
|
trusted_issuers: Vec<TrustAnchor>,
|
|
time_provider: Arc<dyn TimeProvider>,
|
|
) -> Self {
|
|
Self {
|
|
trusted_issuers,
|
|
time_provider,
|
|
session_transcript: None,
|
|
}
|
|
}
|
|
|
|
/// Attach a session transcript. Required when the credential being
|
|
/// verified includes a `device_signed` block.
|
|
pub fn with_transcript(mut self, t: SessionTranscript) -> Self {
|
|
self.session_transcript = Some(t);
|
|
self
|
|
}
|
|
|
|
/// Run the full verification pipeline.
|
|
pub fn verify(&self, doc: &MobileDoc) -> Result<VerifiedClaims, MdlError> {
|
|
// 1. Verify issuer COSE_Sign1.
|
|
let sign1 = &doc.issuer_signed.issuer_auth;
|
|
let sig_input = sign1.sig_structure(&[])?;
|
|
let alg = read_alg_from_protected(&sign1.protected)?;
|
|
let mut accepted = false;
|
|
for anchor in &self.trusted_issuers {
|
|
if anchor.alg != alg {
|
|
continue;
|
|
}
|
|
if verify_sig_with_anchor(anchor, &sig_input, &sign1.signature).is_ok() {
|
|
accepted = true;
|
|
break;
|
|
}
|
|
}
|
|
if !accepted {
|
|
return Err(MdlError::NoTrustAnchor);
|
|
}
|
|
|
|
// 2. Decode MSO and check validity.
|
|
let mso = MobileSecurityObject::from_cbor(&sign1.payload)?;
|
|
if mso.doc_type != doc.doc_type {
|
|
return Err(MdlError::Type(format!(
|
|
"MSO docType {} does not match mdoc docType {}",
|
|
mso.doc_type, doc.doc_type
|
|
)));
|
|
}
|
|
let now = self.time_provider.now();
|
|
if now < mso.validity_info.valid_from || now > mso.validity_info.valid_until {
|
|
return Err(MdlError::ValidityOutOfRange {
|
|
now: now.to_rfc3339(),
|
|
signed: mso.validity_info.signed.to_rfc3339(),
|
|
valid_from: mso.validity_info.valid_from.to_rfc3339(),
|
|
valid_until: mso.validity_info.valid_until.to_rfc3339(),
|
|
});
|
|
}
|
|
|
|
// 3. Check digests for every revealed item.
|
|
let mut revealed: BTreeMap<String, BTreeMap<String, CborValue>> =
|
|
BTreeMap::new();
|
|
for (ns, items) in &doc.issuer_signed.name_spaces {
|
|
let ns_digests = mso.value_digests.get(ns).ok_or_else(|| {
|
|
MdlError::Missing(format!("MSO valueDigests[{ns}]"))
|
|
})?;
|
|
let mut out: BTreeMap<String, CborValue> = BTreeMap::new();
|
|
for item in items {
|
|
let computed = Sha256::digest(item.to_tag24_bytes()?).to_vec();
|
|
let expected =
|
|
ns_digests.get(&item.digest_id).ok_or_else(|| {
|
|
MdlError::Missing(format!(
|
|
"MSO valueDigests[{ns}][{}]",
|
|
item.digest_id
|
|
))
|
|
})?;
|
|
if computed != *expected {
|
|
return Err(MdlError::DigestMismatch {
|
|
namespace: ns.clone(),
|
|
element: item.element_identifier.clone(),
|
|
});
|
|
}
|
|
out.insert(item.element_identifier.clone(), item.element_value.clone());
|
|
}
|
|
revealed.insert(ns.clone(), out);
|
|
}
|
|
|
|
// 4. Verify device authentication, if present.
|
|
if let Some(device_signed) = &doc.device_signed {
|
|
let transcript = self.session_transcript.as_ref().ok_or_else(|| {
|
|
MdlError::Missing("session transcript required to verify device_signed".into())
|
|
})?;
|
|
verify_device_auth(device_signed, &mso, transcript, &doc.doc_type)?;
|
|
for (ns, m) in &device_signed.name_spaces {
|
|
let entry = revealed.entry(ns.clone()).or_default();
|
|
for (k, v) in m {
|
|
entry.insert(k.clone(), v.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(VerifiedClaims {
|
|
doc_type: doc.doc_type.clone(),
|
|
claims: revealed,
|
|
verified_at: now,
|
|
validity_signed: mso.validity_info.signed,
|
|
validity_until: mso.validity_info.valid_until,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn verify_device_auth(
|
|
device_signed: &crate::mdoc::DeviceSigned,
|
|
mso: &MobileSecurityObject,
|
|
transcript: &SessionTranscript,
|
|
doc_type: &str,
|
|
) -> Result<(), MdlError> {
|
|
let sig = match &device_signed.device_auth {
|
|
DeviceAuth::Signature(s) => s,
|
|
DeviceAuth::Mac(_) => {
|
|
return Err(MdlError::UnsupportedAlg(
|
|
"device MAC authentication not implemented".into(),
|
|
));
|
|
}
|
|
};
|
|
// Reconstruct device-signed namespaces bytes (must match what the
|
|
// holder signed).
|
|
let mut ns_entries: Vec<(CborValue, CborValue)> = Vec::new();
|
|
for (ns, items) in &device_signed.name_spaces {
|
|
let mut inner: Vec<(CborValue, CborValue)> = Vec::new();
|
|
for (k, v) in items {
|
|
inner.push((CborValue::Text(k.clone()), v.clone()));
|
|
}
|
|
ns_entries.push((CborValue::Text(ns.clone()), CborValue::Map(inner)));
|
|
}
|
|
let device_ns_bytes = encode_cbor(&CborValue::Map(ns_entries))?;
|
|
let detached = transcript.device_authentication_bytes(doc_type, &device_ns_bytes)?;
|
|
let sig_structure = CborValue::Array(vec![
|
|
CborValue::Text("Signature1".into()),
|
|
CborValue::Bytes(sig.protected.clone()),
|
|
CborValue::Bytes(Vec::new()),
|
|
CborValue::Bytes(detached),
|
|
]);
|
|
let sig_input = encode_cbor(&sig_structure)?;
|
|
let alg = read_alg_from_protected(&sig.protected)?;
|
|
if alg != COSE_ALG_ES256 {
|
|
return Err(MdlError::UnsupportedAlg(format!(
|
|
"device alg {alg} (only ES256 implemented)"
|
|
)));
|
|
}
|
|
let device_vk = device_key_p256_from_cose(&mso.device_key)?;
|
|
let signature = P256Signature::from_slice(&sig.signature)
|
|
.map_err(|e| MdlError::Signature(format!("device sig parse: {e}")))?;
|
|
device_vk
|
|
.verify(&sig_input, &signature)
|
|
.map_err(|e| MdlError::Signature(format!("device verify: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn device_key_p256_from_cose(v: &CborValue) -> Result<P256VerifyingKey, MdlError> {
|
|
let entries = match v {
|
|
CborValue::Map(m) => m,
|
|
_ => return Err(MdlError::Type("deviceKey not a map".into())),
|
|
};
|
|
let mut x: Option<Vec<u8>> = None;
|
|
let mut y: Option<Vec<u8>> = None;
|
|
for (k, val) in entries {
|
|
if let CborValue::Integer(i) = k {
|
|
let n: i128 = (*i).into();
|
|
match n {
|
|
-2 => {
|
|
if let CborValue::Bytes(b) = val {
|
|
x = Some(b.clone());
|
|
}
|
|
}
|
|
-3 => {
|
|
if let CborValue::Bytes(b) = val {
|
|
y = Some(b.clone());
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
let x = x.ok_or_else(|| MdlError::Missing("deviceKey x".into()))?;
|
|
let y = y.ok_or_else(|| MdlError::Missing("deviceKey y".into()))?;
|
|
let mut sec1 = Vec::with_capacity(1 + x.len() + y.len());
|
|
sec1.push(0x04);
|
|
sec1.extend_from_slice(&x);
|
|
sec1.extend_from_slice(&y);
|
|
P256VerifyingKey::from_sec1_bytes(&sec1)
|
|
.map_err(|e| MdlError::Signature(format!("device sec1: {e}")))
|
|
}
|
|
|
|
fn read_alg_from_protected(protected: &[u8]) -> Result<i64, MdlError> {
|
|
if protected.is_empty() {
|
|
return Err(MdlError::Missing("alg missing: protected empty".into()));
|
|
}
|
|
let v: CborValue = decode_cbor(protected)?;
|
|
let entries = match v {
|
|
CborValue::Map(m) => m,
|
|
_ => {
|
|
return Err(MdlError::Type(
|
|
"protected not a map".into(),
|
|
));
|
|
}
|
|
};
|
|
for (k, val) in entries {
|
|
if let CborValue::Integer(i) = k {
|
|
let n: i128 = i.into();
|
|
if n == 1
|
|
&& let CborValue::Integer(ai) = val
|
|
{
|
|
let an: i128 = ai.into();
|
|
return i64::try_from(an)
|
|
.map_err(|e| MdlError::Type(format!("alg int: {e}")));
|
|
}
|
|
}
|
|
}
|
|
Err(MdlError::Missing("alg".into()))
|
|
}
|
|
|
|
fn verify_sig_with_anchor(
|
|
anchor: &TrustAnchor,
|
|
sig_input: &[u8],
|
|
signature_bytes: &[u8],
|
|
) -> Result<(), MdlError> {
|
|
if anchor.alg != COSE_ALG_ES256 {
|
|
return Err(MdlError::UnsupportedAlg(format!(
|
|
"anchor alg {}",
|
|
anchor.alg
|
|
)));
|
|
}
|
|
let vk = anchor.as_p256()?;
|
|
let signature = P256Signature::from_slice(signature_bytes)
|
|
.map_err(|e| MdlError::Signature(format!("sig parse: {e}")))?;
|
|
vk.verify(sig_input, &signature)
|
|
.map_err(|e| MdlError::Signature(format!("verify: {e}")))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::issuer::{Issuer, IssuerKey, ValidityInfo};
|
|
use chrono::TimeZone;
|
|
|
|
fn validity_window() -> ValidityInfo {
|
|
ValidityInfo {
|
|
signed: Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap(),
|
|
valid_from: Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap(),
|
|
valid_until: Utc.with_ymd_and_hms(2030, 5, 23, 12, 0, 0).unwrap(),
|
|
expected_update: None,
|
|
}
|
|
}
|
|
|
|
fn issue_simple() -> (MobileDoc, IssuerKey) {
|
|
let issuer_key = IssuerKey::generate_es256();
|
|
let device_key = IssuerKey::generate_es256();
|
|
let issuer = Issuer::new(
|
|
IssuerKey {
|
|
alg: issuer_key.alg,
|
|
p256: issuer_key.p256.clone(),
|
|
},
|
|
Vec::new(),
|
|
validity_window(),
|
|
);
|
|
let mut ns: BTreeMap<String, BTreeMap<String, CborValue>> = BTreeMap::new();
|
|
let mut inner: BTreeMap<String, CborValue> = BTreeMap::new();
|
|
inner.insert("family_name".into(), CborValue::Text("Doe".into()));
|
|
inner.insert("given_name".into(), CborValue::Text("Jane".into()));
|
|
ns.insert(crate::namespace::NS_MDL.into(), inner);
|
|
let doc = issuer
|
|
.issue(
|
|
"org.iso.18013.5.1.mDL",
|
|
ns,
|
|
device_key.cose_public_key(),
|
|
b"seed",
|
|
)
|
|
.unwrap();
|
|
(doc, issuer_key)
|
|
}
|
|
|
|
#[test]
|
|
fn verifier_accepts_valid_doc() {
|
|
let (doc, issuer_key) = issue_simple();
|
|
let anchor = TrustAnchor::es256(issuer_key.p256.verifying_key(), None);
|
|
let time = Arc::new(FixedTime(
|
|
Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap(),
|
|
));
|
|
let v = Verifier::new(vec![anchor], time);
|
|
let claims = v.verify(&doc).unwrap();
|
|
assert_eq!(claims.doc_type, "org.iso.18013.5.1.mDL");
|
|
assert_eq!(
|
|
claims.claims[crate::namespace::NS_MDL]["family_name"],
|
|
CborValue::Text("Doe".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn verifier_rejects_wrong_anchor() {
|
|
let (doc, _issuer_key) = issue_simple();
|
|
let bogus = IssuerKey::generate_es256();
|
|
let anchor = TrustAnchor::es256(bogus.p256.verifying_key(), None);
|
|
let time = Arc::new(FixedTime(
|
|
Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap(),
|
|
));
|
|
let v = Verifier::new(vec![anchor], time);
|
|
assert!(matches!(v.verify(&doc), Err(MdlError::NoTrustAnchor)));
|
|
}
|
|
}
|