Skip to main content

scrollcase_consumer/
trust.rs

1//! Trusted keys, and the signature check every consumer path begins with.
2//!
3//! A trust anchor is a file the *caller* names. This crate never discovers a key, never fetches one,
4//! and never treats a key shipped beside an archive as trusted because it arrived: whoever calls
5//! decides which public keys they accept, and that decision is the whole basis of every guarantee
6//! below it.
7//!
8//! A document is accepted when **any one** of its signatures verifies against a trusted key. That is
9//! what lets a document signed by both an outgoing and an incoming key stay valid across a rotation,
10//! and it is why a signature naming an unknown key is skipped rather than treated as an attack — a
11//! build that carries only one of the two keys must still be able to verify.
12
13use std::path::Path;
14
15use base64::engine::general_purpose::STANDARD as BASE64;
16use base64::Engine as _;
17use ed25519_dalek::pkcs8::DecodePublicKey as _;
18use ed25519_dalek::{Signature, VerifyingKey};
19use serde::Deserialize;
20
21use crate::contract::documents::{sha256_hex, SignedDocument, SIGNATURE_ALGORITHM};
22use crate::error::{fail, Error, Result};
23
24/// One public key a caller is willing to accept signatures from.
25///
26/// Deserialised leniently on purpose: a trust file is the caller's own artefact, not a document the
27/// box format governs, and `keygen` writes a `publicKeyBase64` beside the PEM that this crate — like
28/// the Node and Python consumers — does not read.
29#[derive(Debug, Clone, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct TrustedKey {
32    /// Identifier a signature names to select this key.
33    pub key_id: String,
34    /// The key itself, SPKI PEM. A key without one is skipped rather than rejected.
35    #[serde(default)]
36    pub public_key_pem: Option<String>,
37}
38
39#[derive(Deserialize)]
40#[serde(untagged)]
41enum TrustFile {
42    Bundle { keys: Vec<TrustedKey> },
43    Single(TrustedKey),
44}
45
46/// The payload of a document whose signature has verified.
47#[derive(Debug, Clone)]
48pub struct VerifiedPayload {
49    /// The exact bytes that were signed, which are also the bytes that were published.
50    pub bytes: Vec<u8>,
51    /// Those bytes parsed as JSON — only ever produced after a signature has verified.
52    pub value: serde_json::Value,
53}
54
55/// Reads a trust file holding either a single key or a `{ "keys": [...] }` bundle.
56///
57/// # Errors
58///
59/// When the file cannot be read or is not one of the two shapes.
60pub fn load_trusted_keys(path: &Path) -> Result<Vec<TrustedKey>> {
61    let raw = std::fs::read(path).map_err(|error| {
62        Error::new(format!(
63            "Invalid trusted ed25519 key file {}: {error}",
64            path.display()
65        ))
66    })?;
67    match serde_json::from_slice::<TrustFile>(&raw) {
68        Ok(TrustFile::Bundle { keys }) => Ok(keys),
69        Ok(TrustFile::Single(key)) => Ok(vec![key]),
70        Err(_) => Err(Error::new("Invalid trusted ed25519 key file.")),
71    }
72}
73
74/// Verifies a signed document against a set of trusted keys and returns its payload.
75///
76/// The payload is checksummed first — cheap, and it catches truncation — and is parsed only once a
77/// signature has verified, so no attacker-controlled JSON ever reaches a typed deserialiser on the
78/// strength of the envelope alone.
79///
80/// # Errors
81///
82/// When the payload does not match its checksum, when no signature verifies against a trusted key,
83/// or when the verified bytes are not a JSON object.
84pub fn verify_signed_document(
85    document: &SignedDocument,
86    trusted: &[TrustedKey],
87) -> Result<VerifiedPayload> {
88    let bytes = document.decode_payload()?;
89    if sha256_hex(&bytes) != document.payload_sha256 {
90        fail!("Signed payload SHA-256 mismatch.");
91    }
92
93    let verified = document.signatures.iter().any(|signature| {
94        // The signed-document schema pins the algorithm to ed25519, so a document naming another one
95        // is already refused by name upstream. Skipping here as well costs nothing and keeps this
96        // function correct on its own.
97        if signature.algorithm != SIGNATURE_ALGORITHM {
98            return false;
99        }
100        // An unknown key id is not necessarily an attack: it is a key this caller does not carry.
101        let Some(key) = trusted
102            .iter()
103            .find(|candidate| candidate.key_id == signature.key_id)
104        else {
105            return false;
106        };
107        let Some(pem) = key.public_key_pem.as_deref() else {
108            return false;
109        };
110        let Ok(verifying_key) = VerifyingKey::from_public_key_pem(pem) else {
111            return false;
112        };
113        let Ok(raw_signature) = BASE64.decode(&signature.signature_base64) else {
114            return false;
115        };
116        let Ok(parsed) = Signature::from_slice(&raw_signature) else {
117            return false;
118        };
119        // `verify_strict` rather than `verify`: it additionally refuses a small-order public key and
120        // a non-canonical signature component. No compliant signer can produce either, so this can
121        // only diverge from the Node and Python consumers on inputs an honest signer never emits —
122        // and on those, refusing is the correct answer.
123        verifying_key.verify_strict(&bytes, &parsed).is_ok()
124    });
125    if !verified {
126        fail!("Document has no valid signature from a trusted ed25519 key.");
127    }
128
129    let value: serde_json::Value = serde_json::from_slice(&bytes)
130        .map_err(|error| Error::new(format!("Invalid signed JSON payload: {error}")))?;
131    if !value.is_object() {
132        fail!("Invalid signed JSON payload: expected an object.");
133    }
134    Ok(VerifiedPayload { bytes, value })
135}
136
137/// Verifies a signed document against a trust file the caller names.
138///
139/// # Errors
140///
141/// See [`load_trusted_keys`] and [`verify_signed_document`].
142pub fn verify_signed_document_with_key_file(
143    document: &SignedDocument,
144    public_key_path: &Path,
145) -> Result<VerifiedPayload> {
146    let trusted = load_trusted_keys(public_key_path)?;
147    verify_signed_document(document, &trusted)
148}
149
150#[cfg(test)]
151mod tests {
152    use super::{load_trusted_keys, verify_signed_document, TrustedKey};
153    use crate::contract::documents::SignedDocument;
154
155    // A real signature, not a mock. The pair was generated with the same `node:crypto` calls
156    // `scrollcase keygen` uses, the payload was signed with `sign(null, bytes, privateKey)` exactly
157    // as `signWithLocalKey` does, and the private half was discarded with that process. Verifying a
158    // hand-built fixture would only prove this crate agrees with itself; this proves it agrees with
159    // the signer whose documents it exists to read.
160    const PUBLIC_PEM: &str = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA08Lm8cd7zGmEO16cmcblXzdpWYq6KMrg1yZ/Nfzj1VI=\n-----END PUBLIC KEY-----\n";
161    const PAYLOAD: &[u8] = br#"{"schemaVersion":2,"kind":"scrollcase.box.release"}"#;
162    const SIGNATURE_BASE64: &str =
163        "UjyCeFBV9egneyDRuUL/7HcUjQrgcbknsjeUW5inXM1/gynilfzdi7O/YoMQxrqC0FDgPkGpXeK56j0rosTBCQ==";
164
165    fn trusted() -> Vec<TrustedKey> {
166        vec![TrustedKey {
167            key_id: "fixture-key".to_string(),
168            public_key_pem: Some(PUBLIC_PEM.to_string()),
169        }]
170    }
171
172    fn document(signature_base64: &str, key_id: &str) -> SignedDocument {
173        use base64::engine::general_purpose::STANDARD as BASE64;
174        use base64::Engine as _;
175        serde_json::from_value(serde_json::json!({
176            "schemaVersion": 2,
177            "payloadEncoding": "base64-json-utf8",
178            "payloadBase64": BASE64.encode(PAYLOAD),
179            "payloadSha256": crate::contract::documents::sha256_hex(PAYLOAD),
180            "signatures": [{
181                "algorithm": "ed25519",
182                "keyId": key_id,
183                "signatureBase64": signature_base64,
184            }],
185        }))
186        .unwrap()
187    }
188
189    #[test]
190    fn a_genuine_signature_verifies() {
191        let payload = verify_signed_document(&document(SIGNATURE_BASE64, "fixture-key"), &trusted())
192            .expect("the pinned signature must verify");
193        assert_eq!(payload.bytes, PAYLOAD);
194        assert_eq!(payload.value["kind"], "scrollcase.box.release");
195    }
196
197    #[test]
198    fn a_signature_from_an_untrusted_key_is_refused() {
199        let error = verify_signed_document(&document(SIGNATURE_BASE64, "someone-else"), &trusted())
200            .unwrap_err();
201        assert!(error.message().contains("no valid signature"), "{error}");
202    }
203
204    #[test]
205    fn a_tampered_signature_is_refused() {
206        use base64::engine::general_purpose::STANDARD as BASE64;
207        use base64::Engine as _;
208        let mut raw = BASE64.decode(SIGNATURE_BASE64).unwrap();
209        raw[0] ^= 0x01;
210        let error =
211            verify_signed_document(&document(&BASE64.encode(raw), "fixture-key"), &trusted())
212                .unwrap_err();
213        assert!(error.message().contains("no valid signature"), "{error}");
214    }
215
216    #[test]
217    fn both_trust_file_shapes_load() {
218        let directory = std::env::temp_dir().join(format!(
219            "scrollcase-trust-{}",
220            std::time::SystemTime::now()
221                .duration_since(std::time::UNIX_EPOCH)
222                .unwrap()
223                .as_nanos()
224        ));
225        std::fs::create_dir_all(&directory).unwrap();
226
227        let single = directory.join("single.json");
228        std::fs::write(
229            &single,
230            serde_json::to_vec(&serde_json::json!({
231                "algorithm": "ed25519",
232                "keyId": "one",
233                "publicKeyBase64": "ignored-by-every-consumer",
234                "publicKeyPem": PUBLIC_PEM,
235            }))
236            .unwrap(),
237        )
238        .unwrap();
239        let keys = load_trusted_keys(&single).unwrap();
240        assert_eq!(keys.len(), 1);
241        assert_eq!(keys[0].key_id, "one");
242
243        let bundle = directory.join("bundle.json");
244        std::fs::write(
245            &bundle,
246            serde_json::to_vec(&serde_json::json!({
247                "keys": [
248                    { "keyId": "outgoing", "publicKeyPem": PUBLIC_PEM },
249                    { "keyId": "incoming", "publicKeyPem": PUBLIC_PEM },
250                ],
251            }))
252            .unwrap(),
253        )
254        .unwrap();
255        // A bundle is what makes rotation possible: both keys are trusted at once.
256        assert_eq!(load_trusted_keys(&bundle).unwrap().len(), 2);
257
258        let malformed = directory.join("malformed.json");
259        std::fs::write(&malformed, b"[]").unwrap();
260        assert!(load_trusted_keys(&malformed).is_err());
261
262        std::fs::remove_dir_all(directory).unwrap();
263    }
264}