crates/gunnar-server/tests/ed25519_kat.rs
//! **Known answers for the raw Ed25519 gunnar-server signs and checks with
//! `ed25519-dalek`**, pinned on the pre-bump stack (2026-10-03, the release-3
//! merge: ed25519-dalek 2, ssh-key 0.6, rand_core 0.6) before the RustCrypto bump
//! moves all three.
//!
//! Two doors use `ed25519-dalek` directly rather than through `ssh-key`:
//!
//! * the **actor-ticket key** ([`ActorTicketKey`]) — an OpenSSH key in the secret
//! store whose 32-byte seed `ed25519-dalek` signs with, raw, for
//! `monetize_embed::ticket::verify_ticket` on the other side of the seam;
//! * the installer's **layout config** ([`layout_config::verify`]) — a
//! domain-separated blake3 of the body, signed by the enrolled root key.
//!
//! The actor-ticket half needs `--features metered` (the module is metered-only);
//! the layout half runs in every build.
//!
//! A box that upgrades keeps its stored key and its sealed config, so both must
//! give the same answers after the bump. Ed25519 is deterministic, so the answers
//! are exact: the actor-ticket half is held to RFC 8032 section 7.1 TEST 1 and
//! TEST 2 (published vectors, re-derived independently with Python
//! `cryptography` 46), the layout half to a signature the pre-bump code wrote.
//! No private key is committed (`xtask key-census`): the seeds are the RFC's.
use gunnar_server::layout_config::{self, Authenticity};
/// (seed, public key, message, signature) — RFC 8032 section 7.1 TEST 1, TEST 2.
const RFC8032: [(&str, &str, &str, &str); 2] = [
(
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
"",
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b",
),
(
"4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",
"3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",
"72",
"92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00",
),
];
/// A layout-config body, as `korp_installer::layout` writes one.
const LAYOUT_BODY: &str = concat!(
"korp-layout-config 1\n",
"install.unix=1789000000\n",
"install.product=gunnar\n",
"install.mode=fresh\n",
"boot.dev=/dev/vda\n",
"data.mountpoint=/data\n",
"data.count=1\n",
"data.0.dev=/dev/vdb\n",
);
/// `auth.signature` the pre-bump code wrote over [`LAYOUT_BODY`] with RFC 8032
/// TEST 1's key (ed25519-dalek 2.2.0, blake3 1).
const LAYOUT_SIGNATURE: &str = "16cc05b6dbbb1c3126cb6d750439784bc5fb81e1ab357df28c49c05e0454e8670d97614899fe7ff5de7e4cdd01db15b30ca26f3da11363df83f66951824dcf0e";
fn unhex(s: &str) -> Vec<u8> {
(0..s.len() / 2)
.map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).expect("hex"))
.collect()
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn seed(s: &str) -> [u8; 32] {
unhex(s).try_into().expect("32 bytes")
}
fn sealed(body: &str, signature_hex: &str, public_hex: &str) -> String {
format!(
"{body}auth.domain=korp.layout-config.v1\nauth.blake3={}\nauth.signature={signature_hex}\n\
auth.pubkey={public_hex}\nauth.note=\n",
blake3::hash(body.as_bytes()).to_hex()
)
}
#[cfg(feature = "metered")]
#[test]
fn the_actor_ticket_key_signs_the_rfc_8032_vectors() {
use gunnar_core::ssh_key::{private::Ed25519Keypair, PrivateKey};
use gunnar_server::actor_ticket::ActorTicketKey;
// The seed as the secret store holds it: an OpenSSH ed25519 private key.
let stored = |seed_hex: &str| {
PrivateKey::new(
Ed25519Keypair::from_seed(&seed(seed_hex)).into(),
"gunnar-kat",
)
.expect("build the key")
};
for (seed_hex, public, message, signature) in RFC8032 {
let key = ActorTicketKey::from_openssh(&stored(seed_hex)).expect("an ed25519 key");
assert_eq!(
key.public_key_hex(),
public,
"the public half of {seed_hex}"
);
assert_eq!(
hex(&key.sign(&unhex(message))),
signature,
"the raw signature over {message:?} under {seed_hex}"
);
assert_eq!(
gunnar_server::actor_ticket::public_key_from_hex(key.public_key_hex()).expect("parses"),
seed(public),
"the hex the appliance serves parses back to the same 32 bytes"
);
}
}
#[test]
fn a_layout_config_signed_before_the_bump_still_verifies() {
let (seed_hex, public, _, _) = RFC8032[0];
let key = seed(public);
// What the pre-bump code signs today is what it signed then — with the
// ed25519-dalek the installer side signs with (`korp_installer::layout`).
use ed25519_dalek::Signer as _;
let sk = ed25519_dalek::SigningKey::from_bytes(&seed(seed_hex));
assert_eq!(hex(sk.verifying_key().as_bytes()), public);
let fresh = hex(&sk
.sign(&layout_config::signing_message(LAYOUT_BODY))
.to_bytes());
assert_eq!(
fresh, LAYOUT_SIGNATURE,
"the layout-config signature over the same body under the same key changed"
);
let config = sealed(LAYOUT_BODY, LAYOUT_SIGNATURE, public);
assert_eq!(
layout_config::verify(&config, Some(&key)),
Ok(Authenticity::Verified),
"a config the installer sealed before the bump must still verify"
);
// Anti-vacuity: the same signature over another body is refused.
let altered = LAYOUT_BODY.replace("/dev/vdb", "/dev/vdc");
let forged = sealed(&altered, LAYOUT_SIGNATURE, public);
assert!(
layout_config::verify(&forged, Some(&key)).is_err(),
"a signature over one body verified another"
);
}