theseus/bundled-addons/pithos/core/sia-account.js
Local Dev d355bbbf87 Theseus: bundle Pithos 0.3.11
New installs carry the same Pithos the extension channel serves, including
drives on Silent Mode and the Sia account card.
2026-10-04 03:36:50 +02:00

98 lines
4.6 KiB
JavaScript

// 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);
}
}