pithos/hosted/gate.js

146 lines
7.1 KiB
JavaScript
Raw Normal View History

// Who may open a hosted slot. A slot costs us memory, disk and traffic, so
// sign-up asks for one of:
// bns a signature from the wallet that owns a BNS name
// (one slot per name)
// deposit a BCH payment of at least `minSats` to the operator's deposit
// address carrying OP_RETURN "pithos:<account id>" (one slot per
// tx; refunds are an operator decision)
// invite a code the operator handed out (one use each)
// plus a per-IP limit on sign-ups per day.
import path from 'node:path';
import {
sha256, hash160, secp256k1, utf8ToBin, binToHex, base64ToBin, cashAddressToLockingBytecode,
} from '@bitauth/libauth';
import { readJson, writeJson } from './store.js';
import { electrumCall } from './electrum.js';
export const bnsMessage = (id) => `pithos-hosted signup ${id}`;
export const depositMemo = (id) => `pithos:${id}`;
function varStr(bytes) {
const n = bytes.length;
const prefix = n < 0xfd ? [n] : [0xfd, n & 0xff, (n >> 8) & 0xff];
return Uint8Array.from([...prefix, ...bytes]);
}
// BIP-137: recover the signer, then compare its locking bytecode with the
// address we expected. Recovery alone proves nothing: any signature recovers
// to *some* key.
export function signedByAddress(message, signatureB64, expectedAddress) {
let sig;
try { sig = base64ToBin(String(signatureB64).trim()); } catch { return false; }
if (!(sig instanceof Uint8Array) || sig.length !== 65 || sig[0] < 27 || sig[0] > 34) return false;
const compressed = sig[0] >= 31;
const digest = sha256.hash(sha256.hash(Uint8Array.from([
...varStr(utf8ToBin('Bitcoin Signed Message:\n')), ...varStr(utf8ToBin(message)),
])));
const recover = compressed ? secp256k1.recoverPublicKeyCompressed : secp256k1.recoverPublicKeyUncompressed;
const pub = recover(sig.slice(1), (sig[0] - 27) & 3, digest);
if (typeof pub === 'string') return false;
const signer = binToHex(Uint8Array.from([0x76, 0xa9, 0x14, ...hash160(pub), 0x88, 0xac]));
const expected = cashAddressToLockingBytecode(expectedAddress);
return typeof expected !== 'string' && binToHex(expected.bytecode) === signer;
}
export function createGate(root, cfg, { fetchImpl = fetch, electrum = electrumCall } = {}) {
const file = path.join(root, 'gate.json');
const state = readJson(file, { names: {}, txids: {}, invites: {}, ip: {} });
const save = () => writeJson(file, state);
const today = () => new Date().toISOString().slice(0, 10);
// Claims in flight. A proof is checked across awaits (BNS lookup, Electrum),
// so the name, tx or IP slot is taken before the first await and given back
// if the check fails; otherwise parallel sign-ups could share one proof.
const pending = new Set();
function claim(key, id) {
if (pending.has(key)) throw gateError('this sign-up is already being checked; try again in a moment', 429);
pending.add(key);
return () => pending.delete(key);
}
const used = (map, k, id) => Object.hasOwn(map, k) && map[k] !== id;
async function bns({ name, signature }, id) {
if (!cfg.bns) throw gateError('BNS sign-up is not enabled here');
const n = String(name || '').trim().toLowerCase();
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(n)) throw gateError('enter a BNS name like yourname.bch');
if (used(state.names, n, id)) throw gateError(`${n} already opened a hosted slot`);
const r = await fetchImpl(`${cfg.bns.api.replace(/\/$/, '')}/api/name/${encodeURIComponent(n)}`);
const info = await r.json().catch(() => ({}));
if (!r.ok || !info.registered || !info.owner) throw gateError(`${n} is not a registered BNS name`);
if (!signedByAddress(bnsMessage(id), signature, info.owner)) {
throw gateError(`the signature is not from the wallet that owns ${n} (${info.owner})`);
}
return { type: 'bns', name: n, owner: info.owner };
}
async function deposit({ txid }, id) {
const d = cfg.deposit;
if (!d) throw gateError('deposit sign-up is not enabled here');
const t = String(txid || '').trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(t)) throw gateError('enter the 64-character transaction id');
if (used(state.txids, t, id)) throw gateError('this deposit already opened a hosted slot');
const tx = await electrum(d.electrum, 'blockchain.transaction.get', [t, true]);
if ((tx.confirmations || 0) < (d.confirmations ?? 1)) throw gateError('the deposit has no confirmation yet; try again in about ten minutes');
const want = cashAddressToLockingBytecode(d.address);
if (typeof want === 'string') throw new Error('deposit address in the gateway config is invalid');
const wantHex = binToHex(want.bytecode);
const memoHex = Buffer.from(depositMemo(id), 'utf8').toString('hex');
let paid = 0;
let memo = false;
for (const o of tx.vout || []) {
const hex = String(o.scriptPubKey?.hex || '');
if (hex === wantHex) paid += Math.round(Number(o.value) * 1e8);
if (hex.startsWith('6a') && hex.includes(memoHex)) memo = true;
}
if (paid < d.minSats) throw gateError(`the deposit must send at least ${d.minSats} sats to ${d.address}`);
if (!memo) throw gateError(`the deposit must carry the memo "${depositMemo(id)}" (OP_RETURN)`);
return { type: 'deposit', txid: t, sats: paid };
}
function invite({ code }, id) {
const c = String(code || '').trim();
if (!(cfg.invites || []).includes(c)) throw gateError('this invite code is not valid');
if (used(state.invites, c, id)) throw gateError('this invite code was already used');
return { type: 'invite', code: c };
}
return {
describe() {
return {
bns: !!cfg.bns,
deposit: cfg.deposit ? { address: cfg.deposit.address, minSats: cfg.deposit.minSats } : null,
invite: !!(cfg.invites && cfg.invites.length),
};
},
async admit(proof, id, ip) {
const day = today();
for (const key of Object.keys(state.ip)) if (!key.startsWith(day)) delete state.ip[key];
const ipKey = `${day} ${ip}`;
const kind = proof?.type;
if (!['bns', 'deposit', 'invite'].includes(kind)) throw gateError('choose how to open your slot: BNS name, deposit or invite', 400);
// The IP count goes up now and comes back down on failure.
if ((state.ip[ipKey] || 0) >= (cfg.perIpPerDay ?? 3)) throw gateError('too many sign-ups from this network today', 429);
state.ip[ipKey] = (state.ip[ipKey] || 0) + 1;
const what = kind === 'bns' ? `n:${String(proof.name || '').trim().toLowerCase()}`
: kind === 'deposit' ? `t:${String(proof.txid || '').trim().toLowerCase()}` : `i:${String(proof.code || '').trim()}`;
let unclaim = () => {};
try {
unclaim = claim(what, id);
const r = kind === 'bns' ? await bns(proof, id) : kind === 'deposit' ? await deposit(proof, id) : invite(proof, id);
if (r.name) state.names[r.name] = id;
if (r.txid) state.txids[r.txid] = id;
if (r.code) state.invites[r.code] = id;
save();
return r;
} catch (e) {
state.ip[ipKey] = Math.max(0, (state.ip[ipKey] || 1) - 1);
throw e;
} finally {
unclaim();
}
},
};
}
function gateError(message, status = 403) { return Object.assign(new Error(message), { status }); }