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()
))
})?;
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.")),
}
}
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_key_file(
document: &SignedDocument,
public_key_path: &Path,
) -> Result<VerifiedPayload> {
let trusted = load_trusted_keys(public_key_path)?;
verify_signed_document(document, &trusted)
}
#[cfg(test)]
mod tests {
use super::{load_trusted_keys, verify_signed_document, 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 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();
}
}