fix(repo): spec-correct MST and hardened DAG-CBOR / CAR parsing #147
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!147
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-111-mst-cbor"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #111.
Correctness: MST
land every entry'st, written as CBORnullwhen absent. The empty tree hashes tobafyreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvske7mr3hpm.nulllinks.MstNodeData.FromBytesreadsnull(and, leniently, omitted)l/t.MerkleSearchTree.Deserializenow loads realgetRepotrees.ComputeRootCid/Serialize/SerializeProof. Empty ranges become null links, and ranges whose keys all sit lower get one entry-less wrapper node per skipped layer. Any edit sequence therefore yields the bulk-build root by construction, and a seeded property test checks it anyway (100 trials × 150 random add/update/delete ops, against a shuffled bulk build and a Serialize→Deserialize→Validate round trip).isValidMstKey:collection/rkey, charsetA-Z a-z 0-9 _ ~ - : ., ≤ 1024 characters.SerializeProofnow ports the referencegetCoveringProof(the key's path plus the left and right neighbour paths). Relays need those nodes to invert a#commit. It is a superset of the old path-only proof.Validate()now rebuilds and compares. AfterDeserializeit checks that the canonical rebuild equals the loaded root. For in-memory trees it is trivially true.ComputeNodeCid/SerializeNodeandCreate(entries)/CreateFromEntriesare deduplicated.MstNodeData,MstTreeEntryandMstKeyDepthare nowinternal.Security: untrusted input
PrefixLen:0 ≤ p ≤ previousKey.Lengthis enforced inMstNodeData.FromBytesbefore any slicing.pis read as Int64, so out-of-range, negative, 2^31, 2^40,long.MinValueand2^64-1all fail withFormatException.MerkleSearchTree.Deserializealso rejects:CarReaderheader: replaced the recursive hand-written CBOR parser withCborReader+SkipValue. A 100 000-deep nested header value is now skipped without a crash, and a truncated one throwsFormatException. The header must carryversion, its roots must be tag-42 links with the0x00prefix, and no trailing bytes are allowed.CarReaderframing: every length (header, block, CID digest) is checked against the remaining input before it is narrowed or sliced. Varints must be ≤ 9 bytes and minimal.CarReader.FromBytesrejects CIDv0 blocks, andVerifyBlockCidreports them asUnknownCodec.System.Text.Jsondefault, counting the$link/$byteswrappers) in the shared transcoder and inDagCborDecoder.TryValidate. Chains of unknown tags are iterated, not recursed.DagCborDecoder.Decodenormalizes all malformed input toFormatException.FirehoseEventParser.Parsereturnsnullas before.Consolidation
DagCborLink(internal) is the one null-aware tag-42 read/write helper. It is used byCarWriter,CarReader,MstNodeData,RepoCommit,DagCborEncoder,SpaceRepoCarand the transcoder, and throwsFormatExceptionfor malformed links. Writes go through a stack buffer.DagCborJson(internal) is the one-pass DAG-CBOR→Utf8JsonWritertranscoder, inWrapped($link/$bytes) andFlattened(firehose models) forms, with the depth limit.DagCborDecoder.Decodeparses its output withJsonElement.ParseValue, with noJsonNodetree.DagCborEncoderwrites withCborConformanceMode.Laxover its own sort and detects duplicate keys itself, so the writer no longer sorts maps a second time.CompareCanonicalnow compares equal-length keys by UTF-8 bytes. Before, it used UTF-16 order, which was wrong where U+10000+ meets U+E000–U+FFFF; the Canonical writer's re-sort had been hiding that bug. Output is asserted byte-identical to a Canonical-writer reference on 500 random documents with adversarial keys.SpaceRepoCar.Serializewrites the index map directly withCborWriter, byte-identical to the oldJsonObject→encoder path (asserted).DagCborDecoder.DecodeToNode,DagCborEncoder.ComputeCid,CarBlock.DataLength, and the unused locals inCarReader.CidComputation.TryDecodeCidStringandVerifyare kept, as areCarWriter.WriteToAsyncandTid.FromInt64/ToInt64.Breaking changes (migration)
MerkleSearchTree.Add/Createreject invalid MST keys;Createrejects duplicate keysMerkleSearchTree.CreateFromEntriesremovedMerkleSearchTree.Create(entries)MstNodeData,MstTreeEntry,MstKeyDepthinternalMerkleSearchTreeDagCborDecoder.DecodethrowsFormatException(wasInvalidOperationException/CborContentException);Deserializedepth →FormatExceptionFormatExceptionDagCborDecoder.DecodeToNode,DagCborEncoder.ComputeCid,CarBlock.DataLengthremovedJsonSerializer.SerializeToNode(Decode(b)),CidComputation.ComputeForDagCbor,Data.LengthCarReaderrejects CIDv0 blocks, headers withoutversion, untagged roots, non-minimal varintsVerification
mst.test.tsknown maps and edge cases: 11 roots, including add/delete sequencescommon_prefix.json(13),key_heights.json(9) andexample_keys.txt(156 heights)firehose/commit-proof-fixtures.json: 6 fixtures × (root before, root after,blocksInProof⊆SerializeProof)@atproto/repo0.10.14 from a deterministic LCG key generator that is reproduced in C#:getCoveringProofexactlydata-model-fixtures.jsonvectors (bytes and CID) encode exactly and decode→re-encode to identical bytes.p, CIDv0, truncated and overlong varints, oversized lengths, malformed headers and nodes, and malformed CID links. All throwFormatException, or returnnullfrom the firehose parser.RepoWritePathTestswrites 120 records → MST → signedRepoCommit→CarWriter. It reads the result back withCarReader(verifyBlockCids: true), verifies the signature viaFirehoseVerifier.ExtractSignedView, then runsDeserializeandValidate()and checks every record. A second test carries a commit diff with its covering proof.CompareCanonicalto UTF-16 order fails both encoder-ordering tests.Benchmarks
Release build, best of 5, base
7f672c0vs this branch:DagCborDecoder.Decode(post)DagCborEncoder.Encode(post)FirehoseEventParser.Parse(#commit)ComputeRootCid, 10k entriesSerialize, 10k entriesSerializeProof(1 key), 10k entriesCarReader.FromBytes(verify), 10k-entry MSTThe "before" MST numbers are for the old, non-conformant tree.
Deliberately deferred / notes
ComputeRootCid/Serialize/SerializeProofrebuilds the node structure (≈3.7 ms per call at 10k entries). This was chosen for obvious correctness. Caching subtree CIDs between edits is a possible later optimization, and the property test and vectors would guard it.Deserializedoes not re-hash blocks. It documents passing CID-verified blocks, andValidate()catches any mismatch against the root.DagCborDecoderstill reads inLaxmode, so duplicate map keys and invalid UTF-8 are accepted as before. Rejecting them (DRISL strictness) would be a separate behaviour change.FirehoseVerifier.cs:11still claims MST-proof verification. That belongs to the streaming work package (WP12 T8) and was not touched here.SpaceRepoCar.csis the only Spaces file touched, anddocs/crypto.mdanddocs/api-reference.mdwere updated for the removed or now-internal APIs.🤖 Generated with Claude Code
@claude /review
🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
✅ Verdict: approve — no blocking issues found.
This is an extensive, carefully engineered rewrite of the MST and DAG-CBOR/CAR parsing paths, and the diff is internally consistent with the PR description's claims. I traced through the trickiest pieces by hand:
Build/BuildRangeinMerkleSearchTree.cs): the layer-by-layer construction correctly produces null links for empty ranges and entry-less wrapper nodes for gaps whose keys sit at lower layers, matching the described spec-conformant shape. Recursion terminates safely (base case onstart == endregardless oflayer, so no negative-layer issues).MstNodeData.FromBytesprefix-length validation:pis bounds-checked against the previous key's reconstructed byte length before any slicing, closing the SEC-4-class bug the PR describes.CarReaderframing: header/block/digest lengths are all checked against the actually-remaining bytes (via a truncated span for CID digest bounds) before narrowing/slicing, andReadUvarintcorrectly enforces the 9-byte/63-bit cap with minimality checking.DagCborEncoder.CompareCanonical's UTF-16→UTF-8-byte-order fix: I worked through the surrogate remapping (CodePointOrder) by hand against the UTF-8 lead-byte-ordering property and it is correct — supplementary code points are correctly ranked above all BMP code points, and the char-by-char walk stays index-aligned with the true codepoint sequence as long as it's scanning for the first difference (which it is).SerializeProof's covering-proof mirror ofgetCoveringProof: complex, but theChildren()/FindGreaterOrEqualEntryindexing is consistent, and this is exactly the kind of logic the PR's 8 pinned interop covering-proof vectors are built to catch.I did not execute the test suite myself, so the vector/benchmark numbers are taken on faith, but nothing in the diff contradicts them and the code changes are coherent with the stated design.
One minor, non-blocking observation below about a depth-limit semantic mismatch between
DagCborDecoder.TryValidateandDecode.⏱ 3m 32s · 1 turns · tokens: 74.4k in / 21.2k out (+23.9k cache-read)
📜 Full session transcript
TryValidate's depth counter only increments on raw CBOR map/array nesting, whileDecode's JSON-emitting depth counter (DagCborJson) also charges +1 for the$link/$byteswrapper object a CID or byte string produces. So input that sits at exactly the container depth limit with a CID/bytes value at the deepest level can passTryValidate(..., out error) == trueyet still throwFormatExceptionfromDecode. Not a security issue (Decode is the stricter one), but a caller relying onTryValidateas a pre-check beforeDecodecould be surprised. Worth a doc note or aligning the two depth counts if that's not intentional.🔧 Let Claude fix this — tick the box and claude-bot picks up the review above, on this PR branch. Nothing runs until you do.
941ed782c5dd0dd566fdRebased onto main to resolve a CHANGELOG-only conflict (#148 merged).
verify-rebase: the PR's patch outsideCHANGELOG.mdis byte-identical between the approved head941ed782and the new headdd0dd566(4344 changed lines, hunk offsets ignored). CI is green on the new head, so the approval carries over (maintainer-approved policy for CHANGELOG-only rebases).