pithos/hosted/gate.js
Local Dev cdbc8ad93b Pithos hosting: fixes from an independent review
A server-sent file list could name paths outside the s3d folder (or the key
file in it), and a failed move back deleted every listed path. Transfers now
accept only s3d data paths, check all of them before writing, never
overwrite, and clean up only what they wrote.

A lost reply could lose or fork the drives: move-up and move-down now ask the
server who holds them before touching the local copy, and once the server is
told to release, the local copy is never removed (the release is retried).

The gateway no longer lets one account route another's access key id, refuses
. and .. in shared-key paths, strips presign parameters before re-signing,
counts upload size on disk, stops s3d before it can be restarted mid-move, and
claims sign-up proofs before checking them. The key file is never replaced
when unreadable, a local edit removes the older encrypted copy, listing pages
no longer repeat, and data hashes stream instead of loading whole files.
2026-10-04 03:15:42 +02:00

145 lines
7.1 KiB
JavaScript

// 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 }); }