Skip to content

Commit ae76216

Browse files
committed
Add local access attempt limiting
1 parent fb970c8 commit ae76216

15 files changed

Lines changed: 268 additions & 1 deletion

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,8 @@ PHANTASM_STATE_DIR=/path/to/state python3 main.py init
295295
| `PHANTASM_FIELD_MODE=1` | Reduce normal WebUI operational detail |
296296
| `PHANTASM_PROFILE` | Select local capability mode: `standard`, `field`, or `maintenance` |
297297
| `PHANTASM_MIN_PASSPHRASE_LENGTH` | Minimum Store passphrase length |
298+
| `PHANTASM_ACCESS_MAX_FAILURES` | Failed access attempts before temporary lockout |
299+
| `PHANTASM_ACCESS_LOCKOUT_SECONDS` | Temporary access lockout duration |
298300
| `PHANTASM_AUDIT=1` | Enable audit logging |
299301

300302
## Local-Only Trust Boundary

docs/REVIEW_VALIDATION_RECORD.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ python3 -m unittest discover -s tests
2424
Result:
2525

2626
```text
27-
2026-05-02, macOS Darwin arm64, python3 -m unittest discover -s tests, 141 tests passed.
27+
2026-05-02, macOS Darwin arm64, python3 -m unittest discover -s tests, 147 tests passed.
2828
```
2929

3030
Restricted-flow scenario matrix:

docs/SPECIFICATION.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,8 @@ The physical object is an operational cue, not a high-entropy cryptographic fact
316316
| `PHANTASM_FIELD_MODE` | Reduce normal WebUI operational detail | `0` |
317317
| `PHANTASM_PROFILE` | Select local capability mode: `standard`, `field`, or `maintenance` | `standard` |
318318
| `PHANTASM_MIN_PASSPHRASE_LENGTH` | Minimum Store passphrase length | `10` |
319+
| `PHANTASM_ACCESS_MAX_FAILURES` | Failed access attempts before temporary lockout | `5` |
320+
| `PHANTASM_ACCESS_LOCKOUT_SECONDS` | Temporary access lockout duration | `60` |
319321
| `PHANTASM_AUDIT` | Enable audit logging | `0` |
320322
| `PHANTASM_AUDIT_FILENAMES` | Record filename hashes | unset |
321323

docs/THREAT_MODEL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ It is not a substitute for audited full-disk encryption, hardware-backed key sto
4343
- Reference keys are stored together in a single AES-GCM encrypted ORB state blob under the configured state directory, not as raw reference photos or semantic per-entry template filenames.
4444
- Image-key matching requires stable results across a short frame window rather than accepting a single-frame match.
4545
- Web mutation endpoints require `X-Phantasm-Token`, apply a simple per-client rate limit, and enforce upload size limits.
46+
- Access recovery flows count repeated local failures and apply a bounded temporary lockout. WebUI limiting is process-local; CLI limiting is stored in local state.
4647
- Web responses include no-store cache headers, frame denial, MIME-sniffing protection, no-referrer policy, constrained browser permissions, and a local-only content security policy. These reduce browser residue and common Web embedding risks but do not make the WebUI safe for untrusted networks.
4748
- Sensitive Web actions require a fresh restricted confirmation session in addition to the Web token and UI unlock state. Restricted action pages and entry maintenance details are withheld until that confirmation is active.
4849
- Optional UI face lock (`PHANTASM_UI_FACE_LOCK=1`) can gate normal WebUI routes with a short-lived local session. This is a UI access control only and is not used for vault encryption.
@@ -71,6 +72,7 @@ These surfaces should not reveal the internal disclosure model, internal trial o
7172
- Dual password slots duplicate encrypted payload material within the selected internal storage span. This improves operational control but reduces maximum payload size.
7273
- UI face lock can be affected by lighting, camera angle, false rejects, false accepts, and presentation attacks using photos or screens.
7374
- The in-memory Web rate limiter and restricted confirmation state reset on process restart and are not substitutes for a full access-control layer.
75+
- Access-attempt limiting slows repeated local failures but does not stop offline guessing against copied data, compromised hosts, or deliberate state rollback.
7476
- UI tokens can be read from a compromised browser or host session.
7577
- Passphrase policy cannot compensate for observed input, reused passwords, coercion, compromised hosts, or poor operational separation.
7678
- Metadata checks and metadata reduction are best-effort. They can miss embedded identifiers, thumbnails, histories, and application-specific fields.

