1use 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#[derive(Debug, Clone, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct TrustedKey {
43 pub key_id: String,
45 #[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#[derive(Debug, Clone)]
59pub struct VerifiedPayload {
60 pub bytes: Vec<u8>,
62 pub value: serde_json::Value,
64}
65
66pub 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
81pub 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#[derive(Debug, Clone, Copy)]
106pub enum TrustAnchors<'a> {
107 KeyFile(&'a Path),
109 Keys(&'a [TrustedKey]),
111}
112
113impl<'a> TrustAnchors<'a> {
114 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
127pub 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 if signature.algorithm != SIGNATURE_ALGORITHM {
151 return false;
152 }
153 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 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
190pub 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 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 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 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 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}