Files
plainleaf/client/spaces_ui/encryption.ts
T

128 lines
4.2 KiB
TypeScript

/**
* Client-encryption key handling for a space's login page.
*
* The key is derived in the browser from the credentials the user just typed
* and handed to the service worker, which keeps it in memory only (see
* `client/service_worker.ts`). It is deliberately never persisted: what
* survives a reload is the `enableEncryption` flag, and the key itself is
* re-fetched from whichever service worker still holds it (`boot.ts`'s
* `findEncryptionKey`). Logging in again is what re-derives it.
*/
export function base64Encode(buffer: Uint8Array): string {
let binary = "";
for (let i = 0; i < buffer.byteLength; i++) {
binary += String.fromCharCode(buffer[i]);
}
return btoa(binary);
}
export function base64Decode(value: string): Uint8Array<ArrayBuffer> {
const binary = atob(value);
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/** PBKDF2(`username:password`, salt) → a raw AES-CTR key, base64-encoded. */
export async function deriveEncryptionKey(
phrase: string,
salt: Uint8Array<ArrayBuffer>,
): Promise<string> {
const baseKey = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(phrase),
{ name: "PBKDF2" },
false,
["deriveBits", "deriveKey"],
);
const ctrKey = await crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" },
baseKey,
{ name: "AES-CTR", length: 256 },
true,
["encrypt", "decrypt"],
);
return base64Encode(
new Uint8Array(await crypto.subtle.exportKey("raw", ctrKey)),
);
}
/** Resolve `promise`, or `undefined` if it takes longer than `ms`. */
function withTimeout<T>(
promise: Promise<T>,
ms: number,
): Promise<T | undefined> {
return Promise.race([
promise,
new Promise<undefined>((resolve) =>
setTimeout(() => resolve(undefined), ms),
),
]);
}
/**
* Post the key to one worker and wait for it to say it has stored it.
*
* The acknowledgement is the point: the caller navigates away the moment this
* resolves, and the worker stores the key asynchronously (it has to import it
* first). A bare postMessage lets the navigation win, and the editor then
* boots to "no key" and bounces back to the login page.
*/
function deliverKey(
registration: ServiceWorkerRegistration,
key: string,
ms: number,
): Promise<boolean> {
const worker = registration.active;
if (!worker) return Promise.resolve(false);
return new Promise((resolve) => {
const channel = new MessageChannel();
const settle = (delivered: boolean) => {
clearTimeout(timer);
channel.port1.close();
resolve(delivered);
};
const timer = setTimeout(() => settle(false), ms);
channel.port1.onmessage = () => settle(true);
worker.postMessage({ type: "set-encryption-key", key }, [channel.port2]);
});
}
/**
* Hand the key to the service worker(s) that will need it, and report whether
* the one serving this space actually took it.
*
* On an account-managed server one login covers every space, so the key goes
* to every registration — a space opened later picks it up from whichever
* worker still holds it. Only this space's own worker gates the result: a
* sibling scope we are not about to open must not hold up the login.
*/
export async function publishEncryptionKey(
key: string,
accountManaged: boolean,
ms = 10_000,
): Promise<boolean> {
if (!navigator.serviceWorker) return false;
// `ready` rather than `getRegistration()`: the login page registers its
// worker on mount, so a quick submit — a password manager, or a test — can
// arrive while that registration is still installing and its `active` is
// still null. Posting to null silently does nothing, and the old code
// reported success regardless.
const own = await withTimeout(navigator.serviceWorker.ready, ms);
if (!own) return false;
if (accountManaged) {
const all = await navigator.serviceWorker.getRegistrations();
await Promise.all(
all
.filter((registration) => registration !== own)
.map((registration) => deliverKey(registration, key, ms)),
);
}
return await deliverKey(own, key, ms);
}