Password Authentication
This demo builds a privacy-preserving password authentication system using Ligetron. By the end, you'll understand how zero-knowledge proofs enable proving knowledge of a password without ever revealing it.
🔗 Complete source code: github.com/ligeroinc/ligero-prover/tree/main/demo/password-auth
The Problem
Traditional password authentication has a fundamental flaw: the password must be transmitted to the server (even if encrypted in transit). This creates risks:
- Server breaches expose password hashes
- Man-in-the-middle attacks can intercept passwords
- Server operators can see passwords
- Passwords are logged or stored insecurely
What if you could prove you know the password without ever sending it?
The Solution: Zero-Knowledge Proofs
With Ligetron, we can create a system where:
- Client knows password
P - Server knows only
H = SHA256(P) - Client generates a cryptographic proof: "I know a value that hashes to H"
- Server verifies the proof without ever seeing
P
The password never leaves the client device.
What We're Building
An interactive web application with three components:
- WASM Program: Computes SHA-256 and verifies hash match (C++ or Rust)
- Web Interface: User enters password, displays metrics
- Server: Generates and verifies proofs
Prerequisites
Before starting, ensure you have:
- Ligetron built and installed (Installation Guide)
- Emscripten for WASM compilation
- Python 3 for the demo server
- A web browser with WebGPU support
Step 1: The WASM Program (C++)
The core of our demo is a WebAssembly program that verifies password hashes. This program will be proven in zero-knowledge.
Create password_verify.cpp:
#include <ligetron/api.h>
#include <ligetron/sha2.h>
int main(int argc, char *argv[]) {
unsigned char password_hash[32];
// Get password (private input)
const unsigned char* password =
reinterpret_cast<const unsigned char*>(argv[1]);
int password_len = *reinterpret_cast<int*>(argv[2]);
// Get expected hash (public input)
const unsigned char* expected_hash =
reinterpret_cast<const unsigned char*>(argv[3]);
// Compute SHA-256 hash of password
ligetron_sha2_256(password_hash, password, password_len);
// Verify hash matches
for (int i = 0; i < 32; i++) {
assert_one(password_hash[i] == expected_hash[i]);
}
}
Understanding the Code
Arguments:
argv[1]: Password (marked as PRIVATE - stays on client)argv[2]: Password lengthargv[3]: Expected SHA-256 hash (PUBLIC - known to server)
Key Function:
ligetron_sha2_256(): Computes SHA-256 hashassert_one(): Creates a zero-knowledge constraint
The assert_one() calls become mathematical constraints in the proof. The proof is valid only if all constraints are satisfied.
Building the C++ Version
Create CMakeLists.txt:
cmake_minimum_required(VERSION 3.24)
project(PasswordAuthDemo)
set(CMAKE_CXX_STANDARD 20)
# SDK paths
set(SDK_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../sdk/cpp/include")
set(SDK_LIB_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../sdk/cpp/build")
include_directories(${SDK_INCLUDE_DIR})
link_directories(${SDK_LIB_DIR})
add_executable(password_verify password_verify.cpp)
target_link_libraries(password_verify ligetron)
set_target_properties(password_verify PROPERTIES
SUFFIX ".wasm"
LINK_FLAGS "-O2 -sWASM=1 -sSTANDALONE_WASM=1"
)
Build it:
cd demo/password-auth/cpp
mkdir -p build && cd build
emcmake cmake ..
make
This produces password_verify.wasm.
Step 2: The WASM Program (Rust)
Alternatively, build the same logic in Rust for a more modern development experience.
Create src/lib.rs:
use ligetron::*;
use ligetron::sha2::*;
fn main() {
let args = get_args();
// Get password (private input)
let password = args.get_as_bytes(1);
let password_len = args.get_as_int(2) as u32;
// Get expected hash (public input)
let expected_hash = args.get_as_bytes(3);
// Compute SHA-256 hash
let mut password_hash = [0u8; 32];
ligetron_sha2_256(&mut password_hash, password, password_len);
// Verify hash matches
for i in 0..32 {
assert_one(password_hash[i] == expected_hash[i]);
}
}
Create Cargo.toml:
[package]
name = "password-verify-demo"
version = "1.0.0"
edition = "2021"
[dependencies]
ligetron = { path = "../../../sdk/rust" }
[lib]
crate-type = ["cdylib"]
Build it:
cd demo/password-auth/rust
cargo build --target wasm32-wasip1 --release
This produces target/wasm32-wasip1/release/password_verify_demo.wasm.
Step 3: The Web Interface
Now let's create the user interface. We'll use vanilla HTML/CSS/JavaScript for simplicity.
HTML Structure
The interface needs:
- Password input field
- Buttons to generate proof and clear results
- Results display showing metrics
- Console output for transparency
<div class="container">
<h1>🔐 Password Authentication Demo</h1>
<div class="privacy-notice">
Your password is processed locally. Only the proof is sent to the server.
</div>
<input type="password" id="password" placeholder="Try: ligero2024" />
<button id="proveBtn">Generate Proof</button>
<div id="results">
<!-- Metrics displayed here -->
</div>
</div>
JavaScript Logic
The client-side logic:
async function generateProof() {
const password = document.getElementById('password').value;
// Compute SHA-256 locally (stays in browser!)
const passwordHash = await computeSHA256(password);
// Send proof generation request
const response = await fetch('/generate-proof', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
password: password,
expected_hash: EXPECTED_HASH
})
});
const result = await response.json();
// Display metrics
displayMetrics(result.proof_size, result.generation_time);
// Verify the proof
await verifyProof(result.proof_data);
}
async function computeSHA256(text) {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
Step 4: The Server
The server orchestrates proof generation and verification using Ligetron's native binaries.
#!/usr/bin/env python3
import http.server
import json
import subprocess
from pathlib import Path
PROVER_PATH = Path("../../build/webgpu_prover")
VERIFIER_PATH = Path("../../build/webgpu_verifier")
WASM_PATH = Path("../cpp/build/password_verify.wasm")
SHADER_PATH = Path("../../shader")
class DemoHandler(http.server.SimpleHTTPRequestHandler):
def handle_generate_proof(self, data):
password = data['password']
expected_hash = data['expected_hash']
# Build prover configuration
config = {
"program": str(WASM_PATH),
"shader-path": str(SHADER_PATH),
"packing": 8192,
"private-indices": [1], # Password is private
"args": [
{"str": password},
{"i64": len(password)},
{"hex": expected_hash}
]
}
# Run the prover
result = subprocess.run(
[str(PROVER_PATH), json.dumps(config)],
capture_output=True,
timeout=60
)
# Read generated proof
with open("proof_data.gz", 'rb') as f:
proof_data = f.read()
return {
'success': True,
'proof_size': len(proof_data),
'proof_data': 'stored'
}
def handle_verify_proof(self, data):
expected_hash = data['expected_hash']
# Verifier uses DUMMY password - doesn't need the real one!
config = {
"program": str(WASM_PATH),
"shader-path": str(SHADER_PATH),
"packing": 8192,
"private-indices": [1],
"args": [
{"str": "DUMMY_PASSWORD"}, # Not the real password!
{"i64": 14},
{"hex": expected_hash}
]
}
# Run the verifier
result = subprocess.run(
[str(VERIFIER_PATH), json.dumps(config)],
capture_output=True,
timeout=30
)
return {
'success': result.returncode == 0
}
Step 5: Running the Demo
Start the server:
cd demo/password-auth/web
python3 server.py
Open http://localhost:8000 in your browser.
Test Case 1: Correct Password
- Enter password:
ligero2024 - Click "Generate Proof"
- Observe:
- Proof generation time: ~2-10 seconds
- Proof size: ~10-100 KB (depends on packing)
- Verification time: ~0.5-2 seconds
- Result: ✅ Verification Successful
Test Case 2: Wrong Password
- Enter password:
wrong_password - Click "Generate Proof"
- Observe:
- Proof generation completes
- Verification runs
- Result: ❌ Verification Failed
This is the key insight: Even though a proof was generated, it fails verification because the constraints aren't satisfied.
Understanding the JSON Configuration
The JSON passed to prover/verifier is crucial:
{
"program": "../cpp/build/password_verify.wasm",
"shader-path": "../shader",
"packing": 8192,
"private-indices": [1],
"args": [
{"str": "ligero2024"},
{"i64": 10},
{"hex": "a8b3c5d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9"}
]
}
Parameters Explained
program: Path to the WASM program to prove
shader-path: Directory containing WebGPU shaders for acceleration
packing: FFT packing size (affects proof size and speed)
- Smaller = larger proofs, faster generation
- Larger = smaller proofs, slower generation
- Default: 8192 is a good balance
private-indices: Which arguments are private (1-indexed)
[1]means arg 1 (password) is private- Verifier can use dummy values for these
args: Program arguments in typed format
{"str": "value"}- String argument{"i64": 123}- 64-bit integer{"hex": "ab..."}- Hex-encoded bytes
Performance Metrics
Here's what you can expect:
| Metric | Typical Range | Notes |
|---|---|---|
| Proof Generation | 2-10 seconds | Depends on CPU/GPU |
| Proof Size | 10-100 KB | Depends on packing |
| Verification | 0.5-2 seconds | Much faster than proving |
| Hash Computation | < 1ms | SHA-256 is very fast |
Optimizing Performance
Reduce Proof Size:
{"packing": 16384} // Larger packing = smaller proof
Faster Generation:
{"packing": 4096} // Smaller packing = faster generation
Security Considerations
What's Protected
✅ Password never transmitted: Stays on client device ✅ Zero-knowledge: Server learns nothing except "password is correct" ✅ Tamper-proof: Cannot forge a valid proof without the password ✅ Replay protection: Add nonce/timestamp to prevent replay attacks
What's NOT Protected
❌ Brute force: If password space is small, attacker can try all possibilities ❌ Side channels: Timing attacks might leak information ❌ Client compromise: If client device is hacked, password can be stolen
Best Practices
- Use strong passwords: ZK doesn't make weak passwords stronger
- Add rate limiting: Prevent brute force attempts on server
- Include timestamps: Prevent replay attacks
- Secure the client: Protect the environment where proving happens
Production Deployment
This demo simplifies several aspects. For production:
1. Run Prover in Browser
Build the full web version:
mkdir -p build-web && cd build-web
cmake -DCMAKE_BUILD_TYPE=Web ..
make -j
This creates an HTML file with embedded prover, ensuring the password truly never leaves the browser.
2. Store Proofs
def handle_generate_proof(self, data):
# ... generate proof ...
# Store proof in database
proof_id = store_proof(user_id, proof_data, timestamp)
return {'proof_id': proof_id}
3. Add Authentication Flow
# Registration: Store password hash
def register(username, password):
password_hash = sha256(password)
store_user(username, password_hash)
# Login: Verify proof
def login(username, proof):
expected_hash = get_user_hash(username)
return verify_proof(proof, expected_hash)
4. Implement Replay Protection
# Include timestamp in proof
config = {
# ... other config ...
"args": [
{"str": password},
{"i64": len(password)},
{"hex": expected_hash},
{"i64": current_timestamp} # Added!
]
}
# Verify timestamp is recent
def verify_with_timestamp(proof, max_age_seconds=300):
# ... verify proof ...
# ... check timestamp is within max_age ...
Debugging
Common Issues
"WASM file not found":
# Check if WASM was built
ls demo/password-auth/cpp/build/password_verify.wasm
# If not, rebuild with emcmake
"Prover failed":
# Run prover manually to see full output
./build/webgpu_prover '{"program":"demo/password-auth/cpp/build/password_verify.wasm",...}'
"Verification always fails":
- Check that prover and verifier use same packing parameter
- Ensure expected hash is correct
- Verify proof file exists and is valid
Enable Verbose Logging
Modify the WASM program:
#include <ligetron/api.h>
// Add debug output
print_str("Password length: ", password_len);
print_str("Expected hash: ", expected_hash);
dump_memory(password_hash, 32); // Dump computed hash
Next Steps
Now that you understand password authentication, try:
- Age Verification Demo: Prove you're 18+ without revealing exact age
- Build your own: Adapt this pattern for credit scores, credentials, etc.
- Optimize: Experiment with different packing parameters
- Deploy: Integrate into a real application
Complete Source Code
All code for this demo is available at:
github.com/ligeroinc/ligero-prover/tree/main/demo/password-auth
Files included:
cpp/password_verify.cpp- C++ implementationrust/src/lib.rs- Rust implementationweb/index.html- Web interfaceweb/app.js- Client logicweb/server.py- Demo serverREADME.md- Quick start guide