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