feat(v2): add secure control plane
This commit is contained in:
@@ -13,7 +13,9 @@ dependencies = [
|
||||
"aiofiles==25.1.0",
|
||||
"aiosqlite==0.22.1",
|
||||
"alembic==1.18.5",
|
||||
"argon2-cffi==25.1.0",
|
||||
"apscheduler==3.11.3",
|
||||
"cryptography==49.0.0",
|
||||
"fastapi==0.136.1",
|
||||
"httpx==0.28.1",
|
||||
"paramiko==5.0.0",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP boundary for Backup Tool v2."""
|
||||
@@ -0,0 +1,479 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Any, cast
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, Request, Response
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from backup_tool.cli import build_alembic_config
|
||||
from backup_tool.config import Settings
|
||||
from backup_tool.db.engine import SchemaNotCurrentError, assert_schema_current, create_engine
|
||||
from backup_tool.db.models import ApiToken, AuditEvent, IdempotencyRecord, Secret, User
|
||||
from backup_tool.security.auth import (
|
||||
hash_password,
|
||||
hash_token,
|
||||
new_csrf_token,
|
||||
new_token,
|
||||
sign_session,
|
||||
verify_password,
|
||||
verify_session,
|
||||
)
|
||||
from backup_tool.security.redaction import redact
|
||||
from backup_tool.security.secrets import EnvelopeCipher
|
||||
|
||||
|
||||
class Problem(Exception):
|
||||
def __init__(self, status: int, code: str, detail: str):
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def problem_response(request: Request, status: int, code: str, detail: str) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status,
|
||||
media_type="application/problem+json",
|
||||
content={
|
||||
"type": f"https://backup-tool.invalid/problems/{code}",
|
||||
"title": code.replace("_", " ").title(),
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
"instance": str(request.url.path),
|
||||
"code": code,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class SetupInput(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=255)
|
||||
password: str = Field(min_length=12, max_length=1024)
|
||||
|
||||
|
||||
class LoginInput(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=255)
|
||||
password: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class TokenInput(BaseModel):
|
||||
scopes: list[str] = Field(min_length=1)
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class SecretInput(BaseModel):
|
||||
purpose: str = Field(min_length=1, max_length=64)
|
||||
value: str = Field(min_length=1, max_length=65536)
|
||||
|
||||
|
||||
class UserPatch(BaseModel):
|
||||
state: str
|
||||
|
||||
|
||||
def _etag(user: User) -> str:
|
||||
return f'"{user.id}:{user.updated_at.isoformat()}"'
|
||||
|
||||
|
||||
def _cursor(item_id: str) -> str:
|
||||
return base64.urlsafe_b64encode(item_id.encode()).decode().rstrip("=")
|
||||
|
||||
|
||||
def _decode_cursor(value: str) -> str:
|
||||
try:
|
||||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)).decode()
|
||||
except (ValueError, UnicodeDecodeError) as error:
|
||||
raise Problem(400, "invalid_cursor", "Cursor is invalid.") from error
|
||||
|
||||
|
||||
def _digest_request(payload: TokenInput) -> str:
|
||||
value = payload.model_dump(mode="json")
|
||||
encoded = repr(sorted(value.items())).encode()
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
class SessionDependency:
|
||||
def __init__(self, factory: async_sessionmaker[AsyncSession]):
|
||||
self._factory = factory
|
||||
|
||||
async def __call__(self) -> AsyncIterator[AsyncSession]:
|
||||
async with self._factory() as db:
|
||||
yield db
|
||||
|
||||
|
||||
def create_app(settings: Settings) -> FastAPI:
|
||||
app = FastAPI(title="Backup Tool API", version="2.0.0", docs_url=None, redoc_url=None)
|
||||
app.state.settings = settings
|
||||
app.state.engine = create_engine(settings)
|
||||
app.state.sessions = async_sessionmaker(app.state.engine, expire_on_commit=False)
|
||||
app.state.cipher = EnvelopeCipher.from_file(settings.master_key_file)
|
||||
app.state.setup_lock = asyncio.Lock()
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_id_middleware(request: Request, call_next: Any) -> Response:
|
||||
from backup_tool.ids import new_uuid7
|
||||
|
||||
request.state.request_id = str(new_uuid7())
|
||||
response = cast(Response, await call_next(request))
|
||||
response.headers["X-Request-ID"] = request.state.request_id
|
||||
return response
|
||||
|
||||
@app.exception_handler(Problem)
|
||||
async def handle_problem(request: Request, error: Problem) -> JSONResponse:
|
||||
return problem_response(request, error.status, error.code, error.detail)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def handle_validation(request: Request, _error: RequestValidationError) -> JSONResponse:
|
||||
return problem_response(request, 422, "validation_failed", "Request validation failed.")
|
||||
|
||||
session = SessionDependency(app.state.sessions)
|
||||
|
||||
async def actor(
|
||||
request: Request,
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> tuple[User, set[str], bool]:
|
||||
if authorization and authorization.startswith("Bearer "):
|
||||
supplied = authorization.removeprefix("Bearer ")
|
||||
token = await db.scalar(
|
||||
select(ApiToken).where(ApiToken.token_hash == hash_token(supplied))
|
||||
)
|
||||
if token is None or token.revoked_at is not None:
|
||||
raise Problem(401, "authentication_required", "Authentication is required.")
|
||||
if token.expires_at is not None and token.expires_at <= datetime.now(UTC):
|
||||
raise Problem(401, "authentication_required", "Authentication is required.")
|
||||
user = await db.get(User, token.owner_id)
|
||||
if user is None or user.state != "active":
|
||||
raise Problem(401, "authentication_required", "Authentication is required.")
|
||||
return user, set(token.scopes), False
|
||||
encoded = request.cookies.get("backup_tool_session")
|
||||
data = verify_session(encoded, settings.master_key_file) if encoded else None
|
||||
if data is None:
|
||||
raise Problem(401, "authentication_required", "Authentication is required.")
|
||||
user = await db.get(User, data["sub"])
|
||||
if user is None or user.state != "active":
|
||||
raise Problem(401, "authentication_required", "Authentication is required.")
|
||||
return user, {"*"}, True
|
||||
|
||||
async def require(
|
||||
request: Request,
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
csrf: Annotated[str | None, Header(alias="X-CSRF-Token")] = None,
|
||||
) -> tuple[User, set[str], bool]:
|
||||
user, scopes, cookie_auth = await actor(request, db, authorization)
|
||||
if cookie_auth:
|
||||
data = verify_session(
|
||||
request.cookies.get("backup_tool_session", ""), settings.master_key_file
|
||||
)
|
||||
if data is None or csrf is None or csrf != data.get("csrf"):
|
||||
raise Problem(403, "csrf_failed", "CSRF validation failed.")
|
||||
return user, scopes, cookie_auth
|
||||
|
||||
async def audit(
|
||||
db: AsyncSession,
|
||||
request: Request,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
resource_id: str | None,
|
||||
outcome: str,
|
||||
actor_id: str | None,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_id=actor_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
outcome=outcome,
|
||||
request_id=request.state.request_id,
|
||||
details=redact(details or {}),
|
||||
)
|
||||
)
|
||||
|
||||
def set_session(response: Response, user_id: str) -> None:
|
||||
csrf = new_csrf_token()
|
||||
response.set_cookie(
|
||||
"backup_tool_session",
|
||||
sign_session(user_id, csrf, settings.master_key_file),
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="strict",
|
||||
path="/",
|
||||
)
|
||||
response.set_cookie(
|
||||
"backup_tool_csrf", csrf, httponly=False, secure=True, samesite="strict", path="/"
|
||||
)
|
||||
|
||||
@app.get("/livez")
|
||||
async def livez() -> dict[str, str]:
|
||||
return {"status": "alive"}
|
||||
|
||||
@app.get("/readyz")
|
||||
async def readyz(db: Annotated[AsyncSession, Depends(session)]) -> dict[str, str]:
|
||||
try:
|
||||
await assert_schema_current(app.state.engine, build_alembic_config(settings))
|
||||
except SchemaNotCurrentError as error:
|
||||
raise Problem(503, "schema_not_current", "Metadata schema is not current.") from error
|
||||
if await db.scalar(select(User.id).limit(1)) is None:
|
||||
raise Problem(503, "setup_required", "Initial administrator setup is required.")
|
||||
return {"status": "ready"}
|
||||
|
||||
@app.post("/api/v2/setup", status_code=201)
|
||||
async def setup(
|
||||
input_: SetupInput,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
) -> dict[str, str]:
|
||||
async with app.state.setup_lock:
|
||||
if await db.scalar(select(User.id).limit(1)) is not None:
|
||||
raise Problem(409, "setup_complete", "Initial administrator already exists.")
|
||||
user = User(username=input_.username, password_hash=hash_password(input_.password))
|
||||
db.add(user)
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError as error:
|
||||
await db.rollback()
|
||||
raise Problem(
|
||||
409, "setup_complete", "Initial administrator already exists."
|
||||
) from error
|
||||
await audit(db, request, "setup", "user", user.id, "success", user.id)
|
||||
await db.commit()
|
||||
set_session(response, user.id)
|
||||
return {"id": user.id, "username": user.username}
|
||||
|
||||
@app.post("/api/v2/auth/login")
|
||||
async def login(
|
||||
input_: LoginInput,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
) -> dict[str, str]:
|
||||
user = await db.scalar(select(User).where(User.username == input_.username))
|
||||
if (
|
||||
user is None
|
||||
or user.state != "active"
|
||||
or not verify_password(user.password_hash, input_.password)
|
||||
):
|
||||
await audit(db, request, "login", "user", None, "denied", None)
|
||||
await db.commit()
|
||||
raise Problem(401, "authentication_failed", "Invalid credentials.")
|
||||
await audit(db, request, "login", "user", user.id, "success", user.id)
|
||||
await db.commit()
|
||||
set_session(response, user.id)
|
||||
return {"id": user.id, "username": user.username}
|
||||
|
||||
@app.post("/api/v2/auth/logout", status_code=204)
|
||||
async def logout(
|
||||
response: Response, _: Annotated[tuple[User, set[str], bool], Depends(require)]
|
||||
) -> None:
|
||||
response.delete_cookie("backup_tool_session", path="/")
|
||||
response.delete_cookie("backup_tool_csrf", path="/")
|
||||
|
||||
@app.get("/api/v2/auth/session")
|
||||
async def get_session(
|
||||
identity: Annotated[tuple[User, set[str], bool], Depends(actor)],
|
||||
) -> dict[str, str]:
|
||||
user, _, _ = identity
|
||||
return {"id": user.id, "username": user.username, "state": user.state}
|
||||
|
||||
@app.post("/api/v2/auth/tokens")
|
||||
async def create_token(
|
||||
input_: TokenInput,
|
||||
request: Request,
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
identity: Annotated[tuple[User, set[str], bool], Depends(require)],
|
||||
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None,
|
||||
) -> JSONResponse:
|
||||
user, scopes, _ = identity
|
||||
if "*" not in scopes:
|
||||
raise Problem(403, "insufficient_scope", "Required scope is missing.")
|
||||
if not idempotency_key:
|
||||
raise Problem(400, "idempotency_key_required", "Idempotency-Key is required.")
|
||||
digest = _digest_request(input_)
|
||||
existing = await db.scalar(
|
||||
select(IdempotencyRecord).where(
|
||||
IdempotencyRecord.actor_id == user.id,
|
||||
IdempotencyRecord.key == idempotency_key,
|
||||
IdempotencyRecord.operation == "create_api_token",
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.request_digest != digest:
|
||||
raise Problem(
|
||||
409, "idempotency_mismatch", "Idempotency-Key was used for another request."
|
||||
)
|
||||
return JSONResponse(
|
||||
{"id": existing.response_resource_id, "token": None}, status_code=200
|
||||
)
|
||||
raw = new_token()
|
||||
token = ApiToken(
|
||||
owner_id=user.id,
|
||||
token_hash=hash_token(raw),
|
||||
scopes=input_.scopes,
|
||||
expires_at=input_.expires_at,
|
||||
)
|
||||
db.add(token)
|
||||
await db.flush()
|
||||
db.add(
|
||||
IdempotencyRecord(
|
||||
actor_id=user.id,
|
||||
key=idempotency_key,
|
||||
operation="create_api_token",
|
||||
request_digest=digest,
|
||||
response_resource_type="api_token",
|
||||
response_resource_id=token.id,
|
||||
)
|
||||
)
|
||||
await audit(db, request, "create", "api_token", token.id, "success", user.id)
|
||||
await db.commit()
|
||||
return JSONResponse({"id": token.id, "token": raw}, status_code=201)
|
||||
|
||||
@app.delete("/api/v2/auth/tokens/{token_id}", status_code=204)
|
||||
async def revoke_token(
|
||||
token_id: str,
|
||||
request: Request,
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
identity: Annotated[tuple[User, set[str], bool], Depends(require)],
|
||||
) -> None:
|
||||
user, scopes, _ = identity
|
||||
if "*" not in scopes:
|
||||
raise Problem(403, "insufficient_scope", "Required scope is missing.")
|
||||
token = await db.get(ApiToken, token_id)
|
||||
if token is None or token.owner_id != user.id:
|
||||
raise Problem(404, "resource_not_found", "API token was not found.")
|
||||
token.revoked_at = datetime.now(UTC)
|
||||
await audit(db, request, "revoke", "api_token", token.id, "success", user.id)
|
||||
await db.commit()
|
||||
|
||||
@app.post("/api/v2/admin/secrets", status_code=201)
|
||||
async def create_secret(
|
||||
input_: SecretInput,
|
||||
request: Request,
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
identity: Annotated[tuple[User, set[str], bool], Depends(require)],
|
||||
) -> dict[str, Any]:
|
||||
user, scopes, _ = identity
|
||||
if "*" not in scopes and "admin:write" not in scopes:
|
||||
raise Problem(403, "insufficient_scope", "Required scope is missing.")
|
||||
ciphertext, key_id = app.state.cipher.encrypt(
|
||||
input_.value, purpose=input_.purpose, version=1
|
||||
)
|
||||
secret = Secret(ciphertext=ciphertext, key_id=key_id, purpose=input_.purpose)
|
||||
db.add(secret)
|
||||
await db.flush()
|
||||
await audit(
|
||||
db,
|
||||
request,
|
||||
"create",
|
||||
"secret",
|
||||
secret.id,
|
||||
"success",
|
||||
user.id,
|
||||
{"purpose": input_.purpose},
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"id": secret.id,
|
||||
"purpose": secret.purpose,
|
||||
"key_id": secret.key_id,
|
||||
"version": secret.version,
|
||||
}
|
||||
|
||||
@app.get("/api/v2/admin/secrets")
|
||||
async def list_secrets(
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
identity: Annotated[tuple[User, set[str], bool], Depends(actor)],
|
||||
) -> list[dict[str, Any]]:
|
||||
_, scopes, _ = identity
|
||||
if "*" not in scopes and "admin:read" not in scopes:
|
||||
raise Problem(403, "insufficient_scope", "Required scope is missing.")
|
||||
result = await db.scalars(select(Secret).order_by(desc(Secret.created_at)))
|
||||
return [
|
||||
{"id": item.id, "purpose": item.purpose, "key_id": item.key_id, "version": item.version}
|
||||
for item in result
|
||||
]
|
||||
|
||||
@app.get("/api/v2/admin/users/{user_id}")
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
response: Response,
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
_: Annotated[tuple[User, set[str], bool], Depends(actor)],
|
||||
) -> dict[str, str]:
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise Problem(404, "resource_not_found", "User was not found.")
|
||||
response.headers["ETag"] = _etag(user)
|
||||
return {"id": user.id, "username": user.username, "state": user.state}
|
||||
|
||||
@app.patch("/api/v2/admin/users/{user_id}")
|
||||
async def patch_user(
|
||||
user_id: str,
|
||||
input_: UserPatch,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
identity: Annotated[tuple[User, set[str], bool], Depends(require)],
|
||||
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
|
||||
) -> dict[str, str]:
|
||||
actor_user, scopes, _ = identity
|
||||
if "*" not in scopes and "admin:write" not in scopes:
|
||||
raise Problem(403, "insufficient_scope", "Required scope is missing.")
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise Problem(404, "resource_not_found", "User was not found.")
|
||||
if if_match != _etag(user):
|
||||
raise Problem(412, "etag_mismatch", "Resource was modified by another request.")
|
||||
if input_.state not in {"active", "disabled"}:
|
||||
raise Problem(422, "validation_failed", "State is invalid.")
|
||||
user.state = input_.state
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await audit(db, request, "update", "user", user.id, "success", actor_user.id)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
response.headers["ETag"] = _etag(user)
|
||||
return {"id": user.id, "username": user.username, "state": user.state}
|
||||
|
||||
@app.get("/api/v2/audit")
|
||||
async def list_audit(
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
identity: Annotated[tuple[User, set[str], bool], Depends(actor)],
|
||||
limit: int = 50,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
_, scopes, _ = identity
|
||||
if "*" not in scopes and "audit:read" not in scopes:
|
||||
raise Problem(403, "insufficient_scope", "Required scope is missing.")
|
||||
if not 1 <= limit <= 100:
|
||||
raise Problem(422, "validation_failed", "Limit must be between 1 and 100.")
|
||||
statement = select(AuditEvent).order_by(desc(AuditEvent.id)).limit(limit + 1)
|
||||
if cursor:
|
||||
statement = statement.where(AuditEvent.id < _decode_cursor(cursor))
|
||||
items = list((await db.scalars(statement)).all())
|
||||
page, remainder = items[:limit], items[limit:]
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"action": item.action,
|
||||
"resource_type": item.resource_type,
|
||||
"resource_id": item.resource_id,
|
||||
"outcome": item.outcome,
|
||||
"request_id": item.request_id,
|
||||
"created_at": item.created_at.isoformat(),
|
||||
"details": redact(item.details),
|
||||
}
|
||||
for item in page
|
||||
],
|
||||
"next_cursor": _cursor(page[-1].id) if page and remainder else None,
|
||||
}
|
||||
|
||||
return app
|
||||
@@ -0,0 +1 @@
|
||||
"""Security primitives and authentication services."""
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
_PASSWORDS = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _PASSWORDS.hash(password)
|
||||
|
||||
|
||||
def verify_password(password_hash: str, password: str) -> bool:
|
||||
try:
|
||||
return _PASSWORDS.verify(password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def new_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _session_key(master_key_file: Path) -> bytes:
|
||||
return hashlib.sha256(b"backup-tool-session\0" + master_key_file.read_bytes()).digest()
|
||||
|
||||
|
||||
def sign_session(user_id: str, csrf: str, master_key_file: Path) -> str:
|
||||
payload = json.dumps(
|
||||
{"sub": user_id, "csrf": csrf, "iat": datetime.now(UTC).isoformat()},
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode()
|
||||
encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
|
||||
signature = hmac.new(
|
||||
_session_key(master_key_file), encoded.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
return f"{encoded}.{signature}"
|
||||
|
||||
|
||||
def verify_session(value: str, master_key_file: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
encoded, supplied = value.rsplit(".", 1)
|
||||
expected = hmac.new(
|
||||
_session_key(master_key_file), encoded.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(supplied, expected):
|
||||
return None
|
||||
padded = encoded + "=" * (-len(encoded) % 4)
|
||||
decoded = json.loads(base64.urlsafe_b64decode(padded))
|
||||
if not isinstance(decoded, dict) or not isinstance(decoded.get("sub"), str):
|
||||
return None
|
||||
return decoded
|
||||
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
SECRET_KEYS = frozenset(
|
||||
{
|
||||
"password",
|
||||
"password_hash",
|
||||
"secret",
|
||||
"token",
|
||||
"token_hash",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"ciphertext",
|
||||
"master_key",
|
||||
"private_key",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def redact(value: Any, *, canaries: Sequence[str] = ()) -> Any:
|
||||
"""Return a recursively redacted copy safe for operator-visible sinks."""
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
str(key): (
|
||||
"[REDACTED]" if str(key).lower() in SECRET_KEYS else redact(item, canaries=canaries)
|
||||
)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [redact(item, canaries=canaries) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(redact(item, canaries=canaries) for item in value)
|
||||
if isinstance(value, str):
|
||||
result = value
|
||||
for canary in canaries:
|
||||
if canary:
|
||||
result = result.replace(canary, "[REDACTED]")
|
||||
return result
|
||||
return value
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
|
||||
class EnvelopeCipher:
|
||||
"""Purpose-bound authenticated encryption for persisted secret values."""
|
||||
|
||||
def __init__(self, master_key: bytes):
|
||||
self._key = hashlib.sha256(master_key).digest()
|
||||
self.key_id = hashlib.sha256(b"backup-tool-key-id\0" + self._key).hexdigest()[:24]
|
||||
self._cipher = AESGCM(self._key)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path) -> EnvelopeCipher:
|
||||
return cls(path.read_bytes())
|
||||
|
||||
@staticmethod
|
||||
def _associated_data(purpose: str, version: int) -> bytes:
|
||||
return f"backup-tool-secret:{purpose}:v{version}".encode()
|
||||
|
||||
def encrypt(self, value: str, *, purpose: str, version: int) -> tuple[bytes, str]:
|
||||
nonce = os.urandom(12)
|
||||
encrypted = self._cipher.encrypt(
|
||||
nonce, value.encode(), self._associated_data(purpose, version)
|
||||
)
|
||||
return nonce + encrypted, self.key_id
|
||||
|
||||
def decrypt(self, ciphertext: bytes, *, purpose: str, version: int) -> str:
|
||||
nonce, encrypted = ciphertext[:12], ciphertext[12:]
|
||||
plaintext = self._cipher.decrypt(nonce, encrypted, self._associated_data(purpose, version))
|
||||
return plaintext.decode()
|
||||
Reference in New Issue
Block a user