Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -41,6 +41,7 @@ jobs:
run: pytest testing/backend -q -m "not benchmark"

benchmark:
if: github.event_name == 'push'
runs-on: ubuntu-latest
needs: [backend-lint]
steps:
Expand Down Expand Up @@ -91,8 +92,10 @@ 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

formatting-hygiene:
Expand Down
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ python-multipart>=0.0.9
xhtml2pdf>=0.2.17
aiosqlite>=0.20.0
python-whois>=0.9.4
cryptography>=40.0.0
13 changes: 13 additions & 0 deletions backend/secuscan/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,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

# Rate Limiting
max_concurrent_tasks: int = 3
Expand Down Expand Up @@ -130,6 +131,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 @@ -158,6 +158,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 @@ -283,6 +284,19 @@ async def _create_schema(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
84 changes: 76 additions & 8 deletions backend/secuscan/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1025,32 +1025,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 All @@ -1060,6 +1068,66 @@ async def delete_vault_secret(name: str):
return {"name": name, "deleted": True}


@router.post("/vault/rotate")
async def rotate_vault_keys():
"""Rotate all credential_vault entries to the current configured key.

This endpoint does NOT accept keys in the request body. An operator must
configure the previous key via `SECUSCAN_VAULT_KEY_PREVIOUS` in the
environment before invoking this endpoint. The operation is transactional
and will roll back if any entry cannot be decrypted.
"""
# Require previous key to be configured; we intentionally avoid accepting
# the previous key in the request body to reduce accidental leakage.
if not settings.resolved_vault_key_previous:
raise HTTPException(
status_code=400,
detail="Previous vault key not configured (set SECUSCAN_VAULT_KEY_PREVIOUS)",
)

db = await get_db()


current_key = settings.resolved_vault_key
prev_key = settings.resolved_vault_key_previous
crypto = VaultCrypto(current_key, previous_keys=[prev_key])

conn = db.connection
try:
# Begin transaction
await conn.execute("BEGIN")
rows = await db.fetchall("SELECT id, encrypted_value, key_version FROM credential_vault")
updated = 0
for r in rows:
try:
# Attempt to decrypt using known keys
plaintext = crypto.decrypt(r["encrypted_value"])
except Exception:
# Abort and rollback on any undecryptable record
await conn.execute("ROLLBACK")
raise HTTPException(status_code=500, detail=f"Rotation aborted: unable to decrypt record {r['id']}")

new_blob = crypto.encrypt(plaintext)
await conn.execute(
"UPDATE credential_vault SET encrypted_value = ?, key_version = ?, updated_at = datetime('now') WHERE id = ?",
(new_blob, crypto.version, r["id"]),
)
updated += 1

await conn.commit()
except HTTPException:
raise
except Exception as e:
try:
await conn.execute("ROLLBACK")
except Exception:
pass
logger.exception("Vault rotation failed: %s", e)
raise HTTPException(status_code=500, detail="Vault rotation failed")

return {"rotated": updated}


@router.get("/workflows")
async def list_workflows():
db = await get_db()
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
Loading
Loading