Skip to content

Commit 45c5897

Browse files
committed
fix: fail closed when proof-of-life has no burst frames
Selfie-only requests (no burst frames) previously passed liveness because the burst_required flag defaulted to satisfied when no frames were supplied, collapsing the anti-spoon signal to 'a face is present'. Make liveness evidence a mandatory precondition: without burst frames, is_real_person is always false with a clear reason. Burst- based requests are scored on actual blink/head-movement evidence. Closes #431
1 parent 8290244 commit 45c5897

2 files changed

Lines changed: 99 additions & 1 deletion

File tree

app/ai-service/proof_of_life.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,16 @@ def analyze(
120120

121121
burst_required = bool(burst_images_base64)
122122
has_liveness_evidence = (
123-
checks["blink_detected"] or checks["head_movement_detected"] or not burst_required
123+
checks["blink_detected"] or checks["head_movement_detected"]
124124
)
125125
is_real_person = confidence >= threshold and has_liveness_evidence
126126

127127
reason = "Face detected and confidence threshold met"
128128
if burst_required and not has_liveness_evidence:
129129
reason = "No liveness signal detected from burst frames"
130+
elif not burst_required:
131+
is_real_person = False
132+
reason = "Liveness verification requires burst frames"
130133
elif confidence < threshold:
131134
reason = "Confidence score is below threshold"
132135

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Tests for proof-of-life liveness gate (Issue #431).
2+
3+
Verifies that:
4+
- A selfie-only request (no burst frames) returns is_real_person: false.
5+
- Burst-based requests are scored on actual blink/head-movement evidence.
6+
"""
7+
8+
from unittest.mock import patch
9+
10+
from proof_of_life import ProofOfLifeAnalyzer, ProofOfLifeConfig
11+
12+
13+
def _make_analyzer():
14+
"""Build an analyzer in test-provider mode so we skip cascade loading."""
15+
cfg = ProofOfLifeConfig(confidence_threshold=0.65)
16+
analyzer = ProofOfLifeAnalyzer(config=cfg)
17+
return analyzer
18+
19+
20+
class TestSelfieOnlyRefusal:
21+
"""Selfie-only requests must always be refused."""
22+
23+
def test_selfie_only_returns_false(self):
24+
"""Without burst frames, is_real_person must be False."""
25+
analyzer = _make_analyzer()
26+
result = analyzer.analyze(selfie_image_base64="dGVzdA==")
27+
assert result["is_real_person"] is False
28+
29+
def test_selfie_only_reason_mentions_liveness(self):
30+
analyzer = _make_analyzer()
31+
result = analyzer.analyze(selfie_image_base64="dGVzdA==")
32+
assert "liveness" in result["reason"].lower()
33+
34+
def test_empty_burst_list_treated_as_selfie_only(self):
35+
"""An explicit empty list is equivalent to no burst frames."""
36+
analyzer = _make_analyzer()
37+
result = analyzer.analyze(
38+
selfie_image_base64="dGVzdA==",
39+
burst_images_base64=[],
40+
)
41+
assert result["is_real_person"] is False
42+
43+
44+
class TestBurstLivenessEvidence:
45+
"""When burst frames are provided, liveness is scored on actual signals."""
46+
47+
@patch.object(ProofOfLifeAnalyzer, "_analyze_burst_frames")
48+
@patch.object(ProofOfLifeAnalyzer, "_detect_primary_face")
49+
@patch.object(ProofOfLifeAnalyzer, "_decode_image")
50+
def test_burst_with_blink_and_movement_can_pass(
51+
self, mock_decode, mock_face, mock_burst
52+
):
53+
import numpy as np
54+
55+
mock_decode.return_value = np.zeros((200, 200, 3), dtype=np.uint8)
56+
mock_face.return_value = (50, 50, 100, 100)
57+
mock_burst.return_value = {
58+
"blink_detected": True,
59+
"head_movement_detected": True,
60+
"processed_burst_frames": 5,
61+
}
62+
63+
analyzer = _make_analyzer()
64+
# Use a very low threshold so the combined score passes
65+
analyzer.config.confidence_threshold = 0.10
66+
result = analyzer.analyze(
67+
selfie_image_base64="dGVzdA==",
68+
burst_images_base64=["frame1", "frame2"],
69+
)
70+
assert result["checks"]["blink_detected"] is True
71+
assert result["checks"]["head_movement_detected"] is True
72+
73+
@patch.object(ProofOfLifeAnalyzer, "_analyze_burst_frames")
74+
@patch.object(ProofOfLifeAnalyzer, "_detect_primary_face")
75+
@patch.object(ProofOfLifeAnalyzer, "_decode_image")
76+
def test_burst_without_liveness_fails(
77+
self, mock_decode, mock_face, mock_burst
78+
):
79+
import numpy as np
80+
81+
mock_decode.return_value = np.zeros((200, 200, 3), dtype=np.uint8)
82+
mock_face.return_value = (50, 50, 100, 100)
83+
mock_burst.return_value = {
84+
"blink_detected": False,
85+
"head_movement_detected": False,
86+
"processed_burst_frames": 5,
87+
}
88+
89+
analyzer = _make_analyzer()
90+
result = analyzer.analyze(
91+
selfie_image_base64="dGVzdA==",
92+
burst_images_base64=["frame1", "frame2"],
93+
)
94+
assert result["is_real_person"] is False
95+
assert "liveness" in result["reason"].lower()

0 commit comments

Comments
 (0)