418 lines
13 KiB
Rust
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();
|
|
}
|