a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
105
fork

Configure Feed

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

at trunk 244 lines 5.9 kB view raw
1import * as CAR from '@atcute/car'; 2import * as CBOR from '@atcute/cbor'; 3import * as CID from '@atcute/cid'; 4import type { PublicKey } from '@atcute/crypto'; 5import type { AtprotoDid } from '@atcute/lexicons/syntax'; 6import { isNodeData, type NodeData } from '@atcute/mst'; 7import { decodeUtf8From, encodeUtf8, toSha256 } from '@atcute/uint8array'; 8 9import { isCommit, type Commit } from './types.ts'; 10 11type BlockMap = Map<string, Uint8Array>; 12 13export interface VerifiedRecord { 14 /** CID of the record */ 15 cid: string; 16 /** Record data */ 17 record: unknown; 18} 19 20export interface VerifyRecordOptions { 21 did?: AtprotoDid; 22 collection: string; 23 rkey: string; 24 publicKey?: PublicKey; 25 carBytes: Uint8Array; 26} 27 28export const verifyRecord = async ({ 29 did, 30 collection, 31 rkey, 32 publicKey, 33 carBytes, 34}: VerifyRecordOptions): Promise<VerifiedRecord> => { 35 // read the car 36 let blockmap: BlockMap; 37 let commit: Commit; 38 { 39 const reader = CAR.fromUint8Array(carBytes); 40 if (reader.header.data.roots.length !== 1) { 41 throw new Error(`car must have exactly one root`); 42 } 43 44 blockmap = new Map(); 45 for (const entry of reader) { 46 const cidString = CID.toString(entry.cid); 47 48 // Verify that `bytes` matches its associated CID 49 const expectedCid = CID.toString(await CID.create(entry.cid.codec as 85 | 113, entry.bytes)); 50 if (cidString !== expectedCid) { 51 throw new Error(`cid does not match bytes`); 52 } 53 54 blockmap.set(cidString, entry.bytes); 55 } 56 57 if (blockmap.size === 0) { 58 throw new Error(`car must have at least one block`); 59 } 60 61 commit = readBlock(blockmap, reader.header.data.roots[0].$link, isCommit); 62 } 63 64 // verify did in commit matches the did 65 if (did !== undefined && commit.did !== did) { 66 throw new Error(`did in commit does not match expected did`); 67 } 68 69 // verify signature contained in commit is valid (if publicKey provided) 70 if (publicKey) { 71 const { sig, ...unsigned } = commit; 72 73 const data = CBOR.encode(unsigned); 74 const valid = await publicKey.verify( 75 CBOR.fromBytes(sig) as Uint8Array<ArrayBuffer>, 76 data as Uint8Array<ArrayBuffer>, 77 ); 78 79 if (!valid) { 80 throw new Error(`signature verification failed`); 81 } 82 } 83 84 // find and verify the record in the commit 85 const targetKey = `${collection}/${rkey}`; 86 const { found } = await dfs(blockmap, commit.data.$link, targetKey); 87 if (!found) { 88 throw new Error(`could not find record in car`); 89 } 90 91 return { 92 cid: found.cid, 93 record: found.record, 94 }; 95}; 96 97const readBlock = <T>(blockmap: BlockMap, cid: string, validate: (value: unknown) => value is T): T => { 98 const bytes = blockmap.get(cid); 99 if (!bytes) { 100 throw new Error(`cid not found in blockmap; cid=${cid}`); 101 } 102 103 const decoded = CBOR.decode(bytes); 104 if (!validate(decoded)) { 105 throw new Error(`validation failed for cid=${cid}`); 106 } 107 108 return decoded; 109}; 110 111interface DfsResult { 112 found: false | { cid: string; record: unknown }; 113 min?: string; 114 max?: string; 115 depth?: number; 116} 117 118const dfs = async ( 119 blockmap: BlockMap, 120 from: string | undefined, 121 targetKey: string, 122 visited = new Set<string>(), 123): Promise<DfsResult> => { 124 // If there's no starting point, return empty state 125 if (from == null) { 126 return { found: false }; 127 } 128 129 // Check for cycles 130 { 131 if (visited.has(from)) { 132 throw new Error(`cycle detected; cid=${from}`); 133 } 134 135 visited.add(from); 136 } 137 138 // Get the block data 139 let node: NodeData; 140 { 141 const bytes = blockmap.get(from); 142 if (!bytes) { 143 return { found: false }; 144 } 145 146 const decoded = CBOR.decode(bytes); 147 if (!isNodeData(decoded)) { 148 throw new Error(`invalid mst node; cid=${from}`); 149 } 150 151 node = decoded; 152 } 153 154 // Recursively process the left child 155 const left = await dfs(blockmap, node.l?.$link, targetKey, visited); 156 157 let key = ''; 158 let found = left.found; 159 let depth: number | undefined; 160 let firstKey: string | undefined; 161 let lastKey: string | undefined; 162 163 // Process all entries in this node 164 for (const entry of node.e) { 165 // Construct the key by truncating and appending 166 key = key.substring(0, entry.p) + decodeUtf8From(CBOR.fromBytes(entry.k)); 167 168 // Check if this is our target key 169 if (key === targetKey) { 170 const recordBytes = blockmap.get(entry.v.$link); 171 if (recordBytes) { 172 const record = CBOR.decode(recordBytes); 173 found = { cid: entry.v.$link, record }; 174 } 175 } 176 177 // Calculate depth based on leading zeros in the hash 178 const keyDigest = await toSha256(encodeUtf8(key)); 179 let zeroCount = 0; 180 181 outerLoop: for (const byte of keyDigest) { 182 for (let bit = 7; bit >= 0; bit--) { 183 if (((byte >> bit) & 1) !== 0) { 184 break outerLoop; 185 } 186 zeroCount++; 187 } 188 } 189 190 const thisDepth = Math.floor(zeroCount / 2); 191 192 // Ensure consistent depth 193 if (depth === undefined) { 194 depth = thisDepth; 195 } else if (depth !== thisDepth) { 196 throw new Error(`node has entries with different depths; cid=${from}`); 197 } 198 199 // Track first and last keys 200 if (lastKey === undefined) { 201 firstKey = key; 202 lastKey = key; 203 } 204 205 // Check key ordering 206 if (lastKey > key) { 207 throw new Error(`entries are out of order; cid=${from}`); 208 } 209 210 // Process right child 211 const right = await dfs(blockmap, entry.t?.$link, targetKey, visited); 212 213 // Check ordering with right subtree 214 if (right.min && right.min < lastKey) { 215 throw new Error(`entries are out of order; cid=${from}`); 216 } 217 218 found = found || right.found; 219 220 // Check depth ordering 221 if (left.depth !== undefined && left.depth >= thisDepth) { 222 throw new Error(`depths are out of order; cid=${from}`); 223 } 224 225 if (right.depth !== undefined && right.depth >= thisDepth) { 226 throw new Error(`depths are out of order; cid=${from}`); 227 } 228 229 // Update last key based on right subtree 230 lastKey = right.max ?? key; 231 } 232 233 // Check ordering with left subtree 234 if (left.max && firstKey && left.max > firstKey) { 235 throw new Error(`entries are out of order; cid=${from}`); 236 } 237 238 return { 239 found, 240 min: firstKey, 241 max: lastKey, 242 depth, 243 }; 244};