Skip to content

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:

  1. A random 252-bit scalar is generated via crypto.getRandomValues with bounded rejection sampling.
  2. 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).
  3. The encrypted blob (with a fresh random 16-byte salt and 12-byte IV) is stored in IndexedDB.
  4. 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:

  1. Generated by a CSPRNG (crypto.randomBytes on Node.js, crypto.getRandomValues in browsers).
  2. Stored server-side with a configurable TTL (default: 60 seconds).
  3. 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 ≠ c2

The 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 / importKeyBlob to 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 ArrayBuffer before the fill.
  • The bigint scalars used internally by @noble/curves cannot 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:

ts
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: browserComputeProofverifyProof always returns true for 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: verifyProof returns false (never throws) for tampered material.
    • Nonce zero-fill: the nonce buffer is zeroed after use.
  • Key storage is verified by a shared contract test suite running against both MemoryKeyStorage and IndexedDBKeyStorage (via fake-indexeddb), covering:
    • Correct round-trip: generateAndStoreunlock produces a scalar x where x·G == publicKey.
    • Wrong PIN: AES-GCM tag mismatch throws DECRYPTION_FAILED.
    • Blob transfer: exportBlob / importBlob produces the same private key on the target.
    • Salt uniqueness: two generateAndStore calls always produce different salts.
  • 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, p values).
  • 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 KeyStorage backend (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.

Released under the MIT License.