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

Password confidentiality

The plaintext password never leaves the browser. It is fed into PBKDF2 to derive an Ed25519 keypair, and then discarded. The server stores only the 32-byte public key. Even if the server's database is fully compromised, an attacker gains:

  • A list of public keys — which are mathematically useless without the corresponding private keys.
  • No password hashes — so offline dictionary attacks are impossible by design.

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. It is visible to:

  • Any JavaScript running in the same origin — XSS that gains code execution can read it.
  • Browser devtools if the user or an attacker opens them.
  • Memory inspection tools on the user's device.

This is an inherent limitation of JavaScript cryptography. The key can be derived again from credentials, so it doesn't need to be persisted — but it exists in memory for the duration of the session.

Server-side key management

The server stores public keys. This library provides no built-in mechanism for:

  • Key rotation — changing a user's keypair requires re-registration.
  • Key revocation — there is no built-in blocklist; deleting the public key from your database is the only revocation mechanism.
  • Multi-device — each device that derives the keypair from the same credentials produces the same public key, so multi-device works transparently, but sharing credentials defeats per-device revocation.

Transport security

ZKP Auth does not encrypt or authenticate the network channel. Deploy behind HTTPS. Without TLS:

  • An attacker can capture and replay 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: knowledge of the username and password. It provides no second factor and does not integrate with TOTP, WebAuthn, or hardware keys.

Password strength enforcement

The library accepts any password (including empty string) at the protocol level. Password strength policy (minimum length, complexity, breach-password checks) must be enforced at the application layer before calling register().

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

r_bytes.fill(0) is called on the nonce buffer 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 r and x 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 password, they lose access. The protocol cannot be used to recover credentials — a password-derived key is only as recoverable as the password itself. Implement an out-of-band recovery mechanism (email-based re-registration, recovery codes) at the application layer.


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, PBKDF2) 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: computeProofverifyProof 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.
    • Password no-op: changing password with a fixed nonce produces identical proofs.
    • Nonce zero-fill: the nonce buffer is zeroed after use.
  • 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.
  • The __forTesting__ fixed-nonce escape hatch is grep-asserted to appear exactly once in src/ and is absent from all public exports.

What has NOT been done

  • Independent review of the Fiat-Shamir transcript construction.
  • 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.
  • Review of the PBKDF2 parameterisation (iterations, salt construction).

Reporting vulnerabilities

Please report security issues by email to security@your-org.example rather than opening a public GitHub issue. We aim to acknowledge reports within 48 hours.

Released under the MIT License.