A lexicon-driven AppView for ATProto.
0

Configure Feed

Select the types of activity you want to include in your feed.

happyview / src / cid_verify.rs
9.0 kB 238 lines
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}