Skip to content

Commit 65a0aeb

Browse files
01rabbitclaude
andauthored
feat: add Silent Standby state machine and TUI screen (#133)
* feat: add Silent Standby state machine and TUI screen Adds StandbyStateMachine (active→standby→sealed→dummy_disclosure), non-sensitive StandbyScreen, ctrl+s hotkey binding in PhasmidApp, and PHASMID_STANDBY_HOTKEY config. Sealed state clears sensitive UI surfaces without erasing memory. Closes #127. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: clean standby screen lint issues --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d6de2ff commit 65a0aeb

6 files changed

Lines changed: 493 additions & 0 deletions

File tree

src/phasmid/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,3 +223,8 @@ def tui_light_enabled() -> bool:
223223
def context_profile_name() -> str:
224224
name = env_text("PHASMID_CONTEXT_PROFILE", "travel").strip().lower()
225225
return name or "travel"
226+
227+
228+
def standby_hotkey() -> str:
229+
key = env_text("PHASMID_STANDBY_HOTKEY", "ctrl+s").strip().lower()
230+
return key or "ctrl+s"

src/phasmid/standby_state.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""
2+
Silent Standby state machine for coercion-safe UI transitions.
3+
4+
Provides a configurable hotkey-triggered transition from sensitive UI state
5+
to a non-sensitive standby state. Recovery requires re-authentication.
6+
7+
State diagram:
8+
active → standby → sealed
9+
sealed → active (re-authentication required)
10+
sealed → dummy_disclosure (coercion-safe path)
11+
12+
What standby does:
13+
- Removes sensitive UI state from the visible surface.
14+
- Detaches true-profile UI references.
15+
- Transitions to a non-sensitive screen.
16+
17+
What standby does NOT do:
18+
- Erase key material from process memory.
19+
- Forge logs, fake system events, or perform anti-forensic tampering.
20+
- Hide the Phasmid process from the system process list.
21+
- Prevent live memory capture from recovering in-use key material.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import threading
27+
from enum import Enum
28+
29+
30+
class StandbyState(str, Enum):
31+
ACTIVE = "active"
32+
STANDBY = "standby"
33+
SEALED = "sealed"
34+
DUMMY_DISCLOSURE = "dummy_disclosure"
35+
36+
37+
class InvalidTransitionError(Exception):
38+
"""Raised when a state transition is not allowed."""
39+
40+
41+
class StandbyStateMachine:
42+
"""
43+
Thread-safe standby state machine.
44+
45+
Transitions:
46+
active → standby (trigger_standby)
47+
standby → sealed (seal — automatic on standby activation)
48+
sealed → active (recover — requires re-authentication)
49+
sealed → dummy_disclosure (enter_dummy_disclosure)
50+
dummy_disclosure → sealed (seal_dummy)
51+
52+
Re-entry to active from standby is disallowed without re-authentication.
53+
"""
54+
55+
_VALID_TRANSITIONS: dict[StandbyState, set[StandbyState]] = {
56+
StandbyState.ACTIVE: {StandbyState.STANDBY},
57+
StandbyState.STANDBY: {StandbyState.SEALED},
58+
StandbyState.SEALED: {StandbyState.ACTIVE, StandbyState.DUMMY_DISCLOSURE},
59+
StandbyState.DUMMY_DISCLOSURE: {StandbyState.SEALED},
60+
}
61+
62+
def __init__(self) -> None:
63+
self._state = StandbyState.ACTIVE
64+
self._lock = threading.Lock()
65+
66+
@property
67+
def state(self) -> StandbyState:
68+
with self._lock:
69+
return self._state
70+
71+
def trigger_standby(self) -> None:
72+
"""
73+
Transition from active to standby, then immediately to sealed.
74+
75+
This is the hotkey-triggered path. After this call, the state is SEALED.
76+
"""
77+
with self._lock:
78+
if self._state != StandbyState.ACTIVE:
79+
raise InvalidTransitionError(
80+
f"Cannot trigger standby from state '{self._state}'; "
81+
"must be in ACTIVE state."
82+
)
83+
self._state = StandbyState.STANDBY
84+
self._state = StandbyState.SEALED
85+
86+
def recover(self) -> None:
87+
"""
88+
Transition from sealed back to active.
89+
90+
Caller is responsible for verifying re-authentication before calling this.
91+
Direct restoration of previous sensitive UI state is disallowed by design.
92+
"""
93+
with self._lock:
94+
if self._state != StandbyState.SEALED:
95+
raise InvalidTransitionError(
96+
f"Cannot recover from state '{self._state}'; "
97+
"must be in SEALED state."
98+
)
99+
self._state = StandbyState.ACTIVE
100+
101+
def enter_dummy_disclosure(self) -> None:
102+
"""
103+
Transition from sealed to dummy_disclosure.
104+
105+
Used in coercion-safe recognition fallback and manual operator choice.
106+
"""
107+
with self._lock:
108+
if self._state != StandbyState.SEALED:
109+
raise InvalidTransitionError(
110+
f"Cannot enter dummy disclosure from state '{self._state}'; "
111+
"must be in SEALED state."
112+
)
113+
self._state = StandbyState.DUMMY_DISCLOSURE
114+
115+
def seal_dummy(self) -> None:
116+
"""Return from dummy_disclosure back to sealed."""
117+
with self._lock:
118+
if self._state != StandbyState.DUMMY_DISCLOSURE:
119+
raise InvalidTransitionError(
120+
f"Cannot seal dummy from state '{self._state}'; "
121+
"must be in DUMMY_DISCLOSURE state."
122+
)
123+
self._state = StandbyState.SEALED
124+
125+
def is_active(self) -> bool:
126+
return self.state == StandbyState.ACTIVE
127+
128+
def is_sealed(self) -> bool:
129+
return self.state == StandbyState.SEALED
130+
131+
def is_in_standby_or_sealed(self) -> bool:
132+
return self.state in (StandbyState.STANDBY, StandbyState.SEALED)
133+
134+
def is_dummy_disclosure(self) -> bool:
135+
return self.state == StandbyState.DUMMY_DISCLOSURE
136+
137+
def status_dict(self) -> dict[str, object]:
138+
"""Return a status dict safe for diagnostics. Contains no key material."""
139+
return {"state": self.state.value}

src/phasmid/tui/app.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from textual.binding import Binding
55
from textual.events import Key
66

7+
from ..config import standby_hotkey
78
from ..services.webui_service import WebUIService
9+
from ..standby_state import StandbyStateMachine
810
from .screens.home import HomeScreen
911
from .theme import PHASMID_DARK, PHASMID_LIGHT
1012

@@ -18,6 +20,7 @@ class PhasmidApp(App):
1820
BINDINGS = [
1921
Binding("ctrl+c", "quit", "Quit", show=False),
2022
Binding("w", "toggle_webui", "WebUI"),
23+
Binding(standby_hotkey(), "trigger_standby", "Standby", show=False),
2124
]
2225

2326
CSS = """
@@ -37,6 +40,7 @@ def __init__(
3740
self._vessel_path = vessel_path
3841
self.webui_svc = WebUIService()
3942
self.webui_svc.set_timeout_callback(self._on_webui_timeout)
43+
self.standby = StandbyStateMachine()
4044

4145
def on_mount(self) -> None:
4246
try:
@@ -170,6 +174,27 @@ def _push_luks(self) -> None:
170174
self.push_screen(HomeScreen())
171175
self.push_screen(LuksScreen())
172176

177+
def action_trigger_standby(self) -> None:
178+
"""Hotkey-triggered standby: clear sensitive UI and enter sealed state."""
179+
if not self.standby.is_active():
180+
return
181+
182+
from .screens.standby import StandbyScreen
183+
184+
try:
185+
self.standby.trigger_standby()
186+
except Exception:
187+
return
188+
189+
def _on_standby_dismissed(result: bool | None) -> None:
190+
if result:
191+
try:
192+
self.standby.recover()
193+
except Exception:
194+
pass
195+
196+
self.push_screen(StandbyScreen(), _on_standby_dismissed)
197+
173198

174199
def run_tui(
175200
initial_screen: str = "home",

src/phasmid/tui/screens/standby.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""
2+
Silent Standby screen for the TUI Operator Console.
3+
4+
This screen is displayed after standby activation. It contains no sensitive
5+
content and no references to the true disclosure state.
6+
7+
Allowed content: maintenance status, diagnostics, storage checks, update
8+
preparation, local system information.
9+
10+
Disallowed content: any reference to the true profile, vault contents,
11+
protected entries, recognition mode, or restricted recovery state.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import platform
17+
from datetime import datetime
18+
19+
from textual.app import ComposeResult
20+
from textual.binding import Binding
21+
from textual.widgets import Button, Footer, Static
22+
23+
from .base import OperatorScreen
24+
25+
_STANDBY_MESSAGE = """\
26+
STANDBY MODE
27+
28+
Local system is in standby state.
29+
No sensitive operations are available.
30+
31+
To return to normal operation, re-authentication is required.
32+
"""
33+
34+
_MAINTENANCE_ITEMS = [
35+
"Storage integrity check: OK",
36+
"Configuration directory: accessible",
37+
"Local services: idle",
38+
"Background tasks: none",
39+
"System clock: synchronized",
40+
]
41+
42+
43+
class StandbyScreen(OperatorScreen):
44+
"""
45+
Non-sensitive standby screen.
46+
47+
Displayed after hotkey-triggered standby activation. Contains only
48+
maintenance-style information. No sensitive UI references.
49+
"""
50+
51+
BINDINGS = [
52+
Binding("ctrl+r", "request_recovery", "Re-authenticate", show=True),
53+
Binding("escape", "request_recovery", "Re-authenticate", show=False),
54+
]
55+
56+
DEFAULT_CSS = """
57+
StandbyScreen {
58+
background: $background;
59+
padding: 1 4;
60+
}
61+
StandbyScreen #standby-banner {
62+
text-align: center;
63+
color: $text-muted;
64+
text-style: bold;
65+
padding: 1 0;
66+
height: 3;
67+
dock: top;
68+
}
69+
StandbyScreen #status-title {
70+
color: $text-muted;
71+
text-style: bold;
72+
padding: 1 0 0 0;
73+
}
74+
StandbyScreen #maintenance-panel {
75+
height: 10;
76+
border: solid $text-muted;
77+
padding: 1 2;
78+
color: $text-muted;
79+
margin: 0 0 1 0;
80+
}
81+
StandbyScreen #time-panel {
82+
color: $text-muted;
83+
height: 2;
84+
margin: 0 0 1 0;
85+
}
86+
StandbyScreen #recover-btn {
87+
margin-top: 1;
88+
width: 100%;
89+
}
90+
"""
91+
92+
def __init__(self, **kwargs) -> None:
93+
super().__init__(**kwargs)
94+
95+
def compose(self) -> ComposeResult:
96+
yield self.webui_warning_banner()
97+
yield Static("[ STANDBY ]", id="standby-banner")
98+
yield Static("SYSTEM STATUS", id="status-title")
99+
yield Static("", id="maintenance-panel", markup=False)
100+
yield Static("", id="time-panel", markup=False)
101+
yield Button("Re-authenticate to continue", id="recover-btn", variant="default")
102+
yield Footer()
103+
104+
def on_mount(self) -> None:
105+
self._refresh_status()
106+
self.set_interval(5, self._refresh_status)
107+
108+
def _refresh_status(self) -> None:
109+
lines = list(_MAINTENANCE_ITEMS)
110+
panel = self.query_one("#maintenance-panel", Static)
111+
panel.update("\n".join(lines))
112+
113+
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
114+
node = platform.node() or "local"
115+
time_panel = self.query_one("#time-panel", Static)
116+
time_panel.update(f"Time: {now} | Host: {node}")
117+
118+
def on_button_pressed(self, event: Button.Pressed) -> None:
119+
if event.button.id == "recover-btn":
120+
self.action_request_recovery()
121+
122+
def action_request_recovery(self) -> None:
123+
"""Signal that the operator wants to re-authenticate and return to active."""
124+
self.dismiss(True)

0 commit comments

Comments
 (0)