diff --git a/core/server.js b/core/server.js index bfb486f..ff41514 100644 --- a/core/server.js +++ b/core/server.js @@ -25,6 +25,7 @@ import * as accounts from './accounts.js'; import { readPrefs, writePrefs } from './prefs.js'; import { createAutoFlush, clampMinutes, DEFAULT_MINUTES } from './autoflush.js'; import { installHosted } from './hosted-routes.js'; +import { accountInfo, readConnection, fingerprint } from './sia-account.js'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const UI_DIR = path.join(HERE, '..', 'ui'); @@ -258,15 +259,36 @@ export async function createPithos(opts = {}) { // Upload pipeline // Sia account: which one this s3d uses, a user-set label (Pithos cannot // see the account's email), switching to another and restoring an old one. - route('GET', '/api/account', async () => { + // Which Sia account this is, as the indexer sees it (public key, storage, + // last use), so it can be matched to an app on the sia.storage dashboard. + // Asked at most once a minute; ?fresh=1 skips the cache. + let siaCache = null; + async function siaAccount(dir, fresh) { + if (!fresh && siaCache && siaCache.dir === dir && Date.now() - siaCache.at < 60_000) return siaCache.info; + let info; + try { info = await accountInfo(dir); } catch (e) { info = { error: e.message }; } + siaCache = { dir, at: Date.now(), info }; + return info; + } + // An archive's key is read from its own s3d.db, without asking anyone. + async function archiveKey(dir) { + try { + const c = await readConnection(dir); + return c?.appKey ? fingerprint(c.appKey.subarray(32).toString('hex')) : null; + } catch { return null; } + } + route('GET', '/api/account', async (req, url) => { const c = cfg(); const reg = await registration(); + const archives = accounts.listArchives(c.directory); + for (const a of archives) a.fingerprint = await archiveKey(path.join(c.directory, a.name)); return { indexerUrl: reg?.indexerUrl || null, registered: !!reg?.registered, label: accounts.readLabel(c.directory), dataDir: c.directory, - archives: accounts.listArchives(c.directory), + sia: reg?.registered ? await siaAccount(c.directory, url.searchParams.get('fresh') === '1') : null, + archives, }; }); route('PUT', '/api/account/label', async (req) => { diff --git a/hosted/server.js b/hosted/server.js index 276991c..cc4981b 100644 --- a/hosted/server.js +++ b/hosted/server.js @@ -125,7 +125,7 @@ export async function createHosted(cfg, log = console.log) { if (up.statusCode === 200 && url.pathname === '/api/status') body = JSON.stringify(hostedStatus(a, JSON.parse(text))); if (up.statusCode === 200 && url.pathname === '/api/account') { const acc = JSON.parse(text); - body = JSON.stringify({ indexerUrl: acc.indexerUrl, registered: acc.registered, label: acc.label, archives: [], hosted: true }); + body = JSON.stringify({ indexerUrl: acc.indexerUrl, registered: acc.registered, label: acc.label, sia: acc.sia || null, archives: [], hosted: true }); } res.writeHead(up.statusCode, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(body); diff --git a/test/sia-account.test.js b/test/sia-account.test.js new file mode 100644 index 0000000..8dab175 --- /dev/null +++ b/test/sia-account.test.js @@ -0,0 +1,42 @@ +// Telling which Sia account an s3d uses: BLAKE2b-256 against RFC 7693, and +// requests signed the way indexd's app API checks them (api/app/auth.go). + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import { blake2b, blake2b256 } from '../core/blake2b.js'; +import { signRequest, fingerprint } from '../core/sia-account.js'; + +test('BLAKE2b matches RFC 7693 and OpenSSL', () => { + assert.equal(blake2b256('').toString('hex'), '0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8'); + assert.equal(blake2b('abc', 64).toString('hex'), 'ba80a53f981c4d0d6a2797b69f12f6e94c212f14685ac4b74b12bb6fdbffa2d17d87c5392aab792dc252d5de4533cc9518d38aa8dbf1925ab92386edd4009923'); + for (const n of [1, 127, 128, 129, 255, 256, 257, 1000]) { + const b = crypto.randomBytes(n); + assert.ok(blake2b(b, 64).equals(crypto.createHash('blake2b512').update(b).digest()), `length ${n}`); + } +}); + +test('signed /account request: what indexd verifies', () => { + // A Go types.PrivateKey: 32-byte seed followed by the public key. + const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519'); + const seed = privateKey.export({ format: 'der', type: 'pkcs8' }).subarray(-32); + const pub = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32); + const appKey = Buffer.concat([seed, pub]); + const sv = 1_900_000_000; + const u = signRequest(appKey, 'GET', 'https://sia.storage/account', sv); + const q = u.searchParams; + // Go's base64.URLEncoding: URL-safe alphabet, with padding. + assert.match(q.get('sc'), /^[A-Za-z0-9_-]{43}=$/); + assert.match(q.get('ss'), /^[A-Za-z0-9_-]{86}==$/); + assert.equal(q.get('sv'), String(sv)); + assert.ok(Buffer.from(q.get('sc'), 'base64').equals(pub)); + const exp = Buffer.alloc(8); + exp.writeBigUInt64LE(BigInt(sv)); + const digest = blake2b256(Buffer.concat([Buffer.from('GET'), Buffer.from('sia.storage'), Buffer.from('/account'), exp])); + assert.ok(crypto.verify(null, digest, publicKey, Buffer.from(q.get('ss'), 'base64'))); + // Host and path come from the indexer URL, port included. + const v = signRequest(appKey, 'GET', 'http://127.0.0.1:9880/base/account', sv); + const d2 = blake2b256(Buffer.concat([Buffer.from('GET127.0.0.1:9880/base/account'), exp])); + assert.ok(crypto.verify(null, d2, publicKey, Buffer.from(v.searchParams.get('ss'), 'base64'))); + assert.equal(fingerprint('3cc1a2f2' + '0'.repeat(52) + 'ac52'), 'ed25519:3cc1a2f2…ac52'); +}); diff --git a/ui/app.js b/ui/app.js index 34c0838..d9e94af 100644 --- a/ui/app.js +++ b/ui/app.js @@ -684,7 +684,8 @@ function setup(main) { h('label', { class: 'field', style: 'max-width:420px' }, h('span', {}, 'Account label'), h('div', { class: 'row', style: 'flex-wrap:nowrap' }, label, h('button', { class: 'btn', onclick: async () => { try { await api('PUT', '/api/account/label', { label: label.value }); await loadAccount(); toast('Label saved'); } catch (e) { fail(e); } } }, 'Save')), - h('small', {}, 'Pithos cannot see which login the account belongs to. On sia.storage it is the account whose "My apps" lists "S3d".')), + h('small', {}, 'Pithos cannot see the login email, so name it here: the email you approved the connection with is a good label.')), + siaAccountBlock(accountInfo, async () => { try { accountInfo = await api('GET', '/api/account?fresh=1'); draw(); } catch (e) { fail(e); } }), h('div', { class: 'row', style: 'margin-top:16px' }, daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); location.hash = '#/overview'; } }, 'Start s3d'), h('a', { class: 'btn', href: '#/users' }, 'Users & keys'), @@ -761,7 +762,7 @@ function setup(main) { h('p', { class: 'small muted', style: 'margin-top:0' }, 'Set aside by "Switch Sia account". Restoring one puts its users, keys and buckets back (the current account is set aside the same way).'), h('table', {}, h('tbody', {}, list.map((a) => h('tr', {}, h('td', {}, h('strong', {}, a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name)), - h('div', { class: 'small muted' }, `${a.archivedAt ? fmtDate(a.archivedAt) : a.name}${a.users.length ? ` · users: ${a.users.join(', ')}` : ''}`)), + h('div', { class: 'small muted' }, `${a.archivedAt ? fmtDate(a.archivedAt) : a.name}${a.fingerprint ? ` · key ${a.fingerprint}` : ''}${a.users.length ? ` · users: ${a.users.join(', ')}` : ''}`)), h('td', { class: 'actions' }, h('button', { class: 'btn small', disabled: !a.hasData, onclick: () => restoreAccount(a) }, 'Restore'))))))); } @@ -1141,7 +1142,33 @@ async function loadAccount() { try { accountInfo = await api('GET', '/api/account'); } catch { accountInfo = null; } return accountInfo; } -const accountName = (a) => (a && (a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : ''))) || 'not connected'; +const accountName = (a) => (a && (a.label || a.sia?.fingerprint || (a.indexerUrl ? new URL(a.indexerUrl).host : ''))) || 'not connected'; + +// What the indexer says about this s3d's Sia account. The figures are the +// ones sia.storage shows for the app, which is how a person tells which of +// their accounts (or apps) this is. +function siaAccountBlock(a, onRefresh) { + const sia = a?.sia; + if (!sia?.connected) return null; + const used = sia.pinnedData != null + ? `${fmtBytes(sia.pinnedData)}${sia.maxPinnedData ? ` of ${fmtBytes(sia.maxPinnedData)}` : ''}` + : '–'; + return h('div', { class: 'card', style: 'margin:14px 0;background:var(--surface-2)' }, + h('div', { class: 'row spread' }, h('h3', { style: 'margin:0' }, 'Which Sia account this is'), + onRefresh && h('button', { class: 'btn small', type: 'button', onclick: onRefresh }, 'Check again')), + sia.error && h('p', { class: 'small error' }, `The indexer did not answer: ${sia.error}`), + h('dl', { class: 'kv' }, + h('dt', {}, 'Account key'), h('dd', {}, h('code', { title: sia.publicKey }, sia.fingerprint), ' ', + h('button', { class: 'btn small', type: 'button', onclick: () => copy(`ed25519:${sia.publicKey}`, 'Account key copied') }, 'Copy')), + h('dt', {}, 'App'), h('dd', {}, sia.app?.name || 'S3d'), + h('dt', {}, 'Stored'), h('dd', {}, used), + h('dt', {}, 'Last used'), h('dd', {}, sia.lastUsed ? fmtDate(sia.lastUsed) : '–'), + h('dt', {}, 'Indexer'), h('dd', {}, h('code', {}, sia.indexerUrl))), + h('p', { class: 'small muted', style: 'margin:8px 0 0' }, + 'On sia.storage, the account whose app "', sia.app?.name || 'S3d', '" shows this storage and last-used time is this one. ', + 'The account is decided by two things together: the sia.storage login that approved the connection, and the recovery phrase. ', + 'The same phrase approved from two different logins makes two different accounts; the same phrase from the same login is the same account, even on another computer.')); +} // "Sia account: …" line for page headers; fills itself in. function accountLine() {