Skip to content

Commit a8c1bee

Browse files
committed
Add crypto boundary self tests
1 parent ae76216 commit a8c1bee

11 files changed

Lines changed: 142 additions & 1 deletion

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,8 @@ PHANTASM_STATE_DIR=/path/to/state python3 main.py init
303303

304304
Phantasm is intended for localhost or USB Ethernet gadget access. It should not be exposed to an untrusted network and should not be deployed as an Internet-facing service. Remote management, telemetry, cloud unlock, and analytics are intentionally out of scope.
305305

306+
At startup, Phantasm runs local primitive self-tests for the cryptographic operations it depends on. These checks improve failure detection and reviewability; they are not a validated cryptographic-module certification.
307+
306308
Restricted local data-loss behavior should be understood primarily as key-material destruction and local access-path invalidation. Best-effort overwrite may be attempted, but SD card behavior means overwrite must not be treated as guaranteed secure deletion.
307309

308310
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.

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

3030
Restricted-flow scenario matrix:

docs/SPECIFICATION.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ The default state directory is `.state/` and can be changed with `PHANTASM_STATE
5454

5555
New local state code paths should use the typed state store for schema-versioned records, atomic writes, restrictive file permissions, and explicit transition checks. Existing binary state files remain compatibility-managed by their owning modules until a migration path is defined.
5656

57+
## 4.1 Cryptographic Boundary
58+
59+
Phantasm defines a local cryptographic primitive boundary in `src/phantasm/crypto_boundary.py`. Startup checks cover AES-GCM round trip behavior, HMAC-SHA-256 behavior, and random byte generation health. Failure causes local startup to stop with a neutral message in the CLI path.
60+
61+
This boundary improves reviewability and failure detection. It is not a FIPS validation, certification claim, or replacement for independent cryptographic review.
62+
5763
## 5. Internal Entry Model
5864

5965
The container uses two fixed internal storage spans. The CLI keeps a compact `--entry a` / `--entry b` selector for compatibility, while WebUI v2 maps the internal model to neutral protected-entry workflows and does not expose internal labels during normal operation.

docs/THREAT_MODEL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ It is not a substitute for audited full-disk encryption, hardware-backed key sto
3434
## Current Defenses
3535

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.
37+
- Startup self-tests check local AES-GCM, HMAC-SHA-256, and random byte generation behavior before normal CLI/WebUI operation.
3738
- The local access key is mixed into Argon2id by default, so copying `vault.bin` alone is insufficient for recovery.
3839
- Protected entries can be stored with normal access and restricted recovery passwords that share the same object cue.
3940
- Store flows reject empty, duplicate, short, or highly repetitive passphrases to reduce accidental weak input.
@@ -67,6 +68,7 @@ These surfaces should not reveal the internal disclosure model, internal trial o
6768
- If the local access key is copied with `vault.bin`, the local access-key protection does not raise attacker cost.
6869
- If `vault.bin`, the configured state directory, and external key material are carried together on one medium, separation benefits are reduced.
6970
- Secure deletion is best-effort only. SSD wear leveling, backups, snapshots, and journaling filesystems may retain previous data.
71+
- Startup self-tests detect some local primitive failures but are not cryptographic certification and do not prove the host is uncompromised.
7072
- On flash media, recovery resistance depends primarily on key-material destruction or removal, not overwrite guarantees.
7173
- The v3 format avoids a plaintext format marker, but surrounding tool files can still reveal that a Phantasm-style container may be in use.
7274
- Dual password slots duplicate encrypted payload material within the selected internal storage span. This improves operational control but reduces maximum payload size.

src/phantasm/cli.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .audit import audit_event
1010
from .bridge_ui import ui
1111
from .config import duress_mode_enabled, purge_confirmation_required
12+
from .crypto_boundary import CryptoSelfTestError, ensure_crypto_self_tests
1213
from .emergency_daemon import EmergencyDaemon
1314
from .face_lock import face_lock
1415
from .gv_core import GhostVault
@@ -191,7 +192,18 @@ def _print_operation_report(report):
191192
print(f"- {check['name']}: {check['status']} - {check['message']}")
192193

193194

195+
def _run_startup_checks():
196+
try:
197+
ensure_crypto_self_tests()
198+
return True
199+
except CryptoSelfTestError:
200+
print("[!] Startup check failed.")
201+
return False
202+
203+
194204
def main():
205+
if not _run_startup_checks():
206+
return
195207
parser = argparse.ArgumentParser(description="Phantasm - Local Protected Storage")
196208
parser.add_argument(
197209
"action",

src/phantasm/crypto_boundary.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Reviewable local cryptographic primitive boundary and self-tests."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import hmac
7+
import os
8+
9+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
10+
11+
12+
class CryptoSelfTestError(RuntimeError):
13+
"""Raised when a required local primitive check fails."""
14+
15+
16+
_SELF_TEST_PASSED = False
17+
18+
19+
def ensure_crypto_self_tests():
20+
global _SELF_TEST_PASSED
21+
if _SELF_TEST_PASSED:
22+
return True
23+
try:
24+
_check_aes_gcm()
25+
_check_hmac_sha256()
26+
_check_random_bytes()
27+
except Exception as exc:
28+
raise CryptoSelfTestError("cryptographic self-test failed") from exc
29+
_SELF_TEST_PASSED = True
30+
return True
31+
32+
33+
def random_bytes(length: int):
34+
if length <= 0:
35+
raise ValueError("length must be positive")
36+
return os.urandom(length)
37+
38+
39+
def _check_aes_gcm():
40+
key = bytes.fromhex("00" * 31 + "01")
41+
nonce = bytes.fromhex("00" * 11 + "01")
42+
aad = b"phantasm-self-test"
43+
plaintext = b"local primitive check"
44+
aesgcm = AESGCM(key)
45+
ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
46+
recovered = aesgcm.decrypt(nonce, ciphertext, aad)
47+
if recovered != plaintext:
48+
raise CryptoSelfTestError("aes-gcm check failed")
49+
50+
51+
def _check_hmac_sha256():
52+
digest = hmac.new(b"phantasm", b"self-test", hashlib.sha256).hexdigest()
53+
expected = "e22f31e13630eceeca685522387389b16ed3ef5378b55eb4f37871fd72d29cf5"
54+
if not hmac.compare_digest(digest, expected):
55+
raise CryptoSelfTestError("hmac check failed")
56+
57+
58+
def _check_random_bytes():
59+
first = random_bytes(32)
60+
second = random_bytes(32)
61+
if len(first) != 32 or len(second) != 32 or first == second:
62+
raise CryptoSelfTestError("random byte check failed")

src/phantasm/web_server.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
ui_face_enrollment_enabled,
3737
ui_face_lock_enabled,
3838
)
39+
from .crypto_boundary import ensure_crypto_self_tests
3940
from .face_lock import face_lock
4041
from .gv_core import GhostVault
4142
from .metadata import metadata_risk_report, scrub_metadata
@@ -48,6 +49,13 @@
4849
from . import strings as text
4950

5051
app = FastAPI(title="Phantasm - Local Secure Interface")
52+
53+
54+
@app.on_event("startup")
55+
async def startup_self_tests():
56+
ensure_crypto_self_tests()
57+
58+
5159
templates = Jinja2Templates(directory=str(Path(__file__).with_name("templates")))
5260
vault = GhostVault("vault.bin")
5361
WEB_TOKEN = os.environ.get("PHANTASM_WEB_TOKEN") or secrets.token_urlsafe(32)

tests/test_cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,17 @@ def check(self, _scope):
143143

144144
self.assertIn("Access temporarily unavailable", output.getvalue())
145145

146+
def test_startup_check_failure_is_neutral(self):
147+
output = io.StringIO()
148+
149+
with unittest.mock.patch.object(
150+
cli, "ensure_crypto_self_tests", side_effect=cli.CryptoSelfTestError
151+
):
152+
with contextlib.redirect_stdout(output):
153+
self.assertFalse(cli._run_startup_checks())
154+
155+
self.assertIn("Startup check failed", output.getvalue())
156+
146157

147158
if __name__ == "__main__":
148159
unittest.main()

tests/test_crypto_boundary.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import os
2+
import sys
3+
import unittest
4+
from unittest import mock
5+
6+
ROOT = os.path.dirname(os.path.dirname(__file__))
7+
sys.path.insert(0, os.path.join(ROOT, "src"))
8+
9+
from phantasm import crypto_boundary
10+
11+
12+
class CryptoBoundaryTests(unittest.TestCase):
13+
def tearDown(self):
14+
crypto_boundary._SELF_TEST_PASSED = False
15+
16+
def test_self_tests_pass(self):
17+
self.assertTrue(crypto_boundary.ensure_crypto_self_tests())
18+
19+
def test_random_bytes_rejects_invalid_length(self):
20+
with self.assertRaises(ValueError):
21+
crypto_boundary.random_bytes(0)
22+
23+
def test_self_test_failure_is_neutral(self):
24+
with mock.patch.object(
25+
crypto_boundary, "_check_random_bytes", side_effect=RuntimeError("detail")
26+
):
27+
with self.assertRaises(crypto_boundary.CryptoSelfTestError) as ctx:
28+
crypto_boundary.ensure_crypto_self_tests()
29+
30+
self.assertEqual(str(ctx.exception), "cryptographic self-test failed")
31+
32+
33+
if __name__ == "__main__":
34+
unittest.main()

tests/test_docs_and_templates.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def test_readme_addresses_reviewer_and_use_boundaries(self):
4747
self.assertIn("python3 -m bandit -r src", readme)
4848
self.assertIn("PHANTASM_MIN_PASSPHRASE_LENGTH", readme)
4949
self.assertIn("PHANTASM_ACCESS_MAX_FAILURES", readme)
50+
self.assertIn("not a validated cryptographic-module certification", readme)
5051
self.assertIn("not approved classified-data handling infrastructure", readme)
5152
self.assertIn("Field Mode is not a security boundary", readme)
5253
self.assertIn("Metadata detection and reduction are best-effort", readme)
@@ -66,6 +67,8 @@ def test_specification_defines_field_mode_and_metadata_routes(self):
6667
self.assertIn("Field Mode is not a security boundary", spec)
6768
self.assertIn("PHANTASM_MIN_PASSPHRASE_LENGTH", spec)
6869
self.assertIn("PHANTASM_ACCESS_LOCKOUT_SECONDS", spec)
70+
self.assertIn("Cryptographic Boundary", spec)
71+
self.assertIn("not a FIPS validation", spec)
6972

7073
def test_threat_model_names_leakage_surfaces(self):
7174
threat = read_text("docs/THREAT_MODEL.md")

0 commit comments

Comments
 (0)