@orangecheck/sdk 1.14.1 → 1.16.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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/challenge.ts"],"names":["Buffer","verifyBitcoinSignature"],"mappings":"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","file":"challenge.js","sourcesContent":["/**\n * Signed-challenge auth\n *\n * When a gate can't trust the address source (public header, query string,\n * unsigned cookie), run a one-shot BIP-322 challenge first to cryptographically\n * prove the caller controls the address. Bind the proven address to a session\n * cookie or JWT, and the gate reads from there.\n *\n * This is a different wire format from the main OCP attestation — the header\n * literal is `orangecheck-auth`, not `orangecheck`, so a signed auth challenge\n * can never be confused with a reputation attestation.\n *\n * // Server, on the \"connect wallet\" flow:\n * const challenge = issueChallenge({ address, ttlSeconds: 300 });\n * // → send challenge.message to the client\n *\n * // Client signs challenge.message with BIP-322 via the user's wallet,\n * // then posts { message, signature } back.\n *\n * // Server, on callback:\n * const result = await verifyChallenge({ message, signature });\n * if (result.ok) {\n * // result.address is now cryptographically proven; attach to session.\n * }\n */\n\nimport type { Scheme } from './types';\n\nimport { Buffer } from 'buffer';\n\nimport { verifySignature as verifyBitcoinSignature } from '@orangecheck/bip322';\n\nconst AUTH_HEADER = 'orangecheck-auth';\nconst AUTH_ACK = 'I authorize this session under the OrangeCheck auth challenge.';\n\nfunction randomNonce(): string {\n // 16 bytes → 32 lowercase hex chars. Matches OCP core nonce format.\n const bytes = new Uint8Array(16);\n // Runtimes: Node 20+ (webcrypto), browsers.\n if (typeof globalThis.crypto?.getRandomValues === 'function') {\n globalThis.crypto.getRandomValues(bytes);\n } else {\n // Fallback: Math.random is *not* cryptographic — acceptable only for\n // environments without webcrypto (old Node). Flagged explicitly.\n for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);\n }\n return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');\n}\n\nfunction iso(date: Date): string {\n return date.toISOString().replace(/\\.\\d{3}Z$/, 'Z');\n}\n\nexport interface IssueChallengeOptions {\n /** Bitcoin address the caller claims to control. */\n address: string;\n /** How long the challenge is valid for. Default 300 (5 minutes). */\n ttlSeconds?: number;\n /** Explicit nonce. Rare; prefer auto-generated. */\n nonce?: string;\n /** Origin-binding extension. If set, verify will require a match. */\n audience?: string;\n /** Human-readable label baked into the signed message. */\n purpose?: string;\n /** Freeze `now` for tests. */\n now?: Date;\n}\n\nexport interface Challenge {\n /** The canonical text the client MUST sign. */\n message: string;\n /** The generated nonce (kept for correlation / server-side storage). */\n nonce: string;\n /** Unix ms epoch the challenge expires at. */\n expiresAt: number;\n /** ISO string of the same. */\n expiresAtIso: string;\n}\n\n/**\n * Build a new signed-challenge message. The server MUST remember the nonce\n * it issued against the session (Redis, signed cookie, DB) so it can detect\n * replay or a different nonce coming back on verify.\n */\nexport function issueChallenge(opts: IssueChallengeOptions): Challenge {\n const now = opts.now ?? new Date();\n const ttl = opts.ttlSeconds ?? 300;\n const expiresAt = new Date(now.getTime() + ttl * 1000);\n const nonce = opts.nonce ?? randomNonce();\n\n if (!/^[0-9a-f]{32}$/.test(nonce)) {\n throw new Error('nonce must be 32 lowercase hex characters');\n }\n\n const coreLines = [\n AUTH_HEADER,\n `address: ${opts.address}`,\n `nonce: ${nonce}`,\n `issued_at: ${iso(now)}`,\n `expires_at: ${iso(expiresAt)}`,\n `ack: ${AUTH_ACK}`,\n ];\n\n const extensions: Array<[string, string]> = [];\n if (opts.audience) extensions.push(['audience', opts.audience]);\n if (opts.purpose) extensions.push(['purpose', opts.purpose]);\n // Lexicographic sort by key — matches OCP canonicalization rules.\n extensions.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));\n\n const extLines = extensions.map(([k, v]) => `${k}: ${v}`);\n const message = [...coreLines, ...extLines].join('\\n') + '\\n';\n\n return {\n message,\n nonce,\n expiresAt: expiresAt.getTime(),\n expiresAtIso: iso(expiresAt),\n };\n}\n\ninterface ParsedChallenge {\n address?: string;\n nonce?: string;\n issuedAt?: Date;\n expiresAt?: Date;\n ack?: string;\n audience?: string;\n purpose?: string;\n extensions: Array<[string, string]>;\n}\n\nfunction parseChallenge(message: string): ParsedChallenge {\n const lines = message.endsWith('\\n') ? message.slice(0, -1).split('\\n') : message.split('\\n');\n\n if (lines[0] !== AUTH_HEADER) {\n throw new Error('not an orangecheck-auth challenge');\n }\n\n const out: ParsedChallenge = { extensions: [] };\n const coreKeys = ['address', 'nonce', 'issued_at', 'expires_at', 'ack'] as const;\n const coreKeySet = new Set<string>(coreKeys);\n // Duplicate detection: a challenge with two `address:` lines would\n // previously let the second overwrite the first. A signer who saw both\n // lines would still sign the whole text — which means the verifier and\n // the signer disagree about what the message \"said\". Refuse instead.\n const seenCore = new Set<string>();\n\n for (let i = 1; i < lines.length; i++) {\n const line = lines[i] ?? '';\n const sep = line.indexOf(': ');\n if (sep === -1) continue;\n const key = line.slice(0, sep);\n const val = line.slice(sep + 2);\n\n if (coreKeySet.has(key)) {\n if (seenCore.has(key)) {\n throw new Error(`duplicate core key in challenge: ${key}`);\n }\n seenCore.add(key);\n if (key === 'address') out.address = val;\n else if (key === 'nonce') out.nonce = val;\n else if (key === 'issued_at') out.issuedAt = new Date(val);\n else if (key === 'expires_at') out.expiresAt = new Date(val);\n else if (key === 'ack') out.ack = val;\n } else {\n out.extensions.push([key, val]);\n if (key === 'audience') out.audience = val;\n if (key === 'purpose') out.purpose = val;\n }\n }\n\n if (seenCore.size !== coreKeys.length) {\n throw new Error(`challenge missing core lines (${seenCore.size}/${coreKeys.length})`);\n }\n return out;\n}\n\nexport interface VerifyChallengeOptions {\n /** The signed canonical challenge text, exactly as issued. */\n message: string;\n /** BIP-322 or legacy signature. */\n signature: string;\n /** Scheme override. Default 'bip322'. */\n scheme?: Scheme;\n\n /** If set, require the challenge's `audience:` extension to match. */\n expectedAudience?: string;\n /** If set, require the challenge's `purpose:` extension to match. */\n expectedPurpose?: string;\n /**\n * If set, require the challenge's `nonce:` to match. Use this to defeat\n * replay — pair with server-side nonce storage (Redis / signed cookie).\n */\n expectedNonce?: string;\n\n /** Tolerance on expiry check in ms. Default 0. */\n clockSkewMs?: number;\n /** Freeze `now` for tests. */\n now?: Date;\n}\n\nexport type VerifyChallengeReason =\n | 'ok'\n | 'malformed'\n | 'expired'\n | 'not_yet_valid'\n | 'sig_invalid'\n | 'sig_unsupported_scheme'\n | 'audience_mismatch'\n | 'purpose_mismatch'\n | 'nonce_mismatch';\n\nexport interface VerifyChallengeResult {\n ok: boolean;\n reason: VerifyChallengeReason;\n /** Present on success — the cryptographically proven Bitcoin address. */\n address?: string;\n /** Present on success — when the challenge expires (unix ms). */\n expiresAt?: number;\n /** Present on success — the nonce from the challenge. */\n nonce?: string;\n /** Present on success — the audience extension, if any. */\n audience?: string;\n /** Present on success — the purpose extension, if any. */\n purpose?: string;\n}\n\n/**\n * Verify a signed challenge. Returns the proven address on success.\n *\n * The caller is responsible for nonce replay prevention — pass `expectedNonce`\n * with the value you stashed at issue time.\n */\nexport async function verifyChallenge(\n opts: VerifyChallengeOptions\n): Promise<VerifyChallengeResult> {\n let parsed: ParsedChallenge;\n try {\n parsed = parseChallenge(opts.message);\n } catch {\n return { ok: false, reason: 'malformed' };\n }\n\n const { address, nonce, issuedAt, expiresAt, ack, audience, purpose } = parsed;\n if (!address || !nonce || !issuedAt || !expiresAt || !ack) {\n return { ok: false, reason: 'malformed' };\n }\n if (ack !== AUTH_ACK) {\n return { ok: false, reason: 'malformed' };\n }\n if (!/^[0-9a-f]{32}$/.test(nonce)) {\n return { ok: false, reason: 'malformed' };\n }\n\n const now = opts.now ?? new Date();\n const skew = opts.clockSkewMs ?? 0;\n\n if (now.getTime() - skew > expiresAt.getTime()) {\n return { ok: false, reason: 'expired' };\n }\n if (now.getTime() + skew < issuedAt.getTime()) {\n return { ok: false, reason: 'not_yet_valid' };\n }\n\n if (opts.expectedNonce && nonce !== opts.expectedNonce) {\n return { ok: false, reason: 'nonce_mismatch' };\n }\n if (opts.expectedAudience && audience !== opts.expectedAudience) {\n return { ok: false, reason: 'audience_mismatch' };\n }\n if (opts.expectedPurpose && purpose !== opts.expectedPurpose) {\n return { ok: false, reason: 'purpose_mismatch' };\n }\n\n const scheme: Scheme = opts.scheme ?? 'bip322';\n let sigValid: boolean;\n if (scheme === 'bip322') {\n const sigBase64 =\n /^[0-9a-fA-F]+$/.test(opts.signature) && opts.signature.length % 2 === 0\n ? Buffer.from(opts.signature, 'hex').toString('base64')\n : opts.signature;\n sigValid = verifyBitcoinSignature(address, opts.message, sigBase64);\n } else if (scheme === 'legacy') {\n // Legacy signmessage only works for P2PKH addresses.\n if (!/^[1m][a-km-zA-HJ-NP-Z1-9]{25,34}$/.test(address)) {\n return { ok: false, reason: 'sig_unsupported_scheme' };\n }\n sigValid = verifyBitcoinSignature(address, opts.message, opts.signature);\n } else {\n return { ok: false, reason: 'sig_unsupported_scheme' };\n }\n\n if (!sigValid) return { ok: false, reason: 'sig_invalid' };\n\n return {\n ok: true,\n reason: 'ok',\n address,\n expiresAt: expiresAt.getTime(),\n nonce,\n ...(audience !== undefined ? { audience } : {}),\n ...(purpose !== undefined ? { purpose } : {}),\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/utils/signature.ts","../src/challenge.ts"],"names":["Buffer","verifyBitcoinSignature"],"mappings":"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","file":"challenge.js","sourcesContent":["import type { Scheme } from '../types';\n\nimport { Buffer } from 'buffer';\n\n/** A signature as base64: hex input is converted, anything else passes through. */\nexport function toBase64Maybe(sig: string): string {\n if (/^[0-9a-fA-F]+$/.test(sig) && sig.length % 2 === 0) {\n return Buffer.from(sig, 'hex').toString('base64');\n }\n return sig;\n}\n\n/**\n * The scheme a signature's bytes say it is, the same rule @orangecheck/bip322\n * verifies by: 65 bytes is a BIP-137 compact signature, whatever the address,\n * and anything else is BIP-322 simple. `null` for a BIP-322 full or\n * proof-of-funds signature, which is not supported.\n */\nexport function signatureScheme(sigBase64: string): Scheme | null {\n if (/^(ful|pof)/.test(sigBase64)) return null;\n if (sigBase64.startsWith('smp')) return 'bip322';\n return Buffer.from(sigBase64, 'base64').length === 65 ? 'legacy' : 'bip322';\n}\n","/**\n * Signed-challenge auth\n *\n * When a gate can't trust the address source (public header, query string,\n * unsigned cookie), run a one-shot BIP-322 challenge first to cryptographically\n * prove the caller controls the address. Bind the proven address to a session\n * cookie or JWT, and the gate reads from there.\n *\n * This is a different wire format from the main OCP attestation — the header\n * literal is `orangecheck-auth`, not `orangecheck`, so a signed auth challenge\n * can never be confused with a reputation attestation.\n *\n * // Server, on the \"connect wallet\" flow:\n * const challenge = issueChallenge({ address, ttlSeconds: 300 });\n * // → send challenge.message to the client\n *\n * // Client signs challenge.message with BIP-322 via the user's wallet,\n * // then posts { message, signature } back.\n *\n * // Server, on callback:\n * const result = await verifyChallenge({ message, signature });\n * if (result.ok) {\n * // result.address is now cryptographically proven; attach to session.\n * }\n */\n\nimport type { Scheme } from './types';\n\nimport { verifySignature as verifyBitcoinSignature } from '@orangecheck/bip322';\n\nimport { signatureScheme, toBase64Maybe } from './utils/signature';\n\nconst AUTH_HEADER = 'orangecheck-auth';\nconst AUTH_ACK = 'I authorize this session under the OrangeCheck auth challenge.';\n\nfunction randomNonce(): string {\n // 16 bytes → 32 lowercase hex chars. Matches OCP core nonce format.\n const bytes = new Uint8Array(16);\n // Runtimes: Node 20+ (webcrypto), browsers.\n if (typeof globalThis.crypto?.getRandomValues === 'function') {\n globalThis.crypto.getRandomValues(bytes);\n } else {\n // Fallback: Math.random is *not* cryptographic — acceptable only for\n // environments without webcrypto (old Node). Flagged explicitly.\n for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);\n }\n return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');\n}\n\nfunction iso(date: Date): string {\n return date.toISOString().replace(/\\.\\d{3}Z$/, 'Z');\n}\n\nexport interface IssueChallengeOptions {\n /** Bitcoin address the caller claims to control. */\n address: string;\n /** How long the challenge is valid for. Default 300 (5 minutes). */\n ttlSeconds?: number;\n /** Explicit nonce. Rare; prefer auto-generated. */\n nonce?: string;\n /** Origin-binding extension. If set, verify will require a match. */\n audience?: string;\n /** Human-readable label baked into the signed message. */\n purpose?: string;\n /** Freeze `now` for tests. */\n now?: Date;\n}\n\nexport interface Challenge {\n /** The canonical text the client MUST sign. */\n message: string;\n /** The generated nonce (kept for correlation / server-side storage). */\n nonce: string;\n /** Unix ms epoch the challenge expires at. */\n expiresAt: number;\n /** ISO string of the same. */\n expiresAtIso: string;\n}\n\n/**\n * Build a new signed-challenge message. The server MUST remember the nonce\n * it issued against the session (Redis, signed cookie, DB) so it can detect\n * replay or a different nonce coming back on verify.\n */\nexport function issueChallenge(opts: IssueChallengeOptions): Challenge {\n const now = opts.now ?? new Date();\n const ttl = opts.ttlSeconds ?? 300;\n const expiresAt = new Date(now.getTime() + ttl * 1000);\n const nonce = opts.nonce ?? randomNonce();\n\n if (!/^[0-9a-f]{32}$/.test(nonce)) {\n throw new Error('nonce must be 32 lowercase hex characters');\n }\n\n const coreLines = [\n AUTH_HEADER,\n `address: ${opts.address}`,\n `nonce: ${nonce}`,\n `issued_at: ${iso(now)}`,\n `expires_at: ${iso(expiresAt)}`,\n `ack: ${AUTH_ACK}`,\n ];\n\n const extensions: Array<[string, string]> = [];\n if (opts.audience) extensions.push(['audience', opts.audience]);\n if (opts.purpose) extensions.push(['purpose', opts.purpose]);\n // Lexicographic sort by key — matches OCP canonicalization rules.\n extensions.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));\n\n const extLines = extensions.map(([k, v]) => `${k}: ${v}`);\n const message = [...coreLines, ...extLines].join('\\n') + '\\n';\n\n return {\n message,\n nonce,\n expiresAt: expiresAt.getTime(),\n expiresAtIso: iso(expiresAt),\n };\n}\n\ninterface ParsedChallenge {\n address?: string;\n nonce?: string;\n issuedAt?: Date;\n expiresAt?: Date;\n ack?: string;\n audience?: string;\n purpose?: string;\n extensions: Array<[string, string]>;\n}\n\nfunction parseChallenge(message: string): ParsedChallenge {\n const lines = message.endsWith('\\n') ? message.slice(0, -1).split('\\n') : message.split('\\n');\n\n if (lines[0] !== AUTH_HEADER) {\n throw new Error('not an orangecheck-auth challenge');\n }\n\n const out: ParsedChallenge = { extensions: [] };\n const coreKeys = ['address', 'nonce', 'issued_at', 'expires_at', 'ack'] as const;\n const coreKeySet = new Set<string>(coreKeys);\n // Duplicate detection: a challenge with two `address:` lines would\n // previously let the second overwrite the first. A signer who saw both\n // lines would still sign the whole text — which means the verifier and\n // the signer disagree about what the message \"said\". Refuse instead.\n const seenCore = new Set<string>();\n\n for (let i = 1; i < lines.length; i++) {\n const line = lines[i] ?? '';\n const sep = line.indexOf(': ');\n if (sep === -1) continue;\n const key = line.slice(0, sep);\n const val = line.slice(sep + 2);\n\n if (coreKeySet.has(key)) {\n if (seenCore.has(key)) {\n throw new Error(`duplicate core key in challenge: ${key}`);\n }\n seenCore.add(key);\n if (key === 'address') out.address = val;\n else if (key === 'nonce') out.nonce = val;\n else if (key === 'issued_at') out.issuedAt = new Date(val);\n else if (key === 'expires_at') out.expiresAt = new Date(val);\n else if (key === 'ack') out.ack = val;\n } else {\n out.extensions.push([key, val]);\n if (key === 'audience') out.audience = val;\n if (key === 'purpose') out.purpose = val;\n }\n }\n\n if (seenCore.size !== coreKeys.length) {\n throw new Error(`challenge missing core lines (${seenCore.size}/${coreKeys.length})`);\n }\n return out;\n}\n\nexport interface VerifyChallengeOptions {\n /** The signed canonical challenge text, exactly as issued. */\n message: string;\n /** BIP-322 or legacy signature, base64 or hex. */\n signature: string;\n /** @deprecated Ignored: the signature's own bytes decide the scheme. */\n scheme?: Scheme;\n\n /** If set, require the challenge's `audience:` extension to match. */\n expectedAudience?: string;\n /** If set, require the challenge's `purpose:` extension to match. */\n expectedPurpose?: string;\n /**\n * If set, require the challenge's `nonce:` to match. Use this to defeat\n * replay — pair with server-side nonce storage (Redis / signed cookie).\n */\n expectedNonce?: string;\n\n /** Tolerance on expiry check in ms. Default 0. */\n clockSkewMs?: number;\n /** Freeze `now` for tests. */\n now?: Date;\n}\n\nexport type VerifyChallengeReason =\n | 'ok'\n | 'malformed'\n | 'expired'\n | 'not_yet_valid'\n | 'sig_invalid'\n | 'sig_unsupported_scheme'\n | 'audience_mismatch'\n | 'purpose_mismatch'\n | 'nonce_mismatch';\n\nexport interface VerifyChallengeResult {\n ok: boolean;\n reason: VerifyChallengeReason;\n /** Present on success — the cryptographically proven Bitcoin address. */\n address?: string;\n /** Present on success — the scheme that verified the signature. */\n scheme?: Scheme;\n /** Present on success — when the challenge expires (unix ms). */\n expiresAt?: number;\n /** Present on success — the nonce from the challenge. */\n nonce?: string;\n /** Present on success — the audience extension, if any. */\n audience?: string;\n /** Present on success — the purpose extension, if any. */\n purpose?: string;\n}\n\n/**\n * Verify a signed challenge. Returns the proven address on success.\n *\n * The caller is responsible for nonce replay prevention — pass `expectedNonce`\n * with the value you stashed at issue time.\n */\nexport async function verifyChallenge(\n opts: VerifyChallengeOptions\n): Promise<VerifyChallengeResult> {\n let parsed: ParsedChallenge;\n try {\n parsed = parseChallenge(opts.message);\n } catch {\n return { ok: false, reason: 'malformed' };\n }\n\n const { address, nonce, issuedAt, expiresAt, ack, audience, purpose } = parsed;\n if (!address || !nonce || !issuedAt || !expiresAt || !ack) {\n return { ok: false, reason: 'malformed' };\n }\n if (ack !== AUTH_ACK) {\n return { ok: false, reason: 'malformed' };\n }\n if (!/^[0-9a-f]{32}$/.test(nonce)) {\n return { ok: false, reason: 'malformed' };\n }\n\n const now = opts.now ?? new Date();\n const skew = opts.clockSkewMs ?? 0;\n\n if (now.getTime() - skew > expiresAt.getTime()) {\n return { ok: false, reason: 'expired' };\n }\n if (now.getTime() + skew < issuedAt.getTime()) {\n return { ok: false, reason: 'not_yet_valid' };\n }\n\n if (opts.expectedNonce && nonce !== opts.expectedNonce) {\n return { ok: false, reason: 'nonce_mismatch' };\n }\n if (opts.expectedAudience && audience !== opts.expectedAudience) {\n return { ok: false, reason: 'audience_mismatch' };\n }\n if (opts.expectedPurpose && purpose !== opts.expectedPurpose) {\n return { ok: false, reason: 'purpose_mismatch' };\n }\n\n const sigBase64 = toBase64Maybe(opts.signature);\n const scheme = signatureScheme(sigBase64);\n if (!scheme) return { ok: false, reason: 'sig_unsupported_scheme' };\n if (!verifyBitcoinSignature(address, opts.message, sigBase64)) {\n return { ok: false, reason: 'sig_invalid' };\n }\n\n return {\n ok: true,\n reason: 'ok',\n address,\n scheme,\n expiresAt: expiresAt.getTime(),\n nonce,\n ...(audience !== undefined ? { audience } : {}),\n ...(purpose !== undefined ? { purpose } : {}),\n };\n}\n"]}
@@ -1,5 +1,18 @@
1
- import { Buffer } from 'buffer';
2
1
  import { verifySignature } from '@orangecheck/bip322';
2
+ import { Buffer } from 'buffer';
3
+
4
+ // src/challenge.ts
5
+ function toBase64Maybe(sig) {
6
+ if (/^[0-9a-fA-F]+$/.test(sig) && sig.length % 2 === 0) {
7
+ return Buffer.from(sig, "hex").toString("base64");
8
+ }
9
+ return sig;
10
+ }
11
+ function signatureScheme(sigBase64) {
12
+ if (/^(ful|pof)/.test(sigBase64)) return null;
13
+ if (sigBase64.startsWith("smp")) return "bip322";
14
+ return Buffer.from(sigBase64, "base64").length === 65 ? "legacy" : "bip322";
15
+ }
3
16
 
4
17
  // src/challenge.ts
5
18
  var AUTH_HEADER = "orangecheck-auth";
@@ -115,24 +128,17 @@ async function verifyChallenge(opts) {
115
128
  if (opts.expectedPurpose && purpose !== opts.expectedPurpose) {
116
129
  return { ok: false, reason: "purpose_mismatch" };
117
130
  }
118
- const scheme = opts.scheme ?? "bip322";
119
- let sigValid;
120
- if (scheme === "bip322") {
121
- const sigBase64 = /^[0-9a-fA-F]+$/.test(opts.signature) && opts.signature.length % 2 === 0 ? Buffer.from(opts.signature, "hex").toString("base64") : opts.signature;
122
- sigValid = verifySignature(address, opts.message, sigBase64);
123
- } else if (scheme === "legacy") {
124
- if (!/^[1m][a-km-zA-HJ-NP-Z1-9]{25,34}$/.test(address)) {
125
- return { ok: false, reason: "sig_unsupported_scheme" };
126
- }
127
- sigValid = verifySignature(address, opts.message, opts.signature);
128
- } else {
129
- return { ok: false, reason: "sig_unsupported_scheme" };
131
+ const sigBase64 = toBase64Maybe(opts.signature);
132
+ const scheme = signatureScheme(sigBase64);
133
+ if (!scheme) return { ok: false, reason: "sig_unsupported_scheme" };
134
+ if (!verifySignature(address, opts.message, sigBase64)) {
135
+ return { ok: false, reason: "sig_invalid" };
130
136
  }
131
- if (!sigValid) return { ok: false, reason: "sig_invalid" };
132
137
  return {
133
138
  ok: true,
134
139
  reason: "ok",
135
140
  address,
141
+ scheme,
136
142
  expiresAt: expiresAt.getTime(),
137
143
  nonce,
138
144
  ...audience !== void 0 ? { audience } : {},
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/challenge.ts"],"names":["verifyBitcoinSignature"],"mappings":"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","file":"challenge.mjs","sourcesContent":["/**\n * Signed-challenge auth\n *\n * When a gate can't trust the address source (public header, query string,\n * unsigned cookie), run a one-shot BIP-322 challenge first to cryptographically\n * prove the caller controls the address. Bind the proven address to a session\n * cookie or JWT, and the gate reads from there.\n *\n * This is a different wire format from the main OCP attestation — the header\n * literal is `orangecheck-auth`, not `orangecheck`, so a signed auth challenge\n * can never be confused with a reputation attestation.\n *\n * // Server, on the \"connect wallet\" flow:\n * const challenge = issueChallenge({ address, ttlSeconds: 300 });\n * // → send challenge.message to the client\n *\n * // Client signs challenge.message with BIP-322 via the user's wallet,\n * // then posts { message, signature } back.\n *\n * // Server, on callback:\n * const result = await verifyChallenge({ message, signature });\n * if (result.ok) {\n * // result.address is now cryptographically proven; attach to session.\n * }\n */\n\nimport type { Scheme } from './types';\n\nimport { Buffer } from 'buffer';\n\nimport { verifySignature as verifyBitcoinSignature } from '@orangecheck/bip322';\n\nconst AUTH_HEADER = 'orangecheck-auth';\nconst AUTH_ACK = 'I authorize this session under the OrangeCheck auth challenge.';\n\nfunction randomNonce(): string {\n // 16 bytes → 32 lowercase hex chars. Matches OCP core nonce format.\n const bytes = new Uint8Array(16);\n // Runtimes: Node 20+ (webcrypto), browsers.\n if (typeof globalThis.crypto?.getRandomValues === 'function') {\n globalThis.crypto.getRandomValues(bytes);\n } else {\n // Fallback: Math.random is *not* cryptographic — acceptable only for\n // environments without webcrypto (old Node). Flagged explicitly.\n for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);\n }\n return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');\n}\n\nfunction iso(date: Date): string {\n return date.toISOString().replace(/\\.\\d{3}Z$/, 'Z');\n}\n\nexport interface IssueChallengeOptions {\n /** Bitcoin address the caller claims to control. */\n address: string;\n /** How long the challenge is valid for. Default 300 (5 minutes). */\n ttlSeconds?: number;\n /** Explicit nonce. Rare; prefer auto-generated. */\n nonce?: string;\n /** Origin-binding extension. If set, verify will require a match. */\n audience?: string;\n /** Human-readable label baked into the signed message. */\n purpose?: string;\n /** Freeze `now` for tests. */\n now?: Date;\n}\n\nexport interface Challenge {\n /** The canonical text the client MUST sign. */\n message: string;\n /** The generated nonce (kept for correlation / server-side storage). */\n nonce: string;\n /** Unix ms epoch the challenge expires at. */\n expiresAt: number;\n /** ISO string of the same. */\n expiresAtIso: string;\n}\n\n/**\n * Build a new signed-challenge message. The server MUST remember the nonce\n * it issued against the session (Redis, signed cookie, DB) so it can detect\n * replay or a different nonce coming back on verify.\n */\nexport function issueChallenge(opts: IssueChallengeOptions): Challenge {\n const now = opts.now ?? new Date();\n const ttl = opts.ttlSeconds ?? 300;\n const expiresAt = new Date(now.getTime() + ttl * 1000);\n const nonce = opts.nonce ?? randomNonce();\n\n if (!/^[0-9a-f]{32}$/.test(nonce)) {\n throw new Error('nonce must be 32 lowercase hex characters');\n }\n\n const coreLines = [\n AUTH_HEADER,\n `address: ${opts.address}`,\n `nonce: ${nonce}`,\n `issued_at: ${iso(now)}`,\n `expires_at: ${iso(expiresAt)}`,\n `ack: ${AUTH_ACK}`,\n ];\n\n const extensions: Array<[string, string]> = [];\n if (opts.audience) extensions.push(['audience', opts.audience]);\n if (opts.purpose) extensions.push(['purpose', opts.purpose]);\n // Lexicographic sort by key — matches OCP canonicalization rules.\n extensions.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));\n\n const extLines = extensions.map(([k, v]) => `${k}: ${v}`);\n const message = [...coreLines, ...extLines].join('\\n') + '\\n';\n\n return {\n message,\n nonce,\n expiresAt: expiresAt.getTime(),\n expiresAtIso: iso(expiresAt),\n };\n}\n\ninterface ParsedChallenge {\n address?: string;\n nonce?: string;\n issuedAt?: Date;\n expiresAt?: Date;\n ack?: string;\n audience?: string;\n purpose?: string;\n extensions: Array<[string, string]>;\n}\n\nfunction parseChallenge(message: string): ParsedChallenge {\n const lines = message.endsWith('\\n') ? message.slice(0, -1).split('\\n') : message.split('\\n');\n\n if (lines[0] !== AUTH_HEADER) {\n throw new Error('not an orangecheck-auth challenge');\n }\n\n const out: ParsedChallenge = { extensions: [] };\n const coreKeys = ['address', 'nonce', 'issued_at', 'expires_at', 'ack'] as const;\n const coreKeySet = new Set<string>(coreKeys);\n // Duplicate detection: a challenge with two `address:` lines would\n // previously let the second overwrite the first. A signer who saw both\n // lines would still sign the whole text — which means the verifier and\n // the signer disagree about what the message \"said\". Refuse instead.\n const seenCore = new Set<string>();\n\n for (let i = 1; i < lines.length; i++) {\n const line = lines[i] ?? '';\n const sep = line.indexOf(': ');\n if (sep === -1) continue;\n const key = line.slice(0, sep);\n const val = line.slice(sep + 2);\n\n if (coreKeySet.has(key)) {\n if (seenCore.has(key)) {\n throw new Error(`duplicate core key in challenge: ${key}`);\n }\n seenCore.add(key);\n if (key === 'address') out.address = val;\n else if (key === 'nonce') out.nonce = val;\n else if (key === 'issued_at') out.issuedAt = new Date(val);\n else if (key === 'expires_at') out.expiresAt = new Date(val);\n else if (key === 'ack') out.ack = val;\n } else {\n out.extensions.push([key, val]);\n if (key === 'audience') out.audience = val;\n if (key === 'purpose') out.purpose = val;\n }\n }\n\n if (seenCore.size !== coreKeys.length) {\n throw new Error(`challenge missing core lines (${seenCore.size}/${coreKeys.length})`);\n }\n return out;\n}\n\nexport interface VerifyChallengeOptions {\n /** The signed canonical challenge text, exactly as issued. */\n message: string;\n /** BIP-322 or legacy signature. */\n signature: string;\n /** Scheme override. Default 'bip322'. */\n scheme?: Scheme;\n\n /** If set, require the challenge's `audience:` extension to match. */\n expectedAudience?: string;\n /** If set, require the challenge's `purpose:` extension to match. */\n expectedPurpose?: string;\n /**\n * If set, require the challenge's `nonce:` to match. Use this to defeat\n * replay — pair with server-side nonce storage (Redis / signed cookie).\n */\n expectedNonce?: string;\n\n /** Tolerance on expiry check in ms. Default 0. */\n clockSkewMs?: number;\n /** Freeze `now` for tests. */\n now?: Date;\n}\n\nexport type VerifyChallengeReason =\n | 'ok'\n | 'malformed'\n | 'expired'\n | 'not_yet_valid'\n | 'sig_invalid'\n | 'sig_unsupported_scheme'\n | 'audience_mismatch'\n | 'purpose_mismatch'\n | 'nonce_mismatch';\n\nexport interface VerifyChallengeResult {\n ok: boolean;\n reason: VerifyChallengeReason;\n /** Present on success — the cryptographically proven Bitcoin address. */\n address?: string;\n /** Present on success — when the challenge expires (unix ms). */\n expiresAt?: number;\n /** Present on success — the nonce from the challenge. */\n nonce?: string;\n /** Present on success — the audience extension, if any. */\n audience?: string;\n /** Present on success — the purpose extension, if any. */\n purpose?: string;\n}\n\n/**\n * Verify a signed challenge. Returns the proven address on success.\n *\n * The caller is responsible for nonce replay prevention — pass `expectedNonce`\n * with the value you stashed at issue time.\n */\nexport async function verifyChallenge(\n opts: VerifyChallengeOptions\n): Promise<VerifyChallengeResult> {\n let parsed: ParsedChallenge;\n try {\n parsed = parseChallenge(opts.message);\n } catch {\n return { ok: false, reason: 'malformed' };\n }\n\n const { address, nonce, issuedAt, expiresAt, ack, audience, purpose } = parsed;\n if (!address || !nonce || !issuedAt || !expiresAt || !ack) {\n return { ok: false, reason: 'malformed' };\n }\n if (ack !== AUTH_ACK) {\n return { ok: false, reason: 'malformed' };\n }\n if (!/^[0-9a-f]{32}$/.test(nonce)) {\n return { ok: false, reason: 'malformed' };\n }\n\n const now = opts.now ?? new Date();\n const skew = opts.clockSkewMs ?? 0;\n\n if (now.getTime() - skew > expiresAt.getTime()) {\n return { ok: false, reason: 'expired' };\n }\n if (now.getTime() + skew < issuedAt.getTime()) {\n return { ok: false, reason: 'not_yet_valid' };\n }\n\n if (opts.expectedNonce && nonce !== opts.expectedNonce) {\n return { ok: false, reason: 'nonce_mismatch' };\n }\n if (opts.expectedAudience && audience !== opts.expectedAudience) {\n return { ok: false, reason: 'audience_mismatch' };\n }\n if (opts.expectedPurpose && purpose !== opts.expectedPurpose) {\n return { ok: false, reason: 'purpose_mismatch' };\n }\n\n const scheme: Scheme = opts.scheme ?? 'bip322';\n let sigValid: boolean;\n if (scheme === 'bip322') {\n const sigBase64 =\n /^[0-9a-fA-F]+$/.test(opts.signature) && opts.signature.length % 2 === 0\n ? Buffer.from(opts.signature, 'hex').toString('base64')\n : opts.signature;\n sigValid = verifyBitcoinSignature(address, opts.message, sigBase64);\n } else if (scheme === 'legacy') {\n // Legacy signmessage only works for P2PKH addresses.\n if (!/^[1m][a-km-zA-HJ-NP-Z1-9]{25,34}$/.test(address)) {\n return { ok: false, reason: 'sig_unsupported_scheme' };\n }\n sigValid = verifyBitcoinSignature(address, opts.message, opts.signature);\n } else {\n return { ok: false, reason: 'sig_unsupported_scheme' };\n }\n\n if (!sigValid) return { ok: false, reason: 'sig_invalid' };\n\n return {\n ok: true,\n reason: 'ok',\n address,\n expiresAt: expiresAt.getTime(),\n nonce,\n ...(audience !== undefined ? { audience } : {}),\n ...(purpose !== undefined ? { purpose } : {}),\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/utils/signature.ts","../src/challenge.ts"],"names":["verifyBitcoinSignature"],"mappings":"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","file":"challenge.mjs","sourcesContent":["import type { Scheme } from '../types';\n\nimport { Buffer } from 'buffer';\n\n/** A signature as base64: hex input is converted, anything else passes through. */\nexport function toBase64Maybe(sig: string): string {\n if (/^[0-9a-fA-F]+$/.test(sig) && sig.length % 2 === 0) {\n return Buffer.from(sig, 'hex').toString('base64');\n }\n return sig;\n}\n\n/**\n * The scheme a signature's bytes say it is, the same rule @orangecheck/bip322\n * verifies by: 65 bytes is a BIP-137 compact signature, whatever the address,\n * and anything else is BIP-322 simple. `null` for a BIP-322 full or\n * proof-of-funds signature, which is not supported.\n */\nexport function signatureScheme(sigBase64: string): Scheme | null {\n if (/^(ful|pof)/.test(sigBase64)) return null;\n if (sigBase64.startsWith('smp')) return 'bip322';\n return Buffer.from(sigBase64, 'base64').length === 65 ? 'legacy' : 'bip322';\n}\n","/**\n * Signed-challenge auth\n *\n * When a gate can't trust the address source (public header, query string,\n * unsigned cookie), run a one-shot BIP-322 challenge first to cryptographically\n * prove the caller controls the address. Bind the proven address to a session\n * cookie or JWT, and the gate reads from there.\n *\n * This is a different wire format from the main OCP attestation — the header\n * literal is `orangecheck-auth`, not `orangecheck`, so a signed auth challenge\n * can never be confused with a reputation attestation.\n *\n * // Server, on the \"connect wallet\" flow:\n * const challenge = issueChallenge({ address, ttlSeconds: 300 });\n * // → send challenge.message to the client\n *\n * // Client signs challenge.message with BIP-322 via the user's wallet,\n * // then posts { message, signature } back.\n *\n * // Server, on callback:\n * const result = await verifyChallenge({ message, signature });\n * if (result.ok) {\n * // result.address is now cryptographically proven; attach to session.\n * }\n */\n\nimport type { Scheme } from './types';\n\nimport { verifySignature as verifyBitcoinSignature } from '@orangecheck/bip322';\n\nimport { signatureScheme, toBase64Maybe } from './utils/signature';\n\nconst AUTH_HEADER = 'orangecheck-auth';\nconst AUTH_ACK = 'I authorize this session under the OrangeCheck auth challenge.';\n\nfunction randomNonce(): string {\n // 16 bytes → 32 lowercase hex chars. Matches OCP core nonce format.\n const bytes = new Uint8Array(16);\n // Runtimes: Node 20+ (webcrypto), browsers.\n if (typeof globalThis.crypto?.getRandomValues === 'function') {\n globalThis.crypto.getRandomValues(bytes);\n } else {\n // Fallback: Math.random is *not* cryptographic — acceptable only for\n // environments without webcrypto (old Node). Flagged explicitly.\n for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);\n }\n return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');\n}\n\nfunction iso(date: Date): string {\n return date.toISOString().replace(/\\.\\d{3}Z$/, 'Z');\n}\n\nexport interface IssueChallengeOptions {\n /** Bitcoin address the caller claims to control. */\n address: string;\n /** How long the challenge is valid for. Default 300 (5 minutes). */\n ttlSeconds?: number;\n /** Explicit nonce. Rare; prefer auto-generated. */\n nonce?: string;\n /** Origin-binding extension. If set, verify will require a match. */\n audience?: string;\n /** Human-readable label baked into the signed message. */\n purpose?: string;\n /** Freeze `now` for tests. */\n now?: Date;\n}\n\nexport interface Challenge {\n /** The canonical text the client MUST sign. */\n message: string;\n /** The generated nonce (kept for correlation / server-side storage). */\n nonce: string;\n /** Unix ms epoch the challenge expires at. */\n expiresAt: number;\n /** ISO string of the same. */\n expiresAtIso: string;\n}\n\n/**\n * Build a new signed-challenge message. The server MUST remember the nonce\n * it issued against the session (Redis, signed cookie, DB) so it can detect\n * replay or a different nonce coming back on verify.\n */\nexport function issueChallenge(opts: IssueChallengeOptions): Challenge {\n const now = opts.now ?? new Date();\n const ttl = opts.ttlSeconds ?? 300;\n const expiresAt = new Date(now.getTime() + ttl * 1000);\n const nonce = opts.nonce ?? randomNonce();\n\n if (!/^[0-9a-f]{32}$/.test(nonce)) {\n throw new Error('nonce must be 32 lowercase hex characters');\n }\n\n const coreLines = [\n AUTH_HEADER,\n `address: ${opts.address}`,\n `nonce: ${nonce}`,\n `issued_at: ${iso(now)}`,\n `expires_at: ${iso(expiresAt)}`,\n `ack: ${AUTH_ACK}`,\n ];\n\n const extensions: Array<[string, string]> = [];\n if (opts.audience) extensions.push(['audience', opts.audience]);\n if (opts.purpose) extensions.push(['purpose', opts.purpose]);\n // Lexicographic sort by key — matches OCP canonicalization rules.\n extensions.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));\n\n const extLines = extensions.map(([k, v]) => `${k}: ${v}`);\n const message = [...coreLines, ...extLines].join('\\n') + '\\n';\n\n return {\n message,\n nonce,\n expiresAt: expiresAt.getTime(),\n expiresAtIso: iso(expiresAt),\n };\n}\n\ninterface ParsedChallenge {\n address?: string;\n nonce?: string;\n issuedAt?: Date;\n expiresAt?: Date;\n ack?: string;\n audience?: string;\n purpose?: string;\n extensions: Array<[string, string]>;\n}\n\nfunction parseChallenge(message: string): ParsedChallenge {\n const lines = message.endsWith('\\n') ? message.slice(0, -1).split('\\n') : message.split('\\n');\n\n if (lines[0] !== AUTH_HEADER) {\n throw new Error('not an orangecheck-auth challenge');\n }\n\n const out: ParsedChallenge = { extensions: [] };\n const coreKeys = ['address', 'nonce', 'issued_at', 'expires_at', 'ack'] as const;\n const coreKeySet = new Set<string>(coreKeys);\n // Duplicate detection: a challenge with two `address:` lines would\n // previously let the second overwrite the first. A signer who saw both\n // lines would still sign the whole text — which means the verifier and\n // the signer disagree about what the message \"said\". Refuse instead.\n const seenCore = new Set<string>();\n\n for (let i = 1; i < lines.length; i++) {\n const line = lines[i] ?? '';\n const sep = line.indexOf(': ');\n if (sep === -1) continue;\n const key = line.slice(0, sep);\n const val = line.slice(sep + 2);\n\n if (coreKeySet.has(key)) {\n if (seenCore.has(key)) {\n throw new Error(`duplicate core key in challenge: ${key}`);\n }\n seenCore.add(key);\n if (key === 'address') out.address = val;\n else if (key === 'nonce') out.nonce = val;\n else if (key === 'issued_at') out.issuedAt = new Date(val);\n else if (key === 'expires_at') out.expiresAt = new Date(val);\n else if (key === 'ack') out.ack = val;\n } else {\n out.extensions.push([key, val]);\n if (key === 'audience') out.audience = val;\n if (key === 'purpose') out.purpose = val;\n }\n }\n\n if (seenCore.size !== coreKeys.length) {\n throw new Error(`challenge missing core lines (${seenCore.size}/${coreKeys.length})`);\n }\n return out;\n}\n\nexport interface VerifyChallengeOptions {\n /** The signed canonical challenge text, exactly as issued. */\n message: string;\n /** BIP-322 or legacy signature, base64 or hex. */\n signature: string;\n /** @deprecated Ignored: the signature's own bytes decide the scheme. */\n scheme?: Scheme;\n\n /** If set, require the challenge's `audience:` extension to match. */\n expectedAudience?: string;\n /** If set, require the challenge's `purpose:` extension to match. */\n expectedPurpose?: string;\n /**\n * If set, require the challenge's `nonce:` to match. Use this to defeat\n * replay — pair with server-side nonce storage (Redis / signed cookie).\n */\n expectedNonce?: string;\n\n /** Tolerance on expiry check in ms. Default 0. */\n clockSkewMs?: number;\n /** Freeze `now` for tests. */\n now?: Date;\n}\n\nexport type VerifyChallengeReason =\n | 'ok'\n | 'malformed'\n | 'expired'\n | 'not_yet_valid'\n | 'sig_invalid'\n | 'sig_unsupported_scheme'\n | 'audience_mismatch'\n | 'purpose_mismatch'\n | 'nonce_mismatch';\n\nexport interface VerifyChallengeResult {\n ok: boolean;\n reason: VerifyChallengeReason;\n /** Present on success — the cryptographically proven Bitcoin address. */\n address?: string;\n /** Present on success — the scheme that verified the signature. */\n scheme?: Scheme;\n /** Present on success — when the challenge expires (unix ms). */\n expiresAt?: number;\n /** Present on success — the nonce from the challenge. */\n nonce?: string;\n /** Present on success — the audience extension, if any. */\n audience?: string;\n /** Present on success — the purpose extension, if any. */\n purpose?: string;\n}\n\n/**\n * Verify a signed challenge. Returns the proven address on success.\n *\n * The caller is responsible for nonce replay prevention — pass `expectedNonce`\n * with the value you stashed at issue time.\n */\nexport async function verifyChallenge(\n opts: VerifyChallengeOptions\n): Promise<VerifyChallengeResult> {\n let parsed: ParsedChallenge;\n try {\n parsed = parseChallenge(opts.message);\n } catch {\n return { ok: false, reason: 'malformed' };\n }\n\n const { address, nonce, issuedAt, expiresAt, ack, audience, purpose } = parsed;\n if (!address || !nonce || !issuedAt || !expiresAt || !ack) {\n return { ok: false, reason: 'malformed' };\n }\n if (ack !== AUTH_ACK) {\n return { ok: false, reason: 'malformed' };\n }\n if (!/^[0-9a-f]{32}$/.test(nonce)) {\n return { ok: false, reason: 'malformed' };\n }\n\n const now = opts.now ?? new Date();\n const skew = opts.clockSkewMs ?? 0;\n\n if (now.getTime() - skew > expiresAt.getTime()) {\n return { ok: false, reason: 'expired' };\n }\n if (now.getTime() + skew < issuedAt.getTime()) {\n return { ok: false, reason: 'not_yet_valid' };\n }\n\n if (opts.expectedNonce && nonce !== opts.expectedNonce) {\n return { ok: false, reason: 'nonce_mismatch' };\n }\n if (opts.expectedAudience && audience !== opts.expectedAudience) {\n return { ok: false, reason: 'audience_mismatch' };\n }\n if (opts.expectedPurpose && purpose !== opts.expectedPurpose) {\n return { ok: false, reason: 'purpose_mismatch' };\n }\n\n const sigBase64 = toBase64Maybe(opts.signature);\n const scheme = signatureScheme(sigBase64);\n if (!scheme) return { ok: false, reason: 'sig_unsupported_scheme' };\n if (!verifyBitcoinSignature(address, opts.message, sigBase64)) {\n return { ok: false, reason: 'sig_invalid' };\n }\n\n return {\n ok: true,\n reason: 'ok',\n address,\n scheme,\n expiresAt: expiresAt.getTime(),\n nonce,\n ...(audience !== undefined ? { audience } : {}),\n ...(purpose !== undefined ? { purpose } : {}),\n };\n}\n"]}
package/dist/identity.js CHANGED
@@ -3,6 +3,8 @@
3
3
  var secp256k1_js = require('@noble/curves/secp256k1.js');
4
4
  var sha2_js = require('@noble/hashes/sha2.js');
5
5
  var base = require('@scure/base');
6
+ require('@orangecheck/bip322');
7
+ require('buffer');
6
8
 
7
9
  // src/utils/logger.ts
8
10
  function createLogger(namespace) {
@@ -408,7 +410,7 @@ var DEFAULT_RELAYS = [
408
410
  ];
409
411
 
410
412
  // src/identity/nostr.ts
411
- var log4 = createLogger("identity-verification/nostr");
413
+ var log5 = createLogger("identity-verification/nostr");
412
414
  var HEX_64_RE2 = /^[0-9a-f]{64}$/;
413
415
  function toHex2(bytes) {
414
416
  let out = "";
@@ -431,7 +433,7 @@ function npubToHex(npub) {
431
433
  async function queryNostrEvents(options) {
432
434
  const { authors, kinds, search, relays = DEFAULT_RELAYS, limit = 100 } = options;
433
435
  const events = [];
434
- log4.info({ authors, kinds, relayCount: relays.length }, "Querying Nostr for events");
436
+ log5.info({ authors, kinds, relayCount: relays.length }, "Querying Nostr for events");
435
437
  const queryPromises = relays.map(async (relayUrl) => {
436
438
  let ws = null;
437
439
  try {
@@ -472,11 +474,11 @@ async function queryNostrEvents(options) {
472
474
  resolve();
473
475
  }
474
476
  } catch (err) {
475
- log4.error({ relay: relayUrl, error: err }, "Failed to parse event");
477
+ log5.error({ relay: relayUrl, error: err }, "Failed to parse event");
476
478
  }
477
479
  };
478
480
  wsRef.onerror = (err) => {
479
- log4.error({ relay: relayUrl, error: err }, "WebSocket error during query");
481
+ log5.error({ relay: relayUrl, error: err }, "WebSocket error during query");
480
482
  cleanup();
481
483
  reject(err);
482
484
  };
@@ -485,7 +487,7 @@ async function queryNostrEvents(options) {
485
487
  };
486
488
  });
487
489
  } catch (err) {
488
- log4.error({ relay: relayUrl, error: err }, "Failed to query relay");
490
+ log5.error({ relay: relayUrl, error: err }, "Failed to query relay");
489
491
  } finally {
490
492
  if (ws && ws.readyState !== WebSocket.CLOSED) {
491
493
  ws.close();
@@ -493,7 +495,7 @@ async function queryNostrEvents(options) {
493
495
  }
494
496
  });
495
497
  await Promise.allSettled(queryPromises);
496
- log4.info({ eventCount: events.length }, "Query complete");
498
+ log5.info({ eventCount: events.length }, "Query complete");
497
499
  return events;
498
500
  }
499
501
  async function verifyNostrEventSignature(event) {
@@ -514,7 +516,7 @@ function nostrEventSignatureValid(event) {
514
516
  ]);
515
517
  const computedId = toHex2(sha2_js.sha256(new TextEncoder().encode(preimage)));
516
518
  if (computedId !== event.id.toLowerCase()) {
517
- log4.warn(
519
+ log5.warn(
518
520
  { eventId: event.id, computedId },
519
521
  "nostr event id does not match its content"
520
522
  );
@@ -522,12 +524,12 @@ function nostrEventSignatureValid(event) {
522
524
  }
523
525
  return secp256k1_js.schnorr.verify(fromHex(event.sig), fromHex(event.id), fromHex(event.pubkey));
524
526
  } catch (err) {
525
- log4.warn({ err }, "schnorr verify threw");
527
+ log5.warn({ err }, "schnorr verify threw");
526
528
  return false;
527
529
  }
528
530
  }
529
531
  async function verifyNostrIdentity(attestationId, npub, relays) {
530
- log4.info({ attestationId, npub }, "Verifying Nostr identity");
532
+ log5.info({ attestationId, npub }, "Verifying Nostr identity");
531
533
  try {
532
534
  const pubkeyHex = npubToHex(npub);
533
535
  const events = await queryNostrEvents({
@@ -538,7 +540,7 @@ async function verifyNostrIdentity(attestationId, npub, relays) {
538
540
  relays
539
541
  });
540
542
  if (events.length === 0) {
541
- log4.info({ npub }, "No verification events found");
543
+ log5.info({ npub }, "No verification events found");
542
544
  return {
543
545
  verified: false,
544
546
  error: "No verification event found. User must post a note containing the attestation ID."
@@ -548,13 +550,13 @@ async function verifyNostrIdentity(attestationId, npub, relays) {
548
550
  if (event.content.includes(attestationId)) {
549
551
  const isValid = await verifyNostrEventSignature(event);
550
552
  if (isValid) {
551
- log4.info({ npub, eventId: event.id }, "Nostr identity verified");
553
+ log5.info({ npub, eventId: event.id }, "Nostr identity verified");
552
554
  return {
553
555
  verified: true,
554
556
  event
555
557
  };
556
558
  } else {
557
- log4.warn({ npub, eventId: event.id }, "Invalid event signature");
559
+ log5.warn({ npub, eventId: event.id }, "Invalid event signature");
558
560
  }
559
561
  }
560
562
  }
@@ -563,7 +565,7 @@ async function verifyNostrIdentity(attestationId, npub, relays) {
563
565
  error: "Found events but none had valid signatures"
564
566
  };
565
567
  } catch (err) {
566
- log4.error({ error: err, npub }, "Failed to verify Nostr identity");
568
+ log5.error({ error: err, npub }, "Failed to verify Nostr identity");
567
569
  return {
568
570
  verified: false,
569
571
  error: err instanceof Error ? err.message : "Unknown error"
@@ -591,9 +593,9 @@ function isNostrIdentityVerified(attestationId, npub, cachedEvents) {
591
593
  }
592
594
 
593
595
  // src/identity/twitter.ts
594
- var log5 = createLogger("identity-verification/twitter");
596
+ var log6 = createLogger("identity-verification/twitter");
595
597
  async function verifyTwitterIdentity(attestationId, handle, tweetUrl) {
596
- log5.info({ attestationId, handle, tweetUrl }, "Verifying Twitter identity");
598
+ log6.info({ attestationId, handle, tweetUrl }, "Verifying Twitter identity");
597
599
  if (!tweetUrl) {
598
600
  return {
599
601
  verified: false,
@@ -608,7 +610,7 @@ async function verifyTwitterIdentity(attestationId, handle, tweetUrl) {
608
610
  error: "Invalid tweet URL format"
609
611
  };
610
612
  }
611
- log5.info({ handle, tweetId }, "Manual verification required");
613
+ log6.info({ handle, tweetId }, "Manual verification required");
612
614
  return {
613
615
  verified: false,
614
616
  tweetUrl,
@@ -616,7 +618,7 @@ async function verifyTwitterIdentity(attestationId, handle, tweetUrl) {
616
618
  error: "Automatic verification not available. Please verify manually by checking the tweet."
617
619
  };
618
620
  } catch (err) {
619
- log5.error({ error: err, handle }, "Failed to verify Twitter identity");
621
+ log6.error({ error: err, handle }, "Failed to verify Twitter identity");
620
622
  return {
621
623
  verified: false,
622
624
  error: err instanceof Error ? err.message : "Unknown error"