From cacbdd571f4f04760f910be95a00f7f51423ea9b Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 20:42:21 +0200 Subject: [PATCH] Pithos 0.3.0: PIN gate and a recovery phrase from the Theseus vault In Theseus, Pithos now opens only with the vault unlocked: it asks Theseus for the unlock (api.vault.requestUnlock), and Theseus shows its own PIN prompt, falling back to the master password after three wrong tries. Pithos never sees either. When the vault locks (Settings > Lock now) Pithos locks within a few seconds; the add-on watches from the main process because a hidden panel's timers run about once a minute. The guide's phrase step gains "Use my Theseus vault" (the default when a vault exists): the add-on derives the 12-word phrase from the vault (purpose path pithos/sia-recovery/v1, first 16 bytes as BIP-39 entropy) and hands it straight to s3d, so there is nothing to write down and the vault backup restores storage access. The path is now fixed; changing it would cut users off from their data. Needs the vault-derive capability. On a Theseus without requestUnlock it runs ungated, as before. --- package.json | 2 +- theseus/addon.json | 5 ++-- theseus/index.js | 62 ++++++++++++++++++++++++++++++++++++++++++++++ ui/app.js | 55 +++++++++++++++++++++++++++++++++++++--- ui/index.html | 9 +++++++ 5 files changed, 126 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 9a2ea96..7ed2d4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pithos", - "version": "0.2.0", + "version": "0.3.0", "description": "A control panel for s3d, the Sia S3 gateway: daemon, users and keys, buckets, upload pipeline.", "license": "MPL-2.0", "type": "module", diff --git a/theseus/addon.json b/theseus/addon.json index 60fd25f..b3e6c81 100644 --- a/theseus/addon.json +++ b/theseus/addon.json @@ -1,13 +1,14 @@ { "id": "pithos", "name": "Pithos", - "version": "0.2.0", + "version": "0.3.0", "description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.", "author": "Silent Mode", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=", "main": "index.js", "capabilities": [ - "sidebar-panel" + "sidebar-panel", + "vault-derive" ], "updateURL": "https://navigate.st/bns/theseus.x/extensions/pithos/updates.json" } diff --git a/theseus/index.js b/theseus/index.js index b578dd2..4f366b5 100644 --- a/theseus/index.js +++ b/theseus/index.js @@ -114,6 +114,68 @@ module.exports = { return { ok: true }; }); + // ---- Theseus vault: PIN gate and the vault-derived recovery phrase ------ + // Older Theseus builds have the vault but no requestUnlock (no PIN prompt); + // Pithos then runs ungated, as before. + const vault = api.vault || null; + const canPrompt = !!(vault && typeof vault.requestUnlock === "function"); + + async function vaultStatus() { + if (!vault || !vault.lifecycle) return { setup: false, unlocked: false, prompt: false }; + const st = await vault.lifecycle.status(); + return { setup: !!st.setup, unlocked: !!st.unlocked, prompt: canPrompt }; + } + + api.onMessage("vault-status", () => vaultStatus()); + + // Watch for the vault locking (Settings › Lock now, or a lock from another + // extension) and tell the panel at once. This runs in the main process: + // a hidden panel's own timers are throttled to about once a minute. + if (canPrompt) { + let lastUnlocked = null; + setInterval(async () => { + try { + const st = await vaultStatus(); + if (st.unlocked !== lastUnlocked) { + lastUnlocked = st.unlocked; + api.emit("pithos-vault", st); + } + } catch {} + }, 3000).unref?.(); + } + + // Pithos shows access-key secrets and can delete buckets, so in Theseus it + // opens only with the vault unlocked: Theseus asks for the PIN (or the + // master password after three wrong tries) in its own prompt. + api.onMessage("gate", async () => { + const st = await vaultStatus(); + if (!st.setup || !st.prompt || st.unlocked) return { ok: true, ...st }; + const r = await vault.requestUnlock({ reason: "Open Pithos, your S3 storage on Sia." }); + return { ...r, ...(await vaultStatus()) }; + }); + + // Connect with a recovery phrase derived from the vault: nothing to write + // down, and restoring the vault restores access. The phrase is built and + // handed to s3d here; it never reaches the panel page. + // CHANGING THE PURPOSE PATH OR THE DERIVATION CUTS USERS OFF FROM THEIR DATA. + api.onMessage("connect-with-vault", async ({ indexerUrl } = {}) => { + const st = await vaultStatus(); + if (!st.setup) throw new Error("Set up the password vault in Settings › Passwords first."); + if (!st.unlocked) { + if (!canPrompt) throw new Error("Unlock the password vault in Settings › Passwords first."); + const r = await vault.requestUnlock({ reason: "Create the recovery phrase for your Sia storage." }); + if (!r.ok) throw new Error("The vault stayed locked."); + } + const bytes = await vault.derive("pithos/sia-recovery/v1"); + const bip39 = api.require("bip39"); + const phrase = bip39.entropyToMnemonic(Buffer.from(bytes.subarray(0, 16)).toString("hex")); + bytes.fill(0); + return call("POST", "/api/login", { + body: JSON.stringify({ indexerUrl, phrase }), + headers: { "content-type": "application/json" }, + }); + }); + api.registerSidebarPanel({ id: "main", title: "Pithos", page: "ui/index.html" }); // The host has no quit hook for add-ons; without this an s3d we started diff --git a/ui/app.js b/ui/app.js index e08f012..bc58eca 100644 --- a/ui/app.js +++ b/ui/app.js @@ -422,15 +422,19 @@ function getStarted(main) { case 3: { const phrase = h('textarea', { rows: 3, autocomplete: 'off', spellcheck: 'false', placeholder: 'twelve words separated by spaces', oninput: (e) => { wiz.phrase = e.target.value; } }); phrase.value = wiz.phrase; + const vaultOk = !!(bridge && vaultInfo && vaultInfo.setup); content = [ h('h2', {}, 'Your recovery phrase'), - h('p', {}, 'Twelve words that let you reconnect to the same storage later, on this computer or a new one. Your files cannot be reached without them, so keep them offline and safe.'), + h('p', {}, 'Twelve words that let you reconnect to the same storage later, on this computer or a new one. Your files cannot be reached without them.'), h('div', { class: 'stack' }, - h('label', { class: 'check' }, h('input', { type: 'radio', name: 'wm', checked: wiz.mode === 'new', onchange: () => { wiz.mode = 'new'; draw(); } }), 'Create a new phrase for me (first time)'), - h('label', { class: 'check' }, h('input', { type: 'radio', name: 'wm', checked: wiz.mode === 'existing', onchange: () => { wiz.mode = 'existing'; draw(); } }), 'I already have a phrase (reconnecting)')), + vaultOk && h('label', { class: 'check', style: 'align-items:flex-start' }, h('input', { type: 'radio', name: 'wm', checked: wiz.mode === 'vault', onchange: () => { wiz.mode = 'vault'; wiz.modeChosen = true; draw(); } }), + h('span', {}, h('strong', {}, 'Use my Theseus vault'), ' (recommended)', h('br'), h('span', { class: 'small muted' }, 'Nothing to write down. The phrase comes from your password vault, so backing up the vault backs up your storage too.'))), + h('label', { class: 'check' }, h('input', { type: 'radio', name: 'wm', checked: wiz.mode === 'new', onchange: () => { wiz.mode = 'new'; wiz.modeChosen = true; draw(); } }), 'Create a new phrase for me to write down'), + h('label', { class: 'check' }, h('input', { type: 'radio', name: 'wm', checked: wiz.mode === 'existing', onchange: () => { wiz.mode = 'existing'; wiz.modeChosen = true; draw(); } }), 'I already have a phrase (reconnecting)')), wiz.mode === 'existing' && h('label', { class: 'field', style: 'margin-top:12px' }, h('span', {}, 'Recovery phrase'), phrase, h('small', {}, 'Sent only to the s3d process on this computer.')), wiz.mode === 'new' && h('p', { class: 'muted small', style: 'margin-top:12px' }, 'You will see the new phrase on the next screen and confirm you saved it before anything is registered.'), + wiz.mode === 'vault' && h('p', { class: 'muted small', style: 'margin-top:12px' }, 'Theseus may ask for your PIN or master password to open the vault.'), nav({ nextLabel: 'Connect', onNext: () => { if (wiz.mode === 'existing' && wiz.phrase.trim().split(/\s+/).length !== 12) return toast('A recovery phrase is 12 words', 'error'); wiz.openedUrl = null; @@ -513,6 +517,13 @@ function getStarted(main) { async function startConnect() { if (state.status?.registration?.registered) return; + if (wiz.mode === 'vault' && bridge) { + try { + const r = await bridge.invoke('connect-with-vault', { indexerUrl: wiz.indexerUrl }); + if (r.status >= 400) throw new Error(r.data?.error || `HTTP ${r.status}`); + } catch (e) { fail(e); } + return; + } try { await api('POST', '/api/login', { indexerUrl: wiz.indexerUrl, phrase: wiz.mode === 'existing' ? wiz.phrase.trim() : '' }); wiz.phrase = ''; @@ -590,6 +601,7 @@ function getStarted(main) { } } + if (bridge && vaultInfo?.setup && !wiz.modeChosen) wiz.mode = 'vault'; // Resume where things stand if the user comes back mid-setup. if (wiz.step === 0 && state.login.state === 'approve') wiz.step = 4; draw(); @@ -1202,6 +1214,39 @@ function addSidebarControls() { $('.sidebar').insertBefore(row, $('#daemon-pill')); } +// Theseus: Pithos opens only with the vault unlocked. The add-on asks Theseus +// for the unlock, and Theseus shows its own PIN / master-password prompt. +let vaultInfo = null; +async function vaultGate() { + let r; + try { r = await bridge.invoke('gate'); } catch (e) { fail(e); r = { ok: false }; } + vaultInfo = r; + if (r.ok) { $('#locked').hidden = true; return true; } + $('#app').hidden = true; + $('#locked').hidden = false; + return false; +} + +// Re-lock when the vault locks (Settings › Lock now), so secrets are not left +// on screen. The add-on watches from the main process and pushes the change. +function watchVaultLock() { + bridge.on('pithos-vault', (st) => { + vaultInfo = { ...vaultInfo, ...st }; + if (st.setup && st.prompt && !st.unlocked && $('#locked').hidden) { + $('#app').hidden = true; + $('#locked').hidden = false; + } + }); +} + +$('#unlock-btn').addEventListener('click', async () => { + if (await vaultGate()) { + const first = $('#app').dataset.booted !== '1'; + $('#app').hidden = false; + if (first) location.reload(); + } +}); + function showSignin() { $('#app').hidden = true; $('#signin').hidden = false; @@ -1219,8 +1264,10 @@ async function boot() { return; } $('#signin').hidden = true; + if (bridge && !(await vaultGate())) return; $('#app').hidden = false; - if (bridge) addSidebarControls(); + $('#app').dataset.booted = '1'; + if (bridge) { addSidebarControls(); watchVaultLock(); } // Backfill logs the server already holds, then stream. try { state.logs = await api('GET', '/api/logs'); diff --git a/ui/index.html b/ui/index.html index 66004b2..3cf1b7d 100644 --- a/ui/index.html +++ b/ui/index.html @@ -19,6 +19,15 @@ + +