War on CARs

A widespread authenticity problem in the Authenticated Transfer Protocol, from real-world use of Content-Addressible aRchives


Hello and welcome to data service diaries! Expect atproto deep cuts and niche database tricks. General announcements will stay over at Microcosm updates!


Disclosure: this is a hit(-by-a-CAR) piece, by me, the author of an alternative atproto repo export format called STAR-lite.


Authenticated Transfer. Any time you grab some data in atproto, that "AT" prefix guarantees the data's authenticity. You don't need to extend your trust boundary to subscribe to someone else' relay or pull a whole repo archive from Hubble -- those services can't tamper with atproto data1 without you noticing.

At least, as long as you do all the checks. Unfortunately, there seems to be a sort of last-mile complacency around checking the contents of CARs (one could call it a... close-to-home effect?2):

I did a wide (non-comprehensive) survey of atproto projects, and found that the Atmosphere frequently fails to verify CAR blocks.

Verifies CAR block CIDs
Verifies on some paths
No block CID verification
Bluesky's stuff
🍏🍏🍏 4 (57%)
🌝 2 (29%)
πŸ’” 1 (14%)
CAR libraries
πŸ€ 6 (29%)
πŸ₯ 2 (10%)
πŸ₯€πŸ₯€πŸ₯€ 13 (62%)
atproto libraries
🐸🐸 4 (33%)
πŸ‚πŸ‚ 4 (33%)
🎈🎈 4 (33%)
atproto apps
πŸͺ΄ 5 (31%)
πŸ‹ 1 (6%)
πŸ™πŸ™πŸ™ 10 (62%)

I think these results are driven by a CAR-specific hazard that has been under-appreciated, and which undermines authenticity in the protocol.

Authenticating atproto data

Every account in the Atmosphere has a key-value data store and a stable identifier ("DID") that resolves to a public signing key. A Merkle Search Tree bridges signatures to data, verifiable in five easy steps:

diagram: a big "CAR file" box, an external "Identity" box, and three groups of internal "blocks", with numbered arrows between them.

Blue "Commit object block" with three items:
- DID with an arrow out to Identity
- MST root CID with an arrow to the MST blocks
- Signature with an inbound arrow from Identity

The external Identity block (yellow/orange) has one item:
- Public key (points back into the CAR file, to Signature)

Three MST blocks (purple):
- a "root" block with key1 and key3
- blocks for key2 and key4
all "keyN"s are followed by "CID" (green)

Record blocks (green) a set of four blocks (record1, record2, record3, record4).

There are green arrows from MST key1's CID to record1, and from MST key2's CID to record2.
  1. πŸ’ͺ Resolve the DID to its public key!
  2. πŸ’ͺVerify the signature with that key!
  3. πŸ‘€ Follow the CID link -> root of Merkle Search Tree!
  4. πŸ‘€ Traverse MST to enumerate keys and links-to-values!
  5. πŸ‘€ Look up values by CID link!

πŸ’β€β™€οΈ CIDs are Content IDentifiers, a hash of the data they identify, and part of the atproto data model. Any change in key-value data "bubbles up" and changes the MST root CID, which the signed commit covers.

Implementations tend to get steps 1 and 2 right (πŸ’ͺ). You have to get them at least mostly right, or else things don't work. This is a nice property!

In steps 3, 4, and 5 (πŸ‘€), CARs work against this property. Things mostly work if you do it wrong, while looking deceptivelyβ€”even obviously!β€”right.

The body of a CAR file is a sequence of [len, CID, bytes],

| ---------------- CAR body ---------------- |
[ len | CID | bytes ] [ len | CID | bytes ] …

where CID is supposed to(!) be the CID of bytes, and bytes is a serialized block. The CAR-format CID is the same CID as the atproto data model CID.

CARs index blocks by the exact links we need to look them up by! This should be a nice thing! And yet...

CAR reader libraries don't verify block CIDs

CAR libraries give you a stream of (CID, bytes) pairs.

CAR libraries do not check* that the CID actually matches the bytes they are giving you.

Your CAR library will happily give you wrong (CID, bytes) pairs from a malicious / buggy / tampered-with archive!

I'm being repetitive, but this was really shocking to me when I found out!


Why don't CAR libraries verify CIDs?

Because CIDs come from the IPLD multihash / multiformats / multicodec / multi-whatever universe, with 653 codecs registered at time of writing, meant to grow over time. Should a CAR library support all CID types? Some subset? (which subset? who picks?)

Almost all CAR libraries don't try to answer this, and using them safely requires manually checking each block. Most libraries don't warn about this in their docs. Most atproto projects don't have the check.

*Positive exceptions:

🐸 ipld/go-car has a registry / hash-lookup system, and does verify contents. Golang projects like Bluesky's back-end and Streamplace benefit from this.

🐸 carbox (python) and elixir-dasl (elixir) are made-for-atproto CAR libraries, and explicitly expect atproto CIDs.

πŸ’β€β™€οΈ CIDs in the atproto data model are always sha256

πŸ‘Ή ~All other notable libraries surveyed pass unverified contents.


Can this be fixed?

Yeah of course, switch everything to STAR-lite! I'm mostly joking.

The root of the problem is the inclusion of CIDs in the archive at all. They are redundant by definition, since the contents are right there! And you have to recompute the CID from the contents anyway, to check if the CID is right! Having it can only help implementations do the wrong thing.

Dropping block CIDs, forcing them to be computed from the content, would get that nice property:

You have to get it least mostly right, or else things don't work.

You can't have that with CARs.


πŸ’β€β™€οΈ I think this is a form of Parse, don't validate.


Can this be mitigated?

Yeah probably!

