Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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());
    }
}