FileKey reference implementation, full source v1.12 · 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 Substack Signal Group Contact Us DEVELOPER Changelog 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 unlock"), 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, selfEncryptStream, 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: ``, // Contacts-footer actions. export = arrow up out of the tray (mirror of import); trash = Clear all. // Stroke-based with currentColor so they follow the link color (and its hover light-up). export: ``, trash: ``, // Cancel's "✗", the counterpart to Confirm's check — only used where a text Cancel sits beside an iconed Confirm. close: ``, // "Show QR" toggle on the receive-a-file link. Filled QR glyph (three finder rings + a few modules); // fill-rule evenodd carves the ring holes; fill via currentColor so it follows the link color on hover. qr: ``, // Unlock button's "touch to unlock" biometric affordance (a fingerprint). Stroke-based so it inherits // the button's text color via currentColor — white on the solid-blue Unlock button. This is a biometric // cue, NOT the identity-verification fingerprint deliberately left unbuilt (see DESIGN.md Appendix A). fingerprint: ``, // Update-notice action icons (stroke + currentColor, same family as import/export/trash/close): // download = install the update; doc = open the changelog; clock = "Later" (defer). download: ``, doc: ``, clock: ``, }; // 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}`; // Same, but ending a sentence: keeps the link + its period on one line so the "." never wraps alone // onto a new line (the external-link icon is an atomic inline, which would otherwise allow a break). const extLinkDot = (href: string, text: string) => `${extLink(href, text)}.`; 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, """); // Strip Unicode bidi override/embedding/isolate controls (U+202A–202E, U+2066–2069). A sender-controlled // filename like "photogpj.exe" otherwise displays/saves as "photo.jpg" but lands on disk as .exe. // Override/isolate chars only — NOT the LRM/RLM marks (U+200E/200F) that legitimate RTL filenames use. const stripBidi = (s: string) => s.replace(/[\u202a-\u202e\u2066-\u2069]/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 createdThisSession = false; // true once a filekey is created this page-load, so the recovery reminder only fires on a genuine return-visit authenticate 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; icon?: string; 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`; if (a.icon) s.innerHTML = `${a.icon}${esc(a.label)}`; else s.textContent = a.label; s.addEventListener("click", a.onClick); row.appendChild(s); } host.appendChild(row); scrollToBottom(); } // Like actionRow, but renders REAL buttons (the .dc_btn vocabulary: solid-blue primary + ghost outline) // with an optional leading icon — for the first-run moment that earns a tappable button over an inline // link (Unlock / Create). DESIGN.md: distinct choices belong in a row, never two inline links. function buttonRow(host: HTMLElement, buttons: { label: string; icon?: string; ghost?: boolean; onClick: () => void }[]): void { const row = document.createElement("div"); row.className = "auth_row"; for (const b of buttons) { const btn = document.createElement("button"); btn.className = `dc_btn auth_btn ${b.ghost ? "dc_btn_ghost" : "dc_btn_primary"} no_select`; btn.innerHTML = `${b.icon ?? ""}${esc(b.label)}`; btn.addEventListener("click", b.onClick); row.appendChild(btn); } 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 { filename = stripBidi(filename); // neutralize bidi-override extension spoofing before display 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)); (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 (stripBidi(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) { const name = sanitizeName(filename); const big = blob.size >= STREAM_THRESHOLD; // large files get a progress / "keep tab open" note; every save is confirmed 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). The // picker already resolved here, so the user committed to a location; cancelling it threw above. st = new StatusMsg("Saving"); await ws.write(blob); await ws.close(); st.finish("Saved ✓"); 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 hands the blob to the browser's own download manager — there's no completion callback, // so we can't show "done" (the browser's download UI does). Frame it as a local save, not a network // transfer, and for a big file note that closing the tab early can interrupt the browser writing the blob. void appMsg([big ? "Your browser is saving the encrypted file to your downloads (nothing leaves your device). It's a large file, so keep this tab open until your browser shows the download finished." : "Saved to your downloads."], { speed: 6 }); // 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 unlock" -> 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 passkey on this device on ${fmtKeyDate(prior[prior.length - 1]!)}. A new one is a separate identity, so files locked with your existing passkey 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 passkey. Please try again."], ERR); return; } recordCreated(now); createdThisSession = true; // brand-new user: skip the recovery reminder on the create -> authenticate hop await appMsg(["Passkey created. ", { link: "Now tap to unlock", 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(); try { identity = await deriveIdentityFromPrf(prf, NS); } finally { prf.fill(0); } // scrub the PRF secret once identity is derived (best-effort) } 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([`Failed to unlock. Please try again.`], ERR); return; } await Contacts.loadContacts(identity!, SET); // local address book (public keys + your nicknames), decrypted into memory await appMsg(["Unlocked. Now 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(); // Returning user (didn't just create one): a gentle, one-time recovery reminder, well clear of onboarding. if (!createdThisSession) void maybeRecoveryNudge(); } 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, masterPrk: identity!.masterPrk, 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 ${extLinkDot(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; // Only nudge a profile with persistent state from a prior session. Incognito (and cleared profiles) // reset localStorage every visit, so the acked flag above can't dedupe; without this guard, someone // who lives in incognito with one passkey would be nagged every single session. No created-log means // no persistence, so stay quiet (recovery is still one tap away in the menu). if (!createdLog().length) return; flagSet(RECOVERY_ACK_KEY); await appMsg(["Have you saved a recovery code? It's the only way back into your files if you ever lose this passkey. ", { 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, selfEncrypt: true, senderKeyPair: identity!.keyPair, senderPk: identity!.staticPkRaw, recipientPk: identity!.staticPkRaw, senderMasterPrk: identity!.masterPrk, 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, selfEncrypt: true, senderKeyPair: identity!.keyPair, senderPk: identity!.staticPkRaw, recipientPk: identity!.staticPkRaw, senderMasterPrk: identity!.masterPrk, 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 + self-encrypt on the main thread (fast; not worth the Worker round-trip). // Uses suite 0x02 (selfEncryptStream), like single-file encryptToSelf, so bundles are post-quantum-safe. const zipBlob = await zipBundleToBlob(items); const parts: Blob[] = []; for await (const piece of selfEncryptStream({ masterPrk: identity!.masterPrk!, 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("Confirm${SVG.close.replace("Cancel${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; icon?: string; onClick: () => void }[] = [{ label: "Save", icon: SVG.save.replace(" void saveBlob(out, sharedName) }]; if (canSend) acts.push({ label: "Send…", icon: SVG.share.replace(" 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. 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); // The name comes straight from the link (#name=), so it's unverified. Never assert it as the verified // recipient ("only Mom can open it"). State what's cryptographically true (only the link's keyholder can // open it) and nudge the sender to confirm the link's provenance for anything sensitive. const segs: Seg[] = [ { t: name ? `Send a file to “${name}”.` : "Send a secure file.", b: true }, " Drop a file anywhere, or use the buttons below. It's encrypted in your browser so only the owner of this link's key can open it. No account or app needed.", ]; if (name) segs.push(` “${name}” is just the label on this link, so if you're sending anything sensitive, confirm the link really came from them.`); await appMsg(segs); 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(); // Don't pass the link's (unverified) name as the recipient label: the post-encrypt confirmation would // otherwise read 'encrypted for ""'. Anonymous senders have no contacts, so this // falls back to the neutral "for your recipient". (`name` is still shown, framed as a claim, in sendToMode.) const opts = { sender, 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, icon: string, iconCls: string) => { const s = document.createElement("span"); s.className = "dl_action no_select"; s.innerHTML = `${icon.replace("${esc(label)}`; return s; }; const copyBtn = mk("Copy", SVG.copy, "copy_icon"); acts.appendChild(copyBtn); const shareBtn = typeof navigator.share === "function" ? mk("Share", SVG.share, "dl_icon") : null; if (shareBtn) acts.appendChild(shareBtn); const qrBtn = mk("Show QR", SVG.qr, "qr_icon"); 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); const l = copyBtn.querySelector(".lbl")!; l.textContent = "Copied!"; setTimeout(() => (l.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.querySelector(".lbl")!.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.querySelector(".lbl")!.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 { const prf = await getPrfSecret(); masterPrk = masterPrkFromPrfSecret(prf); prf.fill(0); // scrub the PRF secret as soon as master_prk is derived } catch { await appMsg(["Passkey check cancelled, so the recovery code wasn't shown."], ERR); return; } const bip39 = encodeRecoveryBip39(masterPrk); masterPrk.fill(0); // the BIP39 string now carries the code for display; drop the raw master-PRK bytes // 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 footAct = (cls: string, iconHtml: string, label: string, onClick: () => void) => { const s = document.createElement("span"); s.className = `${cls} no_select`; s.innerHTML = `${iconHtml}${esc(label)}`; s.addEventListener("click", onClick); return s; }; footer.appendChild(footAct("contacts_add", SVG.plus.replace(" openAddForm(container))); footer.appendChild(footAct("contacts_add", SVG.import.replace(" importContacts(container))); if (list.length) { footer.appendChild(footAct("contacts_add", SVG.export.replace(" exportContacts())); const clear = document.createElement("span"); clear.className = "contacts_clear_link no_select"; clear.innerHTML = `${SVG.trash.replace("Clear All`; const lbl = clear.querySelector(".lbl") as HTMLElement; let arming = false; clear.addEventListener("click", async () => { if (!arming) { arming = true; lbl.textContent = "Clear all contacts? Tap again to confirm"; setTimeout(() => { if (arming) { arming = false; lbl.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("Save${SVG.close.replace("Cancel
`; 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(); }); $("chiz_changelog").addEventListener("click", () => { close(); void showChangelog(versionManifest.releases as Releases); }); // Appearance: Light / Dark / Auto. "auto" follows the OS (prefers-color-scheme) and updates live. // Default (nothing saved) is auto, following the OS. 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") || "auto"; } catch { return "auto"; } })(); 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) ---- // 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; type Releases = Record; // Friendly date from an ISO "YYYY-MM-DD" string, parsed by hand to avoid Date()/timezone drift. function fmtReleaseDate(iso?: string): string { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso ?? ""); if (!m) return iso ?? ""; const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; return `${months[Number(m[2]) - 1] ?? ""} ${Number(m[3])}, ${m[1] ?? ""}`; } // The full changelog: every release, newest first, as a typed chat panel (the Terms/Privacy menu idiom). // Reached from the update notice's Changelog action. async function showChangelog(releases: Releases | undefined): Promise { const entries = Object.entries(releases ?? {}) .map(([v, r]) => ({ v, date: r?.date, notes: (r?.notes ?? []).filter(Boolean) })) .sort((a, b) => (b.date ?? "").localeCompare(a.date ?? "")); // reverse chronological by date const body = entries.length ? entries.map((e) => `