Maybe we can make insecure atproto projects happen less frequent / less likely:

  1. Call out this hazard in the IETF spec (issue opened), so that at least spec-following implementations are more likely to get it right.
  2. Periodically audit ecosystem repo libraries: fixes here are high-leverage, benefiting downstream apps.
  3. Encourage more CAR libraries to add block verification directly, or warn about the hazard in their docs if they don't. (eg @ipld/car and iroh-car both already have open issues)

I think those efforts could help in a limited way. But the CAR format is so simple that people (and agents!) do often (and will continue to) hand-roll parsers directly into their projects, with an evidently high risk of failing to verify contents.






🦐 this is the end, the article is over






Appendix 1: CARs in atproto spaces

The current atproto permissioned data draft specifies repository serialization in CAR format. For the purpose of this post, that's probably fine: The authenticity risk doesn't apply, because space data has no authenticity check at all! That's the trade for deniable (and non-rebroadcastable) data.

I still think CARs are a bad fit for spaces though, and I hope to make the case for using a STAR-lite spaces variant (or any safer streamable key-value-set alternative format!) anyway. Maybe if DASL adopts it, this could even be a realistic possibility.

Or maybe we start a bottom-up shift in the ecosystem, like with public-data STAR-lite archives. There are already multiple STAR-lite producers on the network, at least half a dozen consumers, and third-party implementations in languages I don't even know. Better formats win? :)



Appendix 2: Detailed survey results

Bluesky's stuff:

πŸ‹ Typescript libraries: sometimes. Its CAR reader defaults to always verifying, but its firehose consumer explicitly turns that off. It seems to think that MST structural consistency (from inductive proof transform?) is sufficient, but I'm pretty sure it's not. (MST aside, record contents wouldn't be covered by that. i didn't trace this all the way to POC).

🐊 PDS: verifies, inherited from typescript library

πŸ‹ Indigo: sometimes. Everything that uses go-car inherits its checks. The reader at repo/carutil/reader.go appears to have regressed, no longer recomputing block hashes.

πŸ₯¬ Tap: verifies, inherited from go-car. LoadCommitFromCAR might bypass the check for Commit Object lookup. I think that can only lead to confusing bugs, not authenticity weakness.

🌲 Relay: verifies, inherited from go-car.

πŸ₯¦ Jetstream v2: verifies, inherited from go-car.

πŸ’„ Jetstream v1: does not verify in its firehose consumer, so commit contents are likely not covered by the signature check.

CAR libraries:

CAR library
language
verifies CID on read?
javascript
πŸ’” no, open issue
typescript
πŸ’” no, but see
@atcute/repo
(see next table)
golang
🐸 yes! rare find!
rust
πŸ’” no, open issue
rust
πŸ’” no
python
πŸ’” no
python
🐸 yes!
made for atproto
perl
πŸ’” no
elixir
πŸ’” no
elixir
🐸 yes!
made for atproto
javascript
πŸ’” no

atproto repo libraries

library
language
verifies CIDs?
rust
🌹 no, uses iroh-car without additional checks
rust
🐝 inconclusive, its CAR reader verifies but isn't wired in to its repo loader? (probably resolvable with effort)
rust
πŸ₯’ yes, both roll their own CAR readers with checks.
rust
πŸ’… yes (er... at least on the alpha releases)
rust
πŸŒ™ no (semi-manual parser), but verifies CIDs on some read paths, so corrupted content shouldn't propagate.
python
🐞 no
typescript
🦎 verifyRecord: yes
πŸ… repo iters (fromStream, fromUint8Array): no
ruby
πŸ’ no
elixir
πŸ«‘ yes, inherited from elixir-dasl
dart
πŸ“no
zig
πŸ₯‘ yes

atproto apps

app
language
verifies CIDs?
rust
🌿 rsky-relay: yes
πŸ₯ space-archiver: yes
🍎 rsky-pds: no (repo import)
🧱 wintermute: no
rust
🍟 no (repo-import)
uses iroh-car without additonal checks
rust
πŸ„ no
firehose: iroh-car without additonal checks
repo import: repo-stream version without checks
typescript
🐒 yes importrepo, via bluesky typescript library
typescript
πŸ“• no, bluesky typescript firehose consumer
javascript
πŸ¦‘ no, custom parser
dart
πŸ“ no
go
πŸƒ yes (fork of indigo before carutil regression)
go
🦜 yes (importrepo), inherited from go-car
zig
🌲 yes, inherited from zat
note: skips all event verification for identities not in cache, which can pass invalid CARs through
python
🍐 repo import: yes (carbox)
πŸ‘  firehose consumer: no (libipld)
C11
🧲 no (importrepo)
gleam
πŸ–οΈ no
C#
🌢️ no

I hope I got everything right in the survey. It was kind of a lot to pull together. Corrections welcome.





  1. There are actually a few ways that rebroadcasted data can be tampered with and still pass all checks, but these are constrained. Only the MST root node's hash is bound by an atproto commit object's signature, so you can do things like adding unnecessary (but valid) blocks (effectively a noop), or removing necessary blocks (authenticity-passing data but fails to apply). Firehose commit events' operation lists are not bound by any signature, so they can be modified arbitrarily, but only lists valid for transition between real repo states will pass sync1.1 inductive proof consistency check. Services can serve authentic-but-stale data or refuse to broadcast events, which is a kind of repo-level denial-of-service effect. None of these enable a malicious service to convince you to accept inauthentic data.
  2. The effect in automobiles does hold when controlled for exposure (ie., people have to get to/from home no matter where they're going so they end up spending a lot of time driving near home, so of course there are a lot of accidents near home): "Roads within 11Β km (6.8Β miles) of home accounted for half of all travel and 62% of all crashes". Thanks New Zealand: https://www.sciencedirect.com/science/article/abs/pii/S092575351730783X