use super::*;
use crate::client::ClientId;
use crate::grant::GrantType;
use crate::scope::ScopeSet;
use crate::server::ClientCredential;
fn cred<'a>(
secret: Option<&'a str>,
certificate: Option<&'a ClientCertificate<'a>>,
) -> ClientCredential<'a> {
ClientCredential {
certificate,
..ClientCredential::secret(secret)
}
}
const CERT_A: &[u8] = b"\x30\x82\x01\x0a-client-a-certificate-der-bytes";
const CERT_B: &[u8] = b"\x30\x82\x01\x0a-client-b-certificate-der-bytes";
fn mtls_client(registration: MtlsClientRegistration) -> Client {
Client {
client_id: ClientId::new("mtls-client"),
auth: ClientAuth::Mtls { registration },
grant_types: vec![GrantType::ClientCredentials],
redirect_uris: Vec::new(),
allowed_scopes: ScopeSet::parse("read").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration: None,
}
}
#[test]
fn the_thumbprint_is_unpadded_base64url_of_the_sha256_of_the_der() {
let thumbprint = CertificateThumbprint::from_der(CERT_A);
let text = thumbprint.to_base64url();
assert_eq!(text.len(), 43, "{text}");
assert!(
!text.contains('='),
"RFC 8705 s3.1 is base64url WITHOUT padding: {text}"
);
assert!(
!text.contains('+') && !text.contains('/'),
"the URL-safe alphabet has no + or /: {text}"
);
let expected = {
use sha2::{Digest, Sha256};
base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
Sha256::digest(CERT_A),
)
};
assert_eq!(text, expected);
assert_eq!(
CertificateThumbprint::from_base64url(&text).unwrap(),
thumbprint
);
}
#[test]
fn the_pem_helper_hashes_the_der_and_not_the_armour() {
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, CERT_A);
let pem = format!("-----BEGIN CERTIFICATE-----\n{b64}\n-----END CERTIFICATE-----\n");
assert_eq!(
CertificateThumbprint::from_pem(&pem).unwrap(),
CertificateThumbprint::from_der(CERT_A)
);
assert_eq!(
CertificateThumbprint::from_pem("not a pem file"),
Err(MtlsRegistrationError::MalformedCertificate)
);
}
#[test]
fn a_thumbprint_that_is_not_a_sha256_is_refused() {
assert_eq!(
CertificateThumbprint::from_base64url("c2hvcnQ"),
Err(MtlsRegistrationError::MalformedThumbprint)
);
assert_eq!(
CertificateThumbprint::from_base64url("not base64url!!"),
Err(MtlsRegistrationError::MalformedThumbprint)
);
}
#[test]
fn a_registration_naming_two_subject_parameters_is_refused() {
let refused = ExpectedSubject::from_registration_parameters([
(TLS_CLIENT_AUTH_SUBJECT_DN, "CN=payments,O=Example,C=GB"),
(TLS_CLIENT_AUTH_SAN_DNS, "client.example.com"),
]);
assert_eq!(refused, Err(MtlsRegistrationError::MoreThanOneSubjectValue));
let refused = ExpectedSubject::from_registration_parameters([
(TLS_CLIENT_AUTH_SAN_DNS, "client.example.com"),
(TLS_CLIENT_AUTH_SUBJECT_DN, "CN=payments,O=Example,C=GB"),
]);
assert_eq!(refused, Err(MtlsRegistrationError::MoreThanOneSubjectValue));
let refused = ExpectedSubject::from_registration_parameters([
(TLS_CLIENT_AUTH_SAN_URI, "https://client.example.com/"),
(TLS_CLIENT_AUTH_SAN_EMAIL, "ops@example.com"),
]);
assert_eq!(refused, Err(MtlsRegistrationError::MoreThanOneSubjectValue));
}
#[test]
fn a_registration_naming_no_subject_parameter_or_an_empty_one_is_refused() {
assert_eq!(
ExpectedSubject::from_registration_parameters([("client_name", "Payments")]),
Err(MtlsRegistrationError::NoSubjectValue)
);
assert_eq!(
ExpectedSubject::from_registration_parameters([(TLS_CLIENT_AUTH_SAN_DNS, "")]),
Err(MtlsRegistrationError::EmptySubjectValue)
);
}
#[test]
fn a_registration_naming_exactly_one_subject_parameter_is_accepted() {
let expected = ExpectedSubject::from_registration_parameters([
("client_name", "Payments"),
(TLS_CLIENT_AUTH_SAN_DNS, "client.example.com"),
("grant_types", "client_credentials"),
])
.unwrap();
assert_eq!(
expected,
ExpectedSubject::SanDns("client.example.com".to_string())
);
assert_eq!(expected.parameter_name(), TLS_CLIENT_AUTH_SAN_DNS);
assert_eq!(expected.value(), "client.example.com");
}
#[test]
fn a_certificate_with_the_wrong_subject_does_not_authenticate() {
let client = mtls_client(MtlsClientRegistration::TlsClientAuth(
ExpectedSubject::SubjectDn("CN=payments,O=Example,C=GB".to_string()),
));
let attacker = ClientCertificate::from_der(CERT_B)
.with_subject_dn("CN=intern-laptop,O=Example,C=GB")
.with_san_dns(&["intern.example.com"]);
assert_eq!(
verify_certificate(&client, &cred(None, Some(&attacker))),
Err(ClientAuthFailure::CertificateMismatch),
"a certificate issued to a different subject must not authenticate this client"
);
let genuine = ClientCertificate::from_der(CERT_A).with_subject_dn("CN=payments,O=Example,C=GB");
assert_eq!(
verify_certificate(&client, &cred(None, Some(&genuine))),
Ok(())
);
}
#[test]
fn a_san_match_of_the_wrong_kind_does_not_authenticate() {
let client = mtls_client(MtlsClientRegistration::TlsClientAuth(
ExpectedSubject::SanDns("client.example.com".to_string()),
));
let attacker = ClientCertificate::from_der(CERT_B)
.with_san_dns(&["attacker.example.com"])
.with_san_uri(&["client.example.com"])
.with_san_email(&["client.example.com"]);
assert_eq!(
verify_certificate(&client, &cred(None, Some(&attacker))),
Err(ClientAuthFailure::CertificateMismatch)
);
let genuine = ClientCertificate::from_der(CERT_A)
.with_san_dns(&["other.example.com", "client.example.com"]);
assert_eq!(
verify_certificate(&client, &cred(None, Some(&genuine))),
Ok(())
);
}
#[test]
fn a_wildcard_san_matches_only_itself() {
let client = mtls_client(MtlsClientRegistration::TlsClientAuth(
ExpectedSubject::SanDns("client.example.com".to_string()),
));
let wildcard = ClientCertificate::from_der(CERT_B).with_san_dns(&["*.example.com"]);
assert_eq!(
verify_certificate(&client, &cred(None, Some(&wildcard))),
Err(ClientAuthFailure::CertificateMismatch)
);
}
#[test]
fn matching_is_case_sensitive() {
let client = mtls_client(MtlsClientRegistration::TlsClientAuth(
ExpectedSubject::SanDns("client.example.com".to_string()),
));
let shouty = ClientCertificate::from_der(CERT_A).with_san_dns(&["CLIENT.EXAMPLE.COM"]);
assert_eq!(
verify_certificate(&client, &cred(None, Some(&shouty))),
Err(ClientAuthFailure::CertificateMismatch)
);
}
#[test]
fn a_self_signed_certificate_nobody_registered_does_not_authenticate() {
let client = mtls_client(MtlsClientRegistration::SelfSignedTlsClientAuth(
RegisteredCertificates::from_der_certificates([CERT_A]).unwrap(),
));
let forged = ClientCertificate::from_der(CERT_B);
assert_eq!(
verify_certificate(&client, &cred(None, Some(&forged))),
Err(ClientAuthFailure::CertificateMismatch)
);
assert_eq!(
verify_certificate(
&client,
&cred(None, Some(&ClientCertificate::from_der(CERT_A)))
),
Ok(())
);
}
#[test]
fn every_registered_self_signed_certificate_authenticates() {
let client = mtls_client(MtlsClientRegistration::SelfSignedTlsClientAuth(
RegisteredCertificates::from_der_certificates([CERT_A, CERT_B]).unwrap(),
));
for der in [CERT_A, CERT_B] {
assert_eq!(
verify_certificate(
&client,
&cred(None, Some(&ClientCertificate::from_der(der)))
),
Ok(())
);
}
}
#[test]
fn a_registration_reads_certificates_out_of_the_clients_jwks() {
let a = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, CERT_A);
let jwks = format!(
r#"{{"keys":[{{"kty":"RSA","n":"...","e":"AQAB"}},{{"kty":"EC","x5c":["{a}","aXNzdWVy"]}}]}}"#
);
let registered = RegisteredCertificates::from_jwks(&jwks).unwrap();
assert_eq!(
registered.thumbprints(),
&[CertificateThumbprint::from_der(CERT_A)],
"only the LEAF (first x5c entry) is the client's own certificate"
);
assert_eq!(
RegisteredCertificates::from_jwks(r#"{"keys":[{"kty":"RSA"}]}"#),
Err(MtlsRegistrationError::NoCertificateInJwks)
);
assert_eq!(
RegisteredCertificates::from_jwks("{"),
Err(MtlsRegistrationError::MalformedJwks)
);
}
#[test]
fn no_secret_and_no_certificate_must_not_authenticate_a_mutual_tls_client() {
let client = mtls_client(MtlsClientRegistration::TlsClientAuth(
ExpectedSubject::SanDns("client.example.com".to_string()),
));
assert_eq!(
verify_certificate(&client, &cred(None, None)),
Err(ClientAuthFailure::NoCertificatePresented),
"naming a client id is not authenticating as it"
);
assert_eq!(
verify_certificate(&client, &cred(Some("hunter2"), None)),
Err(ClientAuthFailure::SecretMismatch)
);
let genuine = ClientCertificate::from_der(CERT_A).with_san_dns(&["client.example.com"]);
assert_eq!(
verify_certificate(&client, &cred(Some("hunter2"), Some(&genuine))),
Err(ClientAuthFailure::SecretMismatch)
);
assert!(!client.auth.verify_with(None, None));
assert!(!client.auth.verify_with(Some("hunter2"), None));
assert!(!client.auth.verify_with(Some(""), None));
assert!(client.auth.is_confidential());
}
#[test]
fn a_certificate_does_not_authenticate_a_client_registered_any_other_way() {
let certificate = ClientCertificate::from_der(CERT_A).with_san_dns(&["client.example.com"]);
let mut client = mtls_client(MtlsClientRegistration::TlsClientAuth(
ExpectedSubject::SanDns("client.example.com".to_string()),
));
for other in [
ClientAuth::Public,
ClientAuth::ConfidentialSecret {
secret: "s3cret".to_string(),
},
ClientAuth::ConfidentialSecretHash {
hash: crate::client::SecretHash::sha256("s3cret"),
},
] {
client.auth = other;
assert_eq!(
verify_certificate(&client, &cred(None, Some(&certificate))),
Err(ClientAuthFailure::SecretMismatch),
"a certificate is not this registration's credential: {:?}",
client.auth
);
}
}
#[test]
fn a_confirmation_confirms_only_the_certificate_it_was_built_from() {
let certificate = ClientCertificate::from_der(CERT_A);
let cnf = Confirmation::for_certificate(&certificate);
assert!(cnf.confirms_certificate(CERT_A));
assert!(
!cnf.confirms_certificate(CERT_B),
"a stolen bound token presented over a different connection must not confirm"
);
assert_eq!(
cnf.certificate_thumbprint(),
Some(&CertificateThumbprint::from_der(CERT_A))
);
let unbound = Confirmation::default();
assert!(unbound.is_empty());
assert!(
!unbound.confirms_certificate(CERT_A),
"an unbound token is not bound to the caller's certificate; false is the safe answer"
);
}
#[test]
fn the_confirmation_serializes_as_the_rfc_7800_object() {
let cnf = Confirmation::for_certificate(&ClientCertificate::from_der(CERT_A));
let json = serde_json::to_value(&cnf).unwrap();
assert_eq!(
json,
serde_json::json!({ "x5t#S256": CertificateThumbprint::from_der(CERT_A).to_base64url() })
);
let back: Confirmation = serde_json::from_value(json).unwrap();
assert_eq!(back, cnf);
assert_eq!(
serde_json::to_value(Confirmation::default()).unwrap(),
serde_json::json!({})
);
let both = serde_json::json!({
"x5t#S256": CertificateThumbprint::from_der(CERT_A).to_base64url(),
"jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I",
});
let parsed: Confirmation = serde_json::from_value(both).unwrap();
assert_eq!(
parsed.certificate_thumbprint(),
Some(&CertificateThumbprint::from_der(CERT_A))
);
}
#[test]
fn the_registered_method_names_are_the_rfc_8705_spellings() {
assert_eq!(TLS_CLIENT_AUTH, "tls_client_auth");
assert_eq!(SELF_SIGNED_TLS_CLIENT_AUTH, "self_signed_tls_client_auth");
assert_eq!(
MtlsClientRegistration::TlsClientAuth(ExpectedSubject::SanDns("d".into())).method_name(),
"tls_client_auth"
);
assert_eq!(
MtlsClientRegistration::SelfSignedTlsClientAuth(
RegisteredCertificates::from_der_certificates([CERT_A]).unwrap()
)
.method_name(),
"self_signed_tls_client_auth"
);
}
#[test]
fn the_thumbprint_debug_is_the_wire_form() {
let printed = format!("{:?}", CertificateThumbprint::from_der(CERT_A));
assert!(
printed.contains(&CertificateThumbprint::from_der(CERT_A).to_base64url()),
"{printed}"
);
}