monetize-embed 0.1.1

The thin client a monetized product compiles in: an Ed25519-verified entitlement cache with nanosecond verdicts that keeps answering while the licence server is unreachable. No network, no ledger — the product feeds it signed facts and asks.
Documentation
//! **The one canonical form** an entitlement is signed over, and the verify side.
//!
//! Signing lives in `monetize` (it has the key); verifying lives here because a product
//! must check a fact without linking the core (redb, vendors). Core depends on this
//! crate for [`fact_message`] so there is exactly one canonical form.
//!
//! Canonical JSON: the fact's fields minus `signature`, objects with keys sorted
//! bytewise, no whitespace. `serde_json`'s map is sorted by default but that is a cargo
//! feature (`preserve_order`) any crate in the build could flip, so the sort is done
//! here, explicitly, and does not depend on it.

use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use monetize_product::EntitlementFact;
use serde_json::Value;

/// A signed set of facts, the file monetize hands a product so it can start (or
/// restart) with the full picture before any push arrives.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Snapshot {
    /// Monotonic: a cache refuses a snapshot older than the one it holds (replay).
    pub issued_unix_ms: u64,
    pub facts: Vec<EntitlementFact>,
    /// Ed25519 over [`snapshot_message`], by monetize's key.
    pub signature: Vec<u8>,
}

#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SignatureError {
    #[error("signature is not 64 bytes")]
    Malformed,
    #[error("signature does not verify for tenant {0}")]
    Fact(String),
    #[error("snapshot envelope signature does not verify")]
    Envelope,
    #[error("go-ahead signature does not verify for nonce {0}")]
    GoAhead(String),
    /// An actor ticket was refused. The string is the OPERATOR's reason and may
    /// name the ticket's own fields; see `crate::ticket::verify_ticket` for why
    /// none of them is a customer's to read.
    #[error("the actor ticket was refused: {0}")]
    Ticket(String),
}

/// Write `value` as canonical JSON: keys sorted, no whitespace.
pub fn canonical_json(value: &Value, out: &mut String) {
    match value {
        Value::Object(map) => {
            let mut keys: Vec<&String> = map.keys().collect();
            keys.sort();
            out.push('{');
            for (i, k) in keys.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                out.push_str(&serde_json::to_string(k).expect("string"));
                out.push(':');
                canonical_json(&map[*k], out);
            }
            out.push('}');
        }
        Value::Array(items) => {
            out.push('[');
            for (i, v) in items.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                canonical_json(v, out);
            }
            out.push(']');
        }
        other => out.push_str(&other.to_string()),
    }
}

/// The bytes a fact's signature covers: every field except `signature`.
pub fn fact_message(fact: &EntitlementFact) -> Vec<u8> {
    let mut v = serde_json::to_value(fact).expect("fact serializes");
    v.as_object_mut().expect("fact is an object").remove("signature");
    let mut s = String::new();
    canonical_json(&v, &mut s);
    s.into_bytes()
}

/// The bytes a snapshot's envelope signature covers. The facts inside keep their own
/// signatures (they are part of the message), so a snapshot vouches for the *set*.
pub fn snapshot_message(issued_unix_ms: u64, facts: &[EntitlementFact]) -> Vec<u8> {
    let v = serde_json::json!({ "issued_unix_ms": issued_unix_ms, "facts": facts });
    let mut s = String::new();
    canonical_json(&v, &mut s);
    s.into_bytes()
}

pub(crate) fn check(key: &VerifyingKey, msg: &[u8], sig: &[u8]) -> Result<bool, SignatureError> {
    let sig = Signature::from_slice(sig).map_err(|_| SignatureError::Malformed)?;
    Ok(key.verify(msg, &sig).is_ok())
}

pub fn verify_fact(fact: &EntitlementFact, key: &VerifyingKey) -> Result<(), SignatureError> {
    if check(key, &fact_message(fact), &fact.signature)? {
        Ok(())
    } else {
        Err(SignatureError::Fact(fact.tenant.0.clone()))
    }
}

/// Envelope first, then every fact: one forged fact rejects the whole snapshot.
pub fn verify_snapshot(snap: &Snapshot, key: &VerifyingKey) -> Result<(), SignatureError> {
    if !check(key, &snapshot_message(snap.issued_unix_ms, &snap.facts), &snap.signature)? {
        return Err(SignatureError::Envelope);
    }
    snap.facts.iter().try_for_each(|f| verify_fact(f, key))
}

// ── the growth go-ahead (`DATA-SET-GROWTH-FLOW.md` D3 / T14) ───────────────

