A lexicon-driven AppView for ATProto.
1//! Record CID content verification (security review finding L9).
2//!
3//! Records arriving from Jetstream and from PDS backfill carry a CID that was,
4//! until now, trusted verbatim. For backfill the source PDS is attacker-
5//! controllable (via the DID document), so a hostile PDS could serve a record
6//! whose stored CID does not match its content. This module recomputes the CID
7//! from the record's canonical DAG-CBOR encoding so a mismatch can be detected
8//! and the record rejected before it is indexed.
9//!
10//! The canonical encoding (length-first map-key ordering, minimal integers,
11//! CID links as CBOR tag 42, `$bytes` as byte strings) is delegated to
12//! `serde_ipld_dagcbor` — the same DAG-CBOR codec the atproto Rust ecosystem
13//! uses — rather than hand-rolled, so the recomputed CID matches what a PDS
14//! produces. The only atproto-specific step is mapping the JSON `$link` /
15//! `$bytes` conventions onto IPLD before encoding.
16
17use cid::Cid;
18use cid::multihash::Multihash;
19use ipld_core::ipld::Ipld;
20use serde_json::Value;
21use sha2::{Digest, Sha256};
22use std::collections::BTreeMap;
23use std::str::FromStr;
24
25/// DAG-CBOR IPLD codec.
26const DAG_CBOR_CODEC: u64 = 0x71;
27/// SHA2-256 multihash code.
28const SHA2_256_CODE: u64 = 0x12;
29
30/// Outcome of checking a claimed CID against a record's content.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum CidCheck {
33 /// The claimed CID matches the CID recomputed from the record content.
34 Match,
35 /// The claimed CID is present but does not match the record content
36 /// (malformed or content-mismatched) — the caller should reject the record.
37 Mismatch,
38 /// Verification was not attempted or not possible (no claimed CID, or the
39 /// value could not be encoded to DAG-CBOR) — the caller should proceed
40 /// without rejecting, to avoid dropping records over an encoder limitation.
41 Skipped,
42}
43
44/// Convert an atproto JSON value into an IPLD value, honoring atproto's
45/// `{"$link": "<cid>"}` (CID link) and `{"$bytes": "<base64>"}` (byte string)
46/// conventions. Returns `None` if the value can't be represented (e.g. a
47/// non-finite number, or a malformed `$link`/`$bytes`).
48fn atproto_json_to_ipld(value: &Value) -> Option<Ipld> {
49 Some(match value {
50 Value::Null => Ipld::Null,
51 Value::Bool(b) => Ipld::Bool(*b),
52 Value::Number(n) => {
53 if let Some(i) = n.as_i64() {
54 Ipld::Integer(i as i128)
55 } else if let Some(u) = n.as_u64() {
56 Ipld::Integer(u as i128)
57 } else if let Some(f) = n.as_f64() {
58 Ipld::Float(f)
59 } else {
60 return None;
61 }
62 }
63 Value::String(s) => Ipld::String(s.clone()),
64 Value::Array(arr) => {
65 let mut items = Vec::with_capacity(arr.len());
66 for v in arr {
67 items.push(atproto_json_to_ipld(v)?);
68 }
69 Ipld::List(items)
70 }
71 Value::Object(obj) => {
72 // atproto encodes a CID link as {"$link": "<cid>"} and raw bytes as
73 // {"$bytes": "<base64>"} — single-key objects that must become an
74 // IPLD Link / Bytes rather than a map.
75 if obj.len() == 1 {
76 if let Some(Value::String(link)) = obj.get("$link") {
77 return Some(Ipld::Link(Cid::from_str(link).ok()?));
78 }
79 if let Some(Value::String(b64)) = obj.get("$bytes") {
80 let bytes =
81 base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)
82 .ok()?;
83 return Some(Ipld::Bytes(bytes));
84 }
85 }
86 let mut map = BTreeMap::new();
87 for (k, v) in obj {
88 map.insert(k.clone(), atproto_json_to_ipld(v)?);
89 }
90 Ipld::Map(map)
91 }
92 })
93}
94
95/// Recompute the DAG-CBOR CID (CIDv1, dag-cbor codec, sha2-256) for a record
96/// value. `serde_ipld_dagcbor` produces the canonical encoding (length-first
97/// key ordering, minimal integers, tag-42 links), matching what a PDS emits.
98/// Returns `None` if the value cannot be represented as DAG-CBOR.
99pub fn compute_record_cid(value: &Value) -> Option<Cid> {
100 let ipld = atproto_json_to_ipld(value)?;
101 let cbor = serde_ipld_dagcbor::to_vec(&ipld).ok()?;
102 let digest = Sha256::digest(&cbor);
103
104 // multihash: <code=0x12><len=0x20><digest>
105 let mut mh_bytes = Vec::with_capacity(2 + digest.len());
106 mh_bytes.push(SHA2_256_CODE as u8);
107 mh_bytes.push(digest.len() as u8);
108 mh_bytes.extend_from_slice(&digest);
109 let multihash = Multihash::<64>::from_bytes(&mh_bytes).ok()?;
110
111 Some(Cid::new_v1(DAG_CBOR_CODEC, multihash))
112}
113
114/// Check a claimed CID string against a record value.
115///
116/// - `Skipped` when there is no claimed CID, or the value can't be encoded to
117/// DAG-CBOR (we never reject a record over an encoder limitation).
118/// - `Mismatch` when the claimed CID is malformed, or is a valid CID that does
119/// not match the recomputed content CID.
120/// - `Match` otherwise.
121pub fn verify_record_cid(claimed_cid: &str, value: &Value) -> CidCheck {
122 if claimed_cid.is_empty() {
123 return CidCheck::Skipped;
124 }
125 let computed = match compute_record_cid(value) {
126 Some(cid) => cid,
127 None => return CidCheck::Skipped,
128 };
129 match Cid::from_str(claimed_cid) {
130 Ok(claimed) if claimed == computed => CidCheck::Match,
131 _ => CidCheck::Mismatch,
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use serde_json::json;
139
140 // Ground-truth CIDs, computed independently from the raw DAG-CBOR bytes
141 // (see the L9 implementation notes): CIDv1, dag-cbor codec, sha2-256.
142 const EMPTY_MAP_CID: &str = "bafyreigbtj4x7ip5legnfznufuopl4sg4knzc2cof6duas4b3q2fy6swua";
143 const A1_CID: &str = "bafyreihltcnuuyqp2jm24aqydpnlj7b6w3ogwrplomrjtg5rifv44mmjey";
144 // {"b":1,"aa":2} under length-first canonical key ordering. The naive
145 // raw-string ordering ("aa" < "b") would give a DIFFERENT CID, so this
146 // vector proves the encoder uses DAG-CBOR canonical ordering.
147 const ORDERING_CID: &str = "bafyreihbaf6v4gjeo76rl6ncekrny5lwbgyjf7zdw2m7w77xsjm3xvige4";
148
149 #[test]
150 fn computes_known_cid_for_empty_map() {
151 assert_eq!(
152 compute_record_cid(&json!({}))
153 .expect("encodable")
154 .to_string(),
155 EMPTY_MAP_CID
156 );
157 }
158
159 #[test]
160 fn computes_known_cid_for_small_record() {
161 assert_eq!(
162 compute_record_cid(&json!({ "a": 1 }))
163 .expect("encodable")
164 .to_string(),
165 A1_CID
166 );
167 }
168
169 #[test]
170 fn uses_length_first_canonical_key_ordering() {
171 // Input order is deliberately NOT the canonical order.
172 assert_eq!(
173 compute_record_cid(&json!({ "aa": 2, "b": 1 }))
174 .expect("encodable")
175 .to_string(),
176 ORDERING_CID
177 );
178 }
179
180 #[test]
181 fn verify_matches_recomputed_cid() {
182 let value = json!({
183 "$type": "app.bsky.feed.post",
184 "text": "hello",
185 "createdAt": "2023-01-01T00:00:00.000Z"
186 });
187 let cid = compute_record_cid(&value).expect("encodable").to_string();
188 assert_eq!(verify_record_cid(&cid, &value), CidCheck::Match);
189 }
190
191 #[test]
192 fn verify_detects_content_mismatch() {
193 // A structurally valid CID that belongs to a different value.
194 assert_eq!(
195 verify_record_cid(EMPTY_MAP_CID, &json!({ "text": "hello" })),
196 CidCheck::Mismatch
197 );
198 }
199
200 #[test]
201 fn verify_treats_unparseable_claimed_cid_as_mismatch() {
202 assert_eq!(
203 verify_record_cid("not-a-real-cid", &json!({ "text": "hi" })),
204 CidCheck::Mismatch
205 );
206 }
207
208 #[test]
209 fn verify_skips_when_no_claimed_cid() {
210 assert_eq!(
211 verify_record_cid("", &json!({ "text": "hi" })),
212 CidCheck::Skipped
213 );
214 }
215
216 #[test]
217 fn link_encodes_as_ipld_link_not_string() {
218 // {"$link": cid} must encode as a CBOR tag-42 link, so it produces a
219 // different CID than the same CID carried as a plain string.
220 let as_link = json!({ "ref": { "$link": EMPTY_MAP_CID } });
221 let as_string = json!({ "ref": EMPTY_MAP_CID });
222 assert_ne!(
223 compute_record_cid(&as_link).expect("encodable"),
224 compute_record_cid(&as_string).expect("encodable"),
225 );
226 }
227
228 #[test]
229 fn bytes_encode_as_byte_string_not_text() {
230 // {"$bytes": base64} must encode as a CBOR byte string.
231 let as_bytes = json!({ "data": { "$bytes": "aGVsbG8=" } }); // "hello"
232 let as_string = json!({ "data": "aGVsbG8=" });
233 assert_ne!(
234 compute_record_cid(&as_bytes).expect("encodable"),
235 compute_record_cid(&as_string).expect("encodable"),
236 );
237 }
238}