diff --git a/core/accounts.js b/core/accounts.js index ba83434..ebfc5f5 100644 --- a/core/accounts.js +++ b/core/accounts.js @@ -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'); diff --git a/core/blake2b.js b/core/blake2b.js new file mode 100644 index 0000000..4e9f7f2 --- /dev/null +++ b/core/blake2b.js @@ -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); diff --git a/core/hosted-routes.js b/core/hosted-routes.js index 6a3859f..b963f79 100644 --- a/core/hosted-routes.js +++ b/core/hosted-routes.js @@ -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 diff --git a/core/hosted.js b/core/hosted.js index 404c448..cb0f12a 100644 --- a/core/hosted.js +++ b/core/hosted.js @@ -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 }); } diff --git a/core/sia-account.js b/core/sia-account.js new file mode 100644 index 0000000..d2dead5 --- /dev/null +++ b/core/sia-account.js @@ -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); + } +} diff --git a/hosted/gate.js b/hosted/gate.js index ab373bf..388c68d 100644 --- a/hosted/gate.js +++ b/hosted/gate.js @@ -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(); + } }, }; } diff --git a/hosted/server.js b/hosted/server.js index df1020d..276991c 100644 --- a/hosted/server.js +++ b/hosted/server.js @@ -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///'); - 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') }); diff --git a/hosted/supervisor.js b/hosted/supervisor.js index c03c038..e89cebe 100644 --- a/hosted/supervisor.js +++ b/hosted/supervisor.js @@ -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 + 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()]) { diff --git a/hosted/test/gateway.test.js b/hosted/test/gateway.test.js index 7d1bf54..f17c383 100644 --- a/hosted/test/gateway.test.js +++ b/hosted/test/gateway.test.js @@ -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); +}); diff --git a/test/hosted.test.js b/test/hosted.test.js new file mode 100644 index 0000000..9e0e846 --- /dev/null +++ b/test/hosted.test.js @@ -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); +});