Pithos: run your drives on Silent Mode with your own Sia account
s3d binds one Sia account per process, so the server runs one s3d per
person (pithos-hosted), started on demand and flushed and stopped when idle.
Only the connected s3d data folder moves between PC and server; the Sia
recovery phrase never leaves the PC and nothing is re-uploaded to Sia.
Because the server holds the connection key, local Pithos encrypts names and
contents before they leave the machine, with keys from the Theseus vault (or
a local key file elsewhere). What s3d cannot do on its own, the gateway now
enforces: public folders, expiring share links and S3 keys for other people
limited to one folder with read, add-only or edit rights. Files those reach
are stored readable, since visitors have no key, and go back to encrypted
when the rule is removed. Sign-up needs a BNS name signature, a deposit or
an invite, with a per-IP limit.
2026-10-04 01:33:20 +02:00
|
|
|
// 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);
|
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
|
|
|
// 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;
|
Pithos: run your drives on Silent Mode with your own Sia account
s3d binds one Sia account per process, so the server runs one s3d per
person (pithos-hosted), started on demand and flushed and stopped when idle.
Only the connected s3d data folder moves between PC and server; the Sia
recovery phrase never leaves the PC and nothing is re-uploaded to Sia.
Because the server holds the connection key, local Pithos encrypts names and
contents before they leave the machine, with keys from the Theseus vault (or
a local key file elsewhere). What s3d cannot do on its own, the gateway now
enforces: public folders, expiring share links and S3 keys for other people
limited to one folder with read, add-only or edit rights. Files those reach
are stored readable, since visitors have no key, and go back to encrypted
when the rule is removed. Sign-up needs a BNS name signature, a deposit or
an invite, with a per-IP limit.
2026-10-04 01:33:20 +02:00
|
|
|
|
|
|
|
|
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');
|
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
|
|
|
if (used(state.names, n, id)) throw gateError(`${n} already opened a hosted slot`);
|
Pithos: run your drives on Silent Mode with your own Sia account
s3d binds one Sia account per process, so the server runs one s3d per
person (pithos-hosted), started on demand and flushed and stopped when idle.
Only the connected s3d data folder moves between PC and server; the Sia
recovery phrase never leaves the PC and nothing is re-uploaded to Sia.
Because the server holds the connection key, local Pithos encrypts names and
contents before they leave the machine, with keys from the Theseus vault (or
a local key file elsewhere). What s3d cannot do on its own, the gateway now
enforces: public folders, expiring share links and S3 keys for other people
limited to one folder with read, add-only or edit rights. Files those reach
are stored readable, since visitors have no key, and go back to encrypted
when the rule is removed. Sign-up needs a BNS name signature, a deposit or
an invite, with a per-IP limit.
2026-10-04 01:33:20 +02:00
|
|
|
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');
|
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
|
|
|
if (used(state.txids, t, id)) throw gateError('this deposit already opened a hosted slot');
|
Pithos: run your drives on Silent Mode with your own Sia account
s3d binds one Sia account per process, so the server runs one s3d per
person (pithos-hosted), started on demand and flushed and stopped when idle.
Only the connected s3d data folder moves between PC and server; the Sia
recovery phrase never leaves the PC and nothing is re-uploaded to Sia.
Because the server holds the connection key, local Pithos encrypts names and
contents before they leave the machine, with keys from the Theseus vault (or
a local key file elsewhere). What s3d cannot do on its own, the gateway now
enforces: public folders, expiring share links and S3 keys for other people
limited to one folder with read, add-only or edit rights. Files those reach
are stored readable, since visitors have no key, and go back to encrypted
when the rule is removed. Sign-up needs a BNS name signature, a deposit or
an invite, with a per-IP limit.
2026-10-04 01:33:20 +02:00
|
|
|
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');
|
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
|
|
|
if (used(state.invites, c, id)) throw gateError('this invite code was already used');
|
Pithos: run your drives on Silent Mode with your own Sia account
s3d binds one Sia account per process, so the server runs one s3d per
person (pithos-hosted), started on demand and flushed and stopped when idle.
Only the connected s3d data folder moves between PC and server; the Sia
recovery phrase never leaves the PC and nothing is re-uploaded to Sia.
Because the server holds the connection key, local Pithos encrypts names and
contents before they leave the machine, with keys from the Theseus vault (or
a local key file elsewhere). What s3d cannot do on its own, the gateway now
enforces: public folders, expiring share links and S3 keys for other people
limited to one folder with read, add-only or edit rights. Files those reach
are stored readable, since visitors have no key, and go back to encrypted
when the rule is removed. Sign-up needs a BNS name signature, a deposit or
an invite, with a per-IP limit.
2026-10-04 01:33:20 +02:00
|
|
|
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;
|
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
|
|
|
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);
|
Pithos: run your drives on Silent Mode with your own Sia account
s3d binds one Sia account per process, so the server runs one s3d per
person (pithos-hosted), started on demand and flushed and stopped when idle.
Only the connected s3d data folder moves between PC and server; the Sia
recovery phrase never leaves the PC and nothing is re-uploaded to Sia.
Because the server holds the connection key, local Pithos encrypts names and
contents before they leave the machine, with keys from the Theseus vault (or
a local key file elsewhere). What s3d cannot do on its own, the gateway now
enforces: public folders, expiring share links and S3 keys for other people
limited to one folder with read, add-only or edit rights. Files those reach
are stored readable, since visitors have no key, and go back to encrypted
when the rule is removed. Sign-up needs a BNS name signature, a deposit or
an invite, with a per-IP limit.
2026-10-04 01:33:20 +02:00
|
|
|
state.ip[ipKey] = (state.ip[ipKey] || 0) + 1;
|
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
|
|
|
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();
|
|
|
|
|
}
|
Pithos: run your drives on Silent Mode with your own Sia account
s3d binds one Sia account per process, so the server runs one s3d per
person (pithos-hosted), started on demand and flushed and stopped when idle.
Only the connected s3d data folder moves between PC and server; the Sia
recovery phrase never leaves the PC and nothing is re-uploaded to Sia.
Because the server holds the connection key, local Pithos encrypts names and
contents before they leave the machine, with keys from the Theseus vault (or
a local key file elsewhere). What s3d cannot do on its own, the gateway now
enforces: public folders, expiring share links and S3 keys for other people
limited to one folder with read, add-only or edit rights. Files those reach
are stored readable, since visitors have no key, and go back to encrypted
when the rule is removed. Sign-up needs a BNS name signature, a deposit or
an invite, with a per-IP limit.
2026-10-04 01:33:20 +02:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function gateError(message, status = 403) { return Object.assign(new Error(message), { status }); }
|