Age Verification Flow Evidence

Technical evidence for age verification controls UC-083 through UC-093

Public

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, and cred_nullifier. but not actual date of birth
  • vk_id selects 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_kid and issuer_vk_bytes for 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 ChallengeState enum
  • 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_days is biased via bias_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_REGISTRY HashMap (supports multiple circuit versions via vk_id)
  • Two initialisation modes: single key (init_with_vk_bytes) or multi-key (init_with_vk_registry)
  • Verification checks:
  1. Proof format validity
  2. Public inputs match claimed values (direction, cutoff_days, rp_hash, issuer_vk, nullifier)
  3. 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:
  1. Credential-level: cred_nullifier (32 bytes) prevents the same credential from being used in multiple proofs
  2. 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_bytes which 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_REGISTRY in crypto-verifier maps vk_id: u32 to 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

ControlEvidence QualityKey Files
UC-086: Challenge Generation✅ Strongcrypto-protocol/src/lib.rs
UC-087: Proof Generation✅ Strongcrypto-verifier/src/lib.rs
UC-088: Proof Verification✅ Strongcrypto-verifier/src/lib.rs
UC-089: Nullifier Handling✅ Strongcrypto-protocol/src/lib.rs, provii-verifier/src/routes/redeem.rs
UC-092: Verifier Integration✅ Strongprovii-agegate/src/core/pkce.ts

Partially Implemented Controls

ControlEvidence QualityGaps
UC-083: Age Threshold Accuracy⚠️ ModerateNeed credential issuance with DOB validation
UC-084: Issuer Trust Model⚠️ ModerateNeed JWKS publication/fetching implementation
UC-085: Credential Lifecycle⚠️ ModerateNeed complete issuance lifecycle evidence
UC-091: Accessibility⚠️ ModerateNo dedicated challenge code formatting function; QR + deep links present

Controls Needing Additional Evidence

ControlStatusNext Steps
UC-090: Credential Revocation⚠️ MissingSearch for revocation list checking, expiration validation
UC-093: Issuer Onboarding⚠️ MissingNo 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

  1. PKCE (RFC 7636): Prevents authorisation code interception
  2. Constant-Time Comparison: Prevents timing attacks on PKCE verification
  3. Nonce-Based Replay Prevention: 5-minute TTL for duplicate detection
  4. Nullifier System: Prevents credential reuse across proofs
  5. RP Challenge Binding: Cryptographically binds proofs to requesting party
  6. Idempotency Protection: Request deduplication using idempotency keys
  7. BOLA Protection: Client ID verification prevents broken object-level authorisation

Integration Points

  1. Browser SDK. provii-agegate TypeScript library with Web Crypto API
  2. Verification API. RESTful endpoints for challenge/proof/redeem flow
  3. Storage. SessionStorage (client-side PKCE), backend nonce store (server-side replay prevention)

Evidence Gaps and Next Steps

Priority 1: Critical Controls

  1. UC-090: Credential Revocation
  • Search for: revocation list endpoints, credential expiration validation
  • Expected locations: issuer API, verification flow pre-checks
  1. UC-093: Issuer Onboarding
  • Search for: issuer registration API, JWKS publication/fetching
  • Expected locations: provii-issuer routes, verifying key management

Priority 2: Completeness

  1. UC-083: Age Threshold Accuracy
  • Search for: credential issuance with DOB validation
  • Expected locations: issuer credential generation, proof circuit constraints
  1. UC-084: Issuer Trust Model
  • Search for: JWKS fetching, verifying key validation
  • Expected locations: verifier initialisation, issuer metadata fetching
  1. 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