Security Model
An honest account of what ZKP Auth protects against, what it doesn't, and the current audit status. Read this before deploying to production.
What is protected
No password — no password oracle
ZKP Auth v0.2+ is fully passwordless at the cryptographic protocol level. There is no PBKDF2, no password-derived key, and no way to mount an offline dictionary attack against data leaked from the server.
- Server DB breach reveals only 32-byte Ed25519 public keys — mathematically useless without the corresponding private scalars.
- Public key oracle attacks are impossible: the public key is a random curve point unrelated to any user secret.
Private key confidentiality
The Ed25519 private key is never stored in plaintext and never transmitted.
On registration:
- A random 252-bit scalar is generated via
crypto.getRandomValueswith bounded rejection sampling. - The scalar is encrypted with AES-256-GCM using a wrapping key derived from the user's PIN via Argon2id (
m=65536 KiB, t=3, p=1). - The encrypted blob (with a fresh random 16-byte salt and 12-byte IV) is stored in IndexedDB.
- Only the 32-byte public key is sent to the server.
On login, the private key is held in memory only between decryption and proof assembly, then unconditionally zeroed in a finally block.
PIN brute-force resistance
An attacker who steals the IndexedDB blob must break Argon2id to recover the private key. With m=65536 KiB (64 MB), each guess requires 64 MB of memory and approximately 3 passes of computation. This makes GPU-parallel attacks expensive:
- A GPU with 80 GB VRAM can run ≈ 1280 parallel Argon2id instances.
- At typical Argon2id throughput this translates to a few thousand guesses per second — compared to billions/second for bcrypt/PBKDF2 on the same GPU.
Proof non-replayability
Every authentication session binds to a fresh 32-byte server-issued challenge. The challenge is:
- Generated by a CSPRNG (
crypto.randomByteson Node.js,crypto.getRandomValuesin browsers). - Stored server-side with a configurable TTL (default: 60 seconds).
- Atomically deleted from the store the moment it is consumed for verification.
A captured proof from session A cannot be used for session B — the challenge is different, so c = SHA-512(R ∥ X ∥ challenge) mod L produces a different scalar, and the proof fails.
Timing oracle resistance
The final verification equation (s·G == R + c·X) is compared using crypto.timingSafeEqual on the byte encodings of both points — not with EdwardsPoint.equals(), which works in projective coordinates with data-dependent timing.
Malformed proof material (R that cannot decode to a valid Edwards point, or s ≥ L) causes verifyProof to return false silently — it never throws. This eliminates the oracle that would otherwise distinguish "malformed proof" from "mathematically invalid proof", preventing adaptive chosen-ciphertext-style attacks.
Private key uniformity
Private keys are generated using bounded rejection sampling over the CSPRNG output, not randomBytes() mod L. The difference matters: 2^256 is not a multiple of the Ed25519 group order L, so a modular-reduction approach would skew the distribution toward low scalars. Rejection sampling produces a uniform distribution over [1, L) with no bias.
Nonce freshness
The Schnorr nonce r is drawn from the CSPRNG on every proof computation. It is never reused. If the same r were used across two proofs over different challenges, an attacker could recover the private key algebraically:
s1 = r + c1·x (mod L)
s2 = r + c2·x (mod L)
→ s1 - s2 = (c1 - c2)·x
→ x = (s1 - s2) / (c1 - c2) if c1 ≠ c2The nonce buffer is zero-filled after use (best-effort — see Known Limitations).
What is NOT protected
In-memory private key exposure
The private key is held in the JavaScript heap during proof computation. It is visible to:
- Any JavaScript running in the same origin — XSS that gains code execution can read it during this window.
- Browser devtools if the user or an attacker opens them during authentication.
- Memory inspection tools on the user's device.
The window of exposure is limited: the key is loaded, a proof is computed (microseconds), and then zeroed. It is not held for the duration of the session. However, this brief in-memory exposure is an inherent limitation of JavaScript cryptography.
Device loss
If the device is physically lost, the encrypted IndexedDB blob is accessible to anyone who gains OS-level access to the browser profile. The Argon2id wrapping provides resistance proportional to PIN strength. A short numeric PIN (e.g. 4 digits) provides weak protection against a determined attacker with the device.
Mitigation: encourage users to set a meaningful PIN; implement optional biometric unlock at the application layer once WebAuthn support is added.
Server-side key management
The server stores public keys. This library provides no built-in mechanism for:
- Key rotation — a new device generates a new random keypair; re-registration is required.
- Key revocation — deleting the public key from your database is the only revocation mechanism.
- Multi-device — each device generates an independent random keypair. Use
exportKeyBlob/importKeyBlobto transfer a key to a new device, or re-register with a new keypair on the new device.
Transport security
ZKP Auth does not encrypt or authenticate the network channel. Deploy behind HTTPS. Without TLS:
- An attacker can capture and relay challenges (the TTL still protects against stale replays, but a MITM can relay a live challenge).
- The public key sent at registration is visible in plaintext.
Multi-factor authentication
ZKP Auth is single-factor: possession of the device and knowledge of the PIN. It provides no second factor and does not integrate with TOTP or out-of-band codes. WebAuthn integration (hardware-backed keys) is planned as a KeyStorage backend in a future release.
Brute-force protection
The library provides rateLimitHook on all three middleware factories but does not implement rate limiting itself. You must wire up your own rate limiter (e.g. express-rate-limit, Redis-backed counters) — especially on the /auth/challenge and /auth/verify routes.
Known limitations
JavaScript zeroization
privateKey.fill(0) is called unconditionally in the finally block after proof assembly. However, JavaScript provides no hard zeroization guarantee:
- The JIT compiler may have spilled scalar values to registers that
fill(0)cannot reach. - The garbage collector may have moved the backing
ArrayBufferbefore the fill. - The bigint scalars used internally by
@noble/curvescannot be zeroed from user code.
This is documented as best-effort hygiene. The same limitation applies to all browser-side JavaScript cryptography.
In-memory challenge store is single-process
InMemoryChallengeStore is a Map in the Node.js process heap. In a horizontally scaled deployment (multiple server processes or containers), each process has its own store — a challenge issued by process A cannot be consumed by process B.
Mitigation: implement IChallengeStore backed by Redis or another shared store:
import type { IChallengeStore } from '@zkp-auth/server';
import { redis } from './redis-client';
const store: IChallengeStore = {
async set(sessionId, challenge, ttlMs) {
await redis.set(
`zkp:challenge:${sessionId}`,
Buffer.from(challenge).toString('hex'),
{ PX: ttlMs },
);
},
async consumeIfLive(sessionId) {
const key = `zkp:challenge:${sessionId}`;
// Lua script for atomic get-and-delete
const hex = await redis.eval(
`local v = redis.call('GET', KEYS[1])
redis.call('DEL', KEYS[1])
return v`,
{ keys: [key] },
) as string | null;
return hex ? Buffer.from(hex, 'hex') : null;
},
};JWT secret rotation
zkpVerify signs JWTs with a static HMAC-SHA256 secret. There is no built-in secret rotation or key versioning. Rotating the secret invalidates all existing tokens.
No account recovery flow
If a user forgets their PIN and cannot use importKeyBlob from a backup, they lose access. Implement an out-of-band recovery mechanism (e.g. email-based re-registration) at the application layer. The exportKeyBlob / importKeyBlob API provides a self-service recovery path for users who anticipate device transfer.
IndexedDB availability
IndexedDBKeyStorage requires a browser environment with IndexedDB support. In non-browser environments (Node.js, Electron without indexedDB shim), pass storage: new MemoryKeyStorage() to ZkpAuthClient.
Audit status
No independent audit
This library has not been reviewed by an independent security auditor. The cryptographic implementation follows published standards (Schnorr/Ed25519, Fiat-Shamir, Argon2id, AES-256-GCM) and has an extensive adversarial test suite, but that is not a substitute for a formal audit.
Do not deploy to production without your own security review.
What has been done
- Schnorr protocol correctness is verified by property-based tests covering:
- Round-trip:
browserComputeProof→verifyProofalways returnstruefor matching keys. - Cross-key rejection: proofs never verify under the wrong public key.
- Replay rejection: the same proof does not verify with a different challenge.
- Nonce reuse attack: demonstrated algebraically in an adversarial test.
- Malformed proof handling:
verifyProofreturnsfalse(never throws) for tampered material. - Nonce zero-fill: the nonce buffer is zeroed after use.
- Round-trip:
- Key storage is verified by a shared contract test suite running against both
MemoryKeyStorageandIndexedDBKeyStorage(viafake-indexeddb), covering:- Correct round-trip:
generateAndStore→unlockproduces a scalarxwherex·G == publicKey. - Wrong PIN: AES-GCM tag mismatch throws
DECRYPTION_FAILED. - Blob transfer:
exportBlob/importBlobproduces the same private key on the target. - Salt uniqueness: two
generateAndStorecalls always produce different salts.
- Correct round-trip:
- Timing-safe equality is enforced by an automated audit script that greps for forbidden equality operators (
===,!==,Buffer.equals) on byte arrays derived from cryptographic material.
What has NOT been done
- Independent review of the Argon2id parameterisation (
m,t,pvalues). - Side-channel analysis beyond timing-safe equality at the final comparison point.
- Formal verification of the security reduction.
- Penetration testing of the Express middleware layer.
- WebAuthn
KeyStoragebackend (planned).
Reporting vulnerabilities
Please report security issues by email to security@zkp-auth.dev rather than opening a public GitHub issue. We aim to acknowledge reports within 48 hours.