kem-dem-wasm
v0.4.1
Published
WASM KEM-DEM hybrid encryption with flexible field support and a ZK-friendly authenticated DEM
Readme
kem-dem-wasm
A production-grade WebAssembly package for hybrid public-key encryption in React, implementing:
- HPKE (RFC 9180) — standard hybrid encryption with per-field sealing
- ZK-friendly KEM-DEM — BabyJubJub-based encryption over BN254
Frfield elements for zero-knowledge circuit integration
Overview
This library provides React-friendly WASM bindings for two complementary encryption systems:
- HPKE mode — encrypts arbitrary JavaScript objects field-by-field under a single HPKE session, binding each field name as authenticated associated data (AAD) to prevent cross-field ciphertext replay.
- ZK mode — encrypts payloads of BN254 scalar field (
Fr) elements using a BabyJubJub KEM-DEM construction, producing ciphertexts that can be efficiently processed inside SNARK/STARK circuits.
Architecture
| Layer | Standard | Implementation |
|---|---|---|
| KEM | DHKEM(X25519, HKDF-SHA256) | hpke crate |
| KDF | HKDF-SHA256 | hpke crate |
| AEAD | AES-256-GCM | hpke crate |
| Field encryption | HPKE seal with per-field AAD | Custom orchestration |
| Key storage | zeroize + ZeroizeOnDrop | Rust-side only |
The implementation uses HPKE Base mode (no sender authentication, no PSK). Each encryptFields call performs one HPKE setup_sender to create a single session context, then seals every field under that context in deterministic field-name order. The field name is bound as AAD, so ciphertexts cannot be replayed across fields.
Installation
npm install kem-dem-wasm(Optional) If you wish to build the package from source:
Prerequisites
Build the WASM Package
# Build for web (browser/Vite)
wasm-pack build --target web --out-dir pkg
# Build for Node.js (scripts / Circom input generation)
wasm-pack build --target nodejs --out-dir pkg-nodeRun Tests
# Native unit tests
cargo test --lib
# WASM integration tests (requires Chrome)
wasm-pack test --headless --chromeReact API
Initialize
import init, { KemDem } from 'kem-dem-wasm'
await init()
const kemDem = new KemDem()Generate Keypair
const kp = kemDem.generateKeypair()
// kp.publicKey → Uint8Array (32 bytes)
// kp.secretKey → Uint8Array (32 bytes)Encrypt Fields
const pkg = kemDem.encryptFields(kp.publicKey, {
ssn: '123-45-6789',
dob: '1990-01-01',
salary: '150000',
})
// pkg.kemCiphertext → Uint8Array (HPKE encapsulated key)
// pkg.getField('ssn') → Uint8Array (encrypted field ciphertext)
// pkg.fieldNames() → Array<string>Decrypt Fields
const plain = kemDem.decryptFields(kp.secretKey, pkg)
// plain → { ssn: '123-45-6789', dob: '1990-01-01', salary: '150000' }Low-Level Single-Blob API
// Encrypt a single blob
const blob = kemDem.encrypt(kp.publicKey, new TextEncoder().encode('secret data'))
// Decrypt a single blob
const decrypted = kemDem.decrypt(kp.secretKey, blob)Ethereum Wallet Integration
Derive a deterministic X25519 encryption keypair from an Ethereum wallet seed phrase, so users don't need to manage a separate encryption key.
Option A: BIP-32 Derivation (software wallets with seed access)
import { HDNodeWallet, getBytes } from 'ethers'
// The library exposes the canonical BIP-44 path for encryption keys
const path = KemDem.encryptionDerivationPath() // "m/44'/60'/0'/2147483647'/0"
// Derive the child private key directly at the encryption path
const node = HDNodeWallet.fromPhrase(mnemonic, "", path)
const ikm = getBytes(node.privateKey) // 32 bytes
const addr = getBytes(signerAddress) // 20 bytes
const kp = kemDem.deriveKeypairFromIkm(ikm, addr)
// kp.publicKey → Uint8Array (32 bytes, publish on-chain)
// kp.secretKey → Uint8Array (32 bytes, keep local)Option B: Sign-to-Derive (MetaMask / EIP-1193 wallets)
// One-time signature prompt (EIP-712 typed data when available; falls back to personal_sign)
const chainId = Number(BigInt(await provider.request({ method: 'eth_chainId' })))
const path = KemDem.encryptionDerivationPath()
// NOTE: this typed-data payload is *only* used to derive a local
// encryption key via `deriveKeypairFromSignature`. There is no
// on-chain verifier, so the EIP-712 domain intentionally omits
// `verifyingContract`. Do NOT copy this snippet for the
// X25519KeyRegistry's `registerFor` path — that signature uses the
// registry's own EIP-712 domain (which DOES include
// `verifyingContract`) defined inside the contract.
const typedData = {
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
],
KemDemDerive: [
{ name: 'action', type: 'string' },
{ name: 'path', type: 'string' },
],
},
primaryType: 'KemDemDerive',
domain: { name: 'kem-dem-wasm', version: '1', chainId },
message: { action: 'derive-encryption-key', path },
}
let sigHex
try {
sigHex = await provider.request({
method: 'eth_signTypedData_v4',
params: [signerAddress, JSON.stringify(typedData)],
})
} catch {
sigHex = await provider.request({
method: 'personal_sign',
params: ['kem-dem-wasm/v1/derive-encryption-key', signerAddress],
})
}
const sig = getBytes(sigHex) // 65 bytes
const addr = getBytes(signerAddress) // 20 bytes
const kp = kemDem.deriveKeypairFromSignature(sig, addr)Security note: The derived secret key is exposed to the JS garbage collector once returned to the browser. Never persist it in cleartext — cache in memory for the session only, or encrypt at rest with a user passphrase.
Hardware wallet note: Hardware wallets (Ledger, Trezor) cannot natively derive X25519 keys. Use Option B (sign-to-derive) for hardware wallet users. The encryption secret key will live in software on the host.
Signer Determinism Self-Check
Before using a wallet for sign-to-derive, verify that it signs deterministically (RFC 6979). A non-deterministic signer produces a different key every time, which would lock the user out of past ciphertexts.
// Prompt the wallet twice for the same derivation message
const sigA = await provider.request({
method: 'personal_sign',
params: ['kem-dem-wasm/v1/derive-encryption-key', signerAddress],
})
const sigB = await provider.request({
method: 'personal_sign',
params: ['kem-dem-wasm/v1/derive-encryption-key', signerAddress],
})
// Throws if the signer is non-deterministic
KemDem.verifySignerIsDeterministic(
getBytes(sigA),
getBytes(sigB),
)On-chain Key Registry
Once a user has derived their X25519 public key, they need a way to publish it so that other parties can encrypt to them just from their EVM address. The repo ships a minimal Solidity registry at contracts/X25519KeyRegistry.sol.
Design:
bytes32 pubkeyper(account, version)— X25519 keys are exactly 32 B and pack into one storage slot.register(uint32 version, bytes32 pubkey)— EOA self-registration (msg.sender == account).registerFor(account, version, pubkey, deadline, sig)— EIP-712 typed-data path for contract / 4337 / meta-tx accounts. Per-accountregistrationNoncemakes every signature single-use (replay-after-revoke is blocked).revoke(version)— marks a version permanently dead. Cannot be re-registered — caller must use a fresh version number to rotate.getLatest(account)/get(account, version)/isRegistered(account, version)— sender-side lookups.- ECDSA path enforces low-s (EIP-2) and rejects
address(0). ERC-1271 path supported automatically for contract accounts.
Sender flow:
import { Contract, getBytes, hexlify } from 'ethers'
const registry = new Contract(REGISTRY_ADDR, REGISTRY_ABI, provider)
const record = await registry.getLatest(recipientAddress)
const pubKey = getBytes(record.pubkey) // 32 B X25519 pubkey
const pkg = kemDem.encryptFields(pubKey, { ssn: '...' })
// post pkg anywhere (IPFS, calldata, off-chain DB)Recipient publish flow (EOA):
const kp = kemDem.deriveKeypairFromIkm(ikm, addr)
const pubkeyHex = hexlify(kp.publicKey) // 32 B → bytes32
const registry = new Contract(REGISTRY_ADDR, REGISTRY_ABI, signer)
await registry.register(1, pubkeyHex) // version 1To rotate, derive a v2 keypair (e.g. via a v2 info string) and call registry.register(2, newPubkeyHex). Senders calling getLatest automatically pick up the new version.
Deployment: the contract is non-upgradeable on purpose. New derivation schemes get a new contract address; the
SCHEMAconstant (keccak256("kem-dem-wasm/v1/x25519-pubkey")) makes the wire format self-describing.
Security Properties
- Standardized KEM-DEM: Uses HPKE (RFC 9180) instead of a custom construction. The key schedule, nonce derivation, and context binding are all handled by the standard.
- Deterministic field order: Fields are encrypted and decrypted in sorted
BTreeMaporder, eliminatingHashMapiteration nondeterminism. - Per-field AAD with manifest binding (v2): Each field name is authenticated as AAD (
kem-dem-wasm/v2/field:<name>\x00<manifest>), and the sorted field-name set is bound into a manifest hash that is mixed into every field's AAD. This prevents cross-field ciphertext replay, silent field drops, field additions, and field renames. v1 ciphertexts are intentionally not decryptable by v2. - Memory safety: Secret keys use
zeroizein Rust, though they are still exposed to JS GC once returned to the browser.
Example App
A complete React demo is included in examples/react-demo/:
cd examples/react-demo
npm install
npm run devOpen http://localhost:5173 in your browser.
Note: Because the WASM package is symlinked from
../../pkg, Vite's file-system allow list must include the parent directory. Seevite.config.jsfor the configuration.
Build for Production
cd examples/react-demo
npm run buildOn-Chain Key Registry
The contracts/X25519KeyRegistry.sol contract stores X25519 public keys on-chain, indexed by (account, version). It supports two registration paths:
Self-Registration (EOAs)
registry.register(1, pubkeyBytes32);msg.sender is the authenticator — no signature needed.
Delegated Registration (Contract Accounts / 4337 / Meta-Tx)
// Relayer submits on behalf of `account`
registry.registerFor(account, version, pubkey, deadline, eip712Signature);The signature is an EIP-712 typed-data signature over:
Register(address account, uint32 version, bytes32 pubkey, uint256 nonce, uint256 deadline)Key security properties:
- Per-account nonce prevents replay (including replay-after-revoke)
- EIP-712 typed data — wallets display the fields before signing
- Low-s enforcement (EIP-2) on ECDSA signatures
- ERC-1271 support for contract account signatures
- Fork-safe domain separator — recomputed if
chainidchanges
Key Revocation
registry.revoke(version); // Only msg.sender can revoke their own keysRevoked version slots are permanently dead — the account must register with a fresh version number.
getLatestsemantics after revocation:revokedoes not rolllatestVersionback. If the latest version of an account is revoked and no higher version has been registered,getLatestreverts withLatestRevoked(uint32 latestVersion). Sender clients must catch this error and surface a "key was revoked, please re-register" message rather than treating the account as never having registered (whichUnknownVersionwould imply). This is deliberate — silently scanning backwards for an older active version would defeat the purpose of revocation.
Lookups
// Latest active key
Record memory r = registry.getLatest(account);
// Specific version (may be revoked or empty)
Record memory r = registry.get(account, version);
// Cheap presence check
bool active = registry.isRegistered(account, version);ZK-Friendly Encryption (BabyJubJub KEM-DEM)
In addition to the HPKE/X25519 API, the library provides a ZK-friendly encryptor built on the BabyJubJub curve over BN254. This is designed for encrypting payloads that will later be processed inside ZK circuits (e.g., SNARKs/STARKs), where operations over the BN254 scalar field Fr are native.
Architecture
| Layer | Primitive | Purpose |
|---|---|---|
| Curve | BabyJubJub (Edwards over BN254) | ZK-native: point ops are cheap in-circuit |
| KEM | ElGamal-style ephemeral key exchange | ephemeral = G * r, shared = receiver_pub * r |
| DEM | Poseidon-derived keystream + field addition | Each payload element: ciphertext[i] = payload[i] + keystream[i] |
| Encoding | Inputs are 32-byte big-endian Fr hex; ciphertext is hex-encoded 32-byte little-endian Fr elements | Matches the Rust/Circom wire format |
The ciphertext format is:
[ct_0][ct_1]...[ct_n][ephemeral_x][ephemeral_y]Each element is 32 bytes. The last two elements are the uncompressed ephemeral public key, allowing the receiver to recompute the shared secret and subtract the keystream.
API
import init, { ZkEncryptor } from 'kem-dem-wasm'
await init()
// Generate a random BabyJubJub keypair
const kp = ZkEncryptor.generateKeypair()
// kp.secretKey → "0x..." (64 hex chars)
// kp.publicKey.x → "0x..." (64 hex chars, BabyJubJub X coordinate)
// kp.publicKey.y → "0x..." (64 hex chars, BabyJubJub Y coordinate)
// Encrypt a payload of Fr field elements (array of 0x-prefixed 64-char hex strings)
const payload = [
'0x0000000000000000000000000000000000000000000000000000000000000001',
'0x0000000000000000000000000000000000000000000000000000000000000002',
]
// ── Authenticated (recommended for anything outside a SNARK that
// itself enforces integrity). Includes a Poseidon MAC tag.
const ctAuth = ZkEncryptor.encryptAuthenticated(
kp.publicKey.x, kp.publicKey.y, payload,
)
// ctAuth → hex string, length = (payload.length + 3) * 64
const ptAuth = ZkEncryptor.decryptAuthenticated(kp.secretKey, ctAuth)
// ptAuth → ["0x...", "0x..."] (throws if the MAC tag does not verify)
// ── Confidentiality-only (use only when an enclosing SNARK or
// other channel guarantees integrity).
const ct = ZkEncryptor.encrypt(kp.publicKey.x, kp.publicKey.y, payload)
// ct → hex string, length = (payload.length + 2) * 64
const pt = ZkEncryptor.decrypt(kp.secretKey, ct)
// pt → ["0x...", "0x..."]Custom Curve Support (Curve-Agnostic Mode)
By default, the library uses the built-in BabyJubJub curve. However, you can use the curve-agnostic API (*On methods) to perform encryption on any custom Twisted Edwards curve over the BN254 scalar field:
import { ZkCurve, ZkEncryptor } from 'kem-dem-wasm'
// Define a custom curve (a, d, generator X, generator Y, scalar order, cofactor)
// Parameters must be valid: complete curve, on-curve generator, prime-order subgroup.
// The built-in default parameters are shown here as an example:
const myCurve = new ZkCurve(
"0x00000000000000000000000000000000000000000000000000000000000292fc", // a
"0x00000000000000000000000000000000000000000000000000000000000292f8", // d
"0x248f21900a0b22a01d1fa4e0c4014d7a86071060938b8c2c1c68f638148b59d7", // gx
"0x2df7db445a6c4b2b3504104597b83f3d790d96d741c8888b1d1cd780ebbd2f17", // gy
"2736030358979909402780800718157159386076813972158567259200215660948447373041", // scalar_order
8n // cofactor
)
// Generate a keypair on the custom curve
const kp = ZkEncryptor.generateKeypairOn(myCurve)
// Verify derived public key from a secret key on the custom curve
const pk = ZkEncryptor.publicKeyFromSecretOn(myCurve, kp.secretKey)
// Encrypt payload
const ct = ZkEncryptor.encryptAuthenticatedOn(
myCurve, kp.publicKey.x, kp.publicKey.y, payload
)
// Decrypt payload
const pt = ZkEncryptor.decryptAuthenticatedOn(myCurve, kp.secretKey, ct)
// Unauthenticated variants are also available:
// ZkEncryptor.encryptOn(myCurve, ...)
// ZkEncryptor.decryptOn(myCurve, ...)The arithmetic for custom curves runs through a constant-time runtime backend, maintaining the exact same Poseidon-based KEM-DEM construction but using your specified curve parameters.
Domain-Separated Encryption
For protocols that share the same BabyJubJub key material but require cryptographic isolation, the library supports caller-supplied domain constants. Domain constants provide separation between the KEM and DEM layers and — crucially — between different protocols:
// Domain constants are 0x-prefixed 64-char hex Fr254 values.
// Convention: SHA256 hash of a descriptive protocol string, reduced mod the field.
const kemDomain = '0x...' // e.g. Fr(SHA256("MyProtocol|PurposeKEM"))
const demDomain = '0x...' // e.g. Fr(SHA256("MyProtocol|PurposeDEM"))
// ── Unauthenticated domain-separated encrypt/decrypt
const ct = ZkEncryptor.encryptWithDomains(
kp.publicKey.x, kp.publicKey.y, payload,
kemDomain, demDomain,
false, // compress_epk: false = uncompressed [epk_x, epk_y]
)
const pt = ZkEncryptor.decryptWithDomains(
kp.secretKey, ct, kemDomain, demDomain, false,
)
// ── Authenticated domain-separated (recommended)
const ctAuth = ZkEncryptor.encryptAuthenticatedWithDomains(
kp.publicKey.x, kp.publicKey.y, payload,
kemDomain, demDomain,
false, // compress_epk
)
const ptAuth = ZkEncryptor.decryptAuthenticatedWithDomains(
kp.secretKey, ctAuth, kemDomain, demDomain, false,
)Compressed EPK Encoding
Set compress_epk = true to store the ephemeral public key in compressed form ([epk_y, sign_flag] instead of [epk_x, epk_y]). The ciphertext length is unchanged (still 2 trailing elements) but the encoding saves bandwidth in circuits that only need the y-coordinate:
const ct = ZkEncryptor.encryptWithDomains(
kp.publicKey.x, kp.publicKey.y, payload,
kemDomain, demDomain,
true, // compressed
)
// Decompress must also use compress_epk = true
const pt = ZkEncryptor.decryptWithDomains(
kp.secretKey, ct, kemDomain, demDomain, true,
)Note: The decrypt functions handle EPK decompression internally. If you need to recover
xfrom a compressed point outside the encrypt/decrypt flow (e.g., reading a compressed public key from on-chain storage), useZkCurve.recoverX()— see Point Decompression below.
Point Decompression (recoverX)
ZkCurve.recoverX(y_hex, sign) recovers the x coordinate of a point on the curve from its y coordinate and a sign bit, using the curve equation a·x² + y² = 1 + d·x²·y².
The sign parameter follows the arkworks convention: true means x > (p - 1) / 2 (i.e., x is in the upper half of the field). This matches the sign flag produced by compress_epk = true.
import { ZkCurve } from 'kem-dem-wasm'
const curve = ZkCurve.defaultV1()
// Given a compressed point (y coordinate + sign bit from on-chain data)
const y = '0x2df7db445a6c4b2b3504104597b83f3d790d96d741c8888b1d1cd780ebbd2f17'
const sign = false // sign bit: true if x > (p-1)/2, false otherwise
const x = curve.recoverX(y, sign)
// x → "0x..." (0x-prefixed 64-char BE hex)Throws if:
ydoes not correspond to any point on the curve (no square root exists)- The denominator
a - d·y²is zero with a non-zero numerator signistruebut the only validxis zero
Use Cases
- Private voting: Encrypt votes as Fr elements, prove correctness in a SNARK without revealing plaintext
- Confidential transfers: Encrypt amounts as field elements, verify balance constraints in-circuit
- ZK identity: Encrypt identity attributes for selective disclosure proofs
Security Notes
- Prefer
encryptAuthenticated/decryptAuthenticatedfor any data that is not consumed inside a SNARK that itself enforces integrity. The authenticated variant appends a Poseidon MAC tag bound to the shared secret and the ephemeral public key, and the decrypt path verifies the tag in constant time before returning plaintext. - Domain separation prevents cross-protocol attacks when multiple protocols share the same BabyJubJub key material. Use
encryptAuthenticatedWithDomainswith unique constants derived fromSHA256("ProtocolName|Purpose")to isolate protocols cryptographically. - The KEM uses a fresh random ephemeral scalar
rper encryption. Reusingrleaks the payload. - The unauthenticated
encrypt/decryptDEM is a Poseidon-derived stream cipher (addition in the field). It provides confidentiality but no authentication, so a bit-flip in the ciphertext flips the corresponding plaintext bit silently. Only use it when an enclosing SNARK or other channel guarantees integrity; otherwise use the authenticated variant or the HPKE API. - The ciphertext stores the ephemeral public key uncompressed
(x, y)by default. Usecompress_epk = truewith the domain-separated API to store a compressed encoding[epk_y, sign_flag]when bandwidth is a concern.
License
MIT
