crates/gunnar-common/src/monetize_key.rs
//! **Where the metered appliance's verifying key comes from** — a file, or the
//! key itself, spelled inline.
//!
//! `--monetize-pubkey` took a filesystem PATH and nothing else. On an appliance
//! that is a dead end: the installer only stages files onto `/data` when it is
//! told `korp.reinstall=wipe` (`edda/crates/korp-installer/src/install.rs`
//! skips the staging branch by name on the default keep-upgrade), and the ESP
//! is mounted read-only once the box is running. So the only writable surface a
//! keep-upgrade can reach a booting gunnar through is the kernel command line —
//! and a path on the cmdline names a file that nothing was allowed to put
//! there. A metered appliance therefore could not be given monetize's key at
//! all without wiping `/data`.
//!
//! A monetize verifying key is an Ed25519 **public** key: 32 bytes whose whole
//! job is to be handed out. Putting it on a cmdline that `/proc/cmdline` shows
//! every process is disclosing something already public, which is why this is
//! the right value to inline and a private key never would be.
//!
//! # The shape rule
//!
//! There is no second flag. One value, and the SHAPE of it decides — because a
//! key and a path do not look alike:
//!
//! * It is read as a **literal key** when it contains no `/` **and** either
//! every character is an ASCII hex digit, or it is exactly
//! [`HEX_LEN`] characters long.
//! * Anything else is a **path**, exactly as before.
//!
//! The second clause of the literal test is what keeps a near-miss honest. A
//! 63-character key is all-hex, so it is a *key that is wrong* and says so,
//! rather than becoming a filename nothing will ever find; and a 64-character
//! value with a typo in it is key-shaped by length, so it names the character
//! that is not hexadecimal instead of failing as a missing file. Every real
//! path keeps working: an absolute one has a `/`, and a relative one
//! (`monetize.pub`, `keys/monetize`) has a `/` or a character that is not a hex
//! digit.
use std::fmt;
use std::path::{Path, PathBuf};
use std::str::FromStr;
/// An Ed25519 public key is 32 bytes, so 64 hexadecimal characters.
pub const HEX_LEN: usize = 64;
/// What `--monetize-pubkey` was given: the key itself, or a file holding it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MonetizePubkey {
/// The 32 verifying-key bytes, spelled inline as hex.
Literal([u8; 32]),
/// A file to read the key out of, in any of the spellings the reader
/// accepts (raw bytes, hex, or `ed25519:<base64>`).
Path(PathBuf),
/// **[`FED`]: the installer's door feeds the key** (2026-09-24 evening,
/// the constant medium). monetize-server mints a fresh signing key with
/// every front, so a medium that carried the key was a medium per lay.
/// A medium composed with `monetize_pubkey=fed` carries NO key and says
/// so: the metered server starts enforcing nothing until the door's feed
/// arrives, and the pairing loop restarts the box once paired so the fed
/// key is in force from then on (`gunnar-server` `feed`, `pairing`).
/// A word, not an absence, because an absent value falls back to the key
/// the variant bakes — which is exactly the stale key this replaces.
Fed,
}
/// The spelling of [`MonetizePubkey::Fed`] — the whole value, nothing else.
pub const FED: &str = "fed";
impl MonetizePubkey {
/// Whether the key is the door's to feed ([`FED`]).
pub fn is_fed(&self) -> bool {
matches!(self, MonetizePubkey::Fed)
}
/// The key bytes, when the value was spelled inline. `None` for a path,
/// which has to be read before it is anything, and for [`FED`].
pub fn literal(&self) -> Option<[u8; 32]> {
match self {
MonetizePubkey::Literal(k) => Some(*k),
MonetizePubkey::Path(_) | MonetizePubkey::Fed => None,
}
}
/// The file to read, when the value was a path.
pub fn path(&self) -> Option<&Path> {
match self {
MonetizePubkey::Literal(_) | MonetizePubkey::Fed => None,
MonetizePubkey::Path(p) => Some(p),
}
}
}
impl fmt::Display for MonetizePubkey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
// A public key, printed whole: it is what the operator typed, and
// eliding it would make the refusal impossible to act on.
MonetizePubkey::Literal(k) => {
for b in k {
write!(f, "{b:02x}")?;
}
Ok(())
}
MonetizePubkey::Path(p) => write!(f, "{}", p.display()),
MonetizePubkey::Fed => f.write_str(FED),
}
}
}
impl FromStr for MonetizePubkey {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// BEFORE the shape rule: `fed` is all-hex (f, e, d), so it would be
// read as a three-character key that is wrong.
if s == FED {
return Ok(MonetizePubkey::Fed);
}
if looks_like_an_inline_key(s) {
return parse_hex32(s).map(MonetizePubkey::Literal).map_err(|why| {
format!(
"`{s}` is key-shaped, so it was read as monetize's public key spelled \
inline rather than as a filename, and {why}. A path is anything with a \
`/` in it (`/data/monetize.pub`) or any name that is not all hexadecimal \
(`monetize.pub`)."
)
});
}
Ok(MonetizePubkey::Path(PathBuf::from(s)))
}
}
/// The SHAPE rule, in one place: no `/`, and either all-hex or key-length.
///
/// Both clauses matter. All-hex alone would let a 64-character typo become a
/// filename; key-length alone would let a 63-character key become one.
fn looks_like_an_inline_key(s: &str) -> bool {
if s.is_empty() || s.contains('/') {
return false;
}
s.chars().all(|c| c.is_ascii_hexdigit()) || s.len() == HEX_LEN
}
/// 64 hexadecimal characters to the 32 bytes they spell.
///
/// The error names what was wrong — the length that was not 64, or the
/// character that was not a hex digit and where it sat — because this is read
/// off a kernel command line nobody can `ls`.
pub fn parse_hex32(text: &str) -> Result<[u8; 32], String> {
if text.len() != HEX_LEN {
return Err(format!(
"an Ed25519 public key is 32 bytes, so exactly {HEX_LEN} hexadecimal characters; \
this is {} character(s) long",
text.len()
));
}
if let Some((i, c)) = text.char_indices().find(|(_, c)| !c.is_ascii_hexdigit()) {
return Err(format!(
"character {} of the key is {c:?}, which is not hexadecimal (0-9, a-f)",
i + 1
));
}
let mut out = [0u8; 32];
for (i, slot) in out.iter_mut().enumerate() {
*slot = u8::from_str_radix(&text[2 * i..2 * i + 2], 16)
.map_err(|e| format!("byte {} of the key: {e}", i + 1))?;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// monetize's live verifying key, the one an appliance is actually given.
const LIVE: &str = "1c3663248d60900e2996389fba4efa1d7179d6dbd08627d0426f16f94e44c4e6";
/// The 32 bytes `LIVE` spells, written out by hand rather than by calling
/// the parser — a round-trip through the thing under test would agree with
/// itself no matter which byte order it used.
const LIVE_BYTES: [u8; 32] = [
0x1c, 0x36, 0x63, 0x24, 0x8d, 0x60, 0x90, 0x0e, 0x29, 0x96, 0x38, 0x9f, 0xba, 0x4e, 0xfa,
0x1d, 0x71, 0x79, 0xd6, 0xdb, 0xd0, 0x86, 0x27, 0xd0, 0x42, 0x6f, 0x16, 0xf9, 0x4e, 0x44,
0xc4, 0xe6,
];
/// **The blocker, measured.** The live key on the command line must arrive
/// as the 32 verifying-key bytes — not as `Ok`, not as a path that a
/// read-only appliance can never produce.
#[test]
fn the_live_key_spelled_inline_is_the_thirty_two_bytes() {
let parsed: MonetizePubkey = LIVE.parse().expect("the live key parses");
assert_eq!(
parsed.literal(),
Some(LIVE_BYTES),
"the inline key must yield monetize's 32 bytes; got {parsed:?}"
);
assert_eq!(parsed.path(), None, "an inline key is not a path");
}
/// Measured on the fixture itself: `LIVE_BYTES` is not a value every byte
/// order agrees on, so the assertion above can tell a correct key from a
/// mangled one.
#[test]
fn the_fixture_can_tell_a_correct_key_from_a_mangled_one() {
let mut reversed = LIVE_BYTES;
reversed.reverse();
assert_ne!(
reversed, LIVE_BYTES,
"a palindromic fixture cannot distinguish byte order"
);
// And one nibble's difference is a different key.
let mut typo: Vec<char> = LIVE.chars().collect();
typo[0] = '5';
let typo: String = typo.into_iter().collect();
let other: MonetizePubkey = typo.parse().expect("still key-shaped");
assert_ne!(
other.literal(),
Some(LIVE_BYTES),
"a key differing by one nibble must not parse to the same bytes"
);
}
/// `fed` — the door feeds the key (the constant medium). All-hex, so it
/// must be recognised BEFORE the shape rule, and it is neither a key nor
/// a path; anything else spelled near it stays what it was.
#[test]
fn fed_is_the_door_feeds_it_and_is_neither_a_key_nor_a_path() {
let parsed: MonetizePubkey = FED.parse().expect("`fed` parses");
assert!(parsed.is_fed(), "{parsed:?}");
assert_eq!((parsed.literal(), parsed.path()), (None, None));
assert_eq!(parsed.to_string(), "fed", "said back as it was spelled");
assert!(
"fed0".parse::<MonetizePubkey>().is_err(),
"a hex near-miss is a wrong KEY, not `fed`"
);
assert!(!LIVE.parse::<MonetizePubkey>().unwrap().is_fed());
}
/// The path arm is untouched: a value with a `/` is a path, whether or not
/// it exists, and it is NOT swallowed by the literal arm.
#[test]
fn a_path_is_still_a_path_even_when_it_does_not_exist() {
let parsed: MonetizePubkey = "/no/such/dir/monetize.pub"
.parse()
.expect("a path always parses");
assert_eq!(
parsed.path(),
Some(Path::new("/no/such/dir/monetize.pub")),
"the literal arm has swallowed the path arm"
);
assert_eq!(parsed.literal(), None);
// A relative path with an extension, and one with a directory.
for p in ["monetize.pub", "keys/monetize"] {
let parsed: MonetizePubkey = p.parse().unwrap();
assert_eq!(
parsed.path(),
Some(Path::new(p)),
"{p} stopped being a path"
);
}
}
/// A key of the wrong length is REFUSED by length, not truncated to 32
/// bytes and not turned into a filename.
#[test]
fn a_hex_key_of_the_wrong_length_is_refused_by_length() {
for (text, n) in [(&LIVE[..63], 63usize), (&*format!("{LIVE}0"), 65)] {
let err = text
.parse::<MonetizePubkey>()
.expect_err("a near-miss key must be refused, not read as a file");
assert!(
err.contains(&n.to_string()) && err.contains("64"),
"the refusal must name the length that was wrong and the one wanted: {err}"
);
}
}
/// A 64-character value that is not hex names the offending character.
#[test]
fn a_key_shaped_value_that_is_not_hex_names_the_bad_character() {
let mut chars: Vec<char> = LIVE.chars().collect();
chars[7] = 'z';
let text: String = chars.into_iter().collect();
let err = text
.parse::<MonetizePubkey>()
.expect_err("a non-hex key must be refused");
assert!(
err.contains('z') && err.contains('8'),
"the refusal must name the character and where it sat: {err}"
);
}
/// `Display` round-trips a literal back to the hex it was given, so a
/// refusal printed by the server names the key the operator typed.
#[test]
fn display_prints_a_literal_back_as_its_hex() {
let parsed: MonetizePubkey = LIVE.parse().unwrap();
assert_eq!(parsed.to_string(), LIVE);
let parsed: MonetizePubkey = "/etc/monetize.pub".parse().unwrap();
assert_eq!(parsed.to_string(), "/etc/monetize.pub");
}
}