/// **The go-ahead a data-set growth is started on**, as gunnar's
/// `gunnar_server::grow::go_ahead::GoAhead` parses it:
///
/// ```json
/// {"v":1,"signer":"monetize","target_sectors":<u64>,"nonce":"<one line ≤256 B>",
///  "issued_unix_ms":<i64>,"signature":"<base64, standard alphabet, padded>"}
/// ```
///
/// The signature is Ed25519 by monetize's signing key over
/// [`go_ahead_message`]: the canonical JSON (keys sorted bytewise, no
/// whitespace) of the object MINUS `signature` — the same form and the same
/// key an [`EntitlementFact`] is signed with, so a metered gunnar verifies it
/// with the `--monetize-pubkey` it already holds. gunnar checks the structure
/// (`v`, a known `signer`, a non-empty nonce, `target_sectors` equal to the
/// request's, the nonce unspent on that box); a `monetize` policy plugged into
/// its `GoAheadPolicy` calls [`verify_go_ahead`].
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct GoAhead {
    pub v: u32,
    pub signer: String,
    pub target_sectors: u64,
    pub nonce: String,
    pub issued_unix_ms: i64,
    #[serde(default)]
    pub signature: String,
}

/// The signer word a monetize-minted go-ahead carries.
pub const GO_AHEAD_SIGNER_MONETIZE: &str = "monetize";

/// The bytes a go-ahead's signature covers: every field except `signature`,
/// canonical.
pub fn go_ahead_message(go: &GoAhead) -> Vec<u8> {
    let mut v = serde_json::to_value(go).expect("go-ahead serializes");
    v.as_object_mut().expect("go-ahead is an object").remove("signature");
    let mut s = String::new();
    canonical_json(&v, &mut s);
    s.into_bytes()
}

/// Parse the bytes handed on the wire and verify the signature under `key`.
/// Structure first (so a refusal names what is wrong), then the signature.
pub fn verify_go_ahead(bytes: &[u8], key: &VerifyingKey) -> Result<GoAhead, SignatureError> {
    let go: GoAhead = serde_json::from_slice(bytes).map_err(|_| SignatureError::Malformed)?;
    if go.v != 1 || go.signer != GO_AHEAD_SIGNER_MONETIZE || go.nonce.trim().is_empty() {
        return Err(SignatureError::Malformed);
    }
    let sig = base64_decode(&go.signature).ok_or(SignatureError::Malformed)?;
    if check(key, &go_ahead_message(&go), &sig)? {
        Ok(go)
    } else {
        Err(SignatureError::GoAhead(go.nonce.clone()))
    }
}

const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

/// Standard base64, padded — the alphabet `GoAhead.signature` is written in.
/// Twenty lines here rather than a dependency the two crates that need it
/// (this one and `monetize`) would otherwise add for one field.
pub fn base64_encode(bytes: &[u8]) -> String {
    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
    for chunk in bytes.chunks(3) {
        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
        let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
        out.push(B64[(n >> 18) as usize & 63] as char);
        out.push(B64[(n >> 12) as usize & 63] as char);
        out.push(if chunk.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
        out.push(if chunk.len() > 2 { B64[n as usize & 63] as char } else { '=' });
    }
    out
}

/// The inverse; `None` on anything that is not padded standard base64.
pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
    let text = text.trim();
    if text.len() % 4 != 0 {
        return None;
    }
    let val = |c: u8| B64.iter().position(|b| *b == c).map(|p| p as u32);
    let mut out = Vec::with_capacity(text.len() / 4 * 3);
    for chunk in text.as_bytes().chunks(4) {
        let pad = chunk.iter().rev().take_while(|c| **c == b'=').count();
        if pad > 2 || chunk[..4 - pad].iter().any(|c| *c == b'=') {
            return None;
        }
        let mut n = 0u32;
        for (i, c) in chunk.iter().enumerate() {
            let v = if i >= 4 - pad { 0 } else { val(*c)? };
            n = (n << 6) | v;
        }
        out.push((n >> 16) as u8);
        if pad < 2 {
            out.push((n >> 8) as u8);
        }
        if pad < 1 {
            out.push(n as u8);
        }
    }
    Some(out)
}

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

    #[test]
    fn base64_round_trips_every_padding_shape_and_refuses_junk() {
        for n in 0..10 {
            let bytes: Vec<u8> = (0..n).map(|i| (i * 37 + 11) as u8).collect();
            let enc = base64_encode(&bytes);
            assert_eq!(enc.len() % 4, 0);
            assert_eq!(base64_decode(&enc).unwrap(), bytes, "{enc}");
        }
        assert_eq!(base64_encode(b"Man"), "TWFu");
        assert_eq!(base64_encode(b"Ma"), "TWE=");
        assert_eq!(base64_encode(b"M"), "TQ==");
        assert_eq!(base64_decode("TQ="), None);
        assert_eq!(base64_decode("T@=="), None);
        assert_eq!(base64_decode("TQ=x"), None);
    }

    #[test]
    fn the_go_ahead_message_is_canonical_and_excludes_the_signature() {
        let go = GoAhead { v: 1, signer: "monetize".into(), target_sectors: 134_217_728, nonce: "n-1".into(), issued_unix_ms: 1_800_000_000_000, signature: "zzz".into() };
        let msg = String::from_utf8(go_ahead_message(&go)).unwrap();
        assert_eq!(msg, r#"{"issued_unix_ms":1800000000000,"nonce":"n-1","signer":"monetize","target_sectors":134217728,"v":1}"#);
    }
}