@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/README.md +105 -0
- package/dist/atproto.d.ts +22 -0
- package/dist/atproto.d.ts.map +1 -0
- package/dist/atproto.js +134 -0
- package/dist/atproto.js.map +1 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +6 -0
- package/dist/constants.js.map +1 -0
- package/dist/crypto/base64url.d.ts +9 -0
- package/dist/crypto/base64url.d.ts.map +1 -0
- package/dist/crypto/base64url.js +29 -0
- package/dist/crypto/base64url.js.map +1 -0
- package/dist/crypto/canonicalize.d.ts +12 -0
- package/dist/crypto/canonicalize.d.ts.map +1 -0
- package/dist/crypto/canonicalize.js +67 -0
- package/dist/crypto/canonicalize.js.map +1 -0
- package/dist/crypto/signature.d.ts +11 -0
- package/dist/crypto/signature.d.ts.map +1 -0
- package/dist/crypto/signature.js +31 -0
- package/dist/crypto/signature.js.map +1 -0
- package/dist/types.d.ts +125 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/verify.d.ts +19 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +143 -0
- package/dist/verify.js.map +1 -0
- package/package.json +36 -0
- package/src/atproto.ts +177 -0
- package/src/constants.ts +5 -0
- package/src/crypto/base64url.ts +29 -0
- package/src/crypto/canonicalize.ts +74 -0
- package/src/crypto/signature.ts +36 -0
- package/src/types.ts +133 -0
- package/src/verify.ts +167 -0
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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
|
+
}
|