From e150cfb442e422b1c29dd035ba73bab1807bc4fa Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 22:10:01 +0200 Subject: [PATCH] Theseus: bundle Pithos 0.3.2 Rebuilt from Pithos/ with scripts/build-theseus-addon.mjs. --- bundled-addons/pithos/addon.json | 2 +- bundled-addons/pithos/index.js | 53 ++++++++++++++ bundled-addons/pithos/ui/app.js | 116 ++++++++++++++++++++++++++++++- 3 files changed, 169 insertions(+), 2 deletions(-) diff --git a/bundled-addons/pithos/addon.json b/bundled-addons/pithos/addon.json index 92b0513f..34c95046 100644 --- a/bundled-addons/pithos/addon.json +++ b/bundled-addons/pithos/addon.json @@ -1,7 +1,7 @@ { "id": "pithos", "name": "Pithos", - "version": "0.3.1", + "version": "0.3.2", "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=", diff --git a/bundled-addons/pithos/index.js b/bundled-addons/pithos/index.js index c44406bd..f9c58c4e 100644 --- a/bundled-addons/pithos/index.js +++ b/bundled-addons/pithos/index.js @@ -8,6 +8,8 @@ // Loaded with require() by the add-on host; core/ is ESM, hence import(). const path = require("node:path"); +const fs = require("node:fs"); +const crypto = require("node:crypto"); const { pathToFileURL } = require("node:url"); let pithos = null; @@ -178,6 +180,57 @@ module.exports = { // Left edge, beside the quick links. Theseus builds without left panels // ignore `side` and show it in the right sidebar. + // ---- Saved credentials: vault-sealed files and the save dialog -------- + // A credentials file protected "with my Theseus vault" is AES-256-GCM + // under a key derived from the vault, so it needs no extra password and + // opens only where that vault is unlocked. FIXED PATH: changing it makes + // every saved file unreadable. + async function credentialsKey() { + 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: "Protect or open a saved Pithos credentials file." }); + if (!r.ok) throw new Error("The vault stayed locked."); + } + return Buffer.from(await vault.derive("pithos/credentials/v1")); + } + + api.onMessage("vault-seal", async ({ plaintext } = {}) => { + const key = await credentialsKey(); + try { + const iv = crypto.randomBytes(12); + const c = crypto.createCipheriv("aes-256-gcm", key, iv); + const ct = Buffer.concat([c.update(String(plaintext || ""), "utf8"), c.final(), c.getAuthTag()]); + return { iv: iv.toString("base64"), ct: ct.toString("base64") }; + } finally { key.fill(0); } + }); + + api.onMessage("vault-open", async ({ iv, ct } = {}) => { + const key = await credentialsKey(); + try { + const data = Buffer.from(String(ct || ""), "base64"); + const d = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(String(iv || ""), "base64")); + d.setAuthTag(data.subarray(data.length - 16)); + return { plaintext: Buffer.concat([d.update(data.subarray(0, data.length - 16)), d.final()]).toString("utf8") }; + } catch { + throw new Error("This file was saved with a different Theseus vault."); + } finally { key.fill(0); } + }); + + // The sidebar page has no download path of its own; ask where to save. + api.onMessage("save-file", async ({ name, text } = {}) => { + const { dialog, BrowserWindow, app } = require("electron"); + const safe = String(name || "pithos-credentials.pithoskey").replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120); + const r = await dialog.showSaveDialog(BrowserWindow.getFocusedWindow() || undefined, { + defaultPath: path.join(app.getPath("downloads"), safe), + filters: [{ name: "Pithos credentials", extensions: ["pithoskey"] }], + }); + if (r.canceled || !r.filePath) return { saved: false }; + fs.writeFileSync(r.filePath, String(text || ""), { mode: 0o600 }); + return { saved: true }; + }); + api.registerSidebarPanel({ id: "main", title: "Pithos", page: "ui/index.html", side: "left" }); // The host has no quit hook for add-ons; without this an s3d we started diff --git a/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js index 2121f12e..b9be62b7 100644 --- a/bundled-addons/pithos/ui/app.js +++ b/bundled-addons/pithos/ui/app.js @@ -487,6 +487,7 @@ function getStarted(main) { h('p', {}, 'S3 apps sign in with this key pair. You can see it again under Users & keys.'), secretRow('Access key ID', k.accessKeyId), secretRow('Secret key', k.secretKey), + h('div', { class: 'row', style: 'margin-top:10px' }, h('button', { class: 'btn small', type: 'button', onclick: () => saveCredentialsDialog(k) }, 'Save credentials…')), h('p', { class: 'small muted', style: 'margin:14px 0 6px' }, 'Try it with the aws CLI:'), h('pre', { class: 'snippet' }, `aws configure set aws_access_key_id ${k.accessKeyId} --profile ${k.user}\naws configure set aws_secret_access_key ${k.secretKey} --profile ${k.user}\naws configure set region us-east-1 --profile ${k.user}\naws --profile ${k.user} --endpoint-url ${endpoint} s3 mb s3://my-first-bucket`), nav({ backDisabled: true }), @@ -739,7 +740,9 @@ function users(main) { main.append( h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Users & keys'), h('p', { class: 'muted' }, 'Each S3 user owns its own buckets. Access keys let S3 clients (aws, rclone, apps) act as that user.')), - h('button', { class: 'btn primary', onclick: createUser }, 'New user')), + h('div', { class: 'row' }, + h('button', { class: 'btn', onclick: openCredentialsDialog }, 'Open saved credentials'), + h('button', { class: 'btn primary', onclick: createUser }, 'New user'))), list, ); @@ -850,12 +853,123 @@ AWS_ENDPOINT_URL=${endpoint}`; fresh && h('p', { class: 'small muted', style: 'margin:0' }, 'Treat the secret like a password. You can view it again here later.'), secretRow('Access key ID', k.accessKeyId), secretRow('Secret key', k.secretKey), + h('div', {}, h('button', { class: 'btn small', type: 'button', onclick: () => saveCredentialsDialog(k) }, 'Save credentials…')), h('h3', { style: 'margin-top:8px' }, 'Client config'), tabBtns, pre, h('div', {}, h('button', { class: 'btn small', type: 'button', onclick: () => copy(pre.textContent) }, 'Copy snippet'))), [{ label: 'Done', kind: 'primary', submit: true, result: true }]); } +// ---------------------------------------------------------------- saved credentials (encrypted file) + +// A key pair saved to a file the user keeps. Password files use PBKDF2-SHA256 +// (600k) + AES-256-GCM in the page; vault files are sealed by the Theseus +// add-on with a key derived from the vault, so they need no extra password. +// Only the user name and date stay readable, to tell files apart. +const CRED_FORMAT = 'pithos-credentials'; +const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))); +const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); + +async function passwordKey(password, salt, iterations) { + const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']); + return crypto.subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); +} + +async function sealCredentials(k, { mode, password }) { + const payload = JSON.stringify({ + user: k.user, accessKeyId: k.accessKeyId, secretKey: k.secretKey, + endpoint: `http://${state.status?.config.apiAddress || '127.0.0.1:8000'}`, region: 'us-east-1', + }); + const head = { format: CRED_FORMAT, v: 1, user: k.user, saved: new Date().toISOString() }; + if (mode === 'vault') { + const r = await bridge.invoke('vault-seal', { plaintext: payload }); + return { ...head, protection: 'theseus-vault', iv: r.iv, ct: r.ct }; + } + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const iterations = 600000; + const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, await passwordKey(password, salt, iterations), new TextEncoder().encode(payload)); + return { ...head, protection: 'password', kdf: { name: 'PBKDF2', hash: 'SHA-256', iterations, salt: b64(salt) }, iv: b64(iv), ct: b64(ct) }; +} + +async function openCredentials(file, password) { + if (!file || file.format !== CRED_FORMAT || file.v !== 1) throw new Error('This is not a Pithos credentials file'); + let text; + if (file.protection === 'theseus-vault') { + if (!bridge) throw new Error('This file is protected with a Theseus vault. Open it in Pithos inside Theseus.'); + text = (await bridge.invoke('vault-open', { iv: file.iv, ct: file.ct })).plaintext; + } else { + try { + const key = await passwordKey(password, unb64(file.kdf.salt), file.kdf.iterations); + text = new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: unb64(file.iv) }, key, unb64(file.ct))); + } catch { throw new Error('Wrong password, or the file is damaged'); } + } + return JSON.parse(text); +} + +async function saveFile(name, text) { + if (bridge) { + const r = await bridge.invoke('save-file', { name, text }); + return !!r.saved; + } + const url = URL.createObjectURL(new Blob([text], { type: 'application/json' })); + const a = h('a', { href: url, download: name }); + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 5000); + return true; +} + +async function saveCredentialsDialog(k) { + const vaultOk = !!(bridge && vaultInfo && vaultInfo.setup && vaultInfo.prompt); + let mode = vaultOk ? 'vault' : 'password'; + const p1 = h('input', { type: 'password', placeholder: 'Password (at least 8 characters)', autocomplete: 'new-password' }); + const p2 = h('input', { type: 'password', placeholder: 'Repeat the password', autocomplete: 'new-password' }); + const pwBox = h('div', { class: 'stack', hidden: mode === 'vault' }, p1, p2, + h('small', { class: 'muted' }, 'Pithos cannot recover this password. Without it the file cannot be opened.')); + const choice = vaultOk && h('div', { class: 'stack' }, + h('label', { class: 'check', style: 'align-items:flex-start' }, + h('input', { type: 'radio', name: 'credmode', checked: true, onchange: () => { mode = 'vault'; pwBox.hidden = true; } }), + h('span', {}, h('strong', {}, 'Protect with my Theseus vault'), h('br'), h('span', { class: 'small muted' }, 'No extra password. Opens in Pithos with the same vault unlocked.'))), + h('label', { class: 'check', style: 'align-items:flex-start' }, + h('input', { type: 'radio', name: 'credmode', onchange: () => { mode = 'password'; pwBox.hidden = false; } }), + h('span', {}, h('strong', {}, 'Protect with a password'), h('br'), h('span', { class: 'small muted' }, 'Opens in Pithos anywhere, with the password.')))); + await modal('Save credentials', h('div', { class: 'stack' }, + h('p', { class: 'small muted', style: 'margin:0' }, `Saves the access key and secret for ${k.user} to an encrypted file. Open it later under Users & keys.`), + choice, pwBox), + [{ label: 'Cancel', result: null }, { label: 'Save file', kind: 'primary', submit: true, value: async () => { + if (mode === 'password') { + if (p1.value.length < 8) { toast('Use at least 8 characters', 'error'); return false; } + if (p1.value !== p2.value) { toast("The two passwords don't match", 'error'); return false; } + } + try { + const sealed = await sealCredentials(k, { mode, password: p1.value }); + if (await saveFile(`pithos-${k.user}-${k.accessKeyId.slice(0, 6)}.pithoskey`, JSON.stringify(sealed, null, 2))) toast('Credentials saved'); + return true; + } catch (e) { fail(e); return false; } + } }]); +} + +function openCredentialsDialog() { + const input = h('input', { type: 'file', accept: '.pithoskey,application/json' }); + input.addEventListener('change', async () => { + const f = input.files[0]; + if (!f) return; + let file; + try { file = JSON.parse(await f.text()); } catch { return toast('This is not a Pithos credentials file', 'error'); } + let password = ''; + if (file.protection === 'password') { + const pw = h('input', { type: 'password', placeholder: 'Password', autocomplete: 'current-password' }); + password = await modal(`Open credentials${file.user ? ` for ${file.user}` : ''}`, h('label', { class: 'field' }, h('span', {}, 'Password'), pw), + [{ label: 'Cancel', result: null }, { label: 'Open', kind: 'primary', submit: true, value: () => pw.value }]); + if (!password) return; + } + try { showKey(await openCredentials(file, password)); } catch (e) { fail(e); } + }); + input.click(); +} + // ---------------------------------------------------------------- buckets & objects function buckets(main, [bucket, ...prefixParts]) {