@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
JavaScript
//#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 };