mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 12:16:20 +00:00
Encrypt connection credentials with an owner-bound AES-GCM envelope
Adds a v2 credential envelope next to the v1 tool-secret helpers: AES-256-GCM, a master key derived once per process from ENCRYPTION_SECRET_KEY, and a per-record key from HKDF over the owner's id, which is also the associated data, so a blob moved onto another user's row does not decrypt. The envelope names its key, so ENCRYPTION_SECRET_KEY_PREVIOUS keeps old rows readable during a rotation. Log redaction now also covers token_info, tokens and client_info, and the API warns at startup when the public default key is in use.
This commit is contained in:
1 parent
45c4d928c4
commit
667d4bbab0
6 files changed
+275
-3
No files matched your search
@@ -1,11 +1,17 @@
|
||||
import base64
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers import algorithms, Cipher, modes
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
from docsgpt.core.settings import settings
|
||||
@@ -86,3 +92,134 @@ def _pad_data(data: bytes) -> bytes:
|
||||
def _unpad_data(data: bytes) -> bytes:
|
||||
padding_len = data[-1]
|
||||
return data[:-padding_len]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Envelope v2: connection credentials
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# ``v2:<key_id>:<base64(salt | nonce | ciphertext+tag)>``
|
||||
#
|
||||
# AES-256-GCM, so a tampered blob fails to decrypt instead of returning
|
||||
# garbage. The master key is derived once per process from
|
||||
# ENCRYPTION_SECRET_KEY (PBKDF2, cached); each record gets its own key from
|
||||
# HKDF(master, salt, owner id), which keeps the v1 owner binding without
|
||||
# paying 100k PBKDF2 iterations on every token read in the worker. The owner
|
||||
# id is also the GCM associated data, so a blob copied onto another user's
|
||||
# row does not decrypt. ``key_id`` names the master key, so a blob written
|
||||
# under ENCRYPTION_SECRET_KEY_PREVIOUS is still readable during a rotation.
|
||||
|
||||
_V2_PREFIX = "v2"
|
||||
_V2_MASTER_SALT = b"docsgpt-credentials-v2"
|
||||
_V2_ITERATIONS = 200_000
|
||||
_V2_SALT_BYTES = 16
|
||||
_V2_NONCE_BYTES = 12
|
||||
DEFAULT_ENCRYPTION_KEY = "default-docsgpt-encryption-key"
|
||||
|
||||
|
||||
class CredentialDecryptionError(Exception):
|
||||
"""A stored credential could not be decrypted (wrong key, tampering, bad format)."""
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8)
|
||||
def _master_key(secret: str) -> bytes:
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=_V2_MASTER_SALT,
|
||||
iterations=_V2_ITERATIONS,
|
||||
backend=default_backend(),
|
||||
)
|
||||
return kdf.derive(secret.encode())
|
||||
|
||||
|
||||
def _key_id(master: bytes) -> str:
|
||||
return hmac.new(master, b"docsgpt-key-id", hashlib.sha256).hexdigest()[:8]
|
||||
|
||||
|
||||
def _record_key(master: bytes, owner_id: str, salt: bytes) -> bytes:
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
info=b"docsgpt-v2|" + owner_id.encode(),
|
||||
backend=default_backend(),
|
||||
).derive(master)
|
||||
|
||||
|
||||
def _candidate_keys() -> dict[str, bytes]:
|
||||
"""Master keys this process can decrypt with, by key id (current first)."""
|
||||
keys: dict[str, bytes] = {}
|
||||
for secret in (settings.ENCRYPTION_SECRET_KEY, settings.ENCRYPTION_SECRET_KEY_PREVIOUS):
|
||||
if secret:
|
||||
master = _master_key(secret)
|
||||
keys.setdefault(_key_id(master), master)
|
||||
return keys
|
||||
|
||||
|
||||
def current_key_id() -> str:
|
||||
"""Key id of ENCRYPTION_SECRET_KEY, as written into new v2 blobs."""
|
||||
return _key_id(_master_key(settings.ENCRYPTION_SECRET_KEY))
|
||||
|
||||
|
||||
def is_default_encryption_key() -> bool:
|
||||
"""Whether ENCRYPTION_SECRET_KEY is still the public default."""
|
||||
return settings.ENCRYPTION_SECRET_KEY == DEFAULT_ENCRYPTION_KEY
|
||||
|
||||
|
||||
def encrypt_json(data: dict, owner_id: str) -> str:
|
||||
"""Encrypt ``data`` for ``owner_id`` into a v2 envelope.
|
||||
|
||||
Args:
|
||||
data: JSON-serialisable credentials.
|
||||
owner_id: The user the credentials belong to; decryption needs it.
|
||||
|
||||
Returns:
|
||||
The ``v2:<key_id>:<payload>`` string.
|
||||
"""
|
||||
master = _master_key(settings.ENCRYPTION_SECRET_KEY)
|
||||
key_id = _key_id(master)
|
||||
salt = os.urandom(_V2_SALT_BYTES)
|
||||
nonce = os.urandom(_V2_NONCE_BYTES)
|
||||
key = _record_key(master, owner_id, salt)
|
||||
plaintext = json.dumps(data, separators=(",", ":")).encode()
|
||||
ciphertext = AESGCM(key).encrypt(nonce, plaintext, owner_id.encode())
|
||||
payload = base64.b64encode(salt + nonce + ciphertext).decode()
|
||||
return f"{_V2_PREFIX}:{key_id}:{payload}"
|
||||
|
||||
|
||||
def envelope_key_id(blob: str) -> Optional[str]:
|
||||
"""The key id a v2 blob was written with, or None for anything else."""
|
||||
parts = (blob or "").split(":", 2)
|
||||
if len(parts) != 3 or parts[0] != _V2_PREFIX:
|
||||
return None
|
||||
return parts[1]
|
||||
|
||||
|
||||
def decrypt_json(blob: str, owner_id: str) -> dict:
|
||||
"""Decrypt a v2 envelope written for ``owner_id``.
|
||||
|
||||
Raises:
|
||||
CredentialDecryptionError: The blob is malformed, was written with a
|
||||
key this process does not have, belongs to another owner, or was
|
||||
tampered with.
|
||||
"""
|
||||
key_id = envelope_key_id(blob)
|
||||
if key_id is None:
|
||||
raise CredentialDecryptionError("Not a v2 credential envelope")
|
||||
master = _candidate_keys().get(key_id)
|
||||
if master is None:
|
||||
raise CredentialDecryptionError("Credential was encrypted with an unknown key")
|
||||
try:
|
||||
raw = base64.b64decode(blob.split(":", 2)[2].encode(), validate=True)
|
||||
salt = raw[:_V2_SALT_BYTES]
|
||||
nonce = raw[_V2_SALT_BYTES:_V2_SALT_BYTES + _V2_NONCE_BYTES]
|
||||
ciphertext = raw[_V2_SALT_BYTES + _V2_NONCE_BYTES:]
|
||||
key = _record_key(master, owner_id, salt)
|
||||
plaintext = AESGCM(key).decrypt(nonce, ciphertext, owner_id.encode())
|
||||
data = json.loads(plaintext.decode())
|
||||
except Exception as exc:
|
||||
raise CredentialDecryptionError("Credential could not be decrypted") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise CredentialDecryptionError("Credential payload is not an object")
|
||||
return data
|
||||
Reference in new issue
Block a user