feat: add worker AES-256-GCM crypto matching the web envelope
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
|
||||||
|
def _key() -> bytes:
|
||||||
|
b64 = os.environ.get("LYRA_SECRET_KEY")
|
||||||
|
if not b64:
|
||||||
|
raise RuntimeError("LYRA_SECRET_KEY is not set")
|
||||||
|
k = base64.b64decode(b64)
|
||||||
|
if len(k) != 32:
|
||||||
|
raise RuntimeError("LYRA_SECRET_KEY must decode to 32 bytes")
|
||||||
|
return k
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_secret(plaintext: str) -> str:
|
||||||
|
iv = os.urandom(12)
|
||||||
|
full = AESGCM(_key()).encrypt(iv, plaintext.encode("utf-8"), None) # ct || tag(16)
|
||||||
|
ct, tag = full[:-16], full[-16:]
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"iv": base64.b64encode(iv).decode(),
|
||||||
|
"ct": base64.b64encode(ct).decode(),
|
||||||
|
"tag": base64.b64encode(tag).decode(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_secret(envelope: str) -> str:
|
||||||
|
e = json.loads(envelope)
|
||||||
|
iv = base64.b64decode(e["iv"])
|
||||||
|
ct = base64.b64decode(e["ct"])
|
||||||
|
tag = base64.b64decode(e["tag"])
|
||||||
|
pt = AESGCM(_key()).decrypt(iv, ct + tag, None) # AESGCM expects ct || tag
|
||||||
|
return pt.decode("utf-8")
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
psycopg[binary]>=3.2,<4
|
psycopg[binary]>=3.2,<4
|
||||||
pytest>=8.3,<9
|
pytest>=8.3,<9
|
||||||
|
cryptography>=42,<46
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from lyra_worker.crypto import decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
|
TEST_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" # 32 bytes, base64
|
||||||
|
# Produced by web/src/lib/crypto.ts encryptSecret("cross-lang-secret") with TEST_KEY (see Step 2).
|
||||||
|
NODE_FIXTURE = '{"iv":"j9RH6WtmcmkD716H","ct":"5nWNQDDhIHJJVgvEzrO+b8M=","tag":"MBtkQ+ygwAeXwLQLpTsvWw=="}'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _key(monkeypatch):
|
||||||
|
monkeypatch.setenv("LYRA_SECRET_KEY", TEST_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def test_round_trip():
|
||||||
|
env = encrypt_secret("hunter2")
|
||||||
|
assert "hunter2" not in env
|
||||||
|
assert decrypt_secret(env) == "hunter2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_decrypts_node_produced_envelope():
|
||||||
|
# Cross-language contract: the worker must decrypt what the web app encrypted.
|
||||||
|
assert decrypt_secret(NODE_FIXTURE) == "cross-lang-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_tampered_envelope():
|
||||||
|
import json
|
||||||
|
env = json.loads(encrypt_secret("secret"))
|
||||||
|
env["ct"] = "Z2FyYmFnZQ==" # "garbage"
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
decrypt_secret(json.dumps(env))
|
||||||
Reference in New Issue
Block a user