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

nordisk/gunnar

crates/gunnar-growbench/src/money.rs

//! **The bench's own monetize key**, and facts signed with it.
//!
//! The entitlement cells (T1d, TK) need a box that TRUSTS a key the bench holds:
//! a medium composed `--param monetize_pubkey=<the bench key>` — never
//! monetize's. The bench signs exactly what monetize-server signs
//! (`monetize_embed::signing::fact_message{,_issued}` over monetize's own
//! `EntitlementFact`) and hands it to the box through the one door an operator
//! has, `gunnar control entitlement set`, which signs nothing itself.
//!
//! A seed is 32 bytes kept as 64 hex characters in a file the bench names; it
//! is made once from `/dev/urandom` and reused, so a medium composed against it
//! stays usable across runs.

use std::collections::BTreeMap;
use std::io::Read;
use std::path::Path;

use ed25519_dalek::{Signer, SigningKey};
use monetize_embed::{EntitlementFact, State, TenantId};

/// The seed at `path`, made if absent.
pub fn load_or_make(path: &Path) -> Result<SigningKey, String> {
    if !path.exists() {
        let mut seed = [0u8; 32];
        std::fs::File::open("/dev/urandom")
            .and_then(|mut f| f.read_exact(&mut seed))
            .map_err(|e| format!("/dev/urandom: {e}"))?;
        if let Some(dir) = path.parent() {
            let _ = std::fs::create_dir_all(dir);
        }
        std::fs::write(path, hex(&seed) + "\n").map_err(|e| format!("{}: {e}", path.display()))?;
    }
    let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
    let bytes =
        unhex(text.trim()).ok_or_else(|| format!("{} is not 64 hex characters", path.display()))?;
    let seed: [u8; 32] = bytes
        .try_into()
        .map_err(|_| format!("{} is not 32 bytes", path.display()))?;
    Ok(SigningKey::from_bytes(&seed))
}

/// The verifying key as the medium's `monetize_pubkey` parameter spells it.
pub fn pub_hex(key: &SigningKey) -> String {
    hex(&key.verifying_key().to_bytes())
}

/// A fact monetize would mint, signed V1 and stamped (`issued_unix_ms`), as the
/// JSON `gunnar control entitlement set` reads.
pub fn signed_fact_json(
    key: &SigningKey,
    namespace: &str,
    state: State,
    caps: &[(&str, u64)],
    issued_unix_ms: u64,
) -> String {
    let mut fact = EntitlementFact {
        tenant: TenantId(namespace.to_owned()),
        plan: format!("growbench/{namespace}"),
        state,
        paid_until_unix_ms: Some(issued_unix_ms + 30 * 86_400_000),
        caps: caps
            .iter()
            .map(|(k, v)| (k.to_string(), *v))
            .collect::<BTreeMap<_, _>>(),
        source: "payment:growbench:bench-key".into(),
        signature: vec![],
        issued_unix_ms: Some(issued_unix_ms),
        issued_signature: vec![],
    };
    fact.signature = key
        .sign(&monetize_embed::signing::fact_message(&fact))
        .to_bytes()
        .to_vec();
    fact.issued_signature = key
        .sign(&monetize_embed::signing::fact_message_issued(&fact))
        .to_bytes()
        .to_vec();
    serde_json::to_string(&fact).expect("a fact serializes")
}

/// The key the command line trusts, if any.
pub fn pubkey_in(cmdline: &str) -> Option<String> {
    cmdline
        .split_whitespace()
        .find_map(|t| t.strip_prefix("korp.env.GUNNAR_MONETIZE_PUBKEY="))
        .map(str::to_string)
}

fn hex(b: &[u8]) -> String {
    b.iter().map(|x| format!("{x:02x}")).collect()
}

fn unhex(s: &str) -> Option<Vec<u8>> {
    if s.len() % 2 != 0 {
        return None;
    }
    (0..s.len())
        .step_by(2)
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A bench fact VERIFIES under the bench key and not under another — the
    /// same check `EntitlementSlot` makes — so what the bench pushes is what
    /// monetize would have minted, and a wrong-key box refuses it.
    #[test]
    fn a_bench_fact_verifies_under_its_key_and_only_its_key() {
        let key = SigningKey::from_bytes(&[3u8; 32]);
        let other = SigningKey::from_bytes(&[4u8; 32]);
        let json = signed_fact_json(
            &key,
            "team",
            State::Paid,
            &[("pack_bytes", 1 << 30)],
            1_789_000_000_000,
        );
        let fact: EntitlementFact = serde_json::from_str(&json).unwrap();
        assert!(monetize_embed::signing::verify_fact(&fact, &key.verifying_key()).is_ok());
        assert!(monetize_embed::signing::verify_fact(&fact, &other.verifying_key()).is_err());
    }

    #[test]
    fn the_key_is_read_off_a_command_line_and_its_absence_is_none() {
        let a = "a".repeat(64);
        let line = format!("console=tty0 korp.env.GUNNAR_MONETIZE_PUBKEY={a} korp.entry=gunnar");
        assert_eq!(pubkey_in(&line).as_deref(), Some(a.as_str()));
        assert_eq!(pubkey_in("console=tty0 korp.entry=gunnar"), None);
    }

    #[test]
    fn a_seed_is_made_once_and_read_back_the_same() {
        let dir = std::env::temp_dir().join(format!("growbench-money-{}", std::process::id()));
        let p = dir.join("seed");
        let k1 = load_or_make(&p).unwrap();
        let k2 = load_or_make(&p).unwrap();
        assert_eq!(pub_hex(&k1), pub_hex(&k2));
        let _ = std::fs::remove_dir_all(&dir);
    }
}