use std::borrow::Cow;
use std::path::Path;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use ed25519_dalek::pkcs8::DecodePublicKey as _;
use ed25519_dalek::{Signature, VerifyingKey};
use serde::Deserialize;
use crate::contract::documents::{sha256_hex, SignedDocument, SIGNATURE_ALGORITHM};
use crate::error::{fail, Error, Result};
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrustedKey {
pub key_id: String,
#[serde(default)]
pub public_key_pem: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum TrustFile {
Bundle { keys: Vec<TrustedKey> },
Single(TrustedKey),
}
#[derive(Debug, Clone)]
pub struct VerifiedPayload {
pub bytes: Vec<u8>,
pub value: serde_json::Value,
}
pub fn load_trusted_keys(path: &Path) -> Result<Vec<TrustedKey>> {
let raw = std::fs::read(path).map_err(|error| {
Error::new(format!(
"Invalid trusted ed25519 key file {}: {error}",
path.display()
))
})?;
parse_trusted_keys(&raw)
}
pub fn parse_trusted_keys(raw: &[u8]) -> Result<Vec<TrustedKey>> {
match serde_json::from_slice::<TrustFile>(raw) {
Ok(TrustFile::Bundle { keys }) => Ok(keys),
Ok(TrustFile::Single(key)) => Ok(vec![key]),
Err(_) => Err(Error::new("Invalid trusted ed25519 key file.")),
}
}
#[derive(Debug, Clone, Copy)]
pub enum TrustAnchors<'a> {
KeyFile(&'a Path),
Keys(&'a [TrustedKey]),
}
impl<'a> TrustAnchors<'a> {
pub fn resolve(&self) -> Result<Cow<'a, [TrustedKey]>> {
match *self {
Self::KeyFile(path) => Ok(Cow::Owned(load_trusted_keys(path)?)),
Self::Keys(keys) => Ok(Cow::Borrowed(keys)),
}
}
}
pub fn verify_signed_document(
document: &SignedDocument,
trusted: &[TrustedKey],
) -> Result<VerifiedPayload> {
let bytes = document.decode_payload()?;
if sha256_hex(&bytes) != document.payload_sha256 {
fail!("Signed payload SHA-256 mismatch.");
}
let verified = document.signatures.iter().any(|signature| {
if signature.algorithm != SIGNATURE_ALGORITHM {
return false;
}
let Some(key) = trusted
.iter()
.find(|candidate| candidate.key_id == signature.key_id)
else {
return false;
};
let Some(pem) = key.public_key_pem.as_deref() else {
return false;
};
let Ok(verifying_key) = VerifyingKey::from_public_key_pem(pem) else {
return false;
};
let Ok(raw_signature) = BASE64.decode(&signature.signature_base64) else {
return false;
};
let Ok(parsed) = Signature::from_slice(&raw_signature) else {
return false;
};
verifying_key.verify_strict(&bytes, &parsed).is_ok()
});
if !verified {
fail!("Document has no valid signature from a trusted ed25519 key.");
}
let value: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|error| Error::new(format!("Invalid signed JSON payload: {error}")))?;
if !value.is_object() {
fail!("Invalid signed JSON payload: expected an object.");
}
Ok(VerifiedPayload { bytes, value })
}
pub fn verify_signed_document_with_anchors(
document: &SignedDocument,
trust: TrustAnchors<'_>,
) -> Result<VerifiedPayload> {
verify_signed_document(document, &trust.resolve()?)
}
#[cfg(test)]
mod tests {
use super::{
load_trusted_keys, parse_trusted_keys, verify_signed_document,
verify_signed_document_with_anchors, TrustAnchors, TrustedKey,
};
use crate::contract::documents::SignedDocument;
const PUBLIC_PEM: &str = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA08Lm8cd7zGmEO16cmcblXzdpWYq6KMrg1yZ/Nfzj1VI=\n-----END PUBLIC KEY-----\n";
const PAYLOAD: &[u8] = br#"{"schemaVersion":2,"kind":"scrollcase.box.release"}"#;
const SIGNATURE_BASE64: &str =
"UjyCeFBV9egneyDRuUL/7HcUjQrgcbknsjeUW5inXM1/gynilfzdi7O/YoMQxrqC0FDgPkGpXeK56j0rosTBCQ==";
fn trusted() -> Vec<TrustedKey> {
vec![TrustedKey {
key_id: "fixture-key".to_string(),
public_key_pem: Some(PUBLIC_PEM.to_string()),
}]
}
fn document(signature_base64: &str, key_id: &str) -> SignedDocument {
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
serde_json::from_value(serde_json::json!({
"schemaVersion": 2,
"payloadEncoding": "base64-json-utf8",
"payloadBase64": BASE64.encode(PAYLOAD),
"payloadSha256": crate::contract::documents::sha256_hex(PAYLOAD),
"signatures": [{
"algorithm": "ed25519",
"keyId": key_id,
"signatureBase64": signature_base64,
}],
}))
.unwrap()
}
#[test]
fn a_genuine_signature_verifies() {
let payload = verify_signed_document(&document(SIGNATURE_BASE64, "fixture-key"), &trusted())
.expect("the pinned signature must verify");
assert_eq!(payload.bytes, PAYLOAD);
assert_eq!(payload.value["kind"], "scrollcase.box.release");
}
#[test]
fn a_signature_from_an_untrusted_key_is_refused() {
let error = verify_signed_document(&document(SIGNATURE_BASE64, "someone-else"), &trusted())
.unwrap_err();
assert!(error.message().contains("no valid signature"), "{error}");
}
#[test]
fn a_tampered_signature_is_refused() {
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
let mut raw = BASE64.decode(SIGNATURE_BASE64).unwrap();
raw[0] ^= 0x01;
let error =
verify_signed_document(&document(&BASE64.encode(raw), "fixture-key"), &trusted())
.unwrap_err();
assert!(error.message().contains("no valid signature"), "{error}");
}
#[test]
fn a_document_verifies_identically_from_a_file_and_from_compiled_in_bytes() {
let embedded = format!(
r#"{{"keys":[{{"keyId":"fixture-key","publicKeyPem":{}}}]}}"#,
serde_json::to_string(PUBLIC_PEM).unwrap()
);
let signed = document(SIGNATURE_BASE64, "fixture-key");
let keys = parse_trusted_keys(embedded.as_bytes()).expect("a bundle must parse");
let in_memory = verify_signed_document_with_anchors(&signed, TrustAnchors::Keys(&keys))
.expect("compiled-in anchors must verify");
let directory = std::env::temp_dir().join(format!(
"scrollcase-anchors-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&directory).unwrap();
let path = directory.join("trusted-keys.json");
std::fs::write(&path, embedded.as_bytes()).unwrap();
let from_file = verify_signed_document_with_anchors(&signed, TrustAnchors::KeyFile(&path))
.expect("the same bytes as a file must verify");
assert_eq!(in_memory.bytes, from_file.bytes);
let stranger = parse_trusted_keys(
format!(
r#"{{"keys":[{{"keyId":"someone-else","publicKeyPem":{}}}]}}"#,
serde_json::to_string(PUBLIC_PEM).unwrap()
)
.as_bytes(),
)
.unwrap();
let error = verify_signed_document_with_anchors(&signed, TrustAnchors::Keys(&stranger))
.unwrap_err();
assert!(error.message().contains("no valid signature"), "{error}");
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn both_trust_file_shapes_load() {
let directory = std::env::temp_dir().join(format!(
"scrollcase-trust-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&directory).unwrap();
let single = directory.join("single.json");
std::fs::write(
&single,
serde_json::to_vec(&serde_json::json!({
"algorithm": "ed25519",
"keyId": "one",
"publicKeyBase64": "ignored-by-every-consumer",
"publicKeyPem": PUBLIC_PEM,
}))
.unwrap(),
)
.unwrap();
let keys = load_trusted_keys(&single).unwrap();
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].key_id, "one");
let bundle = directory.join("bundle.json");
std::fs::write(
&bundle,
serde_json::to_vec(&serde_json::json!({
"keys": [
{ "keyId": "outgoing", "publicKeyPem": PUBLIC_PEM },
{ "keyId": "incoming", "publicKeyPem": PUBLIC_PEM },
],
}))
.unwrap(),
)
.unwrap();
assert_eq!(load_trusted_keys(&bundle).unwrap().len(), 2);
let malformed = directory.join("malformed.json");
std::fs::write(&malformed, b"[]").unwrap();
assert!(load_trusted_keys(&malformed).is_err());
std::fs::remove_dir_all(directory).unwrap();
}
}