7 Commits

Author SHA1 Message Date
adlee-was-taken
94cc206fb4 fix(cli): redact security.recovery_passphrase_hash in show_cmd
Adjacent secret leak in show_cmd noticed during Task 3 code review.
SecurityConfig has two sensitive fields and the redaction block only
covered pin_hash. vigilar config show would print the recovery
passphrase hash verbatim whenever one was configured.

One-line fix; same redaction pattern as the surrounding secrets.
Part of issue #2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:44:14 -04:00
adlee-was-taken
8ffe1a38ed fix(cli): set-pin emits PBKDF2 under [security] pin_hash (issue #2)
Was: HMAC-SHA256(random, pin) written to [system] arm_pin_hash —
no verifier in the codebase accepted this output.
Now: PBKDF2-SHA256 via alerts.pin.hash_pin written to [security]
pin_hash, matching what the web and FSM paths verify against.

Also fixes show_cmd to redact the new location.
2026-04-05 11:37:59 -04:00
adlee-was-taken
af2ac5693d refactor(events): drop verify_pin alias and clarify audit-log comment
Code review feedback on the Task 2 commit (7fda351):
- The 'verify_pin as _verify_pin_hash' alias was unnecessary — the
  method self.verify_pin and the module-level verify_pin do not
  collide (one is accessed via self, the other via the bare name).
  Removing the alias matches how web/blueprints/system.py already
  imports verify_pin and makes the call site read cleanly.
- The comment on the insert_arm_state None argument now explains
  WHY (PBKDF2 salt is fresh per call, so re-hashing is worthless for
  audit correlation) instead of only referencing the issue.

No behavior change. Part of issue #2.
2026-04-05 11:33:58 -04:00
adlee-was-taken
7fda351c02 fix(events): ArmStateFSM uses PBKDF2 via alerts.pin (issue #2)
Was: unsalted SHA-256 read from [system] arm_pin_hash.
Now: PBKDF2-SHA256 600k iterations read from [security] pin_hash,
matching the web arm/disarm path and the alerts/pin module.

Also drops the redundant pin re-hash on the arm_state_log audit row
(a fresh PBKDF2 salt made the column valueless for traceability).

Part of issue #2 PIN hashing unification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:26:07 -04:00
adlee-was-taken
cdd8cf4693 feat(constants): add Topics.SYSTEM_ARM_REQUEST
Topic for web-originated arm/disarm requests that the event processor
will subscribe to and dispatch to ArmStateFSM.transition. Part of the
PIN unification work (issue #2).
2026-04-05 11:22:10 -04:00
adlee-was-taken
45afaf89ab docs(plan): implementation plan for PIN hashing unification (issue #2)
Plan document for issue #2 — the three-way PIN hash mismatch across
CLI, events FSM, and web arm/disarm. Proposes canonicalizing on
PBKDF2-SHA256 via alerts/pin and [security] pin_hash, deprecating
[system] arm_pin_hash, and wiring web arm/disarm through MQTT to the
FSM so the web buttons actually transition state.

Nine tasks, TDD throughout. No code changes in this commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:20:29 -04:00
adlee-was-taken
e657f2bfbc test: isolate VIGILAR_CONFIG via autouse session fixture
Some web endpoint handlers call _save_and_reload(), which resolves the
target path via VIGILAR_CONFIG env var with a fallback to the relative
"config/vigilar.toml". Any test exercising such an endpoint without
setting the env var rewrites the repo's committed config file via a
Pydantic model_dump round-trip, stripping comments and non-default
fields. The culprit discovered was test_reset_pin_correct_passphrase
in tests/unit/test_system_pin.py.

Add an autouse session-scoped fixture in tests/conftest.py that points
VIGILAR_CONFIG at a path inside pytest's session tmp dir so no test
can touch the real file. Restore the previous env var value on teardown.

Fixes #3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:55:42 -04:00
6 changed files with 75 additions and 23 deletions

View File

@@ -11,6 +11,28 @@ from vigilar.config import VigilarConfig, load_config
from vigilar.storage.schema import metadata from vigilar.storage.schema import metadata
@pytest.fixture(autouse=True, scope="session")
def _isolate_vigilar_config(tmp_path_factory):
"""Prevent tests from writing to the real config/vigilar.toml.
Web endpoint handlers that call `_save_and_reload()` read the target
path from the VIGILAR_CONFIG env var, falling back to the relative
`"config/vigilar.toml"`. Without this fixture, any test that exercises
such an endpoint rewrites the repo's committed config file via a
Pydantic round-trip, stripping comments and non-default fields.
"""
tmp_config = tmp_path_factory.mktemp("vigilar-config") / "vigilar.toml"
prev = os.environ.get("VIGILAR_CONFIG")
os.environ["VIGILAR_CONFIG"] = str(tmp_config)
try:
yield
finally:
if prev is None:
os.environ.pop("VIGILAR_CONFIG", None)
else:
os.environ["VIGILAR_CONFIG"] = prev
def _create_test_engine(db_path: Path): def _create_test_engine(db_path: Path):
"""Create a fresh engine for testing (bypasses the global singleton).""" """Create a fresh engine for testing (bypasses the global singleton)."""
db_path.parent.mkdir(parents=True, exist_ok=True) db_path.parent.mkdir(parents=True, exist_ok=True)

View File

@@ -0,0 +1,31 @@
"""Tests for `vigilar config set-pin`."""
from click.testing import CliRunner
from vigilar.alerts.pin import verify_pin
from vigilar.cli.cmd_config import config_cmd
def test_set_pin_outputs_pbkdf2_hash_under_security_section():
"""The CLI must emit a hash that alerts.pin.verify_pin can validate,
and direct the user to [security] pin_hash (not [system] arm_pin_hash)."""
runner = CliRunner()
result = runner.invoke(config_cmd, ["set-pin"], input="1234\n1234\n")
assert result.exit_code == 0, result.output
# The output must direct the user to the [security] section
assert "[security]" in result.output
assert "arm_pin_hash" not in result.output
assert "pin_hash" in result.output
# Extract the emitted hash (line starts with `pin_hash = "..."`)
hash_line = next(
line for line in result.output.splitlines() if line.strip().startswith("pin_hash")
)
hash_value = hash_line.split('"')[1]
# Round-trip: the emitted hash must accept the plaintext PIN
assert verify_pin("1234", hash_value) is True
assert verify_pin("0000", hash_value) is False
# And it must be in PBKDF2 format (not the legacy HMAC "secret:mac" format)
assert hash_value.startswith("pbkdf2_sha256$")

View File

@@ -1,6 +1,5 @@
"""Tests for the Phase 6 events subsystem: rules, arm state FSM, history.""" """Tests for the Phase 6 events subsystem: rules, arm state FSM, history."""
import hashlib
import time import time
import pytest import pytest
@@ -19,7 +18,7 @@ from vigilar.storage.queries import insert_event
def _make_config(rules=None, pin_hash=""): def _make_config(rules=None, pin_hash=""):
return VigilarConfig( return VigilarConfig(
system={"arm_pin_hash": pin_hash}, security={"pin_hash": pin_hash},
cameras=[], cameras=[],
sensors=[], sensors=[],
rules=rules or [], rules=rules or [],
@@ -27,7 +26,8 @@ def _make_config(rules=None, pin_hash=""):
def _pin_hash(pin: str) -> str: def _pin_hash(pin: str) -> str:
return hashlib.sha256(pin.encode()).hexdigest() from vigilar.alerts.pin import hash_pin
return hash_pin(pin)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -47,8 +47,10 @@ def show_cmd(config_path: str | None) -> None:
# Redact sensitive fields # Redact sensitive fields
if data.get("web", {}).get("password_hash"): if data.get("web", {}).get("password_hash"):
data["web"]["password_hash"] = "***" data["web"]["password_hash"] = "***"
if data.get("system", {}).get("arm_pin_hash"): if data.get("security", {}).get("pin_hash"):
data["system"]["arm_pin_hash"] = "***" data["security"]["pin_hash"] = "***"
if data.get("security", {}).get("recovery_passphrase_hash"):
data["security"]["recovery_passphrase_hash"] = "***"
if data.get("alerts", {}).get("webhook", {}).get("secret"): if data.get("alerts", {}).get("webhook", {}).get("secret"):
data["alerts"]["webhook"]["secret"] = "***" data["alerts"]["webhook"]["secret"] = "***"
click.echo(json.dumps(data, indent=2)) click.echo(json.dumps(data, indent=2))
@@ -82,14 +84,10 @@ def set_password_cmd(config_path: str | None) -> None:
@config_cmd.command("set-pin") @config_cmd.command("set-pin")
def set_pin_cmd() -> None: def set_pin_cmd() -> None:
"""Generate an HMAC hash for the arm/disarm PIN.""" """Generate a PBKDF2 hash for the arm/disarm PIN."""
import hashlib from vigilar.alerts.pin import hash_pin
import hmac
import os
pin = click.prompt("Enter arm/disarm PIN", hide_input=True, confirmation_prompt=True) pin = click.prompt("Enter arm/disarm PIN", hide_input=True, confirmation_prompt=True)
secret = os.urandom(32) hash_str = hash_pin(pin)
mac = hmac.new(secret, pin.encode(), hashlib.sha256).hexdigest() click.echo("\nAdd this to your vigilar.toml [security] section:")
hash_str = secret.hex() + ":" + mac click.echo(f'pin_hash = "{hash_str}"')
click.echo(f"\nAdd this to your vigilar.toml [system] section:")
click.echo(f'arm_pin_hash = "{hash_str}"')

View File

@@ -210,6 +210,8 @@ class Topics:
SYSTEM_ALERT = "vigilar/system/alert" SYSTEM_ALERT = "vigilar/system/alert"
SYSTEM_SHUTDOWN = "vigilar/system/shutdown" SYSTEM_SHUTDOWN = "vigilar/system/shutdown"
SYSTEM_RULES_UPDATED = "vigilar/system/rules_updated" SYSTEM_RULES_UPDATED = "vigilar/system/rules_updated"
# Web-to-FSM arm/disarm request (FSM verifies the PIN and transitions)
SYSTEM_ARM_REQUEST = "vigilar/system/arm_request"
# Wildcard subscriptions # Wildcard subscriptions
ALL = "vigilar/#" ALL = "vigilar/#"

View File

@@ -1,12 +1,11 @@
"""Arm state finite state machine.""" """Arm state finite state machine."""
import hashlib
import hmac
import logging import logging
import time import time
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from vigilar.alerts.pin import verify_pin
from vigilar.config import VigilarConfig from vigilar.config import VigilarConfig
from vigilar.constants import ArmState, EventType, Severity, Topics from vigilar.constants import ArmState, EventType, Severity, Topics
from vigilar.storage.queries import get_current_arm_state, insert_arm_state, insert_event from vigilar.storage.queries import get_current_arm_state, insert_arm_state, insert_event
@@ -19,7 +18,7 @@ class ArmStateFSM:
def __init__(self, engine: Engine, config: VigilarConfig): def __init__(self, engine: Engine, config: VigilarConfig):
self._engine = engine self._engine = engine
self._pin_hash = config.system.arm_pin_hash self._pin_hash = config.security.pin_hash
self._state = ArmState.DISARMED self._state = ArmState.DISARMED
self._bus = None self._bus = None
self._load_initial_state() self._load_initial_state()
@@ -43,12 +42,11 @@ class ArmStateFSM:
return self._state return self._state
def verify_pin(self, pin: str) -> bool: def verify_pin(self, pin: str) -> bool:
"""Verify a PIN against the stored hash using HMAC comparison.""" """Verify a PIN against the stored PBKDF2 hash."""
if not self._pin_hash: if not self._pin_hash:
# No PIN configured — allow all transitions # No PIN configured — allow all transitions
return True return True
candidate = hashlib.sha256(pin.encode()).hexdigest() return verify_pin(pin, self._pin_hash)
return hmac.compare_digest(candidate, self._pin_hash)
def transition( def transition(
self, self,
@@ -68,9 +66,10 @@ class ArmStateFSM:
old_state = self._state old_state = self._state
self._state = new_state self._state = new_state
# Log to database # pin_hash is always None here: PBKDF2 uses a random salt per call, so
pin_hash = hashlib.sha256(pin.encode()).hexdigest() if pin else None # re-hashing the pin now would produce a value unrelated to the stored
insert_arm_state(self._engine, new_state.value, triggered_by, pin_hash) # hash, making the column useless for audit correlation. See issue #2.
insert_arm_state(self._engine, new_state.value, triggered_by, None)
# Log event # Log event
insert_event( insert_event(