Problem
Proof-of-life passes a single static selfie as a "real person" whenever no burst frames are supplied, because the liveness gate defaults to "satisfied" when burst images are absent:
# app/ai-service/proof_of_life.py
burst_required = bool(burst_images_base64)
has_liveness_evidence = (
checks["blink_detected"] or checks["head_movement_detected"] or not burst_required
)
is_real_person = confidence >= threshold and has_liveness_evidence
With burst_images_base64 empty, burst_required is False, so not burst_required is True, making has_liveness_evidence True regardless of any blink/head-movement signal. liveness_score stays at its 0.40 default, and confidence is computed from face_confidence * 0.50 + quality * 0.20 + liveness * 0.30; a clear, centered face photograph easily clears the default confidence_threshold = 0.65.
Consequence: a request that submits only a selfie — no burst frames — is accepted as a real, live person by the same code path that is meant to detect spoofing. The ProofOfLifeRequest model in main.py makes burst_images_base64 optional, so nothing upstream forces burst frames. The anti-fraud signal that gates claims collapses to "a face is present in one image", which a static photograph or screen recording trivially satisfies.
Root cause
The liveness requirement is conditional on the caller choosing to send burst frames, rather than being a mandatory precondition for a is_real_person = true result.
Why this is architecturally hard
- It is a policy/security decision, not a boolean flip. Simply requiring
burst_required would break legitimate selfie-only callers; the fix must decide the minimum liveness bar (burst frames? a video? a challenge-response) and reflect that in the request schema, not just the score.
- The current liveness detector is heuristic and spoofable. Blink is inferred from eye-count differences between Haar-cascade frames and head movement from bounding-box translation; a printed photo waved or a two-frame replay can satisfy them. Raising the bar means designing a defensible liveness signal, not just adding a field.
- It crosses the request contract.
ProofOfLifeRequest is a public Pydantic schema consumed by the backend (app/backend/src/verification and the AI client); changing optional→required or adding a liveness_mode is a breaking contract change.
- Confidence weighting must stay honest. Any fix must ensure
confidence cannot be gamed by a high quality/face score when the liveness evidence is absent; otherwise the threshold remains the weak point.
Proposed design
Make burst/liveness evidence a first-class, required input for a positive result (or add an explicit liveness_mode), and fail closed: if no liveness evidence is supplied, return is_real_person: false with a reason. Optionally add a per-request require_liveness flag so selfie-only callers get an explicit liveness_required refusal rather than a silent pass. Document the minimum acceptable liveness signal.
Acceptance criteria
Service
Tests
Documentation
Out of scope
Face-matching/1:1 verification against a reference image and fraud-clustering scoring are separate issues.
Getting started
Files: app/ai-service/proof_of_life.py, app/ai-service/main.py (ProofOfLifeRequest/ProofOfLifeResponse).
Good first files to read: proof_of_life.py analyze/_score_liveness, then main.py ProofOfLifeRequest.
Problem
Proof-of-life passes a single static selfie as a "real person" whenever no burst frames are supplied, because the liveness gate defaults to "satisfied" when burst images are absent:
With
burst_images_base64empty,burst_requiredisFalse, sonot burst_requiredisTrue, makinghas_liveness_evidenceTrueregardless of any blink/head-movement signal.liveness_scorestays at its0.40default, andconfidenceis computed fromface_confidence * 0.50 + quality * 0.20 + liveness * 0.30; a clear, centered face photograph easily clears the defaultconfidence_threshold = 0.65.Consequence: a request that submits only a selfie — no burst frames — is accepted as a real, live person by the same code path that is meant to detect spoofing. The
ProofOfLifeRequestmodel inmain.pymakesburst_images_base64optional, so nothing upstream forces burst frames. The anti-fraud signal that gates claims collapses to "a face is present in one image", which a static photograph or screen recording trivially satisfies.Root cause
The liveness requirement is conditional on the caller choosing to send burst frames, rather than being a mandatory precondition for a
is_real_person = trueresult.Why this is architecturally hard
burst_requiredwould break legitimate selfie-only callers; the fix must decide the minimum liveness bar (burst frames? a video? a challenge-response) and reflect that in the request schema, not just the score.ProofOfLifeRequestis a public Pydantic schema consumed by the backend (app/backend/src/verificationand the AI client); changing optional→required or adding aliveness_modeis a breaking contract change.confidencecannot be gamed by a high quality/face score when the liveness evidence is absent; otherwise the threshold remains the weak point.Proposed design
Make burst/liveness evidence a first-class, required input for a positive result (or add an explicit
liveness_mode), and fail closed: if no liveness evidence is supplied, returnis_real_person: falsewith a reason. Optionally add a per-requestrequire_livenessflag so selfie-only callers get an explicitliveness_requiredrefusal rather than a silent pass. Document the minimum acceptable liveness signal.Acceptance criteria
Service
is_real_person: falsewith a liveness-related reason.Tests
Documentation
Out of scope
Face-matching/1:1 verification against a reference image and fraud-clustering scoring are separate issues.
Getting started
Files:
app/ai-service/proof_of_life.py,app/ai-service/main.py(ProofOfLifeRequest/ProofOfLifeResponse).cd app/ai-service pytestGood first files to read:
proof_of_life.pyanalyze/_score_liveness, thenmain.pyProofOfLifeRequest.