@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.
- package/README.md +2 -1
- package/dist/attestation.js +228 -64
- package/dist/attestation.js.map +1 -1
- package/dist/attestation.mjs +228 -64
- package/dist/attestation.mjs.map +1 -1
- package/dist/challenge.d.mts +1 -0
- package/dist/challenge.d.ts +1 -0
- package/dist/challenge.js +20 -14
- package/dist/challenge.js.map +1 -1
- package/dist/challenge.mjs +20 -14
- package/dist/challenge.mjs.map +1 -1
- package/dist/identity.js +19 -17
- package/dist/identity.js.map +1 -1
- package/dist/identity.mjs +19 -17
- package/dist/identity.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +555 -585
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +554 -586
- package/dist/index.mjs.map +1 -1
- package/dist/nostr.d.mts +3 -1
- package/dist/nostr.d.ts +3 -1
- package/dist/nostr.js +213 -38
- package/dist/nostr.js.map +1 -1
- package/dist/nostr.mjs +212 -39
- package/dist/nostr.mjs.map +1 -1
- package/dist/verify.d.mts +11 -2
- package/dist/verify.d.ts +11 -2
- package/dist/verify.js +39 -100
- package/dist/verify.js.map +1 -1
- package/dist/verify.mjs +39 -101
- package/dist/verify.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var base = require('@scure/base');
|
|
4
|
-
var buffer = require('buffer');
|
|
5
4
|
var bip322 = require('@orangecheck/bip322');
|
|
5
|
+
var buffer = require('buffer');
|
|
6
6
|
var hmac_js = require('@noble/hashes/hmac.js');
|
|
7
7
|
var sha2_js = require('@noble/hashes/sha2.js');
|
|
8
8
|
var utils_js = require('@noble/hashes/utils.js');
|
|
@@ -214,8 +214,345 @@ function logVerificationError(protocol, identifier, error, context) {
|
|
|
214
214
|
);
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
// src/utils/time.ts
|
|
218
|
+
var RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
219
|
+
function rfc3339Ms(value) {
|
|
220
|
+
if (!value || !RFC3339.test(value)) return null;
|
|
221
|
+
const ms = Date.parse(value);
|
|
222
|
+
return Number.isNaN(ms) ? null : ms;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/scoring.ts
|
|
226
|
+
function scoreV0(sats, days) {
|
|
227
|
+
if (sats <= 0 || days <= 0) return 0;
|
|
228
|
+
return Math.round(Math.log(1 + sats) * (1 + days / 30) * 100) / 100;
|
|
229
|
+
}
|
|
230
|
+
function scoreTier(sats, days) {
|
|
231
|
+
if (sats >= 1e7 && days >= 365) return "platinum";
|
|
232
|
+
if (sats >= 1e6 && days >= 180) return "gold";
|
|
233
|
+
if (sats >= 1e5 && days >= 90) return "silver";
|
|
234
|
+
if (sats >= 1e4 && days >= 30) return "bronze";
|
|
235
|
+
return "none";
|
|
236
|
+
}
|
|
237
|
+
function computeScore(sats, days, config) {
|
|
238
|
+
switch (config.algorithm) {
|
|
239
|
+
case "v0":
|
|
240
|
+
return scoreV0(sats, days);
|
|
241
|
+
case "tier":
|
|
242
|
+
return scoreTier(sats, days);
|
|
243
|
+
case "none":
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function computeAllScores(sats, days) {
|
|
248
|
+
return { v0: scoreV0(sats, days), tier: scoreTier(sats, days) };
|
|
249
|
+
}
|
|
250
|
+
function toBase64Maybe(sig) {
|
|
251
|
+
if (/^[0-9a-fA-F]+$/.test(sig) && sig.length % 2 === 0) {
|
|
252
|
+
return buffer.Buffer.from(sig, "hex").toString("base64");
|
|
253
|
+
}
|
|
254
|
+
return sig;
|
|
255
|
+
}
|
|
256
|
+
function signatureScheme(sigBase64) {
|
|
257
|
+
if (/^(ful|pof)/.test(sigBase64)) return null;
|
|
258
|
+
if (sigBase64.startsWith("smp")) return "bip322";
|
|
259
|
+
return buffer.Buffer.from(sigBase64, "base64").length === 65 ? "legacy" : "bip322";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// src/verify.ts
|
|
263
|
+
var log = createLogger("ocp/verify");
|
|
264
|
+
async function verifySignature({ addr, msg, sig }) {
|
|
265
|
+
const sigBase64 = toBase64Maybe(sig);
|
|
266
|
+
const scheme = signatureScheme(sigBase64);
|
|
267
|
+
if (!scheme) return "sig_unsupported_script";
|
|
268
|
+
const ok = bip322.verifySignature(addr, msg, sigBase64);
|
|
269
|
+
log.info({ addr, ok, scheme, msgLength: msg.length }, "Signature verification result");
|
|
270
|
+
if (!ok) return "sig_invalid";
|
|
271
|
+
return scheme === "legacy" ? "sig_ok_legacy" : "sig_ok_bip322";
|
|
272
|
+
}
|
|
273
|
+
async function signatureIsValid(input) {
|
|
274
|
+
try {
|
|
275
|
+
const code = await verifySignature(input);
|
|
276
|
+
return code === "sig_ok_bip322" || code === "sig_ok_legacy";
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function parseCanonicalMessage(msg) {
|
|
282
|
+
const lines = msg.split("\n").map((l) => l.trim());
|
|
283
|
+
const getVal = (prefix) => {
|
|
284
|
+
const needle = prefix + ":";
|
|
285
|
+
const hit = lines.find((l) => l.toLowerCase().startsWith(needle));
|
|
286
|
+
if (!hit) return void 0;
|
|
287
|
+
const idx = hit.indexOf(":");
|
|
288
|
+
return hit.slice(idx + 1).trim();
|
|
289
|
+
};
|
|
290
|
+
const address = getVal("address") || "";
|
|
291
|
+
const identitiesStr = getVal("identities") || "";
|
|
292
|
+
const issued_at = getVal("issued_at") || "";
|
|
293
|
+
const expires = getVal("expires");
|
|
294
|
+
let identities = [];
|
|
295
|
+
try {
|
|
296
|
+
identities = parseIdentities(identitiesStr);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
log.warn({ identitiesStr, error: err }, "Failed to parse identities field");
|
|
299
|
+
}
|
|
300
|
+
const coreKeys = /* @__PURE__ */ new Set([
|
|
301
|
+
"orangecheck",
|
|
302
|
+
"identities",
|
|
303
|
+
"address",
|
|
304
|
+
"purpose",
|
|
305
|
+
"nonce",
|
|
306
|
+
"issued_at",
|
|
307
|
+
"ack"
|
|
308
|
+
]);
|
|
309
|
+
const extensions = {};
|
|
310
|
+
for (const line of lines) {
|
|
311
|
+
const idx = line.indexOf(":");
|
|
312
|
+
if (idx === -1) continue;
|
|
313
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
314
|
+
const val = line.slice(idx + 1).trim();
|
|
315
|
+
if (!coreKeys.has(key) && val) extensions[key] = val;
|
|
316
|
+
}
|
|
317
|
+
const network = extensions["network"] || "mainnet";
|
|
318
|
+
const bondStr = extensions["bond"];
|
|
319
|
+
const bond = bondStr ? parseInt(bondStr, 10) : void 0;
|
|
320
|
+
return {
|
|
321
|
+
core: { address, identities, issued_at },
|
|
322
|
+
expires,
|
|
323
|
+
network,
|
|
324
|
+
extensions,
|
|
325
|
+
bond: bond !== void 0 && !isNaN(bond) && bond > 0 ? bond : void 0
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
async function getAddressUtxos(address, network, opts) {
|
|
329
|
+
const endpoints = network === "signet" ? [
|
|
330
|
+
opts?.esploraSignetBase || "https://mempool.space/signet/api",
|
|
331
|
+
"https://blockstream.info/signet/api"
|
|
332
|
+
] : network === "testnet" ? [
|
|
333
|
+
opts?.esploraTestnetBase || "https://mempool.space/testnet/api",
|
|
334
|
+
"https://blockstream.info/testnet/api"
|
|
335
|
+
] : [
|
|
336
|
+
opts?.esploraMainnetBase || "https://mempool.space/api",
|
|
337
|
+
"https://blockstream.info/api"
|
|
338
|
+
];
|
|
339
|
+
let lastError = null;
|
|
340
|
+
for (const base of endpoints) {
|
|
341
|
+
try {
|
|
342
|
+
const url = `${base}/address/${address}/utxo`;
|
|
343
|
+
log.debug({ url, network }, "Fetching UTXOs");
|
|
344
|
+
const resp = await fetch(url, {
|
|
345
|
+
method: "GET",
|
|
346
|
+
headers: { Accept: "application/json" },
|
|
347
|
+
signal: AbortSignal.timeout(5e3)
|
|
348
|
+
});
|
|
349
|
+
if (!resp.ok) {
|
|
350
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
351
|
+
}
|
|
352
|
+
const data = await resp.json();
|
|
353
|
+
if (!Array.isArray(data)) {
|
|
354
|
+
throw new Error("Invalid response: expected array");
|
|
355
|
+
}
|
|
356
|
+
for (const utxo of data) {
|
|
357
|
+
if (typeof utxo.value !== "number" || typeof utxo.status !== "object" || typeof utxo.status.confirmed !== "boolean" || typeof utxo.txid !== "string" || typeof utxo.vout !== "number") {
|
|
358
|
+
throw new Error("Invalid UTXO structure");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
log.info({ base, count: data.length, network }, "UTXOs fetched successfully");
|
|
362
|
+
return data;
|
|
363
|
+
} catch (err) {
|
|
364
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
365
|
+
log.warn(
|
|
366
|
+
{ base, error: lastError.message, network },
|
|
367
|
+
"Esplora endpoint failed, trying next"
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
log.error({ endpoints, error: lastError?.message, network }, "All Esplora endpoints failed");
|
|
372
|
+
throw new Error(`esplora_all_failed: ${lastError?.message || "unknown"}`);
|
|
373
|
+
}
|
|
374
|
+
function computeMetrics(utxos, bondAmount, coverSats) {
|
|
375
|
+
const confirmedUtxos = utxos.filter((u) => u.status.confirmed);
|
|
376
|
+
if (confirmedUtxos.length === 0) {
|
|
377
|
+
return { sats_bonded: 0, days_unspent: 0, confirmed_balance: 0 };
|
|
378
|
+
}
|
|
379
|
+
const confirmed_balance = confirmedUtxos.reduce((sum, u) => sum + u.value, 0);
|
|
380
|
+
if (bondAmount !== void 0 && bondAmount > 0) {
|
|
381
|
+
const sorted = [...confirmedUtxos].sort((a, b) => {
|
|
382
|
+
const heightA = a.status.block_height ?? Infinity;
|
|
383
|
+
const heightB = b.status.block_height ?? Infinity;
|
|
384
|
+
if (heightA !== heightB) return heightA - heightB;
|
|
385
|
+
if (a.txid !== b.txid) return a.txid.localeCompare(b.txid);
|
|
386
|
+
return a.vout - b.vout;
|
|
387
|
+
});
|
|
388
|
+
let sum = 0;
|
|
389
|
+
let youngestTime = 0;
|
|
390
|
+
for (const utxo of sorted) {
|
|
391
|
+
sum += utxo.value;
|
|
392
|
+
youngestTime = Math.max(youngestTime, utxo.status.block_time || 0);
|
|
393
|
+
if (sum >= bondAmount) break;
|
|
394
|
+
}
|
|
395
|
+
const days_unspent2 = Math.max(
|
|
396
|
+
0,
|
|
397
|
+
Math.floor((Date.now() / 1e3 - youngestTime) / (24 * 60 * 60))
|
|
398
|
+
);
|
|
399
|
+
return {
|
|
400
|
+
sats_bonded: bondAmount,
|
|
401
|
+
// Use exactly bond amount (ignore surplus)
|
|
402
|
+
days_unspent: days_unspent2,
|
|
403
|
+
confirmed_balance
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
const sats_bonded = confirmed_balance;
|
|
407
|
+
if (coverSats !== void 0 && coverSats > 0) {
|
|
408
|
+
const sorted = [...confirmedUtxos].sort(
|
|
409
|
+
(a, b) => (a.status.block_height ?? Infinity) - (b.status.block_height ?? Infinity)
|
|
410
|
+
);
|
|
411
|
+
let sum = 0;
|
|
412
|
+
let youngestTime = 0;
|
|
413
|
+
for (const utxo of sorted) {
|
|
414
|
+
sum += utxo.value;
|
|
415
|
+
youngestTime = Math.max(youngestTime, utxo.status.block_time || Date.now() / 1e3);
|
|
416
|
+
if (sum >= coverSats) break;
|
|
417
|
+
}
|
|
418
|
+
const days_unspent2 = Math.max(
|
|
419
|
+
0,
|
|
420
|
+
Math.floor((Date.now() / 1e3 - youngestTime) / (24 * 60 * 60))
|
|
421
|
+
);
|
|
422
|
+
return { sats_bonded, days_unspent: days_unspent2, confirmed_balance };
|
|
423
|
+
}
|
|
424
|
+
const oldestTime = Math.min(
|
|
425
|
+
...confirmedUtxos.map((u) => u.status.block_time || Date.now() / 1e3)
|
|
426
|
+
);
|
|
427
|
+
const days_unspent = Math.max(0, Math.floor((Date.now() / 1e3 - oldestTime) / (24 * 60 * 60)));
|
|
428
|
+
return { sats_bonded, days_unspent, confirmed_balance };
|
|
429
|
+
}
|
|
430
|
+
async function verify(input, opts = {}) {
|
|
431
|
+
const codes = [];
|
|
432
|
+
let network = "mainnet";
|
|
433
|
+
try {
|
|
434
|
+
if (!input.msg || !input.addr || !input.sig) {
|
|
435
|
+
codes.push("bad_request");
|
|
436
|
+
return { ok: false, codes, network };
|
|
437
|
+
}
|
|
438
|
+
const parsed = parseCanonicalMessage(input.msg);
|
|
439
|
+
network = parsed.network || "mainnet";
|
|
440
|
+
const attestation_id = await generateAttestationId(input.msg);
|
|
441
|
+
if (parsed.core.address !== input.addr) {
|
|
442
|
+
codes.push("bad_request");
|
|
443
|
+
return { ok: false, codes, network, attestation_id };
|
|
444
|
+
}
|
|
445
|
+
if ((network === "testnet" || network === "signet") && !opts.testMode) {
|
|
446
|
+
codes.push("network_testmode");
|
|
447
|
+
return { ok: false, codes, network, attestation_id };
|
|
448
|
+
}
|
|
449
|
+
if (opts.expectedAud) {
|
|
450
|
+
const aud = parsed.extensions["aud"];
|
|
451
|
+
if (!aud || aud !== opts.expectedAud) {
|
|
452
|
+
codes.push("aud_mismatch");
|
|
453
|
+
return { ok: false, codes, network, attestation_id };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (parsed.expires !== void 0) {
|
|
457
|
+
const exp = rfc3339Ms(parsed.expires);
|
|
458
|
+
if (exp === null || exp < Date.now()) {
|
|
459
|
+
codes.push("expired");
|
|
460
|
+
return { ok: false, codes, network, attestation_id };
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const sigCode = await verifySignature({
|
|
464
|
+
addr: input.addr,
|
|
465
|
+
msg: input.msg,
|
|
466
|
+
sig: input.sig,
|
|
467
|
+
...input.scheme && { scheme: input.scheme }
|
|
468
|
+
});
|
|
469
|
+
codes.push(sigCode);
|
|
470
|
+
const sigOk = sigCode === "sig_ok_bip322" || sigCode === "sig_ok_legacy";
|
|
471
|
+
if (!sigOk) {
|
|
472
|
+
return {
|
|
473
|
+
ok: false,
|
|
474
|
+
codes,
|
|
475
|
+
network,
|
|
476
|
+
attestation_id,
|
|
477
|
+
identities: parsed.core.identities
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
const utxos = await getAddressUtxos(input.addr, network, {
|
|
482
|
+
esploraMainnetBase: opts.esploraMainnetBase,
|
|
483
|
+
esploraSignetBase: opts.esploraSignetBase
|
|
484
|
+
});
|
|
485
|
+
const { sats_bonded, days_unspent, confirmed_balance } = computeMetrics(
|
|
486
|
+
utxos,
|
|
487
|
+
parsed.bond,
|
|
488
|
+
opts.coverSats
|
|
489
|
+
);
|
|
490
|
+
if (parsed.bond !== void 0 && parsed.bond > 0) {
|
|
491
|
+
if (confirmed_balance < parsed.bond) {
|
|
492
|
+
codes.push("bond_insufficient");
|
|
493
|
+
log.warn(
|
|
494
|
+
{
|
|
495
|
+
bond: parsed.bond,
|
|
496
|
+
confirmed_balance,
|
|
497
|
+
address: input.addr
|
|
498
|
+
},
|
|
499
|
+
"Bond extension: insufficient balance"
|
|
500
|
+
);
|
|
501
|
+
return {
|
|
502
|
+
ok: false,
|
|
503
|
+
codes,
|
|
504
|
+
network,
|
|
505
|
+
attestation_id,
|
|
506
|
+
identities: parsed.core.identities
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (sats_bonded <= 0) {
|
|
511
|
+
codes.push("bond_zero");
|
|
512
|
+
if (utxos.some((u) => !u.status.confirmed)) {
|
|
513
|
+
codes.push("bond_pending");
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
ok: false,
|
|
517
|
+
codes,
|
|
518
|
+
network,
|
|
519
|
+
attestation_id,
|
|
520
|
+
identities: parsed.core.identities
|
|
521
|
+
};
|
|
522
|
+
} else {
|
|
523
|
+
codes.push("bond_confirmed");
|
|
524
|
+
}
|
|
525
|
+
const metrics = {
|
|
526
|
+
sats_bonded,
|
|
527
|
+
days_unspent,
|
|
528
|
+
score: computeScore(sats_bonded, days_unspent, { algorithm: "v0" })
|
|
529
|
+
};
|
|
530
|
+
return {
|
|
531
|
+
ok: true,
|
|
532
|
+
codes,
|
|
533
|
+
network,
|
|
534
|
+
attestation_id,
|
|
535
|
+
identities: parsed.core.identities,
|
|
536
|
+
metrics
|
|
537
|
+
};
|
|
538
|
+
} catch (utxoError) {
|
|
539
|
+
log.error({ err: utxoError }, "UTXO fetch failed");
|
|
540
|
+
codes.push("bad_request");
|
|
541
|
+
return { ok: false, codes, network, attestation_id };
|
|
542
|
+
}
|
|
543
|
+
} catch (e) {
|
|
544
|
+
const msg = e instanceof Error ? e.message : String(e ?? "decode_error");
|
|
545
|
+
if (msg === "decode_error") {
|
|
546
|
+
codes.push("decode_error");
|
|
547
|
+
} else {
|
|
548
|
+
codes.push("bad_request");
|
|
549
|
+
}
|
|
550
|
+
return { ok: false, codes, network };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
217
554
|
// src/nostr.ts
|
|
218
|
-
var
|
|
555
|
+
var log2 = createLogger("ocp/nostr");
|
|
219
556
|
var ATTESTATION_EVENT_KIND = 30078;
|
|
220
557
|
var DEFAULT_RELAYS = [
|
|
221
558
|
"wss://relay.damus.io",
|
|
@@ -268,7 +605,7 @@ function createAttestationEvent(envelope, pubkey) {
|
|
|
268
605
|
async function publishToRelays(event, relays = DEFAULT_RELAYS) {
|
|
269
606
|
const success = [];
|
|
270
607
|
const failed = [];
|
|
271
|
-
|
|
608
|
+
log2.info({ eventId: event.id, relayCount: relays.length }, "Publishing event to relays");
|
|
272
609
|
const deadlineAt = Date.now() + FANOUT_DEADLINE_MS;
|
|
273
610
|
const publishPromises = relays.map(async (relayUrl) => {
|
|
274
611
|
let ws = null;
|
|
@@ -307,7 +644,7 @@ async function publishToRelays(event, relays = DEFAULT_RELAYS) {
|
|
|
307
644
|
cleanup();
|
|
308
645
|
resolve();
|
|
309
646
|
} else {
|
|
310
|
-
|
|
647
|
+
log2.warn(
|
|
311
648
|
{ relay: relayUrl, reason: data[3] },
|
|
312
649
|
"Relay rejected event"
|
|
313
650
|
);
|
|
@@ -317,7 +654,7 @@ async function publishToRelays(event, relays = DEFAULT_RELAYS) {
|
|
|
317
654
|
}
|
|
318
655
|
}
|
|
319
656
|
} catch (err) {
|
|
320
|
-
|
|
657
|
+
log2.error(
|
|
321
658
|
{ relay: relayUrl, error: err },
|
|
322
659
|
"Failed to parse relay response"
|
|
323
660
|
);
|
|
@@ -327,7 +664,7 @@ async function publishToRelays(event, relays = DEFAULT_RELAYS) {
|
|
|
327
664
|
}
|
|
328
665
|
};
|
|
329
666
|
wsRef.onerror = (err) => {
|
|
330
|
-
|
|
667
|
+
log2.error({ relay: relayUrl, error: err }, "WebSocket error");
|
|
331
668
|
recordFailure();
|
|
332
669
|
cleanup();
|
|
333
670
|
reject(err);
|
|
@@ -337,7 +674,7 @@ async function publishToRelays(event, relays = DEFAULT_RELAYS) {
|
|
|
337
674
|
};
|
|
338
675
|
});
|
|
339
676
|
} catch (err) {
|
|
340
|
-
|
|
677
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to publish to relay");
|
|
341
678
|
recordFailure();
|
|
342
679
|
} finally {
|
|
343
680
|
if (ws && ws.readyState !== WebSocket.CLOSED) {
|
|
@@ -346,12 +683,12 @@ async function publishToRelays(event, relays = DEFAULT_RELAYS) {
|
|
|
346
683
|
}
|
|
347
684
|
});
|
|
348
685
|
await Promise.allSettled(publishPromises);
|
|
349
|
-
|
|
686
|
+
log2.info({ successCount: success.length, failedCount: failed.length }, "Publishing complete");
|
|
350
687
|
return { success, failed };
|
|
351
688
|
}
|
|
352
689
|
async function queryByAttestationId(attestationId, relays = DEFAULT_RELAYS) {
|
|
353
690
|
const events = [];
|
|
354
|
-
|
|
691
|
+
log2.info({ attestationId, relayCount: relays.length }, "Querying relays for attestation");
|
|
355
692
|
const deadlineAt = Date.now() + FANOUT_DEADLINE_MS;
|
|
356
693
|
const queryPromises = relays.map(async (relayUrl) => {
|
|
357
694
|
let ws = null;
|
|
@@ -391,11 +728,11 @@ async function queryByAttestationId(attestationId, relays = DEFAULT_RELAYS) {
|
|
|
391
728
|
resolve();
|
|
392
729
|
}
|
|
393
730
|
} catch (err) {
|
|
394
|
-
|
|
731
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to parse event");
|
|
395
732
|
}
|
|
396
733
|
};
|
|
397
734
|
wsRef.onerror = (err) => {
|
|
398
|
-
|
|
735
|
+
log2.error({ relay: relayUrl, error: err }, "WebSocket error during query");
|
|
399
736
|
cleanup();
|
|
400
737
|
reject(err);
|
|
401
738
|
};
|
|
@@ -404,7 +741,7 @@ async function queryByAttestationId(attestationId, relays = DEFAULT_RELAYS) {
|
|
|
404
741
|
};
|
|
405
742
|
});
|
|
406
743
|
} catch (err) {
|
|
407
|
-
|
|
744
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to query relay");
|
|
408
745
|
} finally {
|
|
409
746
|
if (ws && ws.readyState !== WebSocket.CLOSED) {
|
|
410
747
|
ws.close();
|
|
@@ -412,12 +749,12 @@ async function queryByAttestationId(attestationId, relays = DEFAULT_RELAYS) {
|
|
|
412
749
|
}
|
|
413
750
|
});
|
|
414
751
|
await Promise.allSettled(queryPromises);
|
|
415
|
-
|
|
752
|
+
log2.info({ eventCount: events.length }, "Query complete");
|
|
416
753
|
return events;
|
|
417
754
|
}
|
|
418
755
|
async function queryByAddress(address, relays = DEFAULT_RELAYS) {
|
|
419
756
|
const events = [];
|
|
420
|
-
|
|
757
|
+
log2.info({ address, relayCount: relays.length }, "Querying relays for address");
|
|
421
758
|
const deadlineAt = Date.now() + FANOUT_DEADLINE_MS;
|
|
422
759
|
const queryPromises = relays.map(async (relayUrl) => {
|
|
423
760
|
try {
|
|
@@ -459,28 +796,28 @@ async function queryByAddress(address, relays = DEFAULT_RELAYS) {
|
|
|
459
796
|
resolve();
|
|
460
797
|
}
|
|
461
798
|
} catch (err) {
|
|
462
|
-
|
|
799
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to parse event");
|
|
463
800
|
}
|
|
464
801
|
};
|
|
465
802
|
ws.onerror = (err) => {
|
|
466
803
|
clearTimeout(timeout);
|
|
467
|
-
|
|
804
|
+
log2.error({ relay: relayUrl, error: err }, "WebSocket error during query");
|
|
468
805
|
reject(err);
|
|
469
806
|
};
|
|
470
807
|
});
|
|
471
808
|
} catch (err) {
|
|
472
|
-
|
|
809
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to query relay");
|
|
473
810
|
}
|
|
474
811
|
});
|
|
475
812
|
await Promise.allSettled(queryPromises);
|
|
476
|
-
|
|
813
|
+
log2.info({ eventCount: events.length }, "Query complete");
|
|
477
814
|
return events;
|
|
478
815
|
}
|
|
479
816
|
async function queryByIdentity(protocol, identifier, relays = DEFAULT_RELAYS) {
|
|
480
817
|
const events = [];
|
|
481
818
|
const identityTag = `${protocol}:${identifier}`;
|
|
482
819
|
const identityTags = protocol === "nostr" ? nostrIdentifierForms(identifier).map((f) => `nostr:${f}`) : [identityTag];
|
|
483
|
-
|
|
820
|
+
log2.info({ protocol, identifier, relayCount: relays.length }, "Querying relays for identity");
|
|
484
821
|
const deadlineAt = Date.now() + FANOUT_DEADLINE_MS;
|
|
485
822
|
const queryPromises = relays.map(async (relayUrl) => {
|
|
486
823
|
try {
|
|
@@ -515,26 +852,26 @@ async function queryByIdentity(protocol, identifier, relays = DEFAULT_RELAYS) {
|
|
|
515
852
|
resolve();
|
|
516
853
|
}
|
|
517
854
|
} catch (err) {
|
|
518
|
-
|
|
855
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to parse event");
|
|
519
856
|
}
|
|
520
857
|
};
|
|
521
858
|
ws.onerror = (err) => {
|
|
522
859
|
clearTimeout(timeout);
|
|
523
|
-
|
|
860
|
+
log2.error({ relay: relayUrl, error: err }, "WebSocket error during query");
|
|
524
861
|
reject(err);
|
|
525
862
|
};
|
|
526
863
|
});
|
|
527
864
|
} catch (err) {
|
|
528
|
-
|
|
865
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to query relay");
|
|
529
866
|
}
|
|
530
867
|
});
|
|
531
868
|
await Promise.allSettled(queryPromises);
|
|
532
|
-
|
|
869
|
+
log2.info({ eventCount: events.length }, "Query complete");
|
|
533
870
|
return events;
|
|
534
871
|
}
|
|
535
872
|
async function queryRecent(relays = DEFAULT_RELAYS, limit = 200) {
|
|
536
873
|
const events = [];
|
|
537
|
-
|
|
874
|
+
log2.info({ relayCount: relays.length, limit }, "Querying relays for recent attestations");
|
|
538
875
|
const deadlineAt = Date.now() + FANOUT_DEADLINE_MS;
|
|
539
876
|
const queryPromises = relays.map(async (relayUrl) => {
|
|
540
877
|
try {
|
|
@@ -570,30 +907,22 @@ async function queryRecent(relays = DEFAULT_RELAYS, limit = 200) {
|
|
|
570
907
|
resolve();
|
|
571
908
|
}
|
|
572
909
|
} catch (err) {
|
|
573
|
-
|
|
910
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to parse event");
|
|
574
911
|
}
|
|
575
912
|
};
|
|
576
913
|
ws.onerror = (err) => {
|
|
577
914
|
clearTimeout(timeout);
|
|
578
|
-
|
|
915
|
+
log2.error({ relay: relayUrl, error: err }, "WebSocket error during query");
|
|
579
916
|
reject(err);
|
|
580
917
|
};
|
|
581
918
|
});
|
|
582
919
|
} catch (err) {
|
|
583
|
-
|
|
920
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to query relay");
|
|
584
921
|
}
|
|
585
922
|
});
|
|
586
923
|
await Promise.allSettled(queryPromises);
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
for (const ev of events) {
|
|
590
|
-
const envelope = parseAttestationFromEvent(ev);
|
|
591
|
-
if (!envelope || seen.has(envelope.attestation_id)) continue;
|
|
592
|
-
if (!await verifyAttestationEnvelope(envelope)) continue;
|
|
593
|
-
seen.add(envelope.attestation_id);
|
|
594
|
-
envelopes.push(envelope);
|
|
595
|
-
}
|
|
596
|
-
log.info({ eventCount: events.length, unique: envelopes.length }, "Recent query complete");
|
|
924
|
+
const envelopes = await collectSignedEnvelopes(events);
|
|
925
|
+
log2.info({ eventCount: events.length, unique: envelopes.length }, "Recent query complete");
|
|
597
926
|
return envelopes;
|
|
598
927
|
}
|
|
599
928
|
async function verifyAttestationEnvelope(envelope) {
|
|
@@ -605,22 +934,74 @@ async function verifyAttestationEnvelope(envelope) {
|
|
|
605
934
|
const computedId = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
606
935
|
return computedId === envelope.attestation_id;
|
|
607
936
|
} catch (err) {
|
|
608
|
-
|
|
937
|
+
log2.error({ error: err }, "Failed to verify attestation envelope");
|
|
609
938
|
return false;
|
|
610
939
|
}
|
|
611
940
|
}
|
|
941
|
+
function base64Url(text) {
|
|
942
|
+
const base64 = btoa(String.fromCharCode(...new TextEncoder().encode(text)));
|
|
943
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
944
|
+
}
|
|
945
|
+
function signedEnvelope(envelope) {
|
|
946
|
+
let parsed;
|
|
947
|
+
try {
|
|
948
|
+
parsed = parseCanonicalMessage(envelope.message);
|
|
949
|
+
} catch {
|
|
950
|
+
return null;
|
|
951
|
+
}
|
|
952
|
+
if (!parsed.core.address || rfc3339Ms(parsed.core.issued_at) === null) return null;
|
|
953
|
+
const { expires_at: _copy, ...rest } = envelope;
|
|
954
|
+
return {
|
|
955
|
+
...rest,
|
|
956
|
+
address: parsed.core.address,
|
|
957
|
+
identities: parsed.core.identities,
|
|
958
|
+
issued_at: parsed.core.issued_at,
|
|
959
|
+
...parsed.expires !== void 0 ? { expires_at: parsed.expires } : {},
|
|
960
|
+
message_b64url: base64Url(envelope.message)
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
async function collectSignedEnvelopes(events, accept = () => true) {
|
|
964
|
+
const copies = /* @__PURE__ */ new Map();
|
|
965
|
+
for (const event of events) {
|
|
966
|
+
const raw = parseAttestationFromEvent(event);
|
|
967
|
+
if (!raw || !await verifyAttestationEnvelope(raw)) continue;
|
|
968
|
+
const envelope = signedEnvelope(raw);
|
|
969
|
+
if (!envelope || !accept(event, envelope)) continue;
|
|
970
|
+
const same = copies.get(envelope.attestation_id) ?? [];
|
|
971
|
+
if (!same.some((e) => e.signature === envelope.signature && e.scheme === envelope.scheme)) {
|
|
972
|
+
same.push(envelope);
|
|
973
|
+
}
|
|
974
|
+
copies.set(envelope.attestation_id, same);
|
|
975
|
+
}
|
|
976
|
+
const kept = [];
|
|
977
|
+
for (const same of copies.values()) {
|
|
978
|
+
if (same.length === 1) {
|
|
979
|
+
kept.push(same[0]);
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
let valid;
|
|
983
|
+
for (const e of same) {
|
|
984
|
+
if (await signatureIsValid({ addr: e.address, msg: e.message, sig: e.signature, scheme: e.scheme })) {
|
|
985
|
+
valid = e;
|
|
986
|
+
break;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
kept.push(valid ?? same[0]);
|
|
990
|
+
}
|
|
991
|
+
return kept;
|
|
992
|
+
}
|
|
612
993
|
function parseAttestationFromEvent(event) {
|
|
613
994
|
try {
|
|
614
995
|
const envelope = JSON.parse(event.content);
|
|
615
996
|
return envelope;
|
|
616
997
|
} catch (err) {
|
|
617
|
-
|
|
998
|
+
log2.error({ eventId: event.id, error: err }, "Failed to parse attestation from event");
|
|
618
999
|
return null;
|
|
619
1000
|
}
|
|
620
1001
|
}
|
|
621
1002
|
|
|
622
1003
|
// src/nostr-crypto.ts
|
|
623
|
-
var
|
|
1004
|
+
var log3 = createLogger("ocp/nostr-crypto");
|
|
624
1005
|
function isNip07Available() {
|
|
625
1006
|
if (typeof window === "undefined") {
|
|
626
1007
|
return false;
|
|
@@ -629,31 +1010,31 @@ function isNip07Available() {
|
|
|
629
1010
|
}
|
|
630
1011
|
async function getNostrPublicKey() {
|
|
631
1012
|
if (!isNip07Available()) {
|
|
632
|
-
|
|
1013
|
+
log3.warn("NIP-07 extension not available");
|
|
633
1014
|
return null;
|
|
634
1015
|
}
|
|
635
1016
|
try {
|
|
636
1017
|
const nostr = window.nostr;
|
|
637
1018
|
const pubkey = await nostr.getPublicKey();
|
|
638
|
-
|
|
1019
|
+
log3.info({ pubkey }, "Got Nostr public key from NIP-07");
|
|
639
1020
|
return pubkey;
|
|
640
1021
|
} catch (err) {
|
|
641
|
-
|
|
1022
|
+
log3.error({ error: err }, "Failed to get Nostr public key");
|
|
642
1023
|
return null;
|
|
643
1024
|
}
|
|
644
1025
|
}
|
|
645
1026
|
async function signNostrEvent(event) {
|
|
646
1027
|
if (!isNip07Available()) {
|
|
647
|
-
|
|
1028
|
+
log3.warn("NIP-07 extension not available");
|
|
648
1029
|
return null;
|
|
649
1030
|
}
|
|
650
1031
|
try {
|
|
651
1032
|
const nostr = window.nostr;
|
|
652
1033
|
const signedEvent = await nostr.signEvent(event);
|
|
653
|
-
|
|
1034
|
+
log3.info({ eventId: signedEvent.id }, "Signed Nostr event with NIP-07");
|
|
654
1035
|
return signedEvent;
|
|
655
1036
|
} catch (err) {
|
|
656
|
-
|
|
1037
|
+
log3.error({ error: err }, "Failed to sign Nostr event");
|
|
657
1038
|
return null;
|
|
658
1039
|
}
|
|
659
1040
|
}
|
|
@@ -670,7 +1051,7 @@ function getNip07Info() {
|
|
|
670
1051
|
}
|
|
671
1052
|
|
|
672
1053
|
// src/attestation.ts
|
|
673
|
-
var
|
|
1054
|
+
var log4 = createLogger("ocp/attestation");
|
|
674
1055
|
var AttestationError = class extends Error {
|
|
675
1056
|
constructor(message, code) {
|
|
676
1057
|
super(message);
|
|
@@ -680,7 +1061,7 @@ var AttestationError = class extends Error {
|
|
|
680
1061
|
};
|
|
681
1062
|
async function createAttestation(options) {
|
|
682
1063
|
const { message, signature, scheme, address, identities = [], extensions = {} } = options;
|
|
683
|
-
|
|
1064
|
+
log4.info({ address, identityCount: identities.length }, "Creating attestation");
|
|
684
1065
|
const envelope = await createAttestationEnvelope(
|
|
685
1066
|
message,
|
|
686
1067
|
signature,
|
|
@@ -691,529 +1072,117 @@ async function createAttestation(options) {
|
|
|
691
1072
|
);
|
|
692
1073
|
const isValid = await verifyAttestationEnvelope(envelope);
|
|
693
1074
|
if (!isValid) {
|
|
694
|
-
throw new AttestationError(
|
|
695
|
-
"Attestation envelope integrity check failed",
|
|
696
|
-
"INTEGRITY_CHECK_FAILED"
|
|
697
|
-
);
|
|
698
|
-
}
|
|
699
|
-
log3.info({ attestationId: envelope.attestation_id }, "Attestation created successfully");
|
|
700
|
-
return envelope;
|
|
701
|
-
}
|
|
702
|
-
async function publishAttestation(options) {
|
|
703
|
-
const { envelope, npub, relays = DEFAULT_RELAYS } = options;
|
|
704
|
-
log3.info(
|
|
705
|
-
{ attestationId: envelope.attestation_id, relayCount: relays.length },
|
|
706
|
-
"Publishing attestation"
|
|
707
|
-
);
|
|
708
|
-
const unsignedEvent = createAttestationEvent(envelope, npub);
|
|
709
|
-
if (!isNip07Available()) {
|
|
710
|
-
log3.warn("NIP-07 unavailable \u2014 skipping Nostr publish");
|
|
711
|
-
throw new Error(
|
|
712
|
-
"nostr_signer_unavailable: install a NIP-07 extension (e.g. nos2x, Alby) to publish"
|
|
713
|
-
);
|
|
714
|
-
}
|
|
715
|
-
log3.info("Using NIP-07 to sign event");
|
|
716
|
-
const signedEvent = await signNostrEvent(unsignedEvent);
|
|
717
|
-
if (!signedEvent) {
|
|
718
|
-
throw new Error("nostr_signing_failed: NIP-07 extension rejected or errored");
|
|
719
|
-
}
|
|
720
|
-
const result = await publishToRelays(signedEvent, relays);
|
|
721
|
-
log3.info(
|
|
722
|
-
{
|
|
723
|
-
attestationId: envelope.attestation_id,
|
|
724
|
-
successCount: result.success.length,
|
|
725
|
-
failedCount: result.failed.length
|
|
726
|
-
},
|
|
727
|
-
"Publishing complete"
|
|
728
|
-
);
|
|
729
|
-
return result;
|
|
730
|
-
}
|
|
731
|
-
function eventIndexesEnvelope(event, envelope) {
|
|
732
|
-
const d = event.tags.find((t) => t[0] === "d")?.[1];
|
|
733
|
-
const id = envelope.attestation_id;
|
|
734
|
-
if (d !== id && d !== `orangecheck:${id}`) return false;
|
|
735
|
-
return event.tags.some((t) => t[0] === "t" && t[1] === envelope.address);
|
|
736
|
-
}
|
|
737
|
-
async function discoverAttestations(options) {
|
|
738
|
-
const { attestationId, address, identity, relays = DEFAULT_RELAYS } = options;
|
|
739
|
-
let events = [];
|
|
740
|
-
if (attestationId) {
|
|
741
|
-
log3.info({ attestationId }, "Discovering by attestation ID");
|
|
742
|
-
events = await queryByAttestationId(attestationId, relays);
|
|
743
|
-
} else if (address) {
|
|
744
|
-
log3.info({ address }, "Discovering by Bitcoin address");
|
|
745
|
-
events = await queryByAddress(address, relays);
|
|
746
|
-
} else if (identity) {
|
|
747
|
-
log3.info({ identity }, "Discovering by identity");
|
|
748
|
-
events = await queryByIdentity(identity.protocol, identity.identifier, relays);
|
|
749
|
-
} else {
|
|
750
|
-
throw new Error("Must provide attestationId, address, or identity");
|
|
751
|
-
}
|
|
752
|
-
const envelopes = [];
|
|
753
|
-
const kept = /* @__PURE__ */ new Set();
|
|
754
|
-
for (const event of events) {
|
|
755
|
-
const envelope = parseAttestationFromEvent(event);
|
|
756
|
-
if (envelope && kept.has(envelope.attestation_id)) continue;
|
|
757
|
-
if (envelope && !eventIndexesEnvelope(event, envelope)) {
|
|
758
|
-
log3.warn({ eventId: event.id }, "Skipping event whose index tags disagree with its envelope");
|
|
759
|
-
} else if (envelope) {
|
|
760
|
-
const isValid = await verifyAttestationEnvelope(envelope);
|
|
761
|
-
if (isValid) {
|
|
762
|
-
kept.add(envelope.attestation_id);
|
|
763
|
-
envelopes.push(envelope);
|
|
764
|
-
} else {
|
|
765
|
-
log3.warn(
|
|
766
|
-
{ attestationId: envelope.attestation_id },
|
|
767
|
-
"Skipping invalid attestation envelope"
|
|
768
|
-
);
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
log3.info({ eventCount: events.length, validCount: envelopes.length }, "Discovery complete");
|
|
773
|
-
return envelopes;
|
|
774
|
-
}
|
|
775
|
-
async function verifyAttestationById(attestationId, relays = DEFAULT_RELAYS) {
|
|
776
|
-
log3.info({ attestationId }, "Verifying attestation by ID");
|
|
777
|
-
const envelopes = await discoverAttestations({ attestationId, relays });
|
|
778
|
-
if (envelopes.length === 0) {
|
|
779
|
-
log3.warn({ attestationId }, "Attestation not found on any relay");
|
|
780
|
-
return null;
|
|
781
|
-
}
|
|
782
|
-
if (envelopes.length > 1) {
|
|
783
|
-
log3.warn(
|
|
784
|
-
{ attestationId, count: envelopes.length },
|
|
785
|
-
"Multiple attestations found with same ID - using first"
|
|
786
|
-
);
|
|
787
|
-
}
|
|
788
|
-
return envelopes[0] || null;
|
|
789
|
-
}
|
|
790
|
-
async function getAttestationsForAddress(address, relays = DEFAULT_RELAYS) {
|
|
791
|
-
log3.info({ address }, "Getting all attestations for address");
|
|
792
|
-
return discoverAttestations({ address, relays });
|
|
793
|
-
}
|
|
794
|
-
async function getAttestationsForIdentity(protocol, identifier, relays = DEFAULT_RELAYS) {
|
|
795
|
-
log3.info({ protocol, identifier }, "Getting all attestations for identity");
|
|
796
|
-
return discoverAttestations({ identity: { protocol, identifier }, relays });
|
|
797
|
-
}
|
|
798
|
-
function formatIdentitiesForDisplay(identities) {
|
|
799
|
-
return identities.map((id) => `${id.protocol}:${id.identifier}`).join(", ");
|
|
800
|
-
}
|
|
801
|
-
function getVerificationUrl(attestationId, baseUrl = "https://attest.ochk.io") {
|
|
802
|
-
return `${baseUrl}/attest/${attestationId}`;
|
|
803
|
-
}
|
|
804
|
-
function extractAttestationIdFromUrl(url) {
|
|
805
|
-
try {
|
|
806
|
-
const u = new URL(url);
|
|
807
|
-
const m = u.pathname.match(/^\/attest\/([0-9a-f]{64})\/?$/i);
|
|
808
|
-
if (m) return m[1].toLowerCase();
|
|
809
|
-
const q = u.searchParams.get("id");
|
|
810
|
-
if (q && /^[0-9a-f]{64}$/i.test(q)) return q.toLowerCase();
|
|
811
|
-
return null;
|
|
812
|
-
} catch {
|
|
813
|
-
return null;
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
// src/scoring.ts
|
|
818
|
-
function scoreV0(sats, days) {
|
|
819
|
-
if (sats <= 0 || days <= 0) return 0;
|
|
820
|
-
return Math.round(Math.log(1 + sats) * (1 + days / 30) * 100) / 100;
|
|
821
|
-
}
|
|
822
|
-
function scoreTier(sats, days) {
|
|
823
|
-
if (sats >= 1e7 && days >= 365) return "platinum";
|
|
824
|
-
if (sats >= 1e6 && days >= 180) return "gold";
|
|
825
|
-
if (sats >= 1e5 && days >= 90) return "silver";
|
|
826
|
-
if (sats >= 1e4 && days >= 30) return "bronze";
|
|
827
|
-
return "none";
|
|
828
|
-
}
|
|
829
|
-
function computeScore(sats, days, config) {
|
|
830
|
-
switch (config.algorithm) {
|
|
831
|
-
case "v0":
|
|
832
|
-
return scoreV0(sats, days);
|
|
833
|
-
case "tier":
|
|
834
|
-
return scoreTier(sats, days);
|
|
835
|
-
case "none":
|
|
836
|
-
return null;
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
function computeAllScores(sats, days) {
|
|
840
|
-
return { v0: scoreV0(sats, days), tier: scoreTier(sats, days) };
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
// src/verify.ts
|
|
844
|
-
var log4 = createLogger("ocp/verify");
|
|
845
|
-
function looksLikeHex(s) {
|
|
846
|
-
return /^[0-9a-fA-F]+$/.test(s) && s.length % 2 === 0;
|
|
847
|
-
}
|
|
848
|
-
function toBase64Maybe(sig) {
|
|
849
|
-
if (looksLikeHex(sig)) {
|
|
850
|
-
return buffer.Buffer.from(sig, "hex").toString("base64");
|
|
851
|
-
}
|
|
852
|
-
return sig;
|
|
853
|
-
}
|
|
854
|
-
function detectSignatureScheme(addr, sig) {
|
|
855
|
-
const isSegWit = addr.startsWith("bc1") || addr.startsWith("tb1");
|
|
856
|
-
if (isSegWit) {
|
|
857
|
-
return "bip322";
|
|
858
|
-
}
|
|
859
|
-
const sigBase64 = toBase64Maybe(sig);
|
|
860
|
-
const looksLikeLegacy = sigBase64.length === 88 && /^[IHG]/.test(sigBase64);
|
|
861
|
-
if (looksLikeLegacy) {
|
|
862
|
-
return "legacy";
|
|
863
|
-
}
|
|
864
|
-
return "bip322";
|
|
865
|
-
}
|
|
866
|
-
async function verifySignature({
|
|
867
|
-
addr,
|
|
868
|
-
msg,
|
|
869
|
-
sig,
|
|
870
|
-
scheme
|
|
871
|
-
}) {
|
|
872
|
-
const detectedScheme = scheme || detectSignatureScheme(addr, sig);
|
|
873
|
-
log4.debug(
|
|
874
|
-
{ addr, msgLength: msg.length, sigLength: sig.length, scheme: detectedScheme },
|
|
875
|
-
"Verifying signature"
|
|
876
|
-
);
|
|
877
|
-
if (detectedScheme === "bip322") {
|
|
878
|
-
const sigBase64 = toBase64Maybe(sig);
|
|
879
|
-
log4.debug(
|
|
880
|
-
{ sigBase64: sigBase64.substring(0, 50) + "..." },
|
|
881
|
-
"Signature converted to base64"
|
|
882
|
-
);
|
|
883
|
-
log4.debug(
|
|
884
|
-
{
|
|
885
|
-
msgFull: msg,
|
|
886
|
-
msgLines: msg.split("\n").length,
|
|
887
|
-
msgFirstLine: msg.split("\n")[0],
|
|
888
|
-
msgLastLine: msg.split("\n")[msg.split("\n").length - 1]
|
|
889
|
-
},
|
|
890
|
-
"Message being verified"
|
|
891
|
-
);
|
|
892
|
-
const ok = bip322.verifySignature(addr, msg, sigBase64);
|
|
893
|
-
log4.info({ addr, ok }, "BIP-322 verification result");
|
|
894
|
-
if (!ok) {
|
|
895
|
-
const isSegWit = addr.startsWith("bc1") || addr.startsWith("tb1");
|
|
896
|
-
if (isSegWit && sigBase64.length === 88) {
|
|
897
|
-
log4.warn(
|
|
898
|
-
{ addr, wallet: "possibly Electrum", sigLength: sigBase64.length },
|
|
899
|
-
"BIP-322 verification failed - likely Electrum wallet (does not support BIP-322 for SegWit)"
|
|
900
|
-
);
|
|
901
|
-
} else {
|
|
902
|
-
log4.error(
|
|
903
|
-
{
|
|
904
|
-
addr,
|
|
905
|
-
sigLength: sigBase64.length,
|
|
906
|
-
msgLength: msg.length,
|
|
907
|
-
msgPreview: msg.substring(0, 200)
|
|
908
|
-
},
|
|
909
|
-
"BIP-322 verification failed - signature does not match message"
|
|
910
|
-
);
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
return ok ? "sig_ok_bip322" : "sig_invalid";
|
|
914
|
-
}
|
|
915
|
-
if (detectedScheme === "legacy") {
|
|
916
|
-
const sigBase64 = toBase64Maybe(sig);
|
|
917
|
-
if (!addr.startsWith("1") && !addr.startsWith("m") && !addr.startsWith("n")) {
|
|
918
|
-
log4.warn({ addr }, "Legacy signature attempted on non-P2PKH address");
|
|
919
|
-
return "sig_unsupported_script";
|
|
920
|
-
}
|
|
921
|
-
log4.debug(
|
|
922
|
-
{
|
|
923
|
-
addr,
|
|
924
|
-
sigBase64: sigBase64.substring(0, 50) + "...",
|
|
925
|
-
msgPreview: msg.substring(0, 100) + "...",
|
|
926
|
-
msgLength: msg.length
|
|
927
|
-
},
|
|
928
|
-
"Verifying legacy signature"
|
|
929
|
-
);
|
|
930
|
-
const ok = bip322.verifySignature(addr, msg, sigBase64);
|
|
931
|
-
log4.info({ addr, ok, sigBase64Length: sigBase64.length }, "Legacy verification result");
|
|
932
|
-
if (!ok) {
|
|
933
|
-
log4.error(
|
|
934
|
-
{
|
|
935
|
-
addr,
|
|
936
|
-
msgFirstLine: msg.split("\n")[0],
|
|
937
|
-
sigFormat: sigBase64.substring(0, 10)
|
|
938
|
-
},
|
|
939
|
-
"Legacy signature verification failed - check message format and signature"
|
|
940
|
-
);
|
|
941
|
-
}
|
|
942
|
-
return ok ? "sig_ok_legacy" : "sig_invalid";
|
|
943
|
-
}
|
|
944
|
-
return "invalid_scheme";
|
|
945
|
-
}
|
|
946
|
-
function parseCanonicalMessage(msg) {
|
|
947
|
-
const lines = msg.split("\n").map((l) => l.trim());
|
|
948
|
-
const getVal = (prefix) => {
|
|
949
|
-
const needle = prefix + ":";
|
|
950
|
-
const hit = lines.find((l) => l.toLowerCase().startsWith(needle));
|
|
951
|
-
if (!hit) return void 0;
|
|
952
|
-
const idx = hit.indexOf(":");
|
|
953
|
-
return hit.slice(idx + 1).trim();
|
|
954
|
-
};
|
|
955
|
-
const address = getVal("address") || "";
|
|
956
|
-
const identitiesStr = getVal("identities") || "";
|
|
957
|
-
let identities = [];
|
|
958
|
-
try {
|
|
959
|
-
identities = parseIdentities(identitiesStr);
|
|
960
|
-
} catch (err) {
|
|
961
|
-
log4.warn({ identitiesStr, error: err }, "Failed to parse identities field");
|
|
962
|
-
}
|
|
963
|
-
const coreKeys = /* @__PURE__ */ new Set([
|
|
964
|
-
"orangecheck",
|
|
965
|
-
"identities",
|
|
966
|
-
"address",
|
|
967
|
-
"purpose",
|
|
968
|
-
"nonce",
|
|
969
|
-
"issued_at",
|
|
970
|
-
"ack"
|
|
971
|
-
]);
|
|
972
|
-
const extensions = {};
|
|
973
|
-
for (const line of lines) {
|
|
974
|
-
const idx = line.indexOf(":");
|
|
975
|
-
if (idx === -1) continue;
|
|
976
|
-
const key = line.slice(0, idx).trim().toLowerCase();
|
|
977
|
-
const val = line.slice(idx + 1).trim();
|
|
978
|
-
if (!coreKeys.has(key) && val) extensions[key] = val;
|
|
979
|
-
}
|
|
980
|
-
const network = extensions["network"] || "mainnet";
|
|
981
|
-
const bondStr = extensions["bond"];
|
|
982
|
-
const bond = bondStr ? parseInt(bondStr, 10) : void 0;
|
|
983
|
-
return {
|
|
984
|
-
core: { address, identities },
|
|
985
|
-
network,
|
|
986
|
-
extensions,
|
|
987
|
-
bond: bond !== void 0 && !isNaN(bond) && bond > 0 ? bond : void 0
|
|
988
|
-
};
|
|
989
|
-
}
|
|
990
|
-
async function getAddressUtxos(address, network, opts) {
|
|
991
|
-
const endpoints = network === "signet" ? [
|
|
992
|
-
opts?.esploraSignetBase || "https://mempool.space/signet/api",
|
|
993
|
-
"https://blockstream.info/signet/api"
|
|
994
|
-
] : network === "testnet" ? [
|
|
995
|
-
opts?.esploraTestnetBase || "https://mempool.space/testnet/api",
|
|
996
|
-
"https://blockstream.info/testnet/api"
|
|
997
|
-
] : [
|
|
998
|
-
opts?.esploraMainnetBase || "https://mempool.space/api",
|
|
999
|
-
"https://blockstream.info/api"
|
|
1000
|
-
];
|
|
1001
|
-
let lastError = null;
|
|
1002
|
-
for (const base of endpoints) {
|
|
1003
|
-
try {
|
|
1004
|
-
const url = `${base}/address/${address}/utxo`;
|
|
1005
|
-
log4.debug({ url, network }, "Fetching UTXOs");
|
|
1006
|
-
const resp = await fetch(url, {
|
|
1007
|
-
method: "GET",
|
|
1008
|
-
headers: { Accept: "application/json" },
|
|
1009
|
-
signal: AbortSignal.timeout(5e3)
|
|
1010
|
-
});
|
|
1011
|
-
if (!resp.ok) {
|
|
1012
|
-
throw new Error(`HTTP ${resp.status}`);
|
|
1013
|
-
}
|
|
1014
|
-
const data = await resp.json();
|
|
1015
|
-
if (!Array.isArray(data)) {
|
|
1016
|
-
throw new Error("Invalid response: expected array");
|
|
1017
|
-
}
|
|
1018
|
-
for (const utxo of data) {
|
|
1019
|
-
if (typeof utxo.value !== "number" || typeof utxo.status !== "object" || typeof utxo.status.confirmed !== "boolean" || typeof utxo.txid !== "string" || typeof utxo.vout !== "number") {
|
|
1020
|
-
throw new Error("Invalid UTXO structure");
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
log4.info({ base, count: data.length, network }, "UTXOs fetched successfully");
|
|
1024
|
-
return data;
|
|
1025
|
-
} catch (err) {
|
|
1026
|
-
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1027
|
-
log4.warn(
|
|
1028
|
-
{ base, error: lastError.message, network },
|
|
1029
|
-
"Esplora endpoint failed, trying next"
|
|
1030
|
-
);
|
|
1031
|
-
}
|
|
1075
|
+
throw new AttestationError(
|
|
1076
|
+
"Attestation envelope integrity check failed",
|
|
1077
|
+
"INTEGRITY_CHECK_FAILED"
|
|
1078
|
+
);
|
|
1032
1079
|
}
|
|
1033
|
-
log4.
|
|
1034
|
-
|
|
1080
|
+
log4.info({ attestationId: envelope.attestation_id }, "Attestation created successfully");
|
|
1081
|
+
return envelope;
|
|
1035
1082
|
}
|
|
1036
|
-
function
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
if (a.txid !== b.txid) return a.txid.localeCompare(b.txid);
|
|
1048
|
-
return a.vout - b.vout;
|
|
1049
|
-
});
|
|
1050
|
-
let sum = 0;
|
|
1051
|
-
let youngestTime = 0;
|
|
1052
|
-
for (const utxo of sorted) {
|
|
1053
|
-
sum += utxo.value;
|
|
1054
|
-
youngestTime = Math.max(youngestTime, utxo.status.block_time || 0);
|
|
1055
|
-
if (sum >= bondAmount) break;
|
|
1056
|
-
}
|
|
1057
|
-
const days_unspent2 = Math.max(
|
|
1058
|
-
0,
|
|
1059
|
-
Math.floor((Date.now() / 1e3 - youngestTime) / (24 * 60 * 60))
|
|
1083
|
+
async function publishAttestation(options) {
|
|
1084
|
+
const { envelope, npub, relays = DEFAULT_RELAYS } = options;
|
|
1085
|
+
log4.info(
|
|
1086
|
+
{ attestationId: envelope.attestation_id, relayCount: relays.length },
|
|
1087
|
+
"Publishing attestation"
|
|
1088
|
+
);
|
|
1089
|
+
const unsignedEvent = createAttestationEvent(envelope, npub);
|
|
1090
|
+
if (!isNip07Available()) {
|
|
1091
|
+
log4.warn("NIP-07 unavailable \u2014 skipping Nostr publish");
|
|
1092
|
+
throw new Error(
|
|
1093
|
+
"nostr_signer_unavailable: install a NIP-07 extension (e.g. nos2x, Alby) to publish"
|
|
1060
1094
|
);
|
|
1061
|
-
return {
|
|
1062
|
-
sats_bonded: bondAmount,
|
|
1063
|
-
// Use exactly bond amount (ignore surplus)
|
|
1064
|
-
days_unspent: days_unspent2,
|
|
1065
|
-
confirmed_balance
|
|
1066
|
-
};
|
|
1067
1095
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
);
|
|
1073
|
-
let sum = 0;
|
|
1074
|
-
let youngestTime = 0;
|
|
1075
|
-
for (const utxo of sorted) {
|
|
1076
|
-
sum += utxo.value;
|
|
1077
|
-
youngestTime = Math.max(youngestTime, utxo.status.block_time || Date.now() / 1e3);
|
|
1078
|
-
if (sum >= coverSats) break;
|
|
1079
|
-
}
|
|
1080
|
-
const days_unspent2 = Math.max(
|
|
1081
|
-
0,
|
|
1082
|
-
Math.floor((Date.now() / 1e3 - youngestTime) / (24 * 60 * 60))
|
|
1083
|
-
);
|
|
1084
|
-
return { sats_bonded, days_unspent: days_unspent2, confirmed_balance };
|
|
1096
|
+
log4.info("Using NIP-07 to sign event");
|
|
1097
|
+
const signedEvent = await signNostrEvent(unsignedEvent);
|
|
1098
|
+
if (!signedEvent) {
|
|
1099
|
+
throw new Error("nostr_signing_failed: NIP-07 extension rejected or errored");
|
|
1085
1100
|
}
|
|
1086
|
-
const
|
|
1087
|
-
|
|
1101
|
+
const result = await publishToRelays(signedEvent, relays);
|
|
1102
|
+
log4.info(
|
|
1103
|
+
{
|
|
1104
|
+
attestationId: envelope.attestation_id,
|
|
1105
|
+
successCount: result.success.length,
|
|
1106
|
+
failedCount: result.failed.length
|
|
1107
|
+
},
|
|
1108
|
+
"Publishing complete"
|
|
1088
1109
|
);
|
|
1089
|
-
|
|
1090
|
-
return { sats_bonded, days_unspent, confirmed_balance };
|
|
1110
|
+
return result;
|
|
1091
1111
|
}
|
|
1092
|
-
|
|
1093
|
-
const
|
|
1094
|
-
|
|
1112
|
+
function eventIndexesEnvelope(event, envelope) {
|
|
1113
|
+
const d = event.tags.find((t) => t[0] === "d")?.[1];
|
|
1114
|
+
const id = envelope.attestation_id;
|
|
1115
|
+
if (d !== id && d !== `orangecheck:${id}`) return false;
|
|
1116
|
+
return event.tags.some((t) => t[0] === "t" && t[1] === envelope.address);
|
|
1117
|
+
}
|
|
1118
|
+
async function discoverAttestations(options) {
|
|
1119
|
+
const { attestationId, address, identity, relays = DEFAULT_RELAYS } = options;
|
|
1120
|
+
let events = [];
|
|
1121
|
+
if (attestationId) {
|
|
1122
|
+
log4.info({ attestationId }, "Discovering by attestation ID");
|
|
1123
|
+
events = await queryByAttestationId(attestationId, relays);
|
|
1124
|
+
} else if (address) {
|
|
1125
|
+
log4.info({ address }, "Discovering by Bitcoin address");
|
|
1126
|
+
events = await queryByAddress(address, relays);
|
|
1127
|
+
} else if (identity) {
|
|
1128
|
+
log4.info({ identity }, "Discovering by identity");
|
|
1129
|
+
events = await queryByIdentity(identity.protocol, identity.identifier, relays);
|
|
1130
|
+
} else {
|
|
1131
|
+
throw new Error("Must provide attestationId, address, or identity");
|
|
1132
|
+
}
|
|
1133
|
+
const envelopes = await collectSignedEnvelopes(events, (event, envelope) => {
|
|
1134
|
+
if (eventIndexesEnvelope(event, envelope)) return true;
|
|
1135
|
+
log4.warn({ eventId: event.id }, "Skipping event whose index tags disagree with its envelope");
|
|
1136
|
+
return false;
|
|
1137
|
+
});
|
|
1138
|
+
log4.info({ eventCount: events.length, validCount: envelopes.length }, "Discovery complete");
|
|
1139
|
+
return envelopes;
|
|
1140
|
+
}
|
|
1141
|
+
async function verifyAttestationById(attestationId, relays = DEFAULT_RELAYS) {
|
|
1142
|
+
log4.info({ attestationId }, "Verifying attestation by ID");
|
|
1143
|
+
const envelopes = await discoverAttestations({ attestationId, relays });
|
|
1144
|
+
if (envelopes.length === 0) {
|
|
1145
|
+
log4.warn({ attestationId }, "Attestation not found on any relay");
|
|
1146
|
+
return null;
|
|
1147
|
+
}
|
|
1148
|
+
if (envelopes.length > 1) {
|
|
1149
|
+
log4.warn(
|
|
1150
|
+
{ attestationId, count: envelopes.length },
|
|
1151
|
+
"Multiple attestations found with same ID - using first"
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
return envelopes[0] || null;
|
|
1155
|
+
}
|
|
1156
|
+
async function getAttestationsForAddress(address, relays = DEFAULT_RELAYS) {
|
|
1157
|
+
log4.info({ address }, "Getting all attestations for address");
|
|
1158
|
+
return discoverAttestations({ address, relays });
|
|
1159
|
+
}
|
|
1160
|
+
async function getAttestationsForIdentity(protocol, identifier, relays = DEFAULT_RELAYS) {
|
|
1161
|
+
log4.info({ protocol, identifier }, "Getting all attestations for identity");
|
|
1162
|
+
return discoverAttestations({ identity: { protocol, identifier }, relays });
|
|
1163
|
+
}
|
|
1164
|
+
function formatIdentitiesForDisplay(identities) {
|
|
1165
|
+
return identities.map((id) => `${id.protocol}:${id.identifier}`).join(", ");
|
|
1166
|
+
}
|
|
1167
|
+
function getVerificationUrl(attestationId, baseUrl = "https://attest.ochk.io") {
|
|
1168
|
+
return `${baseUrl}/attest/${attestationId}`;
|
|
1169
|
+
}
|
|
1170
|
+
function extractAttestationIdFromUrl(url) {
|
|
1095
1171
|
try {
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
codes.push("bad_request");
|
|
1105
|
-
return { ok: false, codes, network, attestation_id };
|
|
1106
|
-
}
|
|
1107
|
-
if ((network === "testnet" || network === "signet") && !opts.testMode) {
|
|
1108
|
-
codes.push("network_testmode");
|
|
1109
|
-
return { ok: false, codes, network, attestation_id };
|
|
1110
|
-
}
|
|
1111
|
-
if (opts.expectedAud) {
|
|
1112
|
-
const aud = parsed.extensions["aud"];
|
|
1113
|
-
if (!aud || aud !== opts.expectedAud) {
|
|
1114
|
-
codes.push("aud_mismatch");
|
|
1115
|
-
return { ok: false, codes, network, attestation_id };
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
if (parsed.extensions["expires"]) {
|
|
1119
|
-
const exp = Date.parse(parsed.extensions["expires"]);
|
|
1120
|
-
if (!Number.isNaN(exp) && exp < Date.now()) {
|
|
1121
|
-
codes.push("expired");
|
|
1122
|
-
return { ok: false, codes, network, attestation_id };
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
const sigCode = await verifySignature({
|
|
1126
|
-
addr: input.addr,
|
|
1127
|
-
msg: input.msg,
|
|
1128
|
-
sig: input.sig,
|
|
1129
|
-
...input.scheme && { scheme: input.scheme }
|
|
1130
|
-
});
|
|
1131
|
-
codes.push(sigCode);
|
|
1132
|
-
const sigOk = sigCode === "sig_ok_bip322" || sigCode === "sig_ok_legacy";
|
|
1133
|
-
if (!sigOk) {
|
|
1134
|
-
return {
|
|
1135
|
-
ok: false,
|
|
1136
|
-
codes,
|
|
1137
|
-
network,
|
|
1138
|
-
attestation_id,
|
|
1139
|
-
identities: parsed.core.identities
|
|
1140
|
-
};
|
|
1141
|
-
}
|
|
1142
|
-
try {
|
|
1143
|
-
const utxos = await getAddressUtxos(input.addr, network, {
|
|
1144
|
-
esploraMainnetBase: opts.esploraMainnetBase,
|
|
1145
|
-
esploraSignetBase: opts.esploraSignetBase
|
|
1146
|
-
});
|
|
1147
|
-
const { sats_bonded, days_unspent, confirmed_balance } = computeMetrics(
|
|
1148
|
-
utxos,
|
|
1149
|
-
parsed.bond,
|
|
1150
|
-
opts.coverSats
|
|
1151
|
-
);
|
|
1152
|
-
if (parsed.bond !== void 0 && parsed.bond > 0) {
|
|
1153
|
-
if (confirmed_balance < parsed.bond) {
|
|
1154
|
-
codes.push("bond_insufficient");
|
|
1155
|
-
log4.warn(
|
|
1156
|
-
{
|
|
1157
|
-
bond: parsed.bond,
|
|
1158
|
-
confirmed_balance,
|
|
1159
|
-
address: input.addr
|
|
1160
|
-
},
|
|
1161
|
-
"Bond extension: insufficient balance"
|
|
1162
|
-
);
|
|
1163
|
-
return {
|
|
1164
|
-
ok: false,
|
|
1165
|
-
codes,
|
|
1166
|
-
network,
|
|
1167
|
-
attestation_id,
|
|
1168
|
-
identities: parsed.core.identities
|
|
1169
|
-
};
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
if (sats_bonded <= 0) {
|
|
1173
|
-
codes.push("bond_zero");
|
|
1174
|
-
if (utxos.some((u) => !u.status.confirmed)) {
|
|
1175
|
-
codes.push("bond_pending");
|
|
1176
|
-
}
|
|
1177
|
-
return {
|
|
1178
|
-
ok: false,
|
|
1179
|
-
codes,
|
|
1180
|
-
network,
|
|
1181
|
-
attestation_id,
|
|
1182
|
-
identities: parsed.core.identities
|
|
1183
|
-
};
|
|
1184
|
-
} else {
|
|
1185
|
-
codes.push("bond_confirmed");
|
|
1186
|
-
}
|
|
1187
|
-
const metrics = {
|
|
1188
|
-
sats_bonded,
|
|
1189
|
-
days_unspent,
|
|
1190
|
-
score: computeScore(sats_bonded, days_unspent, { algorithm: "v0" })
|
|
1191
|
-
};
|
|
1192
|
-
return {
|
|
1193
|
-
ok: true,
|
|
1194
|
-
codes,
|
|
1195
|
-
network,
|
|
1196
|
-
attestation_id,
|
|
1197
|
-
identities: parsed.core.identities,
|
|
1198
|
-
metrics
|
|
1199
|
-
};
|
|
1200
|
-
} catch (utxoError) {
|
|
1201
|
-
log4.error({ err: utxoError }, "UTXO fetch failed");
|
|
1202
|
-
codes.push("bad_request");
|
|
1203
|
-
return { ok: false, codes, network, attestation_id };
|
|
1204
|
-
}
|
|
1205
|
-
} catch (e) {
|
|
1206
|
-
const msg = e instanceof Error ? e.message : String(e ?? "decode_error");
|
|
1207
|
-
if (msg === "decode_error") {
|
|
1208
|
-
codes.push("decode_error");
|
|
1209
|
-
} else {
|
|
1210
|
-
codes.push("bad_request");
|
|
1211
|
-
}
|
|
1212
|
-
return { ok: false, codes, network };
|
|
1172
|
+
const u = new URL(url);
|
|
1173
|
+
const m = u.pathname.match(/^\/attest\/([0-9a-f]{64})\/?$/i);
|
|
1174
|
+
if (m) return m[1].toLowerCase();
|
|
1175
|
+
const q = u.searchParams.get("id");
|
|
1176
|
+
if (q && /^[0-9a-f]{64}$/i.test(q)) return q.toLowerCase();
|
|
1177
|
+
return null;
|
|
1178
|
+
} catch {
|
|
1179
|
+
return null;
|
|
1213
1180
|
}
|
|
1214
1181
|
}
|
|
1215
1182
|
|
|
1216
1183
|
// src/check.ts
|
|
1184
|
+
var ISSUED_AHEAD_MS = 12e4;
|
|
1185
|
+
var MAX_CANDIDATES = 20;
|
|
1217
1186
|
async function check(params) {
|
|
1218
1187
|
const {
|
|
1219
1188
|
addr,
|
|
@@ -1235,14 +1204,17 @@ async function check(params) {
|
|
|
1235
1204
|
} else if (identity) {
|
|
1236
1205
|
found = await getAttestationsForIdentity(identity.protocol, identity.identifier, relays);
|
|
1237
1206
|
}
|
|
1238
|
-
const
|
|
1207
|
+
const latest = Date.now() + ISSUED_AHEAD_MS;
|
|
1208
|
+
const envelopes = found.map(signedEnvelope).filter((e) => e !== null).filter((e) => signedMatch(e, { addr, id, identity })).map((e) => ({ e, at: rfc3339Ms(e.issued_at) })).filter(({ at }) => at <= latest).sort((a, b) => b.at - a.at || (a.e.attestation_id < b.e.attestation_id ? -1 : 1)).map(({ e }) => e);
|
|
1239
1209
|
if (envelopes.length === 0) {
|
|
1240
1210
|
return { ok: false, sats: 0, days: 0, score: 0, reasons: ["not_found"] };
|
|
1241
1211
|
}
|
|
1242
|
-
|
|
1243
|
-
const
|
|
1244
|
-
|
|
1245
|
-
|
|
1212
|
+
let env = envelopes[0];
|
|
1213
|
+
for (const candidate of envelopes.slice(0, MAX_CANDIDATES)) {
|
|
1214
|
+
if (await signedBy(candidate)) {
|
|
1215
|
+
env = candidate;
|
|
1216
|
+
break;
|
|
1217
|
+
}
|
|
1246
1218
|
}
|
|
1247
1219
|
const shared = identity ? await backsOtherIdentity(env, identity, relays) : false;
|
|
1248
1220
|
const outcome = await verify(
|
|
@@ -1270,7 +1242,7 @@ async function check(params) {
|
|
|
1270
1242
|
score,
|
|
1271
1243
|
attestation_id: env.attestation_id,
|
|
1272
1244
|
address: env.address,
|
|
1273
|
-
identities: env
|
|
1245
|
+
identities: signedIdentities(env),
|
|
1274
1246
|
network: outcome.network,
|
|
1275
1247
|
...reasons.length ? { reasons } : {}
|
|
1276
1248
|
};
|
|
@@ -1306,16 +1278,19 @@ function signedMatch(env, q) {
|
|
|
1306
1278
|
}
|
|
1307
1279
|
return false;
|
|
1308
1280
|
}
|
|
1281
|
+
function signedBy(e) {
|
|
1282
|
+
return signatureIsValid({ addr: e.address, msg: e.message, sig: e.signature, scheme: e.scheme });
|
|
1283
|
+
}
|
|
1309
1284
|
async function backsOtherIdentity(env, identity, relays) {
|
|
1310
1285
|
const want = identityKey(identity);
|
|
1311
1286
|
const protocol = identity.protocol.trim().toLowerCase();
|
|
1312
1287
|
const siblings = await getAttestationsForAddress(env.address, relays);
|
|
1313
1288
|
for (const e of [env, ...siblings]) {
|
|
1314
1289
|
if (!signedMatch(e, { addr: env.address })) continue;
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1290
|
+
const other = signedIdentities(e).some(
|
|
1291
|
+
(b) => b.protocol.trim().toLowerCase() === protocol && identityKey(b) !== want
|
|
1292
|
+
);
|
|
1293
|
+
if (other && (e === env || await signedBy(e))) return true;
|
|
1319
1294
|
}
|
|
1320
1295
|
return false;
|
|
1321
1296
|
}
|
|
@@ -1432,24 +1407,17 @@ async function verifyChallenge(opts) {
|
|
|
1432
1407
|
if (opts.expectedPurpose && purpose !== opts.expectedPurpose) {
|
|
1433
1408
|
return { ok: false, reason: "purpose_mismatch" };
|
|
1434
1409
|
}
|
|
1435
|
-
const
|
|
1436
|
-
|
|
1437
|
-
if (scheme
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
} else if (scheme === "legacy") {
|
|
1441
|
-
if (!/^[1m][a-km-zA-HJ-NP-Z1-9]{25,34}$/.test(address)) {
|
|
1442
|
-
return { ok: false, reason: "sig_unsupported_scheme" };
|
|
1443
|
-
}
|
|
1444
|
-
sigValid = bip322.verifySignature(address, opts.message, opts.signature);
|
|
1445
|
-
} else {
|
|
1446
|
-
return { ok: false, reason: "sig_unsupported_scheme" };
|
|
1410
|
+
const sigBase64 = toBase64Maybe(opts.signature);
|
|
1411
|
+
const scheme = signatureScheme(sigBase64);
|
|
1412
|
+
if (!scheme) return { ok: false, reason: "sig_unsupported_scheme" };
|
|
1413
|
+
if (!bip322.verifySignature(address, opts.message, sigBase64)) {
|
|
1414
|
+
return { ok: false, reason: "sig_invalid" };
|
|
1447
1415
|
}
|
|
1448
|
-
if (!sigValid) return { ok: false, reason: "sig_invalid" };
|
|
1449
1416
|
return {
|
|
1450
1417
|
ok: true,
|
|
1451
1418
|
reason: "ok",
|
|
1452
1419
|
address,
|
|
1420
|
+
scheme,
|
|
1453
1421
|
expiresAt: expiresAt.getTime(),
|
|
1454
1422
|
nonce,
|
|
1455
1423
|
...audience !== void 0 ? { audience } : {},
|
|
@@ -2355,12 +2323,12 @@ var STATUS_META = {
|
|
|
2355
2323
|
},
|
|
2356
2324
|
sig_invalid: {
|
|
2357
2325
|
label: "Signature verification failed",
|
|
2358
|
-
detail: "The signature does not match the message and address. Common issues: (1)
|
|
2326
|
+
detail: "The signature does not match the message and address. Common issues: (1) the message was not copied exactly, (2) a different address signed it, (3) the wallet signed on another network.",
|
|
2359
2327
|
severity: "error"
|
|
2360
2328
|
},
|
|
2361
2329
|
sig_unsupported_script: {
|
|
2362
|
-
label: "
|
|
2363
|
-
detail:
|
|
2330
|
+
label: "Signature form not supported",
|
|
2331
|
+
detail: "Your wallet made a BIP-322 full or proof-of-funds signature. Sign with a BIP-322 simple signature, or with legacy message signing, which every address type here accepts.",
|
|
2364
2332
|
severity: "error"
|
|
2365
2333
|
},
|
|
2366
2334
|
bond_confirmed: {
|
|
@@ -2485,6 +2453,8 @@ exports.queryByIdentity = queryByIdentity;
|
|
|
2485
2453
|
exports.queryRecent = queryRecent;
|
|
2486
2454
|
exports.requiresManualVerification = requiresManualVerification;
|
|
2487
2455
|
exports.signNostrEvent = signNostrEvent;
|
|
2456
|
+
exports.signatureIsValid = signatureIsValid;
|
|
2457
|
+
exports.signedEnvelope = signedEnvelope;
|
|
2488
2458
|
exports.supportsAutomaticVerification = supportsAutomaticVerification;
|
|
2489
2459
|
exports.verify = verify;
|
|
2490
2460
|
exports.verifyAllIdentities = verifyAllIdentities;
|