Files
Marcos Paulo c78fd488cc
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: registry core (model, guard, verify, store, net, install, commands), registry browser UI, signing tool, size profile and font dedupe, lazy Hebrew/Arabic fonts, htmlmd/srs/textdiff crates
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 17:28:10 -03:00

116 lines
3.9 KiB
Rust

//! Signature canonical text and ed25519 verification of registry entries.
use notescraft_engine::addons::{Ed25519, Entry, FakeVerifier, Index, Verifier, canonical_text};
use ring::signature::{Ed25519KeyPair, KeyPair};
use serde_json::{Value, json};
/// Test-only seed; not a secret and not the production key.
const TEST_SEED: [u8; 32] = [7; 32];
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn b64(bytes: &[u8]) -> String {
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n = chunk.iter().enumerate().fold(0_u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i));
for i in 0..4 {
if i <= chunk.len() {
out.push(char::from(B64[usize::try_from((n >> (18 - 6 * i)) & 63).unwrap()]));
} else {
out.push('=');
}
}
}
out
}
fn pair() -> Ed25519KeyPair {
Ed25519KeyPair::from_seed_unchecked(&TEST_SEED).unwrap()
}
fn verifier() -> Ed25519 {
Ed25519::new(vec![("k1".into(), pair().public_key().as_ref().try_into().unwrap())])
}
fn files(order: &[&str]) -> Value {
Value::Array(order.iter().map(|p| json!({ "path": p, "size": 5, "sha256": "c".repeat(64), "url": format!("https://h.example/{p}") })).collect())
}
fn entry_json(files: &Value, sig: &str, key_id: &str) -> Value {
json!({
"id": "p", "kind": "pack", "title": "P", "version": "1.2.3", "min_app_version": "0.1.0", "license": "MIT",
"files": files, "signature": { "key_id": key_id, "sig": sig }
})
}
fn parse(value: &Value) -> Entry {
let index = Index::parse(json!({ "version": 2, "entries": [value] }).to_string().as_bytes()).unwrap();
index.entries[0].clone()
}
fn signed(order: &[&str]) -> Entry {
let unsigned = parse(&entry_json(&files(order), "QUJD", "k1"));
let sig = b64(pair().sign(canonical_text(&unsigned).as_bytes()).as_ref());
parse(&entry_json(&files(order), &sig, "k1"))
}
const A: &str = "grammars/a.sublime-syntax";
const B: &str = "themes/b.toml";
#[test]
fn known_answer_verifies() {
let entry = signed(&[A, B]);
assert_eq!(canonical_text(&entry), format!("notescraft-registry-v1\np\npack\n1.2.3\n{A}\n{}\n5\n{B}\n{}\n5\n", "c".repeat(64), "c".repeat(64)));
verifier().verify(&entry).unwrap();
}
#[test]
fn file_order_does_not_change_text_or_signature() {
let entry = signed(&[A, B]);
let mut shuffled = entry.clone();
shuffled.files.reverse();
assert_eq!(canonical_text(&entry), canonical_text(&shuffled));
verifier().verify(&shuffled).unwrap();
}
#[test]
fn tampering_fails() {
let entry = signed(&[A, B]);
let mut hash = entry.clone();
hash.files[0].sha256 = format!("d{}", "c".repeat(63));
assert!(verifier().verify(&hash).is_err());
let mut size = entry.clone();
size.files[0].size += 1;
assert!(verifier().verify(&size).is_err());
let mut version = entry.clone();
version.version.patch += 1;
assert!(verifier().verify(&version).is_err());
let mut id = entry.clone();
id.id = "q".into();
assert!(verifier().verify(&id).is_err());
let mut kind = entry;
kind.kind = notescraft_engine::addons::Kind::Theme;
assert!(verifier().verify(&kind).is_err());
}
#[test]
fn unknown_key_bad_length_and_empty_trust_fail() {
let entry = signed(&[A]);
let mut other = entry.clone();
other.signature.key_id = "k2".into();
assert!(verifier().verify(&other).is_err());
let mut short = entry.clone();
short.signature.sig = b64(&[1, 2, 3]);
assert!(verifier().verify(&short).is_err());
let mut junk = entry.clone();
junk.signature.sig = "!!!!".into();
assert!(verifier().verify(&junk).is_err());
assert!(Ed25519::trusted().verify(&entry).is_err());
}
#[test]
fn fake_verifier() {
let entry = signed(&[A]);
assert!(FakeVerifier { accept: true }.verify(&entry).is_ok());
assert!(FakeVerifier { accept: false }.verify(&entry).is_err());
}