From afb74362b810a1583fc3482d6f9c20e5e9ad32f1 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 03:19:02 +0200 Subject: [PATCH] Pithos: show which Sia account an s3d is connected to A person with several sia.storage accounts (or phrases) could not tell which one a Pithos s3d used: s3d records only the indexer URL. Pithos now reads the app key s3d stores after login and asks the indexer, with a request signed the way indexd's app API expects, for the account's key, storage used and last use. Those match what sia.storage shows for the app, and the page explains that the login and the phrase together decide the account. Earlier accounts set aside on this computer show their key too. --- core/server.js | 26 +++++++++++++++++++++++-- hosted/server.js | 2 +- test/sia-account.test.js | 42 ++++++++++++++++++++++++++++++++++++++++ ui/app.js | 33 ++++++++++++++++++++++++++++--- 4 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 test/sia-account.test.js 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() {