scrollcase_consumer/
trust.rs1use 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#[derive(Debug, Clone, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct TrustedKey {
32 pub key_id: String,
34 #[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#[derive(Debug, Clone)]
48pub struct VerifiedPayload {
49 pub bytes: Vec<u8>,
51 pub value: serde_json::Value,
53}
54
55pub 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
74pub 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 if signature.algorithm != SIGNATURE_ALGORITHM {
98 return false;
99 }
100 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 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
137pub 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 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 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}