new fixes
This commit is contained in:
parent
075a35b441
commit
ae14782da4
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any
|
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.nodesig import verify_request
|
||||||
from app.core.network.guard import check_rate_limit
|
from app.core.network.guard import check_rate_limit
|
||||||
from app.core.models.my_network import KnownNode
|
from app.core.models.my_network import KnownNode
|
||||||
|
from app.core.crypto.keywrap import unwrap_dek, KeyWrapError
|
||||||
|
|
||||||
|
|
||||||
def _b64(b: bytes) -> str:
|
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
|
# Seal the DEK for recipient using libsodium sealed box
|
||||||
try:
|
try:
|
||||||
|
dek_plain = unwrap_dek(ck.key_ciphertext_b64)
|
||||||
import nacl.public
|
import nacl.public
|
||||||
pk = nacl.public.PublicKey(base64.b64decode(recipient_box_pub_b64))
|
pk = nacl.public.PublicKey(base64.b64decode(recipient_box_pub_b64))
|
||||||
box = nacl.public.SealedBox(pk)
|
box = nacl.public.SealedBox(pk)
|
||||||
sealed = box.encrypt(base64.b64decode(ck.key_ciphertext_b64))
|
sealed = box.encrypt(dek_plain)
|
||||||
sealed_b64 = _b64(sealed)
|
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:
|
except Exception as e:
|
||||||
make_log("keys", f"seal failed: {e}", level="error")
|
make_log("keys", f"seal failed: {e}", level="error")
|
||||||
return response.json({"error": "SEAL_FAILED"}, status=500)
|
return response.json({"error": "SEAL_FAILED"}, status=500)
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ from base58 import b58encode
|
||||||
from sanic import response
|
from sanic import response
|
||||||
|
|
||||||
from app.core._secrets import hot_pubkey
|
from app.core._secrets import hot_pubkey
|
||||||
from app.core.crypto.aes_gcm_siv_stream import encrypt_file_to_encf
|
from app.core.crypto.aes_gcm_stream import encrypt_file_to_encf, CHUNK_BYTES
|
||||||
from app.core.crypto.aesgcm_stream import CHUNK_BYTES
|
from app.core.crypto.keywrap import wrap_dek, KeyWrapError
|
||||||
from app.core.ipfs_client import add_streamed_file
|
from app.core.ipfs_client import add_streamed_file
|
||||||
from app.core.logger import make_log
|
from app.core.logger import make_log
|
||||||
from app.core.models.content_v3 import EncryptedContent, ContentKey, IpfsSync, ContentIndexItem, UploadSession
|
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)
|
session.add(us)
|
||||||
await session.commit()
|
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
|
# Generate per-content random DEK and salt
|
||||||
dek = os.urandom(32)
|
dek = os.urandom(32)
|
||||||
salt = os.urandom(16)
|
salt = os.urandom(16)
|
||||||
key_fpr = b58encode(hot_pubkey).decode() # fingerprint as our node id for now
|
key_fpr = b58encode(hot_pubkey).decode() # fingerprint as our node id for now
|
||||||
|
|
||||||
# Stream encrypt into IPFS add
|
# 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:
|
try:
|
||||||
with open(file_path, 'rb') as f:
|
with open(file_path, 'rb') as f:
|
||||||
result = await add_streamed_file(
|
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),
|
plain_size_bytes=os.path.getsize(file_path),
|
||||||
preview_enabled=preview_enabled,
|
preview_enabled=preview_enabled,
|
||||||
preview_conf=({"duration_ms": dur_ms, "intervals": [[start_ms, start_ms + dur_ms]]} if preview_enabled else {}),
|
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,
|
chunk_bytes=CHUNK_BYTES,
|
||||||
salt_b64=_b64(salt),
|
salt_b64=_b64(salt),
|
||||||
)
|
)
|
||||||
|
|
@ -131,7 +144,7 @@ async def s_api_v1_upload_tus_hook(request):
|
||||||
|
|
||||||
ck = ContentKey(
|
ck = ContentKey(
|
||||||
content_id=ec.id,
|
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,
|
key_fingerprint=key_fpr,
|
||||||
issuer_node_id=key_fpr,
|
issuer_node_id=key_fpr,
|
||||||
allow_auto_grant=True,
|
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.models.node_storage import StoredContent
|
||||||
from app.core.ipfs_client import cat_stream
|
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.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.network.key_client import request_key_from_peer
|
||||||
from app.core.models.my_network import KnownNode
|
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)
|
dek = await request_key_from_peer(base_url, ec.encrypted_cid)
|
||||||
if not dek:
|
if not dek:
|
||||||
continue
|
continue
|
||||||
import base64
|
try:
|
||||||
dek_b64 = base64.b64encode(dek).decode()
|
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(
|
session_ck = ContentKey(
|
||||||
content_id=ec.id,
|
content_id=ec.id,
|
||||||
key_ciphertext_b64=dek_b64,
|
key_ciphertext_b64=dek_b64,
|
||||||
|
|
@ -282,10 +285,14 @@ async def main_fn(memory):
|
||||||
await worker_loop()
|
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."""
|
"""Download encrypted ENCF stream from IPFS and decrypt on the fly into a temp file."""
|
||||||
import base64, tempfile
|
import tempfile
|
||||||
dek = base64.b64decode(dek_b64)
|
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 = tempfile.NamedTemporaryFile(prefix=f"dec_{ec.encrypted_cid[:8]}_", delete=False)
|
||||||
tmp_path = tmp.name
|
tmp_path = tmp.name
|
||||||
tmp.close()
|
tmp.close()
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import hashlib
|
||||||
import struct
|
import struct
|
||||||
from typing import BinaryIO, Iterator, AsyncIterator
|
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"
|
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)
|
yield build_header(chunk_bytes, salt)
|
||||||
idx = 0
|
idx = 0
|
||||||
|
cipher = AESGCMSIV(key)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
block = src.read(chunk_bytes)
|
block = src.read(chunk_bytes)
|
||||||
if not block:
|
if not block:
|
||||||
break
|
break
|
||||||
nonce = _derive_nonce(salt, idx)
|
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
|
# Split tag
|
||||||
tag = ct_and_tag[-16:]
|
tag = ct_and_tag[-16:]
|
||||||
ct = 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])
|
salt = bytes(buf[11:11 + salt_len])
|
||||||
del buf[:hdr_len]
|
del buf[:hdr_len]
|
||||||
|
|
||||||
|
cipher = AESGCMSIV(key)
|
||||||
|
|
||||||
async with aiofiles.open(out_path, 'wb') as out:
|
async with aiofiles.open(out_path, 'wb') as out:
|
||||||
idx = 0
|
idx = 0
|
||||||
TAG_LEN = 16
|
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])
|
tag = bytes(buf[p_len:p_len+TAG_LEN])
|
||||||
del buf[:p_len+TAG_LEN]
|
del buf[:p_len+TAG_LEN]
|
||||||
nonce = _derive_nonce(salt, idx)
|
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)
|
await out.write(pt)
|
||||||
idx += 1
|
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 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_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
|
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:
|
if scheme == SCHEME_AES_GCM_SIV:
|
||||||
await _dec_gcmsiv(_prepend_iter(), key, out_path)
|
await _dec_gcmsiv(_prepend_iter(), key, out_path)
|
||||||
|
elif scheme == SCHEME_AES_GCM:
|
||||||
|
await _dec_gcm(_prepend_iter(), key, out_path)
|
||||||
else:
|
else:
|
||||||
await _dec_siv(_prepend_iter(), key, out_path)
|
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)
|
preview_conf = Column(JSON, nullable=False, default=dict)
|
||||||
|
|
||||||
# Crypto parameters (fixed per network)
|
# 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)
|
chunk_bytes = Column(Integer, nullable=False, default=1048576)
|
||||||
salt_b64 = Column(String(64), nullable=True) # per-content salt used for nonce derivation
|
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:
|
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.
|
- 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.
|
- 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'
|
MAGIC(4): 'ENCF'
|
||||||
VER(1): 0x01
|
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)
|
CHUNK(4): plaintext chunk bytes (1048576)
|
||||||
SALT_LEN(1)
|
SALT_LEN(1)
|
||||||
SALT(N)
|
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.
|
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
|
## API
|
||||||
|
|
||||||
|
|
@ -94,4 +108,3 @@ Window ±120s, nonce cache ~10min; replay → 401.
|
||||||
## Keys policy
|
## 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`.
|
`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_URI=mysql+pymysql://user:password@maria_db:3306
|
||||||
MYSQL_ROOT_PASSWORD=playground
|
MYSQL_ROOT_PASSWORD=playground
|
||||||
MYSQL_DATABASE=bot_database
|
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
|
pillow==10.2.0
|
||||||
ffmpeg-python==0.2.0
|
ffmpeg-python==0.2.0
|
||||||
python-magic==0.4.27
|
python-magic==0.4.27
|
||||||
gcm_siv==1.0.0
|
cryptography==42.0.5
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue