A replica of Keytrace
at main 237 lines 8.2 kB view raw
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}