oxedyne/fe2o3/fe2o3_pearlite/tests/collab_security.rs
13.3 KiB, 1 run
created by r1870400018:59042, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! The malicious-peer regression suite: for each shape the security review named, a test that proves |
| 2 | //! the attack fails. A signed operation is attacker-controlled input from an untrusted peer, and every |
| 3 | //! test below hands the fold or the seal exactly what a hostile peer would and asserts it is refused, |
| 4 | //! attributed to the real signer, or skipped -- never trusted, and never able to wedge the whole fold. |
| 5 | //! |
| 6 | //! These are pure-crypto unit tests: no store is stood up, because none of the shapes needs one. The |
| 7 | //! hub's own verify-and-bound ingest is exercised by `collab_roundtrip.rs`; here the concern is the |
| 8 | //! signing core and the fold. |
| 9 | |
| 10 | use oxedyne_fe2o3_austenite::emit::pearl::{ |
| 11 | Annotation, |
| 12 | AnnotationKind, |
| 13 | }; |
| 14 | use oxedyne_fe2o3_core::prelude::*; |
| 15 | use oxedyne_fe2o3_jdat::prelude::*; |
| 16 | use oxedyne_fe2o3_net::ecdsa::P256KeyPair; |
| 17 | use oxedyne_fe2o3_ore::{ |
| 18 | id::OpId, |
| 19 | op::{ |
| 20 | Op, |
| 21 | Record, |
| 22 | Settled, |
| 23 | }, |
| 24 | }; |
| 25 | use oxedyne_fe2o3_pearlite::collab::{ |
| 26 | fold, |
| 27 | op, |
| 28 | replica_of, |
| 29 | sign::{ |
| 30 | self, |
| 31 | Opened, |
| 32 | }, |
| 33 | DocId, |
| 34 | Keyring, |
| 35 | }; |
| 36 | |
| 37 | |
| 38 | /// An annotation on `anchor` claiming `author` in its body, for the given document. |
| 39 | fn annotation(anchor: &str, author: &str) -> Annotation { |
| 40 | Annotation::new(anchor, AnnotationKind::Note, "the note text", author, "2026-09-20T12:00:00Z") |
| 41 | } |
| 42 | |
| 43 | /// Seals `op` as a root record under the replica the signer's own key derives, the way an honest peer |
| 44 | /// would, and returns the envelope. |
| 45 | fn seal_honest(doc: &DocId, key: &P256KeyPair, counter: u64, op: Op) |
| 46 | -> Outcome<oxedyne_fe2o3_ore::envelope::Envelope> |
| 47 | { |
| 48 | let rec = Record::root(OpId::new(replica_of(&key.public_key(), doc), counter), op); |
| 49 | let sig = res!(key.sign(&res!(sign::signing_bytes(&rec)))); |
| 50 | sign::seal(&rec, key.public_key(), sig, doc) |
| 51 | } |
| 52 | |
| 53 | /// Opens a freshly sealed operation, for `doc`, into the verified, signer-bound form the fold consumes. |
| 54 | fn opened(doc: &DocId, key: &P256KeyPair, counter: u64, op: Op) -> Outcome<Opened> { |
| 55 | sign::open(&res!(seal_honest(doc, key, counter, op)), doc) |
| 56 | } |
| 57 | |
| 58 | /// A proposal opening an annotation stamped for `doc`. |
| 59 | fn proposal(doc: &DocId, key: &P256KeyPair, counter: u64, anchor: &str, author: &str, time: u64) |
| 60 | -> Outcome<Opened> |
| 61 | { |
| 62 | opened(doc, key, counter, res!(op::create(doc, &annotation(anchor, author), time))) |
| 63 | } |
| 64 | |
| 65 | |
| 66 | /// A forged author string in the body loses to the signer: the folded author is the keyring's name for |
| 67 | /// the signing key, never the string the body claimed. |
| 68 | #[test] |
| 69 | fn a_forged_author_string_is_ignored_and_the_signer_is_shown() -> Outcome<()> { |
| 70 | let doc = DocId::new("doc-forge"); |
| 71 | let attacker = res!(P256KeyPair::generate()); |
| 72 | |
| 73 | // The body claims to be from "alice"; the attacker holds their own key, not alice's. |
| 74 | let op = res!(proposal(&doc, &attacker, 1, "block-1", "alice", 100)); |
| 75 | |
| 76 | // An empty keyring shows the fingerprint; either way it is derived from the signing key. |
| 77 | let report = fold::fold(&[op], &doc, &Keyring::new()); |
| 78 | req!(1, report.annotations.len()); |
| 79 | let shown = &report.annotations[0].author; |
| 80 | assert_ne!(shown, "alice", "the forged body author string must not be shown"); |
| 81 | assert_eq!(shown, &oxedyne_fe2o3_pearlite::collab::fingerprint(&attacker.public_key()), |
| 82 | "an unknown signer is shown by its own fingerprint"); |
| 83 | Ok(()) |
| 84 | } |
| 85 | |
| 86 | /// A record claiming a replica identity its signer's key does not derive is refused at the seal, and a |
| 87 | /// hand-built envelope carrying one is refused at the open -- so id-squatting cannot occur. |
| 88 | #[test] |
| 89 | fn a_squatted_replica_id_is_refused_at_seal_and_open() -> Outcome<()> { |
| 90 | let attacker = res!(P256KeyPair::generate()); |
| 91 | let victim = res!(P256KeyPair::generate()); |
| 92 | let doc = DocId::new("doc-squat"); |
| 93 | let victim_rep = replica_of(&victim.public_key(), &doc); |
| 94 | |
| 95 | // The attacker builds a record under the victim's replica and signs it with their own key. |
| 96 | let op = res!(op::create(&doc, &annotation("block-1", "victim"), 100)); |
| 97 | let rec = Record::root(OpId::new(victim_rep, 1), op); |
| 98 | let sig = res!(attacker.sign(&res!(sign::signing_bytes(&rec)))); |
| 99 | |
| 100 | // Sealing with the attacker's key is refused: the header's replica is not the one that key derives. |
| 101 | assert!(sign::seal(&rec, attacker.public_key(), sig.clone(), &doc).is_err(), |
| 102 | "a record whose replica is not the signer's must not seal"); |
| 103 | |
| 104 | // And a hand-built envelope carrying the same claim is refused at the open, though its signature is |
| 105 | // perfectly valid over its bytes. |
| 106 | let payload = res!(sign::signing_bytes(&rec)); |
| 107 | let forged = oxedyne_fe2o3_ore::envelope::Envelope::new(payload, attacker.public_key(), sig); |
| 108 | assert!(res!(sign::verify(&forged)), "the forged envelope's signature does hold over its bytes"); |
| 109 | assert!(sign::open(&forged, &doc).is_err(), |
| 110 | "opening an envelope whose replica is not its signer's must be refused"); |
| 111 | Ok(()) |
| 112 | } |
| 113 | |
| 114 | /// A counter-poison operation -- a forged op at a huge counter under the victim's replica, which would |
| 115 | /// wedge every reader's log -- never reaches the fold, because it cannot be opened: its replica is not |
| 116 | /// the forger's. |
| 117 | #[test] |
| 118 | fn a_counter_poison_op_cannot_be_opened() -> Outcome<()> { |
| 119 | let attacker = res!(P256KeyPair::generate()); |
| 120 | let victim = res!(P256KeyPair::generate()); |
| 121 | |
| 122 | let doc = DocId::new("doc-poison"); |
| 123 | let op = res!(op::create(&doc, &annotation("block-1", "victim"), 100)); |
| 124 | // The poison: the victim's replica, a counter far past anything they will mint. |
| 125 | let rec = Record::root(OpId::new(replica_of(&victim.public_key(), &doc), 1_000_000_000), op); |
| 126 | let sig = res!(attacker.sign(&res!(sign::signing_bytes(&rec)))); |
| 127 | let env = oxedyne_fe2o3_ore::envelope::Envelope::new( |
| 128 | res!(sign::signing_bytes(&rec)), attacker.public_key(), sig); |
| 129 | |
| 130 | assert!(sign::open(&env, &doc).is_err(), |
| 131 | "a counter-poison op forging the victim's replica is refused at the open and never folds"); |
| 132 | Ok(()) |
| 133 | } |
| 134 | |
| 135 | /// A deeply nested annotation body is refused by the bounded decoder rather than recursed into: the |
| 136 | /// decode terminates, and a validly-signed proposal carrying one is skipped, not fatal to the fold. |
| 137 | #[test] |
| 138 | fn a_deeply_nested_body_is_refused_and_skipped_not_fatal() -> Outcome<()> { |
| 139 | let doc = DocId::new("doc-deep"); |
| 140 | let good_key = res!(P256KeyPair::generate()); |
| 141 | let bad_key = res!(P256KeyPair::generate()); |
| 142 | |
| 143 | // A body nested a thousand deep, far past the decode limit. The bytes are built from the inside |
| 144 | // out rather than encoded, because the encoder recurses just as the decoder does and would itself |
| 145 | // overflow building the bomb -- which a hostile peer is under no obligation to use. |
| 146 | let mut deep = vec![Dat::EMPTY_CODE]; |
| 147 | for _ in 0..1_000 { |
| 148 | let payload_len = deep.len(); |
| 149 | let mut outer = vec![Dat::LIST_CODE]; |
| 150 | outer = res!(Dat::C64(payload_len as u64).to_bytes(outer)); |
| 151 | outer.append(&mut deep); |
| 152 | deep = outer; |
| 153 | } |
| 154 | |
| 155 | // Decoding it directly terminates and errors rather than exhausting the stack. |
| 156 | assert!(op::annotation_from_body(&deep).is_err(), |
| 157 | "a deeply nested body is refused by the bounded decoder"); |
| 158 | |
| 159 | // A validly-signed proposal carrying it is skipped; a good proposal beside it still folds. |
| 160 | let bad = res!(opened(&doc, &bad_key, 1, Op::Proposal { |
| 161 | title: "block-x".to_string(), |
| 162 | body: deep, |
| 163 | voice: op::VOICE.to_string(), |
| 164 | time: 50, |
| 165 | })); |
| 166 | let good = res!(proposal(&doc, &good_key, 1, "block-1", "author", 100)); |
| 167 | |
| 168 | let report = fold::fold(&[bad, good], &doc, &Keyring::new()); |
| 169 | req!(1, report.annotations.len(), "the good annotation folds despite the poisoned one"); |
| 170 | req!(1, report.skipped.len(), "the deeply nested body is reported as skipped"); |
| 171 | Ok(()) |
| 172 | } |
| 173 | |
| 174 | /// A validly-signed proposal whose body is not an annotation at all is skipped and reported, and does |
| 175 | /// not make the fold fail for the good annotations. |
| 176 | #[test] |
| 177 | fn a_malformed_signed_body_is_skipped_not_fatal() -> Outcome<()> { |
| 178 | let doc = DocId::new("doc-malformed"); |
| 179 | let good_key = res!(P256KeyPair::generate()); |
| 180 | let bad_key = res!(P256KeyPair::generate()); |
| 181 | |
| 182 | // A well-formed daticle that is simply not an Annotation map. |
| 183 | let not_ann = res!(dat!("just a string, not an annotation").to_bytes(Vec::new())); |
| 184 | let bad = res!(opened(&doc, &bad_key, 1, Op::Proposal { |
| 185 | title: "block-x".to_string(), |
| 186 | body: not_ann, |
| 187 | voice: op::VOICE.to_string(), |
| 188 | time: 50, |
| 189 | })); |
| 190 | let good = res!(proposal(&doc, &good_key, 1, "block-1", "author", 100)); |
| 191 | |
| 192 | let report = fold::fold(&[bad, good], &doc, &Keyring::new()); |
| 193 | req!(1, report.annotations.len(), "the good annotation survives a malformed neighbour"); |
| 194 | req!(1, report.skipped.len(), "the malformed body is reported as skipped"); |
| 195 | Ok(()) |
| 196 | } |
| 197 | |
| 198 | /// An annotation stamped for one document and replayed onto another -- where the same block address |
| 199 | /// happens to exist -- is refused by the fold, which folds only bodies stamped for the document in hand. |
| 200 | #[test] |
| 201 | fn a_cross_document_replay_is_rejected() -> Outcome<()> { |
| 202 | let doc_a = DocId::new("doc-A"); |
| 203 | let doc_b = DocId::new("doc-B"); |
| 204 | let key = res!(P256KeyPair::generate()); |
| 205 | |
| 206 | // Signed for document A, then folded under document B. |
| 207 | let op = res!(proposal(&doc_a, &key, 1, "shared-block", "author", 100)); |
| 208 | let report = fold::fold(&[op], &doc_b, &Keyring::new()); |
| 209 | req!(0, report.annotations.len(), "an annotation stamped for another document does not fold"); |
| 210 | req!(1, report.skipped.len(), "and it is reported as skipped"); |
| 211 | Ok(()) |
| 212 | } |
| 213 | |
| 214 | /// An amendment from anyone but the proposal's own signer is refused, so a stranger cannot edit another |
| 215 | /// author's annotation. The original stands. |
| 216 | #[test] |
| 217 | fn a_foreign_amendment_is_rejected() -> Outcome<()> { |
| 218 | let doc = DocId::new("doc-amend"); |
| 219 | let owner = res!(P256KeyPair::generate()); |
| 220 | let stranger = res!(P256KeyPair::generate()); |
| 221 | |
| 222 | let create = res!(proposal(&doc, &owner, 1, "block-1", "owner", 100)); |
| 223 | let on = create.record().id(); |
| 224 | |
| 225 | // The stranger, with a perfectly valid key and signature of their own, amends the owner's proposal. |
| 226 | let amend = res!(opened(&doc, &stranger, 1, res!(op::edit( |
| 227 | &doc, on, &annotation("block-1", "stranger's rewrite"), 200)))); |
| 228 | |
| 229 | let report = fold::fold(&[create, amend], &doc, &Keyring::new()); |
| 230 | req!(1, report.annotations.len()); |
| 231 | assert_eq!(report.annotations[0].payload, "the note text", |
| 232 | "the stranger's amendment must not replace the owner's annotation body"); |
| 233 | req!(1, report.skipped.len(), "the foreign amendment is reported as skipped"); |
| 234 | Ok(()) |
| 235 | } |
| 236 | |
| 237 | /// A proposal its own author withdraws stays withdrawn: a later amendment, even from that same author, |
| 238 | /// cannot resurrect it. Tombstone sticks. |
| 239 | #[test] |
| 240 | fn a_withdrawn_proposal_cannot_be_resurrected_by_an_amendment() -> Outcome<()> { |
| 241 | let doc = DocId::new("doc-tomb"); |
| 242 | let owner = res!(P256KeyPair::generate()); |
| 243 | |
| 244 | let create = res!(proposal(&doc, &owner, 1, "block-1", "owner", 100)); |
| 245 | let on = create.record().id(); |
| 246 | |
| 247 | // The author withdraws it, then -- later -- tries to amend it back into existence. |
| 248 | let withdraw = res!(opened(&doc, &owner, 2, Op::Settled { |
| 249 | on, |
| 250 | state: Settled::Declined, |
| 251 | mark: None, |
| 252 | time: 200, |
| 253 | })); |
| 254 | let amend = res!(opened(&doc, &owner, 3, res!(op::edit( |
| 255 | &doc, on, &annotation("block-1", "back from the dead"), 300)))); |
| 256 | let amend_id = amend.record().id(); |
| 257 | |
| 258 | let report = fold::fold(&[create, withdraw, amend], &doc, &Keyring::new()); |
| 259 | req!(0, report.annotations.len(), "a withdrawn proposal stays withdrawn despite a later amendment"); |
| 260 | assert!(report.skipped.iter().any(|s| s.id == amend_id), |
| 261 | "the resurrection attempt is reported as skipped"); |
| 262 | Ok(()) |
| 263 | } |
| 264 | |
| 265 | /// A validly-signed Settled (and a Said), authored by a key in one document, cannot be replayed into |
| 266 | /// another to withdraw or disturb that key's annotation there -- even though the signature holds and |
| 267 | /// the key is the same. The per-document replica binding refuses it at the open, and the fold skips it |
| 268 | /// belt-and-braces. This is the residual the earlier body-`doc_id` stamp missed: a Settled carries no |
| 269 | /// body to stamp, and its `on` identifier is not inert across documents, Ore minting counters per log. |
| 270 | #[test] |
| 271 | fn a_settled_or_said_from_another_document_cannot_be_replayed() -> Outcome<()> { |
| 272 | let doc_a = DocId::new("doc-A"); |
| 273 | let doc_b = DocId::new("doc-B"); |
| 274 | let owner = res!(P256KeyPair::generate()); |
| 275 | |
| 276 | // The owner's genuine annotation lives in document B. |
| 277 | let create_b = res!(proposal(&doc_b, &owner, 1, "block-1", "owner", 100)); |
| 278 | let on_b = create_b.record().id(); |
| 279 | |
| 280 | // A settlement the owner validly signed in document A, naming that identifier. A relay peer of A |
| 281 | // lifts it and tries to apply it to B, which -- before the per-document binding -- would have |
| 282 | // declined and tombstoned the owner's B annotation, unrecoverable. |
| 283 | let settled_a_env = res!(seal_honest(&doc_a, &owner, 2, Op::Settled { |
| 284 | on: on_b, |
| 285 | state: Settled::Declined, |
| 286 | mark: None, |
| 287 | time: 200, |
| 288 | })); |
| 289 | // Replayed into B, it is refused at the open: its replica is the owner's under A, not under B. |
| 290 | assert!(sign::open(&settled_a_env, &doc_b).is_err(), |
| 291 | "a settlement authored in another document must not open here"); |
| 292 | |
| 293 | // Belt and braces: opened legitimately for its own document A, then handed to a fold for document |
| 294 | // B, it is skipped -- not applied -- so the victim's annotation stands. |
| 295 | let settled_a = res!(sign::open(&settled_a_env, &doc_a)); |
| 296 | let settled_id = settled_a.record().id(); |
| 297 | let report = fold::fold(&[create_b, settled_a], &doc_b, &Keyring::new()); |
| 298 | req!(1, report.annotations.len(), "the victim's annotation survives a cross-document settlement"); |
| 299 | assert!(report.skipped.iter().any(|s| s.id == settled_id), |
| 300 | "the replayed settlement is reported as skipped, not applied"); |
| 301 | |
| 302 | // The same holds for a reply, which also carries no body to stamp. |
| 303 | let said_a_env = res!(seal_honest(&doc_a, &owner, 3, op::reply(on_b, "a reply from doc A", 210))); |
| 304 | assert!(sign::open(&said_a_env, &doc_b).is_err(), |
| 305 | "a reply authored in another document must not open here either"); |
| 306 | Ok(()) |
| 307 | } |