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.
This commit is contained in:
parent
cdbc8ad93b
commit
afb74362b8
4 changed files with 97 additions and 6 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
42
test/sia-account.test.js
Normal file
42
test/sia-account.test.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
33
ui/app.js
33
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() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue