#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use polyjose::Error;
use polyjose::json::{self, Value};
use polyjose::jwk::{EcCurve, Jwk, KeyMaterial, KeyType, OkpCurve};
use polyjose::keys::{KeyFormat, KeySpec, generate};
use proptest::prelude::*;
const FORMATS: [KeyFormat; 4] = [
KeyFormat::Pkcs8,
KeyFormat::Sec1,
KeyFormat::Spki,
KeyFormat::Pkcs1,
];
fn data_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/keys")
}
fn read(dir: &str, file: &str) -> Vec<u8> {
let path = data_dir().join(dir).join(file);
std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}
fn files(dir: &str) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(data_dir().join(dir))
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.filter(|n| n.ends_with(".pem") || n.ends_with(".der"))
.collect();
names.sort();
names
}
fn named_format(name: &str) -> Option<KeyFormat> {
[
("-pkcs8", KeyFormat::Pkcs8),
("-sec1", KeyFormat::Sec1),
("-spki", KeyFormat::Spki),
("-pkcs1", KeyFormat::Pkcs1),
]
.into_iter()
.find(|(token, _)| name.contains(token))
.map(|(_, format)| format)
}
fn import(bytes: &[u8], name: &str, format: Option<KeyFormat>) -> Result<Jwk, Error> {
if name.ends_with(".pem") {
Jwk::from_pem(std::str::from_utf8(bytes).unwrap())
} else {
Jwk::from_der(bytes, format.unwrap_or_else(|| panic!("{name}: no format")))
}
}
fn code(result: &Result<Jwk, Error>) -> &'static str {
match result {
Ok(_) => "ok",
Err(e) => e.code(),
}
}
struct Row {
id: String,
file: String,
base: String,
expect: String,
jwk: Option<String>,
}
fn manifest() -> Vec<Row> {
let manifest = json::parse(&read("negative", "manifest.json")).unwrap();
let field = |row: &Value, name: &str| row.get(name).and_then(Value::as_str).map(str::to_owned);
manifest
.as_array()
.unwrap()
.iter()
.map(|row| Row {
id: field(row, "id").unwrap(),
file: field(row, "file").unwrap(),
base: field(row, "base").unwrap(),
expect: field(row, "expect").unwrap(),
jwk: field(row, "jwk"),
})
.collect()
}
fn row_format(row: &Row) -> KeyFormat {
named_format(&row.file)
.or_else(|| named_format(&row.base))
.unwrap_or_else(|| panic!("{}: no format", row.id))
}
#[test]
fn every_manifest_row_gives_its_expected_result() {
let rows = manifest();
assert_eq!(rows.len(), 71, "68 must-reject and 3 must-accept vectors");
let mut failures = Vec::new();
for row in &rows {
let bytes = read("negative", &row.file);
let result = import(&bytes, &row.file, Some(row_format(row)));
if code(&result) != row.expect {
failures.push(format!(
"{} {}: expected {}, got {:?}",
row.id,
row.file,
row.expect,
result.as_ref().err()
));
continue;
}
if let (Ok(jwk), Some(want)) = (&result, &row.jwk) {
let want = Jwk::from_json(&read("rfc", want)).unwrap();
assert_eq!(jwk.to_object(), want.to_object(), "{}", row.id);
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn pem_armor_around_the_wrong_or_trailing_der_is_rejected() {
let p14 = String::from_utf8(read("negative", "P14-label-public-key-body-pkcs8.pem")).unwrap();
assert!(matches!(Jwk::from_pem(&p14), Err(Error::InvalidKey(_))));
let p16 = String::from_utf8(read("negative", "P16-trailing-der-garbage.pem")).unwrap();
assert!(matches!(Jwk::from_pem(&p16), Err(Error::InvalidKey(_))));
}
type Rule = dyn Fn(&str) -> bool;
fn is_valid_rsa_private(file: &str) -> bool {
let openssl = ["reject-rsa2048-", "reject-rsa3072-", "reject-rsa4096-"]
.iter()
.any(|p| file.starts_with(p))
&& file.contains("-private-")
&& !file.contains("-3prime-");
let rfc9500 = file.starts_with("reject-rfc9500-testRSA2048-")
|| file.starts_with("reject-rfc9500-testRSA4096-");
openssl || rfc9500
}
fn readme_expect(file: &str) -> &'static str {
let rules: [(&Rule, &str); 10] = [
(&|f| f.contains("-3prime-"), "UnsupportedAlgorithm"),
(&|f| f.contains("-encrypted"), "UnsupportedAlgorithm"),
(
&|f| f.starts_with("reject-ed448-") || f.starts_with("reject-x448-"),
"UnsupportedAlgorithm",
),
(
&|f| f.starts_with("reject-ec-brainpoolp256r1-"),
"UnsupportedAlgorithm",
),
(
&|f| f.starts_with("reject-ec-p256-explicit-"),
"UnsupportedAlgorithm",
),
(
&|f| f.starts_with("reject-rsapss2048-"),
"UnsupportedAlgorithm",
),
(
&|f| f.starts_with("reject-rsa1024-") || f.contains("testRSA1024"),
"InvalidKey",
),
(&|f| f == "reject-rfc8410-7-six-dash-end.pem", "InvalidKey"),
(&|f| f.contains("-cert"), "UnsupportedAlgorithm"),
(&|f| is_valid_rsa_private(f), "ok"),
];
rules
.iter()
.find(|(matches, _)| matches(file))
.map(|(_, code)| *code)
.unwrap_or_else(|| panic!("{file}: no row in the fixtures README table"))
}
#[test]
fn every_reject_fixture_gives_its_readme_code() {
let mut checked = 0;
let mut failures = Vec::new();
for dir in ["openssl", "rfc"] {
for file in files(dir).into_iter().filter(|f| f.starts_with("reject-")) {
let result = import(&read(dir, &file), &file, named_format(&file));
let want = readme_expect(&file);
if code(&result) != want {
failures.push(format!(
"{dir}/{file}: expected {want}, got {}",
code(&result)
));
} else if let Ok(jwk) = &result {
let KeyMaterial::Rsa(rsa) = jwk.material() else {
panic!("{file}: not RSA")
};
assert!(rsa.is_private() && !jwk.is_public(), "{file}");
if dir == "openssl" {
let bits = &file["reject-rsa".len().."reject-rsa".len() + 4];
let public = pem_key("openssl", &format!("rsa{bits}-spki.pem"));
assert_eq!(
jwk.to_public().unwrap().to_object(),
public.to_object(),
"{file}"
);
}
}
checked += 1;
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
assert_eq!(checked, 33);
}
#[test]
fn encrypted_keys_are_unsupported_with_a_static_error() {
let pem = [
"reject-ec-p256-pkcs8-encrypted.pem",
"reject-ed25519-pkcs8-encrypted.pem",
"reject-rsa2048-pkcs8-encrypted.pem",
"reject-ec-p256-sec1-legacy-encrypted.pem",
"reject-rsa2048-pkcs1-legacy-encrypted.pem",
];
for file in pem {
let text = String::from_utf8(read("openssl", file)).unwrap();
let err = Jwk::from_pem(&text).unwrap_err();
assert_eq!(err, Error::UnsupportedAlgorithm, "{file}");
assert_eq!(err.to_string(), "unsupported algorithm", "{file}");
}
let der = read("openssl", "reject-ec-p256-pkcs8-encrypted.der");
assert_eq!(
Jwk::from_der(&der, KeyFormat::Pkcs8).unwrap_err(),
Error::UnsupportedAlgorithm
);
}
#[test]
fn der_in_another_format_than_named_is_never_imported() {
for dir in ["openssl", "rfc"] {
for file in files(dir)
.into_iter()
.filter(|f| f.ends_with(".der") && !f.starts_with("reject-"))
{
let der = read(dir, &file);
let own = named_format(&file).unwrap();
for format in FORMATS.into_iter().filter(|f| *f != own) {
if own == KeyFormat::Spki && format == KeyFormat::Pkcs8 {
continue;
}
let result = Jwk::from_der(&der, format);
assert!(
matches!(result, Err(Error::InvalidKey(_))),
"{dir}/{file} as {format:?}: {:?}",
result.err()
);
}
}
}
}
#[test]
fn spki_as_pkcs8_is_invalid_key() {
let mut checked = 0;
for dir in ["openssl", "rfc"] {
for file in files(dir)
.into_iter()
.filter(|f| f.ends_with(".der") && !f.starts_with("reject-"))
.filter(|f| named_format(f) == Some(KeyFormat::Spki))
{
let result = Jwk::from_der(&read(dir, &file), KeyFormat::Pkcs8);
assert!(
matches!(result, Err(Error::InvalidKey(_))),
"{dir}/{file}: {:?}",
result.err()
);
checked += 1;
}
}
assert!(checked >= 10, "{checked} SPKI fixtures");
}
fn pem_key(dir: &str, file: &str) -> Jwk {
Jwk::from_pem(std::str::from_utf8(&read(dir, file)).unwrap()).unwrap()
}
fn assert_export(jwk: &Jwk, format: KeyFormat, want: &Error, what: &str) {
assert_eq!(
jwk.to_der(format).unwrap_err(),
*want,
"{what} to_der {format:?}"
);
assert_eq!(
jwk.to_pem(format).unwrap_err(),
*want,
"{what} to_pem {format:?}"
);
}
#[test]
fn oct_keys_never_export() {
for bytes in [16, 32, 64, 512] {
let oct = generate(KeySpec::Oct { bytes }).unwrap();
for format in FORMATS {
assert_export(&oct, format, &Error::UnsupportedAlgorithm, "oct");
}
}
}
#[test]
fn public_keys_do_not_export_to_private_formats() {
let required = Error::InvalidKey("private key required");
let ec = pem_key("openssl", "ec-p256-spki.pem");
let okp = pem_key("openssl", "ed25519-spki.pem");
let rsa = pem_key("openssl", "rsa2048-spki.pem");
assert_export(&ec, KeyFormat::Pkcs8, &required, "EC public");
assert_export(&ec, KeyFormat::Sec1, &required, "EC public");
assert_export(&okp, KeyFormat::Pkcs8, &required, "OKP public");
assert_export(&rsa, KeyFormat::Pkcs8, &required, "RSA");
let public = pem_key("openssl", "ec-p384-pkcs8.pem").to_public().unwrap();
assert_export(&public, KeyFormat::Sec1, &required, "to_public");
}
#[test]
fn rsa_private_keys_export_byte_for_byte() {
let pkcs8_der = read("openssl", "reject-rsa2048-private-pkcs8.der");
let key = Jwk::from_der(&pkcs8_der, KeyFormat::Pkcs8).unwrap();
let from_pkcs1 = Jwk::from_der(
&read("openssl", "reject-rsa2048-private-pkcs1.der"),
KeyFormat::Pkcs1,
)
.unwrap();
assert_eq!(key.to_object(), from_pkcs1.to_object());
assert_eq!(
key.to_der(KeyFormat::Pkcs8).unwrap().as_slice(),
pkcs8_der.as_slice()
);
assert_eq!(
key.to_pem(KeyFormat::Pkcs8).unwrap().as_bytes(),
read("openssl", "reject-rsa2048-private-pkcs8.pem").as_slice()
);
assert!(matches!(
key.to_der(KeyFormat::Pkcs1),
Err(Error::InvalidKey(_))
));
assert_eq!(
key.to_public()
.unwrap()
.to_der(KeyFormat::Pkcs1)
.unwrap()
.as_slice(),
read("openssl", "rsa2048-pkcs1-pub.der").as_slice()
);
assert_eq!(
key.to_der(KeyFormat::Spki).unwrap().as_slice(),
read("openssl", "rsa2048-spki.der").as_slice()
);
assert_export(
&key,
KeyFormat::Sec1,
&Error::UnsupportedAlgorithm,
"RSA private",
);
for bits in [3072, 4096] {
let file = format!("reject-rsa{bits}-private-pkcs8.pem");
let key = pem_key("openssl", &file);
assert_eq!(
key.to_pem(KeyFormat::Pkcs8).unwrap().as_bytes(),
read("openssl", &file).as_slice(),
"{bits}"
);
assert_eq!(
key.to_pem(KeyFormat::Spki).unwrap().as_bytes(),
read("openssl", &format!("rsa{bits}-spki.pem")).as_slice(),
"{bits}"
);
}
for name in ["testRSA2048", "testRSA4096"] {
let key = pem_key("rfc", &format!("reject-rfc9500-{name}-private-pkcs1.pem"));
let again =
Jwk::from_der(&key.to_der(KeyFormat::Pkcs8).unwrap(), KeyFormat::Pkcs8).unwrap();
assert_eq!(again.to_object(), key.to_object(), "{name}");
}
}
#[test]
fn rsa_private_der_in_another_format_or_label_is_invalid_key() {
for (file, own) in [
("reject-rsa2048-private-pkcs1.der", KeyFormat::Pkcs1),
("reject-rsa2048-private-pkcs8.der", KeyFormat::Pkcs8),
] {
let der = read("openssl", file);
for format in FORMATS.into_iter().filter(|f| *f != own) {
let result = Jwk::from_der(&der, format);
assert!(
matches!(result, Err(Error::InvalidKey(_))),
"{file} as {format:?}: {:?}",
result.err()
);
}
}
let private = String::from_utf8(read("openssl", "reject-rsa2048-private-pkcs1.pem")).unwrap();
let public = String::from_utf8(read("openssl", "rsa2048-pkcs1-pub.pem")).unwrap();
for (what, text) in [
(
"private body",
private.replace("RSA PRIVATE KEY", "RSA PUBLIC KEY"),
),
(
"public body",
public.replace("RSA PUBLIC KEY", "RSA PRIVATE KEY"),
),
] {
let result = Jwk::from_pem(&text);
assert!(
matches!(result, Err(Error::InvalidKey(_))),
"{what}: {:?}",
result.err()
);
}
}
#[test]
fn key_types_a_format_cannot_hold_are_unsupported() {
let unsupported = Error::UnsupportedAlgorithm;
let keys = [
("Ed25519 private", pem_key("openssl", "ed25519-pkcs8.pem")),
("X25519 public", pem_key("openssl", "x25519-spki.pem")),
("RSA", pem_key("openssl", "rsa2048-pkcs1-pub.pem")),
];
for (what, key) in &keys {
assert_export(key, KeyFormat::Sec1, &unsupported, what);
}
for (what, key) in [
("EC private", pem_key("openssl", "ec-p521-sec1.pem")),
("EC public", pem_key("openssl", "ec-secp256k1-spki.pem")),
("Ed25519 private", pem_key("openssl", "ed25519-pkcs8.pem")),
("X25519 public", pem_key("openssl", "x25519-spki.pem")),
] {
assert_export(&key, KeyFormat::Pkcs1, &unsupported, what);
}
}
fn every_import(input: &[u8]) -> Vec<Jwk> {
let mut keys = Vec::new();
if let Ok(text) = std::str::from_utf8(input) {
keys.extend(Jwk::from_pem(text));
}
for format in FORMATS {
keys.extend(Jwk::from_der(input, format));
}
keys
}
fn every_fixture_input() -> Vec<(String, Vec<u8>)> {
let mut inputs = Vec::new();
for dir in ["openssl", "rfc", "negative"] {
for file in files(dir) {
let bytes = read(dir, &file);
if file.ends_with(".pem")
&& let Ok((_, der)) = pem_rfc7468::decode_vec(&bytes)
{
inputs.push((format!("{dir}/{file} (body)"), der));
}
inputs.push((format!("{dir}/{file}"), bytes));
}
}
inputs
}
#[test]
fn no_fixture_or_negative_vector_ever_yields_an_oct_jwk() {
let inputs = every_fixture_input();
assert!(inputs.len() > 200, "{} inputs", inputs.len());
let mut successes = 0;
for (name, input) in &inputs {
for jwk in every_import(input) {
assert_ne!(jwk.key_type(), KeyType::Oct, "{name}");
assert_ne!(
jwk.to_object().get("kty").and_then(Value::as_str),
Some("oct"),
"{name}"
);
successes += 1;
}
}
assert!(successes > 100, "{successes} successful imports");
}
fn some_der() -> Vec<Vec<u8>> {
["openssl", "rfc"]
.iter()
.flat_map(|dir| {
files(dir)
.into_iter()
.filter(|f| f.ends_with(".der") && !f.starts_with("reject-"))
.map(|f| read(dir, &f))
})
.collect()
}
proptest! {
#![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
#[test]
fn random_input_never_yields_an_oct_jwk(input in proptest::collection::vec(any::<u8>(), 0..512)) {
for jwk in every_import(&input) {
prop_assert_ne!(jwk.key_type(), KeyType::Oct);
}
}
#[test]
fn mutated_fixtures_never_yield_an_oct_jwk(
which in any::<prop::sample::Index>(),
at in any::<prop::sample::Index>(),
xor in 1u8..=255,
cut in any::<bool>(),
) {
let all = some_der();
let mut der = which.get(&all).clone();
let i = at.index(der.len());
if cut { der.truncate(i); } else { der[i] ^= xor; }
for jwk in every_import(&der) {
prop_assert_ne!(jwk.key_type(), KeyType::Oct);
}
}
}
fn leaks(message: &str, input: &[u8]) -> Option<String> {
for window in input.windows(6) {
if window.iter().all(|b| *b == window[0]) {
continue; }
let hex: String = window.iter().map(|b| format!("{b:02x}")).collect();
let decimal = format!("{window:?}");
let decimal = &decimal[1..decimal.len() - 1];
let b64 = std_b64(window);
let b64url = b64.replace('+', "-").replace('/', "_");
for needle in [
hex.clone(),
hex.to_uppercase(),
decimal.to_owned(),
b64,
b64url,
] {
if message.contains(&needle) {
return Some(needle);
}
}
}
if let Ok(text) = std::str::from_utf8(input) {
for line in text
.lines()
.filter(|l| l.len() >= 8 && !l.starts_with("-----"))
{
for window in line.as_bytes().windows(8) {
let needle = std::str::from_utf8(window).unwrap();
if message.contains(needle) {
return Some(needle.to_owned());
}
}
}
}
None
}
fn std_b64(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
bytes
.chunks(3)
.flat_map(|c| {
let n = u32::from(c[0]) << 16 | u32::from(c[1]) << 8 | u32::from(c[2]);
(0..4)
.rev()
.map(move |i| ALPHABET[(n >> (6 * i) & 63) as usize] as char)
})
.collect()
}
#[test]
fn the_leak_detector_finds_every_rendering() {
let input = [0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05];
for message in [
"invalid key: 302e02010030",
"invalid key: 302E02010030",
"InvalidKey([48, 46, 2, 1, 0, 48])",
"x MC4CAQAw y",
"the last window 2e0201003005",
] {
assert!(leaks(message, &input).is_some(), "{message}");
}
let url = [0xfb, 0xff, 0xbf, 0x01, 0x02, 0x03];
assert!(leaks("+/+/AQID", &url).is_some());
assert!(leaks("-_-_AQID", &url).is_some());
let pem = b"-----BEGIN X-----\nMCowBQYDK2VwAyEAGb9ECWmEzf6FQbrB\n-----END X-----\n";
assert!(leaks("in MCowBQYD body", pem).is_some());
assert_eq!(leaks("invalid key: DER input too large", &input), None);
}
fn assert_no_leak(err: &Error, input: &[u8], what: &str) {
for message in [format!("{err:?}"), format!("{err:#?}"), err.to_string()] {
assert!(
message.len() < 160,
"{what}: suspiciously long message {message:?}"
);
if let Some(needle) = leaks(&message, input) {
panic!("{what}: {message:?} contains input bytes ({needle})");
}
}
}
#[test]
fn no_error_over_the_negative_suite_contains_input_bytes() {
let mut errors = 0;
for (name, input) in every_fixture_input() {
if let Ok(text) = std::str::from_utf8(&input)
&& let Err(e) = Jwk::from_pem(text)
{
assert_no_leak(&e, &input, &name);
errors += 1;
}
for format in FORMATS {
if let Err(e) = Jwk::from_der(&input, format) {
assert_no_leak(&e, &input, &format!("{name} as {format:?}"));
errors += 1;
}
}
}
assert!(errors > 500, "{errors} errors checked");
}
const MARKER: u8 = 0xA5;
fn overwrite(mut der: Vec<u8>, from: usize, len: usize) -> Vec<u8> {
der[from..from + len].fill(MARKER);
der
}
fn find(haystack: &[u8], needle: &[u8]) -> usize {
haystack
.windows(needle.len())
.position(|w| w == needle)
.unwrap()
}
#[test]
fn marker_key_octets_never_reach_an_error() {
let spki = read("rfc", "rfc7517-a1-ec-p256-spki.der");
let sec1 = read("rfc", "rfc7517-a2-ec-p256-sec1.der");
let pkcs8 = read("rfc", "rfc8037-a1-ed25519-pkcs8.der");
let rsa = read("rfc", "rfc7517-a1-rsa-spki.der");
let n = find(&rsa, &[0x02, 0x82, 0x01, 0x01]) + 4;
let cases = [
(
KeyFormat::Spki,
overwrite(spki.clone(), spki.len() - 64, 64),
),
(KeyFormat::Sec1, overwrite(sec1, 7, 32)),
(KeyFormat::Pkcs8, {
let mut der = overwrite(pkcs8.clone(), pkcs8.len() - 32, 32);
der.pop();
der
}),
(KeyFormat::Spki, overwrite(rsa, n, 257)),
];
for (format, der) in cases {
let err = Jwk::from_der(&der, format).unwrap_err();
let pem = pem_rfc7468::encode_string(
match format {
KeyFormat::Sec1 => "EC PRIVATE KEY",
KeyFormat::Pkcs8 => "PRIVATE KEY",
_ => "PUBLIC KEY",
},
pem_rfc7468::LineEnding::LF,
&der,
)
.unwrap();
let pem_err = Jwk::from_pem(&pem).unwrap_err();
for (err, input) in [(&err, der.as_slice()), (&pem_err, pem.as_bytes())] {
for message in [format!("{err:?}"), err.to_string()] {
for needle in ["165", "a5a5", "A5A5", "paWl", "pQ"] {
assert!(!message.contains(needle), "{message:?} has {needle}");
}
}
assert_no_leak(err, input, &format!("{format:?} marker"));
}
}
}
#[test]
fn generated_private_keys_never_reach_an_error_on_export() {
for spec in [KeySpec::Ec(EcCurve::P256), KeySpec::Okp(OkpCurve::X25519)] {
let key = generate(spec).unwrap();
let err = key.to_der(KeyFormat::Pkcs1).unwrap_err();
let der = key.to_der(KeyFormat::Pkcs8).unwrap();
assert_no_leak(&err, &der, "export");
}
}