Skip to main content

scrollcase_consumer/contract/
documents.rs

1//! Mirror of the Scrollcase signed-document envelope.
2//!
3//! Signed documents carry their payload as exact base64-encoded JSON rather than canonicalized JSON.
4//! That choice is what makes a third implementation possible at all: verifying a signature means
5//! hashing bytes that were transmitted verbatim, so Node, Python and this crate agree without each
6//! maintaining a canonical-JSON implementation — historically the richest source of cross-language
7//! signature bugs.
8//!
9//! Decoding is deliberately separate from verifying. This module unwraps an envelope and proves the
10//! payload bytes are the ones the envelope names; it never decides that they are authentic. Nothing
11//! here reads a key, and the decoded bytes stay bytes: a caller that deserialises them before a
12//! signature has verified has skipped the only step that matters.
13
14use base64::engine::general_purpose::STANDARD as BASE64;
15use base64::Engine as _;
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::error::{fail, Result};
20
21/// Format version carried by every document this contract describes.
22pub const BOX_SCHEMA_VERSION: u32 = 2;
23
24/// The only payload encoding the format defines.
25pub const PAYLOAD_ENCODING: &str = "base64-json-utf8";
26
27/// The only signature algorithm the format defines.
28pub const SIGNATURE_ALGORITHM: &str = "ed25519";
29
30/// Namespace prefixing every document's `kind` discriminator.
31///
32/// A project that already publishes boxes owns its own namespace and must keep emitting it, or its
33/// installed clients stop recognising the documents they are handed. The namespace is therefore the
34/// publishing project's to declare, not the tool's to impose; this is only the default used by a
35/// project with no published history to preserve.
36pub const DEFAULT_DOCUMENT_NAMESPACE: &str = "scrollcase.box";
37
38/// The three document types the format defines.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum DocumentType {
41    /// The immutable description of one built box.
42    Release,
43    /// The releases a channel currently offers.
44    Channel,
45    /// The kill-list withdrawing a published release.
46    Revocations,
47}
48
49impl DocumentType {
50    /// The suffix this type carries in a `kind` string.
51    #[must_use]
52    pub fn as_str(self) -> &'static str {
53        match self {
54            Self::Release => "release",
55            Self::Channel => "channel",
56            Self::Revocations => "revocations",
57        }
58    }
59
60    fn parse(value: &str) -> Option<Self> {
61        match value {
62            "release" => Some(Self::Release),
63            "channel" => Some(Self::Channel),
64            "revocations" => Some(Self::Revocations),
65            _ => None,
66        }
67    }
68}
69
70/// A `kind` split back into the namespace that published it and the type it names.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ParsedDocumentKind {
73    /// The publishing project's namespace.
74    pub namespace: String,
75    /// The document type.
76    pub document_type: DocumentType,
77}
78
79/// Whether a namespace is the dotted lowercase identifier the format allows.
80///
81/// The pattern is `^[a-z0-9]+(?:[.-][a-z0-9]+)*$`, checked by hand so the crate does not carry a
82/// regex engine to answer one question.
83fn is_document_namespace(value: &str) -> bool {
84    if value.is_empty() {
85        return false;
86    }
87    let mut group_is_empty = true;
88    for character in value.chars() {
89        match character {
90            'a'..='z' | '0'..='9' => group_is_empty = false,
91            '.' | '-' if !group_is_empty => group_is_empty = true,
92            _ => return false,
93        }
94    }
95    !group_is_empty
96}
97
98/// Returns the `kind` discriminator for one document type under a namespace.
99///
100/// # Errors
101///
102/// When the namespace is not a dotted lowercase identifier.
103pub fn document_kind(namespace: &str, document_type: DocumentType) -> Result<String> {
104    if !is_document_namespace(namespace) {
105        fail!("Invalid document namespace: {namespace}");
106    }
107    Ok(format!("{namespace}.{}", document_type.as_str()))
108}
109
110/// Splits a `kind` back into its namespace and document type.
111///
112/// Returns `None` when the value is not a document kind at all.
113#[must_use]
114pub fn parse_document_kind(kind: &str) -> Option<ParsedDocumentKind> {
115    let separator = kind.rfind('.')?;
116    if separator == 0 {
117        return None;
118    }
119    let namespace = &kind[..separator];
120    let document_type = DocumentType::parse(&kind[separator + 1..])?;
121    if !is_document_namespace(namespace) {
122        return None;
123    }
124    Some(ParsedDocumentKind {
125        namespace: namespace.to_string(),
126        document_type,
127    })
128}
129
130/// One signature over a document's payload bytes.
131#[derive(Debug, Clone, Deserialize, Serialize)]
132#[serde(rename_all = "camelCase", deny_unknown_fields)]
133pub struct DocumentSignature {
134    /// Signature algorithm; the format defines only `ed25519`.
135    pub algorithm: String,
136    /// Identifier of the key that produced this signature.
137    pub key_id: String,
138    /// The signature itself.
139    pub signature_base64: String,
140}
141
142/// The signing envelope shared by every control document.
143#[derive(Debug, Clone, Deserialize, Serialize)]
144#[serde(rename_all = "camelCase", deny_unknown_fields)]
145pub struct SignedDocument {
146    /// Format version of the envelope.
147    pub schema_version: u32,
148    /// How the payload is encoded.
149    pub payload_encoding: String,
150    /// The payload, exactly as it was signed and published.
151    pub payload_base64: String,
152    /// SHA-256 of the decoded payload bytes.
153    pub payload_sha256: String,
154    /// Every signature offered; the document is accepted when any one of them verifies.
155    pub signatures: Vec<DocumentSignature>,
156}
157
158/// Lowercase hex SHA-256, matching the encoding the manifests use.
159pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
160    let digest = Sha256::digest(bytes);
161    let mut hex = String::with_capacity(digest.len() * 2);
162    for byte in digest {
163        use std::fmt::Write as _;
164        let _ = write!(hex, "{byte:02x}");
165    }
166    hex
167}
168
169impl SignedDocument {
170    /// Parses an envelope without verifying anything about it.
171    ///
172    /// # Errors
173    ///
174    /// When the bytes are not a structurally valid envelope.
175    pub fn parse(bytes: &[u8]) -> Result<Self> {
176        // Read the version before the typed parse, so a v1 document is refused by name instead of
177        // producing a shape complaint that hides why it was rejected.
178        if let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) {
179            if value.get("schemaVersion").and_then(serde_json::Value::as_u64) == Some(1) {
180                fail!("Unsupported schemaVersion 1; rebuild this box with Scrollcase v2.");
181            }
182        }
183        serde_json::from_slice(bytes)
184            .map_err(|error| crate::error::Error::new(format!("Invalid signed document: {error}.")))
185    }
186
187    /// Unwraps the envelope and checks its checksum. Does **not** check any signature.
188    ///
189    /// # Errors
190    ///
191    /// When the envelope is unsupported, or the payload bytes do not hash to the value it names.
192    pub fn decode_payload(&self) -> Result<Vec<u8>> {
193        if self.schema_version == 1 {
194            fail!("Unsupported schemaVersion 1; rebuild this box with Scrollcase v2.");
195        }
196        if self.schema_version != BOX_SCHEMA_VERSION || self.payload_encoding != PAYLOAD_ENCODING {
197            fail!("Unsupported signed document.");
198        }
199        let Ok(bytes) = BASE64.decode(&self.payload_base64) else {
200            fail!("Signed payload SHA-256 mismatch.");
201        };
202        if sha256_hex(&bytes) != self.payload_sha256 {
203            fail!("Signed payload SHA-256 mismatch.");
204        }
205        Ok(bytes)
206    }
207
208    /// Whether the envelope is well formed enough to be worth verifying.
209    ///
210    /// A shape check, never a verification: it says the document deserves an attempt, not that its
211    /// signature is good.
212    #[must_use]
213    pub fn is_well_formed(&self) -> bool {
214        self.schema_version == BOX_SCHEMA_VERSION
215            && self.payload_encoding == PAYLOAD_ENCODING
216            && !self.signatures.is_empty()
217            && self
218                .signatures
219                .iter()
220                .all(|signature| signature.algorithm == SIGNATURE_ALGORITHM)
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::{
227        document_kind, is_document_namespace, parse_document_kind, DocumentType, SignedDocument,
228        DEFAULT_DOCUMENT_NAMESPACE,
229    };
230    use base64::engine::general_purpose::STANDARD as BASE64;
231    use base64::Engine as _;
232
233    fn envelope(payload: &[u8], sha256: &str) -> SignedDocument {
234        serde_json::from_value(serde_json::json!({
235            "schemaVersion": 2,
236            "payloadEncoding": "base64-json-utf8",
237            "payloadBase64": BASE64.encode(payload),
238            "payloadSha256": sha256,
239            "signatures": [{
240                "algorithm": "ed25519",
241                "keyId": "fixture",
242                "signatureBase64": BASE64.encode([0u8; 64]),
243            }],
244        }))
245        .unwrap()
246    }
247
248    #[test]
249    fn the_default_namespace_round_trips() {
250        let kind = document_kind(DEFAULT_DOCUMENT_NAMESPACE, DocumentType::Release).unwrap();
251        assert_eq!(kind, "scrollcase.box.release");
252        let parsed = parse_document_kind(&kind).unwrap();
253        assert_eq!(parsed.namespace, DEFAULT_DOCUMENT_NAMESPACE);
254        assert_eq!(parsed.document_type, DocumentType::Release);
255    }
256
257    #[test]
258    fn a_publishers_own_namespace_is_preserved_verbatim() {
259        // The whole point of the namespace rule: a project with boxes in the field keeps emitting
260        // the kind its installed clients recognise, and this crate never substitutes its own.
261        let parsed = parse_document_kind("acme.runtime-box.release").unwrap();
262        assert_eq!(parsed.namespace, "acme.runtime-box");
263        assert_eq!(parsed.document_type, DocumentType::Release);
264    }
265
266    #[test]
267    fn namespaces_outside_the_pattern_are_refused() {
268        for invalid in ["", ".", "a.", ".a", "a..b", "A.b", "a_b", "a b", "a-", "-a"] {
269            assert!(!is_document_namespace(invalid), "{invalid} was accepted");
270            assert!(document_kind(invalid, DocumentType::Release).is_err());
271        }
272        for invalid in ["release", "scrollcase.box", "scrollcase.box.unknown", ".release"] {
273            assert!(parse_document_kind(invalid).is_none(), "{invalid} parsed");
274        }
275    }
276
277    #[test]
278    fn a_v1_envelope_is_refused_by_name() {
279        let error = SignedDocument::parse(br#"{"schemaVersion":1}"#).unwrap_err();
280        assert!(error.message().contains("Unsupported schemaVersion 1"), "{error}");
281    }
282
283    #[test]
284    fn an_edited_payload_fails_its_own_checksum() {
285        let payload = br#"{"schemaVersion":2}"#;
286        let good = super::sha256_hex(payload);
287        assert_eq!(envelope(payload, &good).decode_payload().unwrap(), payload);
288
289        let error = envelope(b"tampered", &good).decode_payload().unwrap_err();
290        assert!(error.message().contains("Signed payload SHA-256 mismatch"), "{error}");
291    }
292
293    #[test]
294    fn an_unknown_field_is_not_silently_ignored() {
295        // serde's default is to skip unknown fields. The signed-document schema sets
296        // additionalProperties:false, so accepting one here would make this crate agree to
297        // documents Node and Python reject.
298        let raw = br#"{"schemaVersion":2,"payloadEncoding":"base64-json-utf8",
299            "payloadBase64":"","payloadSha256":"","signatures":[],"extra":1}"#;
300        assert!(SignedDocument::parse(raw).is_err());
301    }
302}