Skip to main content

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:

  1. Client knows password P
  2. Server knows only H = SHA256(P)
  3. Client generates a cryptographic proof: "I know a value that hashes to H"
  4. 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:

  1. WASM Program: Computes SHA-256 and verifies hash match (C++ or Rust)
  2. Web Interface: User enters password, displays metrics
  3. 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 length
  • argv[3]: Expected SHA-256 hash (PUBLIC - known to server)

Key Function:

  • ligetron_sha2_256(): Computes SHA-256 hash
  • assert_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

  1. Enter password: ligero2024
  2. Click "Generate Proof"
  3. 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

  1. Enter password: wrong_password
  2. Click "Generate Proof"
  3. 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:

MetricTypical RangeNotes
Proof Generation2-10 secondsDepends on CPU/GPU
Proof Size10-100 KBDepends on packing
Verification0.5-2 secondsMuch faster than proving
Hash Computation< 1msSHA-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

  1. Use strong passwords: ZK doesn't make weak passwords stronger
  2. Add rate limiting: Prevent brute force attempts on server
  3. Include timestamps: Prevent replay attacks
  4. 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:

  1. Age Verification Demo: Prove you're 18+ without revealing exact age
  2. Build your own: Adapt this pattern for credit scores, credentials, etc.
  3. Optimize: Experiment with different packing parameters
  4. 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++ implementation
  • rust/src/lib.rs - Rust implementation
  • web/index.html - Web interface
  • web/app.js - Client logic
  • web/server.py - Demo server
  • README.md - Quick start guide

Questions?