UNPKG

@stainless-code/persist

Version:

Hydration-aware persistence for any reactive store — zero-dep persistSource core; codecs, backends, cross-tab transport, source + framework hydration adapters ship as opt-in recipes

85 lines (84 loc) • 3.27 kB
//#region src/adapters/backends/encrypted.ts /** * AES-GCM encryption over a string-wire `StateStorage` (WebCrypto). Each * stored value is `base64(iv).base64(ciphertext)` (12-byte IV prepended); the * AES-GCM auth tag means a wrong key or tampered ciphertext throws on decrypt. * That throw surfaces in the backend's async `getItem` → persist-core reports * it via `onError` phase `"hydrate"` (NOT the corrupt-payload self-heal — * `clearCorruptOnFailure` only fires when the *codec* throws parsing a * non-corrupt raw, not when the *backend* rejects reading it). * * A backend **wrapper**, not a sync `StorageCodec`, because `crypto.subtle` * is async — the codec serializes (sync), this encrypts the string (async). * Compose: `createStorage(() => createEncryptedStorage(backend, { key }), codec)`. * Returns `undefined` when `crypto.subtle` is unavailable. * * @example * ```ts * import { createStorage } from "@stainless-code/persist"; * import { createEncryptedStorage } from "@stainless-code/persist/backends/encrypted"; * import { serovalCodec } from "@stainless-code/persist/codecs/seroval"; * import { persistStore } from "@stainless-code/persist/sources/tanstack-store"; * * const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]); * const storage = createStorage<Prefs>( * () => createEncryptedStorage(() => localStorage, { key })!, * serovalCodec(), * { clearCorruptOnFailure: true }, * ); * persistStore(store, { name: "app:prefs:v1", storage }); * ``` */ function createEncryptedStorage(getStorage, options) { if (typeof crypto === "undefined" || !crypto?.subtle) return; let backend; try { backend = getStorage(); } catch { return; } if (typeof backend.getItem !== "function" || typeof backend.setItem !== "function" || typeof backend.removeItem !== "function") return; return { getItem: async (name) => { const raw = await backend.getItem(name); if (raw == null) return null; return decryptAesGcm(raw, options.key); }, setItem: async (name, value) => { const ciphertext = await encryptAesGcm(value, options.key); await backend.setItem(name, ciphertext); }, removeItem: (name) => backend.removeItem(name) }; } async function encryptAesGcm(plaintext, key) { const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12)); const encoded = new TextEncoder().encode(plaintext); const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded)); return `${toBase64(iv)}.${toBase64(ciphertext)}`; } async function decryptAesGcm(payload, key) { const [ivB64, ctB64] = payload.split("."); if (!ivB64 || !ctB64) throw new Error("invalid ciphertext payload"); const iv = fromBase64(ivB64); const ciphertext = fromBase64(ctB64); const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); return new TextDecoder().decode(plain); } function toBase64(bytes) { return btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join("")); } function fromBase64(s) { const bin = atob(s); const bytes = new Uint8Array(new ArrayBuffer(bin.length)); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes; } //#endregion export { createEncryptedStorage };