@orangecheck/sdk 0.1.1 → 0.1.3
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 +7 -8
- package/dist/attestation.d.mts +1 -2
- package/dist/attestation.d.ts +1 -2
- package/dist/attestation.js +12 -28
- package/dist/attestation.js.map +1 -1
- package/dist/attestation.mjs +13 -28
- package/dist/attestation.mjs.map +1 -1
- package/dist/canonical.js +27 -5
- package/dist/canonical.js.map +1 -1
- package/dist/canonical.mjs +27 -5
- package/dist/canonical.mjs.map +1 -1
- package/dist/challenge.js +10 -6
- package/dist/challenge.js.map +1 -1
- package/dist/challenge.mjs +10 -6
- package/dist/challenge.mjs.map +1 -1
- package/dist/identity.js +130 -44
- package/dist/identity.js.map +1 -1
- package/dist/identity.mjs +130 -44
- package/dist/identity.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +210 -74
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +208 -75
- package/dist/index.mjs.map +1 -1
- package/dist/nostr.js +5 -6
- package/dist/nostr.js.map +1 -1
- package/dist/nostr.mjs +5 -6
- package/dist/nostr.mjs.map +1 -1
- package/dist/verify.js +44 -13
- package/dist/verify.js.map +1 -1
- package/dist/verify.mjs +44 -13
- package/dist/verify.mjs.map +1 -1
- package/package.json +2 -1
package/dist/challenge.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/challenge.ts"],"names":["Buffer","Verifier","bitcoinMessage"],"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 { Verifier } from 'bip322-js';\nimport * as bitcoinMessage from 'bitcoinjs-message';\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 = new Set(['address', 'nonce', 'issued_at', 'expires_at', 'ack']);\n let seenCore = 0;\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 (coreKeys.has(key)) {\n seenCore++;\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 !== 5) {\n throw new Error(`challenge missing core lines (${seenCore}/5)`);\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 = false;\n\n try {\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 = Verifier.verifySignature(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 = bitcoinMessage.verify(opts.message, address, opts.signature);\n } else {\n return { ok: false, reason: 'sig_unsupported_scheme' };\n }\n } catch {\n sigValid = false;\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/challenge.ts"],"names":["Buffer","Verifier","bitcoinMessage"],"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 { Verifier } from 'bip322-js';\nimport * as bitcoinMessage from 'bitcoinjs-message';\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 = false;\n\n try {\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 = Verifier.verifySignature(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 = bitcoinMessage.verify(opts.message, address, opts.signature);\n } else {\n return { ok: false, reason: 'sig_unsupported_scheme' };\n }\n } catch {\n sigValid = false;\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"]}
|
package/dist/challenge.mjs
CHANGED
|
@@ -52,16 +52,20 @@ function parseChallenge(message) {
|
|
|
52
52
|
throw new Error("not an orangecheck-auth challenge");
|
|
53
53
|
}
|
|
54
54
|
const out = { extensions: [] };
|
|
55
|
-
const coreKeys =
|
|
56
|
-
|
|
55
|
+
const coreKeys = ["address", "nonce", "issued_at", "expires_at", "ack"];
|
|
56
|
+
const coreKeySet = new Set(coreKeys);
|
|
57
|
+
const seenCore = /* @__PURE__ */ new Set();
|
|
57
58
|
for (let i = 1; i < lines.length; i++) {
|
|
58
59
|
const line = lines[i] ?? "";
|
|
59
60
|
const sep = line.indexOf(": ");
|
|
60
61
|
if (sep === -1) continue;
|
|
61
62
|
const key = line.slice(0, sep);
|
|
62
63
|
const val = line.slice(sep + 2);
|
|
63
|
-
if (
|
|
64
|
-
seenCore
|
|
64
|
+
if (coreKeySet.has(key)) {
|
|
65
|
+
if (seenCore.has(key)) {
|
|
66
|
+
throw new Error(`duplicate core key in challenge: ${key}`);
|
|
67
|
+
}
|
|
68
|
+
seenCore.add(key);
|
|
65
69
|
if (key === "address") out.address = val;
|
|
66
70
|
else if (key === "nonce") out.nonce = val;
|
|
67
71
|
else if (key === "issued_at") out.issuedAt = new Date(val);
|
|
@@ -73,8 +77,8 @@ function parseChallenge(message) {
|
|
|
73
77
|
if (key === "purpose") out.purpose = val;
|
|
74
78
|
}
|
|
75
79
|
}
|
|
76
|
-
if (seenCore !==
|
|
77
|
-
throw new Error(`challenge missing core lines (${seenCore}
|
|
80
|
+
if (seenCore.size !== coreKeys.length) {
|
|
81
|
+
throw new Error(`challenge missing core lines (${seenCore.size}/${coreKeys.length})`);
|
|
78
82
|
}
|
|
79
83
|
return out;
|
|
80
84
|
}
|
package/dist/challenge.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/challenge.ts"],"names":[],"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 { Verifier } from 'bip322-js';\nimport * as bitcoinMessage from 'bitcoinjs-message';\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 = new Set(['address', 'nonce', 'issued_at', 'expires_at', 'ack']);\n let seenCore = 0;\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 (coreKeys.has(key)) {\n seenCore++;\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 !== 5) {\n throw new Error(`challenge missing core lines (${seenCore}/5)`);\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 = false;\n\n try {\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 = Verifier.verifySignature(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 = bitcoinMessage.verify(opts.message, address, opts.signature);\n } else {\n return { ok: false, reason: 'sig_unsupported_scheme' };\n }\n } catch {\n sigValid = false;\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/challenge.ts"],"names":[],"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 { Verifier } from 'bip322-js';\nimport * as bitcoinMessage from 'bitcoinjs-message';\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 = false;\n\n try {\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 = Verifier.verifySignature(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 = bitcoinMessage.verify(opts.message, address, opts.signature);\n } else {\n return { ok: false, reason: 'sig_unsupported_scheme' };\n }\n } catch {\n sigValid = false;\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"]}
|
package/dist/identity.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
var secp256k1_js = require('@noble/curves/secp256k1.js');
|
|
4
|
+
var sha2_js = require('@noble/hashes/sha2.js');
|
|
5
|
+
var base = require('@scure/base');
|
|
6
|
+
|
|
3
7
|
// src/utils/logger.ts
|
|
4
8
|
function createLogger(namespace) {
|
|
5
9
|
const shouldLog = (level) => {
|
|
@@ -93,9 +97,25 @@ var identityVerificationLimiter = createRateLimiter(5, 6e4);
|
|
|
93
97
|
|
|
94
98
|
// src/identity/dns.ts
|
|
95
99
|
var log = createLogger("identity-verification/dns");
|
|
100
|
+
var PRIVATE_HOST_RE = /^(?:localhost|.*\.local|.*\.internal|.*\.localhost|.*\.lan|.*\.home|.*\.arpa|\[?::1\]?|\[?::\]?)$/i;
|
|
101
|
+
var PRIVATE_IPV4_RE = /^(?:10\.|127\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|0\.|100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.|22[4-9]\.|23\d\.|24\d\.|25[0-5]\.)/;
|
|
102
|
+
var WELL_KNOWN_TIMEOUT_MS = 5e3;
|
|
103
|
+
function isSafePublicHost(host) {
|
|
104
|
+
const h = host.toLowerCase();
|
|
105
|
+
if (PRIVATE_HOST_RE.test(h)) return false;
|
|
106
|
+
if (PRIVATE_IPV4_RE.test(h)) return false;
|
|
107
|
+
if (h.startsWith("[") || /^[0-9a-f:]+$/.test(h)) return false;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
96
110
|
async function verifyDnsIdentity(attestationId, domain) {
|
|
97
111
|
log.info({ attestationId, domain }, "Verifying DNS identity");
|
|
98
112
|
const cleanDomain = domain.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
113
|
+
if (!isValidDomain(cleanDomain)) {
|
|
114
|
+
return { verified: false, error: `invalid domain format: ${cleanDomain}` };
|
|
115
|
+
}
|
|
116
|
+
if (!isSafePublicHost(cleanDomain)) {
|
|
117
|
+
return { verified: false, error: "domain resolves to a disallowed private host" };
|
|
118
|
+
}
|
|
99
119
|
const wellKnownResult = await checkWellKnownFile(attestationId, cleanDomain);
|
|
100
120
|
if (wellKnownResult.verified) {
|
|
101
121
|
return wellKnownResult;
|
|
@@ -107,38 +127,31 @@ async function verifyDnsIdentity(attestationId, domain) {
|
|
|
107
127
|
};
|
|
108
128
|
}
|
|
109
129
|
async function checkWellKnownFile(attestationId, domain) {
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
verified: true,
|
|
132
|
-
method: "well-known",
|
|
133
|
-
url
|
|
134
|
-
};
|
|
135
|
-
} else {
|
|
136
|
-
log.info({ domain, url }, "Well-known file exists but no attestation ID found");
|
|
137
|
-
}
|
|
130
|
+
const url = `https://${domain}/.well-known/orangecheck.txt`;
|
|
131
|
+
try {
|
|
132
|
+
log.info({ url }, "Checking well-known file");
|
|
133
|
+
const response = await fetch(url, {
|
|
134
|
+
method: "GET",
|
|
135
|
+
headers: { Accept: "text/plain" },
|
|
136
|
+
// Don't follow redirects to other domains
|
|
137
|
+
redirect: "manual",
|
|
138
|
+
signal: AbortSignal.timeout(WELL_KNOWN_TIMEOUT_MS)
|
|
139
|
+
});
|
|
140
|
+
if (response.ok) {
|
|
141
|
+
const content = await response.text();
|
|
142
|
+
if (content.includes(attestationId)) {
|
|
143
|
+
log.info({ domain, url }, "Found attestation ID in well-known file");
|
|
144
|
+
return {
|
|
145
|
+
verified: true,
|
|
146
|
+
method: "well-known",
|
|
147
|
+
url
|
|
148
|
+
};
|
|
149
|
+
} else {
|
|
150
|
+
log.info({ domain, url }, "Well-known file exists but no attestation ID found");
|
|
138
151
|
}
|
|
139
|
-
} catch (err) {
|
|
140
|
-
log.warn({ error: err, url }, "Failed to fetch well-known file");
|
|
141
152
|
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
log.warn({ error: err, url }, "Failed to fetch well-known file");
|
|
142
155
|
}
|
|
143
156
|
return {
|
|
144
157
|
verified: false,
|
|
@@ -211,14 +224,32 @@ function isDnsIdentityVerified(attestationId, _domain, cachedContent) {
|
|
|
211
224
|
|
|
212
225
|
// src/identity/github.ts
|
|
213
226
|
var log2 = createLogger("identity-verification/github");
|
|
227
|
+
var GITHUB_GIST_RAW_HOST = "gist.githubusercontent.com";
|
|
228
|
+
var MAX_GISTS_SCANNED = 30;
|
|
229
|
+
var MAX_FILES_PER_GIST = 10;
|
|
230
|
+
var FETCH_TIMEOUT_MS = 5e3;
|
|
231
|
+
var GITHUB_USERNAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/;
|
|
232
|
+
function isSafeGistRawUrl(raw) {
|
|
233
|
+
try {
|
|
234
|
+
const u = new URL(raw);
|
|
235
|
+
return u.protocol === "https:" && u.hostname === GITHUB_GIST_RAW_HOST;
|
|
236
|
+
} catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
214
240
|
async function verifyGitHubIdentity(attestationId, username) {
|
|
215
241
|
log2.info({ attestationId, username }, "Verifying GitHub identity");
|
|
242
|
+
if (!GITHUB_USERNAME_RE.test(username)) {
|
|
243
|
+
return { verified: false, error: "invalid GitHub username format" };
|
|
244
|
+
}
|
|
216
245
|
try {
|
|
217
|
-
const response = await fetch(
|
|
218
|
-
|
|
219
|
-
|
|
246
|
+
const response = await fetch(
|
|
247
|
+
`https://api.github.com/users/${encodeURIComponent(username)}/gists?per_page=${MAX_GISTS_SCANNED}`,
|
|
248
|
+
{
|
|
249
|
+
headers: { Accept: "application/vnd.github.v3+json" },
|
|
250
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
220
251
|
}
|
|
221
|
-
|
|
252
|
+
);
|
|
222
253
|
if (!response.ok) {
|
|
223
254
|
if (response.status === 404) {
|
|
224
255
|
return {
|
|
@@ -233,7 +264,7 @@ async function verifyGitHubIdentity(attestationId, username) {
|
|
|
233
264
|
}
|
|
234
265
|
const gists = await response.json();
|
|
235
266
|
log2.info({ username, gistCount: gists.length }, "Fetched GitHub gists");
|
|
236
|
-
for (const gist of gists) {
|
|
267
|
+
for (const gist of gists.slice(0, MAX_GISTS_SCANNED)) {
|
|
237
268
|
if (gist.description?.includes(attestationId)) {
|
|
238
269
|
log2.info({ username, gistId: gist.id }, "Found attestation ID in gist description");
|
|
239
270
|
return {
|
|
@@ -242,7 +273,8 @@ async function verifyGitHubIdentity(attestationId, username) {
|
|
|
242
273
|
gistId: gist.id
|
|
243
274
|
};
|
|
244
275
|
}
|
|
245
|
-
|
|
276
|
+
const files = Object.entries(gist.files).slice(0, MAX_FILES_PER_GIST);
|
|
277
|
+
for (const [filename, file] of files) {
|
|
246
278
|
if (file.content && file.content.includes(attestationId)) {
|
|
247
279
|
log2.info(
|
|
248
280
|
{ username, gistId: gist.id, filename },
|
|
@@ -254,8 +286,17 @@ async function verifyGitHubIdentity(attestationId, username) {
|
|
|
254
286
|
gistId: gist.id
|
|
255
287
|
};
|
|
256
288
|
}
|
|
289
|
+
if (!isSafeGistRawUrl(file.raw_url)) {
|
|
290
|
+
log2.warn(
|
|
291
|
+
{ filename, rawUrl: file.raw_url },
|
|
292
|
+
"skipping gist file: raw_url failed allow-list"
|
|
293
|
+
);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
257
296
|
try {
|
|
258
|
-
const fileResponse = await fetch(file.raw_url
|
|
297
|
+
const fileResponse = await fetch(file.raw_url, {
|
|
298
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
299
|
+
});
|
|
259
300
|
if (fileResponse.ok) {
|
|
260
301
|
const content = await fileResponse.text();
|
|
261
302
|
if (content.includes(attestationId)) {
|
|
@@ -342,12 +383,37 @@ var DEFAULT_RELAYS = [
|
|
|
342
383
|
|
|
343
384
|
// src/identity/nostr.ts
|
|
344
385
|
var log4 = createLogger("identity-verification/nostr");
|
|
386
|
+
var HEX_64_RE = /^[0-9a-f]{64}$/;
|
|
387
|
+
function toHex(bytes) {
|
|
388
|
+
let out = "";
|
|
389
|
+
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
function fromHex(hex) {
|
|
393
|
+
if (hex.length % 2 !== 0) throw new Error("odd-length hex");
|
|
394
|
+
const out = new Uint8Array(hex.length / 2);
|
|
395
|
+
for (let i = 0; i < out.length; i++) {
|
|
396
|
+
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
397
|
+
}
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
345
400
|
function npubToHex(npub) {
|
|
346
|
-
|
|
347
|
-
|
|
401
|
+
const trimmed = npub.trim();
|
|
402
|
+
if (HEX_64_RE.test(trimmed.toLowerCase())) {
|
|
403
|
+
return trimmed.toLowerCase();
|
|
404
|
+
}
|
|
405
|
+
if (!trimmed.toLowerCase().startsWith("npub1")) {
|
|
406
|
+
throw new Error(`not an npub: ${trimmed.slice(0, 12)}\u2026`);
|
|
407
|
+
}
|
|
408
|
+
const decoded = base.bech32.decode(trimmed.toLowerCase(), 1023);
|
|
409
|
+
if (decoded.prefix !== "npub") {
|
|
410
|
+
throw new Error(`bech32 prefix mismatch: expected npub, got ${decoded.prefix}`);
|
|
411
|
+
}
|
|
412
|
+
const bytes = base.bech32.fromWords(decoded.words);
|
|
413
|
+
if (bytes.length !== 32) {
|
|
414
|
+
throw new Error(`invalid npub payload length: ${bytes.length}`);
|
|
348
415
|
}
|
|
349
|
-
|
|
350
|
-
return npub;
|
|
416
|
+
return toHex(Uint8Array.from(bytes));
|
|
351
417
|
}
|
|
352
418
|
async function queryNostrEvents(options) {
|
|
353
419
|
const { authors, kinds, search, relays = DEFAULT_RELAYS, limit = 100 } = options;
|
|
@@ -418,11 +484,31 @@ async function queryNostrEvents(options) {
|
|
|
418
484
|
return events;
|
|
419
485
|
}
|
|
420
486
|
async function verifyNostrEventSignature(event) {
|
|
421
|
-
|
|
487
|
+
try {
|
|
488
|
+
if (!event.id || !event.sig || !event.pubkey || typeof event.id !== "string" || typeof event.sig !== "string" || typeof event.pubkey !== "string" || event.sig.length !== 128 || !HEX_64_RE.test(event.id) || !HEX_64_RE.test(event.pubkey)) {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
const preimage = JSON.stringify([
|
|
492
|
+
0,
|
|
493
|
+
event.pubkey,
|
|
494
|
+
event.created_at,
|
|
495
|
+
event.kind,
|
|
496
|
+
event.tags ?? [],
|
|
497
|
+
event.content ?? ""
|
|
498
|
+
]);
|
|
499
|
+
const computedId = toHex(sha2_js.sha256(new TextEncoder().encode(preimage)));
|
|
500
|
+
if (computedId !== event.id.toLowerCase()) {
|
|
501
|
+
log4.warn(
|
|
502
|
+
{ eventId: event.id, computedId },
|
|
503
|
+
"nostr event id does not match its content"
|
|
504
|
+
);
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
return secp256k1_js.schnorr.verify(fromHex(event.sig), fromHex(event.id), fromHex(event.pubkey));
|
|
508
|
+
} catch (err) {
|
|
509
|
+
log4.warn({ err }, "schnorr verify threw");
|
|
422
510
|
return false;
|
|
423
511
|
}
|
|
424
|
-
log4.warn("Nostr signature verification not fully implemented");
|
|
425
|
-
return true;
|
|
426
512
|
}
|
|
427
513
|
async function verifyNostrIdentity(attestationId, npub, relays) {
|
|
428
514
|
log4.info({ attestationId, npub }, "Verifying Nostr identity");
|