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.
This commit is contained in:
Local Dev 2026-10-04 03:15:42 +02:00
parent 9727ce79d3
commit cdbc8ad93b
10 changed files with 646 additions and 126 deletions

View file

@ -18,7 +18,7 @@ const LABEL_FILE = 'pithos.json';
// Files that belong to this computer, not to a Sia account. The hosted key
// file above all: moving or deleting it would make encrypted files unreadable.
const LOCAL = new Set(['s3d.yml', 'pithos-prefs.json', 'pithos-hosted-secret.json']);
const keep = (name) => LOCAL.has(name) || name.startsWith(ARCHIVE_PREFIX);
const keep = (name) => LOCAL.has(name) || name.startsWith('pithos-hosted-secret') || name.startsWith(ARCHIVE_PREFIX);
function stamp(d = new Date()) {
const p = (n) => String(n).padStart(2, '0');

72
core/blake2b.js Normal file
View file

@ -0,0 +1,72 @@
// BLAKE2b (RFC 7693) with a chosen digest length. Node's OpenSSL only offers
// BLAKE2b-512, and BLAKE2b-256 is not a truncation of it (the length is part
// of the parameter block). Sia hashes with BLAKE2b-256 everywhere; Pithos only
// needs it for short messages (signing indexer requests), so this favours
// clarity over speed.
const MASK = (1n << 64n) - 1n;
const IV = [
0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n,
0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n,
];
const SIGMA = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
[14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
[11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4],
[7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8],
[9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13],
[2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9],
[12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11],
[13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10],
[6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5],
[10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0],
];
const rotr = (x, n) => ((x >> n) | (x << (64n - n))) & MASK;
function compress(h, block, t, last) {
const m = [];
for (let i = 0; i < 16; i++) m.push(block.readBigUInt64LE(i * 8));
const v = [...h, ...IV];
v[12] ^= t & MASK;
v[13] ^= t >> 64n;
if (last) v[14] ^= MASK;
const g = (a, b, c, d, x, y) => {
v[a] = (v[a] + v[b] + x) & MASK; v[d] = rotr(v[d] ^ v[a], 32n);
v[c] = (v[c] + v[d]) & MASK; v[b] = rotr(v[b] ^ v[c], 24n);
v[a] = (v[a] + v[b] + y) & MASK; v[d] = rotr(v[d] ^ v[a], 16n);
v[c] = (v[c] + v[d]) & MASK; v[b] = rotr(v[b] ^ v[c], 63n);
};
for (let r = 0; r < 12; r++) {
const s = SIGMA[r % 10];
g(0, 4, 8, 12, m[s[0]], m[s[1]]); g(1, 5, 9, 13, m[s[2]], m[s[3]]);
g(2, 6, 10, 14, m[s[4]], m[s[5]]); g(3, 7, 11, 15, m[s[6]], m[s[7]]);
g(0, 5, 10, 15, m[s[8]], m[s[9]]); g(1, 6, 11, 12, m[s[10]], m[s[11]]);
g(2, 7, 8, 13, m[s[12]], m[s[13]]); g(3, 4, 9, 14, m[s[14]], m[s[15]]);
}
for (let i = 0; i < 8; i++) h[i] ^= v[i] ^ v[i + 8];
}
export function blake2b(data, outLen = 32) {
const input = Buffer.from(data);
const h = [...IV];
h[0] ^= 0x01010000n ^ BigInt(outLen);
let t = 0n;
let off = 0;
// Every block but the last is compressed as "not final"; an empty input is
// one zero block marked final.
while (input.length - off > 128) {
t += 128n;
compress(h, input.subarray(off, off + 128), t, false);
off += 128;
}
const last = Buffer.alloc(128);
input.copy(last, 0, off);
t += BigInt(input.length - off);
compress(h, last, t, true);
const out = Buffer.alloc(64);
h.forEach((x, i) => out.writeBigUInt64LE(x, i * 8));
return out.subarray(0, outLen);
}
export const blake2b256 = (data) => blake2b(data, 32);

View file

@ -145,6 +145,8 @@ export function installHosted(ctx) {
const h = hp();
const out = { mode: isHosted() ? 'hosted' : 'local', url: h?.url || DEFAULT_HOSTED_URL, id: h?.id || null, signedIn: !!h?.token, secrets: secrets.kind || 'vault' };
if (h?.token) {
try { const warn = await settleMoves(); if (warn) out.warning = warn; } catch {}
out.mode = isHosted() ? 'hosted' : 'local';
try { out.me = await gw().call('GET', '/hosted/v1/me'); } catch (e) { out.error = e.message; if (e.status === 401) out.signedIn = false; }
}
if (!out.signedIn) {
@ -182,12 +184,52 @@ export function installHosted(ctx) {
return { mode: 'hosted' };
});
// Asks the server who holds the drives, a few times. null if it cannot tell.
async function serverState(g) {
for (let i = 0; i < 3; i++) {
try { return (await g.call('GET', '/hosted/v1/me')).state; } catch { await new Promise((r) => setTimeout(r, 1000 * (i + 1))); }
}
return null;
}
// The server has the drives: set the local copy aside so it can never run
// next to the server's (that would fork the account).
function adoptHosted(reg, users) {
const c = cfg();
if (fs.existsSync(path.join(c.directory, 's3d.db'))) {
accounts.archiveCurrent(c.directory, { indexerUrl: reg?.indexerUrl || null, users: users || [], movedTo: hp().url });
}
resetRegistration();
writePrefs(configFile, { mode: 'hosted', encryptedData: true, autostart: false, moveUncertain: null });
keyCache.clear();
dropRules();
}
// Finishes a move whose last answer was lost (see move-up and move-down).
async function settleMoves() {
const p = prefs();
if (!p.moveUncertain && !p.pendingRelease) return null;
const g = gw();
const state = await serverState(g);
if (state == null) return 'Silent Mode is not reachable, so an earlier move is not finished yet.';
if (p.moveUncertain) {
if (state === 'active') adoptHosted(p.moveUncertain.reg, p.moveUncertain.users);
else writePrefs(configFile, { moveUncertain: null }); // it never arrived; the local copy is the account
}
if (p.pendingRelease) {
if (state === 'moving-out') await g.call('POST', '/hosted/v1/data/release').catch(() => {});
if ((await serverState(g)) !== 'moving-out') writePrefs(configFile, { pendingRelease: null });
}
return null;
}
// Local -> Silent Mode. Nothing is re-uploaded to Sia: s3d's database and
// any objects still waiting for Sia move, then s3d runs on the server.
route('POST', '/api/hosted/move-up', async (req) => {
const { force } = await readJson(req);
const g = gw();
const progress = (p) => broadcast('move', { direction: 'up', ...p });
if (prefs().moveUncertain) throw new HttpError(409, 'An earlier move is not settled yet; open Settings again in a minute.');
const me = await wrap(() => g.call('GET', '/hosted/v1/me'));
if (me.state !== 'empty') throw new HttpError(409, 'Silent Mode already holds drives for you. Use them from here, or move them back first.');
const c = cfg();
@ -202,34 +244,45 @@ export function installHosted(ctx) {
}
progress({ phase: 'stop' });
await daemon.stop();
let committed = false;
const restartLocal = () => { if (wasRunning) { try { ensureConfig(configFile); daemon.start(); } catch {} } };
let files;
try {
const files = dataFiles(c.directory);
files = await dataFiles(c.directory);
if (!files.some((f) => f.path === 's3d.db')) throw new HttpError(409, 'there is no s3d database to move');
await uploadData(g, c.directory, files, progress);
progress({ phase: 'start' });
const r = await wrap(() => g.call('POST', '/hosted/v1/data/commit', { body: { files } }));
committed = true;
if (!r.registered) throw new HttpError(502, 'the server could not use the moved connection');
// Keep the local copy, set aside like an account switch, never deleted.
const meta = { indexerUrl: reg.indexerUrl, users: r.users, movedTo: hp().url };
accounts.archiveCurrent(c.directory, meta);
resetRegistration();
writePrefs(configFile, { mode: 'hosted', encryptedData: true, autostart: false });
keyCache.clear();
dropRules();
progress({ phase: 'done' });
return { ok: true, ...r };
} catch (e) {
if (!committed) await g.call('POST', '/hosted/v1/data/abort').catch(() => {});
if (wasRunning && !committed) { try { ensureConfig(configFile); daemon.start(); } catch {} }
// Nothing was committed: the server discards what arrived, s3d runs here again.
await g.call('POST', '/hosted/v1/data/abort').catch(() => {});
restartLocal();
progress({ phase: 'failed', error: e.message });
throw e;
}
progress({ phase: 'start' });
let r = null;
let commitError = null;
try { r = await wrap(() => g.call('POST', '/hosted/v1/data/commit', { body: { files } })); } catch (e) { commitError = e; }
// From here an answer can be lost in either direction, so the server's
// state decides which copy is the account.
const state = r ? 'active' : await serverState(g);
if (state === 'active') {
adoptHosted(reg, r?.users);
progress({ phase: 'done' });
return { ok: true, registered: true, users: r?.users || [] };
}
if (state === 'empty') {
restartLocal();
progress({ phase: 'failed', error: commitError?.message });
throw commitError || new HttpError(502, 'the server did not take the drives');
}
// Unknown: keep s3d stopped here until the server can be asked again.
writePrefs(configFile, { moveUncertain: { at: Date.now(), reg: { indexerUrl: reg.indexerUrl }, users: [] } });
progress({ phase: 'failed', error: 'no answer from Silent Mode' });
throw new HttpError(502, 'Silent Mode stopped answering during the move. s3d stays stopped here until Pithos can check which side has your drives; open Settings again in a minute.');
});
// Silent Mode -> local. The server flushes and stops its s3d, the folder
// comes down, the local s3d starts on it, then the server copy is deleted.
// comes down, the local s3d starts on it, and only then is the server copy
// released. Once release is sent the local copy is never removed.
route('POST', '/api/hosted/move-down', async (req) => {
const { force } = await readJson(req);
const g = gw();
@ -241,12 +294,16 @@ export function installHosted(ctx) {
archived = accounts.archiveCurrent(c.directory, { note: 'set aside to bring drives back from Silent Mode' }).name;
}
progress({ phase: 'flush' });
const man = await wrap(() => g.call('POST', `/hosted/v1/data/checkout${force ? '?force=1' : ''}`));
let man;
try { man = await wrap(() => g.call('POST', `/hosted/v1/data/checkout${force ? '?force=1' : ''}`)); } catch (e) {
if (archived) { try { accounts.restoreArchive(c.directory, archived, {}); } catch {} }
throw e;
}
const written = [];
try {
await downloadData(g, c.directory, man.files, progress);
await downloadData(g, c.directory, man.files, progress, written);
progress({ phase: 'start' });
ensureConfig(configFile);
writePrefs(configFile, { mode: 'local', autostart: true, encryptedData: true });
resetRegistration();
daemon.start();
const until = Date.now() + 30_000;
@ -254,20 +311,26 @@ export function installHosted(ctx) {
if (daemon.state !== 'running') throw new Error('s3d did not start on the moved folder');
const reg = await registration();
if (!reg?.registered) throw new Error('the moved folder has no Sia connection');
await wrap(() => g.call('POST', '/hosted/v1/data/release'));
keyCache.clear();
dropRules();
progress({ phase: 'done' });
return { ok: true };
} catch (e) {
// Only what this move wrote is removed; the server keeps its copy.
await daemon.stop().catch(() => {});
for (const f of man.files) fs.rmSync(path.join(c.directory, ...f.path.split('/')), { force: true });
for (const f of written) fs.rmSync(f, { force: true });
if (archived) { try { accounts.restoreArchive(c.directory, archived, {}); } catch {} }
writePrefs(configFile, { mode: 'hosted' });
await g.call('POST', '/hosted/v1/data/cancel-checkout').catch(() => {});
progress({ phase: 'failed', error: e.message });
throw new HttpError(502, `Moving back failed and nothing changed: ${e.message}`);
}
// The drives now run here. Whatever happens to the release, this copy stays.
writePrefs(configFile, { mode: 'local', autostart: true, encryptedData: true, pendingRelease: true });
keyCache.clear();
dropRules();
try {
await g.call('POST', '/hosted/v1/data/release');
writePrefs(configFile, { pendingRelease: null });
} catch { /* settleMoves() retries; the server copy stays parked meanwhile */ }
progress({ phase: 'done' });
return { ok: true };
});
// Rules: public folders, shared keys, links. Files they cover become

View file

@ -27,14 +27,29 @@ const PKCS8_ED25519 = Buffer.from('302e020100300506032b657004220420', 'hex');
export function fileSecrets(configFile) {
const file = path.join(path.dirname(configFile), 'pithos-hosted-secret.json');
let root = null;
const NOTE = 'Pithos hosted identity and encryption key. Anyone with this file can read your encrypted files; without it they are lost.';
function save(secret) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify({ v: 1, secret: secret.toString('base64'), note: NOTE }, null, 2), { mode: 0o600 });
fs.renameSync(tmp, file);
}
// A key is only ever created when there is no file at all. A file that
// cannot be read or parsed is an error, never a reason to make a new key:
// that would silently orphan everything the old one encrypted.
function load() {
if (root) return root;
try { root = Buffer.from(JSON.parse(fs.readFileSync(file, 'utf8')).secret, 'base64'); } catch {}
if (!root || root.length !== 32) {
let text;
try { text = fs.readFileSync(file, 'utf8'); } catch (e) {
if (e.code !== 'ENOENT') throw new Error(`cannot read your Pithos key file (${file}): ${e.message}`);
root = crypto.randomBytes(32);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ v: 1, secret: root.toString('base64'), note: 'Pithos hosted identity and encryption key. Anyone with this file can read your encrypted files; without it they are lost.' }, null, 2), { mode: 0o600 });
save(root);
return root;
}
let b = null;
try { b = Buffer.from(JSON.parse(text).secret, 'base64'); } catch {}
if (!b || b.length !== 32) throw new Error(`your Pithos key file is damaged (${file}); restore it from your backup`);
root = b;
return root;
}
const derive = (label) => Buffer.from(crypto.hkdfSync('sha256', load(), Buffer.alloc(0), label, 32));
@ -47,8 +62,9 @@ export function fileSecrets(configFile) {
importSecret(obj) {
const b = Buffer.from(String(obj?.secret || ''), 'base64');
if (b.length !== 32) throw new Error('this is not a Pithos key file');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ v: 1, secret: b.toString('base64'), note: 'Pithos hosted identity and encryption key.' }, null, 2), { mode: 0o600 });
// The key being replaced may still be the only way to read some files.
if (fs.existsSync(file)) fs.copyFileSync(file, file.replace(/\.json$/, `.replaced-${Date.now()}.json`));
save(b);
root = b;
},
};
@ -161,7 +177,8 @@ export class EncryptingS3Client {
const objects = new Map();
const next = {};
for (const [name, pfx] of sources) {
if (state[name] === null) continue;
// A source that has run out stays out on later pages.
if (state[name] === null) { next[name] = null; continue; }
const page = await this.inner.listObjects(bucket, { prefix: pfx, delimiter, token: state[name] || undefined, max });
next[name] = page.truncated ? page.nextToken : null;
for (const f of page.folders) {
@ -188,8 +205,15 @@ export class EncryptingS3Client {
}
async putObject(bucket, key, body, { contentType, contentLength } = {}) {
if (await this.storeAsPlain(bucket, key)) return this.inner.putObject(bucket, key, body, { contentType, contentLength });
const dk = await this.dk(bucket);
if (await this.storeAsPlain(bucket, key)) {
const r = await this.inner.putObject(bucket, key, body, { contentType, contentLength });
// Reads prefer the encrypted form, so an older encrypted copy under the
// same name would hide this new version.
const ek = encryptPath(dk, key);
if (ek !== key) await this.inner.deleteObject(bucket, ek).catch(() => {});
return r;
}
const ek = encryptPath(dk, key);
if (key.endsWith('/')) return this.inner.putObject(bucket, ek, Buffer.alloc(0), { contentLength: 0 });
const len = Number(contentLength ?? (Buffer.isBuffer(body) ? body.length : NaN));
@ -308,22 +332,54 @@ function withStatus(stream, statusCode, headers) {
// ---- moving the data folder ----------------------------------------------------------------
// What travels: the database and objects still waiting for Sia. Logs, temp
// files, the local config and earlier accounts stay on this computer.
const SKIP_TOP = new Set(['s3d.yml', 'pithos-prefs.json', 'pithos-hosted-secret.json', 'pithos-archive.json', 'tmp', 's3d.log']);
export function dataFiles(dataDir) {
// files, the local config, the key file and earlier accounts stay on this
// computer, and a path naming any of them is refused in either direction.
const SKIP_TOP = new Set(['s3d.yml', 'pithos-prefs.json', 'pithos-hosted-secret.json', 'pithos-archive.json', 'pithos.json', 'tmp', 's3d.log']);
const skipTop = (name) => SKIP_TOP.has(name) || name.startsWith('previous-account-') || name.startsWith('pithos-hosted-secret') || name.endsWith('.log') || name.endsWith('.tmp');
// "uploads/ab.obj" style paths only: forward slashes, no empty, "." or ".."
// segments, no drive letters or backslashes, and nothing that is ours.
export function isTransferPath(rel) {
if (typeof rel !== 'string' || !rel || rel.length > 512) return false;
if (/[\\:\x00]/.test(rel) || rel.startsWith('/')) return false;
const segs = rel.split('/');
if (segs.some((x) => !x || x === '.' || x === '..')) return false;
return !skipTop(segs[0]);
}
// The file a transfer path names, proven to sit inside dir.
export function transferTarget(dir, rel) {
if (!isTransferPath(rel)) throw new Error(`refusing an unsafe file path: ${String(rel).slice(0, 80)}`);
const base = path.resolve(dir);
const target = path.resolve(base, ...rel.split('/'));
const r = path.relative(base, target);
if (!r || r.startsWith('..') || path.isAbsolute(r)) throw new Error(`refusing an unsafe file path: ${rel}`);
return target;
}
export function hashFile(file) {
return new Promise((resolve, reject) => {
const h = crypto.createHash('sha256');
let size = 0;
fs.createReadStream(file)
.on('data', (c) => { h.update(c); size += c.length; })
.on('error', reject)
.on('end', () => resolve({ size, sha256: h.digest('hex') }));
});
}
// Hashes stream from disk: buffered objects can be gigabytes.
export async function dataFiles(dataDir) {
const out = [];
const rec = (rel) => {
const rec = async (rel) => {
for (const e of fs.readdirSync(path.join(dataDir, rel), { withFileTypes: true })) {
const r = rel ? `${rel}/${e.name}` : e.name;
if (!rel && (SKIP_TOP.has(e.name) || e.name.startsWith('previous-account-') || e.name.endsWith('.log') || e.name.endsWith('.tmp'))) continue;
if (e.isDirectory()) rec(r);
else if (e.isFile()) {
const data = fs.readFileSync(path.join(dataDir, r));
out.push({ path: r, size: data.length, sha256: crypto.createHash('sha256').update(data).digest('hex') });
}
if (!rel && skipTop(e.name)) continue;
if (e.isDirectory()) await rec(r);
else if (e.isFile() && isTransferPath(r)) out.push({ path: r, ...(await hashFile(path.join(dataDir, r))) });
}
};
rec('');
await rec('');
return out;
}
@ -335,7 +391,7 @@ export async function uploadData(g, dataDir, files, onProgress = () => {}) {
const res = await fetch(`${g.base}/hosted/v1/data/file?path=${encodeURIComponent(f.path)}`, {
method: 'PUT',
headers: { authorization: g.auth, 'content-length': String(f.size) },
body: fs.createReadStream(path.join(dataDir, f.path)),
body: fs.createReadStream(transferTarget(dataDir, f.path)),
duplex: 'half',
});
const r = await res.json().catch(() => ({}));
@ -346,22 +402,34 @@ export async function uploadData(g, dataDir, files, onProgress = () => {}) {
onProgress({ phase: 'upload', sent, total });
}
export async function downloadData(g, dataDir, files, onProgress = () => {}) {
// The file list comes from the server, so every path is checked before
// anything is written, nothing existing is overwritten, and `written` lists
// exactly what this run created (the only files a failed move may remove).
export async function downloadData(g, dataDir, files, onProgress = () => {}, written = []) {
if (!Array.isArray(files) || !files.length) throw new Error('the server sent no files');
const targets = files.map((f) => {
const t = transferTarget(dataDir, f.path);
if (!/^[0-9a-f]{64}$/.test(String(f.sha256))) throw new Error(`no checksum for ${f.path}`);
if (fs.existsSync(t)) throw new Error(`${f.path} already exists on this computer`);
return t;
});
if (!files.some((f) => f.path === 's3d.db')) throw new Error('the server sent no s3d database');
let got = 0;
const total = files.reduce((n, f) => n + f.size, 0);
for (const f of files) {
const total = files.reduce((n, f) => n + (Number(f.size) || 0), 0);
for (let i = 0; i < files.length; i++) {
const f = files[i];
const target = targets[i];
onProgress({ phase: 'download', file: f.path, got, total });
const target = path.join(dataDir, ...f.path.split('/'));
if (!target.startsWith(path.resolve(dataDir))) throw new Error('bad path from server');
fs.mkdirSync(path.dirname(target), { recursive: true });
const res = await fetch(`${g.base}/hosted/v1/data/file?path=${encodeURIComponent(f.path)}`, { headers: { authorization: g.auth } });
if (!res.ok) throw new HostedError(res.status, (await res.json().catch(() => ({}))).error || `download of ${f.path} failed`);
const hash = crypto.createHash('sha256');
const src = Readable.fromWeb(res.body);
src.on('data', (c) => hash.update(c));
await pipeline(src, fs.createWriteStream(target));
written.push(target);
await pipeline(src, fs.createWriteStream(target, { flags: 'wx' }));
if (hash.digest('hex') !== f.sha256) throw new Error(`${f.path} arrived damaged`);
got += f.size;
got += Number(f.size) || 0;
}
onProgress({ phase: 'download', got, total });
}

98
core/sia-account.js Normal file
View file

@ -0,0 +1,98 @@
// Which Sia account is this s3d using? s3d has no command or admin route that
// says, but after `s3d login` it keeps the app key and indexer URL in s3d.db
// (global_settings). With them Pithos can ask the indexer itself: GET
// /account, signed the way indexd's app API expects (indexd api/app/auth.go):
//
// query sc = app public key, ss = signature, sv = expiry (unix seconds);
// keys and signature are base64 with the URL-safe alphabet AND padding
// signed blake2b-256( method | host | path | u64le(expiry) ), no separators,
// host and path taken from the stored indexer URL
//
// The answer carries the account's public key, storage used, quota and the
// last time it was used, which is what lets a person match this s3d to an app
// on their sia.storage dashboard. The key never leaves this process.
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { blake2b256 } from './blake2b.js';
const PKCS8_ED25519 = Buffer.from('302e020100300506032b657004220420', 'hex');
// node:sqlite is built into Node 22.5+ (and the Electron that Theseus ships);
// loaded lazily so an older runtime only loses this feature.
async function openSqlite(file) {
const { DatabaseSync } = await import('node:sqlite');
return new DatabaseSync(file, { readOnly: true });
}
// { appKey: Buffer(64) | null, indexerUrl } from the data folder, or null when
// there is no s3d database yet. s3d runs SQLite in WAL mode without exclusive
// locking, so reading while it runs is safe.
export async function readConnection(dataDir) {
const file = path.join(dataDir, 's3d.db');
if (!fs.existsSync(file)) return null;
const db = await openSqlite(file);
try {
const row = db.prepare('SELECT app_key, indexer_url FROM global_settings LIMIT 1').get();
if (!row) return { appKey: null, indexerUrl: null };
const key = row.app_key ? Buffer.from(row.app_key) : null;
return { appKey: key && key.length === 64 ? key : null, indexerUrl: row.indexer_url || null };
} finally {
db.close();
}
}
const b64 = (b) => Buffer.from(b).toString('base64').replace(/\+/g, '-').replace(/\//g, '_');
// The query string for a signed app-API request (exported for tests).
export function signRequest(appKey, method, endpoint, validUntil) {
const u = new URL(endpoint);
const exp = Buffer.alloc(8);
exp.writeBigUInt64LE(BigInt(validUntil));
const digest = blake2b256(Buffer.concat([Buffer.from(method), Buffer.from(u.host), Buffer.from(u.pathname), exp]));
const key = crypto.createPrivateKey({ key: Buffer.concat([PKCS8_ED25519, appKey.subarray(0, 32)]), format: 'der', type: 'pkcs8' });
const sig = crypto.sign(null, digest, key);
u.searchParams.set('sv', String(validUntil));
u.searchParams.set('sc', b64(appKey.subarray(32)));
u.searchParams.set('ss', b64(sig));
return u;
}
// A short, stable label for an account key: the first 8 hex characters as
// "ed25519:1a2b3c4d…" (the same form indexd prints, cut short).
export const fingerprint = (pubHex) => `ed25519:${pubHex.slice(0, 8)}…${pubHex.slice(-4)}`;
// What the indexer knows about this s3d's account. Throws with a readable
// message on failure; `connection` lets callers skip re-reading the db.
export async function accountInfo(dataDir, { fetchImpl = fetch, timeoutMs = 10_000, connection } = {}) {
const c = connection || await readConnection(dataDir);
if (!c?.appKey || !c.indexerUrl) return { connected: false };
const publicKey = c.appKey.subarray(32).toString('hex');
const base = { connected: true, indexerUrl: c.indexerUrl, publicKey, fingerprint: fingerprint(publicKey) };
const endpoint = c.indexerUrl.replace(/\/$/, '') + '/account';
const url = signRequest(c.appKey, 'GET', endpoint, Math.floor(Date.now() / 1000) + 600);
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
const res = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: ctl.signal });
const text = await res.text();
if (!res.ok) return { ...base, error: `the indexer answered ${res.status}: ${text.trim().slice(0, 200)}` };
const a = JSON.parse(text);
return {
...base,
accountKey: a.accountKey,
app: a.app ? { name: a.app.name, description: a.app.description, id: a.app.id } : null,
pinnedData: a.pinnedData ?? null,
pinnedSize: a.pinnedSize ?? null,
maxPinnedData: a.maxPinnedData ?? null,
remainingStorage: a.remainingStorage ?? null,
ready: a.ready ?? null,
lastUsed: a.lastUsed || null,
};
} catch (e) {
return { ...base, error: e.name === 'AbortError' ? 'the indexer did not answer' : e.message };
} finally {
clearTimeout(timer);
}
}

View file

@ -48,12 +48,22 @@ export function createGate(root, cfg, { fetchImpl = fetch, electrum = electrumCa
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 (state.names[n] && state.names[n] !== id) throw gateError(`${n} already opened a hosted slot`);
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`);
@ -68,7 +78,7 @@ export function createGate(root, cfg, { fetchImpl = fetch, electrum = electrumCa
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 (state.txids[t] && state.txids[t] !== id) throw gateError('this deposit already opened a hosted slot');
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);
@ -90,7 +100,7 @@ export function createGate(root, cfg, { fetchImpl = fetch, electrum = electrumCa
function invite({ code }, id) {
const c = String(code || '').trim();
if (!(cfg.invites || []).includes(c)) throw gateError('this invite code is not valid');
if (state.invites[c] && state.invites[c] !== id) throw gateError('this invite code was already used');
if (used(state.invites, c, id)) throw gateError('this invite code was already used');
return { type: 'invite', code: c };
}
@ -106,19 +116,28 @@ export function createGate(root, cfg, { fetchImpl = fetch, electrum = electrumCa
const day = today();
for (const key of Object.keys(state.ip)) if (!key.startsWith(day)) delete state.ip[key];
const ipKey = `${day} ${ip}`;
if ((state.ip[ipKey] || 0) >= (cfg.perIpPerDay ?? 3)) throw gateError('too many sign-ups from this network today', 429);
const kind = proof?.type;
let r;
if (kind === 'bns') r = await bns(proof, id);
else if (kind === 'deposit') r = await deposit(proof, id);
else if (kind === 'invite') r = invite(proof, id);
else throw gateError('choose how to open your slot: BNS name, deposit or invite', 400);
if (r.name) state.names[r.name] = id;
if (r.txid) state.txids[r.txid] = id;
if (r.code) state.invites[r.code] = id;
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;
save();
return r;
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();
}
},
};
}

View file

@ -25,6 +25,7 @@ import { createSupervisor, httpError } from './supervisor.js';
import { parseAuth, verify, SigError } from './sigv4.js';
import { validateRule, classify, decideGrant, publicRuleFor, live, forOwner } from './rules.js';
import { readJson, writeJson } from './store.js';
import { isTransferPath, transferTarget, hashFile } from '../core/hosted.js';
// Owner API calls the gateway forwards. Everything that would act on the
// server itself (binary, s3d.yml, Sia login, account switching) is the
@ -43,12 +44,13 @@ const KEY_CHANGES = /^\/api\/(users|keys)/;
export async function createHosted(cfg, log = console.log) {
fs.mkdirSync(cfg.root, { recursive: true });
const auth = createAuth(cfg.root);
const gate = createGate(cfg.root, cfg.gate || {});
const sup = createSupervisor(cfg, log);
const grantsFile = path.join(cfg.root, 'grants.json');
const linksFile = path.join(cfg.root, 'links.json');
const grants = readJson(grantsFile, {}); // accessKeyId -> { id, rule }
const links = readJson(linksFile, {}); // token -> { id, rule }
const gate = createGate(cfg.root, cfg.gate || {});
// Grant key ids are the gateway's: no account's s3d may route the same id.
const sup = createSupervisor(cfg, log, { reservedKey: (akid) => /^PG/.test(akid) || Object.hasOwn(grants, akid) });
const rulesFile = (id) => path.join(sup.dir(id), 'rules.json');
const rulesOf = (id) => readJson(rulesFile(id), []);
@ -98,6 +100,13 @@ export async function createHosted(cfg, log = console.log) {
if (url.pathname === '/api/status' && a.state !== 'active') {
return json(res, 200, hostedStatus(a, { daemon: { state: 'stopped' }, registration: { registered: false } }));
}
const reqBody = req.method === 'GET' ? null : JSON.stringify(await jsonBody(req));
// A chosen access key id must not be one another account (or a grant)
// routes: S3 traffic is sent to whichever s3d owns the id.
if (req.method === 'POST' && url.pathname === '/api/keys') {
const want = JSON.parse(reqBody).accessKey;
if (want && sup.keyTaken(String(want), id)) throw httpError(409, 'that access key id is already in use; leave it empty to get a random one');
}
const inst = await sup.ensure(id);
const release = sup.hold(inst);
try {
@ -105,10 +114,10 @@ export async function createHosted(cfg, log = console.log) {
const up = await new Promise((resolve, reject) => {
const r = http.request(target, {
method: req.method,
headers: { cookie: inst.cookie, 'x-pithos': '1', 'content-type': req.headers['content-type'] || 'application/json' },
headers: { cookie: inst.cookie, 'x-pithos': '1', 'content-type': 'application/json' },
}, resolve);
r.on('error', reject);
req.pipe(r);
r.end(reqBody ?? undefined);
});
const text = await readAll(up);
if (req.method !== 'GET' && KEY_CHANGES.test(url.pathname)) await sup.indexKeys(inst);
@ -138,26 +147,56 @@ export async function createHosted(cfg, log = console.log) {
const incomingManifest = (id) => path.join(sup.dir(id), 'incoming.json');
function safeRel(rel) {
const r = String(rel || '').replace(/\\/g, '/');
if (!r || r.startsWith('/') || r.split('/').some((s) => s === '..' || s === '' || s === '.') || /^[A-Za-z]:/.test(r)) throw httpError(400, 'bad file path');
return r;
if (!isTransferPath(rel)) throw httpError(400, 'bad file path');
return rel;
}
// Bytes actually on disk under a folder: aborted uploads count too.
function diskBytes(dir) {
let n = 0;
const rec = (d) => {
let entries = [];
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const p = path.join(d, e.name);
if (e.isDirectory()) rec(p);
else if (e.isFile()) { try { n += fs.statSync(p).size; } catch {} }
}
};
rec(dir);
return n;
}
// One upload at a time per account, so the size limit cannot be raced.
const receiving = new Set();
async function receiveFile(req, id, rel) {
const a = sup.account(id);
if (a.state !== 'empty') throw httpError(409, 'drives are already hosted here; move them back to your computer first');
const file = path.join(incomingDir(id), safeRel(rel));
fs.mkdirSync(path.dirname(file), { recursive: true });
const hash = crypto.createHash('sha256');
let size = 0;
const man = readJson(incomingManifest(id), {});
const total = Object.values(man).reduce((n, f) => n + f.size, 0);
const limit = cfg.maxDataBytes || 4 * 1024 ** 3;
req.on('data', (c) => { hash.update(c); size += c.length; if (total + size > limit) req.destroy(httpError(413, 'the data folder is larger than this server accepts')); });
await pipeline(req, fs.createWriteStream(file));
man[safeRel(rel)] = { size, sha256: hash.digest('hex') };
writeJson(incomingManifest(id), man);
return man[safeRel(rel)];
const name = safeRel(rel);
if (receiving.has(id)) throw httpError(409, 'send one file at a time');
receiving.add(id);
const file = transferTarget(incomingDir(id), name);
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.rmSync(file, { force: true });
const limit = cfg.maxDataBytes || 4 * 1024 ** 3;
const room = limit - diskBytes(incomingDir(id));
const declared = Number(req.headers['content-length']);
if (Number.isFinite(declared) && declared > room) throw httpError(413, 'the data folder is larger than this server accepts');
const hash = crypto.createHash('sha256');
let size = 0;
req.on('data', (c) => { hash.update(c); size += c.length; if (size > room) req.destroy(httpError(413, 'the data folder is larger than this server accepts')); });
await pipeline(req, fs.createWriteStream(file));
const man = readJson(incomingManifest(id), {});
man[name] = { size, sha256: hash.digest('hex') };
writeJson(incomingManifest(id), man);
return man[name];
} catch (e) {
fs.rmSync(file, { force: true });
throw e;
} finally {
receiving.delete(id);
}
}
async function commit(id, files) {
@ -166,7 +205,7 @@ export async function createHosted(cfg, log = console.log) {
const man = readJson(incomingManifest(id), {});
if (!Array.isArray(files) || !files.length) throw httpError(400, 'no files listed');
for (const f of files) {
const got = man[safeRel(f.path)];
const got = Object.hasOwn(man, safeRel(f.path)) ? man[f.path] : null;
if (!got || got.sha256 !== f.sha256 || got.size !== f.size) throw httpError(400, `${f.path} did not arrive intact; send it again`);
}
if (Object.keys(man).length !== files.length) throw httpError(400, 'the server has files the list does not mention; start the move again');
@ -176,44 +215,51 @@ export async function createHosted(cfg, log = console.log) {
fs.renameSync(incomingDir(id), sup.dataDir(id));
fs.rmSync(incomingManifest(id), { force: true });
sup.setState(id, 'active');
let inst;
try {
inst = await sup.ensure(id);
} catch (e) {
// Anything wrong with the folder puts the account back to empty, so the
// owner's computer (which asks the server's state) keeps its copy.
const refuse = async (status, message) => {
await sup.close(id, 'refused data folder');
fs.rmSync(sup.dataDir(id), { recursive: true, force: true });
fs.mkdirSync(sup.dataDir(id), { recursive: true });
sup.setState(id, 'empty');
throw httpError(502, `s3d could not start on the moved folder: ${e.message}`);
}
throw httpError(status, message);
};
let inst;
try { inst = await sup.ensure(id); } catch (e) { return refuse(502, `s3d could not start on the moved folder: ${e.message}`); }
const reg = await inst.cli.registration().catch(() => ({ registered: false }));
if (!reg.registered) return refuse(422, 'the moved folder has no Sia connection');
const keys = await sup.indexKeys(inst);
return { registered: reg.registered, indexerUrl: reg.indexerUrl || null, users: [...new Set(keys.map((k) => k.user))] };
if (inst.conflicts?.length) return refuse(409, `these access keys are already used on this server: ${inst.conflicts.join(', ')}; create new keys on your computer and try again`);
return { registered: true, indexerUrl: reg.indexerUrl || null, users: [...new Set(keys.map((k) => k.user))] };
}
function walk(base) {
// Hashes stream from disk (buffered objects can be gigabytes) and only
// paths the owner's Pithos will accept are listed.
async function walk(base) {
const out = [];
const rec = (d) => {
const rec = async (d) => {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
if (e.isDirectory()) rec(p);
else if (e.isFile()) out.push(p);
const rel = path.relative(base, p).split(path.sep).join('/');
if (e.isDirectory()) await rec(p);
else if (e.isFile() && isTransferPath(rel)) out.push({ path: rel, ...(await hashFile(p)) });
}
};
rec(base);
return out.map((p) => {
const data = fs.readFileSync(p);
return { path: path.relative(base, p).split(path.sep).join('/'), size: data.length, sha256: crypto.createHash('sha256').update(data).digest('hex') };
});
await rec(base);
return out;
}
async function checkout(id, force) {
const a = sup.account(id);
if (a.state === 'moving-out') return { files: walk(sup.dataDir(id)) };
if (a.state === 'moving-out') return { files: await walk(sup.dataDir(id)) };
if (a.state !== 'active') throw httpError(409, 'nothing is hosted here');
const inst = await sup.ensure(id);
const flushed = await sup.flushQuietly(inst, 10 * 60_000);
if (!flushed && !force) throw httpError(409, 'some uploads could not be sent to Sia yet; try again later, or move anyway and they will finish on your computer');
await sup.close(id, 'moving to the owner\'s computer');
// State first: from now on no request can start s3d on this folder again.
sup.setState(id, 'moving-out');
return { files: walk(sup.dataDir(id)) };
await sup.close(id, 'moving to the owner\'s computer');
return { files: await walk(sup.dataDir(id)) };
}
function release(id) {
@ -227,30 +273,36 @@ export async function createHosted(cfg, log = console.log) {
}
// ---- S3 ----------------------------------------------------------------------
// For shared keys: "." and ".." segments are refused outright, so a key can
// never be made to look inside its folder while naming something outside.
function splitS3Path(rawPath) {
const parts = rawPath.split('/').slice(1);
const dec = (s) => { try { return decodeURIComponent(s); } catch { return s; } };
const dec = (s) => { try { return decodeURIComponent(s); } catch { throw httpError(400, 'malformed path'); } };
const bucket = dec(parts[0] || '');
const key = parts.length > 1 ? parts.slice(1).map(dec).join('/') : null;
const keyParts = parts.slice(1).map(dec);
const key = parts.length > 1 ? keyParts.join('/') : null;
if (key != null && key.split('/').some((x) => x === '.' || x === '..')) throw httpError(400, 'paths with . or .. segments are not allowed');
return { bucket, key };
}
async function s3(req, res, url) {
const a = parseAuth(req, url);
if (!a) return s3Error(res, 403, 'AccessDenied', 'sign requests with a key; public files are at /p/<account>/<drive>/<path>');
const ownerId = sup.ownerOfKey(a.akid);
const g = Object.hasOwn(grants, a.akid) ? grants[a.akid] : null;
const ownerId = g ? null : sup.ownerOfKey(a.akid);
if (ownerId) {
checkReadCap(req, ownerId);
const inst = await sup.ensure(ownerId);
return rawProxy(req, res, inst);
}
const g = grants[a.akid];
const rule = g && ruleById(g.id, g.rule);
if (!rule) return s3Error(res, 403, 'InvalidAccessKeyId', 'The AWS Access Key Id you provided does not exist in our records.');
verify(req, url, a, rule.secretKey);
const raw = req.url.split('?')[0];
const { bucket, key } = splitS3Path(raw);
const query = Object.fromEntries(url.searchParams);
// The client's own signature parameters (presigned URLs) must not reach
// s3d next to the owner signature the gateway adds.
const query = Object.fromEntries([...url.searchParams].filter(([k]) => !/^x-amz-/i.test(k)));
const { op } = classify(req.method, query, key, req.headers);
const d = decideGrant(rule, bucket, op, key, query.prefix || '');
if (!d.allow) return s3Error(res, 403, 'AccessDenied', d.reason);
@ -339,7 +391,9 @@ export async function createHosted(cfg, log = console.log) {
const m = /^\/p\/([0-9a-f]{32})\/([^/]+)\/?(.*)$/.exec(url.pathname);
if (!m) throw httpError(404, 'not found');
const [, id, bucket] = m;
const key = decodeURIComponent(m[3]);
let key;
try { key = decodeURIComponent(m[3]); } catch { throw httpError(404, 'not found'); }
if (key.split('/').some((x) => x === '.' || x === '..')) throw httpError(404, 'not found');
const rule = publicRuleFor(rulesOf(id), bucket, key);
if (!rule) throw httpError(404, 'not found');
if (key === '' || key.endsWith('/')) {
@ -359,7 +413,7 @@ export async function createHosted(cfg, log = console.log) {
async function serveLink(req, res, url) {
const token = url.pathname.slice(3);
const l = links[token];
const l = Object.hasOwn(links, token) ? links[token] : null;
const rule = l && ruleById(l.id, l.rule);
if (!rule || !live(rule)) throw httpError(410, 'this link has expired');
return serveObject(req, res, l.id, rule.bucket, rule.prefix, { inline: !url.searchParams.has('download') });

View file

@ -22,12 +22,15 @@ import { S3Client } from '../core/s3.js';
import * as admin from '../core/admin.js';
import { readJson, writeJson } from './store.js';
export function createSupervisor(cfg, log = console.log) {
// reservedKey(akid): true for key ids the gateway itself hands out (grants),
// which no account's s3d may also use.
export function createSupervisor(cfg, log = console.log, { reservedKey = () => false } = {}) {
const usersDir = path.join(cfg.root, 'users');
const slotsFile = path.join(cfg.root, 'slots.json');
const keysFile = path.join(cfg.root, 'keys.json');
const running = new Map(); // id -> instance
const loading = new Map(); // id -> Promise<instance>
const closing = new Map(); // id -> Promise, while an s3d is being stopped
const keyIndex = readJson(keysFile, {}); // accessKeyId -> account id (owners' s3d keys)
const dir = (id) => path.join(usersDir, id);
@ -71,6 +74,8 @@ export function createSupervisor(cfg, log = console.log) {
}
async function load(id) {
// Never start a second s3d on a folder whose first one is still stopping.
while (closing.has(id)) await closing.get(id);
if (running.has(id)) return running.get(id);
if (loading.has(id)) return loading.get(id);
const p = (async () => {
@ -95,6 +100,8 @@ export function createSupervisor(cfg, log = console.log) {
if (a.state === 'empty') throw httpError(409, 'nothing is hosted yet: move your drives here from Pithos first');
if (a.state === 'moving-out') throw httpError(409, 'this account is being moved to its owner\'s computer');
const inst = await load(id);
// The account may have started moving out while it loaded.
if (account(id).state !== 'active') throw httpError(409, 'this account is being moved');
inst.lastUsed = Date.now();
const d = inst.pithos.daemon;
if (d.state !== 'running') {
@ -107,17 +114,32 @@ export function createSupervisor(cfg, log = console.log) {
return inst;
}
// Routes S3 requests: access key id -> account. A key id another account
// (or a grant) already uses is never taken over; that account's traffic
// would otherwise land in this one's s3d. Such keys are left unrouted and
// reported in inst.conflicts.
async function indexKeys(inst) {
const keys = await inst.cli.listKeys().catch(() => []);
let changed = false;
for (const [k, v] of Object.entries(keyIndex)) if (v === inst.id && !keys.some((x) => x.accessKeyId === k)) { delete keyIndex[k]; changed = true; }
for (const k of keys) if (keyIndex[k.accessKeyId] !== inst.id) { keyIndex[k.accessKeyId] = inst.id; changed = true; }
const conflicts = [];
for (const k of keys) {
const owner = Object.hasOwn(keyIndex, k.accessKeyId) ? keyIndex[k.accessKeyId] : null;
if (owner === inst.id) continue;
if (owner || reservedKey(k.accessKeyId)) { conflicts.push(k.accessKeyId); continue; }
keyIndex[k.accessKeyId] = inst.id;
changed = true;
}
if (changed) writeJson(keysFile, keyIndex);
inst.keys = keys;
if (conflicts.length) log(`[${inst.id.slice(0, 8)}] access keys already used elsewhere, not routed: ${conflicts.join(', ')}`);
inst.keys = keys.filter((k) => !conflicts.includes(k.accessKeyId));
inst.conflicts = conflicts;
inst.buckets.clear();
return keys;
return inst.keys;
}
const keyTaken = (akid, id) => (Object.hasOwn(keyIndex, akid) && keyIndex[akid] !== id) || reservedKey(akid);
// Every drive on the account, mapped to a key of the s3d user owning it.
async function mapBuckets(inst) {
inst.buckets.clear();
@ -140,11 +162,16 @@ export function createSupervisor(cfg, log = console.log) {
}
async function close(id, reason) {
if (closing.has(id)) return closing.get(id);
const inst = running.get(id);
if (!inst) return;
running.delete(id);
log(`[${id.slice(0, 8)}] stopping s3d (${reason})`);
await inst.pithos.close().catch((e) => log(`[${id.slice(0, 8)}] close failed: ${e.message}`));
const p = (async () => {
log(`[${id.slice(0, 8)}] stopping s3d (${reason})`);
await inst.pithos.close().catch((e) => log(`[${id.slice(0, 8)}] close failed: ${e.message}`));
running.delete(id);
})().finally(() => closing.delete(id));
closing.set(id, p);
return p;
}
async function flushQuietly(inst, timeoutMs = 120_000) {
@ -161,7 +188,8 @@ export function createSupervisor(cfg, log = console.log) {
}
async function makeRoom() {
if (running.size < cfg.maxRunning) return;
// Loads in flight count too, or parallel first requests overshoot the cap.
if (running.size + loading.size < cfg.maxRunning) return;
const idle = [...running.values()].filter((i) => !i.busy).sort((a, b) => a.lastUsed - b.lastUsed);
if (!idle.length) throw httpError(503, 'the server is busy; try again in a minute');
await flushQuietly(idle[0]);
@ -171,7 +199,7 @@ export function createSupervisor(cfg, log = console.log) {
async function sweep() {
const cutoff = Date.now() - cfg.idleMinutes * 60_000;
for (const inst of [...running.values()]) {
if (inst.busy || inst.lastUsed > cutoff) continue;
if (inst.busy || inst.lastUsed > cutoff || closing.has(inst.id)) continue;
await flushQuietly(inst);
if (!inst.busy && inst.lastUsed <= cutoff) await close(inst.id, 'idle');
}
@ -189,8 +217,8 @@ export function createSupervisor(cfg, log = console.log) {
return {
dir, dataDir, configFile, account, createAccount, saveAccount, setState,
load, ensure, close, flushQuietly, bucketClient, mapBuckets, indexKeys, hold,
ownerOfKey: (akid) => keyIndex[akid] || null,
load, ensure, close, flushQuietly, bucketClient, mapBuckets, indexKeys, hold, keyTaken,
ownerOfKey: (akid) => (Object.hasOwn(keyIndex, akid) ? keyIndex[akid] : null),
async closeAll() {
clearInterval(sweeper);
for (const id of [...running.keys()]) {

View file

@ -144,3 +144,18 @@ test('gate: deposit needs amount, memo and a confirmation', async () => {
assert.equal(r.sats, 10000);
await assert.rejects(gate.admit({ type: 'deposit', txid: '1'.repeat(64) }, 'e'.repeat(32), 'ip'), /already opened/);
});
test('gate: parallel sign-ups cannot share one proof', async () => {
let release;
const slow = new Promise((r) => { release = r; });
const owner = bchKey();
const fetchImpl = async () => { await slow; return { ok: true, json: async () => ({ registered: true, owner: owner.address }) }; };
const gate = createGate(tmp(), { bns: { api: 'https://bns.test' }, perIpPerDay: 9 }, { fetchImpl });
const id1 = '1'.repeat(32);
const id2 = '2'.repeat(32);
const a = gate.admit({ type: 'bns', name: 'bob.bch', signature: owner.sign(bnsMessage(id1)) }, id1, 'ip');
const b = gate.admit({ type: 'bns', name: 'bob.bch', signature: owner.sign(bnsMessage(id2)) }, id2, 'ip');
release();
const results = await Promise.allSettled([a, b]);
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
});

103
test/hosted.test.js Normal file
View file

@ -0,0 +1,103 @@
// The client half of hosting, the parts that can go wrong without a server:
// what a server-sent file list may touch, the key file, listing pages.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { isTransferPath, transferTarget, downloadData, dataFiles, fileSecrets, EncryptingS3Client } from '../core/hosted.js';
import { driveKeys, encryptPath } from '../core/crypt.js';
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'pithos-hosted-'));
test('transfer paths: only s3d data, never ours, never outside', () => {
for (const ok of ['s3d.db', 's3d.db-wal', 'uploads/02e9.obj']) assert.equal(isTransferPath(ok), true, ok);
const bad = ['', '/etc/passwd', '../x', 'a/../b', './a', 'a//b', 'C:/x', 'a' + String.fromCharCode(92) + 'b',
's3d.yml', 'pithos-prefs.json', 'pithos-hosted-secret.json', 'pithos-hosted-secret.replaced-1.json',
'previous-account-20261004/s3d.db', 'tmp/x', 's3d.log'];
for (const b of bad) assert.equal(isTransferPath(b), false, b);
assert.throws(() => transferTarget('/data', '../outside'), /unsafe/);
});
test('a hostile file list writes nothing and removes nothing', async () => {
const dir = tmp();
const secret = path.join(dir, 'pithos-hosted-secret.json');
fs.writeFileSync(secret, 'precious');
const outside = path.join(path.dirname(dir), `victim-${crypto.randomBytes(4).toString('hex')}.txt`);
fs.writeFileSync(outside, 'keep me');
const sha = 'a'.repeat(64);
const written = [];
const g = { base: 'http://127.0.0.1:9', auth: 'x' };
for (const files of [
[{ path: 's3d.db', size: 1, sha256: sha }, { path: `../${path.basename(outside)}`, size: 1, sha256: sha }],
[{ path: 's3d.db', size: 1, sha256: sha }, { path: 'pithos-hosted-secret.json', size: 1, sha256: sha }],
[{ path: 'uploads/x.obj', size: 1, sha256: sha }],
]) {
await assert.rejects(downloadData(g, dir, files, () => {}, written));
}
assert.equal(written.length, 0, 'nothing was started');
assert.equal(fs.readFileSync(secret, 'utf8'), 'precious');
assert.equal(fs.readFileSync(outside, 'utf8'), 'keep me');
fs.rmSync(outside);
});
test('data files are listed with streamed hashes, skipping local files', async () => {
const dir = tmp();
fs.writeFileSync(path.join(dir, 's3d.db'), 'db');
fs.mkdirSync(path.join(dir, 'uploads'));
fs.writeFileSync(path.join(dir, 'uploads', 'a.obj'), 'obj');
for (const n of ['s3d.yml', 'pithos-prefs.json', 'pithos-hosted-secret.json', 's3d.log']) fs.writeFileSync(path.join(dir, n), 'x');
fs.mkdirSync(path.join(dir, 'previous-account-1'));
fs.writeFileSync(path.join(dir, 'previous-account-1', 's3d.db'), 'old');
const files = await dataFiles(dir);
assert.deepEqual(files.map((f) => f.path).sort(), ['s3d.db', 'uploads/a.obj']);
assert.equal(files.find((f) => f.path === 's3d.db').sha256, crypto.createHash('sha256').update('db').digest('hex'));
});
test('key file: made once, never silently replaced', async () => {
const dir = tmp();
const cfg = path.join(dir, 's3d.yml');
const a = fileSecrets(cfg);
const k1 = await a.encryptionKey();
assert.deepEqual(await fileSecrets(cfg).encryptionKey(), k1, 'same key on the next start');
fs.writeFileSync(a.file, '{"v":1,"secret":"trunc');
await assert.rejects(fileSecrets(cfg).encryptionKey(), /damaged/);
assert.match(fs.readFileSync(a.file, 'utf8'), /trunc/, 'the damaged file is left for the user to restore');
const b = fileSecrets(cfg);
b.importSecret({ secret: crypto.randomBytes(32).toString('base64') });
assert.ok(fs.readdirSync(dir).some((n) => n.startsWith('pithos-hosted-secret.replaced-')), 'the replaced key is kept');
});
// A fake s3d that pages listings, to check the merge of plain and encrypted.
function fakeInner(keys, pageSize) {
const sorted = [...keys].sort();
return {
async listObjects(bucket, { prefix = '', delimiter = '/', token }) {
const all = sorted.filter((k) => k.startsWith(prefix));
const start = token ? Number(token) : 0;
const page = all.slice(start, start + pageSize);
void delimiter;
return { folders: [], objects: page.map((k) => ({ key: k, size: 100, modified: '', etag: '' })), truncated: start + pageSize < all.length, nextToken: String(start + pageSize) };
},
};
}
test('listing pages never repeat a source that ran out', async () => {
const master = crypto.randomBytes(32);
const dk = driveKeys(master, 'd');
const plain = Array.from({ length: 7 }, (_, i) => `f/plain${i}`);
const enc = [encryptPath(dk, 'f/secret1')];
const c = new EncryptingS3Client({ endpoint: 'http://127.0.0.1:1', accessKeyId: 'a', secretKey: 'b', master: async () => master });
c.inner = fakeInner([...plain, ...enc], 2);
const seen = [];
let token;
do {
const page = await c.listObjects('d', { prefix: 'f/', delimiter: '', token });
seen.push(...page.objects.map((o) => o.key));
token = page.truncated ? page.nextToken : null;
} while (token);
assert.equal(seen.filter((k) => k === 'f/secret1').length, 1);
assert.equal(seen.length, 8);
});