pithos/web/lib/hosted.js
Local Dev 083a2e1db8 Pithos for Android: the browser app on a phone, with Save to Pithos
A first Pithos app for phones. It runs the browser app in a WebView and
answers the page's own files from the APK under the gateway's origin, so
the page stays the same-origin client it was designed as and the files
are still encrypted and decrypted in the page.

What the phone adds: "Save to Pithos" in other apps' share menus (the
files wait in a bar until a folder is open), downloads written to the
Downloads folder, the system file picker for uploads, and outside links
opened in the phone's browser.

The browser app learns the two hooks (window.PithosAndroid) and says
plainly when a browser is too old to sign in, instead of a bare
"NotSupportedError".
2026-10-07 23:06:33 +02:00

347 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// The browser app's client for Drives on Silent Mode. It is served from the
// gateway's own origin, so every call here is same-origin: sign-in and the
// account API under /hosted/v1 and /api, S3 at /<drive>/<key>. Names and
// contents are encrypted and decrypted here exactly as core/hosted.js does.
import * as C from './crypt.js';
import { sign, presign, objectPath, canonicalQuery } from './sigv4.js';
const subtle = globalThis.crypto.subtle;
const b64 = (bytes) => btoa(String.fromCharCode(...bytes));
const unb64 = (s) => Uint8Array.from(atob(String(s || '')), (c) => c.charCodeAt(0));
const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
const PKCS8_ED25519 = Uint8Array.from([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20]);
export class ApiError extends Error {
constructor(status, message, data) { super(message); this.status = status; this.data = data; }
}
// ---- key files -----------------------------------------------------------------------
// What a dropped or pasted file is: an owner key (Pithos key file, v1 secret or
// v2 browser key) or an S3 key ({endpoint, accessKey, secretKey, bucket}).
export async function readKeyFile(text) {
let o;
try { o = typeof text === 'string' ? JSON.parse(text) : text; } catch { throw new Error('This is not a key file (it is not JSON).'); }
if (o && o.secret && (o.v === 1 || o.v == null)) {
const secret = unb64(o.secret);
if (secret.length !== 32) throw new Error('This Pithos key file is damaged.');
return {
kind: 'owner',
identitySeed: await C.hkdf(secret, new Uint8Array(0), 'pithos/hosted-identity/v1', 32),
encryptionKey: await C.hkdf(secret, new Uint8Array(0), 'pithos/hosted-encryption/v1', 32),
};
}
if (o && o.v === 2 && o.identitySeed && o.encryptionKey) {
const identitySeed = unb64(o.identitySeed);
const encryptionKey = unb64(o.encryptionKey);
if (identitySeed.length !== 32 || encryptionKey.length !== 32) throw new Error('This browser key file is damaged.');
return { kind: 'owner', identitySeed, encryptionKey };
}
if (o && o.accessKey && o.secretKey) {
return { kind: 's3', accessKeyId: String(o.accessKey), secretKey: String(o.secretKey), bucket: o.bucket ? String(o.bucket) : '', endpoint: o.endpoint ? String(o.endpoint) : '' };
}
throw new Error('This file is neither a Pithos key nor an S3 key (Export JSON) file.');
}
// ---- the gateway's account API -----------------------------------------------------------
export async function api(method, path, { token, body } = {}) {
const headers = {};
if (token) headers.authorization = `Bearer ${token}`;
if (body !== undefined) headers['content-type'] = 'application/json';
const res = await fetch(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), cache: 'no-store' });
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text.slice(0, 300) }; }
if (!res.ok) throw new ApiError(res.status, data?.error || `HTTP ${res.status}`, data);
return data;
}
async function identity(seed) {
let key;
try { key = await subtle.importKey('pkcs8', C.concat(PKCS8_ED25519, seed), { name: 'Ed25519' }, true, ['sign']); }
catch (e) {
// Ed25519 in Web Crypto: Chrome 137+, Firefox 129+, Safari 17+; on Android, an up-to-date Android System WebView
if (e?.name === 'NotSupportedError') throw new Error('This browser is too old to sign in (it has no Ed25519). Update it, or on Android update "Android System WebView", and try again.');
throw e;
}
const jwk = await subtle.exportKey('jwk', key);
const pub = C.unb64u(jwk.x);
return { pubkey: hex(pub), sign: async (msg) => b64(new Uint8Array(await subtle.sign('Ed25519', key, C.utf8(msg)))) };
}
// Ed25519 sign-in. Returns { token, id, state }; accounts are made in Pithos.
export async function signIn(identitySeed) {
const id = await identity(identitySeed);
const { nonce, message } = await api('GET', '/hosted/v1/challenge');
try {
return await api('POST', '/hosted/v1/session', { body: { pubkey: id.pubkey, nonce, signature: await id.sign(message) } });
} catch (e) {
if (e.status === 404 && e.data?.needsSignup) throw new Error('There is no account on Silent Mode for this key yet. Create it in Pithos (Accounts › On Silent Mode), then sign in here.');
throw e;
}
}
export const signOut = (token) => api('DELETE', '/hosted/v1/session', { token }).catch(() => {});
// The owner's S3 key: the first key of the first S3 user, as Pithos does.
export async function ownerS3(token) {
const users = await api('GET', '/api/users', { token });
const names = (Array.isArray(users) ? users : users?.users || []).map((u) => u.name || u.username || u).filter(Boolean);
for (const user of names) {
const keys = await api('GET', `/api/keys?user=${encodeURIComponent(user)}`, { token });
if (keys?.length) return { user, accessKeyId: keys[0].accessKeyId, secretKey: keys[0].secretKey };
}
throw new Error('This account has no S3 keys yet. Create one in Pithos (Users & keys).');
}
// ---- S3, signed in the page ---------------------------------------------------------------
const xmlTag = (el, name) => el.getElementsByTagName(name)[0]?.textContent ?? '';
const xmlAll = (doc, name) => Array.from(doc.getElementsByTagName(name));
const parseXml = (text) => new DOMParser().parseFromString(text, 'application/xml');
async function s3Fail(res) {
const text = await res.text().catch(() => '');
let msg = `HTTP ${res.status}`;
try { const d = parseXml(text); msg = xmlTag(d, 'Message') || xmlTag(d, 'Code') || msg; } catch {}
try { const j = JSON.parse(text); msg = j.error || msg; } catch {}
return new ApiError(res.status, msg);
}
// A drive client. master: the 32-byte encryption key (null for an S3 key file,
// which reads and writes readable files only). rules: async () => sharing rules.
export class Drives {
constructor({ accessKeyId, secretKey, master = null, rules = async () => [] }) {
this.creds = { host: location.host, origin: location.origin, accessKeyId, secretKey };
this.master = master;
this.rules = rules;
this.dks = new Map();
}
async dk(bucket) {
if (!this.master) return null;
if (!this.dks.has(bucket)) this.dks.set(bucket, await C.driveKeys(this.master, bucket));
return this.dks.get(bucket);
}
async req(method, path, { query = {}, headers = {}, body, ok = true } = {}) {
const h = await sign(this.creds, method, path, query, headers);
const qs = canonicalQuery(query);
const res = await fetch(path + (qs ? '?' + qs : ''), { method, headers: h, body, cache: 'no-store' });
if (ok && !res.ok) throw await s3Fail(res);
return res;
}
async listBuckets() {
const d = parseXml(await (await this.req('GET', '/')).text());
return xmlAll(d, 'Bucket').map((b) => ({ name: xmlTag(b, 'Name'), created: xmlTag(b, 'CreationDate') }));
}
async rawList(bucket, { prefix = '', delimiter = '/', token, max = 1000 }) {
const query = { 'list-type': '2', prefix, delimiter, 'max-keys': String(max) };
if (token) query['continuation-token'] = token;
const d = parseXml(await (await this.req('GET', objectPath(bucket), { query })).text());
return {
folders: xmlAll(d, 'CommonPrefixes').map((p) => xmlTag(p, 'Prefix')),
objects: xmlAll(d, 'Contents').map((o) => ({ key: xmlTag(o, 'Key'), size: Number(xmlTag(o, 'Size')), modified: xmlTag(o, 'LastModified') })),
truncated: xmlTag(d.documentElement, 'IsTruncated') === 'true',
nextToken: xmlTag(d.documentElement, 'NextContinuationToken'),
};
}
// One folder level, encrypted and readable names merged (core/hosted.js listObjects).
async list(bucket, prefix = '') {
const dk = await this.dk(bucket);
const folders = new Map();
const objects = new Map();
const sources = dk && prefix ? [await C.encryptPath(dk, prefix), prefix] : [prefix];
for (const pfx of sources) {
let token;
do {
const page = await this.rawList(bucket, { prefix: pfx, token });
for (const f of page.folders) {
const d = dk ? await C.decryptPath(dk, f) : { path: f, encrypted: false };
if (d.path.startsWith(prefix) && d.path !== prefix) folders.set(d.path, { path: d.path, encrypted: d.encrypted, locked: !dk && f.split('/').some((s) => s.startsWith('~')) });
}
for (const o of page.objects) {
const d = dk ? await C.decryptPath(dk, o.key) : { path: o.key, encrypted: false };
if (!d.path.startsWith(prefix) || d.path === prefix) continue;
if (objects.has(d.path) && !d.encrypted) continue;
const size = d.encrypted && !d.path.endsWith('/') ? C.plainSize(o.size) : o.size;
objects.set(d.path, { key: d.path, size: size ?? o.size, modified: o.modified, encrypted: d.encrypted, storageKey: o.key, locked: !dk && o.key.split('/').some((s) => s.startsWith('~')) });
}
token = page.truncated ? page.nextToken : null;
} while (token);
}
const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
return {
folders: [...folders.values()].sort((a, b) => order(a.path, b.path)),
files: [...objects.values()].filter((o) => !o.key.endsWith('/')).sort((a, b) => order(a.key, b.key)),
};
}
async storeAsPlain(bucket, key) {
if (!this.master) return true;
const now = Date.now();
return (await this.rules()).some((r) => r.bucket === bucket && (!r.expires || r.expires > now) && (r.type === 'link' ? r.prefix === key : key.startsWith(r.prefix)));
}
// Where a file lives: { encrypted, storageKey, size (plain) }.
async locate(bucket, key) {
const dk = await this.dk(bucket);
if (dk) {
const ek = await C.encryptPath(dk, key);
const head = await this.req('HEAD', objectPath(bucket, ek), { ok: false });
if (head.ok) {
const total = Number(head.headers.get('content-length'));
return { encrypted: true, storageKey: ek, cipherLen: total, size: C.plainSize(total) };
}
}
const head = await this.req('HEAD', objectPath(bucket, key), { ok: false });
if (!head.ok) throw new ApiError(head.status, head.status === 404 ? 'This file is gone.' : `HTTP ${head.status}`);
return { encrypted: false, storageKey: key, size: Number(head.headers.get('content-length')) };
}
// Plaintext as a stream of Uint8Array chunks (whole file, or [start, end]).
async *read(bucket, key, range) {
const at = await this.locate(bucket, key);
if (!at.encrypted) {
const headers = range ? { range: `bytes=${range.start}-${range.end ?? ''}` } : {};
const res = await this.req('GET', objectPath(bucket, at.storageKey), { headers });
const reader = res.body.getReader();
for (;;) { const { value, done } = await reader.read(); if (done) return; yield value; }
}
const dk = await this.dk(bucket);
const r = C.cipherRange(at.cipherLen, range || null);
if (r.unsatisfiable) return;
const getBytes = async (from, to) => new Uint8Array(await (await this.req('GET', objectPath(bucket, at.storageKey), { headers: { range: `bytes=${from}-${to}` } })).arrayBuffer());
const header = await getBytes(0, C.HEADER_LEN - 1);
const ctx = await C.bodyContext(dk, key, header);
const span = ctx.cs + C.TAG_LEN;
// fetch a few chunks per request, open them one by one
const BATCH = 8;
let cache = { first: -1, bytes: null };
const readChunk = async (i) => {
if (i < cache.first || i >= cache.first + BATCH || !cache.bytes) {
const from = C.HEADER_LEN + i * span;
const to = Math.min(at.cipherLen - 1, from + BATCH * span - 1);
cache = { first: i, bytes: await getBytes(from, to) };
}
const off = (i - cache.first) * span;
return cache.bytes.subarray(off, Math.min(cache.bytes.length, off + span));
};
yield* C.decryptChunks(ctx, r, readChunk);
}
async blob(bucket, key, type) {
const parts = [];
for await (const p of this.read(bucket, key)) parts.push(p);
return new Blob(parts, { type: type || guessType(key) });
}
// A link the page can hand to <audio>/<video>/<img> without decrypting:
// only for readable files (encrypted ones go through blob()).
presigned(bucket, storageKey, seconds = 3600) {
return presign(this.creds, bucket, storageKey, seconds);
}
async write(bucket, key, file, onProgress = () => {}) {
const dk = await this.dk(bucket);
if (await this.storeAsPlain(bucket, key)) {
await this.req('PUT', objectPath(bucket, key), { headers: { 'content-type': file.type || guessType(key) }, body: file });
if (dk) { const ek = await C.encryptPath(dk, key); if (ek !== key) await this.req('DELETE', objectPath(bucket, ek), { ok: false }); }
onProgress(file.size);
return;
}
const ek = await C.encryptPath(dk, key);
const parts = [];
let done = 0;
for await (const p of C.encryptBlob(dk, key, file)) { parts.push(p); done = Math.min(file.size, done + p.length); onProgress(done); }
await this.req('PUT', objectPath(bucket, ek), { headers: { 'content-type': 'application/octet-stream' }, body: new Blob(parts) });
if (ek !== key) await this.req('DELETE', objectPath(bucket, key), { ok: false });
}
async mkdir(bucket, folder) {
const dk = await this.dk(bucket);
const key = folder.endsWith('/') ? folder : folder + '/';
const store = dk && !(await this.storeAsPlain(bucket, key)) ? await C.encryptPath(dk, key) : key;
await this.req('PUT', objectPath(bucket, store), { body: new Uint8Array(0) });
}
async remove(bucket, key) {
const dk = await this.dk(bucket);
if (dk) await this.req('DELETE', objectPath(bucket, await C.encryptPath(dk, key)), { ok: false });
await this.req('DELETE', objectPath(bucket, key), { ok: false });
}
async removeFolder(bucket, prefix, onEach = () => {}) {
const all = [];
const walk = async (p) => {
const l = await this.list(bucket, p);
for (const f of l.files) all.push(f);
for (const d of l.folders) { await walk(d.path); all.push({ key: d.path, storageKey: null }); }
};
await walk(prefix);
for (const f of all) { await this.remove(bucket, f.key); onEach(f.key); }
await this.remove(bucket, prefix);
}
// Encrypted names and contents are bound to their path, so a rename is a
// decrypt-and-encrypt copy here, then a delete.
async rename(bucket, from, to) {
if (from === to) return;
const blob = await this.blob(bucket, from);
await this.write(bucket, to, blob);
await this.remove(bucket, from);
}
}
const MIME = {
html: 'text/html', htm: 'text/html', txt: 'text/plain', md: 'text/markdown', css: 'text/css', js: 'text/javascript', json: 'application/json',
xml: 'application/xml', csv: 'text/csv', pdf: 'application/pdf', zip: 'application/zip', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', mp3: 'audio/mpeg', ogg: 'audio/ogg', oga: 'audio/ogg', wav: 'audio/wav',
flac: 'audio/flac', m4a: 'audio/mp4', opus: 'audio/opus', mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
};
export const guessType = (key) => MIME[String(key).split('.').pop().toLowerCase()] || 'application/octet-stream';
// ---- remember on this device ------------------------------------------------------------------
// The key file, sealed with a passphrase (PBKDF2-SHA256 600k + AES-GCM), in
// IndexedDB on this origin. Nothing usable is stored without the passphrase.
const DB = 'pithos-web';
function idb() {
return new Promise((resolve, reject) => {
const r = indexedDB.open(DB, 1);
r.onupgradeneeded = () => r.result.createObjectStore('kv');
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
});
}
async function kv(mode, fn) {
const db = await idb();
return new Promise((resolve, reject) => {
const tx = db.transaction('kv', mode);
const req = fn(tx.objectStore('kv'));
tx.oncomplete = () => resolve(req?.result);
tx.onerror = () => reject(tx.error);
});
}
async function passKey(pass, salt) {
const base = await subtle.importKey('raw', C.utf8(pass), 'PBKDF2', false, ['deriveKey']);
return subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 600000 }, base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}
export async function remember(keyText, pass) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv }, await passKey(pass, salt), C.utf8(keyText)));
await kv('readwrite', (s) => s.put({ v: 1, salt, iv, ct, saved: Date.now() }, 'key'));
}
export const remembered = () => kv('readonly', (s) => s.get('key')).catch(() => null);
export async function recall(pass) {
const r = await remembered();
if (!r) return null;
try { return new TextDecoder().decode(await subtle.decrypt({ name: 'AES-GCM', iv: r.iv }, await passKey(pass, r.salt), r.ct)); } catch { throw new Error('Wrong passphrase.'); }
}
export const forget = () => kv('readwrite', (s) => s.delete('key')).catch(() => {});