pithos/web/lib/hosted.js

348 lines
17 KiB
JavaScript
Raw Permalink Normal View History

// 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(() => {});