crates/gunnar-server/tests/actor_ticket.rs
//! **The key the appliance signs an actor ticket with.**
//!
//! An actor ticket is the appliance's word that one human may act for one
//! tenant; `Billing.MintActorTicket` makes one and the other side of the
//! monetize seam checks the signature offline, against the public half this
//! box advertises. That arrangement has exactly two failure modes worth a test
//! file, and both are about the key rather than about the ticket:
//!
//! * **the key must be stable.** It is minted on first use and kept in the
//! secret store. If a reboot minted a new one, every ticket this appliance
//! had ever advertised a key for would stop verifying, and the symptom would
//! be "the renewal page is broken" rather than anything naming a key.
//! * **the key must be its own.** The obvious shortcut — sign with the SSH host
//! key, which every appliance already has — is a cross-protocol reuse with no
//! domain separation at the signature layer. `gunnar_store::ACTOR_TICKET_KEY`
//! says at length why that was not taken; this file is what holds the answer
//! to the code.
//!
//! The target is `required-features = ["metered"]`: the community appliance
//! mints nothing under this name, because it does not contain the service that
//! would spend it.
use std::sync::Arc;
use gunnar_grpc::ControlPlane as _;
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
/// **Minted once, and the same bytes ever after.**
///
/// Two `ServerStores` over one root, because that is what a restart is. The
/// second call must read the first call's key rather than mint a second one:
/// `SecretStoreExt::get_or_create_with` is what makes that atomic, and this is
/// the assertion that says we are really using it.
#[test]
fn the_actor_ticket_key_is_minted_once_and_survives_a_restart() {
let dir = tempfile::tempdir().expect("tempdir");
let first = {
let stores = ServerStores::open(dir.path()).expect("stores");
stores
.actor_ticket_key()
.expect("the first call mints a key")
};
let second = {
let stores = ServerStores::open(dir.path()).expect("stores");
stores
.actor_ticket_key()
.expect("the second call must find the first call's key")
};
assert_eq!(
first.public_key_hex(),
second.public_key_hex(),
"the appliance minted a SECOND actor-ticket key on restart. Every ticket signed by \
the first one stops verifying against the key this box now advertises, and nothing \
in the failure says the word 'key'"
);
// The public half is what an operator copies into a configuration by hand,
// so its shape is part of the contract and not an implementation detail.
assert_eq!(first.public_key_hex().len(), 64);
assert!(
first
.public_key_hex()
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"the advertised key must be lowercase hex: {}",
first.public_key_hex()
);
}
/// **It is not the SSH host key.**
///
/// The one assertion that would go red if somebody ever "saved a key" by
/// reusing the one the box already has. See `gunnar_store::ACTOR_TICKET_KEY`
/// for why that saving is not worth what it costs.
#[test]
fn the_actor_ticket_key_is_not_the_ssh_host_key() {
let dir = tempfile::tempdir().expect("tempdir");
let stores = ServerStores::open(dir.path()).expect("stores");
let ticket_key = stores.actor_ticket_key().expect("actor-ticket key");
let host = gunnar_core::ensure_host_key(&stores.secrets().expect("secrets")).expect("host key");
let host_hex = match host.private_key().public_key().key_data().ed25519() {
Some(public) => public.0.iter().map(|b| format!("{b:02x}")).collect(),
None => String::new(),
};
assert_ne!(
ticket_key.public_key_hex(),
host_hex,
"the actor-ticket key IS the SSH host key: one key, two signature formats, and only \
one of them has a domain separator"
);
}
/// **The signature this key makes is the one the seam verifies.**
///
/// Not a ticket — that is `gunnar-grpc/tests/billing.rs`'s job — but the
/// primitive under it: the bytes `sign` returns must check out under the key
/// `public_key_hex` advertises, decoded the way an operator's configuration
/// would decode it. A mismatch here would make every ticket unverifiable while
/// every test that only re-checked with the same object stayed green.
#[test]
fn what_this_key_signs_verifies_under_the_hex_it_advertises() {
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let dir = tempfile::tempdir().expect("tempdir");
let stores = ServerStores::open(dir.path()).expect("stores");
let key = stores.actor_ticket_key().expect("actor-ticket key");
let message = b"the appliance's word, as an operator's monetize would read it";
let signature = key.sign(message);
let mut raw = [0u8; 32];
for (slot, pair) in raw
.iter_mut()
.zip(key.public_key_hex().as_bytes().chunks(2))
{
*slot = u8::from_str_radix(std::str::from_utf8(pair).expect("ascii"), 16)
.expect("hex digit pair");
}
let verifying = VerifyingKey::from_bytes(&raw).expect("a verifying key");
verifying
.verify(message, &Signature::from_bytes(&signature))
.expect("what the appliance signed must verify under what the appliance advertises");
// And the negative half, so the assertion above is not satisfied by a
// verifier that accepts everything.
assert!(
verifying
.verify(b"other bytes", &Signature::from_bytes(&signature))
.is_err(),
"the verifier accepted a signature over other bytes"
);
}
/// **The REAL control plane answers both halves of the seam.**
///
/// `MemoryControlPlane` in `gunnar-grpc/tests/billing.rs` proves the service
/// enforces the scope; it proves nothing about the appliance that actually
/// ships, whose `ControlPlane` is this one. The default in the seam is
/// `Unimplemented` by name — the right answer for a plane with no secret store
/// — so without this test a metered appliance could serve `Billing` and refuse
/// every call, with every test in the tree green.
///
/// Both halves are checked against each other rather than separately: what the
/// plane SIGNS must verify under what the plane ADVERTISES, because two
/// methods reading two different keys is the one failure that no single-method
/// test can see.
#[test]
fn the_real_control_plane_signs_and_advertises_the_same_key() {
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
let plane = CoreControlPlane::new(Arc::clone(&stores));
let hex = plane
.actor_ticket_public_key_hex()
.expect("the shipping appliance must advertise its actor-ticket key");
assert_eq!(hex.len(), 64);
let message = b"what an appliance signs is what an appliance advertises";
let signature = plane
.actor_ticket_sign(message)
.expect("the shipping appliance must sign");
let mut raw = [0u8; 32];
for (slot, pair) in raw.iter_mut().zip(hex.as_bytes().chunks(2)) {
*slot = u8::from_str_radix(std::str::from_utf8(pair).expect("ascii"), 16)
.expect("hex digit pair");
}
VerifyingKey::from_bytes(&raw)
.expect("the advertised key parses")
.verify(message, &Signature::from_bytes(&signature))
.expect(
"the appliance signed under a key it does not advertise. Every ticket it mints is \
unverifiable and nothing on this box would say so",
);
// The key is the STORE's, not the plane's: a second plane over the same
// root advertises the same key, which is what survives a restart.
let second = CoreControlPlane::new(stores);
assert_eq!(
second.actor_ticket_public_key_hex().expect("advertised"),
hex
);
}