// 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 //. 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