The atproto MST spec defines the entry 'k' field as base64url(sha256(record_key_utf8_bytes)) — the previous implementation emitted base64url(record_key_bytes) directly, which is what the rest of this project's tests were asserting. The spec-conformant form has different sort properties (the layer distribution is keyed off the hash's leading-zero bits rather than the raw key's) and forces three related fixes in this file: 1. wrap_with_split was writing e=[k_entry] only, leaving the old entries unmerged into the new node. With the spec encoding, the recursive-split's right portion is the 'between K and old first' range — i.e. the new key's .tree — and the old entries need to be appended after the new key. Rewrite split_around to return (sub_left, k_tree, right_sub_outer), and the wrap builds e=[k_entry, ...old_entries] in one write_node. 2. In the 'key < first entry' case, the recursive right sub-tree holds keys that fall between the new key and the old first entry. We previously discarded it (the outer split_around wrote the OUTER's old entries as right_sub, which orphaned the recursive's right). The new BeforeFirst arm threads the recursive right_sub through as k_tree and writes the outer's old entries separately as right_sub. 3. Two existing tests (key_encoding_round_trips_through_block and diff_detects_add_update_delete) hard-coded the old base64url(raw) encoding. Update their assertions to compare against base64url(sha256(raw)). All 27 at-mst tests pass. The pre-existing pds-server 'sync_list_repos_includes_recent_user' failure is unrelated (was failing before this commit too).
143 lines
4.3 KiB
Rust
143 lines
4.3 KiB
Rust
use anyhow::{anyhow, Result};
|
|
|
|
use at_crypto::cid::sha256;
|
|
|
|
pub const DEFAULT_FANOUT: usize = 8;
|
|
|
|
pub fn max_layer_for_fanout(fanout: usize) -> usize {
|
|
if fanout <= 1 {
|
|
return 0;
|
|
}
|
|
(usize::ilog2(fanout) as usize).saturating_sub(1)
|
|
}
|
|
|
|
pub fn count_leading_zero_bits(hash: &[u8]) -> usize {
|
|
let mut count = 0usize;
|
|
for &byte in hash {
|
|
if byte == 0 {
|
|
count += 8;
|
|
} else {
|
|
count += byte.leading_zeros() as usize;
|
|
break;
|
|
}
|
|
}
|
|
count
|
|
}
|
|
|
|
/// Hash a record key to the 32-byte digest used as comparison input
|
|
/// throughout the MST. Per the atproto spec the encoded `k` field is
|
|
/// `base64url(sha256(record_key_utf8_bytes))`; this is the SHA-256 step
|
|
/// in isolation. Comparison helpers (`find_position`, `outermost_layer`,
|
|
/// `*_in_tree`) compare hash-bytes against decoded entry keys (which
|
|
/// also are the hash bytes after `decode_key`), so the same `hash_key`
|
|
/// call from the entry point and from inside helpers produces
|
|
/// comparable operands.
|
|
pub fn hash_key(raw_key: &str) -> [u8; 32] {
|
|
sha256(raw_key.as_bytes())
|
|
}
|
|
|
|
/// Layer that an already-hashed key occupies in the tree of `fanout`.
|
|
/// Use after `hash_key` to avoid hashing twice.
|
|
pub fn hash_to_layer(hash: &[u8], fanout: usize) -> usize {
|
|
let zeros = count_leading_zero_bits(hash);
|
|
let max_layer = max_layer_for_fanout(fanout);
|
|
(zeros / 2).min(max_layer)
|
|
}
|
|
|
|
pub fn key_to_layer(raw_key: &str, fanout: usize) -> usize {
|
|
hash_to_layer(&hash_key(raw_key), fanout)
|
|
}
|
|
|
|
/// Encode a record key for storage in an MST entry.
|
|
///
|
|
/// Per the atproto MST spec
|
|
/// (<https://atproto.com/specs/data-model-repo#node-data>) the `k`
|
|
/// field is `base64url(sha256(record_key_utf8_bytes))`. Hashing
|
|
/// first ties the layer distribution to the cryptographic digest
|
|
/// of the key — under pre-image resistance, an attacker can't
|
|
/// craft keys that all land at the maximum layer by sorting their
|
|
/// bytes a certain way.
|
|
///
|
|
/// Decoding returns the raw 32-byte hash bytes; callers that need
|
|
/// the original key string have to keep it alongside. Cross-crate
|
|
/// callers passing `vec::Vec<u8>` vs `[u8; 32]` will need a trivial
|
|
/// .as_slice() conversion at the comparison site.
|
|
pub fn encode_key(raw_key: &str) -> String {
|
|
use base64::Engine;
|
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash_key(raw_key))
|
|
}
|
|
|
|
/// Inverse of [`encode_key`]: round-trip the base64url string back
|
|
/// to the 32-byte SHA-256 hash. Rejects anything that doesn't decode
|
|
/// to exactly 32 bytes — i.e. catches the old `base64url(raw_key)`
|
|
/// encoding that predates this commit, which makes it easy to spot
|
|
/// incompatibilities during migration.
|
|
pub fn decode_key(encoded: &str) -> Result<Vec<u8>> {
|
|
use base64::Engine;
|
|
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
|
.decode(encoded.as_bytes())
|
|
.map_err(|e| anyhow!("invalid base64url key `{encoded}`: {e}"))?;
|
|
if bytes.len() != 32 {
|
|
return Err(anyhow!(
|
|
"decoded key `{encoded}` is {} bytes; expected 32 (sha256 hash per the atproto MST spec)",
|
|
bytes.len()
|
|
));
|
|
}
|
|
Ok(bytes)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn max_layer_for_fanout_8() {
|
|
assert_eq!(max_layer_for_fanout(8), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn max_layer_for_fanout_16() {
|
|
assert_eq!(max_layer_for_fanout(16), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn max_layer_for_fanout_1() {
|
|
assert_eq!(max_layer_for_fanout(1), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn count_leading_zeros_all_zero() {
|
|
let h = [0u8; 32];
|
|
assert_eq!(count_leading_zero_bits(&h), 256);
|
|
}
|
|
|
|
#[test]
|
|
fn count_leading_zeros_one_bit() {
|
|
let mut h = [0u8; 32];
|
|
h[0] = 0b0000_0001;
|
|
assert_eq!(count_leading_zero_bits(&h), 7);
|
|
}
|
|
|
|
#[test]
|
|
fn count_leading_zeros_one_nibble() {
|
|
let mut h = [0u8; 32];
|
|
h[0] = 0x0f;
|
|
assert_eq!(count_leading_zero_bits(&h), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn count_leading_zeros_byte_boundary() {
|
|
let mut h = [0u8; 32];
|
|
h[2] = 0x80;
|
|
assert_eq!(count_leading_zero_bits(&h), 16);
|
|
let mut h = [0u8; 32];
|
|
h[2] = 0x01;
|
|
assert_eq!(count_leading_zero_bits(&h), 23);
|
|
}
|
|
|
|
#[test]
|
|
fn key_to_layer_zero_layer() {
|
|
let layer = key_to_layer("com.example.foo/abc", 8);
|
|
assert!(layer <= 2);
|
|
}
|
|
} |