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 @@ +
Unlock your Theseus vault with your PIN or master password to open Pithos.
+ +