Files
Trusty/crates/core/trusty-core/src/credential.rs

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