8cd5392ec3
.env.example shipped a valid, PUBLIC base64 key — if a user didn't replace it, every credential got encrypted with a publicly-known key and everything still "worked," so the footgun was silent. - .env.example now ships an obvious placeholder (CHANGE_ME_generate_with_...) with a "you MUST replace this" note and the openssl hint. - web + worker validate LYRA_SECRET_KEY at startup: missing / placeholder / wrong-length ⇒ loud FATAL banner and refuse to start (web via the Next.js instrumentation hook, worker via _require_secret_key before touching the DB). The old public example key ⇒ a loud WARNING but not fatal, since an existing install may have encrypted its creds under it (rotating needs re-entry). - Validation lives in an edge-safe web/src/lib/secret-key.ts (no node:crypto) so the instrumentation hook bundles for both runtimes; crypto.ts re-exports. - README emphasizes generating a fresh key + keeping it stable. Verified live: worker exits 1 on placeholder/unset before any DB call; web instrumentation throws and refuses to serve on the placeholder. web 138 tests, worker 204/7-skip, tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
68 lines
2.4 KiB
Python
68 lines
2.4 KiB
Python
import base64
|
|
import json
|
|
import os
|
|
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
|
|
# The obvious placeholder shipped in .env.example — must be replaced with a real key.
|
|
PLACEHOLDER_SECRET_KEY = "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
|
# The valid-but-PUBLIC key git used to ship in .env.example. Anyone can decrypt creds
|
|
# encrypted with it, so warn — but don't hard-fail, since an existing install may have
|
|
# encrypted its credentials under it and hard-failing would brick it mid-flight.
|
|
_LEGACY_PUBLIC_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
|
|
|
|
|
|
def secret_key_problem() -> tuple[str, bool] | None:
|
|
"""Return (message, fatal) if LYRA_SECRET_KEY is unusable or insecure, else None.
|
|
fatal=True ⇒ the app should refuse to start; fatal=False ⇒ start but warn loudly."""
|
|
b64 = os.environ.get("LYRA_SECRET_KEY")
|
|
if not b64:
|
|
return ("LYRA_SECRET_KEY is not set", True)
|
|
if b64 == PLACEHOLDER_SECRET_KEY:
|
|
return ("LYRA_SECRET_KEY is still the .env.example placeholder", True)
|
|
try:
|
|
k = base64.b64decode(b64)
|
|
except Exception:
|
|
return ("LYRA_SECRET_KEY is not valid base64", True)
|
|
if len(k) != 32:
|
|
return ("LYRA_SECRET_KEY must decode to 32 bytes", True)
|
|
if b64 == _LEGACY_PUBLIC_KEY:
|
|
return (
|
|
"LYRA_SECRET_KEY is the old PUBLIC example key that shipped in git — anyone "
|
|
"can decrypt your stored credentials; generate a fresh key and re-enter them",
|
|
False,
|
|
)
|
|
return None
|
|
|
|
|
|
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")
|