use std::fmt;
use std::future::Future as _;
use std::panic::AssertUnwindSafe;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use crate::jwt::{Es256Signer, Es256Verifier, Jwk, PublicJwk, SignerError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Violation {
pub check: &'static str,
pub detail: String,
}
impl fmt::Display for Violation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.check, self.detail)
}
}
pub const CHECKS: &[&str] = &[
VERIFIER_RFC7515_A3,
VERIFIER_REJECTS_A_FOREIGN_KEY,
VERIFIER_REJECTS_A_TAMPERED_INPUT,
VERIFIER_REJECTS_A_TAMPERED_SIGNATURE,
VERIFIER_REJECTS_DER,
VERIFIER_REJECTS_A_WRONG_LENGTH_SIGNATURE,
VERIFIER_DOES_NOT_PANIC,
SIGNER_SIGNS,
SIGNER_DOES_NOT_PANIC,
SIGNER_IS_NOT_DER,
SIGNER_VERIFIES_UNDER_ITS_OWN_JWK,
SIGNER_REJECTED_BY_ANOTHER_KEY,
SIGNER_BINDS_THE_SIGNING_INPUT,
SIGNER_PUBLIC_JWK_IS_STABLE,
SIGNER_PUBLIC_JWK_IS_ES256,
SIGNER_PUBLIC_JWK_HAS_A_KID,
SIGNER_IS_NOT_THE_PUBLISHED_EXAMPLE_KEY,
];
const VERIFIER_RFC7515_A3: &str = "verifier/rfc7515_appendix_a3_vector";
const VERIFIER_REJECTS_A_FOREIGN_KEY: &str = "verifier/rejects_a_foreign_key";
const VERIFIER_REJECTS_A_TAMPERED_INPUT: &str = "verifier/rejects_a_tampered_signing_input";
const VERIFIER_REJECTS_A_TAMPERED_SIGNATURE: &str = "verifier/rejects_a_tampered_signature";
const VERIFIER_REJECTS_DER: &str = "verifier/rejects_the_der_encoding";
const VERIFIER_REJECTS_A_WRONG_LENGTH_SIGNATURE: &str = "verifier/rejects_a_wrong_length_signature";
const VERIFIER_DOES_NOT_PANIC: &str = "verifier/does_not_panic";
const SIGNER_SIGNS: &str = "signer/signs";
const SIGNER_DOES_NOT_PANIC: &str = "signer/does_not_panic";
const SIGNER_IS_NOT_DER: &str = "signer/output_is_not_der";
const SIGNER_VERIFIES_UNDER_ITS_OWN_JWK: &str = "signer/verifies_under_its_own_public_jwk";
const SIGNER_REJECTED_BY_ANOTHER_KEY: &str = "signer/does_not_verify_under_another_key";
const SIGNER_BINDS_THE_SIGNING_INPUT: &str = "signer/binds_the_signing_input";
const SIGNER_PUBLIC_JWK_IS_STABLE: &str = "signer/public_jwk_is_stable";
const SIGNER_PUBLIC_JWK_IS_ES256: &str = "signer/public_jwk_is_an_es256_p256_key";
const SIGNER_PUBLIC_JWK_HAS_A_KID: &str = "signer/public_jwk_has_a_kid";
const SIGNER_IS_NOT_THE_PUBLISHED_EXAMPLE_KEY: &str = "signer/is_not_the_published_example_key";
const A3_SIGNING_INPUT: &str = concat!(
"eyJhbGciOiJFUzI1NiJ9",
".",
"eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ"
);
const A3_X: &str = "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU";
const A3_Y: &str = "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0";
const A3_SIGNATURE: &str =
"DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQxfKTUJqPP3-Kg6NU1Q";
const LEADING_ZERO_X: &str = "WRq3ceu8_W2cuQlNEGUordGmnUTCwfYn8InsWLnGGt8";
const LEADING_ZERO_Y: &str = "n05qvw0EXAxpOjxorXyXynK-ZN70om_s0mPdmKkngPA";
const LEADING_ZERO_INPUT: &str = "oauth-as.signer-conformance.leading-zero.250";
const LEADING_ZERO_SIGNATURE: &str =
"ABYodUnuRFUgxNDUB00nlZCrb6c1obObltfhhXjcK115K8XgwahkHOzKfoLF_A_RxR9Oj31_WVjYyXl7-xEuTg";
const OTHER_X: &str = "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4";
const OTHER_Y: &str = "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM";
const OFF_CURVE_X: &str = A3_X;
const OFF_CURVE_Y: &str = "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a4";
const INPUT_A: &str = "oauth-as.signer-conformance.a";
const INPUT_B: &str = "oauth-as.signer-conformance.b";
pub struct SignerConformance<S, V> {
signer: S,
verifier: V,
}
impl<S: Es256Signer, V: Es256Verifier> SignerConformance<S, V> {
pub fn new(signer: S, verifier: V) -> Self {
SignerConformance { signer, verifier }
}
pub async fn run(&self) -> Vec<Violation> {
let mut out = Vec::new();
self.check_verifier(&mut out);
self.check_signer(&mut out).await;
out
}
fn verify(
&self,
context: &str,
key: &PublicJwk,
signing_input: &[u8],
signature: &[u8],
out: &mut Vec<Violation>,
) -> bool {
let called = std::panic::catch_unwind(AssertUnwindSafe(|| {
self.verifier.verify(key, signing_input, signature)
}));
match called {
Ok(verified) => verified,
Err(_) => {
if !out.iter().any(|v| v.check == VERIFIER_DOES_NOT_PANIC) {
out.push(Violation {
check: VERIFIER_DOES_NOT_PANIC,
detail: format!(
"the verifier PANICKED on {context} ({} signature bytes, {} \
signing-input bytes). `signature` is the third segment of a JWS an \
unauthenticated client sent, base64url-decoded, and nothing checks \
its length before you see it: `signature[..64]` and \
`Signature::from_slice(&signature[..64])` both panic on the empty \
slice a token ending in a bare '.' produces. Test the length, or use \
`signature.try_into()` into a [u8; 64]. `key` is no safer: its \
coordinates are width-checked and NOT curve-checked, so \
`VerifyingKey::from_sec1_bytes(&sec1).unwrap()` panics on the jwk of \
a DPoP proof anyone can send. Every input must return false instead",
signature.len(),
signing_input.len()
),
});
}
false
}
}
}
fn check_verifier(&self, out: &mut Vec<Violation>) {
let key = jwk(A3_X, A3_Y);
let signature = decode(A3_SIGNATURE);
if !self.verify(
"the RFC 7515 A.3 vector",
&key,
A3_SIGNING_INPUT.as_bytes(),
&signature,
out,
) {
out.push(Violation {
check: VERIFIER_RFC7515_A3,
detail: "the RFC 7515 appendix A.3 ES256 vector did not verify. Either the \
verifier is not ES256 (ECDSA/P-256/SHA-256), or it expects a signature \
encoding other than the 64-byte fixed-width R || S of RFC 7518 s3.4"
.to_string(),
});
}
if self.verify(
"the A.3 vector under a foreign key",
&jwk(OTHER_X, OTHER_Y),
A3_SIGNING_INPUT.as_bytes(),
&signature,
out,
) {
out.push(Violation {
check: VERIFIER_REJECTS_A_FOREIGN_KEY,
detail: "a valid signature verified under a DIFFERENT public key. The verifier is \
not using the key it was given, so every signature verifies under every \
key and no token is bound to any issuer"
.to_string(),
});
}
let mut tampered = A3_SIGNING_INPUT.as_bytes().to_vec();
let last = tampered.len() - 1;
tampered[last] ^= 0x01;
if self.verify("a tampered signing input", &key, &tampered, &signature, out) {
out.push(Violation {
check: VERIFIER_REJECTS_A_TAMPERED_INPUT,
detail: "a signature verified over a signing input that was not the one signed. \
The verifier is not covering the whole message, so a token's claims can \
be edited without invalidating it"
.to_string(),
});
}
let mut bad_signature = signature.clone();
bad_signature[0] ^= 0x01;
if self.verify(
"a byte-flipped signature",
&key,
A3_SIGNING_INPUT.as_bytes(),
&bad_signature,
out,
) {
out.push(Violation {
check: VERIFIER_REJECTS_A_TAMPERED_SIGNATURE,
detail: "a corrupted signature verified. The verifier is not checking the \
signature at all, which makes every unsigned token a valid one"
.to_string(),
});
}
if self.verify(
"the DER re-encoding of a valid signature",
&key,
A3_SIGNING_INPUT.as_bytes(),
&der(&signature),
out,
) {
out.push(Violation {
check: VERIFIER_REJECTS_DER,
detail: "the ASN.1 DER encoding of a valid signature verified. RFC 7518 s3.4 \
fixes ES256 as the 64-byte fixed-width R || S and admits no other \
encoding; accepting both gives one signature two forms"
.to_string(),
});
}
if self.verify(
"an OFF-CURVE key of the correct coordinate width",
&jwk(OFF_CURVE_X, OFF_CURVE_Y),
A3_SIGNING_INPUT.as_bytes(),
&signature,
out,
) {
out.push(Violation {
check: VERIFIER_REJECTS_A_FOREIGN_KEY,
detail: "a signature verified under coordinates that are NOT a point on P-256. \
PublicJwk only width-checks the coordinates, so the curve check is the \
verifier's, and it is what an invalid-curve attack needs to find missing"
.to_string(),
});
}
if self.verify("an EMPTY signing input", &key, &[], &signature, out) {
out.push(Violation {
check: VERIFIER_REJECTS_A_TAMPERED_INPUT,
detail: "a valid 64-byte signature verified over an EMPTY signing input. The \
verifier is not hashing the message it was handed, so a signature made \
over one token is good for every other"
.to_string(),
});
}
self.check_wrong_length_signatures(&key, &signature, out);
}
fn check_wrong_length_signatures(
&self,
key: &PublicJwk,
signature: &[u8],
out: &mut Vec<Violation>,
) {
let mut too_long = signature.to_vec();
too_long.push(0x00);
let padding_key = jwk(LEADING_ZERO_X, LEADING_ZERO_Y);
let leading_zero = decode(LEADING_ZERO_SIGNATURE);
let a3 = A3_SIGNING_INPUT.as_bytes();
let cases: [(&str, &PublicJwk, &[u8], &[u8]); 6] = [
("0 bytes (an empty third JWS segment)", key, a3, &[]),
("1 byte", key, a3, &signature[..1]),
("32 bytes (R alone, S missing)", key, a3, &signature[..32]),
(
"63 bytes (a valid signature, truncated by one)",
key,
a3,
&signature[..63],
),
(
"63 bytes (a valid signature with its LEADING ZERO byte removed, which a verifier \
that left-pads back up to 64 reconstructs exactly)",
&padding_key,
LEADING_ZERO_INPUT.as_bytes(),
&leading_zero[1..],
),
(
"65 bytes (a valid signature plus a trailing zero)",
key,
a3,
&too_long,
),
];
for (description, case_key, case_input, wrong) in cases {
if self.verify(
&format!("a wrong-length signature of {description}"),
case_key,
case_input,
wrong,
out,
) {
out.push(Violation {
check: VERIFIER_REJECTS_A_WRONG_LENGTH_SIGNATURE,
detail: format!(
"a signature of {description} VERIFIED. RFC 7518 s3.4 fixes the ES256 \
signature at exactly 64 bytes of fixed-width R || S, so anything else \
must be false. A verifier that pads a short input up to 64, or that \
reads a 64-byte prefix and ignores the rest, gives a valid signature \
many spellings and accepts values the signer never produced"
),
});
}
}
}
async fn sign(
&self,
context: &str,
signing_input: &[u8],
out: &mut Vec<Violation>,
) -> Result<[u8; 64], SignerError> {
let built = std::panic::catch_unwind(AssertUnwindSafe(|| {
Box::pin(self.signer.sign(signing_input))
}));
let signed = match built {
Ok(mut future) => {
std::future::poll_fn(move |cx| {
match std::panic::catch_unwind(AssertUnwindSafe(|| future.as_mut().poll(cx))) {
Ok(polled) => polled.map(Some),
Err(_) => std::task::Poll::Ready(None),
}
})
.await
}
Err(_) => None,
};
match signed {
Some(result) => result,
None => {
out.push(Violation {
check: SIGNER_DOES_NOT_PANIC,
detail: format!(
"the signer PANICKED while signing {context} ({} signing-input bytes). \
Every failure you can have (the KMS was unreachable, the key was \
disabled, the credential expired, the response was the wrong length or \
the wrong encoding) is an Err(SignerError), which this crate turns into \
an RFC 6749 s5.2 server_error. A panic instead unwinds into the host's \
token endpoint, and the deployment loses more than the one request",
signing_input.len()
),
});
Err(SignerError::new("the signer panicked"))
}
}
}
async fn check_signer(&self, out: &mut Vec<Violation>) {
let published = self.signer.public_jwk();
self.check_published_key(&published, out);
if published.kid.is_empty() {
out.push(Violation {
check: SIGNER_PUBLIC_JWK_HAS_A_KID,
detail: "public_jwk().kid is empty. Every token this server signs carries it \
(RFC 7515 s4.1.4), and key rotation selects on it"
.to_string(),
});
}
if self.signer.public_jwk() != published {
out.push(Violation {
check: SIGNER_PUBLIC_JWK_IS_STABLE,
detail: "public_jwk() returned two different keys on two calls. It must return a \
value cached at construction: JwtConfig reads it once, so a key that \
changes afterwards is advertised nowhere"
.to_string(),
});
}
if published.x == A3_X && published.y == A3_Y {
out.push(Violation {
check: SIGNER_IS_NOT_THE_PUBLISHED_EXAMPLE_KEY,
detail: "the signing key is the RFC 7515 appendix A.3 example key, whose private \
half is printed in the RFC. Anyone can forge every token this server \
issues"
.to_string(),
});
}
let signed = self.sign("the first input", INPUT_A.as_bytes(), out).await;
let signature = match signed {
Ok(signature) => signature,
Err(e) => {
out.push(Violation {
check: SIGNER_SIGNS,
detail: format!("the signer refused to sign: {e}"),
});
return;
}
};
if signature[0] == 0x30 && signature[2] == 0x02 && (0x40..=0x48).contains(&signature[1]) {
out.push(Violation {
check: SIGNER_IS_NOT_DER,
detail: "the signature looks like an ASN.1 DER SEQUENCE (it begins 0x30 with a \
consistent length byte). RFC 7518 s3.4 requires the 64-byte fixed-width \
R || S concatenation; most KMS APIs and OpenSSL return DER by default \
and it must be converted"
.to_string(),
});
}
let public = published.to_public_jwk();
if !self.verify(
"the signer's own signature under its own JWK",
&public,
INPUT_A.as_bytes(),
&signature,
out,
) {
out.push(Violation {
check: SIGNER_VERIFIES_UNDER_ITS_OWN_JWK,
detail: "the signature did not verify under the signer's OWN public_jwk(). Either \
public_jwk() is not the public half of the signing key, or the signature \
is not ES256 over the bytes it was handed. Every token this server \
issues would fail verification against its own published JWKS"
.to_string(),
});
}
if self.verify(
"the signer's own signature under a foreign key",
&jwk(OTHER_X, OTHER_Y),
INPUT_A.as_bytes(),
&signature,
out,
) {
out.push(Violation {
check: SIGNER_REJECTED_BY_ANOTHER_KEY,
detail: "the signature verified under a key that did not produce it. A signer \
that returns a constant, or a verifier that ignores its key, would both \
land here"
.to_string(),
});
}
let signed_again = self.sign("the second input", INPUT_B.as_bytes(), out).await;
match signed_again {
Ok(other) => {
let covers_its_own = self.verify(
"the second input's signature over the second input",
&public,
INPUT_B.as_bytes(),
&other,
out,
);
let one_way = self.verify(
"the second input's signature over the FIRST input",
&public,
INPUT_A.as_bytes(),
&other,
out,
);
let other_way = self.verify(
"the first input's signature over the SECOND input",
&public,
INPUT_B.as_bytes(),
&signature,
out,
);
let crosses = one_way || other_way;
if !covers_its_own || crosses {
out.push(Violation {
check: SIGNER_BINDS_THE_SIGNING_INPUT,
detail: "a signature is not bound to the input it was made over: either a \
second input's signature did not cover that input, or one \
input's signature verified over the other's. The signer is not \
signing the bytes it was given (a fixed message, a double hash, \
or a constant), so the signature says nothing about the token \
that carries it"
.to_string(),
});
}
}
Err(e) => out.push(Violation {
check: SIGNER_SIGNS,
detail: format!("the signer signed once and then refused: {e}"),
}),
}
}
fn check_published_key(&self, published: &Jwk, out: &mut Vec<Violation>) {
let mut wrong = Vec::new();
if published.kty != "EC" {
wrong.push(format!("kty is {:?}, must be \"EC\"", published.kty));
}
if published.crv != "P-256" {
wrong.push(format!("crv is {:?}, must be \"P-256\"", published.crv));
}
if published.alg != "ES256" {
wrong.push(format!("alg is {:?}, must be \"ES256\"", published.alg));
}
if published.use_ != "sig" {
wrong.push(format!("use is {:?}, must be \"sig\"", published.use_));
}
for (name, value) in [("x", &published.x), ("y", &published.y)] {
match URL_SAFE_NO_PAD.decode(value) {
Ok(bytes) if bytes.len() == 32 => {}
Ok(bytes) => wrong.push(format!(
"{name} decodes to {} bytes, must be exactly 32 with leading zeros kept \
(RFC 7518 s6.2.1.2)",
bytes.len()
)),
Err(_) => wrong.push(format!("{name} is not unpadded base64url")),
}
}
if !wrong.is_empty() {
out.push(Violation {
check: SIGNER_PUBLIC_JWK_IS_ES256,
detail: format!(
"public_jwk() is not a usable ES256 JWK: {}",
wrong.join("; ")
),
});
}
}
}
fn jwk(x: &str, y: &str) -> PublicJwk {
PublicJwk::from_coordinates(x, y).expect("the harness's own fixed vectors are well formed")
}
fn decode(b64: &str) -> Vec<u8> {
URL_SAFE_NO_PAD
.decode(b64)
.expect("the harness's own fixed vectors are base64url")
}
fn der(fixed_width: &[u8]) -> Vec<u8> {
fn integer(value: &[u8], out: &mut Vec<u8>) {
let start = value
.iter()
.position(|b| *b != 0)
.unwrap_or(value.len() - 1);
let body = &value[start..];
let pad = usize::from(body[0] & 0x80 != 0);
out.push(0x02);
out.push((body.len() + pad) as u8);
if pad == 1 {
out.push(0x00);
}
out.extend_from_slice(body);
}
let mut body = Vec::with_capacity(72);
integer(&fixed_width[..32], &mut body);
integer(&fixed_width[32..], &mut body);
let mut out = Vec::with_capacity(body.len() + 2);
out.push(0x30);
out.push(body.len() as u8);
out.extend_from_slice(&body);
out
}
#[cfg(test)]
mod tests {
use super::der;
#[test]
fn der_encodes_an_all_zero_integer_as_a_single_zero_octet() {
assert_eq!(
der(&[0u8; 64]),
vec![0x30, 0x06, 0x02, 0x01, 0x00, 0x02, 0x01, 0x00],
);
}
}