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 <noreply@anthropic.com>
This commit is contained in:
parent
ccea1098ce
commit
d9bc9eb974
2 changed files with 143 additions and 13 deletions
|
|
@ -1883,7 +1883,14 @@ pub struct Cursor {
|
|||
|
||||
impl Cursor {
|
||||
pub fn mint<R: Row>(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<Vec<String>
|
|||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue