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    #[error("snapshot envelope signature does not verify")]
34    Envelope,
35    #[error("go-ahead signature does not verify for nonce {0}")]
36    GoAhead(String),
37    /// An actor ticket was refused. The string is the OPERATOR's reason and may
38    /// name the ticket's own fields; see `crate::ticket::verify_ticket` for why
39    /// none of them is a customer's to read.
40    #[error("the actor ticket was refused: {0}")]
41    Ticket(String),
42}
43
44/// Write `value` as canonical JSON: keys sorted, no whitespace.
45pub fn canonical_json(value: &Value, out: &mut String) {
46    match value {
47        Value::Object(map) => {
48            let mut keys: Vec<&String> = map.keys().collect();
49            keys.sort();
50            out.push('{');
51            for (i, k) in keys.iter().enumerate() {
52                if i > 0 {
53                    out.push(',');
54                }
55                out.push_str(&serde_json::to_string(k).expect("string"));
56                out.push(':');
57                canonical_json(&map[*k], out);
58            }
59            out.push('}');
60        }
61        Value::Array(items) => {
62            out.push('[');
63            for (i, v) in items.iter().enumerate() {
64                if i > 0 {
65                    out.push(',');
66                }
67                canonical_json(v, out);
68            }
69            out.push(']');
70        }
71        other => out.push_str(&other.to_string()),
72    }
73}
74
75/// The bytes a fact's signature covers: every field except `signature`.
76pub fn fact_message(fact: &EntitlementFact) -> Vec<u8> {
77    let mut v = serde_json::to_value(fact).expect("fact serializes");
78    v.as_object_mut().expect("fact is an object").remove("signature");
79    let mut s = String::new();
80    canonical_json(&v, &mut s);
81    s.into_bytes()
82}
83
84/// The bytes a snapshot's envelope signature covers. The facts inside keep their own
85/// signatures (they are part of the message), so a snapshot vouches for the *set*.
86pub fn snapshot_message(issued_unix_ms: u64, facts: &[EntitlementFact]) -> Vec<u8> {
87    let v = serde_json::json!({ "issued_unix_ms": issued_unix_ms, "facts": facts });
88    let mut s = String::new();
89    canonical_json(&v, &mut s);
90    s.into_bytes()
91}
92
93pub(crate) fn check(key: &VerifyingKey, msg: &[u8], sig: &[u8]) -> Result<bool, SignatureError> {
94    let sig = Signature::from_slice(sig).map_err(|_| SignatureError::Malformed)?;
95    Ok(key.verify(msg, &sig).is_ok())
96}
97
98pub fn verify_fact(fact: &EntitlementFact, key: &VerifyingKey) -> Result<(), SignatureError> {
99    if check(key, &fact_message(fact), &fact.signature)? {
100        Ok(())
101    } else {
102        Err(SignatureError::Fact(fact.tenant.0.clone()))
103    }
104}
105
106/// Envelope first, then every fact: one forged fact rejects the whole snapshot.
107pub fn verify_snapshot(snap: &Snapshot, key: &VerifyingKey) -> Result<(), SignatureError> {
108    if !check(key, &snapshot_message(snap.issued_unix_ms, &snap.facts), &snap.signature)? {
109        return Err(SignatureError::Envelope);
110    }
111    snap.facts.iter().try_for_each(|f| verify_fact(f, key))
112}
113
114// ── the growth go-ahead (`DATA-SET-GROWTH-FLOW.md` D3 / T14) ───────────────
115
116/// **The go-ahead a data-set growth is started on**, as gunnar's
117/// `gunnar_server::grow::go_ahead::GoAhead` parses it:
118///
119/// ```json
120/// {"v":1,"signer":"monetize","target_sectors":<u64>,"nonce":"<one line ≤256 B>",
121///  "issued_unix_ms":<i64>,"signature":"<base64, standard alphabet, padded>"}
122/// ```
123///
124/// The signature is Ed25519 by monetize's signing key over
125/// [`go_ahead_message`]: the canonical JSON (keys sorted bytewise, no
126/// whitespace) of the object MINUS `signature` — the same form and the same
127/// key an [`EntitlementFact`] is signed with, so a metered gunnar verifies it
128/// with the `--monetize-pubkey` it already holds. gunnar checks the structure
129/// (`v`, a known `signer`, a non-empty nonce, `target_sectors` equal to the
130/// request's, the nonce unspent on that box); a `monetize` policy plugged into
131/// its `GoAheadPolicy` calls [`verify_go_ahead`].
132#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
133pub struct GoAhead {
134    pub v: u32,
135    pub signer: String,
136    pub target_sectors: u64,
137    pub nonce: String,
138    pub issued_unix_ms: i64,
139    #[serde(default)]
140    pub signature: String,
141}
142
143/// The signer word a monetize-minted go-ahead carries.
144pub const GO_AHEAD_SIGNER_MONETIZE: &str = "monetize";
145
146/// The bytes a go-ahead's signature covers: every field except `signature`,
147/// canonical.
148pub fn go_ahead_message(go: &GoAhead) -> Vec<u8> {
149    let mut v = serde_json::to_value(go).expect("go-ahead serializes");
150    v.as_object_mut().expect("go-ahead is an object").remove("signature");
151    let mut s = String::new();
152    canonical_json(&v, &mut s);
153    s.into_bytes()
154}
155
156/// Parse the bytes handed on the wire and verify the signature under `key`.
157/// Structure first (so a refusal names what is wrong), then the signature.
158pub fn verify_go_ahead(bytes: &[u8], key: &VerifyingKey) -> Result<GoAhead, SignatureError> {
159    let go: GoAhead = serde_json::from_slice(bytes).map_err(|_| SignatureError::Malformed)?;
160    if go.v != 1 || go.signer != GO_AHEAD_SIGNER_MONETIZE || go.nonce.trim().is_empty() {
161        return Err(SignatureError::Malformed);
162    }
163    let sig = base64_decode(&go.signature).ok_or(SignatureError::Malformed)?;
164    if check(key, &go_ahead_message(&go), &sig)? {
165        Ok(go)
166    } else {
167        Err(SignatureError::GoAhead(go.nonce.clone()))
168    }
169}
170
171const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
172
173/// Standard base64, padded — the alphabet `GoAhead.signature` is written in.
174/// Twenty lines here rather than a dependency the two crates that need it
175/// (this one and `monetize`) would otherwise add for one field.
176pub fn base64_encode(bytes: &[u8]) -> String {
177    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
178    for chunk in bytes.chunks(3) {
179        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
180        let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
181        out.push(B64[(n >> 18) as usize & 63] as char);
182        out.push(B64[(n >> 12) as usize & 63] as char);
183        out.push(if chunk.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
184        out.push(if chunk.len() > 2 { B64[n as usize & 63] as char } else { '=' });
185    }
186    out
187}
188
189/// The inverse; `None` on anything that is not padded standard base64.
190pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
191    let text = text.trim();
192    if text.len() % 4 != 0 {
193        return None;
194    }
195    let val = |c: u8| B64.iter().position(|b| *b == c).map(|p| p as u32);
196    let mut out = Vec::with_capacity(text.len() / 4 * 3);
197    for chunk in text.as_bytes().chunks(4) {
198        let pad = chunk.iter().rev().take_while(|c| **c == b'=').count();
199        if pad > 2 || chunk[..4 - pad].iter().any(|c| *c == b'=') {
200            return None;
201        }
202        let mut n = 0u32;
203        for (i, c) in chunk.iter().enumerate() {
204            let v = if i >= 4 - pad { 0 } else { val(*c)? };
205            n = (n << 6) | v;
206        }
207        out.push((n >> 16) as u8);
208        if pad < 2 {
209            out.push((n >> 8) as u8);
210        }
211        if pad < 1 {
212            out.push(n as u8);
213        }
214    }
215    Some(out)
216}
217
218#[cfg(test)]
219mod go_ahead_tests {
220    use super::*;
221
222    #[test]
223    fn base64_round_trips_every_padding_shape_and_refuses_junk() {
224        for n in 0..10 {
225            let bytes: Vec<u8> = (0..n).map(|i| (i * 37 + 11) as u8).collect();
226            let enc = base64_encode(&bytes);
227            assert_eq!(enc.len() % 4, 0);
228            assert_eq!(base64_decode(&enc).unwrap(), bytes, "{enc}");
229        }
230        assert_eq!(base64_encode(b"Man"), "TWFu");
231        assert_eq!(base64_encode(b"Ma"), "TWE=");
232        assert_eq!(base64_encode(b"M"), "TQ==");
233        assert_eq!(base64_decode("TQ="), None);
234        assert_eq!(base64_decode("T@=="), None);
235        assert_eq!(base64_decode("TQ=x"), None);
236    }
237
238    #[test]
239    fn the_go_ahead_message_is_canonical_and_excludes_the_signature() {
240        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() };
241        let msg = String::from_utf8(go_ahead_message(&go)).unwrap();
242        assert_eq!(msg, r#"{"issued_unix_ms":1800000000000,"nonce":"n-1","signer":"monetize","target_sectors":134217728,"v":1}"#);
243    }
244}