Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ae48084
feat(vault): implement key versioning and rotation for credential vault
vansh-09 May 26, 2026
4c8ea0b
test(backend): add backend path for vault rotation tests
vansh-09 May 26, 2026
9565c9a
Merge branch 'main' into feat/cred-lifecycle
vansh-09 May 26, 2026
26168d6
ci: retrigger GitHub Actions
vansh-09 May 26, 2026
391cfa5
feat(vault): add previous key setting and multi-key VaultCrypto for r…
vansh-09 May 26, 2026
2f30272
test(backend): close database loops in test fixtures
vansh-09 May 26, 2026
7792a4e
fix(db): prevent leaked global aiosqlite connections
vansh-09 May 26, 2026
411dcd3
ci: skip benchmarks on pull requests
vansh-09 May 27, 2026
6df9b17
ci: reduce frontend PR workload
vansh-09 May 27, 2026
2262489
ci: retrigger workflows
vansh-09 May 27, 2026
b777b89
Merge branch 'main' into feat/cred-lifecycle
vansh-09 May 27, 2026
3339ed8
Merge branch 'main' into feat/cred-lifecycle
vansh-09 May 30, 2026
67c408c
feat(database): add key_version column to credential_vault and backfi…
vansh-09 May 30, 2026
c580869
Merge branch 'main' into feat/cred-lifecycle
vansh-09 May 30, 2026
956b9c8
fix(routes): improve error message formatting for vault key configura…
vansh-09 May 31, 2026
228d59e
Merge branch 'main' into feat/cred-lifecycle
vansh-09 May 31, 2026
363bac7
Merge branch 'main' into feat/cred-lifecycle
vansh-09 Jun 4, 2026
b54bfb3
Merge branch 'utksh1:main' into feat/cred-lifecycle
vansh-09 Jun 5, 2026
c9135a7
Merge branch 'main' into feat/cred-lifecycle
vansh-09 Jun 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ jobs:
path: ${{ github.workspace }}/sbom.json

benchmark:
if: github.event_name == 'push'
runs-on: ubuntu-latest
needs: [backend-lint]
steps:
Expand Down Expand Up @@ -206,6 +207,8 @@ jobs:
- name: Run frontend quality gate
run: npm run quality
- name: Run unit tests
if: github.event_name == 'push'
run: npm run test
- name: Build frontend
if: github.event_name == 'push'
run: npm run build
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ python-multipart>=0.0.9
xhtml2pdf>=0.2.17
aiosqlite>=0.20.0
python-whois>=0.9.4
cryptography>=40.0.0
httpx>=0.28.1
13 changes: 13 additions & 0 deletions backend/secuscan/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class Settings(BaseSettings):
plugin_signature_key: Optional[str] = None
enforce_plugin_signatures: bool = False
vault_key: Optional[str] = None
vault_key_previous: Optional[str] = None
denied_capabilities: List[str] = []
admin_api_key: Optional[str] = None

Expand Down Expand Up @@ -171,6 +172,18 @@ def resolved_vault_key(self) -> bytes:
digest = hashlib.sha256(seed.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest)

@property
def resolved_vault_key_previous(self) -> Optional[bytes]:
"""Return deterministic 32-byte key for previous vault key if present.

Returns None when no previous key is configured.
"""
seed = self.vault_key_previous or self.plugin_signature_key
if not seed:
return None
digest = hashlib.sha256(seed.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest)

def ensure_directories(self) -> None:
"""Create necessary directories if they don't exist"""
for directory in [
Expand Down
14 changes: 14 additions & 0 deletions backend/secuscan/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ async def _create_schema(self):
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
encrypted_value TEXT NOT NULL,
key_version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now'))
);
Expand Down Expand Up @@ -522,6 +523,19 @@ async def _run_migrations(self):

await self._backfill_risk_scores()

# Ensure credential_vault has a key_version column for rotation/versioning
vault_columns = await self.fetchall("PRAGMA table_info(credential_vault)")
existing_vault_cols = {col["name"] for col in vault_columns}
if "key_version" not in existing_vault_cols:
try:
# Add the column with a sane default for future inserts
await self.execute("ALTER TABLE credential_vault ADD COLUMN key_version INTEGER DEFAULT 1")
# Backfill any existing rows to the default value to preserve non-null semantics
await self.execute("UPDATE credential_vault SET key_version = 1 WHERE key_version IS NULL")
print("Added 'key_version' to credential_vault and backfilled existing rows.")
except Exception as e:
print(f"Failed to add 'key_version' to credential_vault: {e}")

async def _backfill_risk_scores(self):
"""Compute risk scores for existing findings that have none."""
from datetime import datetime, timezone
Expand Down
24 changes: 16 additions & 8 deletions backend/secuscan/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1413,32 +1413,40 @@ async def upsert_vault_secret(name: str, payload: Dict[str, str]):
raise HTTPException(status_code=400, detail="Secret value is required")

db = await get_db()
crypto = VaultCrypto(settings.resolved_vault_key)
# Use the resolved current/previous keys to create a version-aware encryptor
prev = settings.resolved_vault_key_previous
crypto = VaultCrypto(settings.resolved_vault_key, previous_keys=[prev] if prev else None)
encrypted = crypto.encrypt(value)
secret_id = str(uuid.uuid4())

existing = await db.fetchone("SELECT id FROM credential_vault WHERE name = ?", (name,))
if existing:
await db.execute(
"UPDATE credential_vault SET encrypted_value = ?, updated_at = datetime('now') WHERE name = ?",
(encrypted, name),
"UPDATE credential_vault SET encrypted_value = ?, key_version = ?, updated_at = datetime('now') WHERE name = ?",
(encrypted, crypto.version, name),
)
else:
await db.execute(
"INSERT INTO credential_vault (id, name, encrypted_value) VALUES (?, ?, ?)",
(secret_id, name, encrypted),
"INSERT INTO credential_vault (id, name, encrypted_value, key_version) VALUES (?, ?, ?, ?)",
(secret_id, name, encrypted, crypto.version),
)
return {"name": name, "stored": True}


@router.get("/vault/{name}", dependencies=[Depends(vault_limiter)])
async def get_vault_secret(name: str):
db = await get_db()
row = await db.fetchone("SELECT encrypted_value FROM credential_vault WHERE name = ?", (name,))
row = await db.fetchone("SELECT encrypted_value, key_version FROM credential_vault WHERE name = ?", (name,))
if not row:
raise HTTPException(status_code=404, detail="Secret not found")
crypto = VaultCrypto(settings.resolved_vault_key)
return {"name": name, "value": crypto.decrypt(row["encrypted_value"])}
prev = settings.resolved_vault_key_previous
crypto = VaultCrypto(settings.resolved_vault_key, previous_keys=[prev] if prev else None)
try:
value = crypto.decrypt(row["encrypted_value"])
except Exception as e:
logger.exception("Failed to decrypt vault secret %s: %s", name, e)
raise HTTPException(status_code=500, detail="Failed to decrypt secret")
return {"name": name, "value": value}


@router.delete("/vault/{name}", dependencies=[Depends(vault_limiter)])
Expand Down
73 changes: 55 additions & 18 deletions backend/secuscan/vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,49 @@ class VaultCrypto:

_NONCE_LEN = 12

def __init__(self, key: bytes):
"""
def __init__(
self,
current_key: bytes | None,
previous_keys: list[bytes] | None = None,
current_version: int = 1,
):
"""Initialize vault crypto with current and optional previous keys.

