@keytrace/claims 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/verify.js ADDED
@@ -0,0 +1,143 @@
1
+ import { resolveHandle, resolvePds, listClaimRecords, getRecordByUri } from "./atproto.js";
2
+ import { verifyES256Signature } from "./crypto/signature.js";
3
+ /**
4
+ * Verify all keytrace claims for a handle.
5
+ *
6
+ * @param handle The ATProto handle (e.g., "alice.bsky.social") or DID
7
+ * @param options Optional configuration
8
+ * @returns Verification results for all claims
9
+ */
10
+ export async function getClaimsForHandle(handle, options) {
11
+ const did = await resolveHandle(handle, options);
12
+ const result = await getClaimsForDid(did, options);
13
+ return {
14
+ ...result,
15
+ handle: handle.startsWith("did:") ? undefined : handle,
16
+ };
17
+ }
18
+ /**
19
+ * Verify all keytrace claims for a DID.
20
+ *
21
+ * @param did The ATProto DID (e.g., "did:plc:abc123")
22
+ * @param options Optional configuration
23
+ * @returns Verification results for all claims
24
+ */
25
+ export async function getClaimsForDid(did, options) {
26
+ // Resolve PDS for the user
27
+ const pdsUrl = await resolvePds(did, options);
28
+ // Fetch all claim records
29
+ let claimRecords;
30
+ try {
31
+ claimRecords = await listClaimRecords(pdsUrl, did, options);
32
+ }
33
+ catch {
34
+ // No claims found
35
+ return {
36
+ did,
37
+ claims: [],
38
+ summary: { total: 0, verified: 0, failed: 0 },
39
+ };
40
+ }
41
+ // Verify each claim
42
+ const claimResults = [];
43
+ for (const record of claimRecords) {
44
+ const result = await verifySingleClaim(did, record.uri, record.rkey, record.value, options);
45
+ claimResults.push(result);
46
+ }
47
+ return {
48
+ did,
49
+ claims: claimResults,
50
+ summary: {
51
+ total: claimResults.length,
52
+ verified: claimResults.filter((c) => c.verified).length,
53
+ failed: claimResults.filter((c) => !c.verified).length,
54
+ },
55
+ };
56
+ }
57
+ /**
58
+ * Verify a single claim's signature.
59
+ */
60
+ async function verifySingleClaim(did, uri, rkey, claim, options) {
61
+ const steps = [];
62
+ try {
63
+ // Step 1: Validate claim structure
64
+ if (!claim.sig?.src || !claim.sig?.attestation || !claim.sig?.signedAt) {
65
+ steps.push({
66
+ step: "validate_claim",
67
+ success: false,
68
+ error: "Missing signature fields",
69
+ });
70
+ return buildResult(uri, rkey, claim, false, steps, "Missing signature fields");
71
+ }
72
+ steps.push({ step: "validate_claim", success: true, detail: "Claim structure valid" });
73
+ // Step 2: Fetch the signing key
74
+ let keyRecord;
75
+ try {
76
+ keyRecord = await getRecordByUri(claim.sig.src, options);
77
+ steps.push({ step: "fetch_key", success: true, detail: `Fetched key from ${claim.sig.src}` });
78
+ }
79
+ catch (err) {
80
+ const error = `Failed to fetch signing key: ${err instanceof Error ? err.message : String(err)}`;
81
+ steps.push({ step: "fetch_key", success: false, error });
82
+ return buildResult(uri, rkey, claim, false, steps, error);
83
+ }
84
+ // Step 3: Parse the public JWK
85
+ let publicJwk;
86
+ try {
87
+ publicJwk = JSON.parse(keyRecord.publicJwk);
88
+ if (publicJwk.kty !== "EC" || publicJwk.crv !== "P-256") {
89
+ throw new Error("Invalid key type");
90
+ }
91
+ steps.push({ step: "parse_key", success: true, detail: "Parsed ES256 public key" });
92
+ }
93
+ catch (err) {
94
+ const error = `Invalid public key format: ${err instanceof Error ? err.message : String(err)}`;
95
+ steps.push({ step: "parse_key", success: false, error });
96
+ return buildResult(uri, rkey, claim, false, steps, error);
97
+ }
98
+ // Step 4: Reconstruct the signed claim data
99
+ const signedData = {
100
+ did,
101
+ subject: claim.identity.subject,
102
+ type: claim.type,
103
+ verifiedAt: claim.sig.signedAt,
104
+ };
105
+ steps.push({
106
+ step: "reconstruct_data",
107
+ success: true,
108
+ detail: `Reconstructed signed data for ${claim.type}:${claim.identity.subject}`,
109
+ });
110
+ // Step 5: Verify the signature
111
+ const isValid = await verifyES256Signature(signedData, claim.sig.attestation, publicJwk);
112
+ if (isValid) {
113
+ steps.push({ step: "verify_signature", success: true, detail: "Signature verified" });
114
+ return buildResult(uri, rkey, claim, true, steps);
115
+ }
116
+ else {
117
+ steps.push({ step: "verify_signature", success: false, error: "Signature verification failed" });
118
+ return buildResult(uri, rkey, claim, false, steps, "Signature verification failed");
119
+ }
120
+ }
121
+ catch (err) {
122
+ const error = `Unexpected error: ${err instanceof Error ? err.message : String(err)}`;
123
+ steps.push({ step: "unknown", success: false, error });
124
+ return buildResult(uri, rkey, claim, false, steps, error);
125
+ }
126
+ }
127
+ /**
128
+ * Build a claim verification result.
129
+ */
130
+ function buildResult(uri, rkey, claim, verified, steps, error) {
131
+ return {
132
+ uri,
133
+ rkey,
134
+ type: claim.type,
135
+ claimUri: claim.claimUri,
136
+ verified,
137
+ steps,
138
+ error,
139
+ identity: claim.identity,
140
+ claim,
141
+ };
142
+ }
143
+ //# sourceMappingURL=verify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify.js","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@keytrace/claims",
3
+ "version": "0.0.5",
4
+ "description": "Verify keytrace identity claims",
5
+ "files": [
6
+ "src",
7
+ "dist"
8
+ ],
9
+ "type": "module",
10
+ "main": "./src/verify.ts",
11
+ "types": "./src/verify.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./src/verify.ts",
15
+ "default": "./src/verify.ts"
16
+ },
17
+ "./types": {
18
+ "types": "./src/types.ts",
19
+ "default": "./src/types.ts"
20
+ }
21
+ },
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "test": "vitest run",
25
+ "test:watch": "vitest",
26
+ "typecheck": "tsc --noEmit"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^22.0.0",
30
+ "typescript": "^5.7.0",
31
+ "vitest": "^2.1.0"
32
+ },
33
+ "engines": {
34
+ "node": ">=18"
35
+ }
36
+ }
package/src/atproto.ts ADDED
@@ -0,0 +1,177 @@
1
+ import type { ClaimRecord, KeyRecord, VerifyOptions } from "./types.js";
2
+ import { PUBLIC_API_URL, PLC_DIRECTORY_URL, COLLECTION_NSID, DEFAULT_TIMEOUT } from "./constants.js";
3
+
4
+ interface DidDocument {
5
+ id: string;
6
+ service?: Array<{
7
+ id: string;
8
+ type: string;
9
+ serviceEndpoint: string;
10
+ }>;
11
+ }
12
+
13
+ interface ListRecordsResponse {
14
+ records: Array<{
15
+ uri: string;
16
+ cid: string;
17
+ value: unknown;
18
+ }>;
19
+ cursor?: string;
20
+ }
21
+
22
+ interface GetRecordResponse {
23
+ uri: string;
24
+ cid: string;
25
+ value: unknown;
26
+ }
27
+
28
+ /**
29
+ * Fetch with timeout support
30
+ */
31
+ async function fetchWithTimeout(url: string, options?: VerifyOptions): Promise<Response> {
32
+ const fetchFn = options?.fetch ?? globalThis.fetch;
33
+ const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
34
+
35
+ const controller = new AbortController();
36
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
37
+
38
+ try {
39
+ const response = await fetchFn(url, { signal: controller.signal });
40
+ return response;
41
+ } finally {
42
+ clearTimeout(timeoutId);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Resolve a handle to a DID using the public ATProto API.
48
+ */
49
+ export async function resolveHandle(handle: string, options?: VerifyOptions): Promise<string> {
50
+ // If it's already a DID, return it
51
+ if (handle.startsWith("did:")) {
52
+ return handle;
53
+ }
54
+
55
+ const baseUrl = options?.publicApiUrl ?? PUBLIC_API_URL;
56
+ const url = `${baseUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
57
+
58
+ const response = await fetchWithTimeout(url, options);
59
+ if (!response.ok) {
60
+ throw new Error(`Failed to resolve handle: ${response.status} ${response.statusText}`);
61
+ }
62
+
63
+ const data = (await response.json()) as { did: string };
64
+ return data.did;
65
+ }
66
+
67
+ /**
68
+ * Resolve the PDS endpoint from a DID document.
69
+ */
70
+ export async function resolvePds(did: string, options?: VerifyOptions): Promise<string> {
71
+ const plcUrl = options?.plcDirectoryUrl ?? PLC_DIRECTORY_URL;
72
+
73
+ try {
74
+ let url: string;
75
+ if (did.startsWith("did:plc:")) {
76
+ url = `${plcUrl}/${did}`;
77
+ } else if (did.startsWith("did:web:")) {
78
+ const host = did.replace("did:web:", "").replaceAll(":", "/");
79
+ url = `https://${host}/.well-known/did.json`;
80
+ } else {
81
+ return PUBLIC_API_URL;
82
+ }
83
+
84
+ const response = await fetchWithTimeout(url, options);
85
+ if (!response.ok) {
86
+ return PUBLIC_API_URL;
87
+ }
88
+
89
+ const doc = (await response.json()) as DidDocument;
90
+ const pdsService = doc.service?.find((s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer");
91
+
92
+ return pdsService?.serviceEndpoint ?? PUBLIC_API_URL;
93
+ } catch {
94
+ return PUBLIC_API_URL;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * List all keytrace claim records from a user's repo.
100
+ */
101
+ export async function listClaimRecords(pdsUrl: string, did: string, options?: VerifyOptions): Promise<Array<{ uri: string; rkey: string; value: ClaimRecord }>> {
102
+ const claims: Array<{ uri: string; rkey: string; value: ClaimRecord }> = [];
103
+
104
+ try {
105
+ let cursor: string | undefined;
106
+ do {
107
+ const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.listRecords`);
108
+ url.searchParams.set("repo", did);
109
+ url.searchParams.set("collection", COLLECTION_NSID);
110
+ url.searchParams.set("limit", "100");
111
+ if (cursor) url.searchParams.set("cursor", cursor);
112
+
113
+ const response = await fetchWithTimeout(url.toString(), options);
114
+ if (!response.ok) {
115
+ // No records or repo not found
116
+ if (response.status === 400 || response.status === 404) {
117
+ break;
118
+ }
119
+ throw new Error(`Failed to list records: ${response.status}`);
120
+ }
121
+
122
+ const data = (await response.json()) as ListRecordsResponse;
123
+
124
+ for (const record of data.records) {
125
+ const rkey = parseAtUriRkey(record.uri);
126
+ claims.push({
127
+ uri: record.uri,
128
+ rkey,
129
+ value: record.value as ClaimRecord,
130
+ });
131
+ }
132
+
133
+ cursor = data.cursor;
134
+ } while (cursor);
135
+ } catch (err) {
136
+ // Silently handle errors - return whatever we got
137
+ if (claims.length === 0) {
138
+ throw err;
139
+ }
140
+ }
141
+
142
+ return claims;
143
+ }
144
+
145
+ /**
146
+ * Fetch a single record by AT URI.
147
+ */
148
+ export async function getRecordByUri<T>(atUri: string, options?: VerifyOptions): Promise<T> {
149
+ const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
150
+ if (!match) {
151
+ throw new Error(`Invalid AT URI: ${atUri}`);
152
+ }
153
+
154
+ const [, repo, collection, rkey] = match;
155
+ const pdsUrl = await resolvePds(repo, options);
156
+
157
+ const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`);
158
+ url.searchParams.set("repo", repo);
159
+ url.searchParams.set("collection", collection);
160
+ url.searchParams.set("rkey", rkey);
161
+
162
+ const response = await fetchWithTimeout(url.toString(), options);
163
+ if (!response.ok) {
164
+ throw new Error(`Failed to fetch record: ${response.status}`);
165
+ }
166
+
167
+ const data = (await response.json()) as GetRecordResponse;
168
+ return data.value as T;
169
+ }
170
+
171
+ /**
172
+ * Parse the rkey from an AT URI.
173
+ */
174
+ function parseAtUriRkey(atUri: string): string {
175
+ const match = atUri.match(/^at:\/\/[^/]+\/[^/]+\/(.+)$/);
176
+ return match?.[1] ?? "";
177
+ }
@@ -0,0 +1,5 @@
1
+ export const PUBLIC_API_URL = "https://public.api.bsky.app";
2
+ export const PLC_DIRECTORY_URL = "https://plc.directory";
3
+ export const COLLECTION_NSID = "dev.keytrace.claim";
4
+ export const KEY_COLLECTION_NSID = "dev.keytrace.key";
5
+ export const DEFAULT_TIMEOUT = 10000;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Base64url decode a string to UTF-8 text.
3
+ */
4
+ export function base64urlDecode(str: string): string {
5
+ const bytes = base64urlDecodeToBytes(str);
6
+ return new TextDecoder().decode(bytes);
7
+ }
8
+
9
+ /**
10
+ * Base64url decode a string to raw bytes.
11
+ */
12
+ export function base64urlDecodeToBytes(str: string): Uint8Array {
13
+ // Add padding if needed
14
+ let padded = str;
15
+ const remainder = str.length % 4;
16
+ if (remainder === 2) padded += "==";
17
+ else if (remainder === 3) padded += "=";
18
+
19
+ // Convert URL-safe characters back to standard base64
20
+ const base64 = padded.replace(/-/g, "+").replace(/_/g, "/");
21
+
22
+ // Decode - works in both browser and Node.js
23
+ const binary = atob(base64);
24
+ const bytes = new Uint8Array(binary.length);
25
+ for (let i = 0; i < binary.length; i++) {
26
+ bytes[i] = binary.charCodeAt(i);
27
+ }
28
+ return bytes;
29
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Canonicalize a value according to RFC 8785 (JSON Canonicalization Scheme).
3
+ * https://datatracker.ietf.org/doc/html/rfc8785
4
+ *
5
+ * This ensures deterministic JSON output for cryptographic signing by:
6
+ * - Sorting object keys by UTF-16 code units
7
+ * - Serializing numbers per ES2020 Number.toString() (no -0, no NaN/Infinity)
8
+ * - No optional whitespace
9
+ * - Proper string escaping per RFC 8259
10
+ */
11
+ export function canonicalize(data: unknown): string {
12
+ return serialize(data);
13
+ }
14
+
15
+ function serialize(value: unknown): string {
16
+ if (value === null) {
17
+ return "null";
18
+ }
19
+
20
+ switch (typeof value) {
21
+ case "boolean":
22
+ return value ? "true" : "false";
23
+
24
+ case "number":
25
+ if (!Number.isFinite(value)) {
26
+ throw new Error("RFC 8785: Cannot serialize Infinity or NaN");
27
+ }
28
+ // RFC 8785 Section 3.2.2.3: -0 must be serialized as 0
29
+ if (Object.is(value, -0)) {
30
+ return "0";
31
+ }
32
+ // Use ES Number.toString() which produces RFC 8785 compliant output
33
+ return String(value);
34
+
35
+ case "string":
36
+ // JSON.stringify handles RFC 8259 string escaping
37
+ return JSON.stringify(value);
38
+
39
+ case "object":
40
+ if (Array.isArray(value)) {
41
+ return "[" + value.map(serialize).join(",") + "]";
42
+ }
43
+ return serializeObject(value as Record<string, unknown>);
44
+
45
+ default:
46
+ throw new Error(`RFC 8785: Cannot serialize value of type ${typeof value}`);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Serialize an object with keys sorted by UTF-16 code units per RFC 8785 Section 3.2.3.
52
+ */
53
+ function serializeObject(obj: Record<string, unknown>): string {
54
+ // RFC 8785: Sort keys by comparing UTF-16 code units
55
+ const keys = Object.keys(obj).sort((a, b) => {
56
+ const len = Math.min(a.length, b.length);
57
+ for (let i = 0; i < len; i++) {
58
+ const diff = a.charCodeAt(i) - b.charCodeAt(i);
59
+ if (diff !== 0) return diff;
60
+ }
61
+ return a.length - b.length;
62
+ });
63
+
64
+ const pairs: string[] = [];
65
+ for (const key of keys) {
66
+ const val = obj[key];
67
+ // Skip undefined values (not valid in JSON)
68
+ if (val !== undefined) {
69
+ pairs.push(JSON.stringify(key) + ":" + serialize(val));
70
+ }
71
+ }
72
+
73
+ return "{" + pairs.join(",") + "}";
74
+ }
@@ -0,0 +1,36 @@
1
+ import type { ES256PublicJwk, SignedClaimData } from "../types.js";
2
+ import { canonicalize } from "./canonicalize.js";
3
+ import { base64urlDecode, base64urlDecodeToBytes } from "./base64url.js";
4
+
5
+ /**
6
+ * Verify a JWS ES256 signature using Web Crypto API.
7
+ *
8
+ * @param claimData The claim data that was signed
9
+ * @param jws The JWS compact serialization (header.payload.signature)
10
+ * @param publicJwk The P-256 public key as JWK
11
+ * @returns true if signature is valid, false otherwise
12
+ */
13
+ export async function verifyES256Signature(claimData: SignedClaimData, jws: string, publicJwk: ES256PublicJwk): Promise<boolean> {
14
+ const parts = jws.split(".");
15
+ if (parts.length !== 3) return false;
16
+
17
+ const [headerB64, payloadB64, signatureB64] = parts;
18
+
19
+ // Verify payload matches expected canonical form
20
+ const expectedPayload = canonicalize(claimData as unknown as Record<string, unknown>);
21
+ const actualPayload = base64urlDecode(payloadB64);
22
+ if (actualPayload !== expectedPayload) return false;
23
+
24
+ // Import JWK as CryptoKey
25
+ const key = await crypto.subtle.importKey("jwk", { ...publicJwk, alg: "ES256", use: "sig" }, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
26
+
27
+ // Web Crypto expects raw signature bytes (R||S format) - which is what JWS ES256 uses
28
+ const signatureBytes = base64urlDecodeToBytes(signatureB64);
29
+ const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
30
+
31
+ // Create a fresh ArrayBuffer to satisfy TypeScript's strict BufferSource type
32
+ const signatureBuffer = new ArrayBuffer(signatureBytes.length);
33
+ new Uint8Array(signatureBuffer).set(signatureBytes);
34
+
35
+ return crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, signatureBuffer, signingInput);
36
+ }
package/src/types.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Identity metadata from a claim record
3
+ */
4
+ export interface ClaimIdentity {
5
+ subject: string;
6
+ avatarUrl?: string;
7
+ profileUrl?: string;
8
+ displayName?: string;
9
+ }
10
+
11
+ /**
12
+ * Signature data from a claim record
13
+ */
14
+ export interface ClaimSignature {
15
+ /** Key identifier (YYYY-MM-DD) */
16
+ kid: string;
17
+ /** AT URI to the signing key record */
18
+ src: string;
19
+ /** Timestamp when signed */
20
+ signedAt: string;
21
+ /** JWS compact serialization */
22
+ attestation: string;
23
+ }
24
+
25
+ /**
26
+ * Raw claim record from ATProto
27
+ */
28
+ export interface ClaimRecord {
29
+ $type: "dev.keytrace.claim";
30
+ type: string;
31
+ claimUri: string;
32
+ identity: ClaimIdentity;
33
+ sig: ClaimSignature;
34
+ comment?: string;
35
+ createdAt: string;
36
+ prerelease?: boolean;
37
+ }
38
+
39
+ /**
40
+ * Public key record from keytrace service
41
+ */
42
+ export interface KeyRecord {
43
+ $type: "dev.keytrace.key";
44
+ publicJwk: string;
45
+ validFrom: string;
46
+ validUntil: string;
47
+ }
48
+
49
+ /**
50
+ * Parsed JWK for P-256 public key
51
+ */
52
+ export interface ES256PublicJwk {
53
+ kty: "EC";
54
+ crv: "P-256";
55
+ x: string;
56
+ y: string;
57
+ }
58
+
59
+ /**
60
+ * Claim data that is signed (canonical form)
61
+ */
62
+ export interface SignedClaimData {
63
+ did: string;
64
+ subject: string;
65
+ type: string;
66
+ verifiedAt: string;
67
+ }
68
+
69
+ /**
70
+ * Verification step detail for debugging/auditing
71
+ */
72
+ export interface VerificationStep {
73
+ step: string;
74
+ success: boolean;
75
+ detail?: string;
76
+ error?: string;
77
+ }
78
+
79
+ /**
80
+ * Result of verifying a single claim
81
+ */
82
+ export interface ClaimVerificationResult {
83
+ /** AT URI of the claim record */
84
+ uri: string;
85
+ /** Record key */
86
+ rkey: string;
87
+ /** Claim type (github, dns, etc.) */
88
+ type: string;
89
+ /** The claim URI being verified */
90
+ claimUri: string;
91
+ /** Whether signature verification passed */
92
+ verified: boolean;
93
+ /** Verification steps performed */
94
+ steps: VerificationStep[];
95
+ /** Error message if verification failed */
96
+ error?: string;
97
+ /** Identity data (available regardless of verification status) */
98
+ identity: ClaimIdentity;
99
+ /** Full claim record */
100
+ claim: ClaimRecord;
101
+ }
102
+
103
+ /**
104
+ * Result of verifying all claims for a DID
105
+ */
106
+ export interface VerificationResult {
107
+ /** The DID that was verified */
108
+ did: string;
109
+ /** Resolved handle (if available) */
110
+ handle?: string;
111
+ /** Array of claim verification results */
112
+ claims: ClaimVerificationResult[];
113
+ /** Summary statistics */
114
+ summary: {
115
+ total: number;
116
+ verified: number;
117
+ failed: number;
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Options for verification
123
+ */
124
+ export interface VerifyOptions {
125
+ /** Custom fetch function (defaults to globalThis.fetch) */
126
+ fetch?: typeof fetch;
127
+ /** Request timeout in ms (default: 10000) */
128
+ timeout?: number;
129
+ /** PLC directory URL (default: https://plc.directory) */
130
+ plcDirectoryUrl?: string;
131
+ /** Public ATProto API URL for handle resolution (default: https://public.api.bsky.app) */
132
+ publicApiUrl?: string;
133
+ }