FileKey — reference implementation, full source Spec v0.4.7 · HPKE (DHKEM P-256 + HKDF-SHA-256 + AES-256-GCM, namespaced) · passkey (WebAuthn PRF) Everything runs in the browser. No account, no server, nothing uploaded. ============================================================================== === web/index.html === ============================================================================== FileKey: encrypt and share files with passkeys
Receive a file Your Share Key Contacts Recovery Code
CONNECT Blog Signal Group Contact Us DEVELOPER GitHub Source Code LEGAL Terms Privacy License APPEARANCE
Light Dark Auto
Drop files or folders to lock or unlock
============================================================================== === web/app.ts === ============================================================================== // FileKey reference web app. // UI/flow is a faithful reproduction of filekey v1 (source.txt): typewriter chat // (char_speed = characters per animation frame), the filekey "dp" badge on each // message, two-step auth (generate -> "Now tap to authenticate"), animated // Encrypting/Decrypting status, upload-right / download-left cards with Share+Save, // the hamburger ("chiz") menu with v1's verbatim content panels. // // FILE SEMANTICS follow the new spec (user-confirmed), NOT v1: // - route lock/unlock by the FKEY magic header, not the filename extension // - shared files are named ".shared.filekey" (spec §10), not ".shared_filekey" // - Save uses the native save picker where available // Other spec deltas are marked // SPEC-DELTA. import { Namespace, NamespaceSet, deriveIdentityFromPrf, masterPrkFromPrfSecret, encodeShareKey, decodeShareKey, identityFingerprint, encodeRecoveryBip39, encryptToSelf, decrypt, encryptStream, decryptStream, parseHeader, FileKeyError, HEADER_LEN, type Identity, type Metadata, type ByteSource, } from "../src/index.js"; import { checkSupport, enrollPasskey, getPrfSecret, prfBrowserSupport, deploymentRpId } from "./webauthn.js"; import * as Contacts from "./contacts.js"; import { collectFromInput, collectFromDrop, zipBundleToBlob, bundleName, type BundleItem } from "./bundle.js"; import qrcode from "qrcode-generator"; // tiny, dependency-free QR encoder — bundled offline (no CDN; see send-me-a-file spec §8) import versionManifest from "./version.json"; // user-facing version + release notes; re-fetched at runtime to surface a newer deploy // ---- v1 icons (verbatim SVG paths from source.txt) ---- const SVG = { logo: ``, filekey: ``, file: ``, plus: ``, share: ``, copy: ``, save: ``, check: ``, edit: ``, outbound: ``, import: ``, }; // External-link affordance: the menu's box-arrow glyph, inline, inheriting the link's color. const EXT_ICON = SVG.outbound.replace("outbound_link", "ext_icon"); const extLink = (href: string, text: string) => `${text}${EXT_ICON}`; const RP_ID = deploymentRpId(); const NS = new Namespace(RP_ID); const SET = new NamespaceSet([RP_ID]); const APP_VERSION = versionManifest.current; // the version string this bundle shipped as const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const $ = (id: string) => document.getElementById(id)!; const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); let mainInner: HTMLElement; let identity: Identity | null = null; let allowAutoScroll = false; // suppressed during the intro so the page doesn't auto-scroll on load (mobile) let statusCount = 0; // Share model: each Share opens its OWN recipient prompt bound to that file, so different files // in one session can go to different recipients (no single sticky global recipient). type ShareFile = { plaintext: Blob; name: string; mime: string }; // v1 scrollToBottom (only scrolls once the feed fills ~3/4 of the viewport). function scrollToBottom() { if (!allowAutoScroll) return; // hold the page still during the intro (no jump on mobile load) const three_quarters = document.body.clientHeight * 0.75; if (mainInner.clientHeight >= three_quarters) window.scroll(0, document.body.scrollHeight + document.body.scrollHeight / 10); } const setIcon = (el: Element, cls: string) => el.querySelector("svg")!.setAttribute("class", cls); // ---- typewriter: char_speed = characters per animation frame (v1 std_fillTextBoxAnimation) ---- type Seg = string | { t: string; b?: boolean } | { link: string; onClick: () => void } | { html: string }; function typeInto(el: HTMLElement, text: string, perFrame: number): Promise { if (REDUCED) { el.textContent = text; scrollToBottom(); return Promise.resolve(); } return new Promise((resolve) => { let i = 0; const frame = () => { i += perFrame; el.textContent = text.slice(0, i); scrollToBottom(); i < text.length ? requestAnimationFrame(frame) : resolve(); }; requestAnimationFrame(frame); }); } // Typewriter for rich HTML (menu panels, requirements, inline links): rebuilds the DOM // incrementally — cloning each element as the cursor reaches it and typing each text node // char-by-char — so structured content reveals progressively, like plain messages. function typeHtmlInto(dest: HTMLElement, html: string, perFrame: number): Promise { const src = document.createElement("div"); src.innerHTML = html; if (REDUCED) { dest.innerHTML = html; scrollToBottom(); return Promise.resolve(); } const typeText = (tn: Text, text: string) => new Promise((resolve) => { let i = 0; const frame = () => { i += perFrame; tn.data = text.slice(0, i); scrollToBottom(); i < text.length ? requestAnimationFrame(frame) : resolve(); }; requestAnimationFrame(frame); }); const walk = async (from: Node, to: Node): Promise => { for (const child of Array.from(from.childNodes)) { if (child.nodeType === Node.TEXT_NODE) { const tn = document.createTextNode(""); to.appendChild(tn); await typeText(tn, (child as Text).data); } else if (child.nodeType === Node.ELEMENT_NODE) { const clone = (child as Element).cloneNode(false); to.appendChild(clone); await walk(child, clone); } } }; return walk(src, dest); } function appShell(dp = "std_dp", icon = "filekey_icon"): HTMLElement { const outer = document.createElement("div"); outer.className = "std_outer"; outer.innerHTML = `
${SVG.filekey}
`; setIcon(outer.querySelector(`.${dp}`)!, icon); mainInner.appendChild(outer); return outer.querySelector(".std_msg") as HTMLElement; } async function appMsg(segs: Seg[], opts: { speed?: number; dp?: string; icon?: string } = {}): Promise { const speed = opts.speed ?? 8; const msg = appShell(opts.dp ?? "std_dp", opts.icon ?? "filekey_icon"); scrollToBottom(); for (const seg of segs) { if (typeof seg === "string") { const s = document.createElement("span"); msg.appendChild(s); await typeInto(s, seg, speed); } else if ("t" in seg) { const s = document.createElement(seg.b ? "strong" : "span"); msg.appendChild(s); await typeInto(s, seg.t, speed); } else if ("link" in seg) { const a = document.createElement("span"); a.className = "msg_clickable no_select"; a.textContent = seg.link; a.addEventListener("click", seg.onClick); msg.appendChild(a); if (!REDUCED) await new Promise((r) => setTimeout(r, 40)); } else { const s = document.createElement("span"); msg.appendChild(s); await typeHtmlInto(s, seg.html, speed); } } scrollToBottom(); return msg; // returned so actionRow() can attach a choice row INSIDE this same bubble } // A choice row attached inside a chat-message bubble (pass the element appMsg returns) — distinct actions as // blue primary + muted secondary chips, the Confirm/Cancel + Save/Skip vocabulary. Use this instead of // cramming multiple action links into one appMsg separated by a "·". function actionRow(host: HTMLElement, actions: { label: string; muted?: boolean; onClick: () => void }[]): void { const row = document.createElement("div"); row.className = "msg_actions"; for (const a of actions) { const s = document.createElement("span"); s.className = `${a.muted ? "cancel_pub_key" : "confirm_pub_key"} no_select`; s.textContent = a.label; s.addEventListener("click", a.onClick); row.appendChild(s); } host.appendChild(row); scrollToBottom(); } const ERR = { speed: 4, dp: "failed_dp", icon: "failed_filekey_icon" }; // v1 getErrorParams const WARN = { speed: 2, dp: "warning_dp", icon: "warning_filekey_icon" }; // v1 getWarningParams // ---- animated "Encrypting…/Decrypting…" status (v1 set3dotStatusAnimation) ---- class StatusMsg { msg: string; el: HTMLElement; cancelEl: HTMLElement; outer: HTMLElement; active = true; cancelled = false; start = performance.now(); constructor(label: boolean | string) { this.msg = typeof label === "string" ? label : label ? "Encrypting" : "Decrypting"; this.outer = document.createElement("div"); this.outer.className = "std_status_outer"; this.outer.innerHTML = `
${SVG.filekey}
`; setIcon(this.outer.querySelector(".std_dp")!, "filekey_icon"); mainInner.appendChild(this.outer); this.el = this.outer.querySelector(".std_status") as HTMLElement; this.cancelEl = this.outer.querySelector(".std_status_cancel") as HTMLElement; scrollToBottom(); const tick = () => { if (!this.active) return; const s = Math.round((performance.now() - this.start) / 1000) % 3; this.el.textContent = this.msg + (s === 0 ? "." : s === 1 ? ".." : "..."); requestAnimationFrame(tick); }; tick(); } /** Show the Cancel affordance (streaming ops only). Main-thread loops poll `cancelled`; worker jobs * pass `onCancel` to terminate the worker. Both bail before any partial output is saved. */ enableCancel(onCancel?: () => void) { this.cancelEl.style.display = ""; (this.cancelEl.querySelector(".fk_cancel_act") as HTMLElement).addEventListener("click", () => { this.cancel(); onCancel?.(); }); } cancel() { if (this.cancelled) return; this.cancelled = true; this.active = false; this.el.textContent = `${this.msg}… Cancelled`; this.cancelEl.style.display = "none"; } progress(done: number, total: number) { if (this.cancelled) return; // keep the "Cancelled" text; ignore in-flight progress from the last chunk this.active = false; // halt the cycling-dots animation; show byte progress instead this.el.textContent = `${this.msg}… ${fmtBytes(done)} of ${fmtBytes(total)}`; } finish(label: string) { this.active = false; this.el.textContent = label; this.cancelEl.style.display = "none"; } done() { this.finish(this.msg + "... Done!"); } fail() { this.active = false; this.outer.remove(); } } // ---- file cards (v1 html_newFileUpload / html_newDownload) ---- // Middle-ellipsis for filenames: pin the tail (extension) and ellipsize the head, so a long name // like "Screenshot ….png.filekey" stays one clean line instead of breaking mid-word on mobile. function fnameHtml(filename: string): string { const safe = esc(filename); if (filename.length <= 16) return `${safe}`; const tailLen = Math.min(12, Math.ceil(filename.length * 0.35)); const head = esc(filename.slice(0, filename.length - tailLen)); const tail = esc(filename.slice(filename.length - tailLen)); return `${head}${tail}`; } function uploadCard(filename: string, typeLabel: string, isEncrypted: boolean) { const outer = document.createElement("div"); outer.className = "std_upload_outer"; outer.innerHTML = `
${isEncrypted ? SVG.logo : SVG.file}
${fnameHtml(filename)}${esc(typeLabel)}
`; setIcon(outer.querySelector(".icon_container")!, "file_icon"); mainInner.appendChild(outer); scrollToBottom(); } function downloadCard(filename: string, typeLabel: string, isEncrypted: boolean, dataBlob: Blob, shareSource: Blob, originalName: string, mime: string) { const outer = document.createElement("div"); outer.className = "std_dl_outer"; outer.innerHTML = `
${isEncrypted ? SVG.logo : SVG.file}
${fnameHtml(filename)}${esc(typeLabel)}
${SVG.save} Save
`; setIcon(outer.querySelector(".icon_container")!, "file_icon"); setIcon(outer.querySelector(".share_act")!, "dl_icon slight_vert_padding"); setIcon(outer.querySelector(".save_act")!, "save_icon"); (outer.querySelector(".save_act") as HTMLElement).addEventListener("click", () => void saveBlob(dataBlob, filename, { nudgeRecovery: isEncrypted })); (outer.querySelector(".share_act") as HTMLElement).addEventListener("click", () => onShareClick(shareSource, originalName, mime)); mainInner.appendChild(outer); scrollToBottom(); } // Deliver a recipient-encrypted file. Everything FileKey shares is already encrypted, so this is purely a // hand-off choice: "Send" opens the OS share sheet (Signal, Mail, AirDrop, … — the sheet is the target // list), "Save" downloads. Send only appears where the browser can share files (mobile, some desktop); // elsewhere it falls back to Save alone. Nothing is persisted — it just hands off a file you already made. function canSendFile(blob: Blob, filename: string): boolean { try { if (typeof navigator.canShare !== "function") return false; // Web Share for files works on touch devices (phones/tablets). Desktop browsers often report // canShare=true but share() then throws (confirmed on desktop Chrome), so gate on a coarse // (touch) primary pointer — "Send" only appears where a real OS share sheet exists. if (!window.matchMedia || !window.matchMedia("(pointer: coarse)").matches) return false; return navigator.canShare({ files: [new File([blob], filename, { type: "application/octet-stream" })] }); } catch { return false; } } async function sendFile(blob: Blob, filename: string) { try { await navigator.share({ files: [new File([blob], filename, { type: "application/octet-stream" })] }); } catch (e) { if ((e as Error).name === "AbortError") return; // user dismissed the sheet — nothing to do console.error("FileKey: send failed —", e); await saveBlob(blob, filename); await appMsg(["This device couldn't open a share sheet, so the file was saved to your downloads instead."], { speed: 6 }); } } function sanitizeName(name: string) { return (name.replace(/[\/\\]/g, "_").replace(/[\x00-\x1f]/g, "").trim() || "filekey-output").slice(0, 200); } // Best-effort detection of a storage-constrained context (notably private/incognito, which caps // origin storage hard) so a large-file failure can say so rather than a generic "try again". async function storageTooSmallFor(bytes: number): Promise { try { const est = await navigator.storage?.estimate?.(); if (!est || typeof est.quota !== "number") return false; return est.quota - (est.usage ?? 0) < bytes; } catch { return false; } } // Save a fully-assembled Blob. Prefers the native save picker where available (great for huge files — // it streams to disk with no object-URL size limit), else a universal object-URL download (Firefox/ // Safari/mobile). NOT used as a streaming sink, so it never gates browser support on the FS Access API. async function saveBlob(blob: Blob, filename: string, opts?: { nudgeRecovery?: boolean }) { const name = sanitizeName(filename); const big = blob.size >= STREAM_THRESHOLD; // only surface save feedback for large files; small saves are instant const w = window as unknown as { showSaveFilePicker?: (o: unknown) => Promise<{ createWritable: () => Promise<{ write: (b: Blob) => Promise; close: () => Promise }> }> }; if (w.showSaveFilePicker) { let st: StatusMsg | null = null; try { const h = await w.showSaveFilePicker({ suggestedName: name }); const ws = await h.createWritable(); // The native write streams to disk and ws.close() resolves only when fully flushed, so this // Saving → Saved is a *true* completion signal (the object-URL path below can't offer one). if (big) st = new StatusMsg("Saving"); await ws.write(blob); await ws.close(); st?.finish("Saved ✓"); if (opts?.nudgeRecovery) void maybeRecoveryNudge(); return; } catch (e) { st?.fail(); // drop the status whether the user cancelled the picker or the write itself failed if ((e as Error).name === "AbortError") return; // non-abort failure: fall through to the universal object-URL download } } const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = name; a.click(); // An has no completion callback, so the best we can do for a big file is tell the // user not to close the tab until their browser's own download finishes. if (big) void appMsg(["Your download is in progress. Keep this tab open until it finishes."], { speed: 6 }); if (opts?.nudgeRecovery) void maybeRecoveryNudge(); // Keep the URL valid long enough for the download to flush — scale ~1s/MB (60s floor, 10min cap); // freed on tab close regardless. (Chrome's native picker path above doesn't reach here.) const ttl = Math.min(600_000, Math.max(60_000, Math.ceil(blob.size / (1024 * 1024)) * 1000)); setTimeout(() => URL.revokeObjectURL(a.href), ttl); } // ---- streaming helpers (large files: read the File in chunks, write a Blob-of-Blobs) ---- const STREAM_THRESHOLD = 64 * 1024 * 1024; // files ≥64 MB stream; smaller ones use the in-memory path (AES-GCM ~1GB/s, so the byte-progress line only earns its keep past this size) function fmtBytes(n: number): string { if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`; if (n >= 1024 ** 2) return `${Math.round(n / 1024 ** 2)} MB`; if (n >= 1024) return `${Math.round(n / 1024)} KB`; return `${n} B`; } // A Blob-backed ByteSource for the DOM-free core (a File IS a Blob). onRead reports the high-water // byte offset read — used to drive encrypt progress, where slices advance sequentially through the plaintext. function blobSource(blob: Blob, onRead?: (highWater: number) => void): ByteSource { return { size: blob.size, async slice(start: number, end: number): Promise { const stop = Math.min(end, blob.size); const u = new Uint8Array(await blob.slice(start, stop).arrayBuffer()); onRead?.(stop); return u; }, }; } // ---- off-main-thread crypto client (large files only) — runs the streaming core in web/worker.ts ---- // One Worker per job, terminated on completion or cancel: no shared state, and cancel = terminate (instant). // Identity is derived on the main thread (WebAuthn/PRF can't run in a Worker), so the caller passes the // already-derived, structured-cloneable key material; the worker rebuilds Identity/Namespace from it. type JobProgress = (done: number, total: number) => void; type JobOutcome = { blob: Blob; metadata?: Metadata; shareSource?: Blob } | { cancelled: true }; function runCryptoJob(job: Record, onProgress?: JobProgress): { result: Promise; cancel: () => void } { const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" }); let settled = false; let resolveOutcome!: (o: JobOutcome) => void; // captured so cancel() can settle the promise from outside the executor const end = (fn: () => void) => { if (settled) return; settled = true; worker.terminate(); fn(); }; const result = new Promise((resolve, reject) => { resolveOutcome = resolve; worker.onmessage = (e: MessageEvent) => { const m = e.data as { kind: string; done?: number; total?: number; blob?: Blob; metadata?: Metadata; shareSource?: Blob; code?: string; message?: string }; if (m.kind === "progress") onProgress?.(m.done ?? 0, m.total ?? 0); else if (m.kind === "done") end(() => resolve({ blob: m.blob!, metadata: m.metadata, shareSource: m.shareSource })); // Rebuild a coded FileKeyError (so the decrypt catch's `instanceof`/`.code` checks still work); a // plain Error for anything uncoded (e.g. a storage-quota failure → the incognito message path). else if (m.kind === "error") end(() => reject(m.code ? new FileKeyError(m.message ?? "", m.code) : new Error(m.message ?? "worker error"))); }; worker.onerror = (ev) => end(() => reject(new Error((ev as ErrorEvent).message || "encryption worker failed to start"))); worker.postMessage(job); }); return { result, cancel: () => end(() => resolveOutcome({ cancelled: true })) }; } // ---- identity (v1 genNewPasskey -> "Now tap to authenticate" -> loadSecKey) ---- // Per-browser record of filekeys created here (timestamps only — no keys, no secrets, so it's fine under // the "no secrets stored" rule). Two uses: warn before an accidental *second* filekey (a separate identity // whose files won't interchange), and give additional keys a dated name so the OS passkey picker can tell // them apart. Per-browser by nature: it can't see a key created on a different device/browser. const CREATED_LOG_KEY = "filekey.created"; function createdLog(): number[] { try { const a = JSON.parse(localStorage.getItem(CREATED_LOG_KEY) || "[]"); return Array.isArray(a) ? a.filter((x): x is number => typeof x === "number") : []; } catch { return []; } } function recordCreated(at: number): void { try { const a = createdLog(); a.push(at); localStorage.setItem(CREATED_LOG_KEY, JSON.stringify(a)); } catch { /* storage blocked → can't track on this browser */ } } const fmtKeyDate = (ts: number): string => { try { return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); } catch { return "an earlier date"; } }; // Shown when WebAuthn PRF isn't available — either the browser-level pre-check failed, or the // authenticator failed the post-create check. junkPasskey=true means a passkey was just created // that can't do PRF, so we also point the user at removing it. async function showPrfUnsupported(junkPasskey: boolean) { await appMsg([ junkPasskey ? `This passkey doesn't support PRF, which FileKey needs. You can remove the new "FileKey" passkey in your device's passkey settings. ` : `FileKey needs a passkey that supports PRF, which this browser or device doesn't support yet. `, { link: "See what works.", onClick: () => void displayRequirements() }, ], ERR); } async function genNewPasskey() { if (identity) return; const prior = createdLog(); if (prior.length) { // They already made a filekey on this browser — most often they actually meant to sign in. Confirm // intent and offer the right path instead of silently minting a second, non-interchangeable identity. const m = await appMsg([`Heads up: you already created a filekey on this device on ${fmtKeyDate(prior[prior.length - 1]!)}. A new one is a separate identity, so files locked with your existing filekey won't open with it.`]); actionRow(m, [ { label: "Use existing", onClick: () => void loadSecKey() }, { label: "Create new", muted: true, onClick: () => void doEnroll(true) }, ]); return; } await doEnroll(false); } async function doEnroll(additional: boolean) { if (identity) return; // Pre-flight: if the browser definitively lacks PRF, say so without minting a junk passkey. if ((await prfBrowserSupport()) === false) { await showPrfUnsupported(false); return; } const now = Date.now(); // First key on this browser stays a clean "FileKey"; extras get a dated name so they're distinguishable // in the authenticator's passkey picker (which otherwise shows two identical "FileKey" entries). const name = additional ? `FileKey · ${fmtKeyDate(now)}` : "FileKey"; try { await enrollPasskey(name); } catch (e) { console.error("FileKey: passkey creation failed —", e); if ((e as Error).name === "NotAllowedError") return; // Browser allowed PRF but the authenticator didn't (e.g. older Windows Hello): a passkey got // created that can't do PRF — tell them, and that they can remove it. if (/PRF/i.test((e as Error).message)) { await showPrfUnsupported(true); return; } await appMsg(["Failed to create your filekey. Please try again."], ERR); return; } recordCreated(now); await appMsg(["Filekey created. ", { link: "Now tap to authenticate", onClick: () => void loadSecKey() }, "."], { speed: 4 }); } // Glanceable identity for the Your FileKey menu. No passkey name is available on // auth, so we derive a deterministic identicon + 8-char fingerprint from the public // key (identical on any device for the same filekey; distinguishes multiple keys). function identicon(hex: string): string { const bytes = (hex.match(/.{2}/g) || []).map((h) => parseInt(h, 16)); const bit = (i: number) => ((bytes[(i / 8) | 0] ?? 0) >> (7 - (i % 8))) & 1; const hue = (((bytes[0] ?? 0) * 360) / 256) | 0; const fg = `hsl(${hue} 56% 47%)`, bg = `hsl(${hue} 42% 93%)`; const N = 5, cell = 10; let cells = ""; for (let r = 0; r < N; r++) for (let c = 0; c < 3; c++) if (bit(r * 3 + c)) for (const cc of c === 2 ? [2] : [c, 4 - c]) cells += ``; return `${cells}`; } function renderIdentityHeader(): void { const id = identity; if (!id) return; const hex = identityFingerprint(id.staticPkRaw).hex.toUpperCase(); $("acct_identity").innerHTML = `${identicon(hex)}
Your FileKey
${hex.replace(/(.{4})(.{4})/, "$1 $2")}
`; } async function loadSecKey() { if (identity) return; if ((await prfBrowserSupport()) === false) { await showPrfUnsupported(false); return; } try { const prf = await getPrfSecret(); identity = await deriveIdentityFromPrf(prf, NS); } catch (e) { console.error("FileKey: authentication failed —", e); if ((e as Error).name === "NotAllowedError") return; if (/PRF/i.test((e as Error).message)) { await showPrfUnsupported(false); return; } await appMsg([`Authentication failed. Please try again.`], ERR); return; } await Contacts.loadContacts(identity!, SET); // local address book (public keys + your nicknames), decrypted into memory await appMsg(["Filekey authenticated. Now drag and drop files to encrypt or decrypt them!"]); $("drop_container").style.display = "flex"; document.body.classList.add("fk-authed"); // reveals the Your FileKey control (sliders) in the top bar renderIdentityHeader(); scrollToBottom(); } async function ensureAuthed(): Promise { if (identity) return true; await loadSecKey(); return identity != null; } // ---- file flow: route a batch of dropped/picked items (files + folders) ---- // Loose encrypted files decrypt individually; a lone plaintext file encrypts directly; any folder // (or 2+ plaintext items) is zipped into one archive and encrypted as a single .filekey. Decryption // is unchanged — a bundle just decrypts back to its .zip, which the user unpacks. async function handleItems(items: BundleItem[]) { if (!items.length) return; const toDecrypt: BundleItem[] = [], toEncrypt: BundleItem[] = [], toLegacy: BundleItem[] = [], toUnsupported: BundleItem[] = []; for (const it of items) { if (it.fromFolder) { toEncrypt.push(it); continue; } // folder contents are always content to bundle const cls = await classifyFile(it.file); if (cls === "decrypt") toDecrypt.push(it); // valid FKEY header → this build can decrypt it else if (cls === "unsupported") toUnsupported.push(it); // FKEY magic but wrong version/suite, or corrupt header else if (isLegacyName(it.file.name)) toLegacy.push(it); // .filekey/.shared_filekey w/o FKEY magic → made by v1 else toEncrypt.push(it); // not a FileKey file → encrypt it } for (const it of toDecrypt) await decryptFile(it.file); for (const it of toUnsupported) await unsupportedFileCard(it.file); for (const it of toLegacy) await legacyHandoffCard(it.file); if (toEncrypt.length === 1 && !toEncrypt[0]!.fromFolder) await encryptSingle(toEncrypt[0]!.file); else if (toEncrypt.length) await encryptBundle(toEncrypt); } // Classify a dropped file by its 12-byte header (read without pulling the whole file): // "decrypt" — a valid FKEY header this build can open // "unsupported" — FKEY magic matched but the version/suite is one we can't handle, or the header is corrupt // "plain" — no FKEY magic (or too short to have one): not a FileKey file at all async function classifyFile(file: File): Promise<"decrypt" | "unsupported" | "plain"> { try { parseHeader(new Uint8Array(await file.slice(0, HEADER_LEN).arrayBuffer())); return "decrypt"; } catch (e) { const code = e instanceof FileKeyError ? e.code : ""; // Wrong/short magic ⇒ not ours. Any other header failure means the magic matched but we can't open it. return code === "bad_magic" || code === "header_length" ? "plain" : "unsupported"; } } // A file whose header carries the FKEY magic but a version/suite this build can't open (or a corrupt // header). Don't re-encrypt it or send it to the v1 handoff — say so plainly. async function unsupportedFileCard(file: File) { uploadCard(file.name, "FileKey file", true); await appMsg( ["This looks like a FileKey file, but it's an unsupported version or it's corrupted, so it can't be opened here."], WARN, ); } async function decryptFile(file: File) { const isShared = /\.shared[._]filekey$/i.test(file.name); uploadCard(file.name, isShared ? "Shared File" : "Encrypted File", true); if (!(await ensureAuthed())) return; const st = new StatusMsg(false); try { if (file.size >= STREAM_THRESHOLD) { await decryptStreaming(file, st); } else { const bytes = new Uint8Array(await file.arrayBuffer()); const res = await decrypt({ file: bytes, namespaces: SET, resolveIdentity: async () => identity! }); st.done(); const mime = res.metadata.mimeType || "application/octet-stream"; const ptBlob = new Blob([res.plaintext as unknown as BlobPart], { type: mime }); downloadCard(res.metadata.filename, "File", false, ptBlob, ptBlob, res.metadata.filename, mime); } } catch (e) { console.error("FileKey: decrypt failed —", e); st.fail(); const code = e instanceof FileKeyError ? e.code : ""; let msg = "Failed to unlock file with this key. Please try again."; if (code === "wrong_namespace") msg = "This file was encrypted for a different FileKey site, so it can't be opened here."; else if (!(e instanceof FileKeyError) && await storageTooSmallFor(file.size * 2)) msg = "Couldn't unlock: your browser is low on storage for a file this size. Private/incognito mode is especially limited, so try a normal window."; await appMsg([msg], ERR); } } // Large files: stream-decrypt the dropped File chunk-by-chunk into a Blob-of-Blobs (never the whole // file in memory). Policy A — the chunks generator throws on any auth/truncation failure, so a failed // file is caught by decryptFile and never assembled into a downloadable output. async function decryptStreaming(file: File, st: StatusMsg) { // Big file → decrypt in a Worker. A failed/truncated file rejects (no partial plaintext assembled), and the // rejection reaches decryptFile's catch with the original FileKeyError code intact (see runCryptoJob). const job = runCryptoJob({ kind: "decrypt", rpId: RP_ID, rpIds: [RP_ID], keyPair: identity!.keyPair, staticPk: identity!.staticPkRaw, file }, (d, t) => st.progress(d, t)); st.enableCancel(job.cancel); const out = await job.result; if ("cancelled" in out) return; // dropped the decrypted prefix; nothing saved st.done(); const mime = out.metadata?.mimeType || "application/octet-stream"; const name = out.metadata?.filename || "file"; downloadCard(name, "File", false, out.blob, out.blob, name, mime); // only offered after full authentication } // A file named like a FileKey file but lacking the FKEY magic header was made by the original v1 app // (P-521 / different format this version can't read). Don't encrypt it — hand the user off to the v1 app. function isLegacyName(name: string): boolean { return /\.(shared[._])?filekey$/i.test(name); } // The original v1 app lives on a "v1." subdomain of this deployment's registrable domain. function v1AppUrl(): string { const apex = deploymentRpId(); return apex.includes(".") ? `https://v1.${apex}` : "https://v1.filekey.app"; } async function legacyHandoffCard(file: File) { const url = v1AppUrl(); uploadCard(file.name, "Made with an earlier FileKey", true); await appMsg( [{ html: `This file was locked with an earlier version of FileKey that used a different format, so it can't be opened here. Unlock it at ${extLink(url, url.replace(/^https:\/\//, ""))}. Your same passkey works there.` }], { dp: "warning_dp", icon: "warning_filekey_icon" }, ); } // Recovery nudge: a one-time, dismissible heads-up after the first lock (encrypt-to-self), // pointing at the recovery code. Shown at most once, and suppressed entirely if they've // already opened recovery on their own — informed consent, not a recurring nag. const RECOVERY_ACK_KEY = "filekey.recovery_acked"; const flagGet = (k: string) => { try { return localStorage.getItem(k) === "1"; } catch { return false; } }; const flagSet = (k: string) => { try { localStorage.setItem(k, "1"); } catch { /* storage blocked → may re-show next session */ } }; async function maybeRecoveryNudge() { if (flagGet(RECOVERY_ACK_KEY)) return; flagSet(RECOVERY_ACK_KEY); await appMsg(["Your files unlock only with this passkey. Save a recovery code so you can still get in if you ever lose access to it. ", { link: "Show recovery code", onClick: () => void revealRecovery() }], { speed: 4 }); } async function encryptSingle(file: File) { uploadCard(file.name, "File", false); if (!(await ensureAuthed())) return; const st = new StatusMsg(true); try { const meta: Omit = { filename: file.name, mimeType: file.type || "application/octet-stream", createdAtUnixMs: Date.now(), extras: new Map() }; if (file.size >= STREAM_THRESHOLD) { // Big file → run the encrypt in a Worker so the main thread stays responsive (see runCryptoJob). const job = runCryptoJob({ kind: "encrypt", rpId: RP_ID, senderKeyPair: identity!.keyPair, senderPk: identity!.staticPkRaw, recipientPk: identity!.staticPkRaw, blob: file, metadata: meta }, (d, t) => st.progress(d, t)); st.enableCancel(job.cancel); const out = await job.result; if ("cancelled" in out) return; // dropped in-flight output; nothing saved st.done(); // shareSource is the original File (re-readable from disk) so Share re-streams without holding plaintext. downloadCard(`${file.name}.filekey`, "Encrypted File", true, out.blob, file, file.name, "application/octet-stream"); } else { const bytes = new Uint8Array(await file.arrayBuffer()); const out = await encryptToSelf({ identity: identity!, plaintext: bytes, metadata: meta }); st.done(); downloadCard(`${file.name}.filekey`, "Encrypted File", true, new Blob([out as unknown as BlobPart]), new Blob([bytes as unknown as BlobPart], { type: file.type || "application/octet-stream" }), file.name, "application/octet-stream"); } } catch (e) { console.error("FileKey: file encrypt failed —", e); st.fail(); await appMsg([(await storageTooSmallFor(file.size * 2)) ? "Couldn't encrypt: your browser is low on storage for a file this size. Private/incognito mode is especially limited, so try a normal window." : "Failed to encrypt this file. Please try again."], ERR); } } // Bundle names must stay distinct within a session, so repeated loose-file bundles (all named // "filekey-bundle") don't collide in the list or overwrite each other on save. Suffix -2, -3, … on reuse. const usedBundleNames = new Set(); function uniqueBundleName(base: string): string { let name = base; for (let n = 2; usedBundleNames.has(name); n++) name = `${base}-${n}`; usedBundleNames.add(name); return name; } // Multiple files / folders → one .zip, encrypted as a single .filekey. async function encryptBundle(items: BundleItem[]) { const name = uniqueBundleName(bundleName(items)); const zipName = `${name}.zip`; uploadCard(name, `${items.length} ${items.length === 1 ? "file" : "files"}`, false); if (!(await ensureAuthed())) return; const st = new StatusMsg(true); try { const meta: Omit = { filename: zipName, mimeType: "application/zip", createdAtUnixMs: Date.now(), extras: new Map() }; const total = items.reduce((n, it) => n + it.file.size, 0); if (total >= STREAM_THRESHOLD) { // Big folder → zip + encrypt in a Worker (the zip's CRC32 is the biggest main-thread cost). Progress // spans both phases (zip read + encrypt ≈ 2x bytes). shareSource is the archive, returned for Share. const job = runCryptoJob({ kind: "zipEncrypt", rpId: RP_ID, senderKeyPair: identity!.keyPair, senderPk: identity!.staticPkRaw, recipientPk: identity!.staticPkRaw, items, totalBytes: total, metadata: meta }, (d, t) => st.progress(d, t)); st.enableCancel(job.cancel); const out = await job.result; if ("cancelled" in out) return; // cancelled during zip or encrypt; nothing saved st.done(); downloadCard(`${zipName}.filekey`, "Encrypted Bundle", true, out.blob, out.shareSource ?? new Blob([]), zipName, "application/octet-stream"); } else { // Small folder → zip + encrypt on the main thread (fast; not worth the Worker round-trip). const zipBlob = await zipBundleToBlob(items); const parts: Blob[] = []; for await (const piece of encryptStream({ senderIdentity: identity!, recipientPkRaw: identity!.staticPkRaw, namespace: identity!.namespace, plaintext: blobSource(zipBlob), metadata: meta })) parts.push(new Blob([piece as unknown as BlobPart])); st.done(); downloadCard(`${zipName}.filekey`, "Encrypted Bundle", true, new Blob(parts), zipBlob, zipName, "application/octet-stream"); } } catch (e) { console.error("FileKey: folder encrypt failed —", e); // surface the real cause (the message below is generic) st.fail(); await appMsg([(await storageTooSmallFor(items.reduce((n, it) => n + it.file.size, 0) * 2)) ? "Couldn't encrypt: your browser is low on storage for files this large. Private/incognito mode is especially limited, so try a normal window or a smaller batch." : "Failed to encrypt these files. Please try again."], ERR); } } // ---- contacts: label helpers + post-share capture (data layer lives in web/contacts.ts) ---- const shortKey = (key: string) => (key.length > 22 ? `${key.slice(0, 12)}…${key.slice(-6)}` : key); const contactLabel = (c: Contacts.Contact) => c.nickname || shortKey(c.key); const avatarInitial = (c: Contacts.Contact) => { const n = c.nickname?.trim(); return n ? n[0]!.toUpperCase() : "•"; }; // After a successful share: if the recipient is already a saved contact, bump its recency (recent-first); // if brand new, offer to save them — but only persist the contact if they add a nickname (skip = not saved). async function rememberRecipient(key: string) { if (Contacts.findByKey(key)) { await Contacts.rememberUse(key); return; } await promptNickname(key); } async function promptNickname(key: string) { await appMsg([`Save ${shortKey(key)} to your contacts? Add a nickname so it's easy to pick next time:`], { speed: 4 }); const tmp = document.createElement("div"); tmp.innerHTML = `
SaveSkip
`; const row = tmp.firstElementChild as HTMLElement; mainInner.appendChild(row); const input = $("nickname_input") as HTMLInputElement; const saveBtn = $("nickname_save"), skipBtn = $("nickname_skip"); scrollToBottom(); input.focus(); let done = false; const finish = () => { done = true; input.setAttribute("readonly", "true"); input.style.backgroundColor = "var(--fk-fill)"; saveBtn.remove(); skipBtn.remove(); }; const skip = async () => { if (done) return; done = true; row.remove(); // without a nickname we never saved the contact — leave nothing stranded await appMsg(["Okay, not saved to your contacts."], { speed: 4 }); }; const save = async () => { if (done) return; const name = input.value.trim(); if (!name) { await skip(); return; } // no nickname → don't save (treated as skip) const res = await Contacts.addContact(key, name); if (!res.ok) { if (res.reason === "duplicate_nickname") { await appMsg([`"${name}" is already used by ${contactLabel(res.conflict)}. Try a different name.`], { speed: 6 }); return; } finish(); await appMsg([`Already in your contacts as ${contactLabel(res.conflict)}.`], { speed: 4 }); return; // duplicate_key (rare: guarded by findByKey above) } finish(); await appMsg([`Saved "${name}" to your contacts.`], { speed: 4 }); }; saveBtn.addEventListener("click", () => void save()); skipBtn.addEventListener("click", () => void skip()); input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); void save(); } }); } // Share (v1 triggerShare, reworked): each Share opens its OWN recipient prompt bound to that file, // so different files in one session can go to different recipients. Contacts power a quick picker. function onShareClick(plaintext: Blob, name: string, mime: string) { void openRecipientPrompt({ plaintext, name, mime }); } async function openRecipientPrompt(file: ShareFile) { await appMsg([`Share "${file.name}". Enter the recipient's share key for secure sharing:`], { speed: 4 }); // recent-contacts picker (above the input): click a contact to fill+confirm; typing the key filters it. // Local element refs (no ids) so multiple share prompts can coexist in one session. const picker = document.createElement("div"); picker.className = "contacts_picker set_right"; mainInner.appendChild(picker); // v1 html_newTextarea: right-aligned, auto-growing, Confirm (check) + Edit (pencil, hidden). const tmp = document.createElement("div"); tmp.innerHTML = `
${SVG.check.replace("ConfirmCancel${SVG.edit.replace("Edit
`; const cont = tmp.firstElementChild as HTMLElement; mainInner.appendChild(cont); const ta = cont.querySelector(".pub_key_textarea") as HTMLTextAreaElement; const cancelBtn = cont.querySelector(".cancel_pub_key") as HTMLElement; const confirmBtn = cont.querySelector(".confirm_pub_key") as HTMLElement; const editBtn = cont.querySelector(".edit_pub_key") as HTMLElement; const grow = () => { ta.style.height = "auto"; ta.style.height = ta.scrollHeight + "px"; }; // auto-size to fit the (wrapping) key grow(); const renderPicker = (filter = "") => { const all = Contacts.listContacts(); if (!all.length) { picker.style.display = "none"; picker.innerHTML = ""; return; } const f = filter.trim().toLowerCase(); const matches = (f ? all.filter((c) => (c.nickname || "").toLowerCase().includes(f) || c.key.toLowerCase().includes(f)) : all).slice(0, 8); picker.style.display = "flex"; picker.innerHTML = `${f ? "Matching contacts" : "Recent contacts"}`; if (!matches.length) { picker.insertAdjacentHTML("beforeend", `No matches. Paste a share key below.`); return; } for (const c of matches) { const chip = document.createElement("span"); chip.className = "contact_chip no_select"; chip.innerHTML = `${esc(avatarInitial(c))}${esc(contactLabel(c))}`; chip.addEventListener("click", () => { ta.value = c.key; grow(); void onConfirm(); }); picker.appendChild(chip); } }; ta.addEventListener("input", () => { grow(); renderPicker(ta.value); }); scrollToBottom(); let sent = false; const lock = () => { // recipient chosen + file sent: lock the field, show only Edit picker.style.display = "none"; cancelBtn.style.display = "none"; confirmBtn.style.display = "none"; editBtn.style.display = "flex"; ta.setAttribute("readonly", "true"); ta.style.backgroundColor = "var(--fk-fill)"; }; const unlock = () => { // editing the recipient: reopen the field + picker, show Confirm + Cancel again editBtn.style.display = "none"; confirmBtn.style.display = "flex"; cancelBtn.style.display = "flex"; ta.removeAttribute("readonly"); ta.style.backgroundColor = "var(--fk-surface)"; renderPicker(""); }; const onConfirm = async () => { const val = ta.value.trim(); if (!val) return; try { decodeShareKey(val, SET); } catch { await appMsg(["Invalid share key."], { speed: 8 }); return; } lock(); await encryptForRecipient(val, file); sent = true; }; // Cancel before any send dismisses the whole prompt; while editing after a send it just re-locks (that file already went out). const onCancel = () => { if (sent) lock(); else { picker.remove(); cont.remove(); void appMsg(["Share cancelled."], { speed: 6 }); } }; cancelBtn.addEventListener("click", onCancel); confirmBtn.addEventListener("click", onConfirm); editBtn.addEventListener("click", unlock); renderPicker(); } // v1 handleShare: encrypt the plaintext to the chosen recipient and save it directly (no card). async function encryptForRecipient(recipient: string, file: ShareFile, opts: { sender?: Identity; who?: string; remember?: boolean } = {}) { const big = file.plaintext.size >= STREAM_THRESHOLD; const st = big ? new StatusMsg(true) : null; try { const meta: Omit = { filename: file.name, mimeType: file.mime, createdAtUnixMs: Date.now(), extras: new Map() }; const { recipientPkRaw, namespace } = decodeShareKey(recipient, SET); // file's namespace comes from the share key let out: Blob; if (big) { const sender = opts.sender ?? identity!; const job = runCryptoJob({ kind: "encrypt", rpId: namespace.canonicalRpId, senderKeyPair: sender.keyPair, senderPk: sender.staticPkRaw, recipientPk: recipientPkRaw, blob: file.plaintext, metadata: meta }, (d, t) => st!.progress(d, t)); st!.enableCancel(job.cancel); const r = await job.result; if ("cancelled" in r) return; // cancelled; nothing saved st!.done(); out = r.blob; } else { const parts: Blob[] = []; for await (const piece of encryptStream({ senderIdentity: opts.sender ?? identity!, recipientPkRaw, namespace, plaintext: blobSource(file.plaintext), metadata: meta })) parts.push(new Blob([piece as unknown as BlobPart])); out = new Blob(parts, { type: "application/octet-stream" }); } const sharedName = `${file.name}.shared.filekey`; // SPEC: .shared.filekey const nick = opts.who ?? Contacts.findByKey(recipient)?.nickname; const who = nick ? `for "${nick}"` : "for your recipient"; // Deliver as an inline choice, not a second card (a card looked too much like the encrypted-file card). // Save works everywhere; Send (OS share sheet) only where the browser can share files. // Same copy + action row on every browser. Desktop usually lacks an OS file-share sheet, so the // Save-only case is what most people see; Save is always offered, Send… only where the sheet exists. const canSend = canSendFile(out, sharedName); const m = await appMsg([`"${file.name}" is encrypted ${who}. Only they can open it.`], { speed: 6 }); const acts: { label: string; muted?: boolean; onClick: () => void }[] = [{ label: "Save", onClick: () => void saveBlob(out, sharedName) }]; if (canSend) acts.push({ label: "Send…", onClick: () => void sendFile(out, sharedName) }); actionRow(m, acts); if (opts.remember !== false) await rememberRecipient(recipient); // add/refresh this recipient in the local address book } catch (e) { console.error("FileKey: share encrypt failed —", e); st?.fail(); await appMsg([`Couldn't encrypt for that recipient: ${esc((e as Error).message)}`], ERR); } } // ---- "send me a file" link (inbound / anonymous) ------------------------------------------------ // A "#to=" link lets ANYONE encrypt a file to the link owner using a throwaway one-time // sender identity — no passkey, no account, no password. Pure public-key E2E; reuses the normal // encrypt path. See reference/docs/send-me-a-file.md. function parseSendToHash(): { to: string; name?: string } | null { try { const raw = location.hash.replace(/^#/, ""); if (!raw) return null; const p = new URLSearchParams(raw); const to = p.get("to"); return to ? { to, name: p.get("name") || undefined } : null; } catch { return null; } } // CSPRNG bytes through the normal derivation = a valid one-time keypair (codex-confirmed). NS is this // deployment's namespace, which the link's key must also be in (decodeShareKey validates it). const deriveThrowawayIdentity = (): Promise => deriveIdentityFromPrf(crypto.getRandomValues(new Uint8Array(32)), NS); async function sendToMode(toKey: string, rawName?: string) { try { decodeShareKey(toKey, SET); } catch { await appMsg(["This link is invalid or incomplete. Ask for a fresh one."], ERR); return; } const name = (rawName || "").trim().slice(0, 60); const to = name || "the recipient"; await appMsg([{ t: name ? `Send ${name} a file.` : "Send a secure file.", b: true }, ` Drop a file or choose one below. It's encrypted on your device so only ${to} can open it. No account or app needed.`]); const drop = $("drop_container"); drop.style.display = "flex"; const dropLabel = drop.querySelector(".file_title") as HTMLElement | null; if (dropLabel) dropLabel.textContent = "Drop a file to send"; marchingBorder(drop); const fileInput = $("file_input") as HTMLInputElement; const folderInput = $("folder_input") as HTMLInputElement; $("choose_file").addEventListener("click", () => fileInput.click()); $("choose_folder").addEventListener("click", () => folderInput.click()); drop.addEventListener("click", (e) => { if (!(e.target as HTMLElement).closest(".dc_btn, input")) fileInput.click(); }); fileInput.addEventListener("change", () => { if (fileInput.files?.length) void handleSendTo(collectFromInput(fileInput.files), toKey, name); fileInput.value = ""; }); folderInput.addEventListener("change", () => { if (folderInput.files?.length) void handleSendTo(collectFromInput(folderInput.files), toKey, name); folderInput.value = ""; }); const dragWin = $("drag_window"), fdz = $("file_drag_zone"); let depth = 0; window.addEventListener("dragenter", (e) => { e.preventDefault(); if (++depth === 1) { dragWin.style.display = "block"; fdz.style.display = "block"; } }); window.addEventListener("dragover", (e) => e.preventDefault()); window.addEventListener("dragleave", (e) => { e.preventDefault(); if (--depth <= 0) { depth = 0; dragWin.style.display = "none"; fdz.style.display = "none"; } }); window.addEventListener("drop", (e) => { e.preventDefault(); depth = 0; dragWin.style.display = "none"; fdz.style.display = "none"; const dt = (e as DragEvent).dataTransfer; if (dt) void collectFromDrop(dt).then((items) => handleSendTo(items, toKey, name)); }); } // Encrypt whatever the sender dropped to the link owner, with a fresh throwaway sender. No auth and no // address-book write — an anonymous sender has neither. The zip phase (folders / multiple files) gets its // own progress + cancel + error handling for big folders (mirrors encryptBundle); the encrypt phase is // handled by encryptForRecipient. async function handleSendTo(items: BundleItem[], toKey: string, name: string) { if (!items.length) return; const sender = await deriveThrowawayIdentity(); const opts = { sender, who: name || undefined, remember: false }; if (items.length === 1 && !items[0]!.fromFolder) { const f = items[0]!.file; uploadCard(f.name, "File", false); await encryptForRecipient(toKey, { plaintext: f, name: f.name, mime: f.type || "application/octet-stream" }, opts); return; } const base = uniqueBundleName(bundleName(items)); uploadCard(base, `${items.length} ${items.length === 1 ? "file" : "files"}`, false); const total = items.reduce((n, it) => n + it.file.size, 0); const st = total >= STREAM_THRESHOLD ? new StatusMsg(true) : null; // big folders: progress + cancel during zip let zipBlob: Blob; try { st?.enableCancel(); zipBlob = await zipBundleToBlob(items, st ? (b) => st.progress(b, total) : undefined, () => st?.cancelled ?? false); } catch (e) { console.error("FileKey: send-to bundling failed —", e); st?.fail(); await appMsg(["Couldn't prepare those files. Please try again."], ERR); return; } if (st?.cancelled) { st.fail(); return; } st?.done(); await encryptForRecipient(toKey, { plaintext: zipBlob, name: `${base}.zip`, mime: "application/zip" }, opts); } // ---- share key (v1 displayPublicKey) ---- async function displayPublicKey() { if (!(await ensureAuthed())) return; const sk = encodeShareKey(identity!.staticPkRaw, NS); // SPEC-DELTA: bech32 fkey1…, not v1's raw hex // ONE message (v1 displayPublicKey): intro text (typed) + key (word_broken) + Copy button. const msg = appShell(); const intro = document.createElement("span"); msg.appendChild(intro); await typeInto(intro, "Your share key is a public key that allows others to encrypt data that only you can decrypt:", 8); const p = document.createElement("p"); p.textContent = sk; // monospace inset "code block" so the wrap reads as intentional p.style.cssText = "font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;line-height:1.6;word-break:break-all;background:var(--fk-fill);border-radius:10px;padding:14px 16px;margin:12px 0 0;color:var(--fk-ink-soft)"; msg.appendChild(p); const copy = document.createElement("div"); copy.className = "copy_button no_select"; copy.style.marginTop = "14px"; copy.innerHTML = `${SVG.copy.replace("Copy`; msg.appendChild(copy); scrollToBottom(); copy.addEventListener("click", async () => { try { await navigator.clipboard.writeText(sk); const l = copy.querySelector(".cp_lbl")!; l.textContent = "Copied!"; setTimeout(() => (l.textContent = "Copy"), 1000); } catch {} }); } // ---- "receive a file" link (owner side of the send-me-a-file flow) ---- // Wraps the owner's share key in a "#to=" link anyone can open to send them an encrypted file. async function showSendLink() { if (!(await ensureAuthed())) return; const link = `${location.origin}/#to=${encodeShareKey(identity!.staticPkRaw, NS)}`; const msg = appShell(); const intro = document.createElement("span"); msg.appendChild(intro); await typeInto(intro, "Share this link and anyone can send you an encrypted file, even if they don't have FileKey. Only you can open what they send:", 8); const p = document.createElement("p"); p.textContent = link; p.style.cssText = "font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;line-height:1.6;word-break:break-all;background:var(--fk-fill);border-radius:10px;padding:14px 16px;margin:12px 0 0;color:var(--fk-ink-soft)"; msg.appendChild(p); const acts = document.createElement("div"); acts.style.cssText = "display:flex;gap:18px;margin-top:14px;flex-wrap:wrap"; const mk = (label: string) => { const s = document.createElement("span"); s.className = "msg_clickable no_select"; s.style.cursor = "pointer"; s.textContent = label; return s; }; const copyBtn = mk("Copy"); acts.appendChild(copyBtn); const shareBtn = typeof navigator.share === "function" ? mk("Share") : null; if (shareBtn) acts.appendChild(shareBtn); const qrBtn = mk("Show QR"); acts.appendChild(qrBtn); msg.appendChild(acts); const qrBox = document.createElement("div"); qrBox.style.marginTop = "14px"; msg.appendChild(qrBox); scrollToBottom(); copyBtn.addEventListener("click", async () => { try { await navigator.clipboard.writeText(link); copyBtn.textContent = "Copied!"; setTimeout(() => (copyBtn.textContent = "Copy"), 1200); } catch {} }); shareBtn?.addEventListener("click", async () => { try { await navigator.share({ url: link, title: "Send me a file securely with FileKey" }); } catch {} }); qrBtn.addEventListener("click", () => { if (qrBox.childNodes.length) { qrBox.replaceChildren(); qrBtn.textContent = "Show QR"; return; } // toggle off const qr = qrcode(0, "M"); qr.addData(link); qr.make(); qrBox.innerHTML = qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true }); // QR encodes the link as modules, not HTML — safe const svg = qrBox.querySelector("svg"); if (svg) svg.setAttribute("style", "width:180px;height:180px;background:#fff;border-radius:10px;padding:10px;box-sizing:border-box"); // white quiet zone so it scans + shows in dark mode qrBtn.textContent = "Hide QR"; }); } // ---- recovery (SPEC-DELTA §4.6: BIP39 / Bech32m, gated) ---- async function showRecovery() { if (!(await ensureAuthed())) return; flagSet(RECOVERY_ACK_KEY); // engaging with recovery → suppress the post-lock nudge await appMsg(["A recovery code is the only way to access your data if you lose your passkey. ", { link: "Show it", onClick: () => void revealRecovery() }, "."]); } async function revealRecovery() { // Re-verify with the passkey before revealing the recovery code (a bearer secret), and derive // master_prk on demand instead of keeping it resident in the Identity between uses. let masterPrk: Uint8Array; try { masterPrk = masterPrkFromPrfSecret(await getPrfSecret()); } catch { await appMsg(["Passkey check cancelled, so the recovery code wasn't shown."], ERR); return; } const bip39 = encodeRecoveryBip39(masterPrk); // Same mono-inset format as the share key, but word-break:normal so the phrase wraps at spaces (whole words). const msg = appShell(); const intro = document.createElement("span"); msg.appendChild(intro); await typeInto(intro, "Your recovery code. Keep it safe, since anyone who has it can open your files:", 8); const p = document.createElement("p"); p.textContent = bip39; p.style.cssText = "font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;line-height:1.7;word-break:normal;overflow-wrap:break-word;background:var(--fk-fill);border-radius:10px;padding:14px 16px;margin:12px 0 0;color:var(--fk-ink-soft)"; msg.appendChild(p); const copy = document.createElement("div"); copy.className = "copy_button no_select"; copy.style.marginTop = "14px"; copy.innerHTML = `${SVG.copy.replace("Copy`; msg.appendChild(copy); // Pair the code with the offline recovery tool (web/recover.html) — a download, not a nav link. const tool = document.createElement("p"); tool.style.cssText = "margin:18px 0 0;font-size:14px;color:var(--fk-muted-2);line-height:1.55"; tool.innerHTML = `Even if FileKey disappears, this code still works. Pair it with the
${SVG.save.replace(", a single self-contained page that decrypts your files locally.`; msg.appendChild(tool); scrollToBottom(); copy.addEventListener("click", async () => { try { await navigator.clipboard.writeText(bip39); const l = copy.querySelector(".cp_lbl")!; l.textContent = "Copied!"; setTimeout(() => (l.textContent = "Copy"), 1000); } catch {} }); } // ---- contacts manager (menu → Contacts): list, rename, delete, clear-all; gated post-auth ---- async function showContacts() { if (!(await ensureAuthed())) return; const msg = appShell(); const intro = document.createElement("span"); msg.appendChild(intro); await typeInto(intro, "Your contacts are saved on this device, encrypted with your passkey, never uploaded.", 8); const body = document.createElement("div"); body.className = "contacts_manager"; msg.appendChild(body); renderContacts(body); scrollToBottom(); } function renderContacts(container: HTMLElement) { container.innerHTML = ""; const list = Contacts.listContacts(); if (!list.length) { container.insertAdjacentHTML("beforeend", `

No contacts yet. They're added automatically when you share a file with someone's share key, or add one manually below.

`); } else { const ul = document.createElement("div"); ul.className = "contacts_list"; for (const c of list) { const row = document.createElement("div"); row.className = "contact_row"; row.innerHTML = `${esc(avatarInitial(c))}
${esc(contactLabel(c))}${esc(c.key)}
RenameDelete
`; (row.querySelector(".rename_act") as HTMLElement).addEventListener("click", () => renameContact(row, c, container)); (row.querySelector(".delete_act") as HTMLElement).addEventListener("click", async () => { await Contacts.removeContact(c.key); renderContacts(container); }); ul.appendChild(row); } container.appendChild(ul); } // footer: Add contact + Import (always); Export + Clear all (only when there are contacts). const footer = document.createElement("div"); footer.className = "contacts_footer"; const add = document.createElement("span"); add.className = "contacts_add no_select"; add.textContent = "Add"; add.addEventListener("click", () => openAddForm(container)); footer.appendChild(add); const imp = document.createElement("span"); imp.className = "contacts_add no_select"; imp.textContent = "Import"; imp.addEventListener("click", () => importContacts(container)); footer.appendChild(imp); if (list.length) { const exp = document.createElement("span"); exp.className = "contacts_add no_select"; exp.textContent = "Export"; exp.addEventListener("click", () => exportContacts()); footer.appendChild(exp); const clear = document.createElement("span"); clear.className = "contacts_clear_link no_select"; clear.textContent = "Clear All"; let arming = false; clear.addEventListener("click", async () => { if (!arming) { arming = true; clear.textContent = "Clear all contacts? Tap again to confirm"; setTimeout(() => { if (arming) { arming = false; clear.textContent = "Clear All"; } }, 3000); return; } await Contacts.clearContacts(); renderContacts(container); }); footer.appendChild(clear); } container.appendChild(footer); } // Export the address book as a plain-JSON download. Share keys are public, so the file has no secrets — // it just holds your contact list (names + their keys), so it imports fine onto a different passkey/device. function exportContacts() { void saveBlob(new Blob([Contacts.exportContactsJson()], { type: "application/json" }), "filekey-contacts.json"); } // Import contacts from a previously-exported JSON file: validate each share key (namespace-aware), dedupe // against what's already saved, then report how many were added / skipped / rejected. function importContacts(container: HTMLElement) { const input = document.createElement("input"); input.type = "file"; input.accept = "application/json,.json"; input.style.display = "none"; input.addEventListener("change", async () => { const file = input.files?.[0]; input.remove(); if (!file) return; let result; try { const text = await file.text(); result = await Contacts.importContactsJson(text, (k) => { try { decodeShareKey(k, SET); return true; } catch { return false; } }); } catch { await appMsg(["That doesn't look like a FileKey contacts file."], { speed: 8 }); return; } renderContacts(container); const bits = [`Imported ${result.added} contact${result.added === 1 ? "" : "s"}`]; if (result.skipped) bits.push(`${result.skipped} already saved`); if (result.rejected) bits.push(`${result.rejected} invalid`); await appMsg([`${bits.join(" · ")}.`], { speed: 4 }); }); document.body.appendChild(input); input.click(); } // Manually add a contact (paste a share key + optional nickname), independent of sharing a file. function openAddForm(container: HTMLElement) { container.querySelector(".contacts_footer")?.remove(); container.querySelector(".add_contact_form")?.remove(); const form = document.createElement("div"); form.className = "add_contact_form"; form.innerHTML = `
${SVG.check.replace("SaveCancel
`; container.appendChild(form); const keyInput = form.querySelector(".add_key_input") as HTMLTextAreaElement; const nickInput = form.querySelector(".add_nick_input") as HTMLInputElement; const grow = () => { keyInput.style.height = "auto"; keyInput.style.height = keyInput.scrollHeight + "px"; }; keyInput.addEventListener("input", grow); grow(); keyInput.focus({ preventScroll: true }); const save = async () => { const key = keyInput.value.trim(); if (!key) { keyInput.focus(); return; } try { decodeShareKey(key, SET); } catch { await appMsg(["That doesn't look like a valid FileKey share key."], { speed: 8 }); return; } const res = await Contacts.addContact(key, nickInput.value); if (!res.ok) { await appMsg([res.reason === "duplicate_key" ? `You've already saved this key${res.conflict.nickname ? ` as "${res.conflict.nickname}"` : ""}.` : `"${nickInput.value.trim()}" is already used by ${contactLabel(res.conflict)}. Try a different name.`], { speed: 6 }); return; } renderContacts(container); await appMsg([`Added ${contactLabel(res.contact)} to your contacts.`], { speed: 4 }); }; (form.querySelector(".add_save") as HTMLElement).addEventListener("click", () => void save()); (form.querySelector(".add_cancel") as HTMLElement).addEventListener("click", () => renderContacts(container)); // Bring the form into view in place (it lives inside the contacts manager, which may be mid-stream) — // not the page bottom, which would scroll the user away from the form they just opened. form.scrollIntoView({ behavior: "smooth", block: "center" }); } function renameContact(row: HTMLElement, c: Contacts.Contact, container: HTMLElement) { const main = row.querySelector(".contact_main") as HTMLElement; const acts = row.querySelector(".contact_acts") as HTMLElement; main.innerHTML = `${esc(c.key)}`; acts.innerHTML = `SaveCancel`; const input = main.querySelector(".rename_input") as HTMLInputElement; input.value = c.nickname || ""; input.focus(); const save = async () => { const res = await Contacts.setNickname(c.key, input.value); if (!res.ok) { input.style.borderColor = "var(--fk-error)"; input.title = `Already used by ${contactLabel(res.conflict)}`; return; } renderContacts(container); }; (acts.querySelector(".save_rename") as HTMLElement).addEventListener("click", () => void save()); (acts.querySelector(".cancel_rename") as HTMLElement).addEventListener("click", () => renderContacts(container)); input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); void save(); } else if (e.key === "Escape") renderContacts(container); }); } // ---- requirements panel (v1 displayRequirements) ---- async function displayRequirements() { await appMsg([{ html: `

FileKey needs a passkey with PRF

FileKey unlocks your files using your passkey's PRF capability, which your current browser or device doesn't support yet. It works with:

  • A recent iPhone or Mac: Safari 18+ or Chrome (iOS 18.4+ / macOS 15+), with an iCloud Keychain passkey
  • Android: Chrome with a Google Password Manager passkey
  • Windows 11 (25H2 or newer, fully updated): recent Chrome, Edge, or Firefox
  • A hardware security key (e.g. YubiKey) on a desktop browser

Updating your browser and OS usually fixes it.

` }], { speed: 16 }); } // ---- hamburger ("chiz") menu: verbatim v1 content ---- const CHIZ: Record = { chiz_terms: { speed: 10, html: `

Terms of Service

  1. Acceptance of Terms

    By using FileKey, you agree to these Terms of Service. If you do not agree, please do not use our site or services.
  2. Intended Use

    FileKey is designed to help you encrypt and decrypt files locally with your own hardware. You are responsible for using FileKey in compliance with all applicable laws and regulations.
  3. No Guarantees

    We provide FileKey "as is", without warranties of any kind. We do not guarantee that FileKey will be error-free, secure, or meet all your needs.
  4. Your Responsibility

    You must ensure that your hardware security key and devices remain secure. We are not responsible for lost keys, corrupted files, or unauthorized access resulting from your own actions.
  5. Liability Limitations

    To the fullest extent allowed by law, we will not be liable for any direct, indirect, incidental, or consequential damages arising from your use of-or inability to use-FileKey.
  6. No Third-Party Services

    FileKey does not rely on external services or third parties. You are solely responsible for managing your keys and files.
  7. Changes to Terms

    If we update these Terms of Service, we will post the changes here. Your continued use of FileKey after changes means you accept the updated terms.
  8. Contact Us

    If you have questions or concerns, please email us at contact@filekey.app.
    By using FileKey, you acknowledge and agree to these Terms of Service.
` }, chiz_privacy: { speed: 10, html: `

Privacy Policy

No Data Collection:

We do not collect, store, or process any personal information on the website: no names, emails, or accounts. We do not track you, and we do not use analytics.

Local-Only File Handling:

All file encryption and decryption happens entirely on your device. We never send your files or keys to our servers. You remain in full control of your data at all times.

Local Storage:

We may use local storage on your device to remember your settings or key references. This information never leaves your device.

No Third Parties:

We do not share any data with third parties. There are no hidden integrations or external services.

Changes to This Policy:

If we make changes, we will update this page. Your continued use of FileKey means you accept the updated terms.

Contact Us:

If you have questions or concerns, please email us at contact@filekey.app.
By using FileKey, you agree to this policy.` }, chiz_license: { speed: 16, html: `

License

FileKey version 1 is released under the GNU General Public License v3.0 (GPLv3).

This means that you are free to use, modify, and distribute FileKey under the terms of the GPLv3 license. However, any modifications or derivative works must also be released under the same open-source license.

You can read the full license text here.${EXT_ICON}

By using FileKey, you agree to the terms of this license. If you contribute to the project, you also acknowledge that your contributions will be made available under GPLv3.

` }, chiz_contact: { speed: 12, html: `

Contact Us

You can email us at contact@filekey.app, or join our Signal group to chat.

` }, }; function initChiz() { $("chiz_icon_container").innerHTML = ``; const backdrop = $("chiz_hidden_click_container"); // Two dropdowns share one backdrop; only one is open at a time. const menu = $("chiz_menu_container"), icon = $("chiz_icon_container"); // About (hamburger) const acctMenu = $("acct_menu_container"), acctIcon = $("acct_icon_container"); // Your FileKey (sliders) const set = (m: HTMLElement, i: HTMLElement, on: boolean) => { m.style.display = on ? "block" : "none"; i.classList.toggle("is-open", on); i.setAttribute("aria-expanded", String(on)); }; const close = () => { set(menu, icon, false); set(acctMenu, acctIcon, false); backdrop.style.display = "none"; }; const openAbout = () => { close(); set(menu, icon, true); backdrop.style.display = "block"; }; const openAcct = () => { close(); set(acctMenu, acctIcon, true); backdrop.style.display = "block"; }; const toggleAbout = () => (menu.style.display === "block" ? close() : openAbout()); const toggleAcct = () => (acctMenu.style.display === "block" ? close() : openAcct()); icon.addEventListener("click", toggleAbout); icon.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleAbout(); } }); acctIcon.addEventListener("click", toggleAcct); acctIcon.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleAcct(); } }); backdrop.addEventListener("click", close); $("chiz_sendlink").addEventListener("click", () => { close(); void showSendLink(); }); $("chiz_get_public_key").addEventListener("click", () => { close(); void displayPublicKey(); }); $("chiz_contacts").addEventListener("click", () => { close(); void showContacts(); }); $("chiz_recovery").addEventListener("click", () => { close(); void showRecovery(); }); // Appearance: Light / Dark / Auto. "auto" follows the OS (prefers-color-scheme) and updates live. // Default (nothing saved) is light. Choice persists; the menu stays open so the change is visible. const themeMql = window.matchMedia("(prefers-color-scheme: dark)"); // In-memory mode is the source of truth (seeded from storage); persistence is best-effort, so // Auto keeps following the OS live even if localStorage writes are blocked (private mode etc.). let themeMode = ((): string => { try { return localStorage.getItem("filekey-theme") || "light"; } catch { return "light"; } })(); const themeOpts = Array.from(document.querySelectorAll(".theme_opt")); const resolveTheme = (mode: string): "light" | "dark" => mode === "dark" || (mode === "auto" && themeMql.matches) ? "dark" : "light"; const applyTheme = (mode: string) => { themeMode = mode; const resolved = resolveTheme(mode); document.documentElement.dataset.theme = resolved; const meta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null; if (meta) meta.content = resolved === "dark" ? "#0c0c0e" : "#ffffff"; themeOpts.forEach((el) => { const on = el.dataset.mode === mode; el.classList.toggle("active", on); el.setAttribute("aria-checked", String(on)); }); }; const selectTheme = (el: HTMLElement) => { const mode = el.dataset.mode || "light"; try { localStorage.setItem("filekey-theme", mode); } catch { /* storage blocked → in-memory only this session */ } applyTheme(mode); }; applyTheme(themeMode); themeOpts.forEach((el, i) => { el.addEventListener("click", () => selectTheme(el)); el.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectTheme(el); } else if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); const n = themeOpts[(i + 1) % themeOpts.length]!; n.focus(); selectTheme(n); } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); const p = themeOpts[(i - 1 + themeOpts.length) % themeOpts.length]!; p.focus(); selectTheme(p); } }); }); themeMql.addEventListener("change", () => { if (themeMode === "auto") applyTheme("auto"); }); for (const id of Object.keys(CHIZ)) $(id).addEventListener("click", () => { close(); void appMsg([{ html: CHIZ[id]!.html }], { speed: CHIZ[id]!.speed }); }); // external links: add the outbound ↗ icon + close the menu on click (v1/beta structure) document.querySelectorAll(".plain_menu_link").forEach((a) => { a.insertAdjacentHTML("beforeend", ` ${SVG.outbound} `); a.addEventListener("click", () => close()); }); } // ---- intro (v1 initMessage) ---- // ---- update notice (a courtesy, NOT a security control: see SPEC.md on code-delivery trust) ---- // FileKey is served fresh on every reload (network-first SW + must-revalidate caching), so this does // not gate updates; it just tells the user a newer build exists and shows what changed before they // reload into it. version.json is the single source for the user-facing version: imported above as // APP_VERSION (the version this bundle shipped as) and re-fetched here to spot a newer deploy. let updatePrompted = false; async function checkForUpdate() { if (updatePrompted) return; let latest: { current?: string; releases?: Record } | undefined; try { const r = await fetch("/version.json", { cache: "no-store" }); if (r.ok) latest = await r.json(); } catch { /* offline or unreachable — nothing to do */ } if (!latest?.current || latest.current === APP_VERSION) return; updatePrompted = true; const version = latest.current; const notes = (latest.releases?.[version]?.notes ?? []).filter(Boolean); const list = notes.length ? ` Here's what's new:
    ${notes.map((n) => `
  • ${esc(n)}
  • `).join("")}
` : ""; const m = await appMsg( [{ html: `FileKey ${esc(version)} is available.${list}` }], { dp: "warning_dp", icon: "warning_filekey_icon" }, ); actionRow(m, [ { label: "Update now", onClick: () => location.reload() }, { label: "Later", muted: true, onClick: () => m.querySelector(".msg_actions")?.remove() }, ]); } async function intro() { await appMsg([ { t: "Files need protection. FileKey secures them", b: true }, ". Works with passkeys. Drop files in. They lock. Drop them again. They unlock. Your data stays on your device, and only you hold the key. Open source and powered by AES-256 encryption, the same standard trusted by the US government for top-secret information.", " For the latest updates, join our ", { html: extLink("https://signal.group/#CjQKIDpdakX0nr1V00ciNv3dsWCFZgUwm_NylulFJz4VOUJ_EhBtY-bq759RNExzcCWMUGIB", "Signal") }, " group or ", { html: extLink("https://filekey.substack.com/", "Substack") }, ".", ], { speed: 12 }); await appMsg(["To start, ", { link: "create", onClick: () => void genNewPasskey() }, " a new filekey or ", { link: "authenticate", onClick: () => void loadSecKey() }, " your existing filekey."]); } // ---- marching-ants dashed border on the drop zone (v1 createAnimatedBorder) ---- function marchingBorder(el: HTMLElement) { const NS_SVG = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(NS_SVG, "svg"); Object.assign(svg.style, { position: "absolute", inset: "0", width: "100%", height: "100%", pointerEvents: "none", overflow: "visible" } as CSSStyleDeclaration); const rect = document.createElementNS(NS_SVG, "rect"); rect.setAttribute("x", "1"); rect.setAttribute("y", "1"); rect.setAttribute("rx", "14"); rect.setAttribute("fill", "none"); rect.setAttribute("stroke", "#1377f980"); rect.setAttribute("stroke-width", "2"); rect.setAttribute("stroke-dasharray", "3 6"); rect.setAttribute("stroke-linecap", "round"); svg.appendChild(rect); el.prepend(svg); const size = () => { const w = el.clientWidth - 2, h = el.clientHeight - 2; if (w > 0 && h > 0) { rect.setAttribute("width", String(w)); rect.setAttribute("height", String(h)); } }; // guard: drop zone is display:none until auth (clientWidth 0) new ResizeObserver(size).observe(el); size(); if (!REDUCED) { let off = 0; const step = () => { off = (off - 0.25) % 9; rect.setAttribute("stroke-dashoffset", String(off)); requestAnimationFrame(step); }; step(); } } // ---- init ---- function init() { if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js").catch(() => {}); // PWA: installable + offline mainInner = $("main_inner"); $("logo_bar").innerHTML = `${SVG.logo.replace("FileKey`; $("logo_bar").addEventListener("click", () => location.reload()); const dc = document.querySelector(".dc_icon_container") as HTMLElement; // v1: class not id dc.innerHTML = SVG.plus; setIcon(dc, "plus_icon"); initChiz(); $("version_number_ele").textContent = "v" + APP_VERSION; // "Send me a file" link: a #to= visitor encrypts to the owner anonymously (throwaway // sender) — no passkey, so it bypasses the WebAuthn gate. It still needs a secure context (Web Crypto). const sendTo = parseSendToHash(); if (sendTo) { if (!checkSupport().secureContext) { void appMsg(["FileKey needs a secure context (HTTPS or localhost)."], ERR); return; } void sendToMode(sendTo.to, sendTo.name); return; } const support = checkSupport(); if (!support.secureContext || !support.webauthn) { void appMsg([support.secureContext ? "This browser doesn't support WebAuthn passkeys, which FileKey requires." : "FileKey needs a secure context (HTTPS or localhost)."], ERR); return; } const fileInput = $("file_input") as HTMLInputElement; const folderInput = $("folder_input") as HTMLInputElement; // Two explicit pickers (one input can't be both multi-file and webkitdirectory); the zone itself is the drop target. $("choose_file").addEventListener("click", () => fileInput.click()); $("choose_folder").addEventListener("click", () => folderInput.click()); // Clicking the zone itself (background, not a control) opens the file picker — the primary action. // Ignore .dc_btn and the hidden inputs: input.click() dispatches a *bubbling* click that would // otherwise re-enter this handler and open a second (file) picker behind the folder picker. $("drop_container").addEventListener("click", (e) => { if (!(e.target as HTMLElement).closest(".dc_btn, input")) fileInput.click(); }); fileInput.addEventListener("change", () => { if (fileInput.files?.length) void handleItems(collectFromInput(fileInput.files)); fileInput.value = ""; }); folderInput.addEventListener("change", () => { if (folderInput.files?.length) void handleItems(collectFromInput(folderInput.files)); folderInput.value = ""; }); marchingBorder($("drop_container")); const dragWin = $("drag_window"), fdz = $("file_drag_zone"); let depth = 0; window.addEventListener("dragenter", (e) => { e.preventDefault(); if (++depth === 1 && identity) { dragWin.style.display = "block"; fdz.style.display = "block"; } }); window.addEventListener("dragover", (e) => e.preventDefault()); window.addEventListener("dragleave", (e) => { e.preventDefault(); if (--depth <= 0) { depth = 0; dragWin.style.display = "none"; fdz.style.display = "none"; } }); window.addEventListener("drop", (e) => { e.preventDefault(); depth = 0; dragWin.style.display = "none"; fdz.style.display = "none"; const dt = (e as DragEvent).dataTransfer; if (dt) void collectFromDrop(dt).then(handleItems); }); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") void checkForUpdate(); }); void intro().finally(() => { allowAutoScroll = true; void checkForUpdate(); }); } init(); ============================================================================== === web/contacts.ts === ============================================================================== // FileKey reference — local contacts (address book). // // A device-local list of recipients you've shared with: their PUBLIC share key // plus an optional nickname you choose. This is explicitly allowed by the // "no secrets stored" rule — share keys are public, and a nickname is your own // local label (never sender-controlled, never written into any file). See // DESIGN.md → Components → "Recipients / contacts". // // Stored ENCRYPTED-TO-SELF (reusing the core's encryptToSelf/decrypt) in // localStorage, so the address book — a social-graph footprint — isn't readable // at rest without the passkey, and is naturally scoped per identity. Loaded into // memory on unlock. No DOM here; app.ts owns all rendering (per reference/CLAUDE.md). import { encryptToSelf, decrypt, type Identity, type NamespaceSet, } from "../src/index.js"; export interface Contact { /** The recipient's share key (fkey1…). The unique id we dedupe on. */ key: string; /** Your local display label. Optional; never leaves the device. */ nickname?: string; /** Epoch ms, last time you shared to this key (drives recent-first order). */ lastUsed: number; /** Epoch ms, when this contact was first saved. */ addedAt: number; } export type NicknameResult = { ok: true } | { ok: false; conflict: Contact }; let contacts: Contact[] = []; let ident: Identity | null = null; let storageKey = ""; const td = new TextDecoder(); const te = new TextEncoder(); const hex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); function b64encode(b: Uint8Array): string { let s = ""; for (let i = 0; i < b.length; i += 0x8000) s += String.fromCharCode(...b.subarray(i, i + 0x8000)); return btoa(s); } function b64decode(str: string): Uint8Array { const bin = atob(str); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } /** Load + decrypt the address book for this identity. Call once on unlock. */ export async function loadContacts(identity: Identity, namespaces: NamespaceSet): Promise { ident = identity; contacts = []; // Per-identity storage key from the (public) static key, so different passkeys // never read or clobber each other's books. storageKey = "filekey.contacts." + hex(identity.staticPkRaw.subarray(1, 17)); let raw: string | null = null; try { raw = localStorage.getItem(storageKey); } catch { return; } // storage blocked → session-only if (!raw) return; try { const res = await decrypt({ file: b64decode(raw), namespaces, resolveIdentity: async () => identity }); const parsed = JSON.parse(td.decode(res.plaintext)) as unknown; if (Array.isArray(parsed)) { contacts = parsed .filter((c): c is Contact => !!c && typeof (c as Contact).key === "string") .map((c) => ({ key: c.key, nickname: c.nickname?.trim() || undefined, lastUsed: typeof c.lastUsed === "number" ? c.lastUsed : 0, addedAt: typeof c.addedAt === "number" ? c.addedAt : 0, })); } } catch { contacts = []; // corrupt / foreign blob → start clean (overwritten on next save) } } async function persist(): Promise { if (!ident || !storageKey) return; try { const env = await encryptToSelf({ identity: ident, plaintext: te.encode(JSON.stringify(contacts)), metadata: { filename: "filekey-contacts.json", mimeType: "application/json", createdAtUnixMs: Date.now(), extras: new Map() }, }); localStorage.setItem(storageKey, b64encode(env)); } catch { /* storage unavailable → stays session-only */ } } /** Contacts, most-recently-used first (then alphabetical). */ export function listContacts(): Contact[] { return [...contacts].sort( (a, b) => b.lastUsed - a.lastUsed || (a.nickname || a.key).localeCompare(b.nickname || b.key), ); } export function contactCount(): number { return contacts.length; } export function findByKey(key: string): Contact | undefined { return contacts.find((c) => c.key === key); } /** Record that we just shared to `key` (upsert + bump lastUsed). Reports whether it was newly added. */ export async function rememberUse(key: string): Promise<{ contact: Contact; isNew: boolean }> { const now = Date.now(); let c = contacts.find((x) => x.key === key); const isNew = !c; if (c) c.lastUsed = now; else { c = { key, lastUsed: now, addedAt: now }; contacts.push(c); } await persist(); return { contact: c, isNew }; } export type AddResult = { ok: true; contact: Contact } | { ok: false; reason: "duplicate_key" | "duplicate_nickname"; conflict: Contact }; /** Manually add a contact (a share key + optional nickname). Rejects a duplicate key or nickname. */ export async function addContact(key: string, nickname?: string): Promise { const dupKey = contacts.find((c) => c.key === key); if (dupKey) return { ok: false, reason: "duplicate_key", conflict: dupKey }; const n = nickname?.trim(); if (n) { const dupNick = contacts.find((c) => c.nickname?.toLowerCase() === n.toLowerCase()); if (dupNick) return { ok: false, reason: "duplicate_nickname", conflict: dupNick }; } const now = Date.now(); const contact: Contact = n ? { key, nickname: n, lastUsed: now, addedAt: now } : { key, lastUsed: now, addedAt: now }; contacts.push(contact); await persist(); return { ok: true, contact }; } /** Set or clear a contact's nickname. Rejects (no change) if another contact already uses it. */ export async function setNickname(key: string, nickname: string): Promise { const c = contacts.find((x) => x.key === key); if (!c) return { ok: true }; const n = nickname.trim(); if (n) { const clash = contacts.find((x) => x.key !== key && x.nickname?.toLowerCase() === n.toLowerCase()); if (clash) return { ok: false, conflict: clash }; c.nickname = n; } else { delete c.nickname; } await persist(); return { ok: true }; } export async function removeContact(key: string): Promise { contacts = contacts.filter((c) => c.key !== key); await persist(); } export async function clearContacts(): Promise { contacts = []; try { if (storageKey) localStorage.removeItem(storageKey); } catch { /* ignore */ } } // ---- import / export (plain JSON) ---- // Share keys are PUBLIC, so a plain-JSON export carries no secrets — and unlike an encrypted-to-self // export, it can be imported into a different identity (a new passkey on another device). The file does // reveal your contact list (names + their keys), so it's your data to guard, not a secret of the protocol. export interface ImportResult { /** Newly saved contacts. */ added: number; /** Entries whose key was already in the book (left untouched). */ skipped: number; /** Entries with a missing/malformed/wrong-namespace share key. */ rejected: number; } /** Serialize the address book to a portable JSON string: {filekey_contacts, contacts:[{key, nickname?}]}. */ export function exportContactsJson(): string { const out = listContacts().map((c) => (c.nickname ? { key: c.key, nickname: c.nickname } : { key: c.key })); return JSON.stringify({ filekey_contacts: 1, contacts: out }, null, 2); } /** * Merge contacts from an exported JSON string. `isValidKey` is the caller's namespace-aware share-key * check, so this module stays decode-agnostic. Dedupes against existing keys; a nickname collision saves * the key without the clashing label. Returns counts. Throws only if the file isn't a recognizable export. */ export async function importContactsJson(json: string, isValidKey: (key: string) => boolean): Promise { let data: unknown; try { data = JSON.parse(json); } catch { throw new Error("not valid JSON"); } const arr: unknown[] | null = Array.isArray(data) ? data : data && typeof data === "object" && Array.isArray((data as { contacts?: unknown }).contacts) ? (data as { contacts: unknown[] }).contacts : null; if (!arr) throw new Error("not a FileKey contacts export"); let added = 0, skipped = 0, rejected = 0; for (const raw of arr) { const e = (raw ?? {}) as { key?: unknown; shareKey?: unknown; nickname?: unknown }; const key = typeof e.key === "string" ? e.key : typeof e.shareKey === "string" ? e.shareKey : null; if (!key || !isValidKey(key)) { rejected++; continue; } const nickname = typeof e.nickname === "string" ? e.nickname : undefined; let res = await addContact(key, nickname); if (!res.ok && res.reason === "duplicate_nickname") res = await addContact(key); // save the key, drop the clashing label if (res.ok) added++; else skipped++; // remaining failure is duplicate_key } return { added, skipped, rejected }; } ============================================================================== === web/recover.ts === ============================================================================== // FileKey — Offline Recovery Tool (entry for the single self-contained recover.html). // // Break-glass utility: decrypt your .filekey files using your RECOVERY CODE alone — // no passkey, no account, no server — even if filekey.app no longer exists. It reuses // the exact audited core (src/), so it decrypts byte-for-byte what FileKey produced: // // recovery code -> master_prk -> deriveIdentity(file's namespace) -> decrypt // // Everything runs locally; the page makes zero network requests (enforced by its CSP). // Bundled into web/recover.html by web/build-recover.ts. import { decodeRecoveryAuto, deriveIdentity, Namespace, NamespaceSet, decrypt, FileKeyError, type Metadata, } from "../src/index.js"; const LOGO = ``; const CSS = ` *{box-sizing:border-box} body{margin:0;font-family:-apple-system,system-ui,"Segoe UI",Roboto,sans-serif;color:#0d0d0d;background:#fff;line-height:1.5;-webkit-font-smoothing:antialiased} .wrap{max-width:640px;margin:0 auto;padding:44px 24px 96px} .hd{display:flex;align-items:center;gap:11px;margin-bottom:8px} .hd h1{font-size:22px;font-weight:700;margin:0;letter-spacing:-.02em} .sub{color:#555;margin:0 0 30px;font-size:15px} .step{margin:0 0 22px} .step label{display:block;font-weight:600;font-size:14px;margin-bottom:7px} .hint{color:#888;font-size:13px;margin:7px 0 0} textarea,input[type=text]{width:100%;border:1px solid #0000001f;border-radius:10px;padding:12px 14px;font-size:15px;font-family:inherit;color:#0d0d0d;outline:none;background:#fff} textarea{min-height:84px;resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;line-height:1.6} textarea:focus,input[type=text]:focus{border-color:#1377f9aa} #drop{border:1.5px dashed #c8c8ce;border-radius:12px;padding:26px;text-align:center;color:#666;cursor:pointer;background:#fbfbfd;font-size:14px} #drop:hover{border-color:#9aa} #drop.over{border-color:#1377F9;background:#1377f90d;color:#1377F9} #files_list{list-style:none;padding:0;margin:12px 0 0} #files_list li{font-size:13px;color:#444;padding:3px 0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all} #go{background:#1377F9;color:#fff;border:none;border-radius:10px;padding:13px 22px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit} #go:hover{background:#0e63d6} #go:disabled{opacity:.5;cursor:default} #out{margin-top:24px;display:flex;flex-direction:column;gap:10px} .res{border:1px solid #0000001a;border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:12px;font-size:14px} .res.ok{background:#34c75914;border-color:#34c75955} .res.err{background:#ff3b3010;border-color:#ff3b3055} .res .name{flex-grow:1;word-break:break-all} .res a{color:#1377F9;font-weight:600;text-decoration:none;white-space:nowrap;cursor:pointer} .res a:hover{text-decoration:underline} #msg{font-size:14px;color:#c1121f;margin:12px 0 0} .note{margin-top:42px;font-size:12.5px;color:#999;border-top:1px solid #0000000f;padding-top:16px;line-height:1.6} .note b{color:#666} `; const MARKUP = `
${LOGO}

FileKey · Offline Recovery

Decrypt your FileKey files with your recovery code. No passkey, no account, no server. Works even if filekey.app is gone. Everything happens on this device.

The site your files were encrypted on (usually filekey.app). Files are cryptographically bound to their site.

Drop files here, or click to choose

    100% offline. This page makes no network requests. Your recovery code and files never leave this device. It runs the same open-source cryptography as FileKey. Save this file somewhere safe; you don't need the internet to use it.

    `; const style = document.createElement("style"); style.textContent = CSS; document.head.appendChild(style); document.body.innerHTML = MARKUP; const byId = (id: string) => document.getElementById(id) as T; const recEl = byId("rec"); const siteEl = byId("site"); const dropEl = byId("drop"); const pickerEl = byId("picker"); const listEl = byId("files_list"); const outEl = byId("out"); const msgEl = byId("msg"); const goEl = byId("go"); let chosen: File[] = []; const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`); const sanitize = (n: string) => (n.replace(/[/\\]/g, "_").replace(/[\x00-\x1f]/g, "").trim() || "decrypted").slice(0, 200); function renderList() { listEl.innerHTML = chosen.map((f) => `
  • ${escapeHtml(f.name)} · ${fmtSize(f.size)}
  • `).join(""); } function addFiles(fl: FileList | null) { if (!fl) return; for (const f of Array.from(fl)) chosen.push(f); renderList(); } function setMsg(t: string) { msgEl.textContent = t; msgEl.hidden = !t; } dropEl.addEventListener("click", () => pickerEl.click()); pickerEl.addEventListener("change", () => { addFiles(pickerEl.files); pickerEl.value = ""; }); for (const e of ["dragenter", "dragover"]) dropEl.addEventListener(e, (ev) => { ev.preventDefault(); dropEl.classList.add("over"); }); for (const e of ["dragleave", "dragend"]) dropEl.addEventListener(e, (ev) => { ev.preventDefault(); dropEl.classList.remove("over"); }); dropEl.addEventListener("drop", (ev) => { ev.preventDefault(); dropEl.classList.remove("over"); addFiles((ev as DragEvent).dataTransfer?.files ?? null); }); goEl.addEventListener("click", () => void run()); // Canonicalize a pasted site to a bare lowercase host (handles https://host/path, ports, trailing dot). function canonSite(s: string): string { const host = s.trim().toLowerCase().replace(/^[a-z]+:\/\//, "").replace(/[/:?#].*$/, "").replace(/\.$/, ""); // Match the app's deploymentRpId normalization (registrable domain) so a file encrypted on a // subdomain deployment recovers under the same namespace the app used to encrypt it. if (host === "localhost") return host; const parts = host.split("."); return parts.length > 2 ? parts.slice(-2).join(".") : host; } function download(bytes: Uint8Array, name: string, mime: string) { const blob = new Blob([bytes as unknown as BlobPart], { type: mime || "application/octet-stream" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 8000); } function addOk(srcName: string, meta: Metadata, plaintext: Uint8Array) { const name = sanitize(meta.filename || srcName.replace(/\.filekey$/i, "") || "decrypted"); const row = document.createElement("div"); row.className = "res ok"; row.innerHTML = `${escapeHtml(name)} · ${fmtSize(plaintext.length)}`; const a = document.createElement("a"); a.textContent = "Download"; a.addEventListener("click", () => download(plaintext, name, meta.mimeType)); row.appendChild(a); outEl.appendChild(row); } function addErr(srcName: string, e: unknown) { const code = e instanceof FileKeyError ? e.code : ""; const why = code === "wrong_namespace" ? "encrypted for a different site (set the correct domain in step 2)" : code === "auth_failed" ? "couldn't decrypt (wrong recovery code, or this file wasn't encrypted for you)" : (e as Error).message || "couldn't read this file"; const row = document.createElement("div"); row.className = "res err"; row.innerHTML = `✗ ${escapeHtml(srcName)}: ${escapeHtml(why)}`; outEl.appendChild(row); } async function run() { setMsg(""); outEl.innerHTML = ""; const code = recEl.value.trim(); const siteRaw = siteEl.value.trim(); const site = canonSite(siteRaw); if (!code) { setMsg("Enter your recovery code in step 1."); return; } if (!chosen.length) { setMsg("Add at least one .filekey file in step 3."); return; } let nsSet: NamespaceSet; try { nsSet = new NamespaceSet([site]); } catch { setMsg(`"${siteRaw}" isn't a valid site domain.`); return; } let masterPrk: Uint8Array; try { masterPrk = decodeRecoveryAuto(code, nsSet).masterPrk; } catch (e) { setMsg((e as FileKeyError).code === "wrong_namespace" ? "That recovery code is for a different site. Set the correct domain in step 2." : `That doesn't look like a valid recovery code: ${(e as Error).message}`); return; } goEl.disabled = true; goEl.textContent = "Decrypting…"; for (const f of chosen) { try { const bytes = new Uint8Array(await f.arrayBuffer()); const res = await decrypt({ file: bytes, namespaces: nsSet, resolveIdentity: (ns: Namespace) => deriveIdentity(masterPrk, ns), }); addOk(f.name, res.metadata, res.plaintext); } catch (e) { addErr(f.name, e); } } goEl.disabled = false; goEl.textContent = "Decrypt"; } // Defensive: Web Crypto needs a secure context. file:// IS a secure context in major desktop // browsers, but if one ever withholds it for local files, fail loudly instead of cryptically. if (typeof crypto === "undefined" || !crypto.subtle) { setMsg("Your browser disabled secure crypto for local files. Open this page over http(s)/localhost (e.g. run a local server in this folder), or try Chrome or Firefox."); goEl.disabled = true; } ============================================================================== === web/bundle.ts === ============================================================================== // FileKey — input bundling for multi-file / folder encryption (web/ only; the crypto core // is untouched). Collects dropped/picked files with their relative paths — recursing into // folders via the drag-drop entry API — and zips them into one archive. app.ts then encrypts // that archive as a single .filekey. Decryption is unchanged: it yields the .zip, which the // user unpacks themselves. So this is an encrypt-side-only concern. import { zip, Zip, ZipPassThrough, type Zippable } from "fflate"; export interface BundleItem { /** Path inside the archive ("MyFolder/sub/file.txt"), or just the filename for a loose file. */ path: string; file: File; /** True if this came from inside a dropped/picked folder (vs a loose top-level file). */ fromFolder: boolean; } /** Collect items from a file (honors webkitdirectory's relative paths). */ export function collectFromInput(files: FileList): BundleItem[] { return Array.from(files).map((file) => { const rel = file.webkitRelativePath; // "" for a plain multi-file pick; "Folder/…" for a directory pick return rel ? { path: rel, file, fromFolder: true } : { path: file.name, file, fromFolder: false }; }); } /** Collect items from a drop, recursing into folders via the (desktop) entry API. */ export async function collectFromDrop(dt: DataTransfer): Promise { const roots: FileSystemEntry[] = []; for (const item of Array.from(dt.items)) { if (item.kind !== "file") continue; const entry = item.webkitGetAsEntry?.(); if (entry) roots.push(entry); } if (!roots.length) { // No entry API available — fall back to the flat file list (loose files only). return Array.from(dt.files).map((file) => ({ path: file.name, file, fromFolder: false })); } const out: BundleItem[] = []; for (const e of roots) await walk(e, "", out); return out; } async function walk(entry: FileSystemEntry, prefix: string, out: BundleItem[]): Promise { if (entry.isFile) { const file = await new Promise((res, rej) => (entry as FileSystemFileEntry).file(res, rej)); out.push({ path: prefix + entry.name, file, fromFolder: prefix !== "" }); } else if (entry.isDirectory) { const children = await readAll((entry as FileSystemDirectoryEntry).createReader()); if (children.length === 0) { // Preserve an empty directory: a zero-byte entry whose path ends in "/" becomes a real dir in the zip. out.push({ path: prefix + entry.name + "/", file: new File([], entry.name), fromFolder: true }); } else { for (const c of children) await walk(c, prefix + entry.name + "/", out); } } } // readEntries returns results in batches; call until it yields an empty batch. function readAll(reader: FileSystemDirectoryReader): Promise { return new Promise((resolve, reject) => { const all: FileSystemEntry[] = []; const next = () => reader.readEntries((batch) => { if (!batch.length) resolve(all); else { all.push(...batch); next(); } }, reject); next(); }); } /** Suggested archive base name: the common top-level folder, else a generic name. */ export function bundleName(items: BundleItem[]): string { const tops = new Set(items.map((i) => (i.path.includes("/") ? i.path.split("/")[0]! : ""))); if (tops.size === 1) { const only = [...tops][0]!; if (only) return only; } return "filekey-bundle"; } // Dedupe a path against already-used names: "a.txt" -> "a (2).txt". function dedupePath(p: string, seen: Set): string { if (!seen.has(p)) return p; const dot = p.lastIndexOf("."); const stem = dot > 0 ? p.slice(0, dot) : p; const ext = dot > 0 ? p.slice(dot) : ""; let n = 2; while (seen.has(`${stem} (${n})${ext}`)) n++; return `${stem} (${n})${ext}`; } const ZIP_READ_CHUNK = 4 * 1024 * 1024; // read source files in 4MB slices when streaming them into the archive /** * Stream the items into a zip Blob (Blob-of-Blobs, disk-backed) without ever holding the whole archive — * or any whole source file — in memory. Stored (no compression) so framing stays cheap and non-blocking; * the payload is encrypted afterward anyway, and bundles are usually already-compressed media. `onRead` * reports cumulative source bytes consumed (for progress). This is the large-folder counterpart to * {@link zipBundle}, which buffers the whole archive and is fine only for small bundles. */ export function zipBundleToBlob( items: BundleItem[], onRead?: (bytes: number) => void, isCancelled?: () => boolean, ): Promise { return new Promise((resolve, reject) => { const parts: Blob[] = []; const archive = new Zip((err, chunk, final) => { if (err) { reject(err); return; } if (chunk.length) parts.push(new Blob([chunk as unknown as BlobPart])); if (final) resolve(new Blob(parts, { type: "application/zip" })); }); void (async () => { const seen = new Set(); let read = 0; for (const it of items) { if (isCancelled?.()) return resolve(new Blob([])); // caller polls the same flag and discards this const p = dedupePath(it.path, seen); seen.add(p); const entry = new ZipPassThrough(p); archive.add(entry); const size = it.file.size; if (size === 0) { entry.push(new Uint8Array(0), true); // empty file or preserved empty directory continue; } for (let off = 0; off < size; off += ZIP_READ_CHUNK) { if (isCancelled?.()) return resolve(new Blob([])); const end = Math.min(off + ZIP_READ_CHUNK, size); const slice = new Uint8Array(await it.file.slice(off, end).arrayBuffer()); entry.push(slice, end >= size); read += slice.length; onRead?.(read); } } archive.end(); })().catch(reject); }); } /** Zip the items (reading each File) into one archive. Dedupes any colliding paths. */ export async function zipBundle(items: BundleItem[]): Promise { const data: Zippable = {}; const seen = new Set(); for (const it of items) { let p = it.path; if (seen.has(p)) { const dot = p.lastIndexOf("."); const stem = dot > 0 ? p.slice(0, dot) : p; const ext = dot > 0 ? p.slice(dot) : ""; let n = 2; while (seen.has(`${stem} (${n})${ext}`)) n++; p = `${stem} (${n})${ext}`; } seen.add(p); data[p] = new Uint8Array(await it.file.arrayBuffer()); } return new Promise((resolve, reject) => zip(data, { level: 6 }, (err, out) => (err ? reject(err) : resolve(out))), ); } ============================================================================== === web/webauthn.ts === ============================================================================== // WebAuthn PRF provider (browser-only). Produces the 32-byte prf_secret the core consumes. // The credential is discoverable (residentKey: required), so re-authentication needs no // stored credential ID — nothing is stored beyond the passkey (§1.1 rule 1). import { PRF_INPUT_SALT, bs } from "../src/index.js"; export interface PrfSupport { webauthn: boolean; secureContext: boolean; } export function checkSupport(): PrfSupport { return { webauthn: typeof PublicKeyCredential !== "undefined" && !!navigator.credentials, secureContext: window.isSecureContext, }; } /** * Browser-level PRF capability via getClientCapabilities() (where available). * Returns false only when the browser explicitly reports no PRF; true when it reports * PRF; undefined when unknown (older browsers without the API, or no explicit answer) — * the caller should then just attempt the ceremony. Note this reflects the *browser*, * not the chosen authenticator, so a `true` still needs the post-create prf.enabled * check (e.g. Windows Hello before 11 25H2 reports browser-PRF but can't do it). */ export async function prfBrowserSupport(): Promise { const PKC = typeof PublicKeyCredential !== "undefined" ? (PublicKeyCredential as unknown as { getClientCapabilities?: () => Promise> }) : undefined; if (!PKC?.getClientCapabilities) return undefined; try { const v = (await PKC.getClientCapabilities())["extension:prf"]; return v === true ? true : v === false ? false : undefined; } catch { return undefined; } } function randomBytes(n: number): Uint8Array { return crypto.getRandomValues(new Uint8Array(n)); } /** * The RP-ID / namespace for this deployment, normalized to the registrable domain (apex) so the * derived identity survives being served from a subdomain (v1.filekey.app, www.filekey.app, …). * This is the same normalization v1 uses, so both apps agree on the rp.id and share the passkey. * localhost and bare (≤2-label) hostnames are returned unchanged. */ export function deploymentRpId(): string { const host = location.hostname; if (host === "localhost") return host; const parts = host.split("."); return parts.length > 2 ? parts.slice(-2).join(".") : host; } /** * Enroll a new passkey with the PRF extension enabled (§4.1). Throws if PRF is unsupported. * Returns nothing persistent — the credential is discoverable. */ export async function enrollPasskey(displayName: string): Promise { const cred = (await navigator.credentials.create({ publicKey: { rp: { id: deploymentRpId(), name: "FileKey Reference" }, user: { id: bs(randomBytes(16)), name: displayName || "FileKey", displayName: displayName || "FileKey" }, challenge: bs(randomBytes(32)), pubKeyCredParams: [ { type: "public-key", alg: -7 }, // ES256 { type: "public-key", alg: -257 }, // RS256 ], authenticatorSelection: { residentKey: "required", userVerification: "preferred" }, timeout: 60_000, extensions: { prf: {} } as AuthenticationExtensionsClientInputs, }, })) as PublicKeyCredential | null; if (!cred) throw new Error("passkey creation returned null"); const ext = cred.getClientExtensionResults() as { prf?: { enabled?: boolean } }; if (!ext.prf?.enabled) { throw new Error("this authenticator/browser does not support the PRF extension"); } } /** * Perform a PRF assertion and return the 32-byte prf_secret (§4.1). * Uses a discoverable-credential get() with no allowCredentials. */ export async function getPrfSecret(): Promise { const assertion = (await navigator.credentials.get({ publicKey: { rpId: deploymentRpId(), challenge: bs(randomBytes(32)), userVerification: "preferred", timeout: 60_000, extensions: { prf: { eval: { first: bs(PRF_INPUT_SALT) } }, } as AuthenticationExtensionsClientInputs, }, })) as PublicKeyCredential | null; if (!assertion) throw new Error("assertion returned null"); const ext = assertion.getClientExtensionResults() as { prf?: { results?: { first?: ArrayBuffer } } }; const first = ext.prf?.results?.first; if (!first) { throw new Error("no PRF output returned (authenticator may not support PRF, or no passkey enrolled here)"); } const out = new Uint8Array(first); if (out.length !== 32) throw new Error(`PRF output is ${out.length} bytes, expected 32`); return out; } ============================================================================== === web/serve.ts === ============================================================================== // Minimal static dev server for the FileKey reference app. // Serves on http://localhost:8787 — localhost is a WebAuthn secure context. import { fileURLToPath } from "node:url"; import { dirname, join, normalize } from "node:path"; const webDir = dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.PORT ?? 8787); const TYPES: Record = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".map": "application/json; charset=utf-8", ".txt": "text/plain; charset=utf-8", ".svg": "image/svg+xml; charset=utf-8", ".webmanifest": "application/manifest+json; charset=utf-8", ".woff2": "font/woff2", }; function contentType(path: string): string { const dot = path.lastIndexOf("."); return TYPES[path.slice(dot)] ?? "application/octet-stream"; } const server = Bun.serve({ port: PORT, async fetch(req) { const url = new URL(req.url); let pathname = decodeURIComponent(url.pathname); if (pathname === "/") pathname = "/index.html"; // Prevent path traversal. const rel = normalize(pathname).replace(/^(\.\.[/\\])+/, ""); const filePath = join(webDir, rel); if (!filePath.startsWith(webDir)) return new Response("forbidden", { status: 403 }); const file = Bun.file(filePath); if (!(await file.exists())) return new Response("not found", { status: 404 }); return new Response(file, { headers: { "content-type": contentType(filePath) } }); }, }); console.log(`FileKey reference app → http://localhost:${server.port}`); console.log(`(localhost is a WebAuthn secure context; enroll a passkey and test the full flow.)`); ============================================================================== === web/sw.js === ============================================================================== // FileKey reference — service worker. // Precaches the app shell so FileKey installs as a PWA and runs fully offline // (the crypto is all client-side). Strategy: network-first, so an online reload // always gets fresh code, falling back to cache when offline. const CACHE = "filekey-ref-v4"; const SHELL = ["/", "/index.html", "/dist/app.js", "/recover.html", "/manifest.json", "/icon.svg", "/logo.svg", "/fonts/inter.woff2"]; self.addEventListener("install", (event) => { event.waitUntil( caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()), ); }); self.addEventListener("activate", (event) => { event.waitUntil( caches.keys() .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) .then(() => self.clients.claim()), ); }); self.addEventListener("fetch", (event) => { const req = event.request; if (req.method !== "GET") return; // Let cross-origin requests (e.g. the web font) go straight to the network. if (new URL(req.url).origin !== self.location.origin) return; event.respondWith( fetch(req) .then((resp) => { const copy = resp.clone(); caches.open(CACHE).then((c) => c.put(req, copy)); return resp; }) .catch(() => caches.match(req).then((cached) => cached || caches.match("/index.html"))), ); }); ============================================================================== === web/manifest.json === ============================================================================== { "name": "FileKey", "short_name": "FileKey", "description": "Encrypt and share files using passkeys. No accounts, no tracking, and nothing leaves your device. Free and open source.", "id": "/", "start_url": "/", "scope": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#ffffff", "icons": [ { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" } ] } ============================================================================== === web/selftest.ts === ============================================================================== // In-browser self-test: runs the full crypto pipeline under the browser's WebCrypto, // using a fixed prf_secret (no passkey needed). Proves the same code path the Node tests // cover also works in a real browser. Writes a JSON verdict to #result. import { Namespace, NamespaceSet, masterPrkFromPrfSecret, deriveIdentity, encodeShareKey, decodeShareKey, encodeRecoveryBip39, decodeRecoveryBip39, encodeRecoveryBech32m, decodeRecoveryBech32m, encryptToSelf, encryptToShareKey, decrypt, toHex, } from "../src/index.js"; // crypto.getRandomValues() caps at 65536 bytes per call; fill large buffers in chunks. function randBytes(n: number): Uint8Array { const out = new Uint8Array(n); for (let off = 0; off < n; off += 65536) { crypto.getRandomValues(out.subarray(off, Math.min(off + 65536, n))); } return out; } async function run() { const results: Array<{ name: string; pass: boolean; detail?: string }> = []; const check = (name: string, cond: boolean, detail = "") => results.push({ name, pass: cond, detail }); const RP = "localhost"; const NS = new Namespace(RP); const SET = new NamespaceSet([RP]); const senderPrf = new Uint8Array(32).fill(0x11); const recipPrf = new Uint8Array(32).fill(0x22); try { const sender = await deriveIdentity(masterPrkFromPrfSecret(senderPrf), NS); const recipient = await deriveIdentity(masterPrkFromPrfSecret(recipPrf), NS); check("derive identity (HPKE DeriveKeyPair)", sender.staticPkRaw.length === 65 && sender.staticPkRaw[0] === 0x04); // Share key round trip. const sk = encodeShareKey(recipient.staticPkRaw, NS); const dec = decodeShareKey(sk, SET); check("share key round trip", toHex(dec.recipientPkRaw) === toHex(recipient.staticPkRaw), sk); // Self-encrypt round trip across sizes. for (const size of [0, 1, 65536, 65537, 130000]) { const pt = randBytes(size); const file = await encryptToSelf({ identity: sender, plaintext: pt, metadata: { filename: `t${size}.bin`, mimeType: "application/octet-stream", createdAtUnixMs: 0, extras: new Map() } }); const res = await decrypt({ file, namespaces: SET, resolveIdentity: async () => sender }); check(`self round trip ${size}B`, toHex(res.plaintext) === toHex(pt) && res.selfEncrypted); } // Shared encrypt → recipient decrypts. const msg = new TextEncoder().encode("browser shared payload"); const shared = await encryptToShareKey({ senderIdentity: sender, recipientShareKey: sk, namespaces: SET, plaintext: msg, metadata: { filename: "m.txt", mimeType: "text/plain", createdAtUnixMs: 0, extras: new Map() } }); const sharedRes = await decrypt({ file: shared, namespaces: SET, resolveIdentity: async () => recipient }); check("shared encrypt → recipient decrypt", new TextDecoder().decode(sharedRes.plaintext) === "browser shared payload" && !sharedRes.selfEncrypted); // Tamper detection. const tampered = shared.slice(); tampered[tampered.length - 1]! ^= 0x01; let rejected = false; try { await decrypt({ file: tampered, namespaces: SET, resolveIdentity: async () => recipient }); } catch { rejected = true; } check("tamper detection (fails closed)", rejected); // Recovery codes. const mprk = masterPrkFromPrfSecret(senderPrf); check("BIP39 recovery round trip", toHex(decodeRecoveryBip39(encodeRecoveryBip39(mprk))) === toHex(mprk)); check("Bech32m recovery round trip", toHex(decodeRecoveryBech32m(encodeRecoveryBech32m(mprk, NS), SET).masterPrk) === toHex(mprk)); } catch (e) { check("UNCAUGHT", false, (e as Error).message); } const allPass = results.every((r) => r.pass); const el = document.getElementById("result")!; el.textContent = JSON.stringify({ allPass, count: results.length, results }, null, 2); el.setAttribute("data-pass", String(allPass)); } run(); ============================================================================== === src/index.ts === ============================================================================== // FileKey v0.4.7 reference implementation — public API. // // Layering: // - PRF-agnostic core: takes prf_secret (or master_prk) as input. Fully testable headless. // - WebAuthn PRF provider (web/) supplies prf_secret in a browser. // // This module re-exports the core and adds ergonomic share-key wrappers. export * from "./bytes.js"; export * from "./constants.js"; export * from "./namespace.js"; export * from "./identity.js"; export * from "./sharekey.js"; export * from "./recovery.js"; export * from "./metadata.js"; export * from "./wire.js"; // cipher.ts: re-export the public surface explicitly so the test-only deterministic-ephemeral // entry point (encryptWithEphemeralForTest) stays unreachable through the public API. export { validateUncompressedPk, encrypt, decrypt, encryptStream, decryptStream } from "./cipher.js"; export type { EncryptInput, DecryptInput, DecryptResult, EncryptStreamInput, DecryptStreamInput, DecryptStreamResult, } from "./cipher.js"; import { Identity } from "./identity.js"; import { Metadata } from "./metadata.js"; import { NamespaceSet } from "./namespace.js"; import { decodeShareKey } from "./sharekey.js"; import { encrypt } from "./cipher.js"; export interface EncryptToShareKeyInput { senderIdentity: Identity; recipientShareKey: string; namespaces: NamespaceSet; plaintext: Uint8Array; metadata: Omit; } /** Encrypt to a recipient identified by their share key (the file's namespace comes from the share key). */ export async function encryptToShareKey(input: EncryptToShareKeyInput): Promise { const { recipientPkRaw, namespace } = decodeShareKey(input.recipientShareKey, input.namespaces); return encrypt({ senderIdentity: input.senderIdentity, recipientPkRaw, namespace, plaintext: input.plaintext, metadata: input.metadata, }); } export interface EncryptToSelfInput { identity: Identity; plaintext: Uint8Array; metadata: Omit; } /** Self-encrypt (local protection): sender == recipient. */ export async function encryptToSelf(input: EncryptToSelfInput): Promise { return encrypt({ senderIdentity: input.identity, recipientPkRaw: input.identity.staticPkRaw, namespace: input.identity.namespace, plaintext: input.plaintext, metadata: input.metadata, }); } ============================================================================== === src/constants.ts === ============================================================================== // FileKey v0.4.7 protocol constants (spec §3, §5, §6). export const FORMAT_VERSION = 0x01; export const SUITE_ID = 0x01; export const SK_VERSION = 0x01; // share-key encoding version (§4.4) export const REC_VERSION = 0x01; // recovery-code encoding version (§4.6.3) export const MAGIC = new Uint8Array([0x46, 0x4b, 0x45, 0x59]); // "FKEY" export const HEADER_LEN = 12; // magic(4)+ver(1)+suite(1)+flags(1)+reserved(1)+nstag(4) export const PK_LEN = 65; // SEC1 uncompressed P-256 export const ENC_LEN = 65; // HPKE encapsulated key, P-256 uncompressed export const COMPRESSED_PK_LEN = 33; // SEC1 compressed P-256 export const NS_TAG_LEN = 4; // SHA-256(canonical_rp_id)[0:4] export const AAD_LEN = HEADER_LEN + PK_LEN + ENC_LEN; // 142 export const CHUNK_SIZE = 65536; // 64 KiB plaintext per chunk (§5.5) export const GCM_TAG_LEN = 16; export const NONCE_LEN = 12; export const COUNTER_LEN = 11; // big-endian chunk counter (§5.5) export const MAX_CHUNK_INDEX = 2 ** 32; // reject i >= 2^32 (§5.5 counter cap) export const METADATA_PLAINTEXT_MAX = 1_048_576; // 1 MiB (§5.4.1 rule 7) export const METADATA_CT_MAX = 1_048_592; // 1 MiB + 16-byte tag (§5.4.3) export const METADATA_CT_MIN = 17; // 1 byte version + 16-byte tag (§5.4.3) export const METADATA_VERSION = 0x01; // Domain-separation labels (exact bytes are normative). export const LABEL_PRF_INPUT = "FILEKEY-v1/prf-input/identity"; // SHA-256'd → PRF salt (§4.1) export const LABEL_MASTER_PRK = "FILEKEY-v1/master-prk"; // HKDF-Extract salt (§4.2) export const LABEL_IDENTITY_KEM = "FILEKEY-v1/identity-kem"; // HKDF-Expand info (§4.3) export const LABEL_HPKE_INFO = "FILEKEY-v1/hpke-info"; // HPKE info prefix (§6.2) export const LABEL_PAYLOAD_KEY = "FILEKEY-v1 payload-key"; // HPKE export ctx (§6.3) export const LABEL_METADATA_KEY = "FILEKEY-v1 metadata-key"; // HPKE export ctx (§6.3) export const LABEL_FINGERPRINT = "FILEKEY-v1/fingerprint"; // identity fingerprint (§4.7) export const SHARE_KEY_HRP = "fkey"; export const RECOVERY_HRP = "fkeyrec"; // The canonical RP-ID for the public FileKey interop namespace (§8.5). Placeholder // pending ecosystem agreement; deployments override via NamespaceConfig. export const DEFAULT_CANONICAL_RP_ID = "filekey.app"; export const METADATA_NONCE = new Uint8Array(NONCE_LEN); // 12 zero bytes (§6.3.1) ============================================================================== === src/bytes.ts === ============================================================================== // Byte utilities. All multi-byte integers are big-endian (spec §1.3). import { FileKeyError } from "./errors.js"; export const te = new TextEncoder(); export const td = new TextDecoder("utf-8", { fatal: true }); // fatal: reject invalid UTF-8 // WebCrypto's BufferSource type wants an ArrayBuffer-backed view; our Uint8Arrays are // always plain (never SharedArrayBuffer). This cast bridges the TS 5.7+ generic without copying. export function bs(u: Uint8Array): BufferSource { return u as unknown as BufferSource; } export function ascii(s: string): Uint8Array { return te.encode(s); } export function concat(...parts: Uint8Array[]): Uint8Array { let len = 0; for (const p of parts) len += p.length; const out = new Uint8Array(len); let off = 0; for (const p of parts) { out.set(p, off); off += p.length; } return out; } /** * A read-only, random-access byte source (DOM-free). The crypto core reads its * input through this so large files never need to live in one contiguous buffer. * `slice(start, end)` returns the bytes in [start, end); implementations MUST clamp * `end` to `size` (returning fewer bytes near EOF rather than throwing). */ export interface ByteSource { readonly size: number; slice(start: number, end: number): Promise; } /** Wrap an in-memory Uint8Array as a ByteSource (zero-copy subarray views). */ export function bytesSource(buf: Uint8Array): ByteSource { return { size: buf.length, slice(start: number, end: number): Promise { return Promise.resolve(buf.subarray(start, Math.min(end, buf.length))); }, }; } export function equalCT(a: Uint8Array, b: Uint8Array): boolean { // Length is public; compare contents in constant time. if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!; return diff === 0; } export function toHex(b: Uint8Array): string { let s = ""; for (const x of b) s += x.toString(16).padStart(2, "0"); return s; } export function fromHex(h: string): Uint8Array { if (h.length % 2 !== 0) throw new FileKeyError("odd-length hex string", "hex_decode"); if (!/^[0-9a-fA-F]*$/.test(h)) throw new FileKeyError("hex string contains a non-hex character", "hex_decode"); const out = new Uint8Array(h.length / 2); for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return out; } // I2OSP(n, len) — big-endian, RFC 8017. Used for chunk counters. export function i2osp(n: number | bigint, len: number): Uint8Array { const out = new Uint8Array(len); let v = BigInt(n); if (v < 0n) throw new Error("i2osp: negative"); for (let i = len - 1; i >= 0; i--) { out[i] = Number(v & 0xffn); v >>= 8n; } if (v !== 0n) throw new Error("i2osp: integer too large for length"); return out; } // A minimal big-endian reader with bounds checking. Throws on overrun. export class Reader { private off = 0; constructor(private readonly buf: Uint8Array) {} get remaining(): number { return this.buf.length - this.off; } get offset(): number { return this.off; } take(n: number): Uint8Array { if (n < 0) throw new Error("take: negative"); if (this.off + n > this.buf.length) throw new FileKeyError("unexpected end of input (truncated file)", "truncated"); const out = this.buf.subarray(this.off, this.off + n); this.off += n; return out; } u8(): number { return this.take(1)[0]!; } u16(): number { const b = this.take(2); return (b[0]! << 8) | b[1]!; } u32(): number { const b = this.take(4); // Avoid sign issues: use unsigned arithmetic. return b[0]! * 0x1000000 + ((b[1]! << 16) | (b[2]! << 8) | b[3]!); } u64(): bigint { const b = this.take(8); let v = 0n; for (const x of b) v = (v << 8n) | BigInt(x); return v; } } // Big-endian writers for fixed widths. export function u16be(n: number): Uint8Array { if (n < 0 || n > 0xffff) throw new Error("u16 out of range"); return new Uint8Array([(n >> 8) & 0xff, n & 0xff]); } export function u32be(n: number): Uint8Array { if (n < 0 || n > 0xffffffff) throw new Error("u32 out of range"); return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]); } export function u64be(n: bigint): Uint8Array { if (n < 0n || n > 0xffffffffffffffffn) throw new Error("u64 out of range"); const out = new Uint8Array(8); let v = n; for (let i = 7; i >= 0; i--) { out[i] = Number(v & 0xffn); v >>= 8n; } return out; } ============================================================================== === src/namespace.ts === ============================================================================== // Canonical RP-ID validation and namespace tags (spec §4.4, §8.5). import { sha256 } from "@noble/hashes/sha2.js"; import { ascii, equalCT, toHex } from "./bytes.js"; import { NS_TAG_LEN } from "./constants.js"; import { FileKeyError } from "./errors.js"; export { FileKeyError }; const LABEL_RE = /^[a-z0-9-]{1,63}$/; /** * Validate a canonical RP-ID against the normative §8.5 rules: * 1–253 bytes, only [a-z0-9.-], no trailing dot, each label 1–63 bytes, * no leading/trailing hyphen, and the reserved `xx--` prefix only when it is a * valid `xn--` Punycode A-label. (We accept `xn--` labels syntactically; full * Punycode decode verification is left to input canonicalization, §8.5.) * Returns the canonical bytes; throws FileKeyError on violation. */ export function validateCanonicalRpId(rpId: string): Uint8Array { const bytes = ascii(rpId); if (bytes.length < 1 || bytes.length > 253) { throw new FileKeyError(`canonical RP-ID length ${bytes.length} not in 1..253`, "rpid_length"); } if (/[^a-z0-9.-]/.test(rpId)) { throw new FileKeyError("canonical RP-ID contains bytes outside [a-z0-9.-]", "rpid_charset"); } if (rpId.endsWith(".")) { throw new FileKeyError("canonical RP-ID has a trailing dot", "rpid_trailing_dot"); } const labels = rpId.split("."); for (const label of labels) { if (!LABEL_RE.test(label)) { throw new FileKeyError(`invalid label "${label}" (1–63 [a-z0-9-])`, "rpid_label"); } if (label.startsWith("-") || label.endsWith("-")) { throw new FileKeyError(`label "${label}" starts/ends with hyphen`, "rpid_label_hyphen"); } // Reserved xx-- prefix (hyphens in positions 3 AND 4): allowed only as xn-- A-labels. if (label.length >= 4 && label[2] === "-" && label[3] === "-") { if (!label.startsWith("xn--")) { throw new FileKeyError(`label "${label}" uses reserved xx-- prefix but is not xn--`, "rpid_reserved_prefix"); } } } return bytes; } /** namespace_tag = first 4 bytes of SHA-256(canonical_rp_id) (§4.4). */ export function namespaceTag(canonicalRpId: string): Uint8Array { const bytes = validateCanonicalRpId(canonicalRpId); return sha256(bytes).subarray(0, NS_TAG_LEN); } /** A configured interop namespace: its canonical RP-ID and derived 4-byte tag. */ export class Namespace { readonly canonicalRpId: string; readonly rpIdBytes: Uint8Array; readonly tag: Uint8Array; constructor(canonicalRpId: string) { this.rpIdBytes = validateCanonicalRpId(canonicalRpId); this.canonicalRpId = canonicalRpId; this.tag = sha256(this.rpIdBytes).subarray(0, NS_TAG_LEN); } tagEquals(tag: Uint8Array): boolean { return equalCT(this.tag, tag); } } /** * A set of configured namespaces (single- or multi-namespace clients, §4.5). * Enforces the §4.4 rule 5(a) collision-rejection invariant. */ export class NamespaceSet { private readonly byTagHex = new Map(); readonly namespaces: Namespace[] = []; constructor(canonicalRpIds: string[]) { for (const id of canonicalRpIds) this.add(id); } add(canonicalRpId: string): Namespace { const ns = new Namespace(canonicalRpId); const tagHex = toHex(ns.tag); const existing = this.byTagHex.get(tagHex); if (existing && existing.canonicalRpId !== ns.canonicalRpId) { throw new FileKeyError( `namespace tag collision: "${ns.canonicalRpId}" and "${existing.canonicalRpId}" share tag ${tagHex}`, "namespace_tag_collision", ); } if (!existing) { this.byTagHex.set(tagHex, ns); this.namespaces.push(ns); } return ns; } /** Dispatch by the 4-byte file-header tag (§7.2 step 2). Null = no match. */ matchTag(tag: Uint8Array): Namespace | null { return this.byTagHex.get(toHex(tag)) ?? null; } byRpId(canonicalRpId: string): Namespace | null { for (const ns of this.namespaces) if (ns.canonicalRpId === canonicalRpId) return ns; return null; } } ============================================================================== === src/identity.ts === ============================================================================== // PRF → master_prk → identity KEM keypair (spec §4.1–§4.3). import { CipherSuite, DhkemP256HkdfSha256, HkdfSha256, Aes256Gcm } from "@hpke/core"; import { sha256 } from "@noble/hashes/sha2.js"; import { extract, expand } from "@noble/hashes/hkdf.js"; import { p256 } from "@noble/curves/nist.js"; import { bytesToNumberBE } from "@noble/curves/utils.js"; import { base64urlnopad } from "@scure/base"; import { wordlist } from "@scure/bip39/wordlists/english.js"; import { ascii, concat, toHex } from "./bytes.js"; import { LABEL_MASTER_PRK, LABEL_IDENTITY_KEM, LABEL_PRF_INPUT, LABEL_FINGERPRINT, PK_LEN } from "./constants.js"; import { FileKeyError, Namespace } from "./namespace.js"; /** The one and only cipher suite: HPKE Auth, DHKEM(P-256, HKDF-SHA-256) + AES-256-GCM. */ export const suite = new CipherSuite({ kem: new DhkemP256HkdfSha256(), kdf: new HkdfSha256(), aead: new Aes256Gcm(), }); /** The constant 32-byte WebAuthn PRF input salt (§4.1): SHA-256("FILEKEY-v1/prf-input/identity"). */ export const PRF_INPUT_SALT: Uint8Array = sha256(ascii(LABEL_PRF_INPUT)); /** master_prk = HKDF-Extract(salt="FILEKEY-v1/master-prk", IKM=prf_secret) (§4.2). */ export function masterPrkFromPrfSecret(prfSecret: Uint8Array): Uint8Array { if (prfSecret.length !== 32) { throw new FileKeyError(`prf_secret must be 32 bytes, got ${prfSecret.length}`, "prf_secret_length"); } return extract(sha256, prfSecret, ascii(LABEL_MASTER_PRK)); } export interface Identity { readonly namespace: Namespace; readonly keyPair: CryptoKeyPair; /** static_pk as 65-byte SEC1 uncompressed. */ readonly staticPkRaw: Uint8Array; } // ---- DHKEM(P-256) DeriveKeyPair over @noble (Safari-compatible) ---- // @hpke/core's kem.deriveKeyPair() derives the private scalar, then relies on WebCrypto to synthesize // the public key from a private-scalar-only key. Chrome/Bun do that; Safari/WebKit refuses with a // DOMException ("Data provided to an operation does not meet requirements"), which broke identity // derivation — and therefore *all* of FileKey — on every WebKit browser. We compute the point with // @noble and import a COMPLETE JWK (x, y, d), which Safari accepts. This is RFC 9180 §7.1.3 DeriveKeyPair // for DHKEM(P-256, HKDF-SHA-256) and is byte-identical to @hpke/core (verified by the test vectors), so // existing keys, files, and fingerprints are unaffected. Only this deterministic derivation moves off // @hpke/core; the rest of the suite (encap/seal/open) still runs through it. const HPKE_V1 = ascii("HPKE-v1"); const KEM_SUITE_ID = Uint8Array.from([0x4b, 0x45, 0x4d, 0x00, 0x10]); // "KEM" || I2OSP(0x0010, 2) const EMPTY = new Uint8Array(0); function labeledExtractKem(salt: Uint8Array, label: string, ikm: Uint8Array): Uint8Array { return extract(sha256, concat(HPKE_V1, KEM_SUITE_ID, ascii(label), ikm), salt); // HKDF-Extract("HPKE-v1"||suite_id||label||ikm) } function labeledExpandKem(prk: Uint8Array, label: string, info: Uint8Array, len: number): Uint8Array { const li = concat(Uint8Array.from([(len >> 8) & 0xff, len & 0xff]), HPKE_V1, KEM_SUITE_ID, ascii(label), info); return expand(sha256, prk, li, len); } async function deriveP256KeyPair(ikm: Uint8Array): Promise { const dkpPrk = labeledExtractKem(EMPTY, "dkp_prk", ikm); const order = p256.Point.Fn.ORDER; let sk: Uint8Array | undefined; for (let counter = 0; counter <= 255 && !sk; counter++) { const cand = labeledExpandKem(dkpPrk, "candidate", Uint8Array.from([counter]), 32); cand[0] = cand[0]! & 0xff; // P-256 bitmask per RFC 9180 (0xff ⇒ no-op; kept for fidelity) const v = bytesToNumberBE(cand); if (v !== 0n && v < order) sk = cand; // accept the first candidate with 0 < sk < n } if (!sk) throw new FileKeyError("failed to derive a P-256 key pair from IKM", "derive_keypair"); const pub = p256.getPublicKey(sk, false); // 65-byte SEC1 uncompressed: 0x04 || x(32) || y(32) const alg = { name: "ECDH", namedCurve: "P-256" }; const privateKey = await crypto.subtle.importKey( "jwk", { kty: "EC", crv: "P-256", x: base64urlnopad.encode(pub.subarray(1, 33)), y: base64urlnopad.encode(pub.subarray(33, 65)), d: base64urlnopad.encode(sk) }, alg, true, ["deriveBits"], ); const publicKey = await crypto.subtle.importKey("raw", toArrayBuffer(pub), alg, true, []); return { privateKey, publicKey }; } /** * Derive the full identity for a namespace from master_prk (§4.3): * identity_ikm = HKDF-Expand(master_prk, "FILEKEY-v1/identity-kem" || canonical_rp_id, 32) * (static_sk, static_pk) = HPKE.DeriveKeyPair(DHKEM-P256, identity_ikm) * The canonical RP-ID is bound so the identity is namespace-scoped regardless of how * master_prk was obtained (WebAuthn, recovery code, or test vector). */ export async function deriveIdentity(masterPrk: Uint8Array, namespace: Namespace): Promise { if (masterPrk.length !== 32) { throw new FileKeyError(`master_prk must be 32 bytes, got ${masterPrk.length}`, "master_prk_length"); } const info = new Uint8Array([...ascii(LABEL_IDENTITY_KEM), ...namespace.rpIdBytes]); const identityIkm = expand(sha256, masterPrk, info, 32); const keyPair = await deriveP256KeyPair(identityIkm); const staticPkRaw = new Uint8Array(await suite.kem.serializePublicKey(keyPair.publicKey)); if (staticPkRaw.length !== PK_LEN) { throw new FileKeyError(`derived static_pk length ${staticPkRaw.length} != ${PK_LEN}`, "derive_pk_length"); } return { namespace, keyPair, staticPkRaw }; } /** Convenience: PRF output → full identity for a namespace. */ export async function deriveIdentityFromPrf(prfSecret: Uint8Array, namespace: Namespace): Promise { return deriveIdentity(masterPrkFromPrfSecret(prfSecret), namespace); } export interface Fingerprint { /** Canonical 6-word fingerprint for out-of-band verification (§4.7). */ words: string; /** Glanceable secondary form: first 4 bytes of the fingerprint hash, as hex. */ hex: string; } /** * Identity fingerprint (§4.7): SHA-256("FILEKEY-v1/fingerprint" || static_pk), the * top 66 bits encoded as 6 BIP39 English words. Deterministic and identical across * conforming implementations, so two people can verify they hold the same key by eye. */ export function identityFingerprint(staticPkRaw: Uint8Array): Fingerprint { if (staticPkRaw.length !== PK_LEN) { throw new FileKeyError(`static_pk must be ${PK_LEN} bytes, got ${staticPkRaw.length}`, "pk_length"); } const h = sha256(concat(ascii(LABEL_FINGERPRINT), staticPkRaw)); // Top 66 bits of the first 9 bytes (72 bits) → 6 × 11-bit BIP39 indices. let acc = 0n; for (let i = 0; i < 9; i++) acc = (acc << 8n) | BigInt(h[i]!); acc >>= 6n; // drop the low 6 bits, keep the top 66 const idx: number[] = new Array(6); for (let i = 5; i >= 0; i--) { idx[i] = Number(acc & 0x7ffn); acc >>= 11n; } const words = idx.map((i) => wordlist[i]!).join(" "); return { words, hex: toHex(h.subarray(0, 4)) }; } export function toArrayBuffer(u: Uint8Array): ArrayBuffer { // Return a standalone (non-shared) ArrayBuffer copy; handles subarray views safely. const ab = new ArrayBuffer(u.length); new Uint8Array(ab).set(u); return ab; } ============================================================================== === src/sharekey.ts === ============================================================================== // Share-key encoding/decoding (spec §4.4): Bech32m over sk_version || namespace_tag || compressed_pk. import { p256 } from "@noble/curves/nist.js"; import { bech32m } from "@scure/base"; import { concat, toHex } from "./bytes.js"; import { SHARE_KEY_HRP, SK_VERSION, NS_TAG_LEN, COMPRESSED_PK_LEN, PK_LEN } from "./constants.js"; import { FileKeyError, NamespaceSet, Namespace } from "./namespace.js"; const BECH32_LIMIT = 1023; /** Compress a 65-byte SEC1 uncompressed P-256 point to 33 bytes. Validates the point. */ export function compressPk(uncompressed: Uint8Array): Uint8Array { if (uncompressed.length !== PK_LEN || uncompressed[0] !== 0x04) { throw new FileKeyError("expected 65-byte uncompressed SEC1 point (0x04 prefix)", "pk_format"); } try { return p256.Point.fromBytes(uncompressed).toBytes(true); // fromBytes throws if invalid/off-curve/identity } catch (e) { throw new FileKeyError(`point invalid: ${(e as Error).message}`, "point_invalid"); } } /** Decompress a 33-byte SEC1 compressed P-256 point to 65 bytes. Validates the point. */ export function decompressPk(compressed: Uint8Array): Uint8Array { if (compressed.length !== COMPRESSED_PK_LEN || (compressed[0] !== 0x02 && compressed[0] !== 0x03)) { throw new FileKeyError("expected 33-byte compressed SEC1 point (0x02/0x03 prefix)", "pk_format"); } try { const pt = p256.Point.fromBytes(compressed); pt.assertValidity(); // explicit on-curve + non-identity + range check return pt.toBytes(false); } catch (e) { throw new FileKeyError(`point invalid: ${(e as Error).message}`, "point_invalid"); } } /** Encode a share key for a namespace identity. `staticPkRaw` is 65-byte uncompressed. */ export function encodeShareKey(staticPkRaw: Uint8Array, namespace: Namespace): string { const compressed = compressPk(staticPkRaw); const payload = concat(new Uint8Array([SK_VERSION]), namespace.tag, compressed); return bech32m.encode(SHARE_KEY_HRP, bech32m.toWords(payload), BECH32_LIMIT); } export interface DecodedShareKey { /** recipient_pk as 65-byte SEC1 uncompressed, ready for HPKE. */ recipientPkRaw: Uint8Array; /** The matched configured namespace (the file's namespace). */ namespace: Namespace; } /** * Decode + fully validate a share key against the configured namespaces. * Implements the ten §4.4 rejection checks; throws FileKeyError with a distinct * `code` per failure class (notably "wrong_namespace" for check 5). */ export function decodeShareKey(shareKey: string, namespaces: NamespaceSet): DecodedShareKey { // Checks 1–3: Bech32m decode (rejects bech32 non-m checksum, bad checksum, mixed case). let decoded: { prefix: string; words: number[] }; try { decoded = bech32m.decode(shareKey as `${string}1${string}`, BECH32_LIMIT); } catch (e) { throw new FileKeyError(`share key is not valid Bech32m: ${(e as Error).message}`, "bech32m_decode"); } if (decoded.prefix !== SHARE_KEY_HRP) { throw new FileKeyError(`share key HRP "${decoded.prefix}" != "${SHARE_KEY_HRP}"`, "wrong_hrp"); } let payload: Uint8Array; try { payload = bech32m.fromWords(decoded.words); } catch (e) { throw new FileKeyError(`share-key payload is not valid Bech32m data: ${(e as Error).message}`, "bech32m_decode"); } // Check 10: exact payload length before any further work. const expected = 1 + NS_TAG_LEN + COMPRESSED_PK_LEN; // 38 if (payload.length !== expected) { throw new FileKeyError(`share-key payload length ${payload.length} != ${expected}`, "payload_length"); } // Check 4: sk_version. const skVersion = payload[0]!; if (skVersion !== SK_VERSION) { throw new FileKeyError(`unsupported sk_version 0x${skVersion.toString(16)}`, "sk_version"); } // Check 5: namespace tag must match a configured namespace. const tag = payload.subarray(1, 1 + NS_TAG_LEN); const namespace = namespaces.matchTag(tag); if (!namespace) { throw new FileKeyError( `share key is for a namespace not configured (tag 0x${toHex(tag)})`, "wrong_namespace", ); } // Checks 6–9: point prefix, coordinate range, on-curve, non-identity (decompressPk enforces all). const compressed = payload.subarray(1 + NS_TAG_LEN); let recipientPkRaw: Uint8Array; try { recipientPkRaw = decompressPk(compressed); } catch (e) { if (e instanceof FileKeyError) throw e; throw new FileKeyError(`share-key point invalid: ${(e as Error).message}`, "point_invalid"); } return { recipientPkRaw, namespace }; } ============================================================================== === src/recovery.ts === ============================================================================== // Optional recovery codes (spec §4.6): BIP39 24-word phrase and self-describing Bech32m. import * as bip39 from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english.js"; import { bech32m } from "@scure/base"; import { concat, toHex } from "./bytes.js"; import { RECOVERY_HRP, REC_VERSION, NS_TAG_LEN } from "./constants.js"; import { FileKeyError, NamespaceSet, Namespace } from "./namespace.js"; const BECH32_LIMIT = 1023; // ---- Format 1: BIP39 (24 words, 256-bit entropy + 8-bit checksum) — §4.6.2 ---- /** Encode master_prk (32 bytes) as a 24-word BIP39 English mnemonic. */ export function encodeRecoveryBip39(masterPrk: Uint8Array): string { if (masterPrk.length !== 32) { throw new FileKeyError(`master_prk must be 32 bytes, got ${masterPrk.length}`, "master_prk_length"); } return bip39.entropyToMnemonic(masterPrk, wordlist); } /** * Decode a 24-word BIP39 phrase to master_prk. Rejects non-24-word phrases (§4.6.2 step 2), * unknown words, and checksum failures. Carries no namespace — caller supplies it (§4.6.2). */ export function decodeRecoveryBip39(mnemonic: string): Uint8Array { const words = mnemonic.trim().split(/\s+/); if (words.length !== 24) { throw new FileKeyError(`unsupported recovery phrase length ${words.length} (must be 24 words)`, "bip39_word_count"); } const normalized = words.join(" "); let entropy: Uint8Array; try { entropy = bip39.mnemonicToEntropy(normalized, wordlist); // validates words + checksum } catch (e) { throw new FileKeyError(`invalid BIP39 recovery phrase: ${(e as Error).message}`, "bip39_invalid"); } if (entropy.length !== 32) { throw new FileKeyError(`BIP39 entropy length ${entropy.length} != 32`, "bip39_entropy_length"); } return entropy; } // ---- Format 2: Bech32m self-describing — §4.6.3 ---- /** Encode a self-describing recovery code: rec_version || namespace_tag || master_prk. */ export function encodeRecoveryBech32m(masterPrk: Uint8Array, namespace: Namespace): string { if (masterPrk.length !== 32) { throw new FileKeyError(`master_prk must be 32 bytes, got ${masterPrk.length}`, "master_prk_length"); } const payload = concat(new Uint8Array([REC_VERSION]), namespace.tag, masterPrk); return bech32m.encode(RECOVERY_HRP, bech32m.toWords(payload), BECH32_LIMIT); } export interface DecodedRecoveryBech32m { masterPrk: Uint8Array; namespace: Namespace; } /** Decode + validate a Bech32m recovery code against configured namespaces (§4.6.3). */ export function decodeRecoveryBech32m(code: string, namespaces: NamespaceSet): DecodedRecoveryBech32m { let decoded: { prefix: string; words: number[] }; try { decoded = bech32m.decode(code as `${string}1${string}`, BECH32_LIMIT); } catch (e) { throw new FileKeyError(`recovery code is not valid Bech32m: ${(e as Error).message}`, "bech32m_decode"); } if (decoded.prefix !== RECOVERY_HRP) { throw new FileKeyError(`recovery HRP "${decoded.prefix}" != "${RECOVERY_HRP}"`, "wrong_hrp"); } let payload: Uint8Array; try { payload = bech32m.fromWords(decoded.words); } catch (e) { throw new FileKeyError(`recovery payload is not valid Bech32m data: ${(e as Error).message}`, "bech32m_decode"); } const expected = 1 + NS_TAG_LEN + 32; // 37 if (payload.length !== expected) { throw new FileKeyError(`recovery payload length ${payload.length} != ${expected}`, "payload_length"); } const recVersion = payload[0]!; if (recVersion !== REC_VERSION) { throw new FileKeyError(`unsupported rec_version 0x${recVersion.toString(16)}`, "rec_version"); } const tag = payload.subarray(1, 1 + NS_TAG_LEN); const namespace = namespaces.matchTag(tag); if (!namespace) { throw new FileKeyError( `recovery code is for a namespace not configured (tag 0x${toHex(tag)})`, "wrong_namespace", ); } const masterPrk = payload.subarray(1 + NS_TAG_LEN).slice(); return { masterPrk, namespace }; } /** Detect and decode either recovery-code format. BIP39 returns no namespace. */ export function decodeRecoveryAuto( input: string, namespaces: NamespaceSet, ): { masterPrk: Uint8Array; namespace: Namespace | null } { const trimmed = input.trim(); if (/^fkeyrec1/i.test(trimmed)) { return decodeRecoveryBech32m(trimmed, namespaces); } const wordCount = trimmed.split(/\s+/).length; if (wordCount >= 12) { return { masterPrk: decodeRecoveryBip39(trimmed), namespace: null }; } throw new FileKeyError("unsupported recovery code format (expected 24-word BIP39 or fkeyrec1… Bech32m)", "unknown_recovery_format"); } ============================================================================== === src/metadata.ts === ============================================================================== // Metadata chunk plaintext encode/decode (spec §5.4.1). import { td, concat, u16be, u32be, u64be, Reader } from "./bytes.js"; import { METADATA_VERSION, METADATA_PLAINTEXT_MAX } from "./constants.js"; import { FileKeyError } from "./namespace.js"; export interface Metadata { filename: string; mimeType: string; /** original_plaintext_size — authoritative, enforced at decryption (§5.4.1 rule 4). */ originalSize: number; /** created_at_unix_ms; 0 = unknown (§5.4.1 rule 5). */ createdAtUnixMs: number; /** Ordered map of application-defined extras. */ extras: Map; } const enc = new TextEncoder(); function validateFilenameBytes(bytes: Uint8Array): void { if (bytes.length === 0) return; // empty filename allowed only when filename_len == 0 for (const b of bytes) { if (b <= 0x1f || b === 0x7f) throw new FileKeyError("filename contains a C0 control or DEL byte", "filename_control"); if (b === 0x2f || b === 0x5c) throw new FileKeyError("filename contains a path separator", "filename_separator"); } if (bytes[0] === 0x20 || bytes[bytes.length - 1] === 0x20) { throw new FileKeyError("filename begins or ends with SPACE", "filename_space"); } let s: string; try { s = td.decode(bytes); // fatal UTF-8 validation } catch { throw new FileKeyError("filename is not valid UTF-8", "filename_utf8"); } if (s === "." || s === "..") throw new FileKeyError(`filename "${s}" is a path-traversal form`, "filename_dotdot"); } /** Encode metadata to its length-prefixed plaintext (§5.4.1). Validates as it builds. */ export function encodeMetadata(m: Metadata): Uint8Array { const filenameBytes = enc.encode(m.filename); if (filenameBytes.length > 65535) throw new FileKeyError("filename too long (> 65535 bytes)", "filename_length"); validateFilenameBytes(filenameBytes); const mimeBytes = enc.encode(m.mimeType); if (mimeBytes.length > 256) throw new FileKeyError("mime_type too long (> 256 bytes)", "mime_length"); if (m.originalSize < 0 || !Number.isSafeInteger(m.originalSize)) throw new FileKeyError("originalSize invalid", "size_invalid"); if (m.createdAtUnixMs < 0 || !Number.isSafeInteger(m.createdAtUnixMs)) throw new FileKeyError("createdAtUnixMs invalid", "created_invalid"); if (m.extras.size > 256) throw new FileKeyError("too many extras (> 256)", "extras_count"); const parts: Uint8Array[] = [ new Uint8Array([METADATA_VERSION]), u32be(filenameBytes.length), filenameBytes, u32be(mimeBytes.length), mimeBytes, u64be(BigInt(m.originalSize)), u64be(BigInt(m.createdAtUnixMs)), u16be(m.extras.size), ]; for (const [key, value] of m.extras) { const keyBytes = enc.encode(key); if (keyBytes.length < 1 || keyBytes.length > 256) throw new FileKeyError(`extras key length ${keyBytes.length} not in 1..256`, "extras_key_length"); if (value.length > 65536) throw new FileKeyError("extras value too long (> 65536)", "extras_value_length"); parts.push(u16be(keyBytes.length), keyBytes, u32be(value.length), value); } const out = concat(...parts); if (out.length > METADATA_PLAINTEXT_MAX) throw new FileKeyError("metadata plaintext exceeds 1 MiB", "metadata_too_large"); return out; } /** Decode + fully validate metadata plaintext (§5.4.1, all rules). */ export function decodeMetadata(plaintext: Uint8Array): Metadata { if (plaintext.length > METADATA_PLAINTEXT_MAX) { throw new FileKeyError("metadata plaintext exceeds 1 MiB", "metadata_too_large"); } const r = new Reader(plaintext); const version = r.u8(); if (version !== METADATA_VERSION) throw new FileKeyError(`metadata_version 0x${version.toString(16)} != 0x01`, "metadata_version"); const filenameLen = r.u32(); if (filenameLen > 65535) throw new FileKeyError("filename_len > 65535", "filename_length"); const filenameBytes = r.take(filenameLen); validateFilenameBytes(filenameBytes); const filename = filenameLen === 0 ? "" : td.decode(filenameBytes); const mimeLen = r.u32(); if (mimeLen > 256) throw new FileKeyError("mime_type_len > 256", "mime_length"); const mimeBytes = r.take(mimeLen); let mimeType: string; try { mimeType = td.decode(mimeBytes); } catch { throw new FileKeyError("mime_type is not valid UTF-8", "mime_utf8"); } const originalSize = r.u64(); const createdAtUnixMs = r.u64(); const extrasCount = r.u16(); if (extrasCount > 256) throw new FileKeyError("extras_count > 256", "extras_count"); const extras = new Map(); const seen = new Set(); for (let i = 0; i < extrasCount; i++) { const keyLen = r.u16(); if (keyLen < 1 || keyLen > 256) throw new FileKeyError(`extras key length ${keyLen} not in 1..256`, "extras_key_length"); const keyBytes = r.take(keyLen); let key: string; try { key = td.decode(keyBytes); } catch { throw new FileKeyError("extras key is not valid UTF-8", "extras_key_utf8"); } if (seen.has(key)) throw new FileKeyError(`duplicate extras key "${key}"`, "extras_duplicate"); // detect while parsing seen.add(key); const valueLen = r.u32(); if (valueLen > 65536) throw new FileKeyError("extras value too long (> 65536)", "extras_value_length"); const value = r.take(valueLen).slice(); extras.set(key, value); } // Rule 8: no trailing bytes. if (r.remaining !== 0) throw new FileKeyError(`${r.remaining} trailing bytes after metadata`, "metadata_trailing"); if (originalSize > BigInt(Number.MAX_SAFE_INTEGER)) { throw new FileKeyError("original_plaintext_size exceeds MAX_SAFE_INTEGER", "size_too_large"); } if (createdAtUnixMs > BigInt(Number.MAX_SAFE_INTEGER)) { throw new FileKeyError("created_at_unix_ms exceeds MAX_SAFE_INTEGER", "created_too_large"); } return { filename, mimeType, originalSize: Number(originalSize), createdAtUnixMs: Number(createdAtUnixMs), extras, }; } ============================================================================== === src/cipher.ts === ============================================================================== // Encryption (§6.4) and decryption (§7.2) procedures. // // Both directions are built on async generators so a caller can stream a large file // without ever holding it whole in memory. The buffered `encrypt`/`decrypt` keep the // original Uint8Array-in/Uint8Array-out signatures and are thin wrappers that drive the // generators and collect the result. The wire format is byte-for-byte identical either way. import { p256 } from "@noble/curves/nist.js"; import { ascii, concat, u32be, Reader, equalCT, bs, toHex, ByteSource, bytesSource } from "./bytes.js"; import { PK_LEN, ENC_LEN, HEADER_LEN, AAD_LEN, CHUNK_SIZE, GCM_TAG_LEN, MAX_CHUNK_INDEX, METADATA_CT_MAX, METADATA_CT_MIN, METADATA_NONCE, LABEL_PAYLOAD_KEY, LABEL_METADATA_KEY, } from "./constants.js"; import { FileKeyError, Namespace, NamespaceSet } from "./namespace.js"; import { suite, Identity, toArrayBuffer } from "./identity.js"; import { Metadata, encodeMetadata, decodeMetadata } from "./metadata.js"; import { buildHeader, parseHeader, buildInfo, buildAad, chunkNonce } from "./wire.js"; /** Validate a 65-byte uncompressed SEC1 P-256 point (§5.3). Throws on any failure. */ export function validateUncompressedPk(raw: Uint8Array, what: string): void { if (raw.length !== PK_LEN) throw new FileKeyError(`${what} length ${raw.length} != 65`, "pk_length"); if (raw[0] !== 0x04) throw new FileKeyError(`${what} leading byte != 0x04`, "pk_prefix"); try { p256.Point.fromBytes(raw).assertValidity(); // range + on-curve + non-identity } catch (e) { throw new FileKeyError(`${what} is not a valid P-256 point: ${(e as Error).message}`, "pk_invalid"); } } async function importAesKey(raw: ArrayBuffer): Promise { return crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]); } async function aesSeal(key: CryptoKey, nonce: Uint8Array, aad: Uint8Array, pt: Uint8Array): Promise { const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv: bs(nonce), additionalData: bs(aad) }, key, bs(pt)); return new Uint8Array(ct); } async function aesOpen(key: CryptoKey, nonce: Uint8Array, aad: Uint8Array, ct: Uint8Array): Promise { try { const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: bs(nonce), additionalData: bs(aad) }, key, bs(ct)); return new Uint8Array(pt); } catch { throw new FileKeyError("AEAD authentication failed", "auth_failed"); } } // ---------------------------------------------------------------------------- // Encryption (§6.4) // ---------------------------------------------------------------------------- export interface EncryptInput { /** Sender's identity in the file's namespace (must equal `namespace`). */ senderIdentity: Identity; /** Recipient's static_pk as 65-byte SEC1 uncompressed. */ recipientPkRaw: Uint8Array; /** The file's namespace (recipient's; in v1 == sender's). */ namespace: Namespace; plaintext: Uint8Array; /** Metadata sans originalSize (set internally to plaintext.length, the authoritative value). */ metadata: Omit; } /** Streaming variant of {@link EncryptInput}: plaintext is read incrementally from a ByteSource. */ export interface EncryptStreamInput { senderIdentity: Identity; recipientPkRaw: Uint8Array; namespace: Namespace; /** The plaintext as a random-access byte source; `size` is the authoritative original_plaintext_size. */ plaintext: ByteSource; metadata: Omit; } /** * Core streaming encryption (§6.4). Yields the .filekey file in order: first the fixed * head (header‖sender_pk‖hpke_enc‖u32(metaCtLen)‖metaCt), then each payload chunk. Concatenating * everything it yields produces exactly the bytes that the buffered `encrypt` returns. */ async function* sealStream(input: EncryptStreamInput, ekm?: ArrayBuffer | CryptoKeyPair): AsyncGenerator { const { senderIdentity, recipientPkRaw, namespace } = input; const M = input.plaintext.size; // §6.4 step 2: sender must be in the file's namespace. if (senderIdentity.namespace.canonicalRpId !== namespace.canonicalRpId) { throw new FileKeyError("sender identity is not in the recipient's namespace", "sender_namespace_mismatch"); } validateUncompressedPk(recipientPkRaw, "recipient_pk"); const senderPk = senderIdentity.staticPkRaw; // §6.4 steps 4–5: namespace tag + header. const header = buildHeader(namespace.tag); // §6.4 step 6: HPKE info transcript. const info = buildInfo(header, senderPk, recipientPkRaw, namespace.rpIdBytes); // §6.4 step 7: HPKE Auth SetupS (export-only usage). const recipientPublicKey = await suite.kem.deserializePublicKey(toArrayBuffer(recipientPkRaw)); const sender = await suite.createSenderContext({ recipientPublicKey, info, senderKey: senderIdentity.keyPair.privateKey, ...(ekm ? { ekm } : {}), }); const hpkeEnc = new Uint8Array(sender.enc); if (hpkeEnc.length !== ENC_LEN) throw new FileKeyError(`hpke_enc length ${hpkeEnc.length} != 65`, "enc_length"); // §6.4 steps 8–10: export keys, aad. const payloadKey = await importAesKey(await sender.export(ascii(LABEL_PAYLOAD_KEY), 32)); const metadataKey = await importAesKey(await sender.export(ascii(LABEL_METADATA_KEY), 32)); const aad = buildAad(header, senderPk, hpkeEnc); // §6.4 step 13: metadata chunk. const fullMeta: Metadata = { ...input.metadata, originalSize: M }; const metaPt = encodeMetadata(fullMeta); const metaCt = await aesSeal(metadataKey, METADATA_NONCE, aad, metaPt); if (metaCt.length > METADATA_CT_MAX || metaCt.length < METADATA_CT_MIN) { throw new FileKeyError("metadata ciphertext length out of bounds", "metadata_ct_bounds"); } // Emit the fixed head first, then payload chunks. yield concat(header, senderPk, hpkeEnc, u32be(metaCt.length), metaCt); // §6.4 step 14: payload chunks (STREAM). const totalChunks = M === 0 ? 1 : Math.ceil(M / CHUNK_SIZE); if (totalChunks > MAX_CHUNK_INDEX) throw new FileKeyError("payload exceeds 2^32 chunk cap", "chunk_overflow"); for (let i = 0; i < totalChunks; i++) { const start = i * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, M); const chunkPt = await input.plaintext.slice(start, end); // The buffered path gets exact-length chunks for free from a Uint8Array. A custom ByteSource that // under-reads would otherwise emit a file whose payload is shorter than the metadata's originalSize. if (chunkPt.length !== end - start) { throw new FileKeyError(`plaintext source returned ${chunkPt.length} bytes, expected ${end - start}`, "source_short_read"); } const isLast = i === totalChunks - 1; yield await aesSeal(payloadKey, chunkNonce(i, isLast), aad, chunkPt); } } /** * Streaming encryption (§6.4). Yields the .filekey file bytes incrementally so a large * plaintext is never held whole in memory. Byte-identical to {@link encrypt}. */ export function encryptStream(input: EncryptStreamInput): AsyncGenerator { return sealStream(input); } /** Core encryption (§6.4). Returns the complete .filekey file bytes. */ export async function encrypt(input: EncryptInput): Promise { return collect(sealStream(toEncryptStreamInput(input))); } /** * TEST ONLY (§11.1 deterministic-ephemeral vectors): encrypt with caller-supplied HPKE ephemeral * key material instead of fresh randomness. Deliberately NOT re-exported from index.ts, so it is * unreachable through the public API. Reusing `ekm` for the same sender/recipient/namespace repeats * the HPKE enc and every nonce — catastrophic AES-GCM nonce reuse. Never call from a production path. */ export async function encryptWithEphemeralForTest( input: EncryptInput, ekm: ArrayBuffer | CryptoKeyPair, ): Promise { return collect(sealStream(toEncryptStreamInput(input), ekm)); } function toEncryptStreamInput(input: EncryptInput): EncryptStreamInput { return { senderIdentity: input.senderIdentity, recipientPkRaw: input.recipientPkRaw, namespace: input.namespace, plaintext: bytesSource(input.plaintext), metadata: input.metadata, }; } async function collect(gen: AsyncGenerator): Promise { const parts: Uint8Array[] = []; for await (const piece of gen) parts.push(piece); return concat(...parts); } // ---------------------------------------------------------------------------- // Decryption (§7.2) // ---------------------------------------------------------------------------- export interface DecryptResult { metadata: Metadata; plaintext: Uint8Array; /** The sender's static_pk (65-byte uncompressed) — for application identity resolution (§7.3). */ senderPkRaw: Uint8Array; namespace: Namespace; /** True when sender_pk == the recipient's static_pk (self-encrypted file). */ selfEncrypted: boolean; } export interface DecryptInput { file: Uint8Array; namespaces: NamespaceSet; /** * Resolve the recipient's identity for the file's namespace (§7.2 step 5). This is * where an interactive client triggers the WebAuthn PRF assertion. Called at most once. */ resolveIdentity: (namespace: Namespace) => Promise; } /** Streaming variant of {@link DecryptInput}: the file is read incrementally from a ByteSource. */ export interface DecryptStreamInput { file: ByteSource; namespaces: NamespaceSet; resolveIdentity: (namespace: Namespace) => Promise; } export interface DecryptStreamResult { metadata: Metadata; senderPkRaw: Uint8Array; namespace: Namespace; selfEncrypted: boolean; /** * Yields plaintext chunks in order. Each chunk is individually AES-GCM authenticated BEFORE it is * yielded, but file-level integrity — that the final chunk was reached and the total decrypted size * matches `metadata.originalSize` — is only verified AFTER the last chunk, by throwing a FileKeyError * (auth failure, truncation, or size mismatch; §7.4). * * CONTRACT (load-bearing): a caller MUST NOT treat yielded chunks as trusted output until the * generator completes without throwing. For all-or-nothing release (never surfacing a valid prefix of * a file that turns out to be truncated), buffer every chunk and release only on normal completion — * which is exactly what the buffered {@link decrypt} does, so prefer it unless the file is too large to * hold. Streaming chunks straight to disk / a preview accepts that a truncated file can expose an * authenticated prefix before the final throw (Policy B); {@link decrypt} is the Policy-A path. */ chunks: AsyncGenerator; } /** * Streaming decryption (§7.2). Reads the head + metadata (small, eager), triggers the * WebAuthn assertion via `resolveIdentity`, then returns the metadata together with a * generator that yields authenticated payload chunks. Output is never held whole in memory. */ export async function decryptStream(input: DecryptStreamInput): Promise { const source = input.file; // §7.2 step 1: header (+ sender_pk, hpke_enc, metadata_ct_len — the fixed-size head). const headFixed = HEADER_LEN + PK_LEN + ENC_LEN + 4; const head = await source.slice(0, headFixed); // clamps near EOF; Reader throws "truncated" if short const r = new Reader(head); const header = r.take(HEADER_LEN).slice(); const { namespaceTag } = parseHeader(header); // §7.2 step 2: dispatch by tag. const namespace = input.namespaces.matchTag(namespaceTag); if (!namespace) { throw new FileKeyError(`no configured namespace matches file tag 0x${toHex(namespaceTag)}`, "wrong_namespace"); } // §7.2 steps 3–4: sender_pk, hpke_enc. const senderPk = r.take(PK_LEN).slice(); validateUncompressedPk(senderPk, "sender_pk"); const hpkeEnc = r.take(ENC_LEN).slice(); validateUncompressedPk(hpkeEnc, "hpke_enc"); // §7.2 step 5: recipient identity for this namespace (may trigger WebAuthn). const identity = await input.resolveIdentity(namespace); if (identity.namespace.canonicalRpId !== namespace.canonicalRpId) { throw new FileKeyError("resolved identity is not in the file's namespace", "identity_namespace_mismatch"); } const recipientPk = identity.staticPkRaw; // §7.2 step 6–7: info + HPKE Auth SetupR. const info = buildInfo(header, senderPk, recipientPk, namespace.rpIdBytes); let recipient: Awaited>; try { const senderPublicKey = await suite.kem.deserializePublicKey(toArrayBuffer(senderPk)); recipient = await suite.createRecipientContext({ recipientKey: identity.keyPair.privateKey, enc: toArrayBuffer(hpkeEnc), info, senderPublicKey, }); } catch (e) { throw new FileKeyError(`HPKE SetupAuthR failed: ${(e as Error).message}`, "hpke_setup_failed"); } // §7.2 steps 8–9: export keys, aad. const payloadKey = await importAesKey(await recipient.export(ascii(LABEL_PAYLOAD_KEY), 32)); const metadataKey = await importAesKey(await recipient.export(ascii(LABEL_METADATA_KEY), 32)); const aad = buildAad(header, senderPk, hpkeEnc); if (aad.length !== AAD_LEN) throw new FileKeyError("aad length invariant violated", "aad_length"); // §7.2 steps 10–13: metadata length (bounded) + chunk. const metaCtLen = r.u32(); if (metaCtLen > METADATA_CT_MAX || metaCtLen < METADATA_CT_MIN) { throw new FileKeyError(`metadata_ct_len ${metaCtLen} out of bounds`, "metadata_ct_bounds"); } const metaCt = await source.slice(headFixed, headFixed + metaCtLen); if (metaCt.length !== metaCtLen) throw new FileKeyError("unexpected end of input (truncated file)", "truncated"); const metaPt = await aesOpen(metadataKey, METADATA_NONCE, aad, metaCt); const metadata = decodeMetadata(metaPt); const payloadStart = headFixed + metaCtLen; const selfEncrypted = equalCT(senderPk, recipientPk); return { metadata, senderPkRaw: senderPk, namespace, selfEncrypted, chunks: openStream(source, payloadStart, payloadKey, aad, metadata.originalSize), }; } /** * §7.2 steps 14–17: stream-decrypt the payload chunks. Yields each chunk's plaintext only * after it authenticates; enforces the same nonce/size/truncation invariants as the buffered * path, throwing (after the final chunk) on truncation or size mismatch. */ async function* openStream( source: ByteSource, payloadStart: number, payloadKey: CryptoKey, aad: Uint8Array, M: number, ): AsyncGenerator { const total = source.size; let off = payloadStart; let i = 0; let decryptedBytes = 0; let sawLast = false; for (;;) { const remaining = total - off; if (remaining === 0) throw new FileKeyError("no payload chunks (truncated before first chunk)", "no_chunks"); if (remaining < GCM_TAG_LEN) throw new FileKeyError("incomplete final chunk (< 16 bytes)", "incomplete_chunk"); if (i >= MAX_CHUNK_INDEX) throw new FileKeyError("chunk index exceeds 2^32 cap", "chunk_overflow"); const toRead = Math.min(remaining, CHUNK_SIZE + GCM_TAG_LEN); const chunkCt = await source.slice(off, off + toRead); // toRead never exceeds remaining, so a correct source returns exactly toRead. A short read means a // truncated/unstable source — reject it rather than mis-derive isLast from the advanced offset. if (chunkCt.length !== toRead) { throw new FileKeyError("file source returned a short read (truncated or unstable source)", "truncated"); } off += toRead; const isLast = off >= total; if (!isLast && chunkCt.length < GCM_TAG_LEN + 1) { throw new FileKeyError("non-final chunk has no plaintext", "empty_nonfinal_chunk"); } const pt = await aesOpen(payloadKey, chunkNonce(i, isLast), aad, chunkCt); if (isLast && pt.length === 0 && M !== 0) { throw new FileKeyError("empty final chunk but original_plaintext_size != 0", "empty_final_mismatch"); } decryptedBytes += pt.length; if (decryptedBytes > M) throw new FileKeyError("decrypted size overruns original_plaintext_size", "size_overrun"); yield pt; if (isLast) { sawLast = true; break; } i++; } if (!sawLast) throw new FileKeyError("never saw last chunk (truncation)", "truncated"); if (decryptedBytes !== M) { throw new FileKeyError(`decrypted ${decryptedBytes} != original_plaintext_size ${M}`, "size_mismatch"); } } /** Core decryption (§7.2). Buffers output (Policy A, §7.4) and returns it only on full success. */ export async function decrypt(input: DecryptInput): Promise { const res = await decryptStream({ file: bytesSource(input.file), namespaces: input.namespaces, resolveIdentity: input.resolveIdentity, }); const out: Uint8Array[] = []; for await (const pt of res.chunks) out.push(pt); return { metadata: res.metadata, plaintext: concat(...out), senderPkRaw: res.senderPkRaw, namespace: res.namespace, selfEncrypted: res.selfEncrypted, }; } ============================================================================== === src/wire.ts === ============================================================================== // Wire-format builders: header, HPKE info transcript, AAD, chunk nonces (spec §5, §6). import { ascii, concat, i2osp } from "./bytes.js"; import { MAGIC, FORMAT_VERSION, SUITE_ID, HEADER_LEN, NS_TAG_LEN, LABEL_HPKE_INFO, COUNTER_LEN, } from "./constants.js"; import { FileKeyError } from "./namespace.js"; /** 12-byte file header (§5.2): magic||version||suite||flags||reserved||namespace_tag. */ export function buildHeader(namespaceTag: Uint8Array): Uint8Array { if (namespaceTag.length !== NS_TAG_LEN) throw new FileKeyError("namespace_tag must be 4 bytes", "ns_tag_length"); const header = new Uint8Array(HEADER_LEN); header.set(MAGIC, 0); header[4] = FORMAT_VERSION; header[5] = SUITE_ID; header[6] = 0x00; // flags header[7] = 0x00; // reserved header.set(namespaceTag, 8); return header; } export interface ParsedHeader { namespaceTag: Uint8Array; } /** Validate + parse a 12-byte header (§7.2 step 1). Throws on any mismatch. */ export function parseHeader(header: Uint8Array): ParsedHeader { if (header.length !== HEADER_LEN) throw new FileKeyError(`header length ${header.length} != 12`, "header_length"); for (let i = 0; i < 4; i++) { if (header[i] !== MAGIC[i]) throw new FileKeyError("bad magic (not an FKEY file)", "bad_magic"); } if (header[4] !== FORMAT_VERSION) throw new FileKeyError(`unsupported format_version 0x${header[4]!.toString(16)}`, "format_version"); if (header[5] !== SUITE_ID) throw new FileKeyError(`unsupported suite_id 0x${header[5]!.toString(16)}`, "suite_id"); if (header[6] !== 0x00) throw new FileKeyError("non-zero flags byte (reserved in v1)", "flags_nonzero"); if (header[7] !== 0x00) throw new FileKeyError("non-zero reserved byte", "reserved_nonzero"); return { namespaceTag: header.subarray(8, 12) }; } /** HPKE info transcript (§6.2). */ export function buildInfo( header: Uint8Array, senderPk: Uint8Array, recipientPk: Uint8Array, rpIdBytes: Uint8Array, ): Uint8Array { if (rpIdBytes.length > 255) throw new FileKeyError("rp_id too long for u8 length prefix", "rpid_length"); return concat(ascii(LABEL_HPKE_INFO), header, senderPk, recipientPk, new Uint8Array([rpIdBytes.length]), rpIdBytes); } /** AAD bound into every AEAD call (§5.4.2, §5.5): header||sender_pk||hpke_enc = 142 bytes. */ export function buildAad(header: Uint8Array, senderPk: Uint8Array, hpkeEnc: Uint8Array): Uint8Array { return concat(header, senderPk, hpkeEnc); } /** chunk_nonce(i, is_last) = I2OSP(i, 11) || (0x01 if last else 0x00) (§5.5). */ export function chunkNonce(index: number, isLast: boolean): Uint8Array { return concat(i2osp(index, COUNTER_LEN), new Uint8Array([isLast ? 0x01 : 0x00])); }