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>
45 lines
1.5 KiB
Python
45 lines
1.5 KiB
Python
"""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
|