@orangecheck/sdk 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,6 +1,9 @@
1
1
  import { Buffer } from 'buffer';
2
2
  import { Verifier } from 'bip322-js';
3
3
  import * as bitcoinMessage from 'bitcoinjs-message';
4
+ import { schnorr } from '@noble/curves/secp256k1';
5
+ import { sha256 } from '@noble/hashes/sha2';
6
+ import { bech32 } from '@scure/base';
4
7
 
5
8
  // src/canonical.ts
6
9
  function toLowerHex(bytes) {
@@ -15,15 +18,27 @@ function randomNonce16BHexLower() {
15
18
  }
16
19
  } catch {
17
20
  }
18
- for (let i = 0; i < arr.length; i++) {
19
- arr[i] = Math.floor(Math.random() * 256);
21
+ throw new Error("crypto.getRandomValues unavailable \u2014 refusing to mint a weak nonce");
22
+ }
23
+ var IDENTITY_PROTOCOL_RE = /^[a-z][a-z0-9_-]*$/;
24
+ var IDENTITY_FORBIDDEN_CHARS = /[\r\n,]/;
25
+ function assertSafeIdentity({ protocol, identifier }) {
26
+ if (!IDENTITY_PROTOCOL_RE.test(protocol)) {
27
+ throw new Error(
28
+ `invalid identity protocol: ${JSON.stringify(protocol)} \u2014 must match /^[a-z][a-z0-9_-]*$/`
29
+ );
30
+ }
31
+ if (!identifier || IDENTITY_FORBIDDEN_CHARS.test(identifier)) {
32
+ throw new Error(
33
+ `invalid identity identifier: ${JSON.stringify(identifier)} \u2014 must be non-empty and contain no newlines or commas`
34
+ );
20
35
  }
21
- return toLowerHex(arr);
22
36
  }
23
37
  function formatIdentities(identities = []) {
24
38
  if (identities.length === 0) {
25
39
  return "";
26
40
  }
41
+ for (const id of identities) assertSafeIdentity(id);
27
42
  const formatted = identities.map(({ protocol, identifier }) => `${protocol}:${identifier}`).sort();
28
43
  return formatted.join(",");
29
44
  }
@@ -31,7 +46,10 @@ function parseIdentities(identitiesStr) {
31
46
  if (!identitiesStr || identitiesStr.trim() === "") {
32
47
  return [];
33
48
  }
34
- return identitiesStr.split(",").map((binding) => {
49
+ if (IDENTITY_FORBIDDEN_CHARS.test(identitiesStr.replace(/,/g, ""))) {
50
+ throw new Error("identities field contains forbidden characters (newline)");
51
+ }
52
+ const bindings = identitiesStr.split(",").map((binding) => {
35
53
  const colonIndex = binding.indexOf(":");
36
54
  if (colonIndex === -1) {
37
55
  throw new Error(`Invalid identity binding format: ${binding}`);
@@ -40,7 +58,14 @@ function parseIdentities(identitiesStr) {
40
58
  protocol: binding.substring(0, colonIndex).trim(),
41
59
  identifier: binding.substring(colonIndex + 1).trim()
42
60
  };
43
- }).filter((binding) => binding.protocol && binding.identifier);
61
+ });
62
+ for (const b of bindings) {
63
+ if (!b.protocol || !b.identifier) {
64
+ throw new Error(`Invalid identity binding: ${b.protocol}:${b.identifier}`);
65
+ }
66
+ assertSafeIdentity(b);
67
+ }
68
+ return bindings;
44
69
  }
45
70
  function buildCanonicalMessage({ address, identities }, extensions = {}) {
46
71
  const nonce = randomNonce16BHexLower();
@@ -476,6 +501,21 @@ function isNip07Available() {
476
501
  }
477
502
  return !!window.nostr;
478
503
  }
504
+ async function getNostrPublicKey() {
505
+ if (!isNip07Available()) {
506
+ log2.warn("NIP-07 extension not available");
507
+ return null;
508
+ }
509
+ try {
510
+ const nostr = window.nostr;
511
+ const pubkey = await nostr.getPublicKey();
512
+ log2.info({ pubkey }, "Got Nostr public key from NIP-07");
513
+ return pubkey;
514
+ } catch (err) {
515
+ log2.error({ error: err }, "Failed to get Nostr public key");
516
+ return null;
517
+ }
518
+ }
479
519
  async function signNostrEvent(event) {
480
520
  if (!isNip07Available()) {
481
521
  log2.warn("NIP-07 extension not available");
@@ -491,6 +531,17 @@ async function signNostrEvent(event) {
491
531
  return null;
492
532
  }
493
533
  }
534
+ function getNip07Info() {
535
+ if (!isNip07Available()) {
536
+ return { available: false };
537
+ }
538
+ const nostr = window.nostr;
539
+ return {
540
+ available: true,
541
+ name: nostr._metadata?.name,
542
+ version: nostr._metadata?.version
543
+ };
544
+ }
494
545
 
495
546
  // src/attestation.ts
496
547
  var log3 = createLogger("ocp/attestation");
@@ -751,7 +802,13 @@ async function verifySignature({
751
802
  }
752
803
  function parseCanonicalMessage(msg) {
753
804
  const lines = msg.split("\n").map((l) => l.trim());
754
- const getVal = (prefix) => lines.find((l) => l.toLowerCase().startsWith(prefix + ":"))?.split(":", 2)[1]?.trim();
805
+ const getVal = (prefix) => {
806
+ const needle = prefix + ":";
807
+ const hit = lines.find((l) => l.toLowerCase().startsWith(needle));
808
+ if (!hit) return void 0;
809
+ const idx = hit.indexOf(":");
810
+ return hit.slice(idx + 1).trim();
811
+ };
755
812
  const address = getVal("address") || "";
756
813
  const identitiesStr = getVal("identities") || "";
757
814
  let identities = [];
@@ -806,8 +863,7 @@ async function getAddressUtxos(address, network, opts) {
806
863
  const resp = await fetch(url, {
807
864
  method: "GET",
808
865
  headers: { Accept: "application/json" },
809
- signal: AbortSignal.timeout(1e4)
810
- // 10s timeout
866
+ signal: AbortSignal.timeout(5e3)
811
867
  });
812
868
  if (!resp.ok) {
813
869
  throw new Error(`HTTP ${resp.status}`);
@@ -912,13 +968,18 @@ async function verify2(input, opts = {}) {
912
968
  codes.push("network_testmode");
913
969
  return { ok: false, codes, network, attestation_id };
914
970
  }
915
- if (opts.expectedAud && parsed.extensions["aud"] && parsed.extensions["aud"] !== opts.expectedAud) {
916
- codes.push("aud_mismatch");
971
+ if (opts.expectedAud) {
972
+ const aud = parsed.extensions["aud"];
973
+ if (!aud || aud !== opts.expectedAud) {
974
+ codes.push("aud_mismatch");
975
+ return { ok: false, codes, network, attestation_id };
976
+ }
917
977
  }
918
978
  if (parsed.extensions["expires"]) {
919
979
  const exp = Date.parse(parsed.extensions["expires"]);
920
980
  if (!Number.isNaN(exp) && exp < Date.now()) {
921
981
  codes.push("expired");
982
+ return { ok: false, codes, network, attestation_id };
922
983
  }
923
984
  }
924
985
  const sigCode = await verifySignature({
@@ -996,7 +1057,7 @@ async function verify2(input, opts = {}) {
996
1057
  metrics
997
1058
  };
998
1059
  } catch (utxoError) {
999
- console.error("UTXO fetch failed:", utxoError);
1060
+ log4.error({ err: utxoError }, "UTXO fetch failed");
1000
1061
  codes.push("bad_request");
1001
1062
  return { ok: false, codes, network, attestation_id };
1002
1063
  }
@@ -1246,9 +1307,25 @@ var identityVerificationLimiter = createRateLimiter(5, 6e4);
1246
1307
 
1247
1308
  // src/identity/dns.ts
1248
1309
  var log5 = createLogger("identity-verification/dns");
1310
+ var PRIVATE_HOST_RE = /^(?:localhost|.*\.local|.*\.internal|.*\.localhost|.*\.lan|.*\.home|.*\.arpa|\[?::1\]?|\[?::\]?)$/i;
1311
+ 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]\.)/;
1312
+ var WELL_KNOWN_TIMEOUT_MS = 5e3;
1313
+ function isSafePublicHost(host) {
1314
+ const h = host.toLowerCase();
1315
+ if (PRIVATE_HOST_RE.test(h)) return false;
1316
+ if (PRIVATE_IPV4_RE.test(h)) return false;
1317
+ if (h.startsWith("[") || /^[0-9a-f:]+$/.test(h)) return false;
1318
+ return true;
1319
+ }
1249
1320
  async function verifyDnsIdentity(attestationId, domain) {
1250
1321
  log5.info({ attestationId, domain }, "Verifying DNS identity");
1251
1322
  const cleanDomain = domain.replace(/^https?:\/\//, "").replace(/\/$/, "");
1323
+ if (!isValidDomain(cleanDomain)) {
1324
+ return { verified: false, error: `invalid domain format: ${cleanDomain}` };
1325
+ }
1326
+ if (!isSafePublicHost(cleanDomain)) {
1327
+ return { verified: false, error: "domain resolves to a disallowed private host" };
1328
+ }
1252
1329
  const wellKnownResult = await checkWellKnownFile(attestationId, cleanDomain);
1253
1330
  if (wellKnownResult.verified) {
1254
1331
  return wellKnownResult;
@@ -1260,38 +1337,31 @@ async function verifyDnsIdentity(attestationId, domain) {
1260
1337
  };
1261
1338
  }
1262
1339
  async function checkWellKnownFile(attestationId, domain) {
1263
- const urls = [
1264
- `https://${domain}/.well-known/orangecheck.txt`,
1265
- `http://${domain}/.well-known/orangecheck.txt`
1266
- // Fallback to HTTP
1267
- ];
1268
- for (const url of urls) {
1269
- try {
1270
- log5.info({ url }, "Checking well-known file");
1271
- const response = await fetch(url, {
1272
- method: "GET",
1273
- headers: {
1274
- Accept: "text/plain"
1275
- },
1276
- // Don't follow redirects to other domains
1277
- redirect: "manual"
1278
- });
1279
- if (response.ok) {
1280
- const content = await response.text();
1281
- if (content.includes(attestationId)) {
1282
- log5.info({ domain, url }, "Found attestation ID in well-known file");
1283
- return {
1284
- verified: true,
1285
- method: "well-known",
1286
- url
1287
- };
1288
- } else {
1289
- log5.info({ domain, url }, "Well-known file exists but no attestation ID found");
1290
- }
1340
+ const url = `https://${domain}/.well-known/orangecheck.txt`;
1341
+ try {
1342
+ log5.info({ url }, "Checking well-known file");
1343
+ const response = await fetch(url, {
1344
+ method: "GET",
1345
+ headers: { Accept: "text/plain" },
1346
+ // Don't follow redirects to other domains
1347
+ redirect: "manual",
1348
+ signal: AbortSignal.timeout(WELL_KNOWN_TIMEOUT_MS)
1349
+ });
1350
+ if (response.ok) {
1351
+ const content = await response.text();
1352
+ if (content.includes(attestationId)) {
1353
+ log5.info({ domain, url }, "Found attestation ID in well-known file");
1354
+ return {
1355
+ verified: true,
1356
+ method: "well-known",
1357
+ url
1358
+ };
1359
+ } else {
1360
+ log5.info({ domain, url }, "Well-known file exists but no attestation ID found");
1291
1361
  }
1292
- } catch (err) {
1293
- log5.warn({ error: err, url }, "Failed to fetch well-known file");
1294
1362
  }
1363
+ } catch (err) {
1364
+ log5.warn({ error: err, url }, "Failed to fetch well-known file");
1295
1365
  }
1296
1366
  return {
1297
1367
  verified: false,
@@ -1364,14 +1434,32 @@ function isDnsIdentityVerified(attestationId, _domain, cachedContent) {
1364
1434
 
1365
1435
  // src/identity/github.ts
1366
1436
  var log6 = createLogger("identity-verification/github");
1437
+ var GITHUB_GIST_RAW_HOST = "gist.githubusercontent.com";
1438
+ var MAX_GISTS_SCANNED = 30;
1439
+ var MAX_FILES_PER_GIST = 10;
1440
+ var FETCH_TIMEOUT_MS = 5e3;
1441
+ var GITHUB_USERNAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/;
1442
+ function isSafeGistRawUrl(raw) {
1443
+ try {
1444
+ const u = new URL(raw);
1445
+ return u.protocol === "https:" && u.hostname === GITHUB_GIST_RAW_HOST;
1446
+ } catch {
1447
+ return false;
1448
+ }
1449
+ }
1367
1450
  async function verifyGitHubIdentity(attestationId, username) {
1368
1451
  log6.info({ attestationId, username }, "Verifying GitHub identity");
1452
+ if (!GITHUB_USERNAME_RE.test(username)) {
1453
+ return { verified: false, error: "invalid GitHub username format" };
1454
+ }
1369
1455
  try {
1370
- const response = await fetch(`https://api.github.com/users/${username}/gists`, {
1371
- headers: {
1372
- Accept: "application/vnd.github.v3+json"
1456
+ const response = await fetch(
1457
+ `https://api.github.com/users/${encodeURIComponent(username)}/gists?per_page=${MAX_GISTS_SCANNED}`,
1458
+ {
1459
+ headers: { Accept: "application/vnd.github.v3+json" },
1460
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
1373
1461
  }
1374
- });
1462
+ );
1375
1463
  if (!response.ok) {
1376
1464
  if (response.status === 404) {
1377
1465
  return {
@@ -1386,7 +1474,7 @@ async function verifyGitHubIdentity(attestationId, username) {
1386
1474
  }
1387
1475
  const gists = await response.json();
1388
1476
  log6.info({ username, gistCount: gists.length }, "Fetched GitHub gists");
1389
- for (const gist of gists) {
1477
+ for (const gist of gists.slice(0, MAX_GISTS_SCANNED)) {
1390
1478
  if (gist.description?.includes(attestationId)) {
1391
1479
  log6.info({ username, gistId: gist.id }, "Found attestation ID in gist description");
1392
1480
  return {
@@ -1395,7 +1483,8 @@ async function verifyGitHubIdentity(attestationId, username) {
1395
1483
  gistId: gist.id
1396
1484
  };
1397
1485
  }
1398
- for (const [filename, file] of Object.entries(gist.files)) {
1486
+ const files = Object.entries(gist.files).slice(0, MAX_FILES_PER_GIST);
1487
+ for (const [filename, file] of files) {
1399
1488
  if (file.content && file.content.includes(attestationId)) {
1400
1489
  log6.info(
1401
1490
  { username, gistId: gist.id, filename },
@@ -1407,8 +1496,17 @@ async function verifyGitHubIdentity(attestationId, username) {
1407
1496
  gistId: gist.id
1408
1497
  };
1409
1498
  }
1499
+ if (!isSafeGistRawUrl(file.raw_url)) {
1500
+ log6.warn(
1501
+ { filename, rawUrl: file.raw_url },
1502
+ "skipping gist file: raw_url failed allow-list"
1503
+ );
1504
+ continue;
1505
+ }
1410
1506
  try {
1411
- const fileResponse = await fetch(file.raw_url);
1507
+ const fileResponse = await fetch(file.raw_url, {
1508
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
1509
+ });
1412
1510
  if (fileResponse.ok) {
1413
1511
  const content = await fileResponse.text();
1414
1512
  if (content.includes(attestationId)) {
@@ -1486,15 +1584,38 @@ function isGitHubIdentityVerified(attestationId, _username, cachedGists) {
1486
1584
  function getGistCreationUrl() {
1487
1585
  return "https://gist.github.com/";
1488
1586
  }
1489
-
1490
- // src/identity/nostr.ts
1491
1587
  var log7 = createLogger("identity-verification/nostr");
1588
+ var HEX_64_RE = /^[0-9a-f]{64}$/;
1589
+ function toHex(bytes) {
1590
+ let out = "";
1591
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
1592
+ return out;
1593
+ }
1594
+ function fromHex(hex) {
1595
+ if (hex.length % 2 !== 0) throw new Error("odd-length hex");
1596
+ const out = new Uint8Array(hex.length / 2);
1597
+ for (let i = 0; i < out.length; i++) {
1598
+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
1599
+ }
1600
+ return out;
1601
+ }
1492
1602
  function npubToHex(npub) {
1493
- if (npub.match(/^[0-9a-f]{64}$/i)) {
1494
- return npub.toLowerCase();
1603
+ const trimmed = npub.trim();
1604
+ if (HEX_64_RE.test(trimmed.toLowerCase())) {
1605
+ return trimmed.toLowerCase();
1606
+ }
1607
+ if (!trimmed.toLowerCase().startsWith("npub1")) {
1608
+ throw new Error(`not an npub: ${trimmed.slice(0, 12)}\u2026`);
1495
1609
  }
1496
- log7.warn({ npub }, "npub to hex conversion not fully implemented");
1497
- return npub;
1610
+ const decoded = bech32.decode(trimmed.toLowerCase(), 1023);
1611
+ if (decoded.prefix !== "npub") {
1612
+ throw new Error(`bech32 prefix mismatch: expected npub, got ${decoded.prefix}`);
1613
+ }
1614
+ const bytes = bech32.fromWords(decoded.words);
1615
+ if (bytes.length !== 32) {
1616
+ throw new Error(`invalid npub payload length: ${bytes.length}`);
1617
+ }
1618
+ return toHex(Uint8Array.from(bytes));
1498
1619
  }
1499
1620
  async function queryNostrEvents(options) {
1500
1621
  const { authors, kinds, search, relays = DEFAULT_RELAYS, limit = 100 } = options;
@@ -1565,11 +1686,31 @@ async function queryNostrEvents(options) {
1565
1686
  return events;
1566
1687
  }
1567
1688
  async function verifyNostrEventSignature(event) {
1568
- if (!event.sig || event.sig.length !== 128) {
1689
+ try {
1690
+ 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)) {
1691
+ return false;
1692
+ }
1693
+ const preimage = JSON.stringify([
1694
+ 0,
1695
+ event.pubkey,
1696
+ event.created_at,
1697
+ event.kind,
1698
+ event.tags ?? [],
1699
+ event.content ?? ""
1700
+ ]);
1701
+ const computedId = toHex(sha256(new TextEncoder().encode(preimage)));
1702
+ if (computedId !== event.id.toLowerCase()) {
1703
+ log7.warn(
1704
+ { eventId: event.id, computedId },
1705
+ "nostr event id does not match its content"
1706
+ );
1707
+ return false;
1708
+ }
1709
+ return schnorr.verify(fromHex(event.sig), fromHex(event.id), fromHex(event.pubkey));
1710
+ } catch (err) {
1711
+ log7.warn({ err }, "schnorr verify threw");
1569
1712
  return false;
1570
1713
  }
1571
- log7.warn("Nostr signature verification not fully implemented");
1572
- return true;
1573
1714
  }
1574
1715
  async function verifyNostrIdentity(attestationId, npub, relays) {
1575
1716
  log7.info({ attestationId, npub }, "Verifying Nostr identity");
@@ -1925,6 +2066,6 @@ var STATUS_META = {
1925
2066
  }
1926
2067
  };
1927
2068
 
1928
- export { DEFAULT_RELAYS, STATUS_META, buildCanonicalMessage, check, computeScore, createAttestation, createAttestationEnvelope, createAttestationEvent, discoverAttestations, formatIdentities, generateAttestationId, generateGistTemplate, generateTweetText, generateWellKnownContent, getAttestationsForAddress, getAttestationsForIdentity, getDnsVerificationInstructions, getGistCreationUrl, getGitHubVerificationInstructions, getManualVerificationInstructions, getNostrVerificationInstructions, getTweetUrl, getTwitterComposeUrl, getTwitterVerificationInstructions, getVerificationInstructions, getVerificationSummary, getWellKnownUrl, isDnsIdentityVerified, isGitHubIdentityVerified, isNip07Available, isNostrIdentityVerified, isValidDomain, isValidTweetUrl, issueChallenge, parseAttestationFromEvent, parseIdentities, publishAttestation, publishToRelays, queryByAddress, queryByAttestationId, queryByIdentity, requiresManualVerification, signNostrEvent, supportsAutomaticVerification, verify2 as verify, verifyAllIdentities, verifyAttestationById, verifyChallenge, verifyDnsIdentity, verifyGitHubIdentity, verifyIdentity, verifyNostrIdentity, verifyTwitterIdentity };
2069
+ export { DEFAULT_RELAYS, STATUS_META, buildCanonicalMessage, check, computeScore, createAttestation, createAttestationEnvelope, createAttestationEvent, discoverAttestations, formatIdentities, generateAttestationId, generateGistTemplate, generateTweetText, generateWellKnownContent, getAttestationsForAddress, getAttestationsForIdentity, getDnsVerificationInstructions, getGistCreationUrl, getGitHubVerificationInstructions, getManualVerificationInstructions, getNip07Info, getNostrPublicKey, getNostrVerificationInstructions, getTweetUrl, getTwitterComposeUrl, getTwitterVerificationInstructions, getVerificationInstructions, getVerificationSummary, getWellKnownUrl, isDnsIdentityVerified, isGitHubIdentityVerified, isNip07Available, isNostrIdentityVerified, isValidDomain, isValidTweetUrl, issueChallenge, parseAttestationFromEvent, parseIdentities, publishAttestation, publishToRelays, queryByAddress, queryByAttestationId, queryByIdentity, requiresManualVerification, signNostrEvent, supportsAutomaticVerification, verify2 as verify, verifyAllIdentities, verifyAttestationById, verifyChallenge, verifyDnsIdentity, verifyGitHubIdentity, verifyIdentity, verifyNostrIdentity, verifyTwitterIdentity };
1929
2070
  //# sourceMappingURL=index.mjs.map
1930
2071
  //# sourceMappingURL=index.mjs.map