feat(523): backup destinations - a folder or an S3 bucket, with the S3 secret sealed and kept out of backups
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 36s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 36s
services/backup_s3.py is FabledSteward's SigV4 client ported (put + list, never delete, so an append-only key is enough), with its tests against AWS's published signatures. services/backup_destination.py holds the admin config (backup_config JSON) and delivers a dated tar to a mounted folder or a bucket, and checks either without sending a backup. services/secrets.py seals the S3 secret with Fernet under a key derived from SECRET_KEY; backup._SETTINGS_NOT_EXPORTED keeps it out of every backup. cryptography becomes a direct dependency (already in the image through pyjwt). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
"""Sealing a credential Scribe stores so the database alone cannot read it.
|
||||
|
||||
Fernet (AES-128-CBC with an HMAC) under a key derived from the instance's
|
||||
SECRET_KEY. A copy of the database, or of a backup made from it, then holds
|
||||
only ciphertext: reading the credential also needs the SECRET_KEY, which lives
|
||||
in the deployment's environment rather than in the data.
|
||||
|
||||
The derivation is labelled and versioned, so this key is never the same bytes
|
||||
as anything else made from SECRET_KEY (the session-cookie signing uses it
|
||||
raw). An instance that changes its SECRET_KEY can no longer open what it
|
||||
sealed before: `unseal` answers None, and the caller asks for the credential
|
||||
again rather than failing on it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from scribe.config import Config
|
||||
|
||||
_LABEL = b"scribe.secrets.v1\x00"
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
digest = hashlib.sha256(_LABEL + Config.SECRET_KEY.encode()).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(digest))
|
||||
|
||||
|
||||
def seal(plain: str) -> str:
|
||||
"""Encrypt `plain` for storage; the result is ASCII."""
|
||||
return _fernet().encrypt(plain.encode()).decode()
|
||||
|
||||
|
||||
def unseal(token: str) -> str | None:
|
||||
"""Decrypt a value `seal` produced, or None when it can't be opened —
|
||||
sealed under a different SECRET_KEY, or not a sealed value at all."""
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
return _fernet().decrypt(token.encode()).decode()
|
||||
except (InvalidToken, ValueError):
|
||||
return None
|
||||
Reference in New Issue
Block a user