src/phantasm/attempt_limiter.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Local access-attempt limiter for recovery flows."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
import time
7+
8+
from .config import access_lockout_seconds, access_max_failures
9+
from .state_store import LocalStateStore
10+
11+
ATTEMPT_STATE_NAME = "access_attempts.json"
12+
13+
14+
@dataclass(frozen=True)
15+
class AttemptDecision:
16+
allowed: bool
17+
wait_seconds: int = 0
18+
19+
20+
class AttemptLimiter:
21+
def __init__(
22+
self,
23+
max_failures: int | None = None,
24+
lockout_seconds: int | None = None,
25+
clock=None,
26+
):
27+
self.max_failures = max_failures or access_max_failures()
28+
self.lockout_seconds = lockout_seconds or access_lockout_seconds()
29+
self.clock = clock or time.time
30+
self._state = {}
31+
32+
def check(self, scope: str):
33+
state = self._state.get(scope, {"failures": 0, "locked_until": 0})
34+
now = int(self.clock())
35+
locked_until = int(state.get("locked_until", 0))
36+
if locked_until > now:
37+
return AttemptDecision(False, locked_until - now)
38+
return AttemptDecision(True, 0)
39+
40+
def record_failure(self, scope: str):
41+
state = self._state.get(scope, {"failures": 0, "locked_until": 0})
42+
failures = int(state.get("failures", 0)) + 1
43+
locked_until = 0
44+
if failures >= self.max_failures:
45+
locked_until = int(self.clock()) + self.lockout_seconds
46+
self._state[scope] = {
47+
"failures": failures,
48+
"locked_until": locked_until,
49+
}
50+
51+
def record_success(self, scope: str):
52+
self._state.pop(scope, None)
53+
54+
55+
class FileAttemptLimiter(AttemptLimiter):
56+
def __init__(self, store: LocalStateStore | None = None, **kwargs):
57+
super().__init__(**kwargs)
58+
self.store = store or LocalStateStore()
59+
self._state = self._load()
60+
61+
def record_failure(self, scope: str):
62+
super().record_failure(scope)
63+
self._save()
64+
65+
def record_success(self, scope: str):
66+
super().record_success(scope)
67+
self._save()
68+
69+
def _load(self):
70+
try:
71+
data = self.store.read_json(ATTEMPT_STATE_NAME)
72+
except Exception:
73+
return {}
74+
if not isinstance(data, dict):
75+
return {}
76+
return {
77+
str(scope): {
78+
"failures": int(value.get("failures", 0)),
79+
"locked_until": int(value.get("locked_until", 0)),
80+
}
81+
for scope, value in data.items()
82+
if isinstance(value, dict)
83+
}
84+
85+
def _save(self):
86+
self.store.write_json_atomic(ATTEMPT_STATE_NAME, self._state)

src/phantasm/cli.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import time
66

77
from .ai_gate import get_gesture_sequence, gate
8+
from .attempt_limiter import FileAttemptLimiter
89
from .audit import audit_event
910
from .bridge_ui import ui
1011
from .config import duress_mode_enabled, purge_confirmation_required
@@ -13,6 +14,7 @@
1314
from .gv_core import GhostVault
1415
from .operations import doctor, export_redacted_log, verify_audit_log, verify_state
1516
from .passphrase_policy import check_store_passphrases
17+
from . import strings as text
1618

1719
CAMERA_WARMUP_TIMEOUT = 10
1820
REFERENCE_MATCH_TIMEOUT = 10
@@ -323,17 +325,25 @@ def main():
323325
print(" DEVICE STATUS: READY")
324326
print("=" * 55)
325327

328+
attempt_limiter = FileAttemptLimiter()
329+
attempt_scope = "cli-retrieve"
330+
if not attempt_limiter.check(attempt_scope).allowed:
331+
print(f"\n[!] {text.ACCESS_TEMPORARILY_UNAVAILABLE}")
332+
return
333+
326334
pw = getpass.getpass("\n[AUTH] Access password: ")
327335

328336
print("\n[LOCAL] Requesting bound object verification...")
329337
user_gesture_seq = _collect_auth_sequence()
330338

331339
if gate.last_match_mode == gate.MATCH_AMBIGUOUS:
332340
ui.show_alert("ACCESS ERROR\nAMBIGUOUS OBJECT")
341+
attempt_limiter.record_failure(attempt_scope)
333342
print("[!] Access rejected because the object match is ambiguous.")
334343
return
335344
if not user_gesture_seq or user_gesture_seq[0] == gate.MATCH_NONE:
336345
ui.show_alert("ACCESS ERROR\nOBJECT NOT FOUND")
346+
attempt_limiter.record_failure(attempt_scope)
337347
print("[!] No bound object matched.")
338348
return
339349

@@ -355,6 +365,7 @@ def main():
355365
accessed_mode = gate.MODES[1]
356366

357367
if result is not None:
368+
attempt_limiter.record_success(attempt_scope)
358369
ui.show_alert("ACCESS GRANTED")
359370

360371
show_loading("Preparing recovered payload", 2)
@@ -410,6 +421,7 @@ def main():
410421
else:
411422
ui.show_alert("ACCESS DENIED\nINVALID CREDENTIALS")
412423
audit_event("retrieve_failed")
424+
attempt_limiter.record_failure(attempt_scope)
413425
print("\n[!] Access failed.")
414426

415427
elif args.action == "brick":

src/phantasm/config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,19 @@ def passphrase_min_length():
5050
return max(1, int(value))
5151
except ValueError:
5252
return 10
53+
54+
55+
def access_max_failures():
56+
value = os.environ.get("PHANTASM_ACCESS_MAX_FAILURES", "5")
57+
try:
58+
return max(1, int(value))
59+
except ValueError:
60+
return 5
61+
62+
63+
def access_lockout_seconds():
64+
value = os.environ.get("PHANTASM_ACCESS_LOCKOUT_SECONDS", "60")
65+
try:
66+
return max(1, int(value))
67+
except ValueError:
68+
return 60

src/phantasm/strings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
INVALID_WEB_TOKEN = "invalid web token"
99
RATE_LIMIT_EXCEEDED = "rate limit exceeded"
1010
UPLOAD_TOO_LARGE = "upload too large"
11+
ACCESS_TEMPORARILY_UNAVAILABLE = "Access temporarily unavailable."
1112

1213
CONFIRMATION_REJECTED_DISPLAY = "Confirmation rejected."
1314
RESTRICTED_CONFIRMATION_ACCEPTED = "Restricted confirmation accepted."

src/phantasm/web_server.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from fastapi.templating import Jinja2Templates
2525

2626
from .ai_gate import gate, get_gesture_sequence
27+
from .attempt_limiter import AttemptLimiter
2728
from .audit import audit_event
2829
from .capabilities import Capability, active_policy, capability_enabled
2930
from .config import (
@@ -62,6 +63,7 @@
6263
)
6364
_rate_limit = {}
6465
_restricted_sessions = {}
66+
_access_attempts = AttemptLimiter()
6567

6668
ENTRY_TO_MODE = {
6769
"entry_1": gate.MODES[0],
@@ -815,10 +817,15 @@ async def metadata_scrub(request: Request, file: UploadFile = File(...)):
815817
)
816818
async def retrieve(request: Request, password: str = Form(...)):
817819
enforce_rate_limit(request)
820+
attempt_scope = f"web:{_client_id(request)}"
821+
if not _access_attempts.check(attempt_scope).allowed:
822+
return {"error": text.ACCESS_TEMPORARILY_UNAVAILABLE}
818823
auth_sequence = get_gesture_sequence(length=1)
819824
if gate.last_match_mode == gate.MATCH_AMBIGUOUS:
825+
_access_attempts.record_failure(attempt_scope)
820826
return {"error": text.AMBIGUOUS_OBJECT_MATCH}
821827
if auth_sequence[0] == gate.MATCH_NONE:
828+
_access_attempts.record_failure(attempt_scope)
822829
return {"error": text.NO_VALID_ENTRY_FOUND}
823830

824831
for mode in gate.MODES:
@@ -840,6 +847,7 @@ async def retrieve(request: Request, password: str = Form(...)):
840847
if result is not None:
841848
# Release camera to save power and heat after successful retrieval
842849
gate.close()
850+
_access_attempts.record_success(attempt_scope)
843851
if not purge_applied:
844852
purge_applied = _maybe_auto_purge(mode, source="web")
845853
return create_file_response(
@@ -849,6 +857,7 @@ async def retrieve(request: Request, password: str = Form(...)):
849857
)
850858

851859
audit_event("retrieve_failed", source="web")
860+
_access_attempts.record_failure(attempt_scope)
852861
return {"error": text.NO_VALID_ENTRY_FOUND}
853862

854863

tests/test_attempt_limiter.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import os
2+
import tempfile
3+
import unittest
4+
5+
ROOT = os.path.dirname(os.path.dirname(__file__))
6+
import sys
7+
8+
sys.path.insert(0, os.path.join(ROOT, "src"))
9+
10+
from phantasm.attempt_limiter import AttemptLimiter, FileAttemptLimiter
11+
from phantasm.state_store import LocalStateStore
12+
13+
14+
class AttemptLimiterTests(unittest.TestCase):
15+
def test_repeated_failures_trigger_lockout(self):
16+
now = [1000]
17+
limiter = AttemptLimiter(
18+
max_failures=2,
19+
lockout_seconds=30,
20+
clock=lambda: now[0],
21+
)
22+
23+
self.assertTrue(limiter.check("local").allowed)
24+
limiter.record_failure("local")
25+
self.assertTrue(limiter.check("local").allowed)
26+
limiter.record_failure("local")
27+
28+
decision = limiter.check("local")
29+
self.assertFalse(decision.allowed)
30+
self.assertEqual(decision.wait_seconds, 30)
31+
32+
def test_success_resets_attempt_state(self):
33+
limiter = AttemptLimiter(max_failures=1, lockout_seconds=30, clock=lambda: 1000)
34+
35+
limiter.record_failure("local")
36+
self.assertFalse(limiter.check("local").allowed)
37+
limiter.record_success("local")
38+
39+
self.assertTrue(limiter.check("local").allowed)
40+
41+
def test_file_limiter_persists_state(self):
42+
tmpdir = tempfile.mkdtemp()
43+
store = LocalStateStore(tmpdir)
44+
limiter = FileAttemptLimiter(
45+
store=store,
46+
max_failures=1,
47+
lockout_seconds=30,
48+
clock=lambda: 1000,
49+
)
50+
limiter.record_failure("cli")
51+
52+
restored = FileAttemptLimiter(
53+
store=store,
54+
max_failures=1,
55+
lockout_seconds=30,
56+
clock=lambda: 1000,
57+
)
58+
59+
self.assertFalse(restored.check("cli").allowed)
60+
61+
62+
if __name__ == "__main__":
63+
unittest.main()

0 commit comments

Comments
 (0)