feat: add smolboid-core feed format with payload-signature verification
This commit is contained in:
commit
fd3f9bcf24
13 changed files with 2531 additions and 0 deletions
418
core/tests/vectors.rs
Normal file
418
core/tests/vectors.rs
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
//! 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue