new fixes
This commit is contained in:
parent
075a35b441
commit
ae14782da4
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <ContentKey.key_ciphertext_b64>
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue