From e630c206b28e7d32af2fe8a59269e6b272b48b4f Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 17:40:01 -0400 Subject: [PATCH] feat(F4): PIN verification on arm/disarm + reset-pin endpoint Adds PIN checking to arm/disarm endpoints using verify_pin() against cfg.security.pin_hash, and a new POST /system/api/reset-pin endpoint that verifies the recovery passphrase before updating the PIN hash. Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_system_pin.py | 79 ++++++++++++++++++++++++++++++++ vigilar/web/blueprints/system.py | 28 ++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_system_pin.py diff --git a/tests/unit/test_system_pin.py b/tests/unit/test_system_pin.py new file mode 100644 index 0000000..6bdaeeb --- /dev/null +++ b/tests/unit/test_system_pin.py @@ -0,0 +1,79 @@ +"""Tests for PIN verification on arm/disarm endpoints.""" + +import pytest +from vigilar.alerts.pin import hash_pin +from vigilar.config import VigilarConfig, SecurityConfig +from vigilar.web.app import create_app + + +@pytest.fixture +def app_with_pin(): + pin_hash = hash_pin("1234") + cfg = VigilarConfig( + security=SecurityConfig( + pin_hash=pin_hash, + recovery_passphrase_hash=hash_pin("recover123"), + ) + ) + app = create_app(cfg) + app.config["TESTING"] = True + return app + + +@pytest.fixture +def app_no_pin(): + cfg = VigilarConfig() + app = create_app(cfg) + app.config["TESTING"] = True + return app + + +def test_arm_without_pin_set(app_no_pin): + with app_no_pin.test_client() as c: + rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY"}) + assert rv.status_code == 200 + assert rv.get_json()["ok"] is True + + +def test_arm_correct_pin(app_with_pin): + with app_with_pin.test_client() as c: + rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "1234"}) + assert rv.status_code == 200 + assert rv.get_json()["ok"] is True + + +def test_arm_wrong_pin(app_with_pin): + with app_with_pin.test_client() as c: + rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "0000"}) + assert rv.status_code == 401 + + +def test_disarm_correct_pin(app_with_pin): + with app_with_pin.test_client() as c: + rv = c.post("/system/api/disarm", json={"pin": "1234"}) + assert rv.status_code == 200 + + +def test_disarm_wrong_pin(app_with_pin): + with app_with_pin.test_client() as c: + rv = c.post("/system/api/disarm", json={"pin": "9999"}) + assert rv.status_code == 401 + + +def test_reset_pin_correct_passphrase(app_with_pin): + with app_with_pin.test_client() as c: + rv = c.post("/system/api/reset-pin", json={ + "recovery_passphrase": "recover123", + "new_pin": "5678", + }) + assert rv.status_code == 200 + assert rv.get_json()["ok"] is True + + +def test_reset_pin_wrong_passphrase(app_with_pin): + with app_with_pin.test_client() as c: + rv = c.post("/system/api/reset-pin", json={ + "recovery_passphrase": "wrong", + "new_pin": "5678", + }) + assert rv.status_code == 401 diff --git a/vigilar/web/blueprints/system.py b/vigilar/web/blueprints/system.py index 296a0fa..6cc1e3e 100644 --- a/vigilar/web/blueprints/system.py +++ b/vigilar/web/blueprints/system.py @@ -4,6 +4,7 @@ import os from flask import Blueprint, current_app, jsonify, render_template, request +from vigilar.alerts.pin import hash_pin, verify_pin from vigilar.config import VigilarConfig from vigilar.config_writer import ( save_config, @@ -58,7 +59,10 @@ def arm_system(): data = request.get_json() or {} mode = data.get("mode", "ARMED_AWAY") pin = data.get("pin", "") - # TODO: verify PIN against config hash + cfg = _get_cfg() + pin_hash = cfg.security.pin_hash + if pin_hash and not verify_pin(pin, pin_hash): + return jsonify({"error": "Invalid PIN"}), 401 return jsonify({"ok": True, "state": mode}) @@ -66,10 +70,30 @@ def arm_system(): def disarm_system(): data = request.get_json() or {} pin = data.get("pin", "") - # TODO: verify PIN + cfg = _get_cfg() + pin_hash = cfg.security.pin_hash + if pin_hash and not verify_pin(pin, pin_hash): + return jsonify({"error": "Invalid PIN"}), 401 return jsonify({"ok": True, "state": "DISARMED"}) +@system_bp.route("/api/reset-pin", methods=["POST"]) +def reset_pin(): + data = request.get_json() or {} + recovery_passphrase = data.get("recovery_passphrase", "") + new_pin = data.get("new_pin", "") + cfg = _get_cfg() + if not verify_pin(recovery_passphrase, cfg.security.recovery_passphrase_hash): + return jsonify({"error": "Invalid recovery passphrase"}), 401 + new_security = cfg.security.model_copy(update={"pin_hash": hash_pin(new_pin)}) + new_cfg = cfg.model_copy(update={"security": new_security}) + try: + _save_and_reload(new_cfg) + except Exception: + current_app.config["VIGILAR_CONFIG"] = new_cfg + return jsonify({"ok": True}) + + # --- Config Read API --- @system_bp.route("/api/config")