diff --git a/service/listings.js b/service/listings.js index 9251180..d1f9bad 100644 --- a/service/listings.js +++ b/service/listings.js @@ -49,7 +49,14 @@ const CATEGORIES = new Set([ const MAX_BODY_BYTES = 1024 * 1024; // 1 MiB — listings are small JSON const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$|^[a-z0-9]$/; -const BNS_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}\.[a-z0-9]{1,20}$/; +// A publisher is EITHER a BNS name (foo.x) OR a raw cashaddress (bitcoincash:q…). +// Address-only publishers skip the on-chain name lookup — the signer_address +// IS the identity, with no name claim to verify. Readers' trust lists can +// still subscribe to the address; they just lose the mnemonic benefit of a name. +const BNS_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}\.[a-z0-9]{1,20}$/; +const CASHADDR_RE = /^(bitcoincash|bchtest):q[a-z0-9]{41}$/; +const PUBLISHER_RE = new RegExp(`(?:${BNS_NAME_RE.source.slice(1,-1)})|(?:${CASHADDR_RE.source.slice(1,-1)})`); +function isCashaddr(s) { return CASHADDR_RE.test(String(s)); } /* ====================================================================== POST /api/listing @@ -82,19 +89,34 @@ export async function handleListingCreate(req, res) { // field except itself, so changing any value invalidates the signature. const message = buildSigningMessage(body); - // Resolve the publisher's claimed BNS name to its current on-chain owner. - // This is the trust claim — recovery alone is never enough; we must know - // *who* was supposed to sign before we accept a signature as proof. + // Two paths for the "who signed this" check: + // + // - BNS name: look up the current on-chain owner, verify the signature + // recovers to that address. The address can change (name transfer); the + // signature's validity is anchored to who owns the name RIGHT NOW. + // + // - Cashaddress: skip the lookup entirely. The address IS the identity, + // and the signature just has to recover to it. The publisher trades the + // mnemonic benefit of a name for not needing one. + // + // Either way, recovery alone is never enough — we always compare the + // recovered address against a claimed one, per [[recovery-is-not-verification]]. const name = String(body.publisher).toLowerCase(); - const nameInfo = await lookupName(name); - if (!nameInfo.ok) { - return json(res, 400, { ok: false, reason: `publisher '${name}': ${nameInfo.reason}` }); + let expectedAddress; + if (isCashaddr(name)) { + expectedAddress = name; + } else { + const nameInfo = await lookupName(name); + if (!nameInfo.ok) { + return json(res, 400, { ok: false, reason: `publisher '${name}': ${nameInfo.reason}` }); + } + expectedAddress = nameInfo.owner; } const verify = verifyControl({ message, signature: body.signature, - expectedAddress: nameInfo.owner, + expectedAddress, prefix: DEFAULT_PREFIX, }); if (!verify.ok) { @@ -102,7 +124,7 @@ export async function handleListingCreate(req, res) { ok: false, reason: `signature verification failed: ${verify.reason}`, ...(verify.signedBy ? { signed_by: verify.signedBy } : {}), - claimed_owner: nameInfo.owner, + claimed_owner: expectedAddress, }); } @@ -198,8 +220,11 @@ function validate(body) { if (!body || typeof body !== 'object') { return { ok: false, reason: 'body must be a JSON object' }; } - if (!BNS_NAME_RE.test(String(body.publisher || '').toLowerCase())) { - return { ok: false, reason: '`publisher` must be a BNS name like `label.tld`' }; + if (!PUBLISHER_RE.test(String(body.publisher || '').toLowerCase())) { + return { + ok: false, + reason: '`publisher` must be a BNS name like `foo.x` or a BCH cashaddress like `bitcoincash:q…`', + }; } if (!SLUG_RE.test(String(body.slug || '').toLowerCase())) { return { ok: false, reason: '`slug` must be [a-z0-9-], 1..64 chars, no leading/trailing dash' }; diff --git a/service/pages-browse.js b/service/pages-browse.js index e0b28bb..d7023b3 100644 --- a/service/pages-browse.js +++ b/service/pages-browse.js @@ -321,7 +321,7 @@ export function appPage(req, publisher, slug) { : `
Signed by ${escapeHtml(row.publisher)} — a BNS name owned by
- ${escapeHtml(row.signer_address)}. Any installer
- the chain says belongs to that key is trusted by your client; any client
- that follows a different trust list will not see it as trusted.
Signed by ${escapeHtml(shortenPublisher(row.publisher))} —
+ ${/^(bitcoincash|bchtest):q[a-z0-9]{41}$/.test(String(row.publisher))
+ ? `a raw BCH address publisher. The signing key is this address itself; no BNS name is attached.`
+ : `a BNS name owned by ${escapeHtml(row.signer_address)}.`}
+ Any installer the chain says belongs to that key is trusted by your client;
+ any client that follows a different trust list will not see it as trusted.