342 lines
17 KiB
JavaScript
342 lines
17 KiB
JavaScript
|
|
// 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) {
|
|||
|
|
const key = await subtle.importKey('pkcs8', C.concat(PKCS8_ED25519, seed), { name: 'Ed25519' }, true, ['sign']);
|
|||
|
|
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(() => {});
|