//! What kind of key a producer enrolled, and what may be done with it. //! //! # The failure this prevents //! //! A `user` principal authenticates with WebAuthn. The credential is a //! **COSE key**, and the authenticator does not sign the message a //! relying party cares about -- it signs //! `authenticatorData ‖ SHA-256(clientDataJSON)`, with the challenge //! buried inside the client data. //! //! An `agent` or `program` holds a raw Ed25519 key and produces a bare //! signature over the message itself. //! //! These are not interchangeable, and the dangerous part is how they //! fail together. Handing a COSE key and a WebAuthn assertion to a bare //! Ed25519 verifier does not produce a clean error -- it produces a //! verification failure that looks like a wrong key, which invites //! somebody to "fix" it by loosening the check. A registry with one //! column and one verifier makes that mistake available; this makes it //! unavailable. //! //! # Where the boundary is //! //! Trusty stores the format and **refuses** to verify what it cannot //! verify. It does not verify WebAuthn assertions, deliberately: doing //! that requires the relying party's RP ID, origin, and challenge //! policy, none of which trusty has any business knowing. A half-built //! WebAuthn verifier is worse than none, because it looks like one. //! //! The consumer that *is* the relying party verifies the assertion and //! tells trusty the outcome. use serde::{Deserialize, Serialize}; use crate::crypto::CryptoError; /// How a producer's enrolled key material is encoded, and therefore how /// a signature from it must be checked. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "kebab-case")] pub enum KeyFormat { /// A raw 32-byte Ed25519 public key. The signature covers the /// message directly. This is what an agent or program enrols. #[default] Ed25519Raw, /// A COSE_Key from a WebAuthn authenticator. The signature covers /// `authenticatorData ‖ SHA-256(clientDataJSON)`, not the message, /// so it cannot be checked without the assertion that produced it. /// This is what a user enrols. Cose, } impl KeyFormat { pub fn as_str(self) -> &'static str { match self { Self::Ed25519Raw => "ed25519-raw", Self::Cose => "cose", } } /// Parse a stored format string. /// /// An unrecognised value is an error, not a default. Defaulting to /// `Ed25519Raw` would silently route a credential of unknown shape /// into the bare-signature path -- the exact failure this type /// exists to prevent. pub fn parse(s: &str) -> Result { match s { "ed25519-raw" => Ok(Self::Ed25519Raw), "cose" => Ok(Self::Cose), other => Err(CryptoError::UnsupportedCredential(format!( "unknown key format {other:?}; refusing rather than guessing, \ because guessing wrong means checking a WebAuthn credential \ as a bare signature" ))), } } /// Whether a bare signature over the message is the right check. pub fn is_bare_signature(self) -> bool { matches!(self, Self::Ed25519Raw) } } /// Refuse to verify a credential whose format is not bare-signature. /// /// Call this before any `Verifier::verify` that uses a key from the /// producer registry. Returns the error a caller should surface rather /// than working around. pub fn require_bare_signature(format: KeyFormat, producer_oid: &str) -> Result<(), CryptoError> { if format.is_bare_signature() { return Ok(()); } Err(CryptoError::UnsupportedCredential(format!( "producer {producer_oid:?} enrolled a {} credential, which signs \ `authenticatorData || SHA-256(clientDataJSON)` rather than the message. \ It cannot be checked as a bare signature. Verify the WebAuthn assertion \ at the relying party -- which holds the RP ID, origin and challenge -- \ and record the outcome; trusty does not verify assertions and will not \ pretend to.", format.as_str() ))) } #[cfg(test)] mod tests { use super::*; #[test] fn a_cose_credential_cannot_be_checked_as_a_bare_signature() { let err = require_bare_signature(KeyFormat::Cose, "user:01H") .expect_err("must refuse"); let msg = err.to_string(); assert!( msg.contains("clientDataJSON"), "the refusal must say WHY, or the next person will assume a wrong \ key and loosen the check to make it pass: {msg}" ); assert!( msg.contains("relying party"), "and it must say who should verify instead, or the reader is left \ with a refusal and nowhere to go: {msg}" ); } #[test] fn a_raw_ed25519_credential_is_allowed_through() { assert!(require_bare_signature(KeyFormat::Ed25519Raw, "agent:01J").is_ok()); } #[test] fn an_unknown_format_refuses_rather_than_defaulting() { assert!( KeyFormat::parse("something-new").is_err(), "defaulting an unrecognised format to raw Ed25519 would route a \ credential of unknown shape into the bare-signature path, which \ is the whole failure this type prevents" ); // Default is still Ed25519Raw for a row written before the // column existed -- but that is a schema default applied at // write time, never a parse fallback. assert_eq!(KeyFormat::default(), KeyFormat::Ed25519Raw); } #[test] fn the_stored_strings_round_trip() { for f in [KeyFormat::Ed25519Raw, KeyFormat::Cose] { assert_eq!(KeyFormat::parse(f.as_str()).expect("round trip"), f); } } #[test] fn the_two_formats_are_distinguishable_in_storage() { assert_ne!( KeyFormat::Ed25519Raw.as_str(), KeyFormat::Cose.as_str(), "two formats sharing a stored value would make the column useless" ); } }