Theseus: bundle Pithos 0.3.2

Rebuilt from Pithos/ with scripts/build-theseus-addon.mjs.
This commit is contained in:
Local Dev 2026-10-03 22:10:01 +02:00
parent fa50f97e6f
commit e150cfb442
3 changed files with 169 additions and 2 deletions

View file

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

View file

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

View file

@ -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]) {