Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/cachekit-core-wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ rotating.free(); // zeroize the whole keyring when the grace window ends
Linear memory is a host-readable `ArrayBuffer`, so this is weaker isolation
than the NAPI binding's Rust heap — on Workers the host is your own
isolate, making it roughly JS-heap-equivalent in threat model.
- `deriveKey` reads the master key from the JS `Uint8Array` and returns the
derived key as a new JS `Uint8Array`: neither is staged in a linear-memory
buffer that is freed unwiped, and the build checks the generated glue for
that. The returned key is the caller's to wipe. HKDF's working buffers stay
on the stack in linear memory until later calls overwrite them.
- `blake2b256Hex` copies its input (a cache key's encoded arguments) into
linear memory and wipes that copy before freeing it. The hasher's last block
of input, up to 128 bytes and so all of a short key, stays on the stack in
Expand Down
8 changes: 7 additions & 1 deletion packages/cachekit-core-wasm/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ export declare class TenantKeys {
hardwareAccelerationEnabled(): boolean;
}

/** Derive a 32-byte domain key using HKDF-SHA256 (RFC 5869). */
/**
* Derive a 32-byte domain key using HKDF-SHA256 (RFC 5869). The master key is
* read from, and the derived key returned as, JS memory: no linear-memory heap
* buffer holding either is freed unwiped. HKDF's working buffers stay on the
* wasm stack, which is linear memory, until later calls overwrite them. Wipe
* the returned key when done.
*/
export declare function deriveKey(
masterKey: Uint8Array,
domain: string,
Expand Down
17 changes: 17 additions & 0 deletions packages/cachekit-core-wasm/scripts/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,21 @@ if [ "$DRIFT" -ne 0 ]; then
exit 1
fi

# Key-material glue check: the Workers SDK takes its AES keys from deriveKey,
# so its generated glue must not stage the master key in linear memory
# (passArray8ToWasm0) nor copy the derived key out of a linear-memory buffer
# it then frees unwiped (getArrayU8FromWasm0 + the free export, which
# wasm-bindgen may rename to __wbindgen_exportN). lib.rs passes both as JS
# Uint8Array handles instead.
DERIVE_KEY_GLUE=$(awk '/^export function deriveKey\(/,/^}/' pkg/cachekit_core_wasm.js)
if [ -z "$DERIVE_KEY_GLUE" ]; then
echo "error: deriveKey not found in pkg/cachekit_core_wasm.js" >&2
exit 1
fi
if grep -qE 'passArray8ToWasm0|getArrayU8FromWasm0|__wbindgen_free|wasm\.__wbindgen_export[0-9]*\([^)]*, 1\)' <<< "$DERIVE_KEY_GLUE"; then
echo "error: deriveKey glue copies key bytes through linear memory:" >&2
echo "$DERIVE_KEY_GLUE" >&2
exit 1
fi

echo "build OK"
29 changes: 23 additions & 6 deletions packages/cachekit-core-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,29 @@ impl ByteStorage {
}

/// Key derivation using HKDF-SHA256 (RFC 5869). Same validation as NAPI.
///
/// The master key arrives as a JS handle and the derived key leaves as a new
/// JS `Uint8Array`, so neither crosses through a linear-memory buffer that is
/// freed unwiped (the `&[u8]` argument and `Vec<u8>` return ABIs both do).
/// The staging copy and this function's copy of the derived key are wiped on
/// every return path. HKDF's own working buffers, the derived key returned by
/// value included, stay on the wasm stack until later calls overwrite them.
/// The Workers SDK takes its AES key from here once per manager.
#[wasm_bindgen(js_name = deriveKey)]
pub fn derive_key(master_key: &[u8], domain: &str, tenant_salt: &str) -> Result<Vec<u8>, JsError> {
if master_key.len() != 32 {
pub fn derive_key(
master_key: js_sys::Uint8Array,
domain: &str,
tenant_salt: &str,
) -> Result<js_sys::Uint8Array, JsError> {
// Checked before the copy: linear memory never shrinks, so copying an
// oversized array first would grow it for good.
if master_key.length() != 32 {
return Err(JsError::new(&format!(
"Master key must be 32 bytes, got {}",
master_key.len()
master_key.length()
)));
}
let master_key = Zeroizing::new(master_key.to_vec());
if domain.is_empty() {
return Err(JsError::new(
"Domain cannot be empty - domain separation required for security",
Expand All @@ -155,9 +170,11 @@ pub fn derive_key(master_key: &[u8], domain: &str, tenant_salt: &str) -> Result<
));
}

derive_domain_key(master_key, domain, tenant_salt.as_bytes())
.map(|derived| derived.to_vec())
.map_err(|e| JsError::new(&e.to_string()))
let derived = Zeroizing::new(
derive_domain_key(&master_key, domain, tenant_salt.as_bytes())
.map_err(|e| JsError::new(&e.to_string()))?,
);
Ok(js_sys::Uint8Array::from(&derived[..]))
}

/// Per-tenant derived keys with automatic zeroization.
Expand Down
41 changes: 24 additions & 17 deletions packages/cachekit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,10 @@ const cache = createCache.secure({
// or: CACHEKIT_PREVIOUS_MASTER_KEYS=<hex>,<hex> (comma-separated)
```

The derived keyring lives behind the NAPI (or wasm) boundary and is zeroized
on dispose; the decoded key buffers are wiped as soon as the keyring is built.
On Node, the derived keyring lives behind the NAPI boundary and is zeroized
when the GC finalizes the handle after dispose. On Workers, the keys are non-extractable WebCrypto keys that
dispose drops for the garbage collector (see the Workers section). On both
runtimes, the decoded key buffers are wiped as soon as the keyring is built.
The hex key strings themselves (config values, environment variables) live in
JavaScript memory and cannot be reliably scrubbed — treat them as sensitive
for the lifetime of the process.
Expand Down Expand Up @@ -726,12 +728,13 @@ runtimes apart.
encryption manager forwards cachekit-core's detection. Informational only: the
crypto backend picks its implementation independently, so use it to explain
`.secure` latency, not to change behaviour. It initialises the bindings if
needed, and returns `null` (unknown) only when the installed binding
predates the accessor. The per-architecture semantics are core's — as of
cachekit-core 0.7.0 a runtime AES-NI probe on x86/x86_64, a runtime probe for
the Armv8 Crypto Extension's AES instructions on aarch64 (a Raspberry Pi 4, a
Cortex-A72 without the Crypto Extension, reports `false`), and `false` on
Cloudflare Workers (wasm32 has no AES instructions).
needed, and returns `null` (unknown) when the installed binding predates the
accessor, and always on Cloudflare Workers: there AES-GCM runs through the
runtime's WebCrypto, and an isolate cannot see whether that uses the host's
AES instructions. On Node the per-architecture semantics are core's — as of
cachekit-core 0.7.0 a runtime AES-NI probe on x86/x86_64, and a runtime probe
for the Armv8 Crypto Extension's AES instructions on aarch64 (a Raspberry Pi 4,
a Cortex-A72 without the Crypto Extension, reports `false`).

```typescript
import { EncryptionManager } from '@cachekit-io/cachekit';
Expand All @@ -750,11 +753,12 @@ The SDK ships a Workers-native entrypoint: `@cachekit-io/cachekit/workers`
(bare `import '@cachekit-io/cachekit'` also resolves to it under wrangler via
the `workerd` export condition). It needs **no `nodejs_compat` flag** — the
bundle contains no `node:*` builtins, no native addons, no ioredis, and no
prom-client. Crypto (AES-256-GCM + HKDF-SHA256, counter nonces) and the
prom-client. Key derivation (HKDF-SHA256), the key fingerprint and the
ByteStorage wire envelope (LZ4 + xxHash3-64) run on a wasm32 build of the
same audited Rust core the Node SDK uses (`@cachekit-io/cachekit-core-wasm`,
~66 KB gzipped), so ciphertexts and envelopes are byte-compatible across
Node, Workers, Python, and Rust.
~66 KB gzipped). AES-256-GCM runs through the runtime's WebCrypto, which is
native code, with the core's counter-nonce scheme. Ciphertexts and envelopes
are byte-compatible across Node, Workers, Python, and Rust.

```typescript
import { createCache, type WorkersCache } from '@cachekit-io/cachekit/workers';
Expand Down Expand Up @@ -791,7 +795,7 @@ export default {
> uniqueness rests on a random 64-bit instance id (a weaker, birthday-bounded
> guarantee at very high request volumes) and leave wasm allocations behind
> on hot isolates. If you do create short-lived caches, call `cache.close()`
> — it zeroizes key material and frees the wasm codec deterministically.
> — it releases key material and frees the wasm codec deterministically.

### Native edge storage: Workers KV and the Cache API

Expand Down Expand Up @@ -879,11 +883,14 @@ reported through the library logger by key digest.
release can be cancelled, leaving the lease held for `lockTimeoutMs` while
other callers wait out `lockWaitMs`.
- **No Prometheus metrics.**
- **Key material semantics**: keys are derived and held in wasm linear
memory, which is a host-readable `ArrayBuffer` — weaker isolation than the
NAPI Rust heap on Node. On Workers, the host is your own isolate, making
this roughly JS-heap-equivalent in threat model; `dispose()`/`close()`
still zeroizes deterministically.
- **Key material semantics**: keys are derived in wasm linear memory, a
host-readable `ArrayBuffer`, then imported once as non-extractable
WebCrypto `CryptoKey`s (decrypt-only for previous keys). The wasm key
handle and every JS copy of derived key bytes are wiped before the first
operation runs; HKDF's working buffers can stay on the wasm stack until
later calls overwrite them. A `CryptoKey` cannot be zeroized:
`dispose()`/`close()` drops it for the garbage collector. As on Node, the
master key itself stays in JS memory as its configured hex string.
- **Startup**: wasm instantiation is a small one-time cost per isolate
(~186 KB module, no I/O). A cache with compression off and no encryption
never needs the module, so its keys are hashed in JS; once any cache has
Expand Down
4 changes: 3 additions & 1 deletion packages/cachekit/bench/ir-workload.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,9 @@ export const WORKLOADS = {
// secure cache on Node: AAD v0x03 + AES-256-GCM through the NAPI core, decrypt + encrypt
'napi-encrypted': napiEncrypted(encoded, 10_000),
'napi-encrypted-64kb': napiEncrypted(incompressible, 500),
// secure cache on Workers: the same through the wasm core
// wasm-AES baseline: the same through the wasm core's AES-256-GCM. The
// Workers entry encrypts through WebCrypto instead, which Node cannot stand
// in for; bench/workers-crypto times that path inside workerd.
'wasm-encrypted': wasmEncrypted(encoded, 5_000),
'wasm-encrypted-64kb': wasmEncrypted(incompressible, 50),
// a secure cache's L2 round trip on Node, L1 off: set + get, each running the
Expand Down
177 changes: 177 additions & 0 deletions packages/cachekit/bench/workers-crypto/ab.bench-workers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// AES-256-GCM per op on Workers, A/B inside workerd: the wasm core (A) against
// WebCrypto (B), both driven through EncryptionManagerCore on the current build.
//
// pnpm --filter @cachekit-io/cachekit bench:workers-crypto
//
// Method:
// - Sizes 100 B, 1 KB, 16 KB, 64 KB and 1 MB; encrypt and decrypt each.
// - A, B and an A/A twin (a second wasm manager) are interleaved within every
// rep, in an order rotated each rep, so host drift lands on all three alike.
// 60 reps at 1 KB and below, 9 above.
// - workerd's clock has 1 ms resolution, so each sample is a batch of k ops,
// k calibrated per arm to fill about 40 ms. The same k of no-op awaits is
// timed right before it and subtracted.
// - Per size and op: median µs/op of each arm; the A/A floor is
// |A − A/A| / A; the gain is (A − B) / A. Each rep also gives a paired gain
// (A_i − B_i) / A_i; a seeded bootstrap (2,000 resamples) of their median
// gives a 95% CI. PASS when the gain clears the floor or the CI excludes
// zero; REGRESS when the loss clears the floor or the CI is below zero;
// otherwise PARITY. The run fails unless every row passes.
//
// Local workerd numbers are indicative, not production timings.
import { expect, it } from 'vitest';
import * as wasm from '@cachekit-io/cachekit-core-wasm';
import {
EncryptionManagerCore,
type EncryptionBindings,
} from '../../src/encryption/manager-core.js';
import { webCryptoEncryptionBindings } from '../../src/workers/webcrypto.js';

const MASTER_KEY = 'ab'.repeat(32);
const KEY = 'ns:bench:func:mod.fn:args:' + '0'.repeat(64) + ':';
const SIZES = [100, 1024, 16 * 1024, 64 * 1024, 1024 * 1024];
const TARGET_MS = 40;
const reps = (size: number) => (size <= 1024 ? 60 : 9);
const RESAMPLES = 2000;

function wasmBindings(): EncryptionBindings {
wasm.ensureInitialized();
return {
deriveTenantKeys: wasm.deriveTenantKeys,
encryptWithTenantKeys: (p, a, tk) => wasm.encryptWithTenantKeys(p, a, tk as wasm.TenantKeys),
decryptWithTenantKeys: (c, a, tk) => wasm.decryptWithTenantKeys(c, a, tk as wasm.TenantKeys),
};
}

function webCryptoBindings(): EncryptionBindings {
wasm.ensureInitialized();
return webCryptoEncryptionBindings({
deriveKey: wasm.deriveKey,
deriveTenantKeys: wasm.deriveTenantKeys,
});
}

type Op = () => Promise<unknown>;
const noop: Op = async () => undefined;

async function batchMs(k: number, op: Op): Promise<number> {
const t0 = performance.now();
for (let i = 0; i < k; i++) await op();
return performance.now() - t0;
}

/** Ops per batch so one batch fills about TARGET_MS. */
async function calibrate(op: Op): Promise<number> {
let k = 1;
for (;;) {
const ms = await batchMs(k, op);
if (ms >= TARGET_MS / 4) return Math.max(3, Math.ceil((k * TARGET_MS) / ms));
k *= 4;
}
}

/** One sample: µs per op, the no-op await baseline subtracted. */
async function sample(k: number, op: Op): Promise<number> {
const base = await batchMs(k, noop);
return ((await batchMs(k, op)) - base) * (1000 / k);
}

/** mulberry32: a seeded PRNG, so a rerun of the same samples gives the same CI. */
function prng(seed: number): () => number {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}

/** 95% percentile-bootstrap CI of the median paired gain. */
function pairedCi(gains: number[]): [number, number] {
const random = prng(0x5eed);
const medians: number[] = [];
for (let r = 0; r < RESAMPLES; r++) {
medians.push(median(gains.map(() => gains[Math.floor(random() * gains.length)])));
}
medians.sort((a, b) => a - b);
return [medians[Math.floor(RESAMPLES * 0.025)], medians[Math.ceil(RESAMPLES * 0.975) - 1]];
}

const median = (xs: number[]) => {
const s = [...xs].sort((a, b) => a - b);
const m = s.length >> 1;
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};

interface Arm {
ops: Record<'encrypt' | 'decrypt', Op>;
k: Record<'encrypt' | 'decrypt', number>;
us: Record<'encrypt' | 'decrypt', number[]>;
}

async function arm(bindings: EncryptionBindings, data: Uint8Array) {
const manager = new EncryptionManagerCore(MASTER_KEY, undefined, async () => bindings);
const ciphertext = await manager.encrypt(data, KEY);
const ops = {
encrypt: () => manager.encrypt(data, KEY),
decrypt: () => manager.decrypt(ciphertext, KEY),
};
const k = { encrypt: await calibrate(ops.encrypt), decrypt: await calibrate(ops.decrypt) };
const result: Arm = { ops, k, us: { encrypt: [], decrypt: [] } };
return { arm: result, manager };
}

it('WebCrypto vs wasm AES-256-GCM, interleaved A/B with an A/A row', async () => {
const rows: string[] = [];
let failures = 0;
for (const size of SIZES) {
const data = new Uint8Array(size);
for (let i = 0; i < size; i++) data[i] = (i * 31 + 7) & 0xff;
const built = [
await arm(wasmBindings(), data), // A
await arm(webCryptoBindings(), data), // B
await arm(wasmBindings(), data), // A/A
];
const arms = built.map((b) => b.arm);
for (let rep = 0; rep < reps(size); rep++) {
for (let i = 0; i < arms.length; i++) {
const a = arms[(rep + i) % arms.length];
for (const op of ['encrypt', 'decrypt'] as const)
a.us[op].push(await sample(a.k[op], a.ops[op]));
}
}
for (const b of built) b.manager.dispose();

for (const op of ['encrypt', 'decrypt'] as const) {
const [a, b, aa] = arms.map((x) => median(x.us[op]));
const floor = Math.abs(a - aa) / a;
const gain = (a - b) / a;
const [lo, hi] = pairedCi(arms[0].us[op].map((ai, i) => (ai - arms[1].us[op][i]) / ai));
const verdict =
gain > floor || lo > 0 ? 'PASS' : gain < -floor || hi < 0 ? 'REGRESS' : 'PARITY';
if (verdict !== 'PASS') failures++;
rows.push(
[
String(size).padStart(8),
op.padEnd(8),
a.toFixed(2).padStart(10),
b.toFixed(2).padStart(10),
aa.toFixed(2).padStart(10),
`${(floor * 100).toFixed(1)}%`.padStart(8),
`${(gain * 100).toFixed(1)}%`.padStart(8),
`${(a / b).toFixed(1)}x`.padStart(8),
`[${(lo * 100).toFixed(1)}%, ${(hi * 100).toFixed(1)}%]`.padStart(18),
verdict,
].join(' ')
);
}
}
console.log(
[
'AES-256-GCM per op in local workerd (indicative): A = wasm core, B = WebCrypto',
' size op A µs B µs A/A µs A/A flr gain speedup paired 95% CI',
...rows,
].join('\n')
);
expect(failures).toBe(0);
});
12 changes: 12 additions & 0 deletions packages/cachekit/bench/workers-crypto/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import workers from '../../vitest.workers.config.js';

// The Workers test lane's workerd setup (wasm module loading included),
// running only the A/B harness, with room for its timed loops.
export default {
...workers,
test: {
...workers.test,
include: ['bench/workers-crypto/ab.bench-workers.ts'],
testTimeout: 600_000,
},
};
1 change: 1 addition & 0 deletions packages/cachekit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"check:workers-bundle": "node scripts/check-workers-bundle.mjs",
"bench:ir": "node bench/ir.mjs",
"bench:cold-start": "node bench/cold-start.mjs",
"bench:workers-crypto": "vitest run --config bench/workers-crypto/vitest.config.ts --reporter=verbose",
"bench:runtime": "node bench/runtime/run.mjs",
"bench:live": "node bench/live/wall-time-probe.mjs",
"bench:readme": "node bench/readme.mjs"
Expand Down
Loading
Loading