use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::{fail, Result};
pub const BOX_SCHEMA_VERSION: u32 = 2;
pub const PAYLOAD_ENCODING: &str = "base64-json-utf8";
pub const SIGNATURE_ALGORITHM: &str = "ed25519";
pub const DEFAULT_DOCUMENT_NAMESPACE: &str = "scrollcase.box";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocumentType {
Release,
Channel,
Revocations,
}
impl DocumentType {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Release => "release",
Self::Channel => "channel",
Self::Revocations => "revocations",
}
}
fn parse(value: &str) -> Option<Self> {
match value {
"release" => Some(Self::Release),
"channel" => Some(Self::Channel),
"revocations" => Some(Self::Revocations),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedDocumentKind {
pub namespace: String,
pub document_type: DocumentType,
}
fn is_document_namespace(value: &str) -> bool {
if value.is_empty() {
return false;
}
let mut group_is_empty = true;
for character in value.chars() {
match character {
'a'..='z' | '0'..='9' => group_is_empty = false,
'.' | '-' if !group_is_empty => group_is_empty = true,
_ => return false,
}
}
!group_is_empty
}
pub fn document_kind(namespace: &str, document_type: DocumentType) -> Result<String> {
if !is_document_namespace(namespace) {
fail!("Invalid document namespace: {namespace}");
}
Ok(format!("{namespace}.{}", document_type.as_str()))
}
#[must_use]
pub fn parse_document_kind(kind: &str) -> Option<ParsedDocumentKind> {
let separator = kind.rfind('.')?;
if separator == 0 {
return None;
}
let namespace = &kind[..separator];
let document_type = DocumentType::parse(&kind[separator + 1..])?;
if !is_document_namespace(namespace) {
return None;
}
Some(ParsedDocumentKind {
namespace: namespace.to_string(),
document_type,
})
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DocumentSignature {
pub algorithm: String,
pub key_id: String,
pub signature_base64: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SignedDocument {
pub schema_version: u32,
pub payload_encoding: String,
pub payload_base64: String,
pub payload_sha256: String,
pub signatures: Vec<DocumentSignature>,
}
pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write as _;
let _ = write!(hex, "{byte:02x}");
}
hex
}
impl SignedDocument {
pub fn parse(bytes: &[u8]) -> Result<Self> {
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) {
if value.get("schemaVersion").and_then(serde_json::Value::as_u64) == Some(1) {
fail!("Unsupported schemaVersion 1; rebuild this box with Scrollcase v2.");
}
}
serde_json::from_slice(bytes)
.map_err(|error| crate::error::Error::new(format!("Invalid signed document: {error}.")))
}
pub fn decode_payload(&self) -> Result<Vec<u8>> {
if self.schema_version == 1 {
fail!("Unsupported schemaVersion 1; rebuild this box with Scrollcase v2.");
}
if self.schema_version != BOX_SCHEMA_VERSION || self.payload_encoding != PAYLOAD_ENCODING {
fail!("Unsupported signed document.");
}
let Ok(bytes) = BASE64.decode(&self.payload_base64) else {
fail!("Signed payload SHA-256 mismatch.");
};
if sha256_hex(&bytes) != self.payload_sha256 {
fail!("Signed payload SHA-256 mismatch.");
}
Ok(bytes)
}
#[must_use]
pub fn is_well_formed(&self) -> bool {
self.schema_version == BOX_SCHEMA_VERSION
&& self.payload_encoding == PAYLOAD_ENCODING
&& !self.signatures.is_empty()
&& self
.signatures
.iter()
.all(|signature| signature.algorithm == SIGNATURE_ALGORITHM)
}
}
#[cfg(test)]
mod tests {
use super::{
document_kind, is_document_namespace, parse_document_kind, DocumentType, SignedDocument,
DEFAULT_DOCUMENT_NAMESPACE,
};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
fn envelope(payload: &[u8], sha256: &str) -> SignedDocument {
serde_json::from_value(serde_json::json!({
"schemaVersion": 2,
"payloadEncoding": "base64-json-utf8",
"payloadBase64": BASE64.encode(payload),
"payloadSha256": sha256,
"signatures": [{
"algorithm": "ed25519",
"keyId": "fixture",
"signatureBase64": BASE64.encode([0u8; 64]),
}],
}))
.unwrap()
}
#[test]
fn the_default_namespace_round_trips() {
let kind = document_kind(DEFAULT_DOCUMENT_NAMESPACE, DocumentType::Release).unwrap();
assert_eq!(kind, "scrollcase.box.release");
let parsed = parse_document_kind(&kind).unwrap();
assert_eq!(parsed.namespace, DEFAULT_DOCUMENT_NAMESPACE);
assert_eq!(parsed.document_type, DocumentType::Release);
}
#[test]
fn a_publishers_own_namespace_is_preserved_verbatim() {
let parsed = parse_document_kind("acme.runtime-box.release").unwrap();
assert_eq!(parsed.namespace, "acme.runtime-box");
assert_eq!(parsed.document_type, DocumentType::Release);
}
#[test]
fn namespaces_outside_the_pattern_are_refused() {
for invalid in ["", ".", "a.", ".a", "a..b", "A.b", "a_b", "a b", "a-", "-a"] {
assert!(!is_document_namespace(invalid), "{invalid} was accepted");
assert!(document_kind(invalid, DocumentType::Release).is_err());
}
for invalid in ["release", "scrollcase.box", "scrollcase.box.unknown", ".release"] {
assert!(parse_document_kind(invalid).is_none(), "{invalid} parsed");
}
}
#[test]
fn a_v1_envelope_is_refused_by_name() {
let error = SignedDocument::parse(br#"{"schemaVersion":1}"#).unwrap_err();
assert!(error.message().contains("Unsupported schemaVersion 1"), "{error}");
}
#[test]
fn an_edited_payload_fails_its_own_checksum() {
let payload = br#"{"schemaVersion":2}"#;
let good = super::sha256_hex(payload);
assert_eq!(envelope(payload, &good).decode_payload().unwrap(), payload);
let error = envelope(b"tampered", &good).decode_payload().unwrap_err();
assert!(error.message().contains("Signed payload SHA-256 mismatch"), "{error}");
}
#[test]
fn an_unknown_field_is_not_silently_ignored() {
let raw = br#"{"schemaVersion":2,"payloadEncoding":"base64-json-utf8",
"payloadBase64":"","payloadSha256":"","signatures":[],"extra":1}"#;
assert!(SignedDocument::parse(raw).is_err());
}
}