crates/eid-core/src/verify.rs
//! The shared auth+sign crypto core: the canonical bytes a holder signs, and verification of an
//! [`Assertion`]'s signature. Both `frida` and `tyr` build their transports around this so the
//! *cryptographic* meaning of "authenticate" / "sign" is identical across protocols.
use crate::{Assertion, AuthError, Challenge, SigAlg};
/// The exact, **length-prefixed** bytes a holder signs for a [`Challenge`]:
/// `len(nonce)‖nonce ‖ len(context)‖context ‖ len(data_to_sign)‖data_to_sign` (each length a
/// big-endian `u32`; missing `data_to_sign` is an empty field).
///
/// Length prefixes are deliberate: plain concatenation would let a byte move across a field
/// boundary produce the same input (a canonicalization attack). This function is the single source
/// of truth for that canonical form.
pub fn signing_input(challenge: &Challenge) -> Vec<u8> {
let data = challenge.data_to_sign.as_deref().unwrap_or(&[]);
let mut out =
Vec::with_capacity(12 + challenge.nonce.len() + challenge.context.len() + data.len());
for field in [challenge.nonce.as_slice(), challenge.context.as_slice(), data] {
out.extend_from_slice(&(field.len() as u32).to_be_bytes());
out.extend_from_slice(field);
}
out
}
/// Verify that `assertion` is a valid signature over `challenge` by `public_key`.
///
/// The caller (a [`crate::Verifier`]) is responsible for resolving `public_key` from a **trusted**
/// registration for `assertion.key_id` — this function only checks the signature, it does not
/// decide trust. `public_key` encoding: raw 32-byte for [`SigAlg::Ed25519`], SEC1 (compressed or
/// uncompressed point) for [`SigAlg::EcdsaP256`].
pub fn verify_assertion(
challenge: &Challenge,
assertion: &Assertion,
public_key: &[u8],
) -> Result<(), AuthError> {
let msg = signing_input(challenge);
match assertion.alg {
SigAlg::Ed25519 => {
use ed25519_dalek::{Signature, VerifyingKey};
let vk = VerifyingKey::try_from(public_key)
.map_err(|e| AuthError::Verification(format!("bad ed25519 key: {e}")))?;
let sig = Signature::from_slice(&assertion.signature)
.map_err(|e| AuthError::Verification(format!("bad ed25519 signature: {e}")))?;
// SECURITY (#179): `verify_strict` — NOT `verify` — because `public_key` is
// caller-supplied. Plain `verify` accepts small-order / identity public keys (e.g.
// the 32-zero-byte identity point), against which a 64-zero-byte signature verifies
// for ANY message — a total auth bypass. `verify_strict` rejects non-canonical and
// small-order keys/points, closing the bypass.
vk.verify_strict(&msg, &sig)
.map_err(|_| AuthError::Verification("ed25519 signature did not verify".into()))
}
SigAlg::EcdsaP256 => {
use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
let vk = VerifyingKey::from_sec1_bytes(public_key)
.map_err(|e| AuthError::Verification(format!("bad p256 key: {e}")))?;
let sig = Signature::from_slice(&assertion.signature)
.map_err(|e| AuthError::Verification(format!("bad p256 signature: {e}")))?;
vk.verify(&msg, &sig)
.map_err(|_| AuthError::Verification("p256 signature did not verify".into()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::HolderKind;
fn challenge(sign: bool) -> Challenge {
Challenge {
nonce: vec![9, 9, 9, 9],
context: b"origin=g11.se;order=abc".to_vec(),
data_to_sign: sign.then(|| b"approve invoice #42".to_vec()),
}
}
#[test]
fn ed25519_round_trip_and_tamper() {
use ed25519_dalek::{Signer, SigningKey};
let sk = SigningKey::from_bytes(&[7u8; 32]);
let pk = sk.verifying_key().to_bytes().to_vec();
let c = challenge(true);
let sig = sk.sign(&signing_input(&c)).to_bytes().to_vec();
let a = Assertion {
kind: HolderKind::PlatformKey,
alg: SigAlg::Ed25519,
key_id: pk.clone(),
signature: sig,
attestation: vec![],
};
assert!(verify_assertion(&c, &a, &pk).is_ok());
// Tampered data → different signing input → reject.
let mut c2 = c.clone();
c2.data_to_sign = Some(b"approve invoice #43".to_vec());
assert!(verify_assertion(&c2, &a, &pk).is_err());
// Wrong key → reject.
let other = SigningKey::from_bytes(&[8u8; 32]).verifying_key().to_bytes().to_vec();
assert!(verify_assertion(&c, &a, &other).is_err());
}
/// SECURITY regression for #179 — the ed25519 weak-key signature-verification bypass.
///
/// `weak_pk` is the 32-zero-byte encoding, which decodes to a **small-order** point on
/// edwards25519 (not a real public key). With `s = 0` and `R` = that same small-order point
/// (64 zero bytes), the cofactor-less verification equation `[s]B = R + H(R‖A‖M)·A` reduces to
/// `identity = (1 + H(R‖A‖M))·P`, which holds whenever `H(R‖A‖M) ≡ -1 (mod ord P)` — i.e. for
/// a large fraction of messages, with NO knowledge of any private key. ed25519-dalek's plain
/// `verify` does not reject small-order keys, so it ACCEPTS such a forgery: a total auth
/// bypass. `verify_strict` rejects the small-order key up front, for every message.
///
/// The test is self-validating: it first *constructs a real forgery* — searching for a
/// challenge whose canonical `signing_input` naive `verify` accepts (proving the vuln is real,
/// this is the RED behavior) — then asserts that `verify_assertion` (now on `verify_strict`)
/// REJECTS it (the GREEN behavior).
#[test]
fn ed25519_weak_key_forgery_is_rejected() {
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let weak_pk = vec![0u8; 32]; // small-order point encoding — not a legitimate key
let zero_sig = vec![0u8; 64]; // R = small-order point, s = 0
let vk = VerifyingKey::try_from(weak_pk.as_slice()).unwrap();
let sig = Signature::from_slice(&zero_sig).unwrap();
// Find a challenge whose signing_input is forgeable under the OLD naive `verify`.
let mut forged: Option<Challenge> = None;
for i in 0u32..100_000 {
let c = Challenge {
nonce: i.to_be_bytes().to_vec(),
context: b"origin=attacker.example".to_vec(),
data_to_sign: Some(b"transfer 1_000_000 to attacker".to_vec()),
};
if vk.verify(&signing_input(&c), &sig).is_ok() {
forged = Some(c);
break;
}
}
let c = forged.expect(
"expected to construct a naive-verify forgery with the weak key (vuln #179 premise)",
);
// Sanity: the forgery really is accepted by naive `verify` (RED) ...
assert!(
vk.verify(&signing_input(&c), &sig).is_ok(),
"precondition: weak-key forgery must pass naive verify"
);
// ... and MUST be rejected by verify_strict directly ...
assert!(
vk.verify_strict(&signing_input(&c), &sig).is_err(),
"verify_strict must reject the small-order key"
);
// ... and therefore rejected by verify_assertion (GREEN — the fix).
let a = Assertion {
kind: HolderKind::PlatformKey,
alg: SigAlg::Ed25519,
key_id: weak_pk.clone(),
signature: zero_sig,
attestation: vec![],
};
assert!(
verify_assertion(&c, &a, &weak_pk).is_err(),
"weak-key forgery must NOT verify (auth bypass #179 must stay closed)"
);
}
#[test]
fn ecdsa_p256_round_trip() {
use p256::ecdsa::{Signature, SigningKey, signature::Signer};
let sk = SigningKey::from_slice(&[1u8; 32]).unwrap();
let pk = sk.verifying_key().to_sec1_bytes().to_vec();
let c = challenge(false); // auth-only challenge
let sig: Signature = sk.sign(&signing_input(&c));
let a = Assertion {
kind: HolderKind::Pkcs11,
alg: SigAlg::EcdsaP256,
key_id: pk.clone(),
signature: sig.to_vec(),
attestation: vec![],
};
assert!(verify_assertion(&c, &a, &pk).is_ok());
// Different nonce → reject.
let mut c2 = c.clone();
c2.nonce = vec![1, 2, 3, 4];
assert!(verify_assertion(&c2, &a, &pk).is_err());
}
}