@thurinlabs/identity-kit 1.3.6 → 1.4.0

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 CHANGED
@@ -57,7 +57,7 @@ A self-contained identity card that fetches and displays all available identity
57
57
  Wrap your app (or just the part using identity-kit) in `IdentityKitProvider`. If you already have a `WagmiProvider`, the SDK detects it and uses your existing config.
58
58
 
59
59
  ```tsx
60
- // Zero config — uses public RPC, no Farcaster verification
60
+ // Zero config — public RPC and a public Farcaster node, no keys
61
61
  <IdentityKitProvider>
62
62
  <ThurinCard ens="vitalik.eth" />
63
63
  </IdentityKitProvider>
@@ -65,7 +65,7 @@ Wrap your app (or just the part using identity-kit) in `IdentityKitProvider`. If
65
65
  // With options
66
66
  <IdentityKitProvider
67
67
  rpcUrl="https://your-node.example"
68
- neynarApiKey="YOUR_NEYNAR_KEY"
68
+ farcasterHub="https://your-farcaster-node.example"
69
69
  baseUrl="https://thurin.id"
70
70
  >
71
71
  <ThurinCard ens="vitalik.eth" />
@@ -75,7 +75,8 @@ Wrap your app (or just the part using identity-kit) in `IdentityKitProvider`. If
75
75
  | Prop | Type | Default | Description |
76
76
  |------|------|---------|-------------|
77
77
  | `rpcUrl` | `string` | publicnode | Ethereum RPC endpoint. Any RPC works — v2 needs only `eth_call` |
78
- | `neynarApiKey` | `string` | — | Neynar API key for Farcaster proof verification |
78
+ | `farcasterHub` | `string` | Hypersnap public node | A Farcaster node's HTTP API for Farcaster proofs. The default, Quilibrium's `haatz.quilibrium.com`, needs no key; it sees the visitor's IP and which account was checked (1.3.7) |
79
+ | `neynarApiKey` | `string` | — | Optional: read Farcaster through Neynar's hub instead. Not needed since 1.3.7 |
79
80
  | `baseUrl` | `string` | `https://thurin.id` | Base URL for "View on Thurin" links |
80
81
  | `network` | `'mainnet' \| 'sepolia' \| 'local'` | `'mainnet'` | Which chain to read the PGPRegistry on (`local` = a running anvil) |
81
82
  | `registryAddress` | `string` | v2 address | Override the registry contract address |
@@ -169,7 +170,7 @@ import { identifyProof, verifyProof, displayUrl, proofHref, proofSecondaryHref }
169
170
  const proof = identifyProof({ name: 'proof@thurin.id', value: 'https://gist.github.com/alice/abc123' })
170
171
  // → { provider: 'github', label: 'GitHub', user: 'alice', gistId: 'abc123', url }
171
172
 
172
- const result = await verifyProof(proof, fingerprint, neynarApiKey /* only needed for Farcaster */)
173
+ const result = await verifyProof(proof, fingerprint) // options: { farcasterHub?, neynarApiKey? }
173
174
  // → { verified: boolean, reason?: string }
174
175
  ```
175
176
 
@@ -184,7 +185,9 @@ const keyInfo = await parsePgpKey(armoredKey)
184
185
  // → { fingerprint, userIDs, algorithm, created, expires, notations, subkeys } | null
185
186
 
186
187
  const verification = await verifyAttestation({ pgpPublicKey, pgpSignature, fingerprint, ethAddress })
187
- // → { verified: boolean, reason?: string }
188
+ // → { verified, kind, at?, revocationReason?, signingKey?, expiresAt?, algorithm?, reason? }
189
+ // kind: 'verified' | 'expired' | 'signing-key-expired' | 'revoked' | 'compromised'
190
+ // | 'signing-key-revoked' | 'unsupported' | 'bad-signature' (1.4.0)
188
191
 
189
192
  // Prepare a key for publishing: drop every user ID that contains an email address.
190
193
  const stripped = await stripEmailUserIDs(armoredKey)
@@ -193,7 +196,30 @@ const stripped = await stripEmailUserIDs(armoredKey)
193
196
  await hasEmailUserID(armoredKey) // → true if any user ID contains an @
194
197
  ```
195
198
 
