A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236use cid::Cid;use cid::multihash::Multihash;use ipld_core::ipld::Ipld;use serde_json::Value;use sha2::{Digest, Sha256};use std::collections::BTreeMap;use std::str::FromStr;
const DAG_CBOR_CODEC: u64 = 0x71;const SHA2_256_CODE: u64 = 0x12;
/// The codec for `$bytes` in the atproto data model — the one place that/// encoding is decided, so every producer and consumer of `$bytes` agrees.////// The data model specifies RFC-4648 §4 base64 in which `=` padding is/// **optional**. Both forms name the same bytes, so both must decode:/// jetstream emits `$bytes` unpadded, our own signer emits it padded, and a/// signature we wrote ourselves comes back off the firehose two characters/// shorter. A padding-strict engine reads that as corruption and reports a/// record we signed as unverifiable — which downstream becomes an accusation/// of forgery aimed at the record's author.////// Encoding is unchanged from `general_purpose::STANDARD` (padding on,/// standard alphabet); only the decoder is made indifferent.pub const BYTES_B64: base64::engine::general_purpose::GeneralPurpose = base64::engine::general_purpose::GeneralPurpose::new( &base64::alphabet::STANDARD, base64::engine::general_purpose::GeneralPurposeConfig::new() .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent), );
/// Outcome of checking a claimed CID against a record's content.#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum CidCheck { /// The claimed CID matches the CID recomputed from the record content. Match, /// The claimed CID is present but does not match the record content /// (malformed or content-mismatched) — the caller should reject the record. Mismatch, /// Verification was not attempted or not possible (no claimed CID, or the /// value could not be encoded to DAG-CBOR) — the caller should proceed /// without rejecting, to avoid dropping records over an encoder limitation. Skipped,}
fn atproto_json_to_ipld(value: &Value) -> Option<Ipld> { Some(match value { Value::Null => Ipld::Null, Value::Bool(b) => Ipld::Bool(*b), Value::Number(n) => { if let Some(i) = n.as_i64() { Ipld::Integer(i as i128) } else if let Some(u) = n.as_u64() { Ipld::Integer(u as i128) } else if let Some(f) = n.as_f64() { Ipld::Float(f) } else { return None; } } Value::String(s) => Ipld::String(s.clone()), Value::Array(arr) => { let mut items = Vec::with_capacity(arr.len()); for v in arr { items.push(atproto_json_to_ipld(v)?); } Ipld::List(items) } Value::Object(obj) => { if obj.len() == 1 { if let Some(Value::String(link)) = obj.get("$link") { return Some(Ipld::Link(Cid::from_str(link).ok()?)); } if let Some(Value::String(b64)) = obj.get("$bytes") { let bytes = base64::Engine::decode(&BYTES_B64, b64).ok()?; return Some(Ipld::Bytes(bytes)); } } let mut map = BTreeMap::new(); for (k, v) in obj { map.insert(k.clone(), atproto_json_to_ipld(v)?); } Ipld::Map(map) } })}
pub fn record_to_dag_cbor(value: &Value) -> Option<Vec<u8>> { let ipld = atproto_json_to_ipld(value)?; serde_ipld_dagcbor::to_vec(&ipld).ok()}
pub fn dag_cbor_cid(cbor: &[u8]) -> Option<Cid> { let digest = Sha256::digest(cbor);
// multihash: <code=0x12><len=0x20><digest> let mut mh_bytes = Vec::with_capacity(2 + digest.len()); mh_bytes.push(SHA2_256_CODE as u8); mh_bytes.push(digest.len() as u8); mh_bytes.extend_from_slice(&digest); let multihash = Multihash::<64>::from_bytes(&mh_bytes).ok()?;
Some(Cid::new_v1(DAG_CBOR_CODEC, multihash))}
pub fn compute_record_cid(value: &Value) -> Option<Cid> { dag_cbor_cid(&record_to_dag_cbor(value)?)}
pub fn verify_record_cid(claimed_cid: &str, value: &Value) -> CidCheck { if claimed_cid.is_empty() { return CidCheck::Skipped; } let computed = match compute_record_cid(value) { Some(cid) => cid, None => return CidCheck::Skipped, }; match Cid::from_str(claimed_cid) { Ok(claimed) if claimed == computed => CidCheck::Match, _ => CidCheck::Mismatch, }}
#[cfg(test)]mod tests { use super::*; use serde_json::json;
const EMPTY_MAP_CID: &str = "bafyreigbtj4x7ip5legnfznufuopl4sg4knzc2cof6duas4b3q2fy6swua"; const A1_CID: &str = "bafyreihltcnuuyqp2jm24aqydpnlj7b6w3ogwrplomrjtg5rifv44mmjey"; const ORDERING_CID: &str = "bafyreihbaf6v4gjeo76rl6ncekrny5lwbgyjf7zdw2m7w77xsjm3xvige4";
#[test] fn computes_known_cid_for_empty_map() { assert_eq!( compute_record_cid(&json!({})) .expect("encodable") .to_string(), EMPTY_MAP_CID ); }
#[test] fn computes_known_cid_for_small_record() { assert_eq!( compute_record_cid(&json!({ "a": 1 })) .expect("encodable") .to_string(), A1_CID ); }
#[test] fn uses_length_first_canonical_key_ordering() { // Input order is deliberately NOT the canonical order. assert_eq!( compute_record_cid(&json!({ "aa": 2, "b": 1 })) .expect("encodable") .to_string(), ORDERING_CID ); }
/// `=` padding on `$bytes` is optional in the data model, so the same /// bytes may arrive either way and must yield the same CID. If the /// unpadded form fails to encode, `verify_record_cid` degrades to /// `Skipped` and the record is indexed with its CID unchecked. #[test] fn padded_and_unpadded_bytes_produce_the_same_cid() { let padded = json!({ "sig": { "$bytes": "3q2+7w==" } }); let unpadded = json!({ "sig": { "$bytes": "3q2+7w" } });
let padded_cid = compute_record_cid(&padded).expect("padded is encodable"); assert_eq!( compute_record_cid(&unpadded), Some(padded_cid), "unpadded $bytes must encode to the same CID" ); }
#[test] fn verify_matches_recomputed_cid() { let value = json!({ "$type": "app.bsky.feed.post", "text": "hello", "createdAt": "2023-01-01T00:00:00.000Z" }); let cid = compute_record_cid(&value).expect("encodable").to_string(); assert_eq!(verify_record_cid(&cid, &value), CidCheck::Match); }
#[test] fn verify_detects_content_mismatch() { assert_eq!( verify_record_cid(EMPTY_MAP_CID, &json!({ "text": "hello" })), CidCheck::Mismatch ); }
#[test] fn verify_treats_unparseable_claimed_cid_as_mismatch() { assert_eq!( verify_record_cid("not-a-real-cid", &json!({ "text": "hi" })), CidCheck::Mismatch ); }
#[test] fn verify_skips_when_no_claimed_cid() { assert_eq!( verify_record_cid("", &json!({ "text": "hi" })), CidCheck::Skipped ); }
#[test] fn link_encodes_as_ipld_link_not_string() { let as_link = json!({ "ref": { "$link": EMPTY_MAP_CID } }); let as_string = json!({ "ref": EMPTY_MAP_CID }); assert_ne!( compute_record_cid(&as_link).expect("encodable"), compute_record_cid(&as_string).expect("encodable"), ); }
#[test] fn bytes_encode_as_byte_string_not_text() { let as_bytes = json!({ "data": { "$bytes": "aGVsbG8=" } }); // "hello" let as_string = json!({ "data": "aGVsbG8=" }); assert_ne!( compute_record_cid(&as_bytes).expect("encodable"), compute_record_cid(&as_string).expect("encodable"), ); }}