smolboid/core/tests/vectors.rs

418 lines
13 KiB
Rust

//! Reference vectors pinning every verification state (PLAN.md step 3).
//!
//! Cases are built from fixed seeds; Ed25519 signing is deterministic, so a
//! rebuilt case is byte-identical to `vectors.json`. The regular tests pin
//! both directions — the JSON must verify to its recorded states, and the
//! code must regenerate exactly the JSON's feeds. `write_vectors`
//! regenerates the file after an intentional format change:
//! `cargo test -- --ignored write_vectors`.
use fumi_core::account::{identity_seed, Identity};
use fumi_core::crypto::b32;
use smolboid_core::feed::{Feed, MailLine};
use smolboid_core::verify::{reply_to_body, repost_body, signed_bytes, verify, State};
use smolboid_core::time;
/// Distinct fixed masters: 0xA0/0xA1 are the daemon's identity keys across a
/// rotation, 0xB0 a foreign instance, 0x5B the author.
const HOST0: u8 = 0xA0;
const HOST1: u8 = 0xA1;
const FOREIGN: u8 = 0xB0;
const AUTHOR: u8 = 0x5B;
const EPK: [u8; 32] = [0xE7; 32];
fn identity(master: u8, index: u32) -> Identity {
Identity::from_seed(identity_seed(&[master; 32], index))
}
/// Signs a record the way smolmail seals it, so the feed carries the very
/// signature that sealed the mail (PLAN.md, Signing).
fn sign(sender: &Identity, to: [u8; 32], unix: i64, body: &str) -> MailLine {
let sender_pk = sender.pk();
MailLine {
sender: sender_pk,
epk: EPK,
sig: sender.sign(&signed_bytes(&to, &EPK, &sender_pk, unix, body.as_bytes())),
}
}
fn post(sender: &Identity, to: [u8; 32], unix: i64, text: &str) -> String {
let mail = sign(sender, to, unix, text);
// The bare form of a post.
format!(
"{}\t{}\n{}\n",
time::render(unix),
text,
mail.render()
)
}
fn post_with_reply_to(sender: &Identity, to: [u8; 32], unix: i64, reply_to: &str, text: &str) -> String {
let mail = sign(sender, to, unix, &reply_to_body(reply_to, text));
format!(
"{}\t{}\n{}\n",
time::render(unix),
text,
mail.render()
)
}
fn repost(sender: &Identity, to: [u8; 32], unix: i64, args: &str) -> String {
let mail = sign(sender, to, unix, &repost_body(args, None));
format!(
"# smol:repost {} {}\n{}\n",
time::render(unix),
args,
mail.render()
)
}
fn repost_with_reply_to(sender: &Identity, to: [u8; 32], unix: i64, reply_to: &str, args: &str) -> String {
let mail = sign(sender, to, unix, &repost_body(args, Some(reply_to)));
format!(
"# smol:repost {} {}\n{}\n",
time::render(unix),
args,
mail.render()
)
}
struct Case {
name: &'static str,
feed: String,
legacy: bool,
states: Vec<State>,
}
fn metadata(host_keys: &[[u8; 32]]) -> String {
let mut meta = String::from(
"# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n",
);
for key in host_keys {
meta.push_str(&format!("# smol:host_key = {}\n", b32(key)));
}
meta
}
fn cases() -> Vec<Case> {
let host0 = identity(HOST0, 0).pk();
let host1 = identity(HOST1, 0).pk();
let foreign = identity(FOREIGN, 0).pk();
let author = identity(AUTHOR, 0);
let rotated = identity(AUTHOR, 1);
let t1: i64 = 1_760_000_000;
let t2 = t1 + 3600;
let t3 = t2 + 3600;
let meta1 = metadata(&[host0]);
vec![
Case {
name: "verified_post",
feed: format!("{}{}", meta1, post(&author, host0, t1, "hello smolverse")),
legacy: false,
states: vec![State::Verified],
},
Case {
name: "unsigned_post",
feed: format!(
"{}{}\t{}\n",
meta1,
time::render(t1),
"no signature line"
),
legacy: false,
states: vec![State::Unsigned],
},
Case {
name: "invalid_tampered_text",
// Signed over "hello smolverse", published as "hello sm0lverse".
feed: format!(
"{}{}\t{}\n{}\n",
meta1,
time::render(t1),
"hello sm0lverse",
sign(&author, host0, t1, "hello smolverse").render()
),
legacy: false,
states: vec![State::Invalid],
},
Case {
name: "invalid_foreign_host",
// Sealed to another instance: no host_key matches, so the record
// cannot be laundered into this feed.
feed: format!(
"{}{}",
meta1,
post(&author, foreign, t1, "sealed elsewhere")
),
legacy: false,
states: vec![State::Invalid],
},
Case {
name: "invalid_bad_timestamp",
feed: format!(
"{}{}\t{}\n{}\n",
meta1,
"2026-13-40T99:99:99Z",
"unparseable time",
sign(&author, host0, t1, "unparseable time").render()
),
legacy: false,
states: vec![State::Invalid],
},
Case {
name: "legacy_feed",
// No smol:author and no host_key: plain twtxt, records not judged.
feed: format!(
"# nick = tobias\n{}\t{}\n",
time::render(t1),
"just a legacy twt"
),
legacy: true,
states: vec![State::Unsigned],
},
Case {
name: "orphan_mail_line",
feed: format!(
"{}{}{}",
meta1,
post(&author, host0, t1, "with a signature"),
format!(
"# smol:mail {} {} {}\n",
b32(&author.pk()),
b32(&EPK),
b32(&author.sign(&signed_bytes(
&host0,
&EPK,
&author.pk(),
t2,
"orphaned".as_bytes()
)))
)
),
legacy: false,
// The second mail line belongs to no record and is ignored.
states: vec![State::Verified],
},
Case {
name: "host_rotation",
// Both eras verify because each host_key is tried in turn.
feed: format!(
"{}{}{}",
metadata(&[host0, host1]),
post(&author, host0, t1, "before the rotation"),
post(&author, host1, t2, "after the rotation")
),
legacy: false,
states: vec![State::Verified, State::Verified],
},
Case {
name: "author_rotation",
// Per-record sender keys: old posts stay valid under the old key.
feed: format!(
"{}{}{}",
meta1,
post(&author, host0, t1, "identity index 0"),
post(&rotated, host0, t2, "identity index 1")
),
legacy: false,
states: vec![State::Verified, State::Verified],
},
Case {
name: "verified_repost",
feed: format!(
"{}{}",
meta1,
repost(
&author,
host0,
t1,
"@<dana https://smol.place/twtxt/dana.txt> #abc1234"
)
),
legacy: false,
states: vec![State::Verified],
},
Case {
name: "invalid_repost_tampered",
feed: format!(
"{}{}",
meta1,
repost(
&author,
host0,
t1,
"@<dana https://smol.place/twtxt/dana.txt> #abc1234"
)
.replace("#abc1234", "#fedcba9") // signed over #abc1234
),
legacy: false,
states: vec![State::Invalid],
},
Case {
name: "dashed_post",
// Text beginning with `---` is sent raw: a single line can never
// open a §5.5 frontmatter block, so nothing is escaped.
feed: format!(
"{}{}",
meta1,
post(&author, host0, t1, "--- dashed opening")
),
legacy: false,
states: vec![State::Verified],
},
Case {
name: "reply_to_post",
// An ordinary send carries a signed Reply-To block; the verifier
// derives the same value from the author address and sender key.
feed: format!(
"{}{}",
meta1,
post_with_reply_to(
&author,
host0,
t1,
&format!("smol://tobias@smol.place/{}", b32(&author.pk())),
"hello from an ordinary send"
)
),
legacy: false,
states: vec![State::Verified],
},
Case {
name: "reply_to_key_mismatch_is_forged",
// The signature covers a Reply-To naming a different key than
// the record's sender, so no derived form reconstructs.
feed: format!(
"{}{}",
meta1,
post_with_reply_to(
&author,
host0,
t1,
&format!("smol://tobias@smol.place/{}", b32(&[0xEE; 32])),
"signed with a foreign reply-to"
)
),
legacy: false,
states: vec![State::Invalid],
},
Case {
name: "repost_with_reply_to",
feed: format!(
"{}{}",
meta1,
repost_with_reply_to(
&author,
host0,
t1,
&format!("smol://tobias@smol.place/{}", b32(&author.pk())),
"@<dana https://smol.place/twtxt/dana.txt> #abc1234"
)
),
legacy: false,
states: vec![State::Verified],
},
Case {
name: "mixed_feed",
feed: format!(
"{}{}\t{}\n{}{}{}",
meta1,
time::render(t1),
"unsigned legacy line",
post(&author, host0, t2, "signed after it"),
repost(&author, host0, t3, "@<nick url> #hash123"),
post(&author, foreign, t1 + 7200, "signed to a foreign host")
),
legacy: false,
states: vec![
State::Unsigned,
State::Verified,
State::Verified,
State::Invalid,
],
},
]
}
#[test]
fn cases_verify_to_their_expected_states() {
for case in cases() {
let report = verify(&Feed::parse(&case.feed));
assert_eq!(report.legacy, case.legacy, "{}: legacy flag", case.name);
assert_eq!(report.states, case.states, "{}: states", case.name);
}
}
#[test]
fn parse_render_round_trips_every_case() {
for case in cases() {
assert_eq!(Feed::parse(&case.feed).render(), case.feed, "{}", case.name);
}
}
#[test]
fn vectors_json_pins_the_generated_cases() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/vectors.json");
let pinned: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(path).expect("vectors.json present"))
.expect("vectors.json parses");
let pinned_cases = pinned["cases"].as_array().expect("cases array");
let generated = cases();
assert_eq!(
pinned_cases.len(),
generated.len(),
"vectors.json and the code disagree on the case set"
);
for (pinned, case) in pinned_cases.iter().zip(&generated) {
assert_eq!(pinned["name"], case.name, "case order");
assert_eq!(pinned["feed"], case.feed, "{}: feed bytes", case.name);
assert_eq!(pinned["legacy"], case.legacy, "{}: legacy", case.name);
let states: Vec<&str> = pinned["states"]
.as_array()
.unwrap()
.iter()
.map(|s| s.as_str().unwrap())
.collect();
let expected: Vec<&str> = case.states.iter().map(State::as_str).collect();
assert_eq!(states, expected, "{}: states", case.name);
// The pinned feed must verify exactly as recorded.
let report = verify(&Feed::parse(case.feed.as_str()));
assert_eq!(report.legacy, case.legacy);
assert_eq!(
report
.states
.iter()
.map(State::as_str)
.collect::<Vec<_>>(),
states
);
}
}
#[test]
#[ignore = "regenerates core/vectors.json after an intentional format change"]
fn write_vectors() {
let cases: Vec<serde_json::Value> = cases()
.iter()
.map(|case| {
serde_json::json!({
"name": case.name,
"feed": case.feed,
"legacy": case.legacy,
"states": case.states.iter().map(State::as_str).collect::<Vec<_>>(),
})
})
.collect();
let doc = serde_json::json!({
"comment": "smolboid-core verification vectors; regenerate with: cargo test -- --ignored write_vectors",
"cases": cases,
});
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/vectors.json");
std::fs::write(path, serde_json::to_string_pretty(&doc).unwrap() + "\n").unwrap();
}