Skip to content

Commit fb970c8

Browse files
committed
Add store passphrase policy
1 parent bfabac9 commit fb970c8

14 files changed

Lines changed: 171 additions & 9 deletions

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ PHANTASM_STATE_DIR=/path/to/state python3 main.py init
294294
| `PHANTASM_RESTRICTED_SESSION_SECONDS` | Restricted confirmation lifetime |
295295
| `PHANTASM_FIELD_MODE=1` | Reduce normal WebUI operational detail |
296296
| `PHANTASM_PROFILE` | Select local capability mode: `standard`, `field`, or `maintenance` |
297+
| `PHANTASM_MIN_PASSPHRASE_LENGTH` | Minimum Store passphrase length |
297298
| `PHANTASM_AUDIT=1` | Enable audit logging |
298299

299300
## Local-Only Trust Boundary
@@ -304,6 +305,8 @@ Restricted local data-loss behavior should be understood primarily as key-materi
304305

305306
For high-risk deployments, do not store all recovery conditions on the same physical medium. Phantasm is strongest when the encrypted container, local state, memorized password, physical-object cue, and optional external key material are separated.
306307

308+
Store flows reject empty, obviously short, duplicate, or highly repetitive passphrases. This policy reduces accidental weak input, but it is not a substitute for secure operational procedure, key separation, or target-hardware validation.
309+
307310
## Test Command
308311

309312
```bash

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, 133 tests passed.
27+
2026-05-02, macOS Darwin arm64, python3 -m unittest discover -s tests, 141 tests passed.
2828
```
2929

3030
Restricted-flow scenario matrix:

docs/SPECIFICATION.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,11 @@ Store flow:
7979

8080
1. Start the camera gate.
8181
2. Prompt for normal access and restricted recovery passwords.
82-
3. Register the physical object cue for the selected internal entry.
83-
4. Read the input file.
84-
5. Derive a key with Argon2id.
85-
6. Encrypt the payload with AES-GCM.
82+
3. Reject empty, duplicate, short, or highly repetitive passphrases.
83+
4. Register the physical object cue for the selected internal entry.
84+
5. Read the input file.
85+
6. Derive a key with Argon2id.
86+
7. Encrypt the payload with AES-GCM.
8687

8788
### Retrieve
8889

@@ -314,6 +315,7 @@ The physical object is an operational cue, not a high-entropy cryptographic fact
314315
| `PHANTASM_RESTRICTED_SESSION_SECONDS` | Restricted confirmation lifetime | `120` |
315316
| `PHANTASM_FIELD_MODE` | Reduce normal WebUI operational detail | `0` |
316317
| `PHANTASM_PROFILE` | Select local capability mode: `standard`, `field`, or `maintenance` | `standard` |
318+
| `PHANTASM_MIN_PASSPHRASE_LENGTH` | Minimum Store passphrase length | `10` |
317319
| `PHANTASM_AUDIT` | Enable audit logging | `0` |
318320
| `PHANTASM_AUDIT_FILENAMES` | Record filename hashes | unset |
319321

docs/THREAT_MODEL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ It is not a substitute for audited full-disk encryption, hardware-backed key sto
3636
- New stores use GhostVault v3 records: random per-record Argon2id salt, random per-record AES-GCM nonce, no plaintext magic/header, and AEAD-authenticated encrypted metadata.
3737
- The local access key is mixed into Argon2id by default, so copying `vault.bin` alone is insufficient for recovery.
3838
- Protected entries can be stored with normal access and restricted recovery passwords that share the same object cue.
39+
- Store flows reject empty, duplicate, short, or highly repetitive passphrases to reduce accidental weak input.
3940
- `PHANTASM_HARDWARE_SECRET_FILE`, `PHANTASM_HARDWARE_SECRET`, or `PHANTASM_HARDWARE_SECRET_PROMPT=1` can add an external value to Argon2id derivation. Data stored with any of these values requires the same value for retrieval.
4041
- Default Argon2id parameters are tuned for Raspberry Pi Zero 2 W class hardware: `memory_cost=32768`, `iterations=2`, `lanes=1`.
4142
- Restricted recovery behavior and explicit restricted actions can update unmatched local state. These paths can cause irreversible data loss.
@@ -71,6 +72,7 @@ These surfaces should not reveal the internal disclosure model, internal trial o
7172
- UI face lock can be affected by lighting, camera angle, false rejects, false accepts, and presentation attacks using photos or screens.
7273
- The in-memory Web rate limiter and restricted confirmation state reset on process restart and are not substitutes for a full access-control layer.
7374
- UI tokens can be read from a compromised browser or host session.
75+
- Passphrase policy cannot compensate for observed input, reused passwords, coercion, compromised hosts, or poor operational separation.
7476
- Metadata checks and metadata reduction are best-effort. They can miss embedded identifiers, thumbnails, histories, and application-specific fields.
7577
- Optional audit logs can support local review, including tamper detection for versioned records, but they also create local metadata.
7678
- Browser history, cache, shell history, systemd logs, environment variables, and temporary files can leak operational context if the appliance is not configured carefully.

src/phantasm/cli.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .face_lock import face_lock
1313
from .gv_core import GhostVault
1414
from .operations import doctor, export_redacted_log, verify_audit_log, verify_state
15+
from .passphrase_policy import check_store_passphrases
1516

