Status: pre-launch. This evidence reflects implemented code and deployed infrastructure. Provii is not yet serving end-user production traffic, so production operational metrics and audit history are not yet available.
Age Verification Flow Evidence
This document maps age verification controls (UC-083 through UC-093) to their implementations in the Provii codebase, providing concrete code evidence for compliance verification.
Control Evidence Matrix
UC-083: Age Threshold Proof Accuracy
Control Requirement: The system shall accurately prove a user is above a specified age threshold without revealing exact age or date of birth.
Implementation Evidence:
Location: provii-crypto/crypto-verifier/src/lib.rs
Evidence: The proof verification system uses cutoff_days as a public input to the zero knowledge proof, ensuring age threshold validation without revealing the actual date of birth.
pub fn verify_age_snark(
proof_bytes: &[u8],
direction: bool,
cutoff_days: i32,
rp_hash: [u8; 32],
issuer_vk_bytes: [u8; 32],
cred_nullifier: [u8; 32],
vk_id: u32,
) -> Result<VerifyResult> {
let registry = VK_REGISTRY.get().ok_or(Error::VerifierNotInitialized)?;
let pvk = registry.get(&vk_id).ok_or(Error::VerifierNotInitialized)?;
let inputs = assemble_public_inputs_canonical(
direction,
cutoff_days,
rp_hash,
issuer_vk_bytes,
cred_nullifier
);
let proof: Proof<Bls12> = Proof::read(&mut &*proof_bytes)
.map_err(|_| Error::InvalidFormat)?;
verify_proof(pvk, &proof, &inputs)
.map_err(|_| Error::VerificationFailed)?;
Ok(VerifyResult {
direction,
cutoff_days,
rp_hash,
issuer_vk_bytes,
cred_nullifier,
vk_id,
})
}
Key Technical Details:
- Uses Groth16 zkSNARK on BLS12-381 curve for zero knowledge proofs
- Public inputs include
direction(over/under threshold),cutoff_days(age threshold as i32),rp_hash,issuer_vk_bytes, andcred_nullifier. but not actual date of birth vk_idselects from a registry of prepared verifying keys (supporting multiple circuit versions)- Proof cryptographically binds to the threshold without revealing private credential data
- Verification returns structured VerifyResult with all 6 public input fields
Compliance Status: ✅ Implemented with strong cryptographic privacy properties
UC-084: Issuer Trust Model
Control Requirement: The system shall verify that age credentials are issued by trusted identity providers through cryptographic binding.
Implementation Evidence:
Location: provii-crypto/crypto-verifier/src/lib.rs
Evidence: The verification system includes issuer_vk_bytes (issuer verifying key) as a public input, cryptographically binding proofs to specific trusted issuers.
let inputs = assemble_public_inputs_canonical(
direction,
cutoff_days,
rp_hash,
issuer_vk_bytes, // Issuer trust anchor
cred_nullifier
);
Location: provii-verifier/src/routes/redeem.rs
Evidence: The redemption endpoint tracks issuer metadata (issuer_kid, issuer_vk_bytes) for billing and verification provenance.
// Track the issuer for billing
let issuer_kid = cached.issuer_kid.clone();
let issuer_vk_bytes = cached.issuer_vk_bytes;
Key Technical Details:
- Each proof cryptographically binds to the issuer’s verifying key (32-byte value)
- Issuer verifying keys serve as trust anchors in the proof system
- System tracks
issuer_kidandissuer_vk_bytesfor operational purposes - Verifying keys are embedded in the proof circuit’s public inputs
Compliance Status: ✅ Partially implemented (issuer binding present, JWKS publication needs verification)
UC-085: Credential Lifecycle Management
Control Requirement: The system shall manage credential issuance, usage, and expiration throughout the credential lifecycle.
Implementation Evidence:
Location: provii-verifier/src/routes/redeem.rs
Evidence: The redemption flow implements state transitions for credential usage tracking.
// State machine: Pending → ProofOkWaitingForRedeem → Verified
match cached.state {
ChallengeState::ProofOkWaitingForRedeem => {
// Continue with redemption
}
ChallengeState::Verified => {
return ApiError::Conflict(Some(
"Challenge already redeemed".into()
)).to_response();
}
_ => {
return ApiError::Conflict(Some(
"Challenge not ready for redemption".into()
)).to_response();
}
}
Key Technical Details:
- Challenge lifecycle: Pending → ProofOkWaitingForRedeem → Verified (also Failed, Expired states)
- State transitions prevent reuse of verification challenges via
ChallengeStateenum - Issuer metadata (
issuer_kid,issuer_vk_bytes) tracked for credential provenance - Nonce-based duplicate detection with 5-minute TTL
Compliance Status: ✅ Partially implemented (verification lifecycle present, issuance lifecycle needs verification)
UC-086: Challenge Generation
Control Requirement: The system shall generate unique, cryptographically secure challenges for each verification session, binding them to the requesting party.
Implementation Evidence:
Location: provii-crypto/crypto-protocol/src/lib.rs
Evidence 1 - Cryptographically Secure Nonce Generation:
/// Generate a fresh cryptographically-secure nonce.
/// Defined in crypto-protocol/src/nonce.rs, re-exported from lib.rs.
pub fn generate_nonce() -> Result<[u8; 32]> {
let mut bytes = [0u8; 32];
#[cfg(not(target_arch = "wasm32"))]
OsRng.fill_bytes(&mut bytes);
#[cfg(target_arch = "wasm32")]
getrandom::getrandom(&mut bytes)?;
Ok(bytes)
}
Evidence 2 - RP Challenge Binding:
/// Generate the relying-party challenge binding.
pub fn rp_challenge(origin: &str, nonce: &[u8]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(origin.as_bytes());
hasher.update(nonce);
hasher.update(b"zerokp.challenge.v1");
hasher.finalize().into()
}
Evidence 3 - Origin Hash Computation:
/// Compute the SHA-256 hash of an origin string.
pub fn compute_origin_hash(origin: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(origin.as_bytes());
hasher.finalize().into()
}
Key Technical Details:
- Nonces are 32 bytes generated via
OsRng(operating system random number generator) - RP challenge binds: origin + nonce + domain separator (“zerokp.challenge.v1”)
- SHA-256 used for RP challenge binding; Blake2s-256 for RP hash (circuit input); SHA-256 for PKCE code challenge
- Domain separator prevents cross-protocol attacks
- Each verification session gets a unique, unforgeable challenge
Compliance Status: ✅ Fully implemented with strong cryptographic security properties
UC-087: Proof Generation
Control Requirement: The system shall enable users to generate zero knowledge proofs demonstrating age eligibility without revealing underlying credential data.
Implementation Evidence:
Location: provii-crypto/crypto-verifier/src/lib.rs
Evidence: The public inputs structure defines what information is revealed versus what remains private.
/// Assemble public inputs in canonical order for proof verification.
/// Defined in crypto-public-inputs crate, re-exported by crypto-verifier.
pub fn assemble_public_inputs_canonical(
direction: bool,
cutoff_days: i32,
rp_hash: [u8; 32],
issuer_vk_bytes: [u8; 32],
cred_nullifier: [u8; 32],
) -> Vec<Scalar> {
let mut all = Vec::new();
// 1) direction (1 element)
// ... packed via bits_le_from_bytes + multipack
// 2) cutoff_days (1 element, biased via bias_for_circuit)
// ... packed via bits_le_from_bytes + multipack
// 3) rp_hash (32 bytes → 2 field elements via multipack)
// ... multipack::compute_multipacking::<Scalar>()
// 4) issuer_vk_bytes (32 bytes → 2 field elements via multipack)
// 5) cred_nullifier (32 bytes → 2 field elements via multipack)
all // Total: 8 Scalar elements
}
Key Technical Details:
- Groth16 zkSNARK proof system (compact, constant-size proofs)
- Public inputs: direction, cutoff_days, rp_hash, issuer_vk_bytes, cred_nullifier (8 field elements total)
- Private inputs (not revealed): actual date of birth, credential signature, issuer private key
- Multipack encoding via
multipack::compute_multipacking::<Scalar>()converts 32-byte values to BLS12-381 scalar field elements cutoff_daysis biased viabias_for_circuit()before packing to handle negative values- Proof demonstrates age above/below threshold (via
direction) without revealing exact age or DOB
Compliance Status: ✅ Fully implemented with strong zero-knowledge privacy properties
UC-088: Proof Verification
Control Requirement: The system shall verify zero knowledge proofs to confirm age eligibility, issuer authenticity, and challenge binding.
Implementation Evidence:
Location: provii-crypto/crypto-verifier/src/lib.rs
Evidence 1 - Verification Function (same as UC-083, showing full 7-parameter signature):
pub fn verify_age_snark(
proof_bytes: &[u8],
direction: bool,
cutoff_days: i32,
rp_hash: [u8; 32],
issuer_vk_bytes: [u8; 32],
cred_nullifier: [u8; 32],
vk_id: u32,
) -> Result<VerifyResult> {
let registry = VK_REGISTRY.get().ok_or(Error::VerifierNotInitialized)?;
let pvk = registry.get(&vk_id).ok_or(Error::VerifierNotInitialized)?;
let inputs = assemble_public_inputs_canonical(
direction,
cutoff_days,
rp_hash,
issuer_vk_bytes,
cred_nullifier
);
let proof: Proof<Bls12> = Proof::read(&mut &*proof_bytes)
.map_err(|_| Error::InvalidFormat)?;
verify_proof(pvk, &proof, &inputs)
.map_err(|_| Error::VerificationFailed)?;
Ok(VerifyResult {
direction, cutoff_days, rp_hash,
issuer_vk_bytes, cred_nullifier, vk_id,
})
}
Evidence 2 - Verifying Key Initialisation:
/// Initialize with a single verifying key (raw bytes).
pub fn init_with_vk_bytes(bytes: &[u8]) -> anyhow::Result<()> {
let pvk = load_vk(bytes)?;
let mut map = HashMap::new();
map.insert(0, pvk);
VK_REGISTRY.set(map)
.map_err(|_| anyhow::anyhow!("VK registry already initialized"))?;
Ok(())
}
/// Initialize with a registry of multiple verifying keys.
pub fn init_with_vk_registry(entries: Vec<(u32, Vec<u8>)>) -> anyhow::Result<()> {
// ... loads multiple VKs into a HashMap<u32, PreparedVerifyingKey<Bls12>>
}
Key Technical Details:
- Uses Bellman library for Groth16 proof verification
- Verifying keys loaded into a
VK_REGISTRYHashMap (supports multiple circuit versions viavk_id) - Two initialisation modes: single key (
init_with_vk_bytes) or multi-key (init_with_vk_registry) - Verification checks:
- Proof format validity
- Public inputs match claimed values (direction, cutoff_days, rp_hash, issuer_vk, nullifier)
- Cryptographic proof equation holds
- Returns structured VerifyResult with all 6 fields on success
- Fast verification (constant time regardless of private input complexity)
Compliance Status: ✅ Fully implemented with strong cryptographic properties
UC-089: Nullifier Handling (Replay Prevention)
Control Requirement: The system shall prevent credential reuse through nullifier-based replay detection while maintaining user privacy.
Implementation Evidence:
Location: provii-crypto/crypto-protocol/src/lib.rs
Evidence 1 - Replay Tag Computation:
/// Compute a replay tag from the origin hash and nonce.
pub fn compute_replay_tag(origin_hash: &[u8], nonce: &[u8]) -> String {
let mut v = Vec::with_capacity(origin_hash.len() + 1 + nonce.len());
v.extend_from_slice(origin_hash);
v.push(b':');
v.extend_from_slice(nonce);
URL_SAFE_NO_PAD.encode(v)
}
Location: provii-verifier/src/routes/redeem.rs
Evidence 2 - Nonce-Based Duplicate Detection:
// Reject duplicate redemption attempts through the nonce store.
let nonce_tag = format!("redeem:{}", sid);
let nonce_ttl = Duration::from_secs(300); // Time-to-live of 5 minutes.
match state.nonce_store.check_and_set(&nonce_tag, nonce_ttl).await {
Ok(true) => {
// Continue processing on the first redemption attempt.
}
Ok(false) => {
console_log!(
"[/v1/redeem] ⚠️ Duplicate redemption detected for challenge {}",
redact_challenge_id(&sid.to_string())
);
return ApiError::Conflict(Some("Duplicate redemption attempt".into())).to_response();
}
Err(e) => {
console_log!(
"[/v1/redeem] Nonce store error for {}: {:?}",
redact_challenge_id(&sid.to_string()),
e
);
return ApiError::Internal(e.into()).to_response();
}
}
Evidence 3 - Credential Nullifier in Public Inputs:
// cred_nullifier is part of the public inputs
let inputs = assemble_public_inputs_canonical(
direction,
cutoff_days,
rp_hash,
issuer_vk_bytes,
cred_nullifier // Prevents credential reuse across proofs
);
Key Technical Details:
- Two-layer replay prevention:
- Credential-level:
cred_nullifier(32 bytes) prevents the same credential from being used in multiple proofs - Session-level: Nonce store prevents duplicate redemption of the same challenge
- Replay tags combine origin_hash + nonce for per-origin tracking
- Nonce TTL of 5 minutes prevents indefinite storage requirements
- Nullifiers are Pedersen-based (from credential signature) for privacy
- Each proof includes unique nullifier, preventing linkability while blocking reuse
Compliance Status: ✅ Fully implemented with dual-layer protection
UC-090: Credential Revocation
Control Requirement: The system shall support credential revocation mechanisms to invalidate compromised or expired credentials.
Implementation Evidence:
Status: Evidence gathering in progress.
Potential Locations:
- Issuer verifying key rotation mechanisms
- Revocation list checking in verification flow
- Credential expiration validation
Key Technical Details:
- System tracks
issuer_vk_byteswhich could support key rotation-based revocation - Nullifier system provides infrastructure for revocation tracking
- Need to verify: active revocation list checking, expiration timestamp validation
Compliance Status: ⚠️ Needs additional evidence gathering
UC-091: Age Verification Accessibility
Control Requirement: The system shall provide accessible verification methods including both QR code and text-based challenge codes.
Implementation Evidence:
Evidence: Challenge accessibility is primarily provided through the provii-agegate SDK which generates both QR codes and deep links for mobile wallet interaction. The crypto-protocol crate provides the underlying challenge binding functions (rp_challenge, compute_origin_hash) used by both QR and text-based flows.
Note: No dedicated build_challenge_code function exists in the codebase. Challenge codes are derived from challenge IDs at the application layer (provii-verifier, provii-agegate), not in the crypto-protocol library.
Key Technical Details:
- QR codes encode deep links (
https://provii.app/verify?d=...) for mobile wallet scanning - Challenge IDs serve as text-based alternative to QR code scanning
- provii-agegate SDK handles both QR rendering and fallback text display
- Accessible to users who cannot scan QR codes via manual challenge ID entry
Compliance Status: ⚠️ Partially implemented (QR and deep link flows present, dedicated text-based challenge code formatting not implemented as a separate function)
UC-092: Verifier Integration
Control Requirement: The system shall provide SDKs and integration points for relying parties to integrate age verification into their applications.
Implementation Evidence:
Location: provii-agegate/src/core/pkce.ts
Evidence 1 - Client-Side PKCE Manager:
/**
* PKCE Manager
*
* Handles generation, storage, and retrieval of PKCE verifiers and challenges.
* Uses sessionStorage for temporary storage (cleared on tab close).
*/
export class PKCEManager {
private readonly storage: Storage;
private readonly debug: boolean;
constructor(debug = false) {
// Use sessionStorage (cleared on tab close)
this.storage = globalThis.sessionStorage;
this.debug = debug;
}
/**
* Generate a new PKCE challenge
*/
async generateChallenge(): Promise<PKCEChallenge> {
this.log('Generating PKCE challenge');
// Generate code_verifier (43-128 random characters)
const verifier = this.generateVerifier();
// Generate code_challenge from verifier
const challenge = await this.generateChallengeFromVerifier(verifier);
return { verifier, challenge };
}
}
Evidence 2 - RFC 7636 Compliance:
/**
* Generate a cryptographically secure code_verifier
*
* RFC 7636 requirements:
* - 43-128 characters long
* - Characters: [A-Z] [a-z] [0-9] - . _ ~ (unreserved characters)
*/
private generateVerifier(): string {
if (!globalThis.crypto || !globalThis.crypto.getRandomValues) {
throw new PKCEError('Web Crypto API is not available');
}
// Generate 32 random bytes (will produce 43 chars in base64url)
const array = new Uint8Array(32);
globalThis.crypto.getRandomValues(array);
// Convert to base64url (43 characters)
return this.base64UrlEncode(array);
}
Evidence 3 - Validation Utilities:
/**
* Validate code_verifier format
*/
export function isValidVerifier(verifier: string): boolean {
if (verifier.length < 43 || verifier.length > 128) {
return false;
}
// Check for unreserved characters only
const validChars = /^[A-Za-z0-9\-._~]+$/;
return validChars.test(verifier);
}
/**
* Validate code_challenge format
*/
export function isValidChallenge(challenge: string): boolean {
if (challenge.length !== 43) {
return false;
}
// Check for base64url characters only
const validChars = /^[A-Za-z0-9\-_]+$/;
return validChars.test(challenge);
}
Key Technical Details:
- Browser-based JavaScript/TypeScript SDK (provii-agegate)
- RFC 7636 compliant PKCE implementation using Web Crypto API
- SessionStorage for secure, temporary verifier storage
- Validation functions for PKCE parameter format checking
- Debug mode support for integration troubleshooting
- Base64url encoding for URL-safe parameter transmission
Compliance Status: ✅ Fully implemented with standards-compliant SDK
UC-093: Issuer Onboarding
Control Requirement: The system shall provide a secure process for onboarding trusted identity providers as credential issuers, including verifying key publication and registration.
Implementation Evidence:
Status: Evidence gathering in progress.
Known Implementation Details:
- System tracks
issuer_kid(issuer key ID) for issuer identification - Issuer verifying keys (32-byte values) serve as trust anchors in the proof system
VK_REGISTRYin crypto-verifier mapsvk_id: u32to prepared verifying keys
Note: No dedicated fingerprint_vk function exists in the codebase. Verifying key identification is handled via the vk_id registry system and issuer_kid tracking in the provii-verifier.
Location: provii-verifier/src/routes/redeem.rs
// Issuer identification via kid and vk_bytes
let issuer_kid = cached.issuer_kid.clone();
let issuer_vk_bytes = cached.issuer_vk_bytes;
Potential Locations:
- Issuer API key management endpoints
- Verifying key distribution via provii-issuer
Compliance Status: ⚠️ Needs additional evidence gathering (issuer key tracking present, formal onboarding/registration flow needs verification)
Evidence Summary
Fully Implemented Controls
| Control | Evidence Quality | Key Files |
|---|---|---|
| UC-086: Challenge Generation | ✅ Strong | crypto-protocol/src/lib.rs |
| UC-087: Proof Generation | ✅ Strong | crypto-verifier/src/lib.rs |
| UC-088: Proof Verification | ✅ Strong | crypto-verifier/src/lib.rs |
| UC-089: Nullifier Handling | ✅ Strong | crypto-protocol/src/lib.rs, provii-verifier/src/routes/redeem.rs |
| UC-092: Verifier Integration | ✅ Strong | provii-agegate/src/core/pkce.ts |
Partially Implemented Controls
| Control | Evidence Quality | Gaps |
|---|---|---|
| UC-083: Age Threshold Accuracy | ⚠️ Moderate | Need credential issuance with DOB validation |
| UC-084: Issuer Trust Model | ⚠️ Moderate | Need JWKS publication/fetching implementation |
| UC-085: Credential Lifecycle | ⚠️ Moderate | Need complete issuance lifecycle evidence |
| UC-091: Accessibility | ⚠️ Moderate | No dedicated challenge code formatting function; QR + deep links present |
Controls Needing Additional Evidence
| Control | Status | Next Steps |
|---|---|---|
| UC-090: Credential Revocation | ⚠️ Missing | Search for revocation list checking, expiration validation |
| UC-093: Issuer Onboarding | ⚠️ Missing | No fingerprint_vk function; issuer_kid tracking present, formal onboarding needs verification |
Technical Architecture Summary
Cryptographic Primitives
- Zero knowledge Proofs. Groth16 zkSNARK on BLS12-381 curve (8 public inputs: direction, cutoff_days, rp_hash, issuer_vk, nullifier)
- Hash Functions. SHA-256 (RP challenge binding), Blake2s-256 (RP hash for circuit), SHA-256 (PKCE code challenge)
- Random Generation. OsRng (native) / getrandom (WASM). cryptographically secure
- Encoding. Base64url (RFC 4648 Section 5) for URL-safe parameters
- Field Arithmetic. BLS12-381 Scalar type via multipack encoding
Security Mechanisms
- PKCE (RFC 7636): Prevents authorisation code interception
- Constant-Time Comparison: Prevents timing attacks on PKCE verification
- Nonce-Based Replay Prevention: 5-minute TTL for duplicate detection
- Nullifier System: Prevents credential reuse across proofs
- RP Challenge Binding: Cryptographically binds proofs to requesting party
- Idempotency Protection: Request deduplication using idempotency keys
- BOLA Protection: Client ID verification prevents broken object-level authorisation
Integration Points
- Browser SDK.
provii-agegateTypeScript library with Web Crypto API - Verification API. RESTful endpoints for challenge/proof/redeem flow
- Storage. SessionStorage (client-side PKCE), backend nonce store (server-side replay prevention)
Evidence Gaps and Next Steps
Priority 1: Critical Controls
- UC-090: Credential Revocation
- Search for: revocation list endpoints, credential expiration validation
- Expected locations: issuer API, verification flow pre-checks
- UC-093: Issuer Onboarding
- Search for: issuer registration API, JWKS publication/fetching
- Expected locations: provii-issuer routes, verifying key management
Priority 2: Completeness
- UC-083: Age Threshold Accuracy
- Search for: credential issuance with DOB validation
- Expected locations: issuer credential generation, proof circuit constraints
- UC-084: Issuer Trust Model
- Search for: JWKS fetching, verifying key validation
- Expected locations: verifier initialisation, issuer metadata fetching
- UC-085: Credential Lifecycle
- Search for: complete issuance flow, credential expiration
- Expected locations: provii-issuer routes, credential schema definitions
Document Metadata
- Generated. 2026-02-14
- Evidence Sources. 4 primary source files analysed (3,100+ lines of code)
- Repositories Covered. provii-verifier, provii-crypto, provii-agegate
- Controls Documented. 11 (UC-083 through UC-093)
- Evidence Quality. 5 strong, 4 moderate, 2 needs gathering