Skip to main content

scrollcase_consumer/
trust.rs

1//! Trusted keys, and the signature check every consumer path begins with.
2//!
3//! The *caller* decides which public keys it accepts, and that decision is the whole basis of every
4//! guarantee below it. This crate never discovers a key, never fetches one, and never treats a key
5//! shipped beside an archive as trusted because it arrived.
6//!
7//! Where those keys come from is the caller's business rather than this crate's, and the difference
8//! is a security property, not an ergonomic one. A trust file on disk suits a command line, whose
9//! operator is also its administrator. An application shipped to someone else's machine usually
10//! wants the opposite: anchors compiled into the binary with `include_str!`, so that editing a file
11//! cannot substitute a key, sign a box with it, and have the application accept the result. Both
12//! reach verification as [`TrustAnchors`], and everything past this module sees the same resolved
13//! slice — there is one verification path, not one per source.
14//!
15//! A document is accepted when **any one** of its signatures verifies against a trusted key. That is
16//! what lets a document signed by both an outgoing and an incoming key stay valid across a rotation,
17//! and it is why a signature naming an unknown key is skipped rather than treated as an attack — a
18//! build that carries only one of the two keys must still be able to verify. Compiled-in anchors do
19//! not change that rule, but they do change who pays for it: rotating a key an application carries
20//! means releasing the application, so an application that may ever rotate should compile in the
21//! bundle shape and not the single key.
22
23use std::borrow::Cow;
24use std::path::Path;
25
26use base64::engine::general_purpose::STANDARD as BASE64;
27use base64::Engine as _;
28use ed25519_dalek::pkcs8::DecodePublicKey as _;
29use ed25519_dalek::{Signature, VerifyingKey};
30use serde::Deserialize;
31
32use crate::contract::documents::{sha256_hex, SignedDocument, SIGNATURE_ALGORITHM};
33use crate::error::{fail, Error, Result};
34
35/// One public key a caller is willing to accept signatures from.
36///
37/// Deserialised leniently on purpose: a trust file is the caller's own artefact, not a document the
38/// box format governs, and `keygen` writes a `publicKeyBase64` beside the PEM that this crate — like
39/// the Node and Python consumers — does not read.
40#[derive(Debug, Clone, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct TrustedKey {
43    /// Identifier a signature names to select this key.
44    pub key_id: String,
45    /// The key itself, SPKI PEM. A key without one is skipped rather than rejected.
46    #[serde(default)]
47    pub public_key_pem: Option<String>,
48}
49
50#[derive(Deserialize)]
51#[serde(untagged)]
52enum TrustFile {
53    Bundle { keys: Vec<TrustedKey> },
54    Single(TrustedKey),
55}
56
57/// The payload of a document whose signature has verified.
58#[derive(Debug, Clone)]
59pub struct VerifiedPayload {
60    /// The exact bytes that were signed, which are also the bytes that were published.
61    pub bytes: Vec<u8>,
62    /// Those bytes parsed as JSON — only ever produced after a signature has verified.
63    pub value: serde_json::Value,
64}
65
66/// Reads a trust file holding either a single key or a `{ "keys": [...] }` bundle.
67///
68/// # Errors
69///
70/// When the file cannot be read or is not one of the two shapes.
71pub fn load_trusted_keys(path: &Path) -> Result<Vec<TrustedKey>> {
72    let raw = std::fs::read(path).map_err(|error| {
73        Error::new(format!(
74            "Invalid trusted ed25519 key file {}: {error}",
75            path.display()
76        ))
77    })?;
78    parse_trusted_keys(&raw)
79}
80
81/// The same two shapes, from bytes the caller already holds.
82///
83/// This is the half an application compiling its anchors in needs: `include_str!` produces the
84/// bytes, and they go through the parser the file path uses rather than a second reading of the
85/// same format written at the call site. A second reading is how the single-key and bundle shapes
86/// come to disagree between a CLI and an application that are supposed to trust identically.
87///
88/// # Errors
89///
90/// When the bytes are neither a single key nor a bundle.
91pub fn parse_trusted_keys(raw: &[u8]) -> Result<Vec<TrustedKey>> {
92    match serde_json::from_slice::<TrustFile>(raw) {
93        Ok(TrustFile::Bundle { keys }) => Ok(keys),
94        Ok(TrustFile::Single(key)) => Ok(vec![key]),
95        Err(_) => Err(Error::new("Invalid trusted ed25519 key file.")),
96    }
97}
98
99/// Where the keys a caller accepts come from.
100///
101/// Every entry point in this crate takes one of these rather than a path, because a library cannot
102/// know whether its caller's trust decision is administrative or compiled in, and choosing for them
103/// would decide their threat model. Resolution happens once, at the entry point, and the rest of the
104/// crate only ever sees `&[TrustedKey]`.
105#[derive(Debug, Clone, Copy)]
106pub enum TrustAnchors<'a> {
107    /// A trust file, read at the moment of verification. Whoever can write it decides what verifies.
108    KeyFile(&'a Path),
109    /// Keys the caller already holds — parsed from a compiled-in bundle, a keychain, wherever.
110    Keys(&'a [TrustedKey]),
111}
112
113impl<'a> TrustAnchors<'a> {
114    /// Produces the keys to verify against, reading the trust file only when there is one.
115    ///
116    /// # Errors
117    ///
118    /// See [`load_trusted_keys`].
119    pub fn resolve(&self) -> Result<Cow<'a, [TrustedKey]>> {
120        match *self {
121            Self::KeyFile(path) => Ok(Cow::Owned(load_trusted_keys(path)?)),
122            Self::Keys(keys) => Ok(Cow::Borrowed(keys)),
123        }
124    }
125}
126
127/// Verifies a signed document against a set of trusted keys and returns its payload.
128///
129/// The payload is checksummed first — cheap, and it catches truncation — and is parsed only once a
130/// signature has verified, so no attacker-controlled JSON ever reaches a typed deserialiser on the
131/// strength of the envelope alone.
132///
133/// # Errors
134///
135/// When the payload does not match its checksum, when no signature verifies against a trusted key,
136/// or when the verified bytes are not a JSON object.
137pub fn verify_signed_document(
138    document: &SignedDocument,
139    trusted: &[TrustedKey],
140) -> Result<VerifiedPayload> {
141    let bytes = document.decode_payload()?;
142    if sha256_hex(&bytes) != document.payload_sha256 {
143        fail!("Signed payload SHA-256 mismatch.");
144    }
145
146    let verified = document.signatures.iter().any(|signature| {
147        // The signed-document schema pins the algorithm to ed25519, so a document naming another one
148        // is already refused by name upstream. Skipping here as well costs nothing and keeps this
149        // function correct on its own.
150        if signature.algorithm != SIGNATURE_ALGORITHM {
151            return false;
152        }
153        // An unknown key id is not necessarily an attack: it is a key this caller does not carry.
154        let Some(key) = trusted
155            .iter()
156            .find(|candidate| candidate.key_id == signature.key_id)
157        else {
158            return false;
159        };
160        let Some(pem) = key.public_key_pem.as_deref() else {
161            return false;
162        };
163        let Ok(verifying_key) = VerifyingKey::from_public_key_pem(pem) else {
164            return false;
165        };
166        let Ok(raw_signature) = BASE64.decode(&signature.signature_base64) else {
167            return false;
168        };
169        let Ok(parsed) = Signature::from_slice(&raw_signature) else {
170            return false;
171        };
172        // `verify_strict` rather than `verify`: it additionally refuses a small-order public key and
173        // a non-canonical signature component. No compliant signer can produce either, so this can
174        // only diverge from the Node and Python consumers on inputs an honest signer never emits —
175        // and on those, refusing is the correct answer.
176        verifying_key.verify_strict(&bytes, &parsed).is_ok()
177    });
178    if !verified {
179        fail!("Document has no valid signature from a trusted ed25519 key.");
180    }
181
182    let value: serde_json::Value = serde_json::from_slice(&bytes)
183        .map_err(|error| Error::new(format!("Invalid signed JSON payload: {error}")))?;
184    if !value.is_object() {
185        fail!("Invalid signed JSON payload: expected an object.");
186    }
187    Ok(VerifiedPayload { bytes, value })
188}
189
190/// Verifies a signed document against anchors from either source.
191///
192/// The one to reach for when the document is not a release — a channel or a revocations document
193/// has no consumer entry point of its own, and this keeps it on the same trust resolution.
194///
195/// # Errors
196///
197/// See [`TrustAnchors::resolve`] and [`verify_signed_document`].
198pub fn verify_signed_document_with_anchors(
199    document: &SignedDocument,
200    trust: TrustAnchors<'_>,
201) -> Result<VerifiedPayload> {
202    verify_signed_document(document, &trust.resolve()?)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::{
208        load_trusted_keys, parse_trusted_keys, verify_signed_document,
209        verify_signed_document_with_anchors, TrustAnchors, TrustedKey,
210    };
211    use crate::contract::documents::SignedDocument;
212
213    // A real signature, not a mock. The pair was generated with the same `node:crypto` calls
214    // `scrollcase keygen` uses, the payload was signed with `sign(null, bytes, privateKey)` exactly
215    // as `signWithLocalKey` does, and the private half was discarded with that process. Verifying a
216    // hand-built fixture would only prove this crate agrees with itself; this proves it agrees with
217    // the signer whose documents it exists to read.
218    const PUBLIC_PEM: &str = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA08Lm8cd7zGmEO16cmcblXzdpWYq6KMrg1yZ/Nfzj1VI=\n-----END PUBLIC KEY-----\n";
219    const PAYLOAD: &[u8] = br#"{"schemaVersion":2,"kind":"scrollcase.box.release"}"#;
220    const SIGNATURE_BASE64: &str =
221        "UjyCeFBV9egneyDRuUL/7HcUjQrgcbknsjeUW5inXM1/gynilfzdi7O/YoMQxrqC0FDgPkGpXeK56j0rosTBCQ==";
222
223    fn trusted() -> Vec<TrustedKey> {
224        vec![TrustedKey {
225            key_id: "fixture-key".to_string(),
226            public_key_pem: Some(PUBLIC_PEM.to_string()),
227        }]
228    }
229
230    fn document(signature_base64: &str, key_id: &str) -> SignedDocument {
231        use base64::engine::general_purpose::STANDARD as BASE64;
232        use base64::Engine as _;
233        serde_json::from_value(serde_json::json!({
234            "schemaVersion": 2,
235            "payloadEncoding": "base64-json-utf8",
236            "payloadBase64": BASE64.encode(PAYLOAD),
237            "payloadSha256": crate::contract::documents::sha256_hex(PAYLOAD),
238            "signatures": [{
239                "algorithm": "ed25519",
240                "keyId": key_id,
241                "signatureBase64": signature_base64,
242            }],
243        }))
244        .unwrap()
245    }
246
247    #[test]
248    fn a_genuine_signature_verifies() {
249        let payload = verify_signed_document(&document(SIGNATURE_BASE64, "fixture-key"), &trusted())
250            .expect("the pinned signature must verify");
251        assert_eq!(payload.bytes, PAYLOAD);
252        assert_eq!(payload.value["kind"], "scrollcase.box.release");
253    }
254
255    #[test]
256    fn a_signature_from_an_untrusted_key_is_refused() {
257        let error = verify_signed_document(&document(SIGNATURE_BASE64, "someone-else"), &trusted())
258            .unwrap_err();
259        assert!(error.message().contains("no valid signature"), "{error}");
260    }
261
262    #[test]
263    fn a_tampered_signature_is_refused() {
264        use base64::engine::general_purpose::STANDARD as BASE64;
265        use base64::Engine as _;
266        let mut raw = BASE64.decode(SIGNATURE_BASE64).unwrap();
267        raw[0] ^= 0x01;
268        let error =
269            verify_signed_document(&document(&BASE64.encode(raw), "fixture-key"), &trusted())
270                .unwrap_err();
271        assert!(error.message().contains("no valid signature"), "{error}");
272    }
273
274    #[test]
275    fn a_document_verifies_identically_from_a_file_and_from_compiled_in_bytes() {
276        // The bytes an application would reach `include_str!` for. Both anchors are built from this
277        // one value, so the test can only pass if the two sources agree on how to read it.
278        let embedded = format!(
279            r#"{{"keys":[{{"keyId":"fixture-key","publicKeyPem":{}}}]}}"#,
280            serde_json::to_string(PUBLIC_PEM).unwrap()
281        );
282        let signed = document(SIGNATURE_BASE64, "fixture-key");
283
284        let keys = parse_trusted_keys(embedded.as_bytes()).expect("a bundle must parse");
285        let in_memory = verify_signed_document_with_anchors(&signed, TrustAnchors::Keys(&keys))
286            .expect("compiled-in anchors must verify");
287
288        let directory = std::env::temp_dir().join(format!(
289            "scrollcase-anchors-{}",
290            std::time::SystemTime::now()
291                .duration_since(std::time::UNIX_EPOCH)
292                .unwrap()
293                .as_nanos()
294        ));
295        std::fs::create_dir_all(&directory).unwrap();
296        let path = directory.join("trusted-keys.json");
297        std::fs::write(&path, embedded.as_bytes()).unwrap();
298
299        let from_file = verify_signed_document_with_anchors(&signed, TrustAnchors::KeyFile(&path))
300            .expect("the same bytes as a file must verify");
301        assert_eq!(in_memory.bytes, from_file.bytes);
302
303        // And the in-memory source is genuinely checking: an anchor set without the signing key
304        // refuses the document a moment after the same call accepted it.
305        let stranger = parse_trusted_keys(
306            format!(
307                r#"{{"keys":[{{"keyId":"someone-else","publicKeyPem":{}}}]}}"#,
308                serde_json::to_string(PUBLIC_PEM).unwrap()
309            )
310            .as_bytes(),
311        )
312        .unwrap();
313        let error = verify_signed_document_with_anchors(&signed, TrustAnchors::Keys(&stranger))
314            .unwrap_err();
315        assert!(error.message().contains("no valid signature"), "{error}");
316
317        std::fs::remove_dir_all(directory).unwrap();
318    }
319
320    #[test]
321    fn both_trust_file_shapes_load() {
322        let directory = std::env::temp_dir().join(format!(
323            "scrollcase-trust-{}",
324            std::time::SystemTime::now()
325                .duration_since(std::time::UNIX_EPOCH)
326                .unwrap()
327                .as_nanos()
328        ));
329        std::fs::create_dir_all(&directory).unwrap();
330
331        let single = directory.join("single.json");
332        std::fs::write(
333            &single,
334            serde_json::to_vec(&serde_json::json!({
335                "algorithm": "ed25519",
336                "keyId": "one",
337                "publicKeyBase64": "ignored-by-every-consumer",
338                "publicKeyPem": PUBLIC_PEM,
339            }))
340            .unwrap(),
341        )
342        .unwrap();
343        let keys = load_trusted_keys(&single).unwrap();
344        assert_eq!(keys.len(), 1);
345        assert_eq!(keys[0].key_id, "one");
346
347        let bundle = directory.join("bundle.json");
348        std::fs::write(
349            &bundle,
350            serde_json::to_vec(&serde_json::json!({
351                "keys": [
352                    { "keyId": "outgoing", "publicKeyPem": PUBLIC_PEM },
353                    { "keyId": "incoming", "publicKeyPem": PUBLIC_PEM },
354                ],
355            }))
356            .unwrap(),
357        )
358        .unwrap();
359        // A bundle is what makes rotation possible: both keys are trusted at once.
360        assert_eq!(load_trusted_keys(&bundle).unwrap().len(), 2);
361
362        let malformed = directory.join("malformed.json");
363        std::fs::write(&malformed, b"[]").unwrap();
364        assert!(load_trusted_keys(&malformed).is_err());
365
366        std::fs::remove_dir_all(directory).unwrap();
367    }
368}