FileKey v${esc(e.v)}${e.date ? ` · ${esc(fmtReleaseDate(e.date))}` : ""}

` + `
    ${e.notes.map((n) => `
  • ${esc(n)}
  • `).join("")}
`, ).join("") : "No changelog available."; // Reveal without the typewriter chasing the bottom, then land on the TOP (newest release): // people open the changelog to see what's new, not to scroll to the oldest entry. const wasAuto = allowAutoScroll; allowAutoScroll = false; const msg = await appMsg([{ html: `

Changelog

${body}` }], { speed: 24 }); allowAutoScroll = wasAuto; const outer = (msg.closest(".std_outer") as HTMLElement | null) ?? msg; outer.style.scrollMarginTop = "13vh"; // sit the heading just below the fixed header outer.scrollIntoView({ behavior: REDUCED ? "auto" : "smooth", block: "start" }); } const SEEN_VERSION_KEY = "filekey-seen-version"; async function checkForUpdate() { if (updatePrompted) return; // Stroke glyphs sized inline (no fill, so they stay line art), spaced from the label — matches the // recipient-prompt Confirm/Cancel chips (the .cancel_icon 18px sizer). const chipIcon = (svg: string) => svg.replace("FileKey v${esc(version)} is available.` }], { dp: "warning_dp", icon: "warning_filekey_icon" }, ); actionRow(m, [ { label: "Update", icon: chipIcon(SVG.download), onClick: () => location.reload() }, { label: "Changelog", icon: chipIcon(SVG.doc), muted: true, onClick: () => void showChangelog(releases) }, { label: "Later", icon: chipIcon(SVG.clock), muted: true, onClick: () => m.closest(".std_outer")?.remove() }, ]); return; } // Case 2 — returning visitor who moved onto a new build: this tab IS on the latest build, but this // browser last recorded an OLDER version. Show "what's new" once, then remember it. let seen: string | null = null; try { seen = localStorage.getItem(SEEN_VERSION_KEY); } catch { /* storage blocked — skip */ } if (seen === APP_VERSION) return; // already shown the notes for this build // First-ever encounter (no record yet): bootstrap silently and show nothing. A brand-new visitor has // no previous version, so nothing is "new" to them — only a returning visitor who was on an earlier // build should see it. (Trade-off: a pre-feature returning user is indistinguishable from a new one, // so the first time we track anyone is silent; the notice kicks in on their next update.) const firstEncounter = seen === null; try { localStorage.setItem(SEEN_VERSION_KEY, APP_VERSION); } catch { /* ignore */ } if (firstEncounter) return; const bakedReleases = versionManifest.releases as Releases; if (!(bakedReleases?.[APP_VERSION]?.notes ?? []).filter(Boolean).length) return; // no notes for this build updatePrompted = true; const m = await appMsg([{ html: `What's new in FileKey v${esc(APP_VERSION)}.` }]); actionRow(m, [ { label: "Changelog", icon: chipIcon(SVG.doc), onClick: () => void showChangelog(bakedReleases) }, { label: "Got it", icon: chipIcon(SVG.close), muted: true, onClick: () => m.closest(".std_outer")?.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: extLinkDot("https://filekey.substack.com/", "Substack") }, ], { speed: 12 }); const m = await appMsg(["To start, unlock your existing passkey or create a new one."]); buttonRow(m, [ { label: "Unlock", icon: SVG.fingerprint.replace(" void loadSecKey() }, { label: "Create", ghost: true, icon: SVG.plus.replace(" void genNewPasskey() }, ]); } // ---- 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); }); void intro().finally(() => { allowAutoScroll = true; void checkForUpdate(); // Attach the visibility watcher only AFTER the intro finishes, so a visibilitychange fired during // the intro can't run checkForUpdate concurrently and interleave its notice into the intro feed // (which showed up as the "What's new" card landing mid-intro, e.g. in a fresh/incognito load). document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") 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). // // 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. 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 { // The stored blob exists but didn't decrypt/parse (corruption, or — astronomically unlikely — a // different passkey colliding on the 16-byte storage-key prefix). Don't silently discard it: the next // persist() would overwrite it for good. Stash the original once under a .bak key so it stays // recoverable, then start clean in memory. try { if (raw && !localStorage.getItem(storageKey + ".bak")) localStorage.setItem(storageKey + ".bak", raw); } catch { /* storage full/blocked → nothing more we can do */ } contacts = []; } } 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`); // Also strip Unicode bidi override/isolate controls (U+202A–202E, U+2066–2069): a sender-controlled // filename can otherwise use RLO to disguise its extension on display/save (e.g. "xgpj.exe"). const sanitize = (n: string) => (n.replace(/[\u202a-\u202e\u2066-\u2069]/g, "").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, ZipPassThrough } 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 only zipper on the encrypt path; * every multi-file/folder bundle streams through it. */ 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); }); } ============================================================================== === web/worker.ts === ============================================================================== // Off-main-thread encrypt / decrypt / zip (v1 dev review, point #1: keep the UI responsive during big // operations). The whole crypto pipeline is already async-chunked and AES runs off-thread in Chrome, so // this mainly moves the residual per-chunk JS (nonce/concat glue, the zip's CRC32, Blob assembly, GC // churn) off the main thread for large files — small files stay on the main thread (see app.ts gating). // // WebAuthn/PRF MUST stay on the main thread (no `navigator.credentials` in a Worker), so this worker // never derives identity. It receives the already-derived key material — a structured-cloned // CryptoKeyPair (CryptoKey survives postMessage) plus the 65-byte static_pk and the canonical rpId — and // rebuilds the Identity/Namespace here (both are class instances that do NOT survive structured clone). // Cancellation is the client calling worker.terminate(), so there is no in-worker cancel flag. import { encryptStream, selfEncryptStream, decryptStream, Namespace, NamespaceSet, FileKeyError, type Identity, type Metadata, type ByteSource, } from "../src/index.js"; import { zipBundleToBlob, type BundleItem } from "./bundle.js"; // `selfEncrypt: true` is the EXPLICIT self-encryption intent → the worker uses suite 0x02 (symmetric, no // KEM, post-quantum-safe) with `senderMasterPrk` and REQUIRES it (a self job missing master_prk is a loud // error, never a silent regression to suite 0x01). Without `selfEncrypt`, the recipient is someone else → // HPKE suite 0x01 (sharing). master_prk is the session root; passing it into this same-origin, same-bundle // worker is consistent with the worker already receiving `senderKeyPair` (the identity private key). // master_prk is strictly more powerful in general (it is the root that derives that keypair), but in this // single-namespace v1 app it compromises the same one identity, and there is no page↔worker trust boundary. type EncryptJob = { kind: "encrypt"; rpId: string; senderKeyPair: CryptoKeyPair; senderPk: Uint8Array; recipientPk: Uint8Array; blob: Blob; metadata: Omit; selfEncrypt?: boolean; senderMasterPrk?: Uint8Array; }; type ZipEncryptJob = { kind: "zipEncrypt"; rpId: string; senderKeyPair: CryptoKeyPair; senderPk: Uint8Array; recipientPk: Uint8Array; items: BundleItem[]; totalBytes: number; metadata: Omit; selfEncrypt?: boolean; senderMasterPrk?: Uint8Array; }; // `masterPrk` lets the worker decrypt suite 0x02 self files (it derives the file keys from the file's // salt). Absent it, only suite 0x01 (HPKE, via keyPair) decrypts. Same trust rationale as above. type DecryptJob = { kind: "decrypt"; rpId: string; rpIds: string[]; keyPair: CryptoKeyPair; staticPk: Uint8Array; file: Blob; masterPrk?: Uint8Array }; type Job = EncryptJob | ZipEncryptJob | DecryptJob; // tsconfig uses the DOM lib (not WebWorker), so `self` is typed as Window; narrow to the bits we use. const ctx = self as unknown as { onmessage: ((e: MessageEvent) => void) | null; postMessage: (m: unknown) => void }; const post = (m: unknown) => ctx.postMessage(m); const progress = (done: number, total: number) => post({ kind: "progress", done, total }); // Read a Blob as a random-access ByteSource (same contract as app.ts's blobSource; Blob.slice works in workers). 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; }, }; } async function runEncrypt(job: EncryptJob | ZipEncryptJob): Promise<{ blob: Blob; shareSource?: Blob }> { const ns = new Namespace(job.rpId); let plaintext: ByteSource; let shareSource: Blob | undefined; if (job.kind === "zipEncrypt") { // Two phases mapped onto the byte total: zip read, then encrypt read (≈ 2x bytes), same as encryptBundle. let processed = 0; const report = () => progress(Math.round(processed / 2), job.totalBytes); const zipBlob = await zipBundleToBlob(job.items, (b) => { processed = b; report(); }, () => false); shareSource = zipBlob; // returned so Share can re-encrypt the archive without re-zipping plaintext = blobSource(zipBlob, (hw) => { processed = job.totalBytes + hw; report(); }); } else { plaintext = blobSource(job.blob, (hw) => progress(hw, job.blob.size)); } // Explicit `selfEncrypt` intent → suite 0x02 (no KEM, post-quantum-safe). The master_prk guard turns a // future refactor that forgets to pass it into a LOUD failure rather than a silent regression to suite // 0x01. Otherwise the recipient is someone else → HPKE suite 0x01 (sharing). Both reuse the src/ core. let stream: AsyncGenerator; if (job.selfEncrypt) { if (!job.senderMasterPrk) throw new FileKeyError("self-encryption requires master_prk (suite 0x02)", "master_prk_missing"); stream = selfEncryptStream({ masterPrk: job.senderMasterPrk, namespace: ns, plaintext, metadata: job.metadata }); } else { stream = encryptStream({ senderIdentity: { namespace: ns, keyPair: job.senderKeyPair, staticPkRaw: job.senderPk }, recipientPkRaw: job.recipientPk, namespace: ns, plaintext, metadata: job.metadata, }); } const parts: Blob[] = []; for await (const piece of stream) parts.push(new Blob([piece as unknown as BlobPart])); return { blob: new Blob(parts, { type: "application/octet-stream" }), shareSource }; } async function runDecrypt(job: DecryptJob): Promise<{ blob: Blob; metadata: Metadata }> { // master_prk is included so suite 0x02 self files decrypt (decryptSelfStream derives the file keys from // it); suite 0x01 ignores it and uses keyPair. Absent (older callers) → only 0x01 decrypts. const identity: Identity = { namespace: new Namespace(job.rpId), keyPair: job.keyPair, staticPkRaw: job.staticPk, masterPrk: job.masterPrk }; const res = await decryptStream({ file: blobSource(job.file), namespaces: new NamespaceSet(job.rpIds), resolveIdentity: async () => identity }); const total = res.metadata.originalSize; const parts: Blob[] = []; let done = 0; for await (const pt of res.chunks) { parts.push(new Blob([pt as unknown as BlobPart])); // only assembled + returned after full authentication done += pt.length; progress(done, total); } return { blob: new Blob(parts, { type: res.metadata.mimeType || "application/octet-stream" }), metadata: res.metadata }; } ctx.onmessage = (e: MessageEvent) => { const job = e.data as Job; void (async () => { try { if (job.kind === "decrypt") { const { blob, metadata } = await runDecrypt(job); post({ kind: "done", blob, metadata }); } else { const { blob, shareSource } = await runEncrypt(job); post({ kind: "done", blob, shareSource }); } } catch (err) { // Serialize the code (empty for non-FileKeyError, e.g. a storage-quota failure) so the client can // rebuild a real FileKeyError vs a plain Error — the catches discriminate on `instanceof FileKeyError`. const code = err instanceof FileKeyError ? err.code : ""; post({ kind: "error", code, message: (err as Error)?.message ?? "worker error" }); } })(); }; ============================================================================== === 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" }, 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 ], // userVerification MUST be "required", for two reasons: // 1. PRF consistency — the CTAP2 hmac-secret returns a DIFFERENT secret for UV vs non-UV assertions // (CredRandomWithUV vs CredRandomWithoutUV). Under "preferred" a later assertion can silently skip // UV and derive a different secret → a different identity that can't open the user's files. // "required" pins every assertion to UV, so the PRF secret (and thus the identity) is stable. // 2. Theft resistance — forcing UV means a stolen authenticator alone can't decrypt without its // PIN/biometric (vs a touch-only assertion that needs only physical possession). // (residentKey:"required" already mandates a PIN on a roaming security key at enrollment, so there // are no no-PIN credentials in play either way — "required" excludes no authenticator that could // have enrolled.) authenticatorSelection: { residentKey: "required", userVerification: "required" }, 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: "required", // MUST match enrollment: PRF returns a different secret without UV (see enrollPasskey) 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"; else if (pathname.endsWith("/")) pathname += "index.html"; // Prevent path traversal. const rel = normalize(pathname).replace(/^(\.\.[/\\])+/, ""); let filePath = join(webDir, rel); if (!filePath.startsWith(webDir)) return new Response("forbidden", { status: 403 }); // Directory-index resolution (matches Vercel): an extensionless path like // /blog/guides resolves to /blog/guides/index.html, so clean URLs work locally too. if (!(await Bun.file(filePath).exists()) && !/\.[a-z0-9]+$/i.test(rel)) { const alt = join(filePath, "index.html"); if (alt.startsWith(webDir) && (await Bun.file(alt).exists())) filePath = alt; } 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-v6"; // /dist/worker.js is precached so large-file (>=64MB) encrypt/decrypt works offline on first run, not // only after a prior online large-file op network-cached it. const SHELL = ["/", "/index.html", "/dist/app.js", "/dist/worker.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) => { // Only cache a complete, successful response. Caching a 404/500 would poison the offline shell // with an error page; status===200 also skips 206 partials (range requests for the font/media). if (resp.ok && resp.status === 200) { const copy = resp.clone(); caches.open(CACHE).then((c) => c.put(req, copy)); } return resp; }) .catch(() => caches.match(req).then((cached) => { if (cached) return cached; // Offline and uncached: substitute the app shell only for an app navigation (the SPA entry point). // The blog and policy pages are their own documents, not the app shell, so don't pass index.html // off as them. For a missed sub-resource, return a real 504 rather than the wrong page. const path = new URL(req.url).pathname; const isAppRoute = !/^\/(blog|privacy|terms|license)(\/|$)/.test(path); if (req.mode === "navigate" && isAppRoute) return caches.match("/index.html"); return new Response("offline", { status: 504, statusText: "Offline" }); }), ), ); }); ============================================================================== === 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, selfEncrypt, selfEncryptStream } from "./cipher.js"; export type { EncryptInput, DecryptInput, DecryptResult, EncryptStreamInput, DecryptStreamInput, DecryptStreamResult, SelfEncryptInput, SelfEncryptStreamInput, } from "./cipher.js"; import { Identity } from "./identity.js"; import { Metadata } from "./metadata.js"; import { NamespaceSet, FileKeyError } from "./namespace.js"; import { decodeShareKey } from "./sharekey.js"; import { encrypt, selfEncrypt } 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). Uses suite 0x02 (symmetric, no KEM): file keys are derived straight * from the identity's master_prk, so the file carries no public key and is post-quantum-safe (only * AES-256 remains, which Grover cannot meaningfully dent). Existing suite 0x01 self-encrypted files * still decrypt unchanged — {@link decrypt} dispatches on the header's suite byte. */ export async function encryptToSelf(input: EncryptToSelfInput): Promise { const masterPrk = input.identity.masterPrk; if (!masterPrk) { throw new FileKeyError("identity is missing master_prk required for self-encryption", "master_prk_missing"); } return selfEncrypt({ masterPrk, 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; // HPKE Auth, DHKEM(P-256)+AES-256-GCM-STREAM (§3) export const SUITE_SELF = 0x02; // symmetric self-encryption — no KEM, post-quantum-safe (§5.6, §6.5) 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 SELF_SALT_LEN = 32; // per-file salt for suite 0x02 self-encryption (§5.6) 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_SELF_PAYLOAD_KEY = "FILEKEY-v1/self-payload-key"; // HKDF-Expand info, suite 0x02 (§6.5) export const LABEL_SELF_METADATA_KEY = "FILEKEY-v1/self-metadata-key"; // HKDF-Expand info, suite 0x02 (§6.5) 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; /** * Retained master_prk (§4.2 RECOMMENDS caching it in the session). Populated by {@link deriveIdentity}; * REQUIRED to encrypt/decrypt suite 0x02 symmetric self-encryption (which derives file keys straight * from it — no KEM). Optional because a minimally-reconstructed identity (e.g. one rebuilt inside a * Web Worker from a postMessage'd keypair for large-file HPKE ops) may not carry it; such an identity * can still do suite 0x01 but the self path throws a clear "master_prk_missing" error. It is a * session-lifetime copy held alongside `keyPair`; in v1 it compromises the same single identity as that * keypair, though master_prk is strictly the more powerful root in general (it derives the keypair). */ readonly masterPrk?: 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" }; // Private key imported NON-extractable: nothing exports it, and FileKey hands @hpke the full key PAIR // (cipher.ts createSenderContext/createRecipientContext), so HPKE uses the provided public key and never // reconstructs it from the private one via exportKey("jwk") — the path that fails for some identities when // the key isn't extractable. This removes a one-shot raw-key exfiltration route for any code that runs in // the page. The PUBLIC key stays extractable (suite.kem.serializePublicKey reads its bytes). Verified on // Bun: 71/71 + KAT vectors byte-identical. NOTE: WebKit's JWK import is finicky, so re-confirm Safari // round-trips if this or the deriveKeyPair path above ever changes. 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, false, ["deriveBits"], ); const publicKey = await crypto.subtle.importKey("raw", toArrayBuffer(pub), alg, true, []); // Best-effort scrub of the raw private scalar + KEM PRK. JS can't guarantee wiping (and the JWK `d` // string handed to importKey is an immutable copy we can't reach), but this clears the obvious live copies. sk.fill(0); dkpPrk.fill(0); 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); identityIkm.fill(0); // scrub the KEM seed once the keypair is derived (best-effort) 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"); } // Retain an independent copy of master_prk on the identity (§4.2) so suite 0x02 self-encryption can // derive file keys without a fresh PRF gesture. `.slice()` survives the caller's scrub of its own copy. return { namespace, keyPair, staticPkRaw, masterPrk: masterPrk.slice() }; } /** Convenience: PRF output → full identity for a namespace. */ export async function deriveIdentityFromPrf(prfSecret: Uint8Array, namespace: Namespace): Promise { const masterPrk = masterPrkFromPrfSecret(prfSecret); try { return await deriveIdentity(masterPrk, namespace); } finally { masterPrk.fill(0); // scrub the derived master PRK; the caller owns (and scrubs) prfSecret } } 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 { sha256 } from "@noble/hashes/sha2.js"; import { expand } from "@noble/hashes/hkdf.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, SUITE_ID, SUITE_SELF, SELF_SALT_LEN, LABEL_SELF_PAYLOAD_KEY, LABEL_SELF_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, buildSelfAad, 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, // full keyPair, not the bare privateKey: @hpke then uses our public key directly and never reconstructs it via exportKey, so the private key can be non-extractable (identity.ts) ...(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); } // ---------------------------------------------------------------------------- // Self-encryption — suite 0x02 (§5.6, §6.5) // // Self-encryption needs no asymmetric crypto: the user already holds master_prk, so the file keys come // straight from it via HKDF — no KEM, no sender_pk, no hpke_enc. A suite-0x02 file therefore carries no // public key and nothing for Shor to attack, making it post-quantum-safe (only AES-256 remains, which // Grover merely halves to ~128-bit). STREAM, 12-byte nonces, the all-zero metadata nonce, and the // truncation/size checks are all identical to suite 0x01. // ---------------------------------------------------------------------------- export interface SelfEncryptInput { /** The encrypting identity's retained master_prk (Identity.masterPrk). */ masterPrk: Uint8Array; /** The file's namespace (provides the tag for the header and rp_id for key derivation). */ namespace: Namespace; plaintext: Uint8Array; metadata: Omit; } /** Streaming variant of {@link SelfEncryptInput}: plaintext is read incrementally from a ByteSource. */ export interface SelfEncryptStreamInput { masterPrk: Uint8Array; namespace: Namespace; plaintext: ByteSource; metadata: Omit; } function randomSalt(n: number): Uint8Array { const s = new Uint8Array(n); crypto.getRandomValues(s); // n = 32 ≤ 65536, within the spec-strict per-call cap return s; } /** * Suite 0x02 file keys (§6.5): * k = HKDF-Expand(master_prk, label || I2OSP(len(rp_id),1) || rp_id || file_salt, 32) * master_prk is already an HKDF PRK (§4.2), so Expand (not Extract+Expand) is correct — this uses * @noble's expand directly, matching identity.ts. rp_id is length-prefixed so the info is unambiguous; * file_salt makes the keys per-file unique (master_prk is constant and STREAM nonces are deterministic * counters, so without it two files would reuse a (key, nonce) pair — catastrophic AES-GCM failure). */ async function deriveSelfKeys( masterPrk: Uint8Array, rpIdBytes: Uint8Array, fileSalt: Uint8Array, ): Promise<{ payloadKey: CryptoKey; metadataKey: CryptoKey }> { if (!masterPrk || masterPrk.length !== 32) throw new FileKeyError("master_prk must be 32 bytes for self-encryption", "master_prk_length"); if (rpIdBytes.length > 255) throw new FileKeyError("rp_id too long for u8 length prefix", "rpid_length"); if (fileSalt.length !== SELF_SALT_LEN) throw new FileKeyError(`file_salt must be ${SELF_SALT_LEN} bytes`, "self_salt_length"); const lenPrefix = new Uint8Array([rpIdBytes.length]); const pInfo = concat(ascii(LABEL_SELF_PAYLOAD_KEY), lenPrefix, rpIdBytes, fileSalt); const mInfo = concat(ascii(LABEL_SELF_METADATA_KEY), lenPrefix, rpIdBytes, fileSalt); const pRaw = expand(sha256, masterPrk, pInfo, 32); const mRaw = expand(sha256, masterPrk, mInfo, 32); const payloadKey = await importAesKey(toArrayBuffer(pRaw)); const metadataKey = await importAesKey(toArrayBuffer(mRaw)); pRaw.fill(0); // scrub the raw key bytes; WebCrypto holds its own copy after import (best-effort) mRaw.fill(0); return { payloadKey, metadataKey }; } /** Core streaming self-encryption (§6.5). Yields header‖file_salt‖u32(metaCtLen)‖metaCt, then payload chunks. */ async function* selfSealStream(input: SelfEncryptStreamInput, saltForTest?: Uint8Array): AsyncGenerator { const { masterPrk, namespace } = input; const M = input.plaintext.size; const header = buildHeader(namespace.tag, SUITE_SELF); const fileSalt = saltForTest ?? randomSalt(SELF_SALT_LEN); const { payloadKey, metadataKey } = await deriveSelfKeys(masterPrk, namespace.rpIdBytes, fileSalt); const aad = buildSelfAad(header, fileSalt); const fullMeta: Metadata = { ...input.metadata, originalSize: M }; const metaCt = await aesSeal(metadataKey, METADATA_NONCE, aad, encodeMetadata(fullMeta)); if (metaCt.length > METADATA_CT_MAX || metaCt.length < METADATA_CT_MIN) { throw new FileKeyError("metadata ciphertext length out of bounds", "metadata_ct_bounds"); } yield concat(header, fileSalt, u32be(metaCt.length), metaCt); 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); 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 self-encryption (§6.5). Byte-identical to {@link selfEncrypt}. */ export function selfEncryptStream(input: SelfEncryptStreamInput): AsyncGenerator { return selfSealStream(input); } /** Core self-encryption (§6.5). Returns the complete .filekey file bytes (suite 0x02). */ export async function selfEncrypt(input: SelfEncryptInput): Promise { return collect(selfSealStream(toSelfStreamInput(input))); } /** * TEST ONLY: self-encrypt with a caller-supplied file_salt for reproducible vectors. NOT re-exported * from index.ts. Reusing a salt for the same master_prk repeats every nonce — catastrophic GCM reuse. */ export async function selfEncryptWithSaltForTest(input: SelfEncryptInput, fileSalt: Uint8Array): Promise { return collect(selfSealStream(toSelfStreamInput(input), fileSalt)); } function toSelfStreamInput(input: SelfEncryptInput): SelfEncryptStreamInput { return { masterPrk: input.masterPrk, namespace: input.namespace, plaintext: bytesSource(input.plaintext), metadata: input.metadata, }; } // ---------------------------------------------------------------------------- // Decryption (§7.2) // ---------------------------------------------------------------------------- export interface DecryptResult { metadata: Metadata; plaintext: Uint8Array; /** * The sender's static_pk (65-byte uncompressed) — for application identity resolution (§7.3). * For suite 0x02 self-encrypted files there is no sender field in the file; this reports the * decrypting identity's own static_pk (a self-encrypted file's "sender" is, by construction, you), * but note suite 0x02 provides NO cryptographic sender authentication — `suiteId` distinguishes it. */ senderPkRaw: Uint8Array; namespace: Namespace; /** True when sender_pk == the recipient's static_pk (self-encrypted file); always true for suite 0x02. */ selfEncrypted: boolean; /** Which suite decrypted the file: SUITE_ID (0x01, HPKE) or SUITE_SELF (0x02, symmetric self). */ suiteId: number; } 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; /** Which suite decrypted the file: SUITE_ID (0x01, HPKE) or SUITE_SELF (0x02, symmetric self). */ suiteId: number; /** * 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: read + parse the 12-byte header, then dispatch by suite. const headerBytes = await source.slice(0, HEADER_LEN); if (headerBytes.length < HEADER_LEN) throw new FileKeyError("unexpected end of input (truncated header)", "truncated"); const header = headerBytes.subarray(0, HEADER_LEN).slice(); const { suiteId, 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"); } // Suite 0x02: symmetric self-encryption (§6.5). Different body grammar (no sender_pk/hpke_enc), so // we dispatch here, before reading any HPKE-specific field. if (suiteId === SUITE_SELF) { return decryptSelfStream(source, header, namespace, input.resolveIdentity); } // Suite 0x01: HPKE Auth. Read the rest of the fixed head (sender_pk, hpke_enc, metadata_ct_len). 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); r.take(HEADER_LEN); // header already parsed above // §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, // full keyPair (see sealStream): lets the private key be non-extractable 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, suiteId: SUITE_ID, chunks: openStream(source, payloadStart, payloadKey, aad, metadata.originalSize), }; } /** * Suite 0x02 streaming decryption (§6.5). Body grammar: header(12) ‖ file_salt(32) ‖ u32(metaCtLen) ‖ * metaCt ‖ payload chunks. Keys come from the resolved identity's master_prk — no HPKE, no sender_pk. */ async function decryptSelfStream( source: ByteSource, header: Uint8Array, namespace: Namespace, resolveIdentity: (namespace: Namespace) => Promise, ): Promise { const headFixed = HEADER_LEN + SELF_SALT_LEN + 4; const head = await source.slice(0, headFixed); const r = new Reader(head); r.take(HEADER_LEN); // header already parsed by the caller const fileSalt = r.take(SELF_SALT_LEN).slice(); // Resolve the recipient identity (may trigger WebAuthn). Suite 0x02 needs its retained master_prk. const identity = await resolveIdentity(namespace); if (identity.namespace.canonicalRpId !== namespace.canonicalRpId) { throw new FileKeyError("resolved identity is not in the file's namespace", "identity_namespace_mismatch"); } if (!identity.masterPrk || identity.masterPrk.length !== 32) { throw new FileKeyError("resolved identity has no master_prk; suite 0x02 decryption requires it", "master_prk_missing"); } const { payloadKey, metadataKey } = await deriveSelfKeys(identity.masterPrk, namespace.rpIdBytes, fileSalt); const aad = buildSelfAad(header, fileSalt); 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; return { metadata, // A self-encrypted file's "sender" is, by construction, the decrypting identity. There is no // sender field in the file and no sender authentication (see DecryptResult.senderPkRaw / suiteId). senderPkRaw: identity.staticPkRaw, namespace, selfEncrypted: true, suiteId: SUITE_SELF, 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, suiteId: res.suiteId, }; } ============================================================================== === 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, SUITE_SELF, 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, suiteId: number = SUITE_ID): 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] = suiteId; header[6] = 0x00; // flags header[7] = 0x00; // reserved header.set(namespaceTag, 8); return header; } export interface ParsedHeader { /** Suite identifier: SUITE_ID (0x01, HPKE) or SUITE_SELF (0x02, symmetric self-encryption). */ suiteId: number; 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"); const suiteId = header[5]!; if (suiteId !== SUITE_ID && suiteId !== SUITE_SELF) { throw new FileKeyError(`unsupported suite_id 0x${suiteId.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 { suiteId, 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); } /** AAD for suite 0x02 symmetric self-encryption (§5.6): header||file_salt. Distinct from {@link buildAad}; * the HPKE 142-byte invariant does not apply because suite 0x02 carries no sender_pk/hpke_enc. */ export function buildSelfAad(header: Uint8Array, fileSalt: Uint8Array): Uint8Array { return concat(header, fileSalt); } /** 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])); }