A replica of Keytrace
1import type { ClaimRecord, ClaimVerificationResult, ES256PublicJwk, KeyRecord, SignedClaimData, VerificationResult, VerificationStep, VerifyOptions } from "./types.js";
2import { getPrimarySig } from "./types.js";
3import { resolveHandle, resolvePds, listClaimRecords, getRecordByUri } from "./atproto.js";
4import { verifyES256Signature } from "./crypto/signature.js";
5
6/** Default trusted signer handles */
7const DEFAULT_TRUSTED_SIGNERS = ["keytrace.dev"];
8
9// Re-export types for convenience
10export type {
11 ClaimIdentity,
12 ClaimRecord,
13 ClaimSignature,
14 ClaimVerificationResult,
15 ES256PublicJwk,
16 KeyRecord,
17 SignedClaimData,
18 VerificationResult,
19 VerificationStep,
20 VerifyOptions,
21} from "./types.js";
22export { getPrimarySig } from "./types.js";
23
24/**
25 * Verify all keytrace claims for a handle.
26 *
27 * @param handle The ATProto handle (e.g., "alice.bsky.social") or DID
28 * @param options Optional configuration
29 * @returns Verification results for all claims
30 */
31export async function getClaimsForHandle(handle: string, options?: VerifyOptions): Promise<VerificationResult> {
32 const did = await resolveHandle(handle, options);
33 const result = await getClaimsForDid(did, options);
34
35 return {
36 ...result,
37 handle: handle.startsWith("did:") ? undefined : handle,
38 };
39}
40
41/**
42 * Verify all keytrace claims for a DID.
43 *
44 * @param did The ATProto DID (e.g., "did:plc:abc123")
45 * @param options Optional configuration
46 * @returns Verification results for all claims
47 */
48export async function getClaimsForDid(did: string, options?: VerifyOptions): Promise<VerificationResult> {
49 // Resolve trusted signer handles to DIDs once for the whole batch
50 const trustedSigners = options?.trustedSigners ?? DEFAULT_TRUSTED_SIGNERS;
51 const trustedDids = await resolveTrustedDids(trustedSigners, options);
52
53 // Resolve PDS for the user
54 const pdsUrl = await resolvePds(did, options);
55
56 // Fetch all claim records
57 let claimRecords: Array<{ uri: string; rkey: string; value: ClaimRecord }>;
58 try {
59 claimRecords = await listClaimRecords(pdsUrl, did, options);
60 } catch {
61 // No claims found
62 return {
63 did,
64 claims: [],
65 summary: { total: 0, verified: 0, failed: 0 },
66 };
67 }
68
69 // Verify each claim
70 const claimResults: ClaimVerificationResult[] = [];
71
72 for (const record of claimRecords) {
73 const result = await verifySingleClaim(did, record.uri, record.rkey, record.value, trustedDids, options);
74 claimResults.push(result);
75 }
76
77 return {
78 did,
79 claims: claimResults,
80 summary: {
81 total: claimResults.length,
82 verified: claimResults.filter((c) => c.verified).length,
83 failed: claimResults.filter((c) => !c.verified).length,
84 },
85 };
86}
87
88/**
89 * Verify a single claim's signature.
90 */
91async function verifySingleClaim(did: string, uri: string, rkey: string, claim: ClaimRecord, trustedDids: Set<string>, options?: VerifyOptions): Promise<ClaimVerificationResult> {
92 const steps: VerificationStep[] = [];
93
94 try {
95 // Resolve the primary signature (supports both old `sig` and new `sigs` format)
96 const sig = getPrimarySig(claim);
97
98 // Step 1: Validate claim structure
99 if (!sig?.src || !sig?.attestation || !sig?.signedAt) {
100 steps.push({
101 step: "validate_claim",
102 success: false,
103 error: "Missing signature fields",
104 });
105 return buildResult(uri, rkey, claim, false, steps, "Missing signature fields");
106 }
107 steps.push({ step: "validate_claim", success: true, detail: "Claim structure valid" });
108
109 // Step 2: Validate signing key is from a trusted signer
110 const signerDid = extractDidFromAtUri(sig.src);
111 if (!signerDid || !trustedDids.has(signerDid)) {
112 const error = `Signing key is not from a trusted signer (source: ${sig.src})`;
113 steps.push({ step: "validate_signer", success: false, error });
114 return buildResult(uri, rkey, claim, false, steps, error);
115 }
116 steps.push({ step: "validate_signer", success: true, detail: `Signing key from trusted signer (${signerDid})` });
117
118 // Step 3: Fetch the signing key
119 let keyRecord: KeyRecord;
120 try {
121 keyRecord = await getRecordByUri<KeyRecord>(sig.src, options);
122 steps.push({ step: "fetch_key", success: true, detail: `Fetched key from ${sig.src}` });
123 } catch (err) {
124 const error = `Failed to fetch signing key: ${err instanceof Error ? err.message : String(err)}`;
125 steps.push({ step: "fetch_key", success: false, error });
126 return buildResult(uri, rkey, claim, false, steps, error);
127 }
128
129 // Step 4: Parse the public JWK
130 let publicJwk: ES256PublicJwk;
131 try {
132 publicJwk = JSON.parse(keyRecord.publicJwk) as ES256PublicJwk;
133 if (publicJwk.kty !== "EC" || publicJwk.crv !== "P-256") {
134 throw new Error("Invalid key type");
135 }
136 steps.push({ step: "parse_key", success: true, detail: "Parsed ES256 public key" });
137 } catch (err) {
138 const error = `Invalid public key format: ${err instanceof Error ? err.message : String(err)}`;
139 steps.push({ step: "parse_key", success: false, error });
140 return buildResult(uri, rkey, claim, false, steps, error);
141 }
142
143 // Step 5: Reconstruct the signed claim data.
144 // Use signedFields to determine which fields were signed and reconstruct them.
145 const fields = sig.signedFields ?? [];
146 const isNewFormat = fields.includes("identity.subject");
147 let signedData: SignedClaimData;
148 if (isNewFormat) {
149 // New format: signedFields tells us exactly which fields to include.
150 // Map each field name to its value from the record.
151 const valueMap: Record<string, string> = {
152 claimUri: claim.claimUri,
153 createdAt: sig.signedAt, // createdAt was set to signedAt during attestation
154 did,
155 "identity.subject": claim.identity.subject,
156 type: claim.type,
157 };
158 signedData = {};
159 for (const field of fields) {
160 if (field in valueMap) {
161 signedData[field] = valueMap[field];
162 }
163 }
164 } else {
165 // Legacy format: { did, subject, type, verifiedAt }
166 signedData = {
167 did,
168 subject: claim.identity.subject,
169 type: claim.type,
170 verifiedAt: sig.signedAt,
171 };
172 }
173 steps.push({
174 step: "reconstruct_data",
175 success: true,
176 detail: `Reconstructed signed data for ${claim.type}:${claim.identity.subject} (${isNewFormat ? "new" : "legacy"} format)`,
177 });
178
179 // Step 6: Verify the signature
180 const isValid = await verifyES256Signature(signedData, sig.attestation, publicJwk);
181
182 if (isValid) {
183 steps.push({ step: "verify_signature", success: true, detail: "Signature verified" });
184 return buildResult(uri, rkey, claim, true, steps);
185 } else {
186 steps.push({ step: "verify_signature", success: false, error: "Signature verification failed" });
187 return buildResult(uri, rkey, claim, false, steps, "Signature verification failed");
188 }
189 } catch (err) {
190 const error = `Unexpected error: ${err instanceof Error ? err.message : String(err)}`;
191 steps.push({ step: "unknown", success: false, error });
192 return buildResult(uri, rkey, claim, false, steps, error);
193 }
194}
195
196/**
197 * Build a claim verification result.
198 */
199function buildResult(uri: string, rkey: string, claim: ClaimRecord, verified: boolean, steps: VerificationStep[], error?: string): ClaimVerificationResult {
200 return {
201 uri,
202 rkey,
203 type: claim.type,
204 claimUri: claim.claimUri,
205 verified,
206 steps,
207 error,
208 identity: claim.identity,
209 claim,
210 };
211}
212
213/**
214 * Extract the DID from an AT URI (at://did/collection/rkey)
215 */
216function extractDidFromAtUri(atUri: string): string | null {
217 const match = atUri.match(/^at:\/\/([^/]+)\//);
218 return match?.[1] ?? null;
219}
220
221/**
222 * Resolve an array of handles to their DIDs.
223 */
224async function resolveTrustedDids(handles: string[], options?: VerifyOptions): Promise<Set<string>> {
225 const dids = new Set<string>();
226 await Promise.all(
227 handles.map(async (handle) => {
228 try {
229 const did = await resolveHandle(handle, options);
230 dids.add(did);
231 } catch {
232 // Skip handles that fail to resolve
233 }
234 }),
235 );
236 return dids;
237}