diff --git a/app/api/routes/keys.py b/app/api/routes/keys.py index d0b2d8b..5bf1170 100644 --- a/app/api/routes/keys.py +++ b/app/api/routes/keys.py @@ -2,6 +2,7 @@ from __future__ import annotations import base64 import json +import os from datetime import datetime from typing import Dict, Any @@ -15,6 +16,7 @@ from app.core.models.content_v3 import EncryptedContent, ContentKey, KeyGrant from app.core.network.nodesig import verify_request from app.core.network.guard import check_rate_limit from app.core.models.my_network import KnownNode +from app.core.crypto.keywrap import unwrap_dek, KeyWrapError def _b64(b: bytes) -> str: @@ -60,11 +62,15 @@ async def s_api_v1_keys_request(request): # Seal the DEK for recipient using libsodium sealed box try: + dek_plain = unwrap_dek(ck.key_ciphertext_b64) import nacl.public pk = nacl.public.PublicKey(base64.b64decode(recipient_box_pub_b64)) box = nacl.public.SealedBox(pk) - sealed = box.encrypt(base64.b64decode(ck.key_ciphertext_b64)) + sealed = box.encrypt(dek_plain) sealed_b64 = _b64(sealed) + except KeyWrapError as e: + make_log("keys", f"unwrap failed: {e}", level="error") + return response.json({"error": "KEY_UNWRAP_FAILED"}, status=500) except Exception as e: make_log("keys", f"seal failed: {e}", level="error") return response.json({"error": "SEAL_FAILED"}, status=500) diff --git a/app/api/routes/upload_tus.py b/app/api/routes/upload_tus.py index 43ef520..d7d4af6 100644 --- a/app/api/routes/upload_tus.py +++ b/app/api/routes/upload_tus.py @@ -10,8 +10,8 @@ from base58 import b58encode from sanic import response from app.core._secrets import hot_pubkey -from app.core.crypto.aes_gcm_siv_stream import encrypt_file_to_encf -from app.core.crypto.aesgcm_stream import CHUNK_BYTES +from app.core.crypto.aes_gcm_stream import encrypt_file_to_encf, CHUNK_BYTES +from app.core.crypto.keywrap import wrap_dek, KeyWrapError from app.core.ipfs_client import add_streamed_file from app.core.logger import make_log from app.core.models.content_v3 import EncryptedContent, ContentKey, IpfsSync, ContentIndexItem, UploadSession @@ -79,13 +79,26 @@ async def s_api_v1_upload_tus_hook(request): session.add(us) await session.commit() - # Read & encrypt by streaming (ENCF v1 / AES-SIV) + # Read & encrypt by streaming (ENCF v1 / AES-GCM) # Generate per-content random DEK and salt dek = os.urandom(32) salt = os.urandom(16) key_fpr = b58encode(hot_pubkey).decode() # fingerprint as our node id for now # Stream encrypt into IPFS add + try: + wrapped_dek = wrap_dek(dek) + except KeyWrapError as e: + make_log("tus-hook", f"Key wrap failed: {e}", level="error") + async with db_session() as session: + if upload_id: + us = await session.get(UploadSession, upload_id) + if us: + us.state = 'failed' + us.error = str(e) + await session.commit() + return response.json({"ok": False, "error": "KEY_WRAP_FAILED"}, status=500) + try: with open(file_path, 'rb') as f: result = await add_streamed_file( @@ -122,7 +135,7 @@ async def s_api_v1_upload_tus_hook(request): plain_size_bytes=os.path.getsize(file_path), preview_enabled=preview_enabled, preview_conf=({"duration_ms": dur_ms, "intervals": [[start_ms, start_ms + dur_ms]]} if preview_enabled else {}), - aead_scheme="AES_GCM_SIV", + aead_scheme="AES_GCM", chunk_bytes=CHUNK_BYTES, salt_b64=_b64(salt), ) @@ -131,7 +144,7 @@ async def s_api_v1_upload_tus_hook(request): ck = ContentKey( content_id=ec.id, - key_ciphertext_b64=_b64(dek), # NOTE: should be wrapped by local KEK; simplified for PoC + key_ciphertext_b64=wrapped_dek, key_fingerprint=key_fpr, issuer_node_id=key_fpr, allow_auto_grant=True, diff --git a/app/core/background/convert_v3_service.py b/app/core/background/convert_v3_service.py index ddcd8ad..09cd1fb 100644 --- a/app/core/background/convert_v3_service.py +++ b/app/core/background/convert_v3_service.py @@ -18,8 +18,8 @@ from app.core.models.content_v3 import ( ) from app.core.models.node_storage import StoredContent from app.core.ipfs_client import cat_stream -from app.core.crypto.aesgcm_stream import CHUNK_BYTES from app.core.crypto.encf_stream import decrypt_encf_auto +from app.core.crypto.keywrap import unwrap_dek, wrap_dek, KeyWrapError from app.core.network.key_client import request_key_from_peer from app.core.models.my_network import KnownNode @@ -226,8 +226,11 @@ async def _pick_pending(limit: int) -> List[Tuple[EncryptedContent, str]]: dek = await request_key_from_peer(base_url, ec.encrypted_cid) if not dek: continue - import base64 - dek_b64 = base64.b64encode(dek).decode() + try: + dek_b64 = wrap_dek(dek) + except KeyWrapError as exc: + make_log('convert_v3', f"wrap failed for peer DEK: {exc}", level='error') + continue session_ck = ContentKey( content_id=ec.id, key_ciphertext_b64=dek_b64, @@ -282,10 +285,14 @@ async def main_fn(memory): await worker_loop() -async def stage_plain_from_ipfs(ec: EncryptedContent, dek_b64: str) -> str | None: +async def stage_plain_from_ipfs(ec: EncryptedContent, dek_wrapped: str) -> str | None: """Download encrypted ENCF stream from IPFS and decrypt on the fly into a temp file.""" - import base64, tempfile - dek = base64.b64decode(dek_b64) + import tempfile + try: + dek = unwrap_dek(dek_wrapped) + except KeyWrapError as exc: + make_log('convert_v3', f"unwrap failed for {ec.encrypted_cid}: {exc}", level='error') + return None tmp = tempfile.NamedTemporaryFile(prefix=f"dec_{ec.encrypted_cid[:8]}_", delete=False) tmp_path = tmp.name tmp.close() diff --git a/app/core/crypto/aes_gcm_siv_stream.py b/app/core/crypto/aes_gcm_siv_stream.py index df2572c..3c0d6ba 100644 --- a/app/core/crypto/aes_gcm_siv_stream.py +++ b/app/core/crypto/aes_gcm_siv_stream.py @@ -5,7 +5,7 @@ import hashlib import struct from typing import BinaryIO, Iterator, AsyncIterator -from gcm_siv import GcmSiv # requires `gcm_siv` package +from cryptography.hazmat.primitives.ciphers.aead import AESGCMSIV MAGIC = b"ENCF" @@ -40,12 +40,14 @@ def encrypt_file_to_encf(src: BinaryIO, key: bytes, chunk_bytes: int, salt: byte """ yield build_header(chunk_bytes, salt) idx = 0 + cipher = AESGCMSIV(key) + while True: block = src.read(chunk_bytes) if not block: break nonce = _derive_nonce(salt, idx) - ct_and_tag = GcmSiv(key).encrypt(nonce, block, associated_data=None) + ct_and_tag = cipher.encrypt(nonce, block, associated_data=None) # Split tag tag = ct_and_tag[-16:] ct = ct_and_tag[:-16] @@ -85,6 +87,8 @@ async def decrypt_encf_to_file(byte_iter: AsyncIterator[bytes], key: bytes, out_ salt = bytes(buf[11:11 + salt_len]) del buf[:hdr_len] + cipher = AESGCMSIV(key) + async with aiofiles.open(out_path, 'wb') as out: idx = 0 TAG_LEN = 16 @@ -103,7 +107,6 @@ async def decrypt_encf_to_file(byte_iter: AsyncIterator[bytes], key: bytes, out_ tag = bytes(buf[p_len:p_len+TAG_LEN]) del buf[:p_len+TAG_LEN] nonce = _derive_nonce(salt, idx) - pt = GcmSiv(key).decrypt(nonce, ct + tag, associated_data=None) + pt = cipher.decrypt(nonce, ct + tag, associated_data=None) await out.write(pt) idx += 1 - diff --git a/app/core/crypto/aes_gcm_stream.py b/app/core/crypto/aes_gcm_stream.py new file mode 100644 index 0000000..8145503 --- /dev/null +++ b/app/core/crypto/aes_gcm_stream.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import hmac +import hashlib +import os +import struct +from typing import BinaryIO, Iterator, AsyncIterator + +import aiofiles +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + +MAGIC = b"ENCF" +VERSION = 1 +SCHEME_AES_GCM = 0x03 + +CHUNK_BYTES = int(os.getenv("CRYPTO_CHUNK_BYTES", "1048576")) + + +def _derive_nonce(salt: bytes, idx: int) -> bytes: + """Derive a deterministic 12-byte nonce from salt and chunk index.""" + if len(salt) < 12: + raise ValueError("salt must be at least 12 bytes") + idx_bytes = idx.to_bytes(8, "big") + return hmac.new(salt, idx_bytes, hashlib.sha256).digest()[:12] + + +def build_header(chunk_bytes: int, salt: bytes) -> bytes: + if not (0 < chunk_bytes <= (1 << 31)): + raise ValueError("chunk_bytes must be between 1 and 2^31") + if not (1 <= len(salt) <= 255): + raise ValueError("salt length must be 1..255 bytes") + # MAGIC(4) | ver(1) | scheme(1) | chunk_bytes(4,BE) | salt_len(1) | salt | reserved(5 zeros) + hdr = bytearray() + hdr += MAGIC + hdr.append(VERSION) + hdr.append(SCHEME_AES_GCM) + hdr += struct.pack(">I", int(chunk_bytes)) + hdr.append(len(salt)) + hdr += salt + hdr += b"\x00" * 5 + return bytes(hdr) + + +def encrypt_file_to_encf(src: BinaryIO, key: bytes, chunk_bytes: int, salt: bytes) -> Iterator[bytes]: + """Yield ENCF v1 frames encrypted with AES-GCM.""" + if len(key) not in (16, 24, 32): + raise ValueError("AES-GCM key must be 128, 192 or 256 bits long") + cipher = AESGCM(key) + yield build_header(chunk_bytes, salt) + idx = 0 + while True: + block = src.read(chunk_bytes) + if not block: + break + nonce = _derive_nonce(salt, idx) + ct = cipher.encrypt(nonce, block, associated_data=None) + tag = ct[-16:] + data = ct[:-16] + yield struct.pack(">I", len(block)) + yield data + yield tag + idx += 1 + + +async def decrypt_encf_to_file(byte_iter: AsyncIterator[bytes], key: bytes, out_path: str) -> None: + """Parse ENCF v1 (AES-GCM) stream and write plaintext to `out_path`.""" + if len(key) not in (16, 24, 32): + raise ValueError("AES-GCM key must be 128, 192 or 256 bits long") + cipher = AESGCM(key) + buf = bytearray() + + async def _fill(n: int) -> None: + nonlocal buf + while len(buf) < n: + try: + chunk = await byte_iter.__anext__() + except StopAsyncIteration: + break + if chunk: + buf.extend(chunk) + + # Parse header + await _fill(11) + if buf[:4] != MAGIC: + raise ValueError("bad magic") + version = buf[4] + scheme = buf[5] + if version != VERSION or scheme != SCHEME_AES_GCM: + raise ValueError("unsupported ENCF header") + chunk_bytes = struct.unpack(">I", bytes(buf[6:10]))[0] + salt_len = buf[10] + hdr_len = 4 + 1 + 1 + 4 + 1 + salt_len + 5 + await _fill(hdr_len) + salt = bytes(buf[11:11 + salt_len]) + del buf[:hdr_len] + + async with aiofiles.open(out_path, "wb") as out: + idx = 0 + TAG_LEN = 16 + while True: + await _fill(4) + if len(buf) == 0: + break + if len(buf) < 4: + raise ValueError("truncated frame length") + p_len = struct.unpack(">I", bytes(buf[:4]))[0] + del buf[:4] + await _fill(p_len + TAG_LEN) + if len(buf) < p_len + TAG_LEN: + raise ValueError("truncated cipher/tag") + ct = bytes(buf[:p_len]) + tag = bytes(buf[p_len:p_len + TAG_LEN]) + del buf[:p_len + TAG_LEN] + nonce = _derive_nonce(salt, idx) + pt = cipher.decrypt(nonce, ct + tag, associated_data=None) + await out.write(pt) + idx += 1 diff --git a/app/core/crypto/cli.py b/app/core/crypto/cli.py new file mode 100644 index 0000000..b4a6587 --- /dev/null +++ b/app/core/crypto/cli.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import os +import sys +from typing import Optional + +from .aes_gcm_stream import CHUNK_BYTES, encrypt_file_to_encf +from .encf_stream import decrypt_encf_auto +from .keywrap import unwrap_dek, KeyWrapError + + +def _normalize_base64(value: str) -> str: + padding = (-len(value)) % 4 + if padding: + return value + "=" * padding + return value + + +def _decode_key(value: str, fmt: str) -> bytes: + if fmt == "base64": + return base64.b64decode(_normalize_base64(value)) + if fmt == "hex": + cleaned = value[2:] if value.lower().startswith("0x") else value + return bytes.fromhex(cleaned) + if fmt == "raw": + return value.encode() + raise ValueError(f"unsupported key format: {fmt}") + + +def _decode_salt(value: str, fmt: str) -> bytes: + if fmt == "base64": + return base64.b64decode(_normalize_base64(value)) + if fmt == "hex": + cleaned = value[2:] if value.lower().startswith("0x") else value + return bytes.fromhex(cleaned) + raise ValueError(f"unsupported salt format: {fmt}") + + +async def _decrypt_file(input_path: str, key: bytes, output_path: str) -> None: + async def _aiter(): + with open(input_path, "rb") as src: + while True: + chunk = src.read(65536) + if not chunk: + break + yield chunk + + await decrypt_encf_auto(_aiter(), key, output_path) + + +def cmd_encrypt(args: argparse.Namespace) -> int: + key = _decode_key(args.key, args.key_format) + salt = _decode_salt(args.salt, args.salt_format) if args.salt else os.urandom(args.salt_bytes) + os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) + with open(args.input, "rb") as src, open(args.output, "wb") as dst: + for chunk in encrypt_file_to_encf(src, key, args.chunk_bytes, salt): + dst.write(chunk) + # Emit JSON metadata with salt for convenience + meta = { + "salt_b64": base64.b64encode(salt).decode(), + "chunk_bytes": args.chunk_bytes, + "aead_scheme": "AES_GCM", + } + print(json.dumps(meta), file=sys.stdout) + return 0 + + +def cmd_decrypt(args: argparse.Namespace) -> int: + if bool(args.key) == bool(args.wrapped_key): + raise SystemExit("Provide exactly one of --key or --wrapped-key") + if args.wrapped_key: + try: + key = unwrap_dek(args.wrapped_key) + except KeyWrapError as exc: + raise SystemExit(f"Failed to unwrap key: {exc}") from exc + else: + key = _decode_key(args.key, args.key_format) + os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) + asyncio.run(_decrypt_file(args.input, key, args.output)) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python -m app.core.crypto.cli", description="ENCF AES-GCM helper") + sub = parser.add_subparsers(dest="command", required=True) + + enc = sub.add_parser("encrypt", help="Encrypt file into ENCF v1 stream (AES-256-GCM)") + enc.add_argument("--input", required=True, help="Path to plaintext input file") + enc.add_argument("--output", required=True, help="Destination path for ENCF output") + enc.add_argument("--key", required=True, help="Encryption key") + enc.add_argument("--key-format", choices=["base64", "hex", "raw"], default="base64") + enc.add_argument("--salt", help="Salt in specified format; generates random if omitted") + enc.add_argument("--salt-format", choices=["base64", "hex"], default="base64") + enc.add_argument("--salt-bytes", type=int, default=16, help="Salt length when generated (default: 16)") + enc.add_argument("--chunk-bytes", type=int, default=CHUNK_BYTES, help="Plaintext chunk size (default from env)") + enc.set_defaults(func=cmd_encrypt) + + dec = sub.add_parser("decrypt", help="Decrypt ENCF stream to plaintext") + dec.add_argument("--input", required=True, help="Path to ENCF input file") + dec.add_argument("--output", required=True, help="Destination path for decrypted file") + dec.add_argument("--key", help="Plaintext key") + dec.add_argument("--wrapped-key", help="Wrapped key produced by the backend") + dec.add_argument("--key-format", choices=["base64", "hex", "raw"], default="base64") + dec.set_defaults(func=cmd_decrypt) + + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/app/core/crypto/encf_stream.py b/app/core/crypto/encf_stream.py index b53ce36..bc34be9 100644 --- a/app/core/crypto/encf_stream.py +++ b/app/core/crypto/encf_stream.py @@ -4,6 +4,7 @@ from typing import AsyncIterator from .aes_gcm_siv_stream import MAGIC as _MAGIC, VERSION as _VER, SCHEME_AES_GCM_SIV from .aes_gcm_siv_stream import decrypt_encf_to_file as _dec_gcmsiv +from .aes_gcm_stream import SCHEME_AES_GCM, decrypt_encf_to_file as _dec_gcm from .aes_siv_stream import decrypt_encf_to_file as _dec_siv @@ -38,6 +39,7 @@ async def decrypt_encf_auto(byte_iter: AsyncIterator[bytes], key: bytes, out_pat if scheme == SCHEME_AES_GCM_SIV: await _dec_gcmsiv(_prepend_iter(), key, out_path) + elif scheme == SCHEME_AES_GCM: + await _dec_gcm(_prepend_iter(), key, out_path) else: await _dec_siv(_prepend_iter(), key, out_path) - diff --git a/app/core/crypto/keywrap.py b/app/core/crypto/keywrap.py new file mode 100644 index 0000000..035ed3f --- /dev/null +++ b/app/core/crypto/keywrap.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import base64 +import os +import threading +from typing import Optional + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + +_VERSION = 1 +_PREFIX_LEN = 1 # version byte +_NONCE_LEN = 12 +_TAG_LEN = 16 +_valid_key_lengths = {16, 24, 32} +_kek_lock = threading.Lock() +_cached_kek: Optional[bytes] = None + + +class KeyWrapError(RuntimeError): + """Raised when KEK configuration or unwrap operations fail.""" + + +def _normalize_base64(value: str) -> str: + v = value.strip() + missing = (-len(v)) % 4 + if missing: + v += "=" * missing + return v + + +def _decode_key_material(value: str) -> bytes: + v = value.strip() + if v.startswith("0x") or v.startswith("0X"): + v = v[2:] + try: + raw = bytes.fromhex(v) + if len(raw) in _valid_key_lengths: + return raw + except ValueError: + pass + try: + raw = base64.b64decode(_normalize_base64(value), validate=False) + if len(raw) in _valid_key_lengths: + return raw + except Exception as exc: # noqa: BLE001 - we want to re-raise as KeyWrapError + raise KeyWrapError(f"invalid KEK encoding: {exc}") from exc + raise KeyWrapError("KEK must decode to 16/24/32 bytes") + + +def _load_kek() -> bytes: + global _cached_kek + if _cached_kek is not None: + return _cached_kek + with _kek_lock: + if _cached_kek is not None: + return _cached_kek + env = os.getenv("CONTENT_KEY_KEK_B64") or os.getenv("CONTENT_KEY_KEK_HEX") + if not env: + raise KeyWrapError("CONTENT_KEY_KEK_B64 or CONTENT_KEY_KEK_HEX must be set") + kek = _decode_key_material(env) + if len(kek) != 32: + # Force 256-bit KEK for uniform security properties + raise KeyWrapError("KEK must be 32 bytes (256-bit) for AES-256-GCM") + _cached_kek = kek + return _cached_kek + + +def wrap_dek(plaintext: bytes) -> str: + """Wrap a DEK (plaintext bytes) with AES-256-GCM; return base64 string.""" + if not isinstance(plaintext, (bytes, bytearray)): + raise TypeError("plaintext must be bytes") + kek = _load_kek() + nonce = os.urandom(_NONCE_LEN) + cipher = AESGCM(kek) + ct = cipher.encrypt(nonce, bytes(plaintext), associated_data=None) + blob = bytes([_VERSION]) + nonce + ct + return base64.b64encode(blob).decode() + + +def unwrap_dek(encoded: str) -> bytes: + """Unwrap DEK from base64 string. Supports legacy (raw base64 key) values.""" + if not encoded: + raise KeyWrapError("empty key payload") + try: + raw = base64.b64decode(_normalize_base64(encoded), validate=False) + except Exception as exc: # noqa: BLE001 + raise KeyWrapError(f"invalid base64 payload: {exc}") from exc + if not raw: + raise KeyWrapError("decoded payload is empty") + version = raw[0] + if version == _VERSION: + if len(raw) < _PREFIX_LEN + _NONCE_LEN + _TAG_LEN + 1: + raise KeyWrapError("wrapped payload too short") + nonce = raw[_PREFIX_LEN:_PREFIX_LEN + _NONCE_LEN] + ciphertext = raw[_PREFIX_LEN + _NONCE_LEN:] + kek = _load_kek() + cipher = AESGCM(kek) + try: + return cipher.decrypt(nonce, ciphertext, associated_data=None) + except Exception as exc: # noqa: BLE001 + raise KeyWrapError(f"unwrap failed: {exc}") from exc + # Legacy fallback: value is raw DEK (no version prefix) + if len(raw) in {16, 24, 32}: + return raw + raise KeyWrapError("unknown key payload format") diff --git a/app/core/models/content_v3.py b/app/core/models/content_v3.py index aceb09d..9d7146e 100644 --- a/app/core/models/content_v3.py +++ b/app/core/models/content_v3.py @@ -28,7 +28,7 @@ class EncryptedContent(AlchemyBase): preview_conf = Column(JSON, nullable=False, default=dict) # Crypto parameters (fixed per network) - aead_scheme = Column(String(32), nullable=False, default='AES_GCM_SIV') + aead_scheme = Column(String(32), nullable=False, default='AES_GCM') chunk_bytes = Column(Integer, nullable=False, default=1048576) salt_b64 = Column(String(64), nullable=True) # per-content salt used for nonce derivation diff --git a/docs/indexation.md b/docs/indexation.md index ec986a7..e527529 100644 --- a/docs/indexation.md +++ b/docs/indexation.md @@ -42,7 +42,7 @@ values:^[ This document describes the simplified, production‑ready stack for content discovery and sync: -- Upload via tus → stream encrypt (ENCF v1, AES‑GCM‑SIV, 1 MiB chunks) → `ipfs add --cid-version=1 --raw-leaves --chunker=size-1048576 --pin`. +- Upload via tus → stream encrypt (ENCF v1, AES‑256‑GCM, 1 MiB chunks) → `ipfs add --cid-version=1 --raw-leaves --chunker=size-1048576 --pin`. - Public index exposes only encrypted sources (CID) and safe metadata; no plaintext ids. - Nodes full‑sync by pinning encrypted CIDs; keys are auto‑granted to trusted peers for preview/full access. @@ -55,7 +55,7 @@ Header (all big endian): ``` MAGIC(4): 'ENCF' VER(1): 0x01 -SCHEME(1): 0x01 = AES_GCM_SIV (0x02 AES_SIV legacy) +SCHEME(1): 0x03 = AES_GCM (0x01 AES_GCM_SIV legacy, 0x02 AES_SIV legacy) CHUNK(4): plaintext chunk bytes (1048576) SALT_LEN(1) SALT(N) @@ -64,7 +64,21 @@ RESERVED(5): zeros Body: repeated frames `[p_len:4][cipher][tag(16)]` where `p_len <= CHUNK` for last frame. -AES‑GCM‑SIV per frame, deterministic `nonce = HMAC_SHA256(salt, u64(frame_idx))[:12]`, AAD unused. +AES‑GCM (scheme `0x03`) encrypts each frame with deterministic `nonce = HMAC_SHA256(salt, u64(frame_idx))[:12]`. Legacy scheme `0x01` keeps AES‑GCM‑SIV with the same nonce derivation. + +For new uploads (v2025-09), the pipeline defaults to AES‑256‑GCM. Legacy AES‑GCM‑SIV/AES‑SIV content is still readable — the decoder auto-detects the scheme byte. + +### Local encryption/decryption helpers + +``` +python -m app.core.crypto.cli encrypt --input demo.wav --output demo.encf \ + --key AAAAEyHSVws5O8JGrg3kUSVtk5dQSc5x5e7jh0S2WGE= --salt-bytes 16 + +python -m app.core.crypto.cli decrypt --input demo.encf --output demo.wav \ + --wrapped-key +``` + +Because we use standard AES‑GCM, you can also re-hydrate frames manually with tools like `openssl aes-256-gcm`. The header exposes `chunk_bytes` and salt; derive the per-frame nonce via `HMAC_SHA256(salt, idx)` where `idx` is the frame number (0-based) and feed the 12-byte prefix as IV. ## API @@ -94,4 +108,3 @@ Window ±120s, nonce cache ~10min; replay → 401. ## Keys policy `KEY_AUTO_GRANT_TRUSTED_ONLY=1` — only KnownNode.meta.role=='trusted' gets DEK automatically. Preview lease TTL via `KEY_GRANT_PREVIEW_TTL_SEC`. - diff --git a/env.example b/env.example index bd383ad..6cd6569 100644 --- a/env.example +++ b/env.example @@ -3,3 +3,4 @@ TELEGRAM_API_KEY=Paste your telegram api key from @BotFather here MYSQL_URI=mysql+pymysql://user:password@maria_db:3306 MYSQL_ROOT_PASSWORD=playground MYSQL_DATABASE=bot_database +CONTENT_KEY_KEK_B64=Paste base64-encoded 32-byte key for wrapping DEKs diff --git a/requirements.txt b/requirements.txt index 229ebf9..e8477fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,4 +17,4 @@ pydub==0.25.1 pillow==10.2.0 ffmpeg-python==0.2.0 python-magic==0.4.27 -gcm_siv==1.0.0 +cryptography==42.0.5