mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
Harden item 6 API route access controls while locked
This commit is contained in:
1 parent
dd6cbea7de
commit
73a175b93f
4 files changed
+96
-15
No files matched your search
@@ -25,6 +25,10 @@ Date: `2026-02-25`
|
||||
3. Added API regression tests for lock enforcement and unlock flow.
|
||||
- Entry/password endpoints blocked while locked.
|
||||
- Unlock endpoint clears locked state path.
|
||||
4. Tightened admin/config access controls while locked.
|
||||
- Config, relay, checksum, and selected profile-management routes now require unlocked state.
|
||||
5. Restricted sensitive config key exposure/update.
|
||||
- `password_hash` and `pin_hash` access/update blocked via generic config API.
|
||||
|
||||
### Validation
|
||||
|
||||
@@ -40,7 +44,9 @@ Date: `2026-02-25`
|
||||
### Remaining follow-up
|
||||
|
||||
1. Decide whether non-sensitive API routes (config/fingerprint/relays) should also require unlocked state.
|
||||
- Completed for key admin/config routes.
|
||||
2. Add API-level test coverage for timeout-triggered lock then unlock flow.
|
||||
- Completed with lock->blocked->unlock->access restoration regression test.
|
||||
3. Evaluate rate-limit tuning and brute-force controls for repeated password attempts on unlock endpoints.
|
||||
|
||||
## Evidence
|
||||
|
||||
+14
-15
@@ -1,18 +1,17 @@
|
||||
# Memory Update (2026-02-25)
|
||||
|
||||
## Security Checklist Progress
|
||||
- Item 6 (`Auth, lock/unlock, and access-control hardening`) moved from `Not Started` to `In Progress`.
|
||||
- Added evidence links in `docs/security_readiness_checklist.md` and created a dedicated review doc.
|
||||
- Item 6 (`Auth, lock/unlock, and access-control hardening`) remains `In Progress` with stronger API-level access control.
|
||||
|
||||
## Item 6 Work Completed This Pass
|
||||
1. Added locked-state access control for protected HTTP API routes in `src/seedpass/api.py`.
|
||||
- Protected routes now return `423` with `"Vault is locked"` when lock flags are active.
|
||||
2. Added explicit HTTP unlock route:
|
||||
- `POST /api/v1/vault/unlock` using `X-SeedPass-Password`.
|
||||
3. Added auth/access regression tests in `src/tests/test_api_new_endpoints.py`:
|
||||
- `test_vault_unlock_endpoint`
|
||||
- `test_entry_endpoints_blocked_when_vault_locked`
|
||||
- `test_generate_password_blocked_when_vault_locked`
|
||||
1. Added broader locked-state enforcement in `src/seedpass/api.py`.
|
||||
- Protected/admin API routes now return `423` when the vault is locked.
|
||||
2. Added sensitive config-key protection in API.
|
||||
- Generic config API now blocks `password_hash` and `pin_hash` access/updates.
|
||||
3. Added lock/unlock/access-control regressions in `src/tests/test_api_new_endpoints.py`:
|
||||
- `test_config_endpoint_blocked_when_vault_locked`
|
||||
- `test_get_config_denies_sensitive_keys`
|
||||
- `test_lock_unlock_cycle_restores_entry_access`
|
||||
|
||||
## Validation
|
||||
- Executed:
|
||||
@@ -21,9 +20,9 @@
|
||||
- `src/tests/test_vault_lock_flag.py`
|
||||
- `src/tests/test_inactivity_lock.py`
|
||||
- `src/tests/test_unlock_sync.py`
|
||||
- Result: `62 passed`.
|
||||
- Result: `65 passed`.
|
||||
|
||||
## Next Item 6 Steps
|
||||
1. Decide whether non-sensitive API routes (config/fingerprint/relays) should also require unlocked state.
|
||||
2. Add timeout-to-lock to unlock route E2E test path.
|
||||
3. Evaluate brute-force resistance/rate-limit behavior specific to unlock attempts.
|
||||
## Remaining Item 6 Focus
|
||||
1. Evaluate whether fingerprint selection/list should be limited while locked in API mode.
|
||||
2. Add explicit unlock-attempt abuse controls beyond general token rate limiting.
|
||||
3. Finalize item-6 completion criteria and move to `Done` once remaining policy decisions are resolved.
|
||||
@@ -32,6 +32,7 @@ _MAX_IMPORT_BYTES = int(os.getenv("SEEDPASS_MAX_IMPORT_BYTES", str(10 * 1024 * 1
|
||||
app = FastAPI()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_SENSITIVE_CONFIG_KEYS = {"password_hash", "pin_hash"}
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -359,6 +360,11 @@ def get_config(
|
||||
request: Request, key: str, authorization: str | None = Header(None)
|
||||
) -> Any:
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
if key in _SENSITIVE_CONFIG_KEYS:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Access to sensitive config is denied"
|
||||
)
|
||||
pm = _get_pm(request)
|
||||
value = pm.config_manager.load_config(require_pin=False).get(key)
|
||||
|
||||
@@ -376,6 +382,11 @@ def update_config(
|
||||
) -> dict[str, str]:
|
||||
"""Update a configuration setting."""
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
if key in _SENSITIVE_CONFIG_KEYS:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Updating sensitive config keys is not allowed"
|
||||
)
|
||||
pm = _get_pm(request)
|
||||
cfg = pm.config_manager
|
||||
mapping = {
|
||||
@@ -407,6 +418,7 @@ def set_secret_mode(
|
||||
) -> dict[str, str]:
|
||||
"""Enable/disable secret mode and set the clipboard delay."""
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
pm = _get_pm(request)
|
||||
enabled = data.get("enabled")
|
||||
|
||||
@@ -437,6 +449,7 @@ def add_fingerprint(
|
||||
) -> dict[str, str]:
|
||||
"""Create a new seed profile."""
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
pm = _get_pm(request)
|
||||
pm.add_new_fingerprint()
|
||||
return {"status": "ok"}
|
||||
@@ -448,6 +461,7 @@ def remove_fingerprint(
|
||||
) -> dict[str, str]:
|
||||
"""Remove a seed profile."""
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
pm = _get_pm(request)
|
||||
pm.fingerprint_manager.remove_fingerprint(fingerprint)
|
||||
return {"status": "deleted"}
|
||||
@@ -541,6 +555,7 @@ def get_notifications(
|
||||
@app.get("/api/v1/nostr/pubkey")
|
||||
def get_nostr_pubkey(request: Request, authorization: str | None = Header(None)) -> Any:
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
pm = _get_pm(request)
|
||||
return {"npub": pm.nostr_client.key_manager.get_npub()}
|
||||
|
||||
@@ -549,6 +564,7 @@ def get_nostr_pubkey(request: Request, authorization: str | None = Header(None))
|
||||
def list_relays(request: Request, authorization: str | None = Header(None)) -> dict:
|
||||
"""Return the configured Nostr relays."""
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
pm = _get_pm(request)
|
||||
cfg = pm.config_manager.load_config(require_pin=False)
|
||||
return {"relays": cfg.get("relays", [])}
|
||||
@@ -560,6 +576,7 @@ def add_relay(
|
||||
) -> dict[str, str]:
|
||||
"""Add a relay URL to the configuration."""
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
pm = _get_pm(request)
|
||||
url = data.get("url")
|
||||
|
||||
@@ -582,6 +599,7 @@ def remove_relay(
|
||||
) -> dict[str, str]:
|
||||
"""Remove a relay by its index (1-based)."""
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
pm = _get_pm(request)
|
||||
cfg = pm.config_manager.load_config(require_pin=False)
|
||||
relays = cfg.get("relays", [])
|
||||
@@ -602,6 +620,7 @@ def reset_relays(
|
||||
) -> dict[str, str]:
|
||||
"""Reset relay list to defaults."""
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
pm = _get_pm(request)
|
||||
from nostr.client import DEFAULT_RELAYS
|
||||
|
||||
@@ -617,6 +636,7 @@ def verify_checksum(
|
||||
) -> dict[str, str]:
|
||||
"""Verify the SeedPass script checksum."""
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
pm = _get_pm(request)
|
||||
pm.handle_verify_checksum()
|
||||
return {"status": "ok"}
|
||||
@@ -628,6 +648,7 @@ def update_checksum(
|
||||
) -> dict[str, str]:
|
||||
"""Regenerate the script checksum file."""
|
||||
_check_token(request, authorization)
|
||||
_require_unlocked(request)
|
||||
pm = _get_pm(request)
|
||||
pm.handle_update_script_checksum()
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -489,6 +489,61 @@ async def test_generate_password_blocked_when_vault_locked(client):
|
||||
assert res.json()["detail"] == "Vault is locked"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_config_endpoint_blocked_when_vault_locked(client):
|
||||
cl, token = client
|
||||
api.app.state.pm.locked = True
|
||||
api.app.state.pm.is_locked = True
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = await cl.get("/api/v1/config/k", headers=headers)
|
||||
assert res.status_code == 423
|
||||
assert res.json()["detail"] == "Vault is locked"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_config_denies_sensitive_keys(client):
|
||||
cl, token = client
|
||||
api.app.state.pm.locked = False
|
||||
api.app.state.pm.is_locked = False
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = await cl.get("/api/v1/config/password_hash", headers=headers)
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lock_unlock_cycle_restores_entry_access(client):
|
||||
cl, token = client
|
||||
api.app.state.pm.locked = False
|
||||
api.app.state.pm.is_locked = False
|
||||
api.app.state.pm.verify_password = lambda pw: pw == "pw"
|
||||
api.app.state.pm.lock_vault = lambda: (
|
||||
setattr(api.app.state.pm, "locked", True),
|
||||
setattr(api.app.state.pm, "is_locked", True),
|
||||
)
|
||||
api.app.state.pm.unlock_vault = lambda pw: (
|
||||
setattr(api.app.state.pm, "locked", False),
|
||||
setattr(api.app.state.pm, "is_locked", False),
|
||||
0.01,
|
||||
)[-1]
|
||||
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
locked_res = await cl.post("/api/v1/vault/lock", headers=auth)
|
||||
assert locked_res.status_code == 200
|
||||
|
||||
denied = await cl.get("/api/v1/entry", params={"query": "x"}, headers=auth)
|
||||
assert denied.status_code == 423
|
||||
|
||||
unlocked = await cl.post(
|
||||
"/api/v1/vault/unlock",
|
||||
headers={**auth, "X-SeedPass-Password": "pw"},
|
||||
)
|
||||
assert unlocked.status_code == 200
|
||||
|
||||
allowed = await cl.get("/api/v1/entry", params={"query": "x"}, headers=auth)
|
||||
assert allowed.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_secret_mode_endpoint(client):
|
||||
cl, token = client
|
||||
|
||||
Reference in new issue
Block a user