196
- **Published identity.** An attestation stores the armored key on-chain, permanently and publicly. Since 0.9.0 the intended shape is a key whose only user ID is a non-email one (any name — `thurin` is the suggestion), carrying the `proof@thurin.id` notations. `stripEmailUserIDs` produces that from a normal export; the stripped key still verifies (`verifyAttestation` needs at least one self-certified user ID, so a key with none is rejected) and keeps the notations on the user ID it retains. Proofs are then read from the on-chain key, never from a keyserver.
199
+ **Published identity.** An attestation stores the armored key on-chain, permanently and publicly. Since 0.9.0 the intended shape is a key whose only user ID is a non-email one (usually the name already on the key, without the email), carrying the `proof@thurin.id` notations. `stripEmailUserIDs` produces that from a normal export; the stripped key still verifies (`verifyAttestation` needs at least one self-certified user ID, so a key with none is rejected) and keeps the notations on the user ID it retains. Proofs are then read from the on-chain key, never from a keyserver.
200
+
201
+ ### Why a claim does or doesn't count (1.4.0)
202
+
203
+ `verifyAttestation` returns a `kind` along with `verified`, so a page can say why in plain words instead of showing a library error. Checked in this order: the key revoked (`compromised` when the owner's reason was compromise), the key expired, the signing subkey revoked, the signing subkey expired; then an algorithm openpgp.js refuses (`unsupported`, e.g. DSA); anything else is `bad-signature`. `at` is the date that goes with it. A verified result carries `expiresAt`: the earlier of the key's and the signing subkey's expiry.
204
+
205
+ ```ts
206
+ import { claimCheckText, expiresSoon, expiresSoonText, claimFates, claimFateText, CLAIM_CHECK_LABEL } from '@thurinlabs/identity-kit'
207
+
208
+ claimCheckText(verification)
209
+ // → { kind: 'expired', label: 'key expired',
210
+ // sentence: 'The key on this claim expired on Mar 5, 2029, so the claim no longer counts.',
211
+ // fix: 'Extend the key, then Update key. No new signature needed.' } // `fix` is for the owner
212
+
213
+ const soon = expiresSoon(verification) // within 30 days → { days, at } | null
214
+ if (soon) expiresSoonText(soon) // 'Key expires in 12 days (Mar 6, 2027).'
215
+
216
+ // Revoked or replaced: reattest revokes and attests in one transaction, so a claim revoked the
217
+ // same second a newer one was created was replaced by it.
218
+ const fates = claimFates(attestations) // Map<index, { state: 'active' | 'revoked' | 'replaced', at?, by? }>
219
+ claimFateText(fates.get(1)!) // 'Replaced by claim #2 on Oct 3, 2026.'
220
+ ```
221
+
222
+ Dates are formatted in UTC ("Mar 5, 2029") so every viewer sees the same day; pass your own formatter as the second argument.
197
223
 
198
224
  Nothing in the kit talks to a keyserver: keys come from the registry, and `thurin keyserver` / keys.thurin.id serve them over HKP for gpg.
199
225
 
@@ -311,7 +337,8 @@ For static sites, Jekyll blogs, WordPress, or any HTML page — use the standalo
311
337
  | `data-thurin-card` | ENS name or ETH address to look up (required) |
312
338
  | `data-theme` | `thurin`, `dark`, or `light` (default: `thurin`). Change it after render and the card follows, so a page with a theme switch can keep the card in step. |
313
339
  | `data-rpc-url` | Optional. Any Ethereum RPC; the card reads the v2 registry with plain calls, so the keyless public default works. |
314
- | `data-neynar-key` | Optional. A Neynar API key, only to verify Farcaster proofs. Without it, Farcaster shows as unverified. |
340
+ | `data-farcaster-hub` | Optional. A Farcaster node for Farcaster proofs; the default is a public node that needs no key. |
341
+ | `data-neynar-key` | Optional. Read Farcaster through Neynar with your key instead (not needed since 1.3.7). |
315
342
  | `data-base-url` | Optional. Where the card's "View on Thurin" link points (default `https://thurin.id`). A page served from ENS can pass its own name so the link stays on ENS. |
316
343
  | `data-network` | Optional. `sepolia` or `local` instead of mainnet. |
317
344
  | `data-registry-address` | Optional. Override the registry contract address. |
@@ -324,7 +351,7 @@ The card talks directly to Ethereum and each proof platform — no intermediary,
324
351
  |----------|-------------|
325
352
  | GitHub | Public gist, or a repository description (the form an organisation can use) |
326
353
  | DNS | TXT record |
327
- | Farcaster | Public cast (requires Neynar API key) |
354
+ | Farcaster | Public cast (read from a public Farcaster node; no key) |
328
355
  | Codeberg | Repository description |
329
356
  | Mastodon | Profile metadata |
330
357
 
@@ -334,7 +361,7 @@ The card talks directly to Ethereum and each proof platform — no intermediary,
334
361
 
335
362
  ## Key algorithms
336
363
 
337
- Any curve openpgp.js can compute is accepted: Ed25519, Cv25519, NIST P-256/384/521, brainpool, RSA, and **secp256k1**. openpgp.js rejects secp256k1 by default because RFC 9580 does not list it; identity-kit removes secp256k1 from `rejectCurves` and leaves the rest of the list alone, since that one entry is a compatibility rule, not a security one (1.3.2; earlier versions cleared the whole set). A secp256k1 PGP key doubles as an Ethereum key (the address is derived from the same public point), so anything that can sign with the PGP key can sign Ethereum transactions: hold such a key if you like, but do not fund its derived address. In Node, openpgp.js needs the `eckey-utils` package for this curve; identity-kit depends on it, so `npm install` brings it in. The browser build needs nothing extra.
364
+ Any curve openpgp.js can compute is accepted: Ed25519, Cv25519, NIST P-256/384/521, brainpool, RSA (2048 bits and up), and **secp256k1**. DSA and short RSA keys are refused by openpgp.js as too weak; since 1.4.0 such a claim reports `kind: 'unsupported'`. openpgp.js rejects secp256k1 by default because RFC 9580 does not list it; identity-kit removes secp256k1 from `rejectCurves` and leaves the rest of the list alone, since that one entry is a compatibility rule, not a security one (1.3.2; earlier versions cleared the whole set). A secp256k1 PGP key doubles as an Ethereum key (the address is derived from the same public point), so anything that can sign with the PGP key can sign Ethereum transactions: hold such a key if you like, but do not fund its derived address. In Node, openpgp.js needs the `eckey-utils` package for this curve; identity-kit depends on it, so `npm install` brings it in. The browser build needs nothing extra.
338
365
 
339
366
  ## Migrating from 0.9.x
340
367
 
@@ -1,4 +1,4 @@
1
- "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }// src/core/abi.ts
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } async function _asyncNullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return await rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } async function _asyncOptionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = await fn(value); } else if (op === 'call' || op === 'optionalCall') { value = await fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }// src/core/abi.ts
2
2
  var REGISTRY_ABI = [
3
3
  {
4
4
  "type": "function",
@@ -1392,6 +1392,61 @@ async function verifyClearsigned({ armoredKey, clearsigned }) {
1392
1392
  return { verified: false, reason: err instanceof Error ? err.message : "Verification failed", text: "" };
1393
1393
  }
1394
1394
  }
1395
+ var REVOCATION_REASON = {
1396
+ // 0 = "no reason specified", gpg's default: say nothing rather than "(reason: no reason given)"
1397
+ 1: "replaced by a newer key",
1398
+ 2: "compromised",
1399
+ 3: "no longer used",
1400
+ 32: "a user ID is no longer valid"
1401
+ };
1402
+ var iso = (d) => d instanceof Date ? d.toISOString() : typeof d === "number" && Number.isFinite(d) ? new Date(d).toISOString() : null;
1403
+ function revocationOf(sigs) {
1404
+ const sig = [..._nullishCoalesce(sigs, () => ( []))].sort((a, b) => (_nullishCoalesce(_optionalChain([b, 'access', _13 => _13.created, 'optionalAccess', _14 => _14.getTime, 'optionalCall', _15 => _15()]), () => ( 0))) - (_nullishCoalesce(_optionalChain([a, 'access', _16 => _16.created, 'optionalAccess', _17 => _17.getTime, 'optionalCall', _18 => _18()]), () => ( 0))))[0];
1405
+ const flag = _nullishCoalesce(_optionalChain([sig, 'optionalAccess', _19 => _19.reasonForRevocationFlag]), () => ( null));
1406
+ return {
1407
+ at: iso(_optionalChain([sig, 'optionalAccess', _20 => _20.created])),
1408
+ compromised: flag === 2,
1409
+ revocationReason: _optionalChain([sig, 'optionalAccess', _21 => _21.reasonForRevocationString]) || (flag !== null ? _nullishCoalesce(REVOCATION_REASON[flag], () => ( null)) : null)
1410
+ };
1411
+ }
1412
+ async function keyLife(openpgp, key, clearsigned, config) {
1413
+ const now = /* @__PURE__ */ new Date();
1414
+ let issuer = null;
1415
+ try {
1416
+ issuer = await _asyncNullishCoalesce(await _asyncOptionalChain([(await openpgp.readCleartextMessage({ cleartextMessage: clearsigned })), 'access', async _22 => _22.signature, 'access', async _23 => _23.packets, 'access', async _24 => _24[0], 'optionalAccess', async _25 => _25.issuerKeyID]), async () => ( null));
1417
+ } catch (e4) {
1418
+ }
1419
+ const signer = issuer && !key.getKeyID().equals(issuer) ? _nullishCoalesce(key.subkeys.find((s) => s.getKeyID().equals(issuer)), () => ( null)) : key;
1420
+ const signingKey = signer ? signer.getFingerprint().toUpperCase() : null;
1421
+ const safe = async (f, fallback) => {
1422
+ try {
1423
+ return await f();
1424
+ } catch (e5) {
1425
+ return fallback;
1426
+ }
1427
+ };
1428
+ const keyRevoked = await safe(() => key.isRevoked(void 0, void 0, now, config), false);
1429
+ const keyExp = await safe(() => key.getExpirationTime(void 0, config), null);
1430
+ const isSub = signer && signer !== key;
1431
+ const subRevoked = isSub ? await safe(() => signer.isRevoked(null, key.keyPacket, now, config), false) : false;
1432
+ const subExp = isSub ? await safe(() => signer.getExpirationTime(null, config), null) : null;
1433
+ const finite = (d) => d instanceof Date && Number.isFinite(d.getTime()) ? d : null;
1434
+ const kExp = finite(keyExp), sExp = finite(subExp);
1435
+ let problem = null;
1436
+ if (keyRevoked) {
1437
+ const r = revocationOf(key.revocationSignatures);
1438
+ problem = { kind: r.compromised ? "compromised" : "revoked", at: r.at, revocationReason: r.revocationReason };
1439
+ } else if (kExp && kExp <= now) {
1440
+ problem = { kind: "expired", at: kExp.toISOString() };
1441
+ } else if (subRevoked) {
1442
+ const r = revocationOf(signer.revocationSignatures);
1443
+ problem = { kind: r.compromised ? "compromised" : "signing-key-revoked", at: r.at, revocationReason: r.revocationReason };
1444
+ } else if (sExp && sExp <= now) {
1445
+ problem = { kind: "signing-key-expired", at: sExp.toISOString() };
1446
+ }
1447
+ const earliest = [kExp, sExp].filter(Boolean).sort((a, b) => a - b)[0];
1448
+ return { problem, signingKey, expiresAt: earliest ? earliest.toISOString() : null, algorithm: algorithmName(_nullishCoalesce(signer, () => ( key))) };
1449
+ }
1395
1450
  async function verifyAttestation({
1396
1451
  pgpPublicKey,
1397
1452
  pgpSignature,
@@ -1400,24 +1455,31 @@ async function verifyAttestation({
1400
1455
  }) {
1401
1456
  try {
1402
1457
  if (!pgpPublicKey || !pgpSignature) {
1403
- return { verified: false, reason: "Missing PGP data" };
1458
+ return { verified: false, reason: "Missing PGP data", kind: "bad-signature" };
1404
1459
  }
1405
1460
  const openpgp = await Promise.resolve().then(() => _interopRequireWildcard(require("openpgp")));
1406
1461
  const { readKey } = openpgp;
1407
1462
  const publicKey = await readKey({ armoredKey: pgpPublicKey });
1408
1463
  const keyFingerprint = publicKey.getFingerprint().toUpperCase();
1409
1464
  if (keyFingerprint !== fingerprint.toUpperCase()) {
1410
- return { verified: false, reason: "Key fingerprint mismatch" };
1465
+ return { verified: false, reason: "Key fingerprint mismatch", kind: "bad-signature" };
1411
1466
  }
1467
+ const life = await keyLife(openpgp, publicKey, pgpSignature, pgpConfig(openpgp));
1412
1468
  const v = await verifyClearsigned({ armoredKey: pgpPublicKey, clearsigned: pgpSignature });
1413
- if (!v.verified) return { verified: false, reason: v.reason };
1469
+ if (!v.verified) {
1470
+ if (life.problem) return { verified: false, reason: v.reason, signingKey: life.signingKey, ...life.problem };
1471
+ if (/too weak|reject|unsupported|not supported|unknown (curve|algorithm)/i.test(v.reason || "")) {
1472
+ return { verified: false, reason: v.reason, kind: "unsupported", algorithm: life.algorithm, signingKey: life.signingKey };
1473
+ }
1474
+ return { verified: false, reason: v.reason, kind: "bad-signature", signingKey: life.signingKey };
1475
+ }
1414
1476
  const signedText = v.text;
1415
1477
  if (!signedText.toLowerCase().includes(ethAddress.toLowerCase())) {
1416
- return { verified: false, reason: "Signed message does not contain ETH address" };
1478
+ return { verified: false, reason: "Signed message does not contain ETH address", kind: "bad-signature", signingKey: life.signingKey };
1417
1479
  }
1418
- return { verified: true };
1419
- } catch (e4) {
1420
- return { verified: false, reason: "Signature verification failed" };
1480
+ return { verified: true, kind: "verified", signingKey: life.signingKey, expiresAt: life.expiresAt };
1481
+ } catch (e6) {
1482
+ return { verified: false, reason: "Signature verification failed", kind: "bad-signature" };
1421
1483
  }
1422
1484
  }
1423
1485
  var EMAIL_UID = /@/;
@@ -1428,7 +1490,7 @@ async function stripEmailUserIDs(armoredKey) {
1428
1490
  const kept = [];
1429
1491
  const removed = [];
1430
1492
  const users = key.users.filter((u) => {
1431
- const uid = _optionalChain([u, 'access', _13 => _13.userID, 'optionalAccess', _14 => _14.userID]);
1493
+ const uid = _optionalChain([u, 'access', _26 => _26.userID, 'optionalAccess', _27 => _27.userID]);
1432
1494
  if (!uid) return false;
1433
1495
  if (EMAIL_UID.test(uid)) {
1434
1496
  removed.push(uid);
@@ -1440,7 +1502,7 @@ async function stripEmailUserIDs(armoredKey) {
1440
1502
  if (users.length === 0) return null;
1441
1503
  key.users = users;
1442
1504
  return { armored: key.armor(), kept, removed };
1443
- } catch (e5) {
1505
+ } catch (e7) {
1444
1506
  return null;
1445
1507
  }
1446
1508
  }
@@ -1448,8 +1510,8 @@ async function hasEmailUserID(armoredKey) {
1448
1510
  try {
1449
1511
  const { readKey } = await Promise.resolve().then(() => _interopRequireWildcard(require("openpgp")));
1450
1512
  const key = await readKey({ armoredKey });
1451
- return key.users.some((u) => EMAIL_UID.test(_nullishCoalesce(_optionalChain([u, 'access', _15 => _15.userID, 'optionalAccess', _16 => _16.userID]), () => ( ""))));
1452
- } catch (e6) {
1513
+ return key.users.some((u) => EMAIL_UID.test(_nullishCoalesce(_optionalChain([u, 'access', _28 => _28.userID, 'optionalAccess', _29 => _29.userID]), () => ( ""))));
1514
+ } catch (e8) {
1453
1515
  return false;
1454
1516
  }
1455
1517
  }
@@ -1480,7 +1542,7 @@ async function fetchEFPGraph(address) {
1480
1542
  top8 = (top8Data.following || []).map((f) => f.data);
1481
1543
  }
1482
1544
  return { followers, following, top8, hasEfp: !!listId || followers > 0 };
1483
- } catch (e7) {
1545
+ } catch (e9) {
1484
1546
  return null;
1485
1547
  }
1486
1548
  }
@@ -1577,7 +1639,7 @@ async function verifyGitHub(proof, fingerprint) {
1577
1639
  const resp = await fetch(`https://api.github.com/gists/${proof.gistId}`);
1578
1640
  if (!resp.ok) return { verified: false, reason: `GitHub API returned ${resp.status}` };
1579
1641
  const data = await resp.json();
1580
- if (_optionalChain([data, 'access', _17 => _17.owner, 'optionalAccess', _18 => _18.login, 'optionalAccess', _19 => _19.toLowerCase, 'call', _20 => _20()]) !== proof.user.toLowerCase()) {
1642
+ if (_optionalChain([data, 'access', _30 => _30.owner, 'optionalAccess', _31 => _31.login, 'optionalAccess', _32 => _32.toLowerCase, 'call', _33 => _33()]) !== proof.user.toLowerCase()) {
1581
1643
  return { verified: false, reason: "Gist owner does not match claimed user" };
1582
1644
  }
1583
1645
  for (const file of Object.values(data.files || {})) {
@@ -1597,7 +1659,7 @@ async function verifyGitHubRepo(proof, fingerprint) {
1597
1659
  );
1598
1660
  if (!resp.ok) return { verified: false, reason: `GitHub API returned ${resp.status}` };
1599
1661
  const data = await resp.json();
1600
- if (_optionalChain([data, 'access', _21 => _21.owner, 'optionalAccess', _22 => _22.login, 'optionalAccess', _23 => _23.toLowerCase, 'call', _24 => _24()]) !== proof.user.toLowerCase()) {
1662
+ if (_optionalChain([data, 'access', _34 => _34.owner, 'optionalAccess', _35 => _35.login, 'optionalAccess', _36 => _36.toLowerCase, 'call', _37 => _37()]) !== proof.user.toLowerCase()) {
1601
1663
  return { verified: false, reason: "Repository owner does not match claimed account" };
1602
1664
  }
1603
1665
  if (data.description && containsFingerprint(data.description, fingerprint)) {
@@ -1627,33 +1689,31 @@ async function verifyDNS(proof, fingerprint) {
1627
1689
  return { verified: false, reason: `DNS fetch failed: ${err.message}` };
1628
1690
  }
1629
1691
  }
1692
+ var FARCASTER_HUB = "https://haatz.quilibrium.com";
1630
1693
  var NEYNAR_HUB = "https://hub-api.neynar.com";
1631
- async function resolveFid(username, apiKey) {
1632
- const resp = await fetch(
1633
- `${NEYNAR_HUB}/v1/userNameProofByName?name=${encodeURIComponent(username)}`,
1634
- { headers: { "x-api-key": apiKey } }
1635
- );
1636
- if (!resp.ok) return null;
1637
- const data = await resp.json();
1638
- return _nullishCoalesce(data.fid, () => ( null));
1639
- }
1640
- async function verifyFarcaster(proof, fingerprint, neynarApiKey) {
1641
- if (!neynarApiKey) {
1642
- return { verified: false, reason: "Farcaster verification requires a Neynar API key" };
1643
- }
1694
+ function farcasterSource(opts) {
1695
+ if (opts.farcasterHub) return { base: opts.farcasterHub.replace(/\/+$/, ""), headers: {} };
1696
+ if (opts.neynarApiKey) return { base: NEYNAR_HUB, headers: { "x-api-key": opts.neynarApiKey } };
1697
+ return { base: FARCASTER_HUB, headers: {} };
1698
+ }
1699
+ async function verifyFarcaster(proof, fingerprint, opts = {}) {
1700
+ const { base, headers } = farcasterSource(opts);
1701
+ const host = base.replace(/^https?:\/\//, "");
1644
1702
  try {
1645
- const fid = await resolveFid(proof.user, neynarApiKey);
1703
+ const nameResp = await fetch(`${base}/v1/userNameProofByName?name=${encodeURIComponent(proof.user)}`, { headers });
1704
+ if (nameResp.status === 404) return { verified: false, reason: `Could not resolve Farcaster user "${proof.user}"` };
1705
+ if (!nameResp.ok) return { verified: false, reason: `Couldn't check: the Farcaster node (${host}) returned ${nameResp.status}` };
1706
+ const fid = (await nameResp.json()).fid;
1646
1707
  if (!fid) return { verified: false, reason: `Could not resolve Farcaster user "${proof.user}"` };
1647
- const headers = { "x-api-key": neynarApiKey };
1648
1708
  let pageToken = "";
1649
1709
  for (let page = 0; page < 5; page++) {
1650
- const url = `${NEYNAR_HUB}/v1/castsByFid?fid=${fid}&pageSize=100&reverse=true${pageToken ? `&pageToken=${pageToken}` : ""}`;
1710
+ const url = `${base}/v1/castsByFid?fid=${fid}&pageSize=100&reverse=true${pageToken ? `&pageToken=${pageToken}` : ""}`;
1651
1711
  const resp = await fetch(url, { headers });
1652
- if (!resp.ok) return { verified: false, reason: `Farcaster Hub returned ${resp.status}` };
1712
+ if (!resp.ok) return { verified: false, reason: `Couldn't check: the Farcaster node (${host}) returned ${resp.status}` };
1653
1713
  const data = await resp.json();
1654
1714
  for (const msg of data.messages || []) {
1655
1715
  if (msg.hash && msg.hash.startsWith(proof.castHash)) {
1656
- const text = _optionalChain([msg, 'access', _25 => _25.data, 'optionalAccess', _26 => _26.castAddBody, 'optionalAccess', _27 => _27.text]) || "";
1716
+ const text = _optionalChain([msg, 'access', _38 => _38.data, 'optionalAccess', _39 => _39.castAddBody, 'optionalAccess', _40 => _40.text]) || "";
1657
1717
  if (containsFingerprint(text, fingerprint)) {
1658
1718
  return { verified: true };
1659
1719
  }
@@ -1664,7 +1724,7 @@ async function verifyFarcaster(proof, fingerprint, neynarApiKey) {
1664
1724
  }
1665
1725
  return { verified: false, reason: "Cast not found" };
1666
1726
  } catch (err) {
1667
- return { verified: false, reason: `Farcaster fetch failed: ${err.message}` };
1727
+ return { verified: false, reason: `Couldn't check: the Farcaster node (${host}) didn't answer (${err.message})` };
1668
1728
  }
1669
1729
  }
1670
1730
  async function verifyCodeberg(proof, fingerprint) {
@@ -1717,10 +1777,10 @@ var verifiers = {
1717
1777
  codeberg: verifyCodeberg,
1718
1778
  mastodon: verifyMastodon
1719
1779
  };
1720
- async function verifyProof(proof, fingerprint, neynarApiKey) {
1780
+ async function verifyProof(proof, fingerprint, opts) {
1721
1781
  const fn = verifiers[proof.provider];
1722
1782
  if (!fn) return { verified: false, reason: "Unknown provider" };
1723
- return fn(proof, fingerprint, neynarApiKey);
1783
+ return fn(proof, fingerprint, typeof opts === "string" ? { neynarApiKey: opts } : _nullishCoalesce(opts, () => ( {})));
1724
1784
  }
1725
1785
 
1726
1786
  // src/core/avatar.ts
@@ -1768,19 +1828,19 @@ async function nftAvatarImage(tokenUri, tokenId, fetchFn = fetch) {
1768
1828
  meta = await res.json();
1769
1829
  break;
1770
1830
  }
1771
- } catch (e8) {
1831
+ } catch (e10) {
1772
1832
  }
1773
1833
  }
1774
1834
  if (!meta) return null;
1775
1835
  }
1776
- return avatarUrl(_nullishCoalesce(_optionalChain([meta, 'optionalAccess', _28 => _28.image]), () => ( _optionalChain([meta, 'optionalAccess', _29 => _29.image_url]))));
1836
+ return avatarUrl(_nullishCoalesce(_optionalChain([meta, 'optionalAccess', _41 => _41.image]), () => ( _optionalChain([meta, 'optionalAccess', _42 => _42.image_url]))));
1777
1837
  }
1778
1838
 
1779
1839
  // src/core/ensHint.ts
1780
1840
  var _ens = require('viem/ens');
1781
1841
  var ENS_HINT_KEY = "id.thurin";
1782
1842
  function ensHintFor(record, verifiedFingerprint) {
1783
- const raw = _nullishCoalesce(_optionalChain([record, 'optionalAccess', _30 => _30.trim, 'call', _31 => _31()]), () => ( ""));
1843
+ const raw = _nullishCoalesce(_optionalChain([record, 'optionalAccess', _43 => _43.trim, 'call', _44 => _44()]), () => ( ""));
1784
1844
  const expected = verifiedFingerprint ? normalizeFingerprint(verifiedFingerprint) : null;
1785
1845
  if (!raw) return { key: ENS_HINT_KEY, state: "unset", record: null, fingerprint: null, expected };
1786
1846
  const fingerprint = normalizeFingerprint(raw);
@@ -1949,12 +2009,12 @@ function decodeRecord(hex) {
1949
2009
  }
1950
2010
  function parsePointer(text) {
1951
2011
  const p = JSON.parse(text);
1952
- if (_optionalChain([p, 'optionalAccess', _32 => _32.v]) !== 1 || !Array.isArray(p.releases)) throw new Error("Not a v1 thurin.pointer record");
2012
+ if (_optionalChain([p, 'optionalAccess', _45 => _45.v]) !== 1 || !Array.isArray(p.releases)) throw new Error("Not a v1 thurin.pointer record");
1953
2013
  return p;
1954
2014
  }
1955
2015
  function addPointer(existing, entry) {
1956
2016
  if (!/^[0-9a-f]{64}$/i.test(entry.sha256)) throw new Error("sha256 must be 64 hex characters");
1957
- const releases = [entry, ...(_nullishCoalesce(_optionalChain([existing, 'optionalAccess', _33 => _33.releases]), () => ( []))).filter((r) => r.name !== entry.name)];
2017
+ const releases = [entry, ...(_nullishCoalesce(_optionalChain([existing, 'optionalAccess', _46 => _46.releases]), () => ( []))).filter((r) => r.name !== entry.name)];
1958
2018
  const dropped = [];
1959
2019
  const record = { v: 1, releases };
1960
2020
  while (new TextEncoder().encode(JSON.stringify(record)).length > MAX_RECORD_BYTES && releases.length > 1) dropped.push(releases.pop());
@@ -1988,14 +2048,14 @@ async function parseRecord(kind, text, opts = {}) {
1988
2048
  case "thurin.affiliation": {
1989
2049
  try {
1990
2050
  const j = JSON.parse(t);
1991
- if (_optionalChain([j, 'optionalAccess', _34 => _34.v]) !== 1 || typeof j.with !== "string" || !j.with) return invalid('Not a v1 affiliation: expected {"v":1,"with":"<address or name>"}');
2051
+ if (_optionalChain([j, 'optionalAccess', _47 => _47.v]) !== 1 || typeof j.with !== "string" || !j.with) return invalid('Not a v1 affiliation: expected {"v":1,"with":"<address or name>"}');
1992
2052
  return { ...base, valid: true, data: { type: "affiliation", with: j.with, role: typeof j.role === "string" ? j.role : null } };
1993
- } catch (e9) {
2053
+ } catch (e11) {
1994
2054
  return invalid("Not JSON");
1995
2055
  }
1996
2056
  }
1997
2057
  case "thurin.canary": {
1998
- const date = _optionalChain([t, 'access', _35 => _35.match, 'call', _36 => _36(ISO_DATE), 'optionalAccess', _37 => _37[1]]);
2058
+ const date = _optionalChain([t, 'access', _48 => _48.match, 'call', _49 => _49(ISO_DATE), 'optionalAccess', _50 => _50[1]]);
1999
2059
  if (!date) return invalid("No date in the statement");
2000
2060
  const clearsigned = t.startsWith("-----BEGIN PGP SIGNED MESSAGE-----");
2001
2061
  let statement = clearsigned ? (_nullishCoalesce(t.split(/\r?\n\r?\n/)[1], () => ( ""))).split("-----BEGIN PGP SIGNATURE-----")[0].trim() : t;
@@ -2015,7 +2075,7 @@ async function parseRecord(kind, text, opts = {}) {
2015
2075
  try {
2016
2076
  const { readMessage } = await Promise.resolve().then(() => _interopRequireWildcard(require("openpgp")));
2017
2077
  recipients = (await readMessage({ armoredMessage: t })).getEncryptionKeyIDs().length;
2018
- } catch (e10) {
2078
+ } catch (e12) {
2019
2079
  return invalid("PGP message does not parse");
2020
2080
  }
2021
2081
  return { ...base, valid: true, data: { type: "encrypted", recipients } };
@@ -2023,7 +2083,7 @@ async function parseRecord(kind, text, opts = {}) {
2023
2083
  case "thurin.pointer": {
2024
2084
  try {
2025
2085
  return { ...base, valid: true, data: { type: "pointer", releases: parsePointer(t).releases } };
2026
- } catch (e11) {
2086
+ } catch (e13) {
2027
2087
  return invalid("Not a v1 pointer record");
2028
2088
  }
2029
2089
  }
@@ -2041,6 +2101,103 @@ async function fetchRecords(client, registry, abi, owner, index, kinds = IDENTIT
2041
2101
  return out;
2042
2102
  }
2043
2103
 
2104
+ // src/core/claimStatus.ts
2105
+ var CLAIM_CHECK_LABEL = {
2106
+ verified: "pgp verified",
2107
+ expired: "key expired",
2108
+ "signing-key-expired": "signing key expired",
2109
+ revoked: "key revoked",
2110
+ compromised: "key compromised",
2111
+ "signing-key-revoked": "signing key revoked",
2112
+ unsupported: "not supported",
2113
+ "bad-signature": "doesn't verify"
2114
+ };
2115
+ function formatClaimDate(isoOrSeconds) {
2116
+ if (isoOrSeconds === null || isoOrSeconds === void 0) return "an unknown date";
2117
+ const d = typeof isoOrSeconds === "number" ? new Date(isoOrSeconds * 1e3) : new Date(isoOrSeconds);
2118
+ if (Number.isNaN(d.getTime())) return "an unknown date";
2119
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
2120
+ }
2121
+ function shortFingerprint(fpr) {
2122
+ if (!fpr) return "unknown";
2123
+ const f = fpr.toUpperCase();
2124
+ return `${f.slice(0, 4)} ${f.slice(4, 8)} \u2026`;
2125
+ }
2126
+ function claimCheckText(v, formatDate = formatClaimDate) {
2127
+ const kind = _nullishCoalesce(v.kind, () => ( (v.verified ? "verified" : "bad-signature")));
2128
+ const at = formatDate(v.at);
2129
+ const t = (sentence, fix = null) => ({ kind, label: CLAIM_CHECK_LABEL[kind], sentence, fix });
2130
+ switch (kind) {
2131
+ case "verified":
2132
+ return t("The key signed a line naming this address, and the signature checks out.");
2133
+ case "expired":
2134
+ return t(
2135
+ `The key on this claim expired on ${at}, so the claim no longer counts.`,
2136
+ "Extend the key, then Update key. No new signature needed."
2137
+ );
2138
+ case "signing-key-expired":
2139
+ return t(
2140
+ `The subkey that signed this claim (${shortFingerprint(v.signingKey)}) expired on ${at}.`,
2141
+ "Extend that subkey, then Update key."
2142
+ );
2143
+ case "revoked":
2144
+ return t(
2145
+ `Its owner revoked this PGP key on ${at}${v.revocationReason ? ` (reason: ${v.revocationReason})` : ""}. The claim no longer counts.`,
2146
+ "Revoke this claim and attest your new key."
2147
+ );
2148
+ case "compromised":
2149
+ return t(
2150
+ `Its owner revoked this PGP key on ${at} as compromised. Don't trust anything it signed after that date.`,
2151
+ "Revoke this claim and attest a new key."
2152
+ );
2153
+ case "signing-key-revoked":
2154
+ return t(
2155
+ `The subkey that signed this claim was revoked on ${at}.`,
2156
+ "Replace the claim with a signature from a current key."
2157
+ );
2158
+ case "unsupported":
2159
+ return t(`This key uses an algorithm Thurin doesn't check${v.algorithm ? ` (${v.algorithm})` : ""}. The claim is on-chain, but no lookup will vouch for it.`);
2160
+ case "bad-signature":
2161
+ default:
2162
+ return t("The signature stored with this claim doesn't match its key, so nothing from this claim is shown.");
2163
+ }
2164
+ }
2165
+ function expiresSoon(v, now = Date.now(), withinDays = 30) {
2166
+ if (!_optionalChain([v, 'optionalAccess', _51 => _51.verified]) || !v.expiresAt) return null;
2167
+ const ms = new Date(v.expiresAt).getTime() - now;
2168
+ if (!Number.isFinite(ms) || ms < 0 || ms > withinDays * 864e5) return null;
2169
+ return { days: Math.floor(ms / 864e5), at: v.expiresAt };
2170
+ }
2171
+ function expiresSoonText(soon, formatDate = formatClaimDate) {
2172
+ const when = soon.days === 0 ? "today" : soon.days === 1 ? "in 1 day" : `in ${soon.days} days`;
2173
+ return `Key expires ${when} (${formatDate(soon.at)}).`;
2174
+ }
2175
+ function claimFates(claims) {
2176
+ const out = /* @__PURE__ */ new Map();
2177
+ for (const c of claims) {
2178
+ if (!c.revokedAt) {
2179
+ out.set(c.index, { state: "active" });
2180
+ continue;
2181
+ }
2182
+ const successor = claims.filter((n) => n.index > c.index && n.createdAt === c.revokedAt).sort((a, b) => a.index - b.index)[0];
2183
+ out.set(c.index, successor ? { state: "replaced", at: c.revokedAt, by: successor.index } : { state: "revoked", at: c.revokedAt });
2184
+ }
2185
+ return out;
2186
+ }
2187
+ function claimFateText(fate, formatDate = (s) => formatClaimDate(s)) {
2188
+ if (fate.state === "revoked") return `Revoked by its owner on ${formatDate(fate.at)}.`;
2189
+ if (fate.state === "replaced") return `Replaced by claim #${fate.by} on ${formatDate(fate.at)}.`;
2190
+ return null;
2191
+ }
2192
+
2193
+
2194
+
2195
+
2196
+
2197
+
2198
+
2199
+
2200
+
2044
2201
 
2045
2202
 
2046
2203
 
@@ -2104,5 +2261,5 @@ async function fetchRecords(client, registry, abi, owner, index, kinds = IDENTIT
2104
2261
 
2105
2262
 
2106
2263
 
2107
- exports.REGISTRY_ABI = REGISTRY_ABI; exports.REGISTRY_ADDRESS = REGISTRY_ADDRESS; exports.NETWORKS = NETWORKS; exports.isNetworkName = isNetworkName; exports.getRegistry = getRegistry; exports.chainFor = chainFor; exports.needsRpcProbe = needsRpcProbe; exports.identityErrorKind = identityErrorKind; exports.IDENTITY_ERROR_TEXT = IDENTITY_ERROR_TEXT; exports.normalizeFingerprint = normalizeFingerprint; exports.fingerprintToBytes = fingerprintToBytes; exports.bytesToFingerprint = bytesToFingerprint; exports.fingerprintHash = fingerprintHash; exports.keyIdOf = keyIdOf; exports.keyIdToBytes = keyIdToBytes; exports.parsePgpKey = parsePgpKey; exports.verifyClearsigned = verifyClearsigned; exports.verifyAttestation = verifyAttestation; exports.stripEmailUserIDs = stripEmailUserIDs; exports.hasEmailUserID = hasEmailUserID; exports.fetchEFPGraph = fetchEFPGraph; exports.identifyProof = identifyProof; exports.displayUrl = displayUrl; exports.proofHref = proofHref; exports.proofSecondaryHref = proofSecondaryHref; exports.verifyProof = verifyProof; exports.IPFS_GATEWAYS = IPFS_GATEWAYS; exports.IPFS_GATEWAY = IPFS_GATEWAY; exports.ARWEAVE_GATEWAY = ARWEAVE_GATEWAY; exports.avatarUrl = avatarUrl; exports.avatarFallbacks = avatarFallbacks; exports.parseNftAvatar = parseNftAvatar; exports.NFT_AVATAR_ABI = NFT_AVATAR_ABI; exports.nftAvatarImage = nftAvatarImage; exports.ENS_HINT_KEY = ENS_HINT_KEY; exports.ensHintFor = ensHintFor; exports.ensHintValue = ensHintValue; exports.ENS_TEXT_RESOLVER_ABI = ENS_TEXT_RESOLVER_ABI; exports.ensHintWrite = ensHintWrite; exports.fetchEnsHint = fetchEnsHint; exports.EIP712_NAME = EIP712_NAME; exports.EIP712_VERSION = EIP712_VERSION; exports.registryDomain = registryDomain; exports.AUTHORIZATION_TYPES = AUTHORIZATION_TYPES; exports.attestTypedData = attestTypedData; exports.reattestTypedData = reattestTypedData; exports.updateKeyTypedData = updateKeyTypedData; exports.revokeTypedData = revokeTypedData; exports.setRecordTypedData = setRecordTypedData; exports.authorizationDigest = authorizationDigest; exports.recordKind = recordKind; exports.MAX_RECORD_BYTES = MAX_RECORD_BYTES; exports.kindName = kindName; exports.KNOWN_KINDS = KNOWN_KINDS; exports.IDENTITY_KINDS = IDENTITY_KINDS; exports.encodeRecord = encodeRecord; exports.decodeRecord = decodeRecord; exports.parsePointer = parsePointer; exports.addPointer = addPointer; exports.renderPointer = renderPointer; exports.parseRecord = parseRecord; exports.fetchRecords = fetchRecords;
2108
- //# sourceMappingURL=chunk-I3Y63ZPK.cjs.map
2264
+ exports.REGISTRY_ABI = REGISTRY_ABI; exports.REGISTRY_ADDRESS = REGISTRY_ADDRESS; exports.NETWORKS = NETWORKS; exports.isNetworkName = isNetworkName; exports.getRegistry = getRegistry; exports.chainFor = chainFor; exports.needsRpcProbe = needsRpcProbe; exports.identityErrorKind = identityErrorKind; exports.IDENTITY_ERROR_TEXT = IDENTITY_ERROR_TEXT; exports.normalizeFingerprint = normalizeFingerprint; exports.fingerprintToBytes = fingerprintToBytes; exports.bytesToFingerprint = bytesToFingerprint; exports.fingerprintHash = fingerprintHash; exports.keyIdOf = keyIdOf; exports.keyIdToBytes = keyIdToBytes; exports.parsePgpKey = parsePgpKey; exports.verifyClearsigned = verifyClearsigned; exports.verifyAttestation = verifyAttestation; exports.stripEmailUserIDs = stripEmailUserIDs; exports.hasEmailUserID = hasEmailUserID; exports.fetchEFPGraph = fetchEFPGraph; exports.identifyProof = identifyProof; exports.displayUrl = displayUrl; exports.proofHref = proofHref; exports.proofSecondaryHref = proofSecondaryHref; exports.FARCASTER_HUB = FARCASTER_HUB; exports.verifyProof = verifyProof; exports.IPFS_GATEWAYS = IPFS_GATEWAYS; exports.IPFS_GATEWAY = IPFS_GATEWAY; exports.ARWEAVE_GATEWAY = ARWEAVE_GATEWAY; exports.avatarUrl = avatarUrl; exports.avatarFallbacks = avatarFallbacks; exports.parseNftAvatar = parseNftAvatar; exports.NFT_AVATAR_ABI = NFT_AVATAR_ABI; exports.nftAvatarImage = nftAvatarImage; exports.ENS_HINT_KEY = ENS_HINT_KEY; exports.ensHintFor = ensHintFor; exports.ensHintValue = ensHintValue; exports.ENS_TEXT_RESOLVER_ABI = ENS_TEXT_RESOLVER_ABI; exports.ensHintWrite = ensHintWrite; exports.fetchEnsHint = fetchEnsHint; exports.EIP712_NAME = EIP712_NAME; exports.EIP712_VERSION = EIP712_VERSION; exports.registryDomain = registryDomain; exports.AUTHORIZATION_TYPES = AUTHORIZATION_TYPES; exports.attestTypedData = attestTypedData; exports.reattestTypedData = reattestTypedData; exports.updateKeyTypedData = updateKeyTypedData; exports.revokeTypedData = revokeTypedData; exports.setRecordTypedData = setRecordTypedData; exports.authorizationDigest = authorizationDigest; exports.recordKind = recordKind; exports.MAX_RECORD_BYTES = MAX_RECORD_BYTES; exports.kindName = kindName; exports.KNOWN_KINDS = KNOWN_KINDS; exports.IDENTITY_KINDS = IDENTITY_KINDS; exports.encodeRecord = encodeRecord; exports.decodeRecord = decodeRecord; exports.parsePointer = parsePointer; exports.addPointer = addPointer; exports.renderPointer = renderPointer; exports.parseRecord = parseRecord; exports.fetchRecords = fetchRecords; exports.CLAIM_CHECK_LABEL = CLAIM_CHECK_LABEL; exports.formatClaimDate = formatClaimDate; exports.claimCheckText = claimCheckText; exports.expiresSoon = expiresSoon; exports.expiresSoonText = expiresSoonText; exports.claimFates = claimFates; exports.claimFateText = claimFateText;
2265
+ //# sourceMappingURL=chunk-M2OJDB7I.cjs.map