//! 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()); }