New installs carry the same Pithos the extension channel serves, including drives on Silent Mode and the Sia account card.
98 lines
4.6 KiB
JavaScript
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);
|
|
}
|
|
}
|