Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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