161 lines
6.0 KiB
Rust
161 lines
6.0 KiB
Rust
//! 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<Self, CryptoError> {
|
|
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"
|
|
);
|
|
}
|
|
}
|