Files
FabledGrimoire/src/scribe/services/secrets.py
T
bvandeusenandClaude Opus 5.5 ff4f2ee5c2
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
feat(523): backup destinations - a folder or an S3 bucket, with the S3 secret sealed and kept out of backups
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>
2026-10-15 17:31:08 -04:00

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