Skip to main content

monetize_embed/
signing.rs

1//! **The one canonical form** an entitlement is signed over, and the verify side.
2//!
3//! Signing lives in `monetize` (it has the key); verifying lives here because a product
4//! must check a fact without linking the core (redb, vendors). Core depends on this
5//! crate for [`fact_message`] so there is exactly one canonical form.
6//!
7//! Canonical JSON: the fact's fields minus `signature`, objects with keys sorted
8//! bytewise, no whitespace. `serde_json`'s map is sorted by default but that is a cargo
9//! feature (`preserve_order`) any crate in the build could flip, so the sort is done
10//! here, explicitly, and does not depend on it.
11
12use ed25519_dalek::{Signature, Verifier, VerifyingKey};
13use monetize_product::EntitlementFact;
14use serde_json::Value;
15
16/// A signed set of facts, the file monetize hands a product so it can start (or
17/// restart) with the full picture before any push arrives.
18#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
19pub struct Snapshot {
20    /// Monotonic: a cache refuses a snapshot older than the one it holds (replay).
21    pub issued_unix_ms: u64,
22    pub facts: Vec<EntitlementFact>,
23    /// Ed25519 over [`snapshot_message`], by monetize's key.
24    pub signature: Vec<u8>,
25}
26
27#[derive(Debug, thiserror::Error, PartialEq, Eq)]
28pub enum SignatureError {
29    #[error("signature is not 64 bytes")]
30    Malformed,
31    #[error("signature does not verify for tenant {0}")]
32    Fact(String),
33    /// The fact's ISSUE TIME is not signed by the same key, or the fact carries
34    /// one half of the stamp without the other. Told apart from [`Self::Fact`]
35    /// on purpose: the six fields were genuine and the monotonic half was not,
36    /// which is a tampering attempt with a different shape and a different
37    /// remedy from a wholly forged fact.
38    #[error("the issue time on the fact for tenant {0} is not signed by the same key")]
39    Issued(String),
40    #[error("snapshot envelope signature does not verify")]
41    Envelope,
42    #[error("go-ahead signature does not verify for nonce {0}")]
43    GoAhead(String),
44    /// An actor ticket was refused. The string is the OPERATOR's reason and may
45    /// name the ticket's own fields; see `crate::ticket::verify_ticket` for why
46    /// none of them is a customer's to read.
47    #[error("the actor ticket was refused: {0}")]
48    Ticket(String),
49}
50
51/// Write `value` as canonical JSON: keys sorted, no whitespace.
52pub fn canonical_json(value: &Value, out: &mut String) {
53    match value {
54        Value::Object(map) => {
55            let mut keys: Vec<&String> = map.keys().collect();
56            keys.sort();
57            out.push('{');
58            for (i, k) in keys.iter().enumerate() {
59                if i > 0 {
60                    out.push(',');
61                }
62                out.push_str(&serde_json::to_string(k).expect("string"));
63                out.push(':');
64                canonical_json(&map[*k], out);
65            }
66            out.push('}');
67        }
68        Value::Array(items) => {
69            out.push('[');
70            for (i, v) in items.iter().enumerate() {
71                if i > 0 {
72                    out.push(',');
73                }
74                canonical_json(v, out);
75            }
76            out.push(']');
77        }
78        other => out.push_str(&other.to_string()),
79    }
80}
81
82/// Canonical JSON of the fact with `drop` removed. The ONE writer of a fact's
83/// signed bytes; the two forms below differ only in what they drop, so they
84/// cannot drift apart.
85fn fact_form(fact: &EntitlementFact, drop: &[&str]) -> Vec<u8> {
86    let mut v = serde_json::to_value(fact).expect("fact serializes");
87    let object = v.as_object_mut().expect("fact is an object");
88    for key in drop {
89        object.remove(*key);
90    }
91    let mut s = String::new();
92    canonical_json(&v, &mut s);
93    s.into_bytes()
94}
95
96/// **The V1 form**: every field except `signature`, `issued_unix_ms` and
97/// `issued_signature`.
98///
99/// This is byte for byte what it was before the issue time existed, and it must
100/// stay that way for ever: it is the form an appliance built before 2026-09-17
101/// computes from the seven proto fields it knows, and every fact ever signed
102/// carries a signature over it. Adding the issue time HERE instead of in a
103/// second form would have invalidated every one of them at once.
104pub fn fact_message(fact: &EntitlementFact) -> Vec<u8> {
105    fact_form(fact, &["signature", "issued_unix_ms", "issued_signature"])
106}
107
108/// **The V2 form**: [`fact_message`] plus `issued_unix_ms`. What
109/// [`EntitlementFact::issued_signature`] covers, and the reason the issue time
110/// cannot be added, moved or bumped by anyone but monetize.
111pub fn fact_message_issued(fact: &EntitlementFact) -> Vec<u8> {
112    fact_form(fact, &["signature", "issued_signature"])
113}
114
115/// The bytes a snapshot's envelope signature covers. The facts inside keep their own
116/// signatures (they are part of the message), so a snapshot vouches for the *set*.
117pub fn snapshot_message(issued_unix_ms: u64, facts: &[EntitlementFact]) -> Vec<u8> {
118    let v = serde_json::json!({ "issued_unix_ms": issued_unix_ms, "facts": facts });
119    let mut s = String::new();
120    canonical_json(&v, &mut s);
121    s.into_bytes()
122}
123
124pub(crate) fn check(key: &VerifyingKey, msg: &[u8], sig: &[u8]) -> Result<bool, SignatureError> {
125    let sig = Signature::from_slice(sig).map_err(|_| SignatureError::Malformed)?;
126    Ok(key.verify(msg, &sig).is_ok())
127}
128
129/// **Both halves.** The V1 signature always, and the V2 signature whenever the
130/// fact carries an issue time.
131///
132/// The two are checked together and never apart, which is what stops the issue
133/// time being editable in transit. Three shapes are refused here and not later:
134///
135/// * a stamped fact whose `issued_signature` does not verify — someone ADDED or
136///   BUMPED an issue time. Bumping matters as much as adding: a fact stamped
137///   far in the future would latch a product's high-water mark past every
138///   legitimate fact monetize will ever mint for that tenant, which is a lasting
139///   denial of service dressed as a plan change;
140/// * `issued_signature` with no `issued_unix_ms`, and
141/// * `issued_unix_ms` with no `issued_signature` — an unsigned issue time is not
142///   a weaker fact, it is a forged one.
143pub fn verify_fact(fact: &EntitlementFact, key: &VerifyingKey) -> Result<(), SignatureError> {
144    if !check(key, &fact_message(fact), &fact.signature)? {
145        return Err(SignatureError::Fact(fact.tenant.0.clone()));
146    }
147    match (fact.issued_unix_ms, fact.issued_signature.is_empty()) {
148        // Unstamped: a fact signed before the issue time existed. Legal.
149        (None, true) => Ok(()),
150        (Some(_), false) => {
151            if check(key, &fact_message_issued(fact), &fact.issued_signature)? {
152                Ok(())
153            } else {
154                Err(SignatureError::Issued(fact.tenant.0.clone()))
155            }
156        }
157        (None, false) | (Some(_), true) => Err(SignatureError::Issued(fact.tenant.0.clone())),
158    }
159}
160
161/// Envelope first, then every fact: one forged fact rejects the whole snapshot.
162pub fn verify_snapshot(snap: &Snapshot, key: &VerifyingKey) -> Result<(), SignatureError> {
163    if !check(key, &snapshot_message(snap.issued_unix_ms, &snap.facts), &snap.signature)? {
164        return Err(SignatureError::Envelope);
165    }
166    snap.facts.iter().try_for_each(|f| verify_fact(f, key))
167}
168
169// ── the growth go-ahead (`DATA-SET-GROWTH-FLOW.md` D3 / T14) ───────────────
170
171/// **The go-ahead a data-set growth is started on**, as gunnar's
172/// `gunnar_server::grow::go_ahead::GoAhead` parses it:
173///
174/// ```json
175/// {"v":1,"signer":"monetize","target_sectors":<u64>,"nonce":"<one line ≤256 B>",
176///  "issued_unix_ms":<i64>,"signature":"<base64, standard alphabet, padded>"}
177/// ```
178///
179/// The signature is Ed25519 by monetize's signing key over
180/// [`go_ahead_message`]: the canonical JSON (keys sorted bytewise, no
181/// whitespace) of the object MINUS `signature` — the same form and the same
182/// key an [`EntitlementFact`] is signed with, so a metered gunnar verifies it
183/// with the `--monetize-pubkey` it already holds. gunnar checks the structure
184/// (`v`, a known `signer`, a non-empty nonce, `target_sectors` equal to the
185/// request's, the nonce unspent on that box); a `monetize` policy plugged into
186/// its `GoAheadPolicy` calls [`verify_go_ahead`].
187#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
188pub struct GoAhead {
189    pub v: u32,
190    pub signer: String,
191    pub target_sectors: u64,
192    pub nonce: String,
193    pub issued_unix_ms: i64,
194    #[serde(default)]
195    pub signature: String,
196}
197
198/// The signer word a monetize-minted go-ahead carries.
199pub const GO_AHEAD_SIGNER_MONETIZE: &str = "monetize";
200
201/// The bytes a go-ahead's signature covers: every field except `signature`,
202/// canonical.
203pub fn go_ahead_message(go: &GoAhead) -> Vec<u8> {
204    let mut v = serde_json::to_value(go).expect("go-ahead serializes");
205    v.as_object_mut().expect("go-ahead is an object").remove("signature");
206    let mut s = String::new();
207    canonical_json(&v, &mut s);
208    s.into_bytes()
209}
210
211/// Parse the bytes handed on the wire and verify the signature under `key`.
212/// Structure first (so a refusal names what is wrong), then the signature.
213pub fn verify_go_ahead(bytes: &[u8], key: &VerifyingKey) -> Result<GoAhead, SignatureError> {
214    let go: GoAhead = serde_json::from_slice(bytes).map_err(|_| SignatureError::Malformed)?;
215    if go.v != 1 || go.signer != GO_AHEAD_SIGNER_MONETIZE || go.nonce.trim().is_empty() {
216        return Err(SignatureError::Malformed);
217    }
218    let sig = base64_decode(&go.signature).ok_or(SignatureError::Malformed)?;
219    if check(key, &go_ahead_message(&go), &sig)? {
220        Ok(go)
221    } else {
222        Err(SignatureError::GoAhead(go.nonce.clone()))
223    }
224}
225
226const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
227
228/// Standard base64, padded — the alphabet `GoAhead.signature` is written in.
229/// Twenty lines here rather than a dependency the two crates that need it
230/// (this one and `monetize`) would otherwise add for one field.
231pub fn base64_encode(bytes: &[u8]) -> String {
232    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
233    for chunk in bytes.chunks(3) {
234        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
235        let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
236        out.push(B64[(n >> 18) as usize & 63] as char);
237        out.push(B64[(n >> 12) as usize & 63] as char);
238        out.push(if chunk.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
239        out.push(if chunk.len() > 2 { B64[n as usize & 63] as char } else { '=' });
240    }
241    out
242}
243
244/// The inverse; `None` on anything that is not padded standard base64.
245pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
246    let text = text.trim();
247    if text.len() % 4 != 0 {
248        return None;
249    }
250    let val = |c: u8| B64.iter().position(|b| *b == c).map(|p| p as u32);
251    let mut out = Vec::with_capacity(text.len() / 4 * 3);
252    for chunk in text.as_bytes().chunks(4) {
253        let pad = chunk.iter().rev().take_while(|c| **c == b'=').count();
254        if pad > 2 || chunk[..4 - pad].iter().any(|c| *c == b'=') {
255            return None;
256        }
257        let mut n = 0u32;
258        for (i, c) in chunk.iter().enumerate() {
259            let v = if i >= 4 - pad { 0 } else { val(*c)? };
260            n = (n << 6) | v;
261        }
262        out.push((n >> 16) as u8);
263        if pad < 2 {
264            out.push((n >> 8) as u8);
265        }
266        if pad < 1 {
267            out.push(n as u8);
268        }
269    }
270    Some(out)
271}
272
273#[cfg(test)]
274mod go_ahead_tests {
275    use super::*;
276
277    #[test]
278    fn base64_round_trips_every_padding_shape_and_refuses_junk() {
279        for n in 0..10 {
280            let bytes: Vec<u8> = (0..n).map(|i| (i * 37 + 11) as u8).collect();
281            let enc = base64_encode(&bytes);
282            assert_eq!(enc.len() % 4, 0);
283            assert_eq!(base64_decode(&enc).unwrap(), bytes, "{enc}");
284        }
285        assert_eq!(base64_encode(b"Man"), "TWFu");
286        assert_eq!(base64_encode(b"Ma"), "TWE=");
287        assert_eq!(base64_encode(b"M"), "TQ==");
288        assert_eq!(base64_decode("TQ="), None);
289        assert_eq!(base64_decode("T@=="), None);
290        assert_eq!(base64_decode("TQ=x"), None);
291    }
292
293    #[test]
294    fn the_go_ahead_message_is_canonical_and_excludes_the_signature() {
295        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() };
296        let msg = String::from_utf8(go_ahead_message(&go)).unwrap();
297        assert_eq!(msg, r#"{"issued_unix_ms":1800000000000,"nonce":"n-1","signer":"monetize","target_sectors":134217728,"v":1}"#);
298    }
299}
300
301// ── the two compatibility directions ───────────────────────────────────────
302
303/// **The issue time was added on 2026-09-17, and nothing signed before it may
304/// break.** These tests are the promise, written down where the forms are.
305///
306/// The two directions a change to a signed form has to answer:
307///
308/// * an OLD fact meeting NEW code — every signature ever minted still verifies,
309///   because the V1 form is untouched;
310/// * a NEW fact meeting an OLD appliance — it reads seven proto fields, computes
311///   the V1 form and checks `signature`, which is exactly what
312///   [`v1_of_a_stamped_fact_is_byte_for_byte_the_old_form`] measures.
313///
314/// Neither direction ends in service withdrawn from a paying customer. The
315/// guard itself is the product's (gunnar's `EntitlementSlot::set`); what is
316/// promised here is only that both generations can still read the fact.
317#[cfg(test)]
318mod stamp_tests {
319    use super::*;
320    use ed25519_dalek::{Signer as _, SigningKey};
321    use monetize_product::{State, TenantId};
322
323    fn key() -> SigningKey {
324        SigningKey::from_bytes(&[3u8; 32])
325    }
326
327    fn bare() -> EntitlementFact {
328        EntitlementFact {
329            tenant: TenantId("team/sub".into()),
330            plan: "gunnar/team/sub/2026-09-03".into(),
331            state: State::Paid,
332            paid_until_unix_ms: Some(1_790_812_800_000),
333            caps: [("pack_bytes".to_string(), 10u64 << 30)].into(),
334            source: "payment:invoice:ocr-42".into(),
335            signature: vec![],
336            issued_unix_ms: None,
337            issued_signature: vec![],
338        }
339    }
340
341    /// A fact as it was signed before the field existed: V1 only.
342    fn v1(k: &SigningKey) -> EntitlementFact {
343        let mut f = bare();
344        f.signature = k.sign(&fact_message(&f)).to_bytes().to_vec();
345        f
346    }
347
348    /// A fact as monetize mints one now: both signatures, stamped.
349    fn stamped(k: &SigningKey, issued: u64) -> EntitlementFact {
350        let mut f = bare();
351        f.issued_unix_ms = Some(issued);
352        f.signature = k.sign(&fact_message(&f)).to_bytes().to_vec();
353        f.issued_signature = k.sign(&fact_message_issued(&f)).to_bytes().to_vec();
354        f
355    }
356
357    /// **The old form is frozen.** The bytes below are what `fact_message`
358    /// produced before the stamp existed, written out in full rather than
359    /// computed, so a future field that forgets to exclude itself fails HERE
360    /// and not on a customer's appliance.
361    const V1_BYTES: &str = concat!(
362        r#"{"caps":{"pack_bytes":10737418240},"paid_until_unix_ms":1790812800000,"#,
363        r#""plan":"gunnar/team/sub/2026-09-03","source":"payment:invoice:ocr-42","#,
364        r#""state":"Paid","tenant":"team/sub"}"#
365    );
366
367    #[test]
368    fn v1_of_a_stamped_fact_is_byte_for_byte_the_old_form() {
369        let k = key();
370        assert_eq!(String::from_utf8(fact_message(&bare())).unwrap(), V1_BYTES);
371        let stamped = stamped(&k, 1_789_000_000_000);
372        assert_eq!(
373            String::from_utf8(fact_message(&stamped)).unwrap(),
374            V1_BYTES,
375            "an appliance that has never heard of the stamp computes exactly this"
376        );
377        // …and therefore the V1 signature on a stamped fact is the one such an
378        // appliance checks, and it passes. This is the NEW-fact-meets-OLD-box
379        // direction, and it must never become a refusal.
380        assert!(check(&k.verifying_key(), V1_BYTES.as_bytes(), &stamped.signature).unwrap());
381    }
382
383    #[test]
384    fn the_v2_form_is_the_v1_form_plus_the_issue_time_and_nothing_else() {
385        let stamped = stamped(&key(), 1_789_000_000_000);
386        assert_eq!(
387            String::from_utf8(fact_message_issued(&stamped)).unwrap(),
388            concat!(
389                r#"{"caps":{"pack_bytes":10737418240},"issued_unix_ms":1789000000000,"#,
390                r#""paid_until_unix_ms":1790812800000,"plan":"gunnar/team/sub/2026-09-03","#,
391                r#""source":"payment:invoice:ocr-42","state":"Paid","tenant":"team/sub"}"#
392            )
393        );
394    }
395
396    #[test]
397    fn a_fact_signed_before_the_stamp_existed_still_verifies() {
398        let k = key();
399        verify_fact(&v1(&k), &k.verifying_key()).expect("OLD fact, NEW code: accepted");
400    }
401
402    #[test]
403    fn a_stamped_fact_verifies_both_halves() {
404        let k = key();
405        verify_fact(&stamped(&k, 1_789_000_000_000), &k.verifying_key()).unwrap();
406    }
407
408    /// **The issue time cannot be added, moved or bumped in transit.**
409    ///
410    /// Bumping is the one that would hurt most: a fact stamped far in the future
411    /// moves a product's high-water mark past every fact monetize will ever mint
412    /// for that tenant, so the tenant's plan can never be changed again. That is
413    /// a lasting denial of service, and it is refused here, at the signature.
414    #[test]
415    fn refuse_twin_an_unsigned_or_bumped_issue_time_is_refused_by_its_own_name() {
416        let k = key();
417        let who = || SignatureError::Issued("team/sub".into());
418
419        // Added to a fact that never had one.
420        let mut added = v1(&k);
421        added.issued_unix_ms = Some(u64::MAX);
422        assert_eq!(verify_fact(&added, &k.verifying_key()), Err(who()));
423
424        // Bumped on a genuinely stamped fact: the V1 signature still passes
425        // (it never covered the field), and the V2 one does not.
426        let mut bumped = stamped(&k, 1_789_000_000_000);
427        bumped.issued_unix_ms = Some(u64::MAX);
428        assert!(check(&k.verifying_key(), &fact_message(&bumped), &bumped.signature).unwrap());
429        assert_eq!(verify_fact(&bumped, &k.verifying_key()), Err(who()));
430
431        // Half a stamp, either way round.
432        let mut no_sig = stamped(&k, 1_789_000_000_000);
433        no_sig.issued_signature.clear();
434        assert_eq!(verify_fact(&no_sig, &k.verifying_key()), Err(who()));
435        let mut no_time = stamped(&k, 1_789_000_000_000);
436        no_time.issued_unix_ms = None;
437        assert_eq!(verify_fact(&no_time, &k.verifying_key()), Err(who()));
438
439        // A stamp lifted off another fact for the same tenant.
440        let mut moved = stamped(&k, 1_789_000_000_000);
441        moved.issued_signature = stamped(&k, 1_789_000_000_001).issued_signature;
442        assert_eq!(verify_fact(&moved, &k.verifying_key()), Err(who()));
443    }
444
445    /// A snapshot of stamped facts verifies whole, and one bumped stamp inside
446    /// it rejects the whole snapshot — the envelope vouches for the SET.
447    #[test]
448    fn a_snapshot_carries_stamped_facts_and_one_bad_stamp_rejects_the_set() {
449        let k = key();
450        let good = stamped(&k, 1_789_000_000_000);
451        let facts = vec![good.clone()];
452        let signature = k.sign(&snapshot_message(9, &facts)).to_bytes().to_vec();
453        let snap = Snapshot { issued_unix_ms: 9, facts, signature };
454        verify_snapshot(&snap, &k.verifying_key()).unwrap();
455
456        let mut bumped = good;
457        bumped.issued_unix_ms = Some(u64::MAX);
458        let facts = vec![bumped];
459        let signature = k.sign(&snapshot_message(9, &facts)).to_bytes().to_vec();
460        let snap = Snapshot { issued_unix_ms: 9, facts, signature };
461        assert_eq!(
462            verify_snapshot(&snap, &k.verifying_key()),
463            Err(SignatureError::Issued("team/sub".into()))
464        );
465    }
466}