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:
parent
9727ce79d3
commit
cdbc8ad93b
10 changed files with 646 additions and 126 deletions
|
|
@ -18,7 +18,7 @@ const LABEL_FILE = 'pithos.json';
|
||||||
// Files that belong to this computer, not to a Sia account. The hosted key
|
// 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.
|
// 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 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()) {
|
function stamp(d = new Date()) {
|
||||||
const p = (n) => String(n).padStart(2, '0');
|
const p = (n) => String(n).padStart(2, '0');
|
||||||
|
|
|
||||||
72
core/blake2b.js
Normal file
72
core/blake2b.js
Normal 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);
|
||||||
|
|
@ -145,6 +145,8 @@ export function installHosted(ctx) {
|
||||||
const h = hp();
|
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' };
|
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) {
|
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; }
|
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) {
|
if (!out.signedIn) {
|
||||||
|
|
@ -182,12 +184,52 @@ export function installHosted(ctx) {
|
||||||
return { mode: 'hosted' };
|
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
|
// 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.
|
// any objects still waiting for Sia move, then s3d runs on the server.
|
||||||
route('POST', '/api/hosted/move-up', async (req) => {
|
route('POST', '/api/hosted/move-up', async (req) => {
|
||||||
const { force } = await readJson(req);
|
const { force } = await readJson(req);
|
||||||
const g = gw();
|
const g = gw();
|
||||||
const progress = (p) => broadcast('move', { direction: 'up', ...p });
|
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'));
|
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.');
|
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();
|
const c = cfg();
|
||||||
|
|
@ -202,34 +244,45 @@ export function installHosted(ctx) {
|
||||||
}
|
}
|
||||||
progress({ phase: 'stop' });
|
progress({ phase: 'stop' });
|
||||||
await daemon.stop();
|
await daemon.stop();
|
||||||
let committed = false;
|
const restartLocal = () => { if (wasRunning) { try { ensureConfig(configFile); daemon.start(); } catch {} } };
|
||||||
|
let files;
|
||||||
try {
|
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');
|
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);
|
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) {
|
} catch (e) {
|
||||||
if (!committed) await g.call('POST', '/hosted/v1/data/abort').catch(() => {});
|
// Nothing was committed: the server discards what arrived, s3d runs here again.
|
||||||
if (wasRunning && !committed) { try { ensureConfig(configFile); daemon.start(); } catch {} }
|
await g.call('POST', '/hosted/v1/data/abort').catch(() => {});
|
||||||
|
restartLocal();
|
||||||
progress({ phase: 'failed', error: e.message });
|
progress({ phase: 'failed', error: e.message });
|
||||||
throw e;
|
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
|
// 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) => {
|
route('POST', '/api/hosted/move-down', async (req) => {
|
||||||
const { force } = await readJson(req);
|
const { force } = await readJson(req);
|
||||||
const g = gw();
|
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;
|
archived = accounts.archiveCurrent(c.directory, { note: 'set aside to bring drives back from Silent Mode' }).name;
|
||||||
}
|
}
|
||||||
progress({ phase: 'flush' });
|
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 {
|
try {
|
||||||
await downloadData(g, c.directory, man.files, progress);
|
await downloadData(g, c.directory, man.files, progress, written);
|
||||||
progress({ phase: 'start' });
|
progress({ phase: 'start' });
|
||||||
ensureConfig(configFile);
|
ensureConfig(configFile);
|
||||||
writePrefs(configFile, { mode: 'local', autostart: true, encryptedData: true });
|
|
||||||
resetRegistration();
|
resetRegistration();
|
||||||
daemon.start();
|
daemon.start();
|
||||||
const until = Date.now() + 30_000;
|
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');
|
if (daemon.state !== 'running') throw new Error('s3d did not start on the moved folder');
|
||||||
const reg = await registration();
|
const reg = await registration();
|
||||||
if (!reg?.registered) throw new Error('the moved folder has no Sia connection');
|
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) {
|
} catch (e) {
|
||||||
|
// Only what this move wrote is removed; the server keeps its copy.
|
||||||
await daemon.stop().catch(() => {});
|
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 {} }
|
if (archived) { try { accounts.restoreArchive(c.directory, archived, {}); } catch {} }
|
||||||
writePrefs(configFile, { mode: 'hosted' });
|
writePrefs(configFile, { mode: 'hosted' });
|
||||||
await g.call('POST', '/hosted/v1/data/cancel-checkout').catch(() => {});
|
await g.call('POST', '/hosted/v1/data/cancel-checkout').catch(() => {});
|
||||||
progress({ phase: 'failed', error: e.message });
|
progress({ phase: 'failed', error: e.message });
|
||||||
throw new HttpError(502, `Moving back failed and nothing changed: ${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
|
// Rules: public folders, shared keys, links. Files they cover become
|
||||||
|
|
|
||||||
122
core/hosted.js
122
core/hosted.js
|
|
@ -27,14 +27,29 @@ const PKCS8_ED25519 = Buffer.from('302e020100300506032b657004220420', 'hex');
|
||||||
export function fileSecrets(configFile) {
|
export function fileSecrets(configFile) {
|
||||||
const file = path.join(path.dirname(configFile), 'pithos-hosted-secret.json');
|
const file = path.join(path.dirname(configFile), 'pithos-hosted-secret.json');
|
||||||
let root = null;
|
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() {
|
function load() {
|
||||||
if (root) return root;
|
if (root) return root;
|
||||||
try { root = Buffer.from(JSON.parse(fs.readFileSync(file, 'utf8')).secret, 'base64'); } catch {}
|
let text;
|
||||||
if (!root || root.length !== 32) {
|
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);
|
root = crypto.randomBytes(32);
|
||||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
save(root);
|
||||||
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 });
|
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;
|
return root;
|
||||||
}
|
}
|
||||||
const derive = (label) => Buffer.from(crypto.hkdfSync('sha256', load(), Buffer.alloc(0), label, 32));
|
const derive = (label) => Buffer.from(crypto.hkdfSync('sha256', load(), Buffer.alloc(0), label, 32));
|
||||||
|
|
@ -47,8 +62,9 @@ export function fileSecrets(configFile) {
|
||||||
importSecret(obj) {
|
importSecret(obj) {
|
||||||
const b = Buffer.from(String(obj?.secret || ''), 'base64');
|
const b = Buffer.from(String(obj?.secret || ''), 'base64');
|
||||||
if (b.length !== 32) throw new Error('this is not a Pithos key file');
|
if (b.length !== 32) throw new Error('this is not a Pithos key file');
|
||||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
// The key being replaced may still be the only way to read some files.
|
||||||
fs.writeFileSync(file, JSON.stringify({ v: 1, secret: b.toString('base64'), note: 'Pithos hosted identity and encryption key.' }, null, 2), { mode: 0o600 });
|
if (fs.existsSync(file)) fs.copyFileSync(file, file.replace(/\.json$/, `.replaced-${Date.now()}.json`));
|
||||||
|
save(b);
|
||||||
root = b;
|
root = b;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -161,7 +177,8 @@ export class EncryptingS3Client {
|
||||||
const objects = new Map();
|
const objects = new Map();
|
||||||
const next = {};
|
const next = {};
|
||||||
for (const [name, pfx] of sources) {
|
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 });
|
const page = await this.inner.listObjects(bucket, { prefix: pfx, delimiter, token: state[name] || undefined, max });
|
||||||
next[name] = page.truncated ? page.nextToken : null;
|
next[name] = page.truncated ? page.nextToken : null;
|
||||||
for (const f of page.folders) {
|
for (const f of page.folders) {
|
||||||
|
|
@ -188,8 +205,15 @@ export class EncryptingS3Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
async putObject(bucket, key, body, { contentType, contentLength } = {}) {
|
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);
|
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);
|
const ek = encryptPath(dk, key);
|
||||||
if (key.endsWith('/')) return this.inner.putObject(bucket, ek, Buffer.alloc(0), { contentLength: 0 });
|
if (key.endsWith('/')) return this.inner.putObject(bucket, ek, Buffer.alloc(0), { contentLength: 0 });
|
||||||
const len = Number(contentLength ?? (Buffer.isBuffer(body) ? body.length : NaN));
|
const len = Number(contentLength ?? (Buffer.isBuffer(body) ? body.length : NaN));
|
||||||
|
|
@ -308,22 +332,54 @@ function withStatus(stream, statusCode, headers) {
|
||||||
// ---- moving the data folder ----------------------------------------------------------------
|
// ---- moving the data folder ----------------------------------------------------------------
|
||||||
|
|
||||||
// What travels: the database and objects still waiting for Sia. Logs, temp
|
// What travels: the database and objects still waiting for Sia. Logs, temp
|
||||||
// files, the local config and earlier accounts stay on this computer.
|
// files, the local config, the key file and earlier accounts stay on this
|
||||||
const SKIP_TOP = new Set(['s3d.yml', 'pithos-prefs.json', 'pithos-hosted-secret.json', 'pithos-archive.json', 'tmp', 's3d.log']);
|
// computer, and a path naming any of them is refused in either direction.
|
||||||
export function dataFiles(dataDir) {
|
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 out = [];
|
||||||
const rec = (rel) => {
|
const rec = async (rel) => {
|
||||||
for (const e of fs.readdirSync(path.join(dataDir, rel), { withFileTypes: true })) {
|
for (const e of fs.readdirSync(path.join(dataDir, rel), { withFileTypes: true })) {
|
||||||
const r = rel ? `${rel}/${e.name}` : e.name;
|
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 (!rel && skipTop(e.name)) continue;
|
||||||
if (e.isDirectory()) rec(r);
|
if (e.isDirectory()) await rec(r);
|
||||||
else if (e.isFile()) {
|
else if (e.isFile() && isTransferPath(r)) out.push({ path: r, ...(await hashFile(path.join(dataDir, r))) });
|
||||||
const data = fs.readFileSync(path.join(dataDir, r));
|
|
||||||
out.push({ path: r, size: data.length, sha256: crypto.createHash('sha256').update(data).digest('hex') });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
rec('');
|
await rec('');
|
||||||
return out;
|
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)}`, {
|
const res = await fetch(`${g.base}/hosted/v1/data/file?path=${encodeURIComponent(f.path)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { authorization: g.auth, 'content-length': String(f.size) },
|
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',
|
duplex: 'half',
|
||||||
});
|
});
|
||||||
const r = await res.json().catch(() => ({}));
|
const r = await res.json().catch(() => ({}));
|
||||||
|
|
@ -346,22 +402,34 @@ export async function uploadData(g, dataDir, files, onProgress = () => {}) {
|
||||||
onProgress({ phase: 'upload', sent, total });
|
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;
|
let got = 0;
|
||||||
const total = files.reduce((n, f) => n + f.size, 0);
|
const total = files.reduce((n, f) => n + (Number(f.size) || 0), 0);
|
||||||
for (const f of files) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const f = files[i];
|
||||||
|
const target = targets[i];
|
||||||
onProgress({ phase: 'download', file: f.path, got, total });
|
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 });
|
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 } });
|
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`);
|
if (!res.ok) throw new HostedError(res.status, (await res.json().catch(() => ({}))).error || `download of ${f.path} failed`);
|
||||||
const hash = crypto.createHash('sha256');
|
const hash = crypto.createHash('sha256');
|
||||||
const src = Readable.fromWeb(res.body);
|
const src = Readable.fromWeb(res.body);
|
||||||
src.on('data', (c) => hash.update(c));
|
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`);
|
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 });
|
onProgress({ phase: 'download', got, total });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
98
core/sia-account.js
Normal file
98
core/sia-account.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,12 +48,22 @@ export function createGate(root, cfg, { fetchImpl = fetch, electrum = electrumCa
|
||||||
const state = readJson(file, { names: {}, txids: {}, invites: {}, ip: {} });
|
const state = readJson(file, { names: {}, txids: {}, invites: {}, ip: {} });
|
||||||
const save = () => writeJson(file, state);
|
const save = () => writeJson(file, state);
|
||||||
const today = () => new Date().toISOString().slice(0, 10);
|
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) {
|
async function bns({ name, signature }, id) {
|
||||||
if (!cfg.bns) throw gateError('BNS sign-up is not enabled here');
|
if (!cfg.bns) throw gateError('BNS sign-up is not enabled here');
|
||||||
const n = String(name || '').trim().toLowerCase();
|
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 (!/^[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 r = await fetchImpl(`${cfg.bns.api.replace(/\/$/, '')}/api/name/${encodeURIComponent(n)}`);
|
||||||
const info = await r.json().catch(() => ({}));
|
const info = await r.json().catch(() => ({}));
|
||||||
if (!r.ok || !info.registered || !info.owner) throw gateError(`${n} is not a registered BNS name`);
|
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');
|
if (!d) throw gateError('deposit sign-up is not enabled here');
|
||||||
const t = String(txid || '').trim().toLowerCase();
|
const t = String(txid || '').trim().toLowerCase();
|
||||||
if (!/^[0-9a-f]{64}$/.test(t)) throw gateError('enter the 64-character transaction id');
|
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]);
|
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');
|
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);
|
const want = cashAddressToLockingBytecode(d.address);
|
||||||
|
|
@ -90,7 +100,7 @@ export function createGate(root, cfg, { fetchImpl = fetch, electrum = electrumCa
|
||||||
function invite({ code }, id) {
|
function invite({ code }, id) {
|
||||||
const c = String(code || '').trim();
|
const c = String(code || '').trim();
|
||||||
if (!(cfg.invites || []).includes(c)) throw gateError('this invite code is not valid');
|
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 };
|
return { type: 'invite', code: c };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,19 +116,28 @@ export function createGate(root, cfg, { fetchImpl = fetch, electrum = electrumCa
|
||||||
const day = today();
|
const day = today();
|
||||||
for (const key of Object.keys(state.ip)) if (!key.startsWith(day)) delete state.ip[key];
|
for (const key of Object.keys(state.ip)) if (!key.startsWith(day)) delete state.ip[key];
|
||||||
const ipKey = `${day} ${ip}`;
|
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;
|
const kind = proof?.type;
|
||||||
let r;
|
if (!['bns', 'deposit', 'invite'].includes(kind)) throw gateError('choose how to open your slot: BNS name, deposit or invite', 400);
|
||||||
if (kind === 'bns') r = await bns(proof, id);
|
// The IP count goes up now and comes back down on failure.
|
||||||
else if (kind === 'deposit') r = await deposit(proof, id);
|
if ((state.ip[ipKey] || 0) >= (cfg.perIpPerDay ?? 3)) throw gateError('too many sign-ups from this network today', 429);
|
||||||
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;
|
|
||||||
state.ip[ipKey] = (state.ip[ipKey] || 0) + 1;
|
state.ip[ipKey] = (state.ip[ipKey] || 0) + 1;
|
||||||
save();
|
const what = kind === 'bns' ? `n:${String(proof.name || '').trim().toLowerCase()}`
|
||||||
return r;
|
: 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();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
146
hosted/server.js
146
hosted/server.js
|
|
@ -25,6 +25,7 @@ import { createSupervisor, httpError } from './supervisor.js';
|
||||||
import { parseAuth, verify, SigError } from './sigv4.js';
|
import { parseAuth, verify, SigError } from './sigv4.js';
|
||||||
import { validateRule, classify, decideGrant, publicRuleFor, live, forOwner } from './rules.js';
|
import { validateRule, classify, decideGrant, publicRuleFor, live, forOwner } from './rules.js';
|
||||||
import { readJson, writeJson } from './store.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
|
// Owner API calls the gateway forwards. Everything that would act on the
|
||||||
// server itself (binary, s3d.yml, Sia login, account switching) is 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) {
|
export async function createHosted(cfg, log = console.log) {
|
||||||
fs.mkdirSync(cfg.root, { recursive: true });
|
fs.mkdirSync(cfg.root, { recursive: true });
|
||||||
const auth = createAuth(cfg.root);
|
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 grantsFile = path.join(cfg.root, 'grants.json');
|
||||||
const linksFile = path.join(cfg.root, 'links.json');
|
const linksFile = path.join(cfg.root, 'links.json');
|
||||||
const grants = readJson(grantsFile, {}); // accessKeyId -> { id, rule }
|
const grants = readJson(grantsFile, {}); // accessKeyId -> { id, rule }
|
||||||
const links = readJson(linksFile, {}); // token -> { 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 rulesFile = (id) => path.join(sup.dir(id), 'rules.json');
|
||||||
const rulesOf = (id) => readJson(rulesFile(id), []);
|
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') {
|
if (url.pathname === '/api/status' && a.state !== 'active') {
|
||||||
return json(res, 200, hostedStatus(a, { daemon: { state: 'stopped' }, registration: { registered: false } }));
|
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 inst = await sup.ensure(id);
|
||||||
const release = sup.hold(inst);
|
const release = sup.hold(inst);
|
||||||
try {
|
try {
|
||||||
|
|
@ -105,10 +114,10 @@ export async function createHosted(cfg, log = console.log) {
|
||||||
const up = await new Promise((resolve, reject) => {
|
const up = await new Promise((resolve, reject) => {
|
||||||
const r = http.request(target, {
|
const r = http.request(target, {
|
||||||
method: req.method,
|
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);
|
}, resolve);
|
||||||
r.on('error', reject);
|
r.on('error', reject);
|
||||||
req.pipe(r);
|
r.end(reqBody ?? undefined);
|
||||||
});
|
});
|
||||||
const text = await readAll(up);
|
const text = await readAll(up);
|
||||||
if (req.method !== 'GET' && KEY_CHANGES.test(url.pathname)) await sup.indexKeys(inst);
|
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');
|
const incomingManifest = (id) => path.join(sup.dir(id), 'incoming.json');
|
||||||
|
|
||||||
function safeRel(rel) {
|
function safeRel(rel) {
|
||||||
const r = String(rel || '').replace(/\\/g, '/');
|
if (!isTransferPath(rel)) throw httpError(400, 'bad file path');
|
||||||
if (!r || r.startsWith('/') || r.split('/').some((s) => s === '..' || s === '' || s === '.') || /^[A-Za-z]:/.test(r)) throw httpError(400, 'bad file path');
|
return rel;
|
||||||
return r;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
async function receiveFile(req, id, rel) {
|
||||||
const a = sup.account(id);
|
const a = sup.account(id);
|
||||||
if (a.state !== 'empty') throw httpError(409, 'drives are already hosted here; move them back to your computer first');
|
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));
|
const name = safeRel(rel);
|
||||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
if (receiving.has(id)) throw httpError(409, 'send one file at a time');
|
||||||
const hash = crypto.createHash('sha256');
|
receiving.add(id);
|
||||||
let size = 0;
|
const file = transferTarget(incomingDir(id), name);
|
||||||
const man = readJson(incomingManifest(id), {});
|
try {
|
||||||
const total = Object.values(man).reduce((n, f) => n + f.size, 0);
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
const limit = cfg.maxDataBytes || 4 * 1024 ** 3;
|
fs.rmSync(file, { force: true });
|
||||||
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')); });
|
const limit = cfg.maxDataBytes || 4 * 1024 ** 3;
|
||||||
await pipeline(req, fs.createWriteStream(file));
|
const room = limit - diskBytes(incomingDir(id));
|
||||||
man[safeRel(rel)] = { size, sha256: hash.digest('hex') };
|
const declared = Number(req.headers['content-length']);
|
||||||
writeJson(incomingManifest(id), man);
|
if (Number.isFinite(declared) && declared > room) throw httpError(413, 'the data folder is larger than this server accepts');
|
||||||
return man[safeRel(rel)];
|
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) {
|
async function commit(id, files) {
|
||||||
|
|
@ -166,7 +205,7 @@ export async function createHosted(cfg, log = console.log) {
|
||||||
const man = readJson(incomingManifest(id), {});
|
const man = readJson(incomingManifest(id), {});
|
||||||
if (!Array.isArray(files) || !files.length) throw httpError(400, 'no files listed');
|
if (!Array.isArray(files) || !files.length) throw httpError(400, 'no files listed');
|
||||||
for (const f of files) {
|
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 (!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');
|
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.renameSync(incomingDir(id), sup.dataDir(id));
|
||||||
fs.rmSync(incomingManifest(id), { force: true });
|
fs.rmSync(incomingManifest(id), { force: true });
|
||||||
sup.setState(id, 'active');
|
sup.setState(id, 'active');
|
||||||
let inst;
|
// Anything wrong with the folder puts the account back to empty, so the
|
||||||
try {
|
// owner's computer (which asks the server's state) keeps its copy.
|
||||||
inst = await sup.ensure(id);
|
const refuse = async (status, message) => {
|
||||||
} catch (e) {
|
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');
|
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 }));
|
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);
|
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 out = [];
|
||||||
const rec = (d) => {
|
const rec = async (d) => {
|
||||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||||
const p = path.join(d, e.name);
|
const p = path.join(d, e.name);
|
||||||
if (e.isDirectory()) rec(p);
|
const rel = path.relative(base, p).split(path.sep).join('/');
|
||||||
else if (e.isFile()) out.push(p);
|
if (e.isDirectory()) await rec(p);
|
||||||
|
else if (e.isFile() && isTransferPath(rel)) out.push({ path: rel, ...(await hashFile(p)) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
rec(base);
|
await rec(base);
|
||||||
return out.map((p) => {
|
return out;
|
||||||
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') };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkout(id, force) {
|
async function checkout(id, force) {
|
||||||
const a = sup.account(id);
|
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');
|
if (a.state !== 'active') throw httpError(409, 'nothing is hosted here');
|
||||||
const inst = await sup.ensure(id);
|
const inst = await sup.ensure(id);
|
||||||
const flushed = await sup.flushQuietly(inst, 10 * 60_000);
|
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');
|
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');
|
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) {
|
function release(id) {
|
||||||
|
|
@ -227,30 +273,36 @@ export async function createHosted(cfg, log = console.log) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- S3 ----------------------------------------------------------------------
|
// ---- 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) {
|
function splitS3Path(rawPath) {
|
||||||
const parts = rawPath.split('/').slice(1);
|
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 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 };
|
return { bucket, key };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function s3(req, res, url) {
|
async function s3(req, res, url) {
|
||||||
const a = parseAuth(req, 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>');
|
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) {
|
if (ownerId) {
|
||||||
checkReadCap(req, ownerId);
|
checkReadCap(req, ownerId);
|
||||||
const inst = await sup.ensure(ownerId);
|
const inst = await sup.ensure(ownerId);
|
||||||
return rawProxy(req, res, inst);
|
return rawProxy(req, res, inst);
|
||||||
}
|
}
|
||||||
const g = grants[a.akid];
|
|
||||||
const rule = g && ruleById(g.id, g.rule);
|
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.');
|
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);
|
verify(req, url, a, rule.secretKey);
|
||||||
const raw = req.url.split('?')[0];
|
const raw = req.url.split('?')[0];
|
||||||
const { bucket, key } = splitS3Path(raw);
|
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 { op } = classify(req.method, query, key, req.headers);
|
||||||
const d = decideGrant(rule, bucket, op, key, query.prefix || '');
|
const d = decideGrant(rule, bucket, op, key, query.prefix || '');
|
||||||
if (!d.allow) return s3Error(res, 403, 'AccessDenied', d.reason);
|
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);
|
const m = /^\/p\/([0-9a-f]{32})\/([^/]+)\/?(.*)$/.exec(url.pathname);
|
||||||
if (!m) throw httpError(404, 'not found');
|
if (!m) throw httpError(404, 'not found');
|
||||||
const [, id, bucket] = m;
|
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);
|
const rule = publicRuleFor(rulesOf(id), bucket, key);
|
||||||
if (!rule) throw httpError(404, 'not found');
|
if (!rule) throw httpError(404, 'not found');
|
||||||
if (key === '' || key.endsWith('/')) {
|
if (key === '' || key.endsWith('/')) {
|
||||||
|
|
@ -359,7 +413,7 @@ export async function createHosted(cfg, log = console.log) {
|
||||||
|
|
||||||
async function serveLink(req, res, url) {
|
async function serveLink(req, res, url) {
|
||||||
const token = url.pathname.slice(3);
|
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);
|
const rule = l && ruleById(l.id, l.rule);
|
||||||
if (!rule || !live(rule)) throw httpError(410, 'this link has expired');
|
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') });
|
return serveObject(req, res, l.id, rule.bucket, rule.prefix, { inline: !url.searchParams.has('download') });
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,15 @@ import { S3Client } from '../core/s3.js';
|
||||||
import * as admin from '../core/admin.js';
|
import * as admin from '../core/admin.js';
|
||||||
import { readJson, writeJson } from './store.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 usersDir = path.join(cfg.root, 'users');
|
||||||
const slotsFile = path.join(cfg.root, 'slots.json');
|
const slotsFile = path.join(cfg.root, 'slots.json');
|
||||||
const keysFile = path.join(cfg.root, 'keys.json');
|
const keysFile = path.join(cfg.root, 'keys.json');
|
||||||
const running = new Map(); // id -> instance
|
const running = new Map(); // id -> instance
|
||||||
const loading = new Map(); // id -> Promise<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 keyIndex = readJson(keysFile, {}); // accessKeyId -> account id (owners' s3d keys)
|
||||||
|
|
||||||
const dir = (id) => path.join(usersDir, id);
|
const dir = (id) => path.join(usersDir, id);
|
||||||
|
|
@ -71,6 +74,8 @@ export function createSupervisor(cfg, log = console.log) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(id) {
|
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 (running.has(id)) return running.get(id);
|
||||||
if (loading.has(id)) return loading.get(id);
|
if (loading.has(id)) return loading.get(id);
|
||||||
const p = (async () => {
|
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 === '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');
|
if (a.state === 'moving-out') throw httpError(409, 'this account is being moved to its owner\'s computer');
|
||||||
const inst = await load(id);
|
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();
|
inst.lastUsed = Date.now();
|
||||||
const d = inst.pithos.daemon;
|
const d = inst.pithos.daemon;
|
||||||
if (d.state !== 'running') {
|
if (d.state !== 'running') {
|
||||||
|
|
@ -107,17 +114,32 @@ export function createSupervisor(cfg, log = console.log) {
|
||||||
return inst;
|
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) {
|
async function indexKeys(inst) {
|
||||||
const keys = await inst.cli.listKeys().catch(() => []);
|
const keys = await inst.cli.listKeys().catch(() => []);
|
||||||
let changed = false;
|
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, 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);
|
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();
|
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.
|
// Every drive on the account, mapped to a key of the s3d user owning it.
|
||||||
async function mapBuckets(inst) {
|
async function mapBuckets(inst) {
|
||||||
inst.buckets.clear();
|
inst.buckets.clear();
|
||||||
|
|
@ -140,11 +162,16 @@ export function createSupervisor(cfg, log = console.log) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function close(id, reason) {
|
async function close(id, reason) {
|
||||||
|
if (closing.has(id)) return closing.get(id);
|
||||||
const inst = running.get(id);
|
const inst = running.get(id);
|
||||||
if (!inst) return;
|
if (!inst) return;
|
||||||
running.delete(id);
|
const p = (async () => {
|
||||||
log(`[${id.slice(0, 8)}] stopping s3d (${reason})`);
|
log(`[${id.slice(0, 8)}] stopping s3d (${reason})`);
|
||||||
await inst.pithos.close().catch((e) => log(`[${id.slice(0, 8)}] close failed: ${e.message}`));
|
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) {
|
async function flushQuietly(inst, timeoutMs = 120_000) {
|
||||||
|
|
@ -161,7 +188,8 @@ export function createSupervisor(cfg, log = console.log) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function makeRoom() {
|
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);
|
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');
|
if (!idle.length) throw httpError(503, 'the server is busy; try again in a minute');
|
||||||
await flushQuietly(idle[0]);
|
await flushQuietly(idle[0]);
|
||||||
|
|
@ -171,7 +199,7 @@ export function createSupervisor(cfg, log = console.log) {
|
||||||
async function sweep() {
|
async function sweep() {
|
||||||
const cutoff = Date.now() - cfg.idleMinutes * 60_000;
|
const cutoff = Date.now() - cfg.idleMinutes * 60_000;
|
||||||
for (const inst of [...running.values()]) {
|
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);
|
await flushQuietly(inst);
|
||||||
if (!inst.busy && inst.lastUsed <= cutoff) await close(inst.id, 'idle');
|
if (!inst.busy && inst.lastUsed <= cutoff) await close(inst.id, 'idle');
|
||||||
}
|
}
|
||||||
|
|
@ -189,8 +217,8 @@ export function createSupervisor(cfg, log = console.log) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dir, dataDir, configFile, account, createAccount, saveAccount, setState,
|
dir, dataDir, configFile, account, createAccount, saveAccount, setState,
|
||||||
load, ensure, close, flushQuietly, bucketClient, mapBuckets, indexKeys, hold,
|
load, ensure, close, flushQuietly, bucketClient, mapBuckets, indexKeys, hold, keyTaken,
|
||||||
ownerOfKey: (akid) => keyIndex[akid] || null,
|
ownerOfKey: (akid) => (Object.hasOwn(keyIndex, akid) ? keyIndex[akid] : null),
|
||||||
async closeAll() {
|
async closeAll() {
|
||||||
clearInterval(sweeper);
|
clearInterval(sweeper);
|
||||||
for (const id of [...running.keys()]) {
|
for (const id of [...running.keys()]) {
|
||||||
|
|
|
||||||
|
|
@ -144,3 +144,18 @@ test('gate: deposit needs amount, memo and a confirmation', async () => {
|
||||||
assert.equal(r.sats, 10000);
|
assert.equal(r.sats, 10000);
|
||||||
await assert.rejects(gate.admit({ type: 'deposit', txid: '1'.repeat(64) }, 'e'.repeat(32), 'ip'), /already opened/);
|
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
103
test/hosted.test.js
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue