Repository: unjs/h3
Pull request #837 (2024-07-21 12:55:15)

perf: improve session crypto
Add a forked/inlined version of `iron-webcrypto` to allow better treeshaking and reduce external deps. 

i have added some additional internal cleanups as well including making sure all options are immutable. base64 utils replaced with cleaner implementation from [deno std lib](https://deno.land/std@0.224.0/encoding/base64url.ts?s=encodeBase64Url) (with more cleanups on top!)



uncrypto is still needed as a dep sadly because of Node.js 18 lacking global web crypto.

Session io (with ~1024 bytes of data) is up to 9x faster on node 18, ~%10 on node 22 and %15-30 faster on bun. 






Patch:
diff --git a/package.json b/package.json
index 85d6de2..e946f67 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,6 @@
   },
   "dependencies": {
     "cookie-es": "^1.2.1",
-    "iron-webcrypto": "^1.2.1",
     "rou3": "^0.4.0",
     "uncrypto": "^0.1.3"
   },
@@ -63,7 +62,7 @@
     "express": "^4.19.2",
     "get-port": "^7.1.0",
     "get-port-please": "^3.1.2",
-    "h3-nightly": "npm:h3-nightly@2.0.0-1720699896.4a34c89",
+    "h3-nightly": "npm:h3-nightly@2.0.0-1721562757.0ea50fe",
     "h3-v1": "npm:h3@^1.12.0",
     "jiti": "2.0.0-beta.3",
     "listhen": "^1.7.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 310487e..2d761f0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,9 +14,6 @@ importers:
       crossws:
         specifier: ^0.2.4
         version: 0.2.4
-      iron-webcrypto:
-        specifier: ^1.2.1
-        version: 1.2.1
       rou3:
         specifier: ^0.4.0
         version: 0.4.0
@@ -76,8 +73,8 @@ importers:
         specifier: ^3.1.2
         version: 3.1.2
       h3-nightly:
-        specifier: npm:h3-nightly@2.0.0-1720699896.4a34c89
-        version: 2.0.0-1720699896.4a34c89(crossws@0.2.4)
+        specifier: npm:h3-nightly@2.0.0-1721562757.0ea50fe
+        version: 2.0.0-1721562757.0ea50fe(crossws@0.2.4)
       h3-v1:
         specifier: npm:h3@^1.12.0
         version: h3@1.12.0
@@ -1738,8 +1735,8 @@ packages:
   graphemer@1.4.0:
     resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
 
-  h3-nightly@2.0.0-1720699896.4a34c89:
-    resolution: {integrity: sha512-jFFdQYnL0SXRvbEOvhmB0GF7phHbkoWoUYyFmupCp3DNAPhXgjKtJDLHnfQ2NY0e3ATJq+We0FA8xA+OdA6nEw==}
+  h3-nightly@2.0.0-1721562757.0ea50fe:
+    resolution: {integrity: sha512-JBD369YWwFeYcKkWJJxip3Gafrnk8W0Hl+7JFXb7g3fxrelLI680YVceB2i6g4n62V1OdQbgV1C3WpNmOwftBg==}
     peerDependencies:
       crossws: ^0.2.4
     peerDependenciesMeta:
@@ -4916,13 +4913,11 @@ snapshots:
 
   graphemer@1.4.0: {}
 
-  h3-nightly@2.0.0-1720699896.4a34c89(crossws@0.2.4):
+  h3-nightly@2.0.0-1721562757.0ea50fe(crossws@0.2.4):
     dependencies:
       cookie-es: 1.2.1
       iron-webcrypto: 1.2.1
-      ohash: 1.1.3
       rou3: 0.4.0
-      ufo: 1.5.4
       uncrypto: 0.1.3
     optionalDependencies:
       crossws: 0.2.4
diff --git a/src/types/utils/session.ts b/src/types/utils/session.ts
index 3d63b8b..f7e17bf 100644
--- a/src/types/utils/session.ts
+++ b/src/types/utils/session.ts
@@ -1,5 +1,5 @@
 import type { CookieSerializeOptions } from "cookie-es";
-import type { SealOptions } from "iron-webcrypto";
+import type { SealOptions } from "../../utils/internal/iron-crypto";
 import type { kGetSession } from "../../utils/internal/session";
 
 type SessionDataT = Record<string, any>;
diff --git a/src/utils/fingerprint.ts b/src/utils/fingerprint.ts
index aecfd26..3efc0a5 100644
--- a/src/utils/fingerprint.ts
+++ b/src/utils/fingerprint.ts
@@ -1,5 +1,5 @@
 import type { H3Event, RequestFingerprintOptions } from "../types";
-import crypto from "uncrypto";
+import crypto from "uncrypto"; // Node.js 18 support
 import { getRequestIP } from "./request";
 
 /**
diff --git a/src/utils/internal/iron-crypto.ts b/src/utils/internal/iron-crypto.ts
new file mode 100644
index 0000000..8ec7c63
--- /dev/null
+++ b/src/utils/internal/iron-crypto.ts
@@ -0,0 +1,532 @@
+/**
+Based on https://github.com/brc-dd/iron-webcrypto/tree/v1.2.1
+Copyright (c) 2021 Divyansh Singh.
+https://github.com/brc-dd/iron-webcrypto/blob/v1.2.1/LICENSE.md
+
+Based on https://github.com/hapijs/iron/tree/v7.0.1
+Copyright (c) 2012-2022, Project contributors Copyright (c) 2012-2020, Sideway Inc All rights reserved.
+https://github.com/hapijs/iron/blob/v7.0.1/LICENSE.md
+
+Base64 encoding based on https://github.com/denoland/std/tree/main/encoding (modified for url compatibility)
+Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
+https://github.com/denoland/std/blob/main/LICENSE
+ */
+
+import crypto from "uncrypto"; // Node.js 18 support
+
+/** The default encryption and integrity settings. */
+export const defaults: Readonly<SealOptions> = /* @__PURE__ */ Object.freeze({
+  ttl: 0,
+  timestampSkewSec: 60,
+  localtimeOffsetMsec: 0,
+  encryption: /* @__PURE__ */ Object.freeze({
+    saltBits: 256,
+    algorithm: "aes-256-cbc",
+    iterations: 1,
+    minPasswordlength: 32,
+  }),
+  integrity: /* @__PURE__ */ Object.freeze({
+    saltBits: 256,
+    algorithm: "sha256",
+    iterations: 1,
+    minPasswordlength: 32,
+  }),
+});
+
+/** Configuration of each supported algorithm. */
+export const algorithms = /* @__PURE__ */ Object.freeze({
+  "aes-128-ctr": /* @__PURE__ */ Object.freeze({
+    keyBits: 128,
+    ivBits: 128,
+    name: "AES-CTR",
+  }),
+  "aes-256-cbc": /* @__PURE__ */ Object.freeze({
+    keyBits: 256,
+    ivBits: 128,
+    name: "AES-CBC",
+  }),
+  sha256: /* @__PURE__ */ Object.freeze({
+    keyBits: 256,
+    ivBits: 128,
+    name: "SHA-256",
+  }),
+});
+
+/** MAC normalization format version. */
+export const macFormatVersion = "2";
+
+/** MAC normalization prefix. */
+export const macPrefix = "Fe26.2"; // `Fe26.${macFormatVersion}`
+
+/** Serializes, encrypts, and signs objects into an iron protocol string. */
+export async function seal(
+  object: Readonly<unknown>,
+  password: Readonly<RawPassword>,
+  opts: Readonly<SealOptions>,
+): Promise<string> {
+  const now = Date.now() + (opts.localtimeOffsetMsec || 0);
+
+  if (!password) {
+    throw new Error("Empty password");
+  }
+  const { id = "", encryption, integrity } = normalizePassword(password);
+  if (id && !/^\w+$/.test(id)) {
+    throw new Error("Invalid password id");
+  }
+
+  // prettier-ignore
+  const { encrypted, key } = await encrypt(encryption, opts.encryption, JSON.stringify(object));
+
+  const encryptedB64 = base64Encode(encrypted);
+  const iv = base64Encode(key.iv);
+  const expiration = opts.ttl ? now + opts.ttl : "";
+  const macBaseString = `${macPrefix}*${id}*${key.salt}*${iv}*${encryptedB64}*${expiration}`;
+
+  const mac = await hmacWithPassword(integrity, opts.integrity, macBaseString);
+  const sealed = `${macBaseString}*${mac.salt}*${mac.digest}`;
+  return sealed;
+}
+
+/** Verifies, decrypts, and reconstruct an iron protocol string into an object. */
+export async function unseal(
+  sealed: string,
+  password: Password | PasswordHash,
+  opts: Readonly<SealOptions>,
+): Promise<unknown> {
+  const now = Date.now() + (opts.localtimeOffsetMsec || 0);
+
+  if (!password) {
+    throw new Error("Empty password");
+  }
+
+  const parts = sealed.split("*");
+  if (parts.length !== 8) {
+    throw new Error("Incorrect number of sealed components");
+  }
+  // prettier-ignore
+  const [prefix, passwordId, encryptionSalt, encryptionIv, encryptedB64, expiration, hmacSalt, hmac] = parts;
+  const macBaseString = `${prefix}*${passwordId}*${encryptionSalt}*${encryptionIv}*${encryptedB64}*${expiration}`;
+
+  if (macPrefix !== prefix) {
+    throw new Error("Wrong mac prefix");
+  }
+
+  if (expiration) {
+    if (!/^\d+$/.test(expiration)) {
+      throw new Error("Invalid expiration");
+    }
+    const exp = Number.parseInt(expiration, 10);
+    if (exp <= now - opts.timestampSkewSec * 1000) {
+      throw new Error("Expired seal");
+    }
+  }
+
+  let pass: RawPassword = "";
+  const _passwordId = passwordId || "default";
+  if (typeof password === "string" || password instanceof Uint8Array) {
+    pass = password;
+  } else if (_passwordId in password) {
+    pass = password[_passwordId]!;
+  } else {
+    throw new Error(`Cannot find password: ${_passwordId}`);
+  }
+
+  pass = normalizePassword(pass);
+
+  // prettier-ignore
+  const mac = await hmacWithPassword(pass.integrity, { ...opts.integrity, salt: hmacSalt }, macBaseString);
+
+  if (!fixedTimeComparison(mac.digest, hmac)) {
+    throw new Error("Bad hmac value");
+  }
+
+  const encrypted = base64Decode(encryptedB64);
+
+  const decryptOptions: GenerateKeyOptions<EncryptionAlgorithm> = {
+    ...opts.encryption,
+    salt: encryptionSalt,
+    iv: base64Decode(encryptionIv),
+  };
+
+  const decrypted = await decrypt(pass.encryption, decryptOptions, encrypted);
+
+  return decrypted ? JSON.parse(decrypted) : null;
+}
+
+// --- hmac ---
+
+/** Calculates a HMAC digest. */
+export async function hmacWithPassword(
+  password: Readonly<Password>,
+  options: Readonly<GenerateKeyOptions<IntegrityAlgorithm>>,
+  data: string,
+): Promise<HMacResult> {
+  const key = await generateKey(password, { ...options, hmac: true });
+  const textBuffer = stringToUint8Array(data);
+  // prettier-ignore
+  const signed = await crypto.subtle.sign({ name: "HMAC" }, key.key, textBuffer);
+  const digest = base64Encode(new Uint8Array(signed));
+  return { digest, salt: key.salt };
+}
+
+// --- key generation ---
+
+/** Generates a key from the password. */
+export async function generateKey(
+  password: Password,
+  options: GenerateKeyOptions,
+): Promise<Key> {
+  if (!password?.length) {
+    throw new Error("Empty password");
+  }
+
+  if (options == null || typeof options !== "object")
+    throw new Error("Bad options");
+  if (!(options.algorithm in algorithms))
+    throw new Error(`Unknown algorithm: ${options.algorithm}`);
+
+  const algorithm = algorithms[options.algorithm];
+
+  let resultKey: Key["key"];
+  let resultSalt: Key["salt"];
+  let resultIV: Key["iv"];
+
+  const hmac = options.hmac ?? false;
+  // prettier-ignore
+  const id = hmac ? { name: "HMAC", hash: algorithm.name } : { name: algorithm.name };
+  const usage: KeyUsage[] = hmac ? ["sign", "verify"] : ["encrypt", "decrypt"];
+
+  if (typeof password === "string") {
+    if (password.length < options.minPasswordlength) {
+      throw new Error(
+        `Password string too short (min ${options.minPasswordlength} characters required)`,
+      );
+    }
+    let { salt = "" } = options;
+    if (!salt) {
+      const { saltBits = 0 } = options;
+      if (!saltBits) throw new Error("Missing salt and saltBits options");
+      const randomSalt = randomBits(saltBits);
+      salt = [...new Uint8Array(randomSalt)]
+        .map((x) => x.toString(16).padStart(2, "0"))
+        .join("");
+    }
+
+    // prettier-ignore
+    const derivedKey = await pbkdf2(password, salt, options.iterations, algorithm.keyBits / 8, "SHA-1");
+    // prettier-ignore
+    const importedEncryptionKey = await crypto.subtle.importKey("raw", derivedKey, id, false, usage);
+    resultKey = importedEncryptionKey;
+    resultSalt = salt;
+  } else {
+    if (password.length < algorithm.keyBits / 8) {
+      throw new Error("Key buffer (password) too small");
+    }
+    // prettier-ignore
+    resultKey = await crypto.subtle.importKey("raw", password, id, false, usage);
+    resultSalt = "";
+  }
+
+  if (options.iv) {
+    resultIV = options.iv;
+  } else if ("ivBits" in algorithm) {
+    resultIV = randomBits(algorithm.ivBits);
+  } else {
+    throw new Error("Missing IV");
+  }
+
+  return <Key>{
+    key: resultKey,
+    salt: resultSalt,
+    iv: resultIV,
+  };
+}
+
+/** Provides an asynchronous Password-Based Key Derivation Function 2 (PBKDF2) implementation. */
+async function pbkdf2(
+  password: string,
+  salt: string,
+  iterations: number,
+  keyLength: number,
+  hash: HashAlgorithmIdentifier,
+): Promise<ArrayBuffer> {
+  const passwordBuffer = stringToUint8Array(password);
+  // prettier-ignore
+  const importedKey = await crypto.subtle.importKey("raw", passwordBuffer, { name: "PBKDF2" }, false, ["deriveBits"]);
+  const saltBuffer = stringToUint8Array(salt);
+  const params = { name: "PBKDF2", hash, salt: saltBuffer, iterations };
+  // prettier-ignore
+  const derivation = await crypto.subtle.deriveBits(params, importedKey, keyLength * 8);
+  return derivation;
+}
+
+// --- encrypt/decrypt ---
+
+export async function encrypt(
+  password: Password,
+  options: GenerateKeyOptions<EncryptionAlgorithm>,
+  data: string,
+): Promise<{ encrypted: Uint8Array; key: Key }> {
+  const key = await generateKey(password, options);
+  const encrypted = await crypto.subtle.encrypt(
+    ...getEncryptParams(options.algorithm, key, data),
+  );
+  return { encrypted: new Uint8Array(encrypted), key };
+}
+
+export async function decrypt(
+  password: Password,
+  options: GenerateKeyOptions<EncryptionAlgorithm>,
+  data: Uint8Array | string,
+): Promise<string> {
+  const key = await generateKey(password, options);
+  const decrypted = await crypto.subtle.decrypt(
+    ...getEncryptParams(options.algorithm, key, data),
+  );
+  return bufferToString(new Uint8Array(decrypted));
+}
+
+function getEncryptParams(
+  algorithm: EncryptionAlgorithm,
+  key: Key,
+  data: Uint8Array | string,
+): [AesCbcParams | AesCtrParams, CryptoKey, Uint8Array] {
+  return [
+    algorithm === "aes-128-ctr"
+      ? ({
+          name: "AES-CTR",
+          counter: key.iv,
+          length: 128,
+        } satisfies AesCtrParams)
+      : ({ name: "AES-CBC", iv: key.iv } satisfies AesCbcParams),
+    key.key,
+    typeof data === "string" ? stringToUint8Array(data) : data,
+  ];
+}
+
+// --- other utils ---
+
+/** Returns true if `a` is equal to `b`, without leaking timing information that would allow an attacker to guess one of the values. */
+function fixedTimeComparison(a: string, b: string): boolean {
+  let mismatch = a.length === b.length ? 0 : 1;
+  if (mismatch) b = a;
+  for (let i = 0; i < a.length; i += 1)
+    mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); // eslint-disable-line unicorn/prefer-code-point
+  return mismatch === 0;
+}
+
+/** Normalizes a password parameter. */
+function normalizePassword(password: RawPassword) {
+  if (typeof password === "string" || password instanceof Uint8Array) {
+    return { encryption: password, integrity: password };
+  }
+  if ("secret" in password) {
+    return {
+      id: password.id,
+      encryption: password.secret,
+      integrity: password.secret,
+    };
+  }
+  return {
+    id: password.id,
+    encryption: password.encryption,
+    integrity: password.integrity,
+  };
+}
+
+/** Generate cryptographically strong pseudorandom bits. */
+export function randomBits(bits: number): Uint8Array {
+  if (bits < 1) throw new Error("Invalid random bits count");
+  const bytes = Math.ceil(bits / 8);
+  return randomBytes(bytes);
+}
+
+/** Generates cryptographically strong pseudorandom bytes. */
+function randomBytes(size: number): Uint8Array {
+  const bytes = new Uint8Array(size);
+  crypto.getRandomValues(bytes);
+  return bytes;
+}
+
+// --- encoding ---
+
+const encoder = /* @__PURE__ */ new TextEncoder();
+const decoder = /* @__PURE__ */ new TextDecoder();
+
+function bufferToString(value: Uint8Array): string {
+  return decoder.decode(value);
+}
+
+function stringToUint8Array(value: string): Uint8Array {
+  return encoder.encode(value);
+}
+
+// ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_
+const base64Code = /* @__PURE__ */ [
+  65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83,
+  84, 85, 86, 87, 88, 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106,
+  107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121,
+  122, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 45, 95,
+];
+
+export function base64Encode(data: ArrayBuffer | Uint8Array | string): string {
+  const buff = validateBinaryLike(data);
+  if (globalThis.Buffer) {
+    return globalThis.Buffer.from(buff).toString("base64url");
+  }
+  // Credits: https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727
+  const bytes: number[] = [];
+  let i;
+  const len = buff.length;
+  for (i = 2; i < len; i += 3) {
+    bytes.push(
+      base64Code[buff[i - 2]! >> 2],
+      base64Code[((buff[i - 2]! & 0x03) << 4) | (buff[i - 1]! >> 4)],
+      base64Code[((buff[i - 1]! & 0x0f) << 2) | (buff[i]! >> 6)],
+      base64Code[buff[i]! & 0x3f],
+    );
+  }
+  if (i === len + 1) {
+    // 1 octet yet to write
+    bytes.push(
+      base64Code[buff[i - 2]! >> 2],
+      base64Code[(buff[i - 2]! & 0x03) << 4],
+    );
+  }
+  if (i === len) {
+    // 2 octets yet to write
+    bytes.push(
+      base64Code[buff[i - 2]! >> 2],
+      base64Code[((buff[i - 2]! & 0x03) << 4) | (buff[i - 1]! >> 4)],
+      base64Code[(buff[i - 1]! & 0x0f) << 2],
+    );
+  }
+  // eslint-disable-next-line unicorn/prefer-code-point
+  return String.fromCharCode(...bytes);
+}
+export function base64Decode(b64Url: string): Uint8Array {
+  if (globalThis.Buffer) {
+    return new Uint8Array(globalThis.Buffer.from(b64Url, "base64url"));
+  }
+  const b64 = b64Url.replace(/-/g, "+").replace(/_/g, "/");
+  const binString = atob(b64);
+  const size = binString.length;
+  const bytes = new Uint8Array(size);
+  for (let i = 0; i < size; i++) {
+    // (Uint8Array values are 0-255)
+    // eslint-disable-next-line unicorn/prefer-code-point
+    bytes[i] = binString.charCodeAt(i);
+  }
+  return bytes;
+}
+
+export function validateBinaryLike(source: unknown): Uint8Array {
+  if (typeof source === "string") {
+    return encoder.encode(source);
+  } else if (source instanceof Uint8Array) {
+    return source;
+  } else if (source instanceof ArrayBuffer) {
+    return new Uint8Array(source);
+  }
+  throw new TypeError(
+    `The input must be a Uint8Array, a string, or an ArrayBuffer.`,
+  );
+}
+
+// --- Types ---
+
+/** Algorithm used for encryption and decryption. */
+type EncryptionAlgorithm = "aes-128-ctr" | "aes-256-cbc";
+
+/** Algorithm used for integrity verification. */
+export type IntegrityAlgorithm = "sha256";
+
+/** @internal */
+type _Algorithm = EncryptionAlgorithm | IntegrityAlgorithm;
+
+/**
+ * Options for customizing the key derivation algorithm used to generate encryption and integrity verification keys as well as the algorithms and salt sizes used.
+ */
+export type SealOptions = Readonly<{
+  /** Encryption step options. */
+  encryption: SealOptionsSub<EncryptionAlgorithm>;
+
+  /** Integrity step options. */
+  integrity: SealOptionsSub<IntegrityAlgorithm>;
+
+  /*Sealed object lifetime in milliseconds where 0 means forever. Defaults to 0. */
+  ttl: number;
+
+  /** Number of seconds of permitted clock skew for incoming expirations. Defaults to 60 seconds. */
+  timestampSkewSec: number;
+
+  /**
+   * Local clock time offset, expressed in number of milliseconds (positive or negative). Defaults to 0.
+   */
+  localtimeOffsetMsec: number;
+}>;
+
+/** `seal()` method options. */
+type SealOptionsSub<Algorithm extends _Algorithm = _Algorithm> = Readonly<{
+  /** The length of the salt (random buffer used to ensure that two identical objects will generate a different encrypted result). Defaults to 256. */
+  saltBits: number;
+
+  /** The algorithm used. Defaults to 'aes-256-cbc' for encryption and 'sha256' for integrity. */
+  algorithm: Algorithm;
+
+  /** The number of iterations used to derive a key from the password. Defaults to 1. */
+  iterations: number;
+
+  /** Minimum password size. Defaults to 32. */
+  minPasswordlength: number;
+}>;
+
+/** Password secret string or buffer.*/
+type Password = Uint8Array | string;
+
+/** `generateKey()` method options. */
+export type GenerateKeyOptions<Algorithm extends _Algorithm = _Algorithm> =
+  Pick<
+    SealOptionsSub<Algorithm>,
+    "algorithm" | "iterations" | "minPasswordlength"
+  > &
+    Readonly<{
+      saltBits?: number | undefined;
+      salt?: string | undefined;
+      iv?: Uint8Array | undefined;
+      ivBits?: number | undefined;
+      hmac?: boolean | undefined;
+    }>;
+
+/** Generated internal key object. */
+type Key = Readonly<{
+  key: CryptoKey;
+  salt: string;
+  iv: Uint8Array;
+}>;
+
+/** Generated HMAC internal results. */
+type HMacResult = Readonly<{
+  digest: string;
+  salt: string;
+}>;
+
+/** Secret object with optional id.*/
+type PasswordSecret = Readonly<{
+  id?: string | undefined;
+  secret: Password;
+}>;
+
+/** Secret object with optional id and specified password for each encryption and integrity. */
+type PasswordSpecific = Readonly<{
+  id?: string | undefined;
+  encryption: Password;
+  integrity: Password;
+}>;
+
+/** Key-value pairs hash of password id to value. */
+type PasswordHash = Readonly<
+  Record<string, Password | PasswordSecret | PasswordSpecific>
+>;
+
+export type RawPassword = Password | PasswordSecret | PasswordSpecific;
diff --git a/src/utils/internal/session.ts b/src/utils/internal/session.ts
index bafa5e1..021de2d 100644
--- a/src/utils/internal/session.ts
+++ b/src/utils/internal/session.ts
@@ -1,6 +1,6 @@
 import type { SessionConfig } from "../../types";
 
-export const kGetSession: unique symbol = Symbol.for(
+export const kGetSession: unique symbol = /* @__PURE__ */ Symbol.for(
   "h3.internal.session.promise",
 );
 
diff --git a/src/utils/session.ts b/src/utils/session.ts
index f649bc9..37ac3d9 100644
--- a/src/utils/session.ts
+++ b/src/utils/session.ts
@@ -1,6 +1,5 @@
 import type { H3Event, Session, SessionConfig, SessionData } from "../types";
-import crypto from "uncrypto";
-import { seal, unseal, defaults as sealDefaults } from "iron-webcrypto";
+import { seal, unseal, defaults as sealDefaults } from "./internal/iron-crypto";
 import { getCookie, setCookie } from "./cookie";
 import {
   DEFAULT_SESSION_NAME,
@@ -162,7 +161,7 @@ export async function sealSession<T extends SessionData = SessionData>(
     (event.context.sessions?.[sessionName] as Session<T>) ||
     (await getSession<T>(event, config));
 
-  const sealed = await seal(config.crypto || crypto, session, config.password, {
+  const sealed = await seal(session, config.password, {
     ...sealDefaults,
     ttl: config.maxAge ? config.maxAge * 1000 : 0,
     ...config.seal,
@@ -179,16 +178,11 @@ export async function unsealSession(
   config: SessionConfig,
   sealed: string,
 ) {
-  const unsealed = (await unseal(
-    config.crypto || crypto,
-    sealed,
-    config.password,
-    {
-      ...sealDefaults,
-      ttl: config.maxAge ? config.maxAge * 1000 : 0,
-      ...config.seal,
-    },
-  )) as Partial<Session>;
+  const unsealed = (await unseal(sealed, config.password, {
+    ...sealDefaults,
+    ttl: config.maxAge ? config.maxAge * 1000 : 0,
+    ...config.seal,
+  })) as Partial<Session>;
   if (config.maxAge) {
     const age = Date.now() - (unsealed.createdAt || Number.NEGATIVE_INFINITY);
     if (age > config.maxAge * 1000) {


Test patch:
diff --git a/test/bench/bundle.test.ts b/test/bench/bundle.test.ts
index 2a29021..f3bab1b 100644
--- a/test/bench/bundle.test.ts
+++ b/test/bench/bundle.test.ts
@@ -11,7 +11,7 @@ describe("benchmark", () => {
       export default createH3();
     `;
     const { bytes, gzipSize } = await getBundleSize(code);
-    // console.log({ bytes, gzipSize });
+    // console.log(`Bundle size: ${bytes} (gzip: ${gzipSize})`);
     expect(bytes).toBeLessThanOrEqual(10_000); // <10kb
     expect(gzipSize).toBeLessThanOrEqual(4000); // <4kb
   });
diff --git a/test/bench/session.ts b/test/bench/session.ts
new file mode 100644
index 0000000..aad494c
--- /dev/null
+++ b/test/bench/session.ts
@@ -0,0 +1,65 @@
+import { bench, run, group as describe } from "mitata";
+import * as h3 from "../../src";
+import * as h3Nightly from "h3-nightly";
+
+// Create a random string
+// prettier-ignore
+const randomStr = Array.from({length: 1024}).map(() => String.fromCodePoint(Math.floor(Math.random() * 94) + 33)).join('');
+
+// Implement the session benchmark
+const password = "some_not_random_password_that_is_also_long_enough";
+const apps = (
+  [
+    ["h3", h3],
+    ["h3-nightly", h3Nightly],
+  ] as const
+).map(([name, lib]) => {
+  return [
+    name,
+    lib.createH3({ debug: true }).get("/", async (event: any) => {
+      const session = await lib.useSession(event, { password });
+      await session.update((data) => {
+        data.ctr = (data.ctr || 0) + 1;
+        data.str = randomStr;
+      });
+      return {
+        id: session.id,
+        ctr: session.data.ctr,
+      };
+    }).fetch,
+  ] as const;
+});
+
+// Quick test
+for (const [name, _fetch] of apps) {
+  for (let i = 0; i < 128; i++) {
+    const res = await _fetch("/");
+    const cookie = res.headers.get("set-cookie") || "";
+    const session1 = await res.json();
+    const res2 = await _fetch("/", {
+      headers: {
+        cookie,
+      },
+    });
+    const session2 = await res2.json();
+    if (session1.id !== session2.id) {
+      throw new Error(`Session ID should be the same (${name})`);
+    }
+  }
+}
+
+describe("session (init + restore)", async () => {
+  for (const [name, _fetch] of apps) {
+    bench(name, async () => {
+      const res = await _fetch("/");
+      const cookie = res.headers.get("set-cookie") || "";
+      await _fetch("/", {
+        headers: {
+          cookie,
+        },
+      });
+    });
+  }
+});
+
+await run();
diff --git a/test/internal/iron-crypto.test.ts b/test/internal/iron-crypto.test.ts
new file mode 100644
index 0000000..acea0c5
--- /dev/null
+++ b/test/internal/iron-crypto.test.ts
@@ -0,0 +1,451 @@
+/**
+Based on https://github.com/brc-dd/iron-webcrypto/tree/v1.2.1
+Copyright (c) 2021 Divyansh Singh.
+https://github.com/brc-dd/iron-webcrypto/blob/v1.2.1/LICENSE.md
+*/
+
+import { describe, it, assert, expect, beforeAll, afterAll } from "vitest";
+import { createHmac } from "node:crypto";
+import * as Iron from "../../src/utils/internal/iron-crypto";
+
+async function rejects(
+  promise: Promise<unknown>,
+  msgIncludes: string | RegExp,
+): Promise<void> {
+  return expect(promise).rejects.toThrow(msgIncludes);
+}
+
+const _originalBuffer = globalThis.Buffer;
+const cases = [
+  ["with buffer", _originalBuffer],
+  ["without buffer", undefined],
+] as const;
+
+const obj = { a: 1, b: 2, c: [3, 4, 5], d: { e: "f" } };
+const password = "some_not_random_password_that_is_also_long_enough";
+
+for (const [name, _buff] of cases) {
+  describe(`Iron (${name})`, () => {
+    beforeAll(() => {
+      // @ts-ignore
+      globalThis.Buffer = _buff;
+    });
+
+    afterAll(() => {
+      // @ts-ignore
+      globalThis.Buffer = _originalBuffer;
+    });
+
+    it("turns object into a ticket than parses the ticket successfully", async () => {
+      const sealed = await Iron.seal(obj, password, Iron.defaults);
+      const unsealed = await Iron.unseal(
+        sealed,
+        { default: password },
+        Iron.defaults,
+      );
+      assert.deepEqual(unsealed, obj);
+    });
+
+    it("unseal and sealed object with expiration", async () => {
+      const options = { ...Iron.defaults, ttl: 200 };
+      const sealed = await Iron.seal(obj, password, options);
+      const unsealed = await Iron.unseal(
+        sealed,
+        { default: password },
+        Iron.defaults,
+      );
+      assert.deepEqual(unsealed, obj);
+    });
+
+    it("unseal and sealed object with expiration and time offset", async () => {
+      // prettier-ignore
+      const options = { ...Iron.defaults, ttl: 200, localtimeOffsetMsec: -100_000 };
+      const sealed = await Iron.seal(obj, password, options);
+
+      const options2 = { ...Iron.defaults, localtimeOffsetMsec: -100_000 };
+      const unsealed = await Iron.unseal(
+        sealed,
+        { default: password },
+        options2,
+      );
+      assert.deepEqual(unsealed, obj);
+    });
+
+    it("unseal and sealed object without time offset", async () => {
+      const options = { ...Iron.defaults, ttl: 200, localtimeOffsetMsec: 0 };
+      const sealed = await Iron.seal(obj, password, options);
+
+      const options2 = { ...Iron.defaults, localtimeOffsetMsec: 0 };
+      const unsealed = await Iron.unseal(
+        sealed,
+        { default: password },
+        options2,
+      );
+      assert.deepEqual(unsealed, obj);
+    });
+
+    it("turns object into a ticket than parses the ticket successfully (password buffer)", async () => {
+      const key = Iron.randomBits(256);
+      const sealed = await Iron.seal(obj, key, Iron.defaults);
+      const unsealed = await Iron.unseal(sealed, key, Iron.defaults);
+      assert.deepEqual(unsealed, obj);
+    });
+
+    it("turns object into a ticket than parses the ticket successfully (password buffer in object)", async () => {
+      const key = Iron.randomBits(256);
+      const sealed = await Iron.seal(obj, key, Iron.defaults);
+      const unsealed = await Iron.unseal(
+        sealed,
+        { default: key },
+        Iron.defaults,
+      );
+      assert.deepEqual(unsealed, obj);
+    });
+
+    it("fails to turns object into a ticket (password buffer too short)", async () => {
+      const key = Iron.randomBits(128);
+      await rejects(
+        Iron.seal(obj, key, Iron.defaults),
+        "Key buffer (password) too small",
+      );
+    });
+
+    it("fails to turn object into a ticket (failed to stringify object)", async () => {
+      const cyclic: unknown[] = [];
+      cyclic[0] = cyclic;
+      const key = Iron.randomBits(128);
+      await rejects(
+        Iron.seal(cyclic, key, Iron.defaults),
+        "Converting circular structure to JSON",
+      );
+    });
+
+    it("turns object into a ticket than parses the ticket successfully (password object)", async () => {
+      const sealed = await Iron.seal(
+        obj,
+        { id: "1", secret: password },
+        Iron.defaults,
+      );
+      const unsealed = await Iron.unseal(
+        sealed,
+        { "1": password },
+        Iron.defaults,
+      );
+      assert.deepEqual(unsealed, obj);
+    });
+
+    it("handles separate password buffers (password object)", async () => {
+      const key = {
+        id: "1",
+        encryption: Iron.randomBits(256),
+        integrity: Iron.randomBits(256),
+      };
+      const sealed = await Iron.seal(obj, key, Iron.defaults);
+      const unsealed = await Iron.unseal(sealed, { "1": key }, Iron.defaults);
+      assert.deepEqual(unsealed, obj);
+    });
+
+    it("handles a common password buffer (password object)", async () => {
+      const key = { id: "1", secret: Iron.randomBits(256) };
+      const sealed = await Iron.seal(obj, key, Iron.defaults);
+      const unsealed = await Iron.unseal(sealed, { "1": key }, Iron.defaults);
+      assert.deepEqual(unsealed, obj);
+    });
+
+    it("fails to parse a sealed object when password not found", async () => {
+      const sealed = await Iron.seal(
+        obj,
+        { id: "1", secret: password },
+        Iron.defaults,
+      );
+      await rejects(
+        Iron.unseal(sealed, { "2": password }, Iron.defaults),
+        "Cannot find password: 1",
+      );
+    });
+
+    describe("generateKey()", () => {
+      it("returns an error when password is missing", async () => {
+        await rejects(
+          Iron.generateKey(null as any, null as any),
+          "Empty password",
+        );
+      });
+
+      it("returns an error when password is too short", async () => {
+        await rejects(
+          Iron.generateKey("password", Iron.defaults.encryption),
+          "Password string too short (min 32 characters required)",
+        );
+      });
+
+      it("returns an error when options are missing", async () => {
+        await rejects(Iron.generateKey(password, null as any), "Bad options");
+
+        await rejects(Iron.generateKey(password, "abc" as any), "Bad options");
+      });
+
+      it("returns an error when an unknown algorithm is specified", async () => {
+        await rejects(
+          Iron.generateKey(password, { algorithm: "unknown" } as any),
+          "Unknown algorithm: unknown",
+        );
+      });
+
+      it("returns an error when no salt and no salt bits are provided", async () => {
+        const options = {
+          algorithm: "sha256" as const,
+          iterations: 2,
+          minPasswordlength: 32,
+        };
+        await rejects(
+          Iron.generateKey(password, options),
+          "Missing salt and saltBits options",
+        );
+      });
+
+      it("returns an error when invalid salt bits are provided", async () => {
+        const options = {
+          saltBits: 999_999_999_999_999,
+          algorithm: "sha256" as const,
+          iterations: 2,
+          minPasswordlength: 32,
+        };
+        await rejects(
+          Iron.generateKey(password, options),
+          /Invalid typed array length|Array buffer allocation failed/,
+        );
+      });
+    });
+
+    describe("encrypt()", () => {
+      it("returns an error when password is missing", async () => {
+        await rejects(
+          Iron.encrypt(null as any, null as any, "data"),
+          "Empty password",
+        );
+      });
+    });
+
+    describe("decrypt", () => {
+      it("returns an error when password is missing", async () => {
+        await rejects(
+          Iron.decrypt(null as any, null as any, "data"),
+          "Empty password",
+        );
+      });
+    });
+
+    describe("hmacWithPassword()", () => {
+      it("returns an error when password is missing", async () => {
+        await rejects(
+          Iron.hmacWithPassword(null as any, null as any, "data"),
+          "Empty password",
+        );
+      });
+
+      if (createHmac)
+        it("produces the same mac when used with buffer password", async () => {
+          const data = "Not so random";
+          const key = Iron.randomBits(256);
+          const hmac = createHmac(
+            Iron.defaults.integrity.algorithm,
+            key,
+          ).update(data);
+          const digest = Iron.base64Encode(hmac.digest());
+          const mac = await Iron.hmacWithPassword(
+            key,
+            Iron.defaults.integrity,
+            data,
+          );
+          assert.deepEqual(mac.digest, digest);
+        });
+    });
+
+    describe("seal()", () => {
+      it("returns an error when password is missing", async () => {
+        await rejects(
+          Iron.seal("data", null as any, Iron.defaults),
+          "Empty password",
+        );
+      });
+
+      it("returns an error when integrity options are missing", async () => {
+        const options = {
+          ...Iron.defaults,
+          integrity: {} as any,
+        };
+        await rejects(
+          Iron.seal("data", password, options),
+          "Unknown algorithm: undefined",
+        );
+      });
+
+      it("returns an error when password.id is invalid", async () => {
+        await rejects(
+          Iron.seal("data", { id: "asd$", secret: "asd" }, Iron.defaults),
+          "Invalid password id",
+        );
+      });
+    });
+
+    describe("unseal()", () => {
+      it("unseals a ticket", async () => {
+        const ticket =
+          "Fe26.2**0cdd607945dd1dffb7da0b0bf5f1a7daa6218cbae14cac51dcbd91fb077aeb5b*aOZLCKLhCt0D5IU1qLTtYw*g0ilNDlQ3TsdFUqJCqAm9iL7Wa60H7eYcHL_5oP136TOJREkS3BzheDC1dlxz5oJ**05b8943049af490e913bbc3a2485bee2aaf7b823f4c41d0ff0b7c168371a3772*R8yscVdTBRMdsoVbdDiFmUL8zb-c3PQLGJn4Y8C-AqI";
+        const unsealed = await Iron.unseal(ticket, password, Iron.defaults);
+        assert.deepEqual(unsealed, obj);
+      });
+
+      it("unseals a aes-128-ctr ticket", async () => {
+        const ticket =
+          "Fe26.2**63c87cc87254b834dbb13cc62abc8713d1da035a9b38f54e5d5795135e217167*TpIsHHn-7txnl14Or7-D6A*HFGMGpcTRPUBSTQobuo27KOZ76MhVWgOsjA5SkFoy3vyqaHuixbd**3c8c403569a744a39c58adfb81e654f6860cb01f145488313e1895276e719f52*d5J1jY3WxP61klQza6q7zTqiYpjWPwocqHmjtDcSeq0";
+        const options = {
+          ...Iron.defaults,
+          encryption: {
+            ...Iron.defaults.encryption,
+            algorithm: "aes-128-ctr" as any,
+          },
+        };
+        const unsealed = await Iron.unseal(ticket, password, options);
+        assert.deepEqual(unsealed, obj);
+      });
+
+      it("returns an error when number of sealed components is wrong", async () => {
+        const ticket =
+          "x*Fe26.2**a6dc6339e5ea5dfe7a135631cf3b7dcf47ea38246369d45767c928ea81781694*D3DLEoi-Hn3c972TPpZXqw*mCBhmhHhRKk9KtBjwu3h-1lx1MHKkgloQPKRkQZxpnDwYnFkb3RqdVTQRcuhGf4M**ff2bf988aa0edf2b34c02d220a45c4a3c572dac6b995771ed20de58da919bfa5*HfWzyJlz_UP9odmXvUaVK1TtdDuOCaezr-TAg2GjBCU";
+        await rejects(
+          Iron.unseal(ticket, password, Iron.defaults),
+          "Incorrect number of sealed components",
+        );
+      });
+
+      it("returns an error when password is missing", async () => {
+        const ticket =
+          "Fe26.2**a6dc6339e5ea5dfe7a135631cf3b7dcf47ea38246369d45767c928ea81781694*D3DLEoi-Hn3c972TPpZXqw*mCBhmhHhRKk9KtBjwu3h-1lx1MHKkgloQPKRkQZxpnDwYnFkb3RqdVTQRcuhGf4M**ff2bf988aa0edf2b34c02d220a45c4a3c572dac6b995771ed20de58da919bfa5*HfWzyJlz_UP9odmXvUaVK1TtdDuOCaezr-TAg2GjBCU";
+
+        await rejects(
+          Iron.unseal(ticket, null as any, Iron.defaults),
+          "Empty password",
+        );
+      });
+
+      it("returns an error when mac prefix is wrong", async () => {
+        const ticket =
+          "Fe27.2**a6dc6339e5ea5dfe7a135631cf3b7dcf47ea38246369d45767c928ea81781694*D3DLEoi-Hn3c972TPpZXqw*mCBhmhHhRKk9KtBjwu3h-1lx1MHKkgloQPKRkQZxpnDwYnFkb3RqdVTQRcuhGf4M**ff2bf988aa0edf2b34c02d220a45c4a3c572dac6b995771ed20de58da919bfa5*HfWzyJlz_UP9odmXvUaVK1TtdDuOCaezr-TAg2GjBCU";
+        await rejects(
+          Iron.unseal(ticket, password, Iron.defaults),
+          "Wrong mac prefix",
+        );
+      });
+
+      it("returns an error when integrity check fails", async () => {
+        const ticket =
+          "Fe26.2**b3ad22402ccc60fa4d527f7d1c9ff2e37e9b2e5723e9e2ffba39a489e9849609*QKCeXLs6Rp7f4LL56V7hBg*OvZEoAq_nGOpA1zae-fAtl7VNCNdhZhCqo-hWFCBeWuTTpSupJ7LxQqzSQBRAcgw**72018a21d3fac5c1608a0f9e461de0fcf17b2befe97855978c17a793faa01db1*Qj53DFE3GZd5yigt-mVl9lnp0VUoSjh5a5jgDmod1EZ";
+        await rejects(
+          Iron.unseal(ticket, password, Iron.defaults),
+          "Bad hmac value",
+        );
+      });
+
+      it("returns an error when decryption fails", async () => {
+        const macBaseString =
+          "Fe26.2**a6dc6339e5ea5dfe7a135631cf3b7dcf47ea38246369d45767c928ea81781694*D3DLEoi-Hn3c972TPpZXqw*mCBhmhHhRKk9KtBjwu3h-1lx1MHKkgloQPKRkQZxpnDwYnFkb3RqdVTQRcuhGf4M??*";
+        const options: Iron.GenerateKeyOptions<Iron.IntegrityAlgorithm> = {
+          ...Iron.defaults.integrity,
+          salt: "ff2bf988aa0edf2b34c02d220a45c4a3c572dac6b995771ed20de58da919bfa5",
+        };
+        const mac = await Iron.hmacWithPassword(
+          password,
+          options,
+          macBaseString,
+        );
+        const ticket = `${macBaseString}*${mac.salt}*${mac.digest}`;
+        await rejects(
+          Iron.unseal(ticket, password, Iron.defaults),
+          /Invalid character|The operation failed for an operation-specific reason/,
+        );
+      });
+
+      it("returns an error when iv base64 decoding fails", async () => {
+        const macBaseString =
+          "Fe26.2**a6dc6339e5ea5dfe7a135631cf3b7dcf47ea38246369d45767c928ea81781694*D3DLEoi-Hn3c972TPpZXqw??*mCBhmhHhRKk9KtBjwu3h-1lx1MHKkgloQPKRkQZxpnDwYnFkb3RqdVTQRcuhGf4M*";
+        const options: Iron.GenerateKeyOptions<Iron.IntegrityAlgorithm> = {
+          ...Iron.defaults.integrity,
+          salt: "ff2bf988aa0edf2b34c02d220a45c4a3c572dac6b995771ed20de58da919bfa5",
+        };
+        const mac = await Iron.hmacWithPassword(
+          password,
+          options,
+          macBaseString,
+        );
+        const ticket = `${macBaseString}*${mac.salt}*${mac.digest}`;
+        await rejects(
+          Iron.unseal(ticket, password, Iron.defaults),
+          /Invalid character|The operation failed for an operation-specific reason/,
+        );
+      });
+
+      it("returns an error when decrypted object is invalid", async () => {
+        const badJson = "{asdasd";
+        const { encrypted, key } = await Iron.encrypt(
+          password,
+          Iron.defaults.encryption,
+          badJson,
+        );
+        const encryptedB64 = Iron.base64Encode(encrypted);
+        const iv = Iron.base64Encode(key.iv);
+        const macBaseString = `${Iron.macPrefix}**${key.salt}*${iv}*${encryptedB64}*`;
+        const mac = await Iron.hmacWithPassword(
+          password,
+          Iron.defaults.integrity,
+          macBaseString,
+        );
+        const ticket = `${macBaseString}*${mac.salt}*${mac.digest}`;
+        await rejects(
+          Iron.unseal(ticket, password, Iron.defaults),
+          /Expected property name or '}' in JSON at position 1|Unexpected token a in JSON at position 1/,
+        );
+      });
+
+      it("returns an error when expired", async () => {
+        const macBaseString =
+          "Fe26.2**a38dc7a7bf2f8ff650b103d8c669d76ad219527fbfff3d98e3b30bbecbe9bd3b*nTsatb7AQE1t0uMXDx-2aw*uIO5bRFTwEBlPC1Nd_hfSkZfqxkxuY1EO2Be_jJPNQCqFNumRBjQAl8WIKBW1beF*1380495854060";
+        const options: Iron.GenerateKeyOptions<Iron.IntegrityAlgorithm> = {
+          ...Iron.defaults.integrity,
+          salt: "e4fe33b6dc4c7ef5ad7907f015deb7b03723b03a54764aceeb2ab1235cc8dce3",
+        };
+        const mac = await Iron.hmacWithPassword(
+          password,
+          options,
+          macBaseString,
+        );
+        const ticket = `${macBaseString}*${mac.salt}*${mac.digest}`;
+        await rejects(
+          Iron.unseal(ticket, password, Iron.defaults),
+          "Expired seal",
+        );
+      });
+
+      it("returns an error when expiration NaN", async () => {
+        const macBaseString =
+          "Fe26.2**a38dc7a7bf2f8ff650b103d8c669d76ad219527fbfff3d98e3b30bbecbe9bd3b*nTsatb7AQE1t0uMXDx-2aw*uIO5bRFTwEBlPC1Nd_hfSkZfqxkxuY1EO2Be_jJPNQCqFNumRBjQAl8WIKBW1beF*a";
+        const options: Iron.GenerateKeyOptions<Iron.IntegrityAlgorithm> = {
+          ...Iron.defaults.integrity,
+          salt: "e4fe33b6dc4c7ef5ad7907f015deb7b03723b03a54764aceeb2ab1235cc8dce3",
+          ivBits: 128,
+        };
+        const mac = await Iron.hmacWithPassword(
+          password,
+          options,
+          macBaseString,
+        );
+        const ticket = `${macBaseString}*${mac.salt}*${mac.digest}`;
+        await rejects(
+          Iron.unseal(ticket, password, Iron.defaults),
+          "Invalid expiration",
+        );
+      });
+    });
+  });
+}