Args:
key: 44-byte base64url-encoded representation of a 32-byte AES-256 key,
as produced by ``settings.resolved_vault_key``.
current_key: base64url-encoded 32-byte key (bytes) or None.
previous_keys: list of base64url-encoded 32-byte keys (bytes) to try
when decrypting older records.
current_version: integer version assigned to values encrypted by
`current_key`.
"""
try:
raw = base64.urlsafe_b64decode(key)
except Exception as exc:
raise ValueError("Vault key must be base64url-encoded") from exc
if len(raw) != 32:
raise ValueError(
f"Vault key must decode to exactly 32 bytes (AES-256); got {len(raw)}"
)
self._aesgcm = AESGCM(raw)
def _make_aesgcm(b: bytes):
try:
raw = base64.urlsafe_b64decode(b)
except Exception as exc:
raise ValueError("Vault key must be base64url-encoded") from exc
if len(raw) != 32:
raise ValueError(
f"Vault key must decode to exactly 32 bytes (AES-256); got {len(raw)}"
)
return AESGCM(raw)

self._current_version = int(current_version)
self._aesgcm = _make_aesgcm(current_key) if current_key is not None else None
self._previous_aes = []
if previous_keys:
for pk in previous_keys:
if pk is None:
continue
self._previous_aes.append(_make_aesgcm(pk))

@property
def version(self) -> int:
"""Returns the integer version associated with the current key."""
return self._current_version

def encrypt(self, plaintext: str) -> str:
if self._aesgcm is None:
raise ValueError("No current vault key configured for encryption")
nonce = os.urandom(self._NONCE_LEN)
ciphertext = self._aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
blob = nonce + ciphertext
Expand All @@ -52,9 +78,20 @@ def decrypt(self, payload: str) -> str:
nonce = blob[: self._NONCE_LEN]
ciphertext = blob[self._NONCE_LEN :]

try:
raw = self._aesgcm.decrypt(nonce, ciphertext, None)
except Exception as exc:
raise ValueError("Vault payload integrity verification failed") from exc
# Try current key first
if self._aesgcm is not None:
try:
raw = self._aesgcm.decrypt(nonce, ciphertext, None)
return raw.decode("utf-8")
except Exception:
pass

# Try previous keys in order
for aes in self._previous_aes:
try:
raw = aes.decrypt(nonce, ciphertext, None)
return raw.decode("utf-8")
except Exception:
continue

return raw.decode("utf-8")
raise ValueError("Vault payload integrity verification failed")
57 changes: 57 additions & 0 deletions docs/vault-rotation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Vault Rotation

This document describes how to rotate the credential vault encryption key safely.

## Overview

SecuScan stores encrypted secrets in the `credential_vault` table. Each entry has
a `key_version` column indicating which key version encrypted the value. The
server supports a transactional rotation workflow that ensures either all
entries are re-encrypted with the new key, or none are modified.

## Important security notes

- Do not supply secret keys in API request bodies. The rotation endpoint
intentionally requires the previous key to be present in the process
environment (via `SECUSCAN_VAULT_KEY_PREVIOUS`) to avoid accidental leakage.
- Configure the previous key in the environment before triggering rotation.
- Rotation is atomic: if any record cannot be decrypted, the operation
aborts and the database is rolled back.

## Operator workflow

1. Ensure the new vault seed is set via `SECUSCAN_VAULT_KEY` in the service
environment.
2. Temporarily set the previous seed in `SECUSCAN_VAULT_KEY_PREVIOUS` (the
same value previously used to encrypt existing secrets).
3. Call the rotation endpoint (once):

POST /api/v1/vault/rotate

4. If the rotation succeeds, remove `SECUSCAN_VAULT_KEY_PREVIOUS` from the
environment and keep the new key in `SECUSCAN_VAULT_KEY`.
5. Verify secrets are readable with `GET /api/v1/vault/{name}`.

## Failure modes

- If any vault record cannot be decrypted with the known keys, rotation will
abort and report which record failed. No records will be partially
re-encrypted.
- If the previous key is not provided via `SECUSCAN_VAULT_KEY_PREVIOUS`, the
rotation endpoint will refuse to run.

## Testing locally

- To simulate rotation locally, set two env vars in your shell and start the
server:

export SECUSCAN_VAULT_KEY_PREVIOUS="old-seed"
export SECUSCAN_VAULT_KEY="new-seed"

- Create a secret via the API, and then call the rotate endpoint.

## Notes

This implementation uses AES-GCM (via the `cryptography` package) and stores a
one-byte version prefix in the encrypted blob. The DB schema contains a
`key_version` integer column to track versions.
1 change: 1 addition & 0 deletions testing/backend/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def anyio_backend():
# Add repo root to sys.path so package imports work (backend.*)
repo_root = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(repo_root))
sys.path.insert(0, str(repo_root / "backend"))

from backend.secuscan.config import settings
from backend.secuscan import database as database_module
Expand Down
50 changes: 30 additions & 20 deletions testing/backend/integration/test_database_indexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import pytest

from backend.secuscan.config import settings
from backend.secuscan import database as db_module
from backend.secuscan.database import init_db


Expand Down Expand Up @@ -62,72 +63,81 @@ def get_index_names(db_path: str) -> set:
return names


def init_db_and_get_indexes(db_path: str) -> set:
"""Initialize a temporary DB, read its indexes, and close the connection.

