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.
This commit is contained in:
Local Dev 2026-10-03 20:42:21 +02:00
parent b9f7e08ae4
commit cacbdd571f
5 changed files with 126 additions and 7 deletions

View file

@ -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",

View file

@ -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"
}

View file

@ -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

View file

@ -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');

View file

@ -19,6 +19,15 @@
</form>
</div>
<div id="locked" class="signin" hidden>
<div class="card signin-card">
<img src="icon.svg" alt="" width="48" height="48">
<h1>Pithos is locked</h1>
<p class="muted">Unlock your Theseus vault with your PIN or master password to open Pithos.</p>
<button class="btn primary" type="button" id="unlock-btn">Unlock</button>
</div>
</div>
<div id="app" class="app" hidden>
<aside class="sidebar">
<div class="brand"><img src="icon.svg" alt="" width="28" height="28"><span>Pithos</span></div>