a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto
bluesky
typescript
npm
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};