From d9bc9eb974aeba9f784b5ca4d1e61383328a5fe0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 21:04:43 +0200 Subject: [PATCH] Rust R-Y: the cursor's HMAC covers its own body, not only the selection Cursor::compute_digest hashed the selection alone (cursor/limit cleared), so a caller holding one valid cursor could splice its genuine digest onto a body naming a different asOf/ordinal/schemaId/recordKey and reuse it - the digest never bound the cursor to the position it names. compute_digest now takes (asOf, ordinal, schemaId, recordKey, selection, key) and folds the body into the same length-prefixed canonical string before hashing; mint and verify_digest pass their own body through. Tests: an in-module unit test (cursor_tests, next to Cursor, where the private digest field and the length-prefix helpers are reachable) splices a genuine digest onto a rewritten ordinal and a rewritten record key and confirms verification fails on both; an integration test confirms two cursors minted for different positions under the same selection and key never share a digest. Co-Authored-By: Claude Sonnet 5 --- packages/cultnet-rs/src/selection.rs | 105 ++++++++++++++++++++++--- packages/cultnet-rs/tests/selection.rs | 51 +++++++++++- 2 files changed, 143 insertions(+), 13 deletions(-) diff --git a/packages/cultnet-rs/src/selection.rs b/packages/cultnet-rs/src/selection.rs index 4b396783..668f5453 100644 --- a/packages/cultnet-rs/src/selection.rs +++ b/packages/cultnet-rs/src/selection.rs @@ -1883,7 +1883,14 @@ pub struct Cursor { impl Cursor { pub fn mint(as_of: u64, last_row: &R, selection: &Selection, key: &CursorKey) -> String { - let digest = Self::compute_digest(selection, key); + let digest = Self::compute_digest( + as_of, + last_row.ordinal(), + last_row.schema_id(), + last_row.record_key(), + selection, + key, + ); // R-O: the cursor body uses length-prefixed fields, not a delimiter - a record key // carrying any character, including the pre-fix delimiter itself (F6), round-trips. let mut body = String::new(); @@ -1915,29 +1922,52 @@ impl Cursor { }) } - /// Verifies this cursor's digest against `selection` under `key` in constant time (R-O): a - /// cursor forged without the key, or minted by a different process's key, is refused. + /// Verifies this cursor's digest against `selection` under `key` in constant time (R-O/R-Y): a + /// cursor forged without the key, minted by a different process's key, or **rewritten in its + /// own position** (`asOf`, `ordinal`, `schemaId` or `recordKey` edited after minting, with the + /// original digest reused) is refused - the digest covers the body, not only the selection. fn verify_digest(&self, selection: &Selection, key: &CursorKey) -> bool { - let expected = Self::compute_digest(selection, key); + let expected = Self::compute_digest( + self.as_of, + self.ordinal, + &self.schema_id, + &self.record_key, + selection, + key, + ); constant_time_eq(self.digest.as_bytes(), expected.as_bytes()) } - /// An HMAC-SHA256 digest, keyed under `key`, of the selection with `cursor` and `limit` - /// cleared, so a cursor is bound to the selection that minted it and to the process that - /// minted it. + /// An HMAC-SHA256 digest, keyed under `key`, of the cursor's own position (`asOf`, `ordinal`, + /// `schemaId`, `recordKey`) as well as the selection with `cursor` and `limit` cleared (R-Y), + /// so a cursor is bound both to the selection that minted it, the process that minted it, and + /// the exact position it names - a caller cannot hold one valid cursor, rewrite `ordinal` or + /// `recordKey` to skip or replay rows, and reuse the original digest. /// /// R-H: every string and list inside the selection is length-prefixed, so no separator /// character can ever make two distinct selections collide (Soul found `["a|b"]` and /// `["a","b"]` digesting the same under a separator-joined scheme). This need not (and does /// not) byte-match the C# reference's own digest - each server answers its own pages (see this /// type's doc comment) - only the collision-freedom and the keying are load-bearing. - pub fn compute_digest(selection: &Selection, key: &CursorKey) -> String { + pub fn compute_digest( + as_of: u64, + ordinal: i64, + schema_id: &str, + record_key: &str, + selection: &Selection, + key: &CursorKey, + ) -> String { + let mut canonical = String::new(); + write_length_prefixed(&mut canonical, &as_of.to_string()); + write_length_prefixed(&mut canonical, &ordinal.to_string()); + write_length_prefixed(&mut canonical, schema_id); + write_length_prefixed(&mut canonical, record_key); + let mut sorted_schemas = selection.schemas.clone().unwrap_or_default(); sorted_schemas.sort(); let mut sorted_keys = selection.keys.clone().unwrap_or_default(); sorted_keys.sort(); - let mut canonical = String::new(); write_length_prefixed_list(&mut canonical, &sorted_schemas); write_length_prefixed_list(&mut canonical, &sorted_keys); @@ -2024,3 +2054,60 @@ fn read_length_prefixed_fields(bytes: &[u8], count: usize) -> Option } Some(fields) } + +/// R-Y: the cursor's digest covers its own body, not only the selection, so a cursor's position +/// cannot be rewritten while reusing a genuine digest. Lives here, rather than in the integration +/// tests, because forging that specific attack - splicing a genuine digest onto a rewritten body - +/// needs the private `digest` field and the length-prefix wire helpers above. +#[cfg(test)] +mod cursor_tests { + use super::*; + + fn encode(as_of: u64, ordinal: i64, schema_id: &str, record_key: &str, digest: &str) -> String { + let mut body = String::new(); + write_length_prefixed(&mut body, &as_of.to_string()); + write_length_prefixed(&mut body, &ordinal.to_string()); + write_length_prefixed(&mut body, schema_id); + write_length_prefixed(&mut body, record_key); + write_length_prefixed(&mut body, digest); + URL_SAFE_NO_PAD.encode(body.as_bytes()) + } + + #[test] + fn a_rewritten_ordinal_under_the_original_digest_fails_verification() { + let key = CursorKey::random(); + let selection = Selection::default(); + let genuine_digest = Cursor::compute_digest(1, 5, "schema-a", "k1", &selection, &key); + + let forged = encode(1, 6, "schema-a", "k1", &genuine_digest); + let parsed = Cursor::parse(&forged).expect("well-formed body, forged digest"); + assert!( + !parsed.verify_digest(&selection, &key), + "reusing the digest under a rewritten ordinal must fail verification" + ); + } + + #[test] + fn a_rewritten_record_key_under_the_original_digest_fails_verification() { + let key = CursorKey::random(); + let selection = Selection::default(); + let genuine_digest = Cursor::compute_digest(1, 5, "schema-a", "k1", &selection, &key); + + let forged = encode(1, 5, "schema-a", "k2", &genuine_digest); + let parsed = Cursor::parse(&forged).expect("well-formed body, forged digest"); + assert!( + !parsed.verify_digest(&selection, &key), + "reusing the digest under a rewritten record key must fail verification" + ); + } + + #[test] + fn the_genuine_digest_still_verifies() { + let key = CursorKey::random(); + let selection = Selection::default(); + let genuine_digest = Cursor::compute_digest(1, 5, "schema-a", "k1", &selection, &key); + let genuine = encode(1, 5, "schema-a", "k1", &genuine_digest); + let parsed = Cursor::parse(&genuine).expect("well-formed body"); + assert!(parsed.verify_digest(&selection, &key)); + } +} diff --git a/packages/cultnet-rs/tests/selection.rs b/packages/cultnet-rs/tests/selection.rs index 43d047c3..ceb2227b 100644 --- a/packages/cultnet-rs/tests/selection.rs +++ b/packages/cultnet-rs/tests/selection.rs @@ -1461,19 +1461,62 @@ fn cursor_digest_does_not_collide_on_differently_split_lists() { ..Selection::default() }; assert_ne!( - Cursor::compute_digest(&one_joined_value, test_cursor_key()), - Cursor::compute_digest(&two_split_values, test_cursor_key()), + Cursor::compute_digest(1, 1, "s", "k", &one_joined_value, test_cursor_key()), + Cursor::compute_digest(1, 1, "s", "k", &two_split_values, test_cursor_key()), "a length-prefixed digest must not let list-splitting collide" ); let one_key = Selection { keys: Some(vec!["a,b".into()]), ..Selection::default() }; let two_keys = Selection { keys: Some(vec!["a".into(), "b".into()]), ..Selection::default() }; assert_ne!( - Cursor::compute_digest(&one_key, test_cursor_key()), - Cursor::compute_digest(&two_keys, test_cursor_key()) + Cursor::compute_digest(1, 1, "s", "k", &one_key, test_cursor_key()), + Cursor::compute_digest(1, 1, "s", "k", &two_keys, test_cursor_key()) ); } +// ------------------------------------------------------------------------------------------ +// R-Y: the digest covers the cursor's own body (`asOf`, `ordinal`, `schemaId`, `recordKey`), not +// only the selection - a caller holding one valid cursor cannot rewrite its position and reuse +// the digest. +// ------------------------------------------------------------------------------------------ + +#[test] +fn cursor_digest_does_not_collide_across_rewritten_positions() { + let selection = Selection::default(); + let key = test_cursor_key(); + let base = Cursor::compute_digest(1, 5, "schema-a", "k1", &selection, key); + let rewritten_ordinal = Cursor::compute_digest(1, 6, "schema-a", "k1", &selection, key); + let rewritten_key = Cursor::compute_digest(1, 5, "schema-a", "k2", &selection, key); + let rewritten_as_of = Cursor::compute_digest(2, 5, "schema-a", "k1", &selection, key); + let rewritten_schema = Cursor::compute_digest(1, 5, "schema-b", "k1", &selection, key); + assert_ne!(base, rewritten_ordinal, "a rewritten ordinal must not reuse the digest"); + assert_ne!(base, rewritten_key, "a rewritten record key must not reuse the digest"); + assert_ne!(base, rewritten_as_of, "a rewritten asOf must not reuse the digest"); + assert_ne!(base, rewritten_schema, "a rewritten schemaId must not reuse the digest"); +} + +// A cursor whose position was rewritten but whose digest bytes were reused (a forgery only +// possible with access to the encoded body, not the process key) is covered by the in-module +// unit test next to `Cursor` itself (`selection.rs`'s `cursor_tests`), where the private `digest` +// field and the length-prefix helpers used to splice a forged body are directly reachable. This +// integration test only exercises the public surface: two cursors minted for different positions +// under the same selection and key never share a digest, which is what makes that forgery +// impossible without the raw bytes. +#[test] +fn cursor_minted_for_a_different_position_never_shares_a_digest() { + let rows = base_rows(); + let selection = Selection { limit: Some(1), ..Selection::default() }; + let page = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + let genuine = page.next_cursor.expect("more than one row"); + let parsed = Cursor::parse(&genuine).expect("mints decode"); + + let d1 = Cursor::compute_digest(1, parsed.ordinal, &parsed.schema_id, &parsed.record_key, &selection, test_cursor_key()); + let d2 = Cursor::compute_digest(1, parsed.ordinal + 1, &parsed.schema_id, &parsed.record_key, &selection, test_cursor_key()); + let d3 = Cursor::compute_digest(1, parsed.ordinal, &parsed.schema_id, "some-other-key", &selection, test_cursor_key()); + assert_ne!(d1, d2, "a rewritten ordinal must not reuse the digest"); + assert_ne!(d1, d3, "a rewritten record key must not reuse the digest"); +} + // ------------------------------------------------------------------------------------------ // R-O: the cursor is keyed. A forged cursor, a cursor minted by another process, and a record // key carrying the pre-fix delimiter character are all refused or round-trip correctly under the