1617
CAMERA_WARMUP_TIMEOUT = 10
1718
REFERENCE_MATCH_TIMEOUT = 10
@@ -139,8 +140,11 @@ def _prompt_store_passwords():
139140
raise ValueError("access password must not be empty")
140141
if not restricted_recovery_password:
141142
raise ValueError("restricted recovery password must not be empty")
142-
if open_password == restricted_recovery_password:
143-
raise ValueError("access and restricted recovery passwords must be different")
143+
passphrase_check = check_store_passphrases(
144+
open_password, restricted_recovery_password
145+
)
146+
if not passphrase_check.ok:
147+
raise ValueError(passphrase_check.message)
144148
return open_password, restricted_recovery_password
145149

146150

src/phantasm/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,11 @@ def ui_face_enrollment_enabled():
4242

4343
def field_mode_enabled():
4444
return env_flag("PHANTASM_FIELD_MODE", default=False)
45+
46+
47+
def passphrase_min_length():
48+
value = os.environ.get("PHANTASM_MIN_PASSPHRASE_LENGTH", "10")
49+
try:
50+
return max(1, int(value))
51+
except ValueError:
52+
return 10

src/phantasm/passphrase_policy.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Local passphrase checks for Store flows."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
7+
from .config import passphrase_min_length
8+
9+
10+
@dataclass(frozen=True)
11+
class PassphraseCheck:
12+
ok: bool
13+
message: str = ""
14+
15+
16+
def check_passphrase(value: str, label: str = "Access password"):
17+
if not value:
18+
return PassphraseCheck(False, f"{label} must not be empty.")
19+
minimum = passphrase_min_length()
20+
if len(value) < minimum:
21+
return PassphraseCheck(
22+
False,
23+
f"{label} must be at least {minimum} characters.",
24+
)
25+
if len(set(value)) <= 2:
26+
return PassphraseCheck(False, f"{label} is too repetitive.")
27+
return PassphraseCheck(True)
28+
29+
30+
def check_store_passphrases(access_value: str, restricted_value: str = ""):
31+
access_check = check_passphrase(access_value, "Access password")
32+
if not access_check.ok:
33+
return access_check
34+
if restricted_value:
35+
if access_value == restricted_value:
36+
return PassphraseCheck(
37+
False,
38+
"Access and restricted recovery passwords must be different.",
39+
)
40+
restricted_check = check_passphrase(
41+
restricted_value,
42+
"Restricted recovery password",
43+
)
44+
if not restricted_check.ok:
45+
return restricted_check
46+
return PassphraseCheck(True)

src/phantasm/web_server.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from .face_lock import face_lock
3939
from .gv_core import GhostVault
4040
from .metadata import metadata_risk_report, scrub_metadata
41+
from .passphrase_policy import check_store_passphrases
4142
from .restricted_actions import (
4243
RestrictedActionPolicy,
4344
RestrictedActionRejected,
@@ -715,8 +716,12 @@ async def store(
715716
try:
716717
if not password:
717718
return {"error": text.ACCESS_PASSWORD_REQUIRED}
718-
if restricted_recovery_password and password == restricted_recovery_password:
719-
return {"error": text.PASSWORDS_MUST_DIFFER}
719+
passphrase_check = check_store_passphrases(
720+
password,
721+
restricted_recovery_password,
722+
)
723+
if not passphrase_check.ok:
724+
return {"error": passphrase_check.message}
720725
if overwrite and overwrite_confirmation != OVERWRITE_CONFIRMATION_PHRASE:
721726
return {"error": text.REPLACEMENT_CONFIRMATION_REQUIRED}
722727
if overwrite and not _restricted_session_valid(request):

tests/test_cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,17 @@ def format_container(self, rotate_access_key=False):
107107
self.assertEqual(enroll_message, "enrollment armed")
108108
arm.assert_called_once_with()
109109

110+
def test_store_password_prompt_rejects_short_values(self):
111+
prompts = iter(["short", "another-short"])
112+
113+
with unittest.mock.patch(
114+
"getpass.getpass", side_effect=lambda _: next(prompts)
115+
):
116+
with self.assertRaises(ValueError) as ctx:
117+
cli._prompt_store_passwords()
118+
119+
self.assertIn("at least", str(ctx.exception))
120+
110121

111122
if __name__ == "__main__":
112123
unittest.main()

tests/test_config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ def test_field_mode_defaults_to_disabled(self):
4646
with mock.patch.dict(os.environ, {}, clear=True):
4747
self.assertFalse(config.field_mode_enabled())
4848

49+
def test_passphrase_min_length_defaults_and_handles_invalid_env(self):
50+
with mock.patch.dict(os.environ, {}, clear=True):
51+
self.assertEqual(config.passphrase_min_length(), 10)
52+
with mock.patch.dict(
53+
os.environ, {"PHANTASM_MIN_PASSPHRASE_LENGTH": "12"}, clear=True
54+
):
55+
self.assertEqual(config.passphrase_min_length(), 12)
56+
with mock.patch.dict(
57+
os.environ, {"PHANTASM_MIN_PASSPHRASE_LENGTH": "bad"}, clear=True
58+
):
59+
self.assertEqual(config.passphrase_min_length(), 10)
60+
4961

5062
if __name__ == "__main__":
5163
unittest.main()

0 commit comments

Comments
 (0)