Zero-Knowledge Proof Authentication for APIs
Prove you know the secret. Never reveal it.
2FApi is a zero-knowledge proof authentication protocol for APIs. Instead of sharing secrets with the server (like passwords or API keys), the client proves it knows a secret without ever revealing it.
The server stores zero secrets. If your database is breached, the attacker gets cryptographic commitments — mathematically useless without the client's secret.
Client Server
│ │
│──── 1. Enroll (commitment C) ─────────>│ Stores C = s·G + r·H
│ │ (never sees s or r)
│ │
│<──── 2. Challenge (nonce n) ───────────│ Issues random nonce
│ │
│──── 3. Proof (A, z_s, z_r) ──────────>│ Verifies: z_s·G + z_r·H == A + c·C
│ │ where c = H(G‖H‖C‖A‖n)
│ │
│<──── 4. Access Token ─────────────────│ Issues JWT/session
| Property | Description |
|---|---|
| Zero-knowledge | Server learns nothing about the client's secret |
| No shared secrets | Server stores only commitments, never passwords or keys |
| Replay-resistant | Single-use nonces with TTL prevent proof reuse |
| Constant-time | All cryptographic operations resist timing attacks |
| 128-bit security | Ristretto255 curve (same level as Ed25519) |
| Package | Description | Language |
|---|---|---|
crypto-core |
Pedersen commitments & Sigma proofs over Ristretto255 | Rust |
crypto-core/napi |
Node.js native bindings (napi-rs) | Rust → Node.js |
extensions/pg-extension |
PostgreSQL extension (pg_2fapi) |
Rust (pgrx) |
extensions/redis-module |
Redis module (redis-2fapi) |
Rust |
packages/client-sdk |
Client SDK for browsers & Node.js | TypeScript |
packages/protocol-spec |
Protocol specification | TypeScript |
# Build & run with Docker
docker build -f extensions/pg-extension/Dockerfile.pg16 -t pg-2fapi:16 .
docker run -d -p 5440:5432 -e POSTGRES_PASSWORD=dev \
-e POSTGRES_USER=twofapi -e POSTGRES_DB=twofapi pg-2fapi:16
# Connect and use
psql -h localhost -p 5440 -U twofapi -d twofapi-- Enroll a client
SELECT twofapi.enroll('my-service', commitment_bytes, proof_bytes);
-- Request a challenge
SELECT twofapi.issue_challenge('my-service');
-- Get the nonce for proof construction
SELECT twofapi.get_challenge_nonce('my-service', 'ch-abc123');
-- Authenticate (verify proof + establish session)
SELECT twofapi.authenticate('my-service', 'ch-abc123', proof_bytes);
-- Use in Row-Level Security policies
ALTER TABLE my_data ENABLE ROW LEVEL SECURITY;
CREATE POLICY zkp_policy ON my_data
USING (owner = twofapi.current_client());
-- Now queries are automatically filtered
SELECT * FROM my_data; -- only returns rows owned by authenticated client# Build & run with Docker
docker build -f extensions/redis-module/Dockerfile -t redis-2fapi .
docker run -d -p 6380:6379 redis-2fapi
# Connect and use
redis-cli -p 6380> 2FAPI.ENROLL my-service <commitment_hex>
OK
> 2FAPI.CHALLENGE my-service
1) "ch-a1b2c3d4e5f67890"
2) "a1b2c3d4e5f6789001234567890abcde"
> 2FAPI.VERIFY my-service ch-a1b2c3d4e5f67890 <proof_hex>
OK
> 2FAPI.WHOAMI
"my-service"
> 2FAPI.STATUS my-service
"active"
# Cargo.toml
[dependencies]
twofapi-crypto-core = { git = "https://github.com/gthstepsecurity/2fapi", path = "crypto-core" }use twofapi_crypto_core as crypto;
// Generate commitment (client-side)
let (g, h) = crypto::generators();
let commitment = crypto::commit(&secret, &blinding);
// Verify proof (server-side)
let valid = crypto::verify_equation_raw(
&g_bytes, &h_bytes, &commitment_bytes,
&announcement, &challenge, &response_s, &response_r,
);cd crypto-core/napi && npm install && npm run buildconst crypto = require('./crypto-core/napi');
// Generators
const G = crypto.getGeneratorG(); // 32 bytes
const H = crypto.getGeneratorH(); // 32 bytes
// Commitment (client-side)
const commitment = crypto.commit(secret, blinding);
// Proof generation (client-side)
const proof = crypto.generateProof({
secret, blinding, commitment,
generatorG: G, generatorH: H,
transcriptData: buildTranscript(...)
});
// Proof verification (server-side)
const valid = crypto.verifyProofEquation({
generatorG: G, generatorH: H,
commitment, announcement,
challenge, responseS, responseR
});Rust backend:
[dependencies]
twofapi-crypto-core = { git = "https://github.com/gthstepsecurity/2fapi", path = "crypto-core" }Node.js backend:
npm install @2fapi/crypto-napiCREATE TABLE zk_commitments (
user_id UUID PRIMARY KEY REFERENCES users(id),
commitment BYTEA NOT NULL CHECK (length(commitment) = 32),
status TEXT NOT NULL DEFAULT 'active',
commitment_version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);POST /auth/zkp/enroll
Body: { commitment: <hex>, proof: <hex> }
Server:
1. Validate commitment (32 bytes, canonical Ristretto255, not identity)
2. Validate proof of possession (96 bytes, canonical encodings)
3. Verify proof opens the commitment
4. Store commitment for the authenticated user
5. Return success
POST /auth/zkp/challenge
Body: { user_id: <uuid> }
Server:
1. Verify user exists and has active commitment
2. Generate 16 random bytes (nonce) via CSPRNG
3. Store nonce in Redis/memory with 120-second TTL
4. Return { challenge_id, nonce }
POST /auth/zkp/verify
Body: { challenge_id: <string>, proof: <hex> }
Server:
1. Fetch and DELETE challenge (atomic, single-use)
2. Fetch user's commitment
3. Build Fiat-Shamir transcript
4. Compute challenge scalar c = SHA-512(transcript)
5. Verify equation: z_s·G + z_r·H == A + c·C
6. If valid: issue JWT/session token
<!-- Load WASM SDK -->
<script src="@2fapi/client-sdk/dist/2fapi.min.js"></script>// On login page
const { commitment, secret, blinding } = await TwoFApi.generateCredential();
// Store secret securely (browser keychain or derived from recovery phrase)
// On each authentication
const challenge = await fetch('/auth/zkp/challenge', { method: 'POST', ... });
const proof = await TwoFApi.generateProof(secret, blinding, challenge.nonce);
const result = await fetch('/auth/zkp/verify', { body: { proof }, ... });| Primitive | Implementation |
|---|---|
| Curve | Ristretto255 (via curve25519-dalek v4) |
| Commitment | Pedersen: C = s·G + r·H |
| Proof | Schnorr/Sigma protocol (non-interactive via Fiat-Shamir) |
| Hash | SHA-512 with domain separation |
| Generator G | Ristretto255 basepoint |
| Generator H | Hash-to-point with DST "2FApi-Pedersen-GeneratorH-v1" |
Length-prefixed fields (4-byte big-endian):
LP("2FApi-v1.0-Sigma") ‖ LP(G) ‖ LP(H) ‖ LP(C) ‖ LP(A) ‖ LP(clientId) ‖ LP(nonce) ‖ LP(channelBinding)
All implementations enforce:
- Canonical Ristretto255 point encoding
- Canonical scalar encoding (reduced mod group order)
- Identity element rejection (commitment, announcement)
- Zero challenge rejection
- Zero response scalar rejection
- Constant-time equation comparison (
subtle::ct_eq)
| Function | Description |
|---|---|
twofapi.enroll(client_id, commitment, proof) |
Register with proof of possession |
twofapi.issue_challenge(client_id) |
Get a fresh challenge nonce |
twofapi.get_challenge_nonce(client_id, challenge_id) |
Retrieve nonce for proof |
twofapi.authenticate(client_id, challenge_id, proof) |
Verify proof + establish session |
twofapi.verify(client_id, challenge_id, proof) |
Verify proof only |
twofapi.current_client() |
Get authenticated client (for RLS) |
twofapi.suspend_client(client_id) |
Suspend a client (admin) |
twofapi.revoke_client(client_id) |
Revoke permanently (admin) |
twofapi.cleanup(retention_days) |
Purge expired challenges + old audit logs |
twofapi.version() |
Extension version |
- All functions use
SECURITY DEFINERwithSET search_path = twofapi, pg_catalog - Session state stored in Rust process memory (immune to GUC spoofing)
- Admin functions require
twofapi_adminrole - Audit log captures all security events
- Challenge consumed atomically (DELETE ... RETURNING)
- SAVEPOINT replay detection via challenge existence check
| Command | Description |
|---|---|
2FAPI.ENROLL <client_id> <commitment_hex> |
Register a client |
2FAPI.CHALLENGE <client_id> |
Issue a challenge |
2FAPI.VERIFY <client_id> <ch_id> <proof_hex> |
Verify and authenticate |
2FAPI.STATUS <client_id> |
Check enrollment status |
2FAPI.SUSPEND <client_id> |
Suspend a client |
2FAPI.REVOKE <client_id> |
Revoke permanently |
2FAPI.WHOAMI |
Get authenticated client |
2FAPI.INFO |
Module statistics |
- Session state in Rust thread_local memory (not Redis keys)
- Hardened config disables 35+ dangerous Redis commands
- Client ID validation prevents namespace injection
- Atomic challenge consumption (single-threaded guarantee)
- Connection-ID-bound sessions
This project has undergone 6 adversarial red team passes analyzing 720+ attack vectors across the crypto core, PostgreSQL extension, and Redis module.
| Pass | Vectors | Critical Found | Status |
|---|---|---|---|
| 1st | 16 | 3 | All fixed |
| 2nd | 224 | 3 | All fixed |
| 3rd | 224 | 0 | Infra implemented |
| 4th | 217 | 4 | All fixed |
| 5th | ~20 | 0 | 2 HIGH fixed |
| 6th | ~20 | 0 | Formal proofs confirmed |
Current status: 0 open findings. The Sigma protocol correctness has been formally proven (soundness, zero-knowledge, transcript injectivity).
cd crypto-core
cargo test # Run 52 tests
cargo build --release# Requires: cargo-pgrx, PostgreSQL dev headers
cd extensions/pg-extension
cargo test --no-default-features # 29 domain tests (no PG needed)
# Docker build (recommended)
docker build -f Dockerfile.pg16 -t pg-2fapi:16 ../..cd extensions/redis-module
cargo test --no-default-features # 40 domain tests (no Redis needed)
# Docker build (recommended)
docker build -f Dockerfile -t redis-2fapi ../..cd crypto-core/napi
npm install
npm run build
npm test| Component | License |
|---|---|
| crypto-core | Apache-2.0 |
| PostgreSQL extension | Apache-2.0 |
| Redis module | Apache-2.0 |
| Client SDK | Apache-2.0 |
| Protocol spec | Apache-2.0 |
Copyright 2024-2026 Continuum Identity SAS.
The 2FApi verification server is available under Business Source License 1.1 (converts to Apache-2.0 after 4 years).