crates/gunnar-core/tests/crypto_kat.rs
//! **Known answers pinned on the pre-bump crypto stack** (2026-10-03, finish-bump).
//!
//! Every expected value and every fixture under `tests/fixtures/kat/` was produced by
//! THIS crate's own code on ssh-key 0.6 / rand_core 0.6 / sha2 0.10 / rcgen 0.13 /
//! x509-parser 0.16 — the stack the estates run today — before the RustCrypto bump
//! (ssh-key 0.7, rand_core 0.10, sha2 0.11, rcgen 0.14, x509-parser 0.18). A box that
//! upgrades keeps what it has stored: its SSH host key (secret store, OpenSSH
//! encoding), its signed logs (sshsig under each log's namespace) and its issuer (CA
//! certificate + PEM key). So each test demands the SAME answer as before the bump:
//!
//! * the host key: the OpenSSH bytes for a given key are the same bytes (so what the
//! old code stored is what the new code reads and writes), they load to the same
//! public line and fingerprint, re-encode identically, and `ensure_host_key` keeps a
//! stored key instead of minting one (a new one = every client's "REMOTE HOST
//! IDENTIFICATION HAS CHANGED");
//! * Ed25519 sshsig is deterministic: the same key over the same bytes gives the same
//! armoured signature, byte for byte, and the stored one verifies;
//! * the SHA-256 values (Merkle root, token hash) are the same hex;
//! * the issuer certificate rcgen 0.13 wrote imports with its key, the leaf it issued
//! still parses to the same notAfter and verifies, and a leaf issued from the old
//! CSR after the bump chains to it (issuer name, AKI = the CA's SKI, signature).
//!
//! No private key is committed (`xtask key-census`): the two keys are minted in
//! `kat_support` from RFC test vectors. The fixtures are certificates, a CSR and
//! signatures — public material.
mod kat_support;
use std::sync::Arc;
use gunnar_core::issuer::{
csr_claimed_common_name, issued_by, not_after_ms, pem_to_der, public_key_info_of_cert,
public_key_info_of_key,
};
use gunnar_core::ssh_key::LineEnding;
use gunnar_core::transparency::{self, TreeHead};
use gunnar_core::{ensure_host_key, memory_control_store, mint_ed25519, HostKey, Pki};
use gunnar_store::mem::MemSecretStore;
use gunnar_store::{SecretStore, SSH_HOST_KEY};
/// `SHA256:` (base64) of the OpenSSH private-key encoding of the RFC 8032 TEST 1
/// key — pins the stored bytes without storing them.
const HOST_KEY_ENCODING_SHA256: &str = "SHA256:XMmgeFxuOOPkVX36jCA0uAgw9x7HmpCizqHBw1V/DXY";
const HOST_PUBLIC: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINdamAGCsQq31Uv+08lkBzoO4XLz2qYjJa8CGmj3B1Ea gunnar-kat";
const HOST_FINGERPRINT: &str = "SHA256:bbXpuKG6zhzdmnxq256TlqzFBzRl2f6OOg722cYNbU8";
const SSHSIG_NAMESPACE: &str = "gunnar-kat";
const SSHSIG_MESSAGE: &[u8] = b"finish-bump 2026-10-03 known answer";
const SSHSIG_PEM: &str = include_str!("fixtures/kat/sshsig_gunnar_kat.pem");
const WEBHOOK_BODY: &[u8] = b"{\"kat\":true}";
const WEBHOOK_PEM: &str = include_str!("fixtures/kat/webhook_delivery.pem");
const STH_PEM: &str = include_str!("fixtures/kat/sth.pem");
const MERKLE_ROOT_ABC: &str = "36642e73c2540ab121e3a6bf9545b0a24982cd830eb13d3cd19de3ce6c021ec1";
const TOKEN_HASH: &str = "6b3ba53d487ef333b0dc26101a09a9d420b4818c4417db97196101f30ad231f5";
const CA_PEM: &str = include_str!("fixtures/kat/issuer_ca.pem");
const CA_FINGERPRINT: &str = "SHA256:f5JTauIYF5T5/n+2p6QgWQPmgBDOtKNpWhaAwCJzuFU";
const CA_SPKI_HEX: &str = "3059301306072a8648ce3d020106082a8648ce3d0301070342000460fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb67903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299";
const CLIENT_CSR_PEM: &str = include_str!("fixtures/kat/client.csr.pem");
const LEAF_PEM: &str = include_str!("fixtures/kat/client_leaf.pem");
const LEAF_FINGERPRINT: &str = "SHA256:1QHygBkQKwG8u+LPV9CC2JJ/CEF0bym83uffhLJTmnQ";
const LEAF_NOT_AFTER_MS: i64 = 2_106_409_750_000;
fn host_key() -> HostKey {
HostKey::from_openssh(kat_support::ed25519_openssh()).expect("the host key loads")
}
/// The issuer key as the old code stored it: PEM, as rcgen serialises it.
fn ca_key_pem() -> String {
rcgen::KeyPair::try_from(kat_support::p256_pkcs8_der().as_slice())
.expect("the RFC 6979 P-256 key")
.serialize_pem()
}
#[test]
fn the_host_key_encoding_and_identity_are_unchanged() {
let bytes = kat_support::ed25519_openssh();
assert_eq!(
gunnar_core::auth::fingerprint_of_blob(&bytes).as_str(),
HOST_KEY_ENCODING_SHA256,
"the OpenSSH private-key bytes for the same key changed"
);
let key = host_key();
assert_eq!(
key.public_openssh().expect("public line").trim(),
HOST_PUBLIC
);
assert_eq!(key.fingerprint().as_str(), HOST_FINGERPRINT);
assert_eq!(
gunnar_core::sshsig::fingerprint(key.private_key().public_key()),
HOST_FINGERPRINT
);
// Parsing and re-encoding gives the stored bytes back (checkint, comment and
// padding survive), so nothing a restart writes can drift.
let reencoded = key
.private_key()
.to_openssh(LineEnding::LF)
.expect("re-encode");
assert_eq!(reencoded.as_bytes(), bytes.as_slice());
// And a key minted the way the appliance mints one (random checkint) does too.
let minted = mint_ed25519().expect("mint");
let reloaded = HostKey::from_openssh(minted.clone()).expect("load the minted key");
let again = reloaded
.private_key()
.to_openssh(LineEnding::LF)
.expect("re-encode");
assert_eq!(again.as_bytes(), minted.as_slice());
}
#[test]
fn ensure_host_key_keeps_a_stored_key() {
let bytes = kat_support::ed25519_openssh();
let secrets = MemSecretStore::new();
secrets
.put(SSH_HOST_KEY, &bytes)
.expect("seed the store as a running box has it");
let loaded = ensure_host_key(&secrets).expect("load");
assert_eq!(
loaded.private_openssh(),
bytes.as_slice(),
"a stored host key was replaced"
);
assert_eq!(loaded.fingerprint().as_str(), HOST_FINGERPRINT);
}
#[test]
fn sshsig_over_fixed_bytes_is_byte_identical_and_the_stored_one_verifies() {
let key = host_key();
let (fingerprint, pem) =
gunnar_core::sshsig::sign(key.private_key(), SSHSIG_NAMESPACE, SSHSIG_MESSAGE)
.expect("sign");
assert_eq!(fingerprint, HOST_FINGERPRINT);
assert_eq!(
pem, SSHSIG_PEM,
"Ed25519 sshsig is deterministic; the bytes moved"
);
let public = key.private_key().public_key();
gunnar_core::sshsig::verify(
public,
SSHSIG_NAMESPACE,
SSHSIG_MESSAGE,
HOST_FINGERPRINT,
SSHSIG_PEM,
)
.expect("a signature made before the bump verifies after it");
// and the refusals still refuse
assert!(gunnar_core::sshsig::verify(
public,
"another-log",
SSHSIG_MESSAGE,
HOST_FINGERPRINT,
SSHSIG_PEM
)
.is_err());
assert!(gunnar_core::sshsig::verify(
public,
SSHSIG_NAMESPACE,
b"tampered",
HOST_FINGERPRINT,
SSHSIG_PEM
)
.is_err());
}
#[test]
fn webhook_and_tree_head_signatures_are_byte_identical() {
let key = host_key();
let delivery =
gunnar_core::sign_delivery(key.private_key(), WEBHOOK_BODY).expect("sign delivery");
assert_eq!(delivery.key_fingerprint, HOST_FINGERPRINT);
assert_eq!(delivery.signature_pem, WEBHOOK_PEM);
gunnar_core::verify_delivery(
key.private_key().public_key(),
WEBHOOK_BODY,
&delivery.signature_header(),
HOST_FINGERPRINT,
)
.expect("verify delivery");
let head = TreeHead {
size: 3,
root: "00".repeat(32),
at_unix_ms: 1_790_000_000_000,
};
let sth = transparency::sign_tree_head(key.private_key(), head).expect("sign tree head");
assert_eq!(sth.signature, STH_PEM);
transparency::verify_tree_head(&sth, key.private_key().public_key()).expect("verify tree head");
}
#[test]
fn sha256_values_are_the_same_hex() {
let leaves: Vec<_> = [b"a".as_slice(), b"b", b"c"]
.iter()
.map(|r| transparency::leaf_hash(r))
.collect();
assert_eq!(
transparency::hex(&transparency::root(&leaves)),
MERKLE_ROOT_ABC
);
assert_eq!(
gunnar_core::auth::token_hash("gunnar-kat-token"),
TOKEN_HASH
);
}
/// The CA certificate's SubjectKeyIdentifier and a leaf's AuthorityKeyIdentifier.
fn key_ids(der: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>) {
use x509_parser::extensions::ParsedExtension;
let (_, cert) = x509_parser::parse_x509_certificate(der).expect("parse");
let (mut ski, mut aki) = (None, None);
for ext in cert.extensions() {
match ext.parsed_extension() {
ParsedExtension::SubjectKeyIdentifier(id) => ski = Some(id.0.to_vec()),
ParsedExtension::AuthorityKeyIdentifier(a) => {
aki = a.key_identifier.as_ref().map(|id| id.0.to_vec())
}
_ => {}
}
}
(ski, aki)
}
fn verifies_under(leaf_der: &[u8], ca_der: &[u8]) -> bool {
let (_, leaf) = x509_parser::parse_x509_certificate(leaf_der).expect("parse leaf");
let (_, ca) = x509_parser::parse_x509_certificate(ca_der).expect("parse ca");
leaf.verify_signature(Some(ca.public_key())).is_ok()
}
#[test]
fn an_issuer_written_by_the_old_rcgen_still_loads_and_issues() {
let ca_key = ca_key_pem();
// What the old code stored, read back field by field.
assert_eq!(
hex::encode(public_key_info_of_cert(CA_PEM).expect("ca spki")),
CA_SPKI_HEX
);
assert_eq!(
public_key_info_of_key(&ca_key).expect("ca key spki"),
public_key_info_of_cert(CA_PEM).expect("ca spki"),
"the stored issuer key and certificate no longer match"
);
let ca_der = pem_to_der(CA_PEM).expect("ca der");
assert_eq!(
gunnar_core::auth::fingerprint_of_certificate(&ca_der).as_str(),
CA_FINGERPRINT
);
// The leaf it issued before the bump.
let old_leaf_der = pem_to_der(LEAF_PEM).expect("leaf der");
assert_eq!(
gunnar_core::auth::fingerprint_of_certificate(&old_leaf_der).as_str(),
LEAF_FINGERPRINT
);
assert_eq!(
not_after_ms(&old_leaf_der).expect("not after"),
LEAF_NOT_AFTER_MS
);
assert_eq!(issued_by(LEAF_PEM, CA_PEM).expect("parse"), Ok(()));
assert!(verifies_under(&old_leaf_der, &ca_der));
assert_eq!(
csr_claimed_common_name(CLIENT_CSR_PEM).expect("csr"),
Some("kat-client".to_owned())
);
// Adopt it on today's code and issue from the old CSR.
let secrets: Arc<dyn SecretStore> = Arc::new(MemSecretStore::new());
let pki = Pki::new(memory_control_store().expect("control"), secrets).expect("pki");
let issuer = pki
.import(CA_PEM, &ca_key)
.expect("the pre-bump issuer imports");
assert_eq!(issuer.fingerprint.as_str(), CA_FINGERPRINT);
let fresh = pki
.issue_from_csr("kat-client", CLIENT_CSR_PEM, 30)
.expect("issue from the old CSR");
assert_eq!(issued_by(&fresh.cert_pem, CA_PEM).expect("parse"), Ok(()));
assert!(
verifies_under(&fresh.cert_der, &ca_der),
"a leaf issued after the bump does not verify under the old CA"
);
let (ca_ski, _) = key_ids(&ca_der);
let (_, fresh_aki) = key_ids(&fresh.cert_der);
assert!(
ca_ski.is_some(),
"the old CA carries a SubjectKeyIdentifier"
);
assert_eq!(
fresh_aki, ca_ski,
"the new leaf's AKI must name the old CA's SKI"
);
assert_eq!(
public_key_info_of_cert(&fresh.cert_pem).expect("fresh spki"),
public_key_info_of_cert(LEAF_PEM).expect("old leaf spki"),
"the issued certificate must carry the requester's key"
);
}
#[test]
fn a_freshly_minted_issuer_and_server_identity_parse_back() {
let secrets: Arc<dyn SecretStore> = Arc::new(MemSecretStore::new());
let pki = Pki::new(memory_control_store().expect("control"), secrets).expect("pki");
let issuer = pki.create("gunnar KAT fresh issuer", 30).expect("create");
let identity = pki
.issue_server_identity(&["kat.example".to_owned(), "10.0.0.7".to_owned()], 30)
.expect("server identity");
let ca_der = pem_to_der(&issuer.cert_pem).expect("ca der");
assert!(verifies_under(&identity.cert_der, &ca_der));
assert_eq!(
issued_by(&identity.cert_pem, &issuer.cert_pem).expect("parse"),
Ok(())
);
let (_, cert) =
x509_parser::parse_x509_certificate(&identity.cert_der).expect("parse identity");
use x509_parser::extensions::GeneralName;
let san = cert
.subject_alternative_name()
.expect("san ext")
.expect("has a SAN");
let names = &san.value.general_names;
assert!(
names
.iter()
.any(|n| matches!(n, GeneralName::DNSName(d) if *d == "kat.example")),
"SANs: {names:?}"
);
assert!(
names
.iter()
.any(|n| matches!(n, GeneralName::IPAddress(ip) if *ip == [10u8, 0, 0, 7].as_slice())),
"SANs: {names:?}"
);
let (_, ca) = x509_parser::parse_x509_certificate(&ca_der).expect("parse ca");
assert!(ca.is_ca(), "the issuer is a CA");
let key_pem = identity
.key_pem
.as_deref()
.expect("server-generated key is handed back");
assert_eq!(
public_key_info_of_key(key_pem).expect("key spki"),
public_key_info_of_cert(&identity.cert_pem).expect("cert spki"),
);
}