The tests in this module only need the schema to exist. Using a dedicated
event loop here keeps the aiosqlite worker thread alive until disconnect()
completes, which avoids the "Event loop is closed" warnings from pytest.
"""
import asyncio

loop = asyncio.new_event_loop()
try:
loop.run_until_complete(init_db(db_path))
return get_index_names(db_path)
finally:
if db_module.db is not None:
loop.run_until_complete(db_module.db.disconnect())
loop.close()


# ── Index existence tests ─────────────────────────────────────────────────────

class TestDatabaseIndexes:

def test_findings_severity_index_exists(self, setup_test_environment):
"""idx_findings_severity must exist for GROUP BY severity queries."""
asyncio.run(init_db(settings.database_path))
indexes = get_index_names(settings.database_path)
indexes = init_db_and_get_indexes(settings.database_path)
assert "idx_findings_severity" in indexes, (
"Missing idx_findings_severity — dashboard GROUP BY severity will do a full scan"
)

def test_findings_discovered_at_index_exists(self, setup_test_environment):
"""idx_findings_discovered_at must exist for ORDER BY discovered_at DESC."""
asyncio.run(init_db(settings.database_path))
indexes = get_index_names(settings.database_path)
indexes = init_db_and_get_indexes(settings.database_path)
assert "idx_findings_discovered_at" in indexes, (
"Missing idx_findings_discovered_at — findings list ORDER BY will do a full scan"
)

def test_findings_task_id_index_exists(self, setup_test_environment):
"""idx_findings_task_id must exist for foreign key lookups."""
asyncio.run(init_db(settings.database_path))
indexes = get_index_names(settings.database_path)
indexes = init_db_and_get_indexes(settings.database_path)
assert "idx_findings_task_id" in indexes

def test_findings_task_severity_composite_index_exists(self, setup_test_environment):
"""idx_findings_task_severity composite index must exist."""
asyncio.run(init_db(settings.database_path))
indexes = get_index_names(settings.database_path)
indexes = init_db_and_get_indexes(settings.database_path)
assert "idx_findings_task_severity" in indexes

def test_reports_generated_at_index_exists(self, setup_test_environment):
"""idx_reports_generated_at must exist for reports list ORDER BY."""
asyncio.run(init_db(settings.database_path))
indexes = get_index_names(settings.database_path)
indexes = init_db_and_get_indexes(settings.database_path)
assert "idx_reports_generated_at" in indexes

def test_reports_task_id_index_exists(self, setup_test_environment):
"""idx_reports_task_id must exist for foreign key lookups."""
asyncio.run(init_db(settings.database_path))
indexes = get_index_names(settings.database_path)
indexes = init_db_and_get_indexes(settings.database_path)
assert "idx_reports_task_id" in indexes

def test_reports_status_index_exists(self, setup_test_environment):
"""idx_reports_status must exist for status filter queries."""
asyncio.run(init_db(settings.database_path))
indexes = get_index_names(settings.database_path)
indexes = init_db_and_get_indexes(settings.database_path)
assert "idx_reports_status" in indexes

def test_audit_log_timestamp_index_exists(self, setup_test_environment):
"""idx_audit_timestamp must exist for audit log ORDER BY timestamp."""
asyncio.run(init_db(settings.database_path))
indexes = get_index_names(settings.database_path)
indexes = init_db_and_get_indexes(settings.database_path)
assert "idx_audit_timestamp" in indexes

def test_audit_log_event_type_index_exists(self, setup_test_environment):
"""idx_audit_event_type must exist for event_type filter queries."""
asyncio.run(init_db(settings.database_path))
indexes = get_index_names(settings.database_path)
indexes = init_db_and_get_indexes(settings.database_path)
assert "idx_audit_event_type" in indexes

def test_tasks_status_created_composite_index_exists(self, setup_test_environment):
"""idx_tasks_status_created composite index must exist."""
asyncio.run(init_db(settings.database_path))
indexes = get_index_names(settings.database_path)
indexes = init_db_and_get_indexes(settings.database_path)
assert "idx_tasks_status_created" in indexes


Expand Down
Loading
Loading