scrollcase_consumer/contract/
documents.rs1use 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
21pub const BOX_SCHEMA_VERSION: u32 = 2;
23
24pub const PAYLOAD_ENCODING: &str = "base64-json-utf8";
26
27pub const SIGNATURE_ALGORITHM: &str = "ed25519";
29
30pub const DEFAULT_DOCUMENT_NAMESPACE: &str = "scrollcase.box";
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum DocumentType {
41 Release,
43 Channel,
45 Revocations,
47}
48
49impl DocumentType {
50 #[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#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ParsedDocumentKind {
73 pub namespace: String,
75 pub document_type: DocumentType,
77}
78
79fn 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
98pub 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#[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#[derive(Debug, Clone, Deserialize, Serialize)]
132#[serde(rename_all = "camelCase", deny_unknown_fields)]
133pub struct DocumentSignature {
134 pub algorithm: String,
136 pub key_id: String,
138 pub signature_base64: String,
140}
141
142#[derive(Debug, Clone, Deserialize, Serialize)]
144#[serde(rename_all = "camelCase", deny_unknown_fields)]
145pub struct SignedDocument {
146 pub schema_version: u32,
148 pub payload_encoding: String,
150 pub payload_base64: String,
152 pub payload_sha256: String,
154 pub signatures: Vec<DocumentSignature>,
156}
157
158pub(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 pub fn parse(bytes: &[u8]) -> Result<Self> {
176 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 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 #[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 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 let raw = br#"{"schemaVersion":2,"payloadEncoding":"base64-json-utf8",
299 "payloadBase64":"","payloadSha256":"","signatures":[],"extra":1}"#;
300 assert!(SignedDocument::parse(raw).is_err());
301 }
302}