66d1abb101
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
137 lines
6.5 KiB
Python
137 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""check-data-key.py — every data-encrypting key carries `data_key: true`, and every flag is accounted for (R-127 a).
|
|
|
|
WHAT WENT WRONG. `data_key: true` on a deploy field tells the controller the app ENCRYPTS STORED DATA with it: the
|
|
restore RECOVERS the value and refuses (fail-closed) when it cannot, and the box never generates a new one
|
|
(felhom-controller internal/backup/restore_unit.go missingDataKeys; internal/stacks/deploy.go GenerateSecretForField).
|
|
In 2026-08 only five fields carried it, while n8n's N8N_ENCRYPTION_KEY, calcom's CALENDSO_ENCRYPTION_KEY, wanderer's
|
|
POCKETBASE_ENCRYPTION_KEY and bookstack's APP_KEY (two-factor secrets) did not — so a restore missing one of them
|
|
would have proceeded onto data it cannot decrypt instead of refusing.
|
|
|
|
WHY NOT THE LABEL. The Hungarian label „Titkosítási kulcs" ("Encryption key") sits on 24 secrets, most of which only
|
|
SIGN sessions (Django SECRET_KEY, Phoenix SECRET_KEY_BASE, JWT secrets): regenerating those signs everyone out, it
|
|
loses no data. A label is copy, frozen byte for byte, and it is not the fact. The facts this gate reads:
|
|
|
|
1. NAME RULE — a field whose env var names an encryption key or a pepper (`ENCRYPTION_KEY`, `PEPPER`) is a data key
|
|
by what the app calls it. It must carry `data_key: true`.
|
|
2. REGISTRY — data keys whose name does not say so (bookstack APP_KEY encrypts two-factor secrets; …) are listed
|
|
below BY APP AND FIELD with the reason. Each must carry the flag (unflagging one is a regression), and an entry
|
|
whose field no longer exists is STALE (refused — a registry nobody prunes stops meaning anything).
|
|
3. AGREEMENT — a `data_key: true` that neither rule names is refused: add it to REGISTRY with its reason. Over-
|
|
flagging is not harmless either: the restore then REFUSES for a key that could have been regenerated.
|
|
|
|
stdlib only (the CI runner has no PyYAML). The flag is read only from a field's own block inside the top-level
|
|
`deploy_fields:` — never from a comment, never from the `i18n:` block, never from `steps/` files.
|
|
|
|
USAGE
|
|
python3 scripts/check-data-key.py [app …] [--root=<dir>] (`--all` accepted, a no-op: every directory is judged)
|
|
Exit: 0 agree · 1 convicted · 2 inconclusive.
|
|
Decoys: scripts/test_gate_decoys.py `data_key_cases` (COVERS "data-key").
|
|
"""
|
|
import io
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
NAME_RULE = re.compile(r"ENCRYPTION_KEY|PEPPER")
|
|
|
|
# (app, env_var) -> why it is a data key although its name does not say so.
|
|
REGISTRY = {
|
|
("adventurelog", "SECRET_KEY"): "the template's own comment: encrypts stored data; restore must recover it",
|
|
("bookstack", "APP_KEY"): "Laravel encrypt() on every member's two-factor secret (app/Access/Mfa/MfaValue.php)",
|
|
("dawarich", "SECRET_KEY_BASE"): "the template's own comment: stored data unreadable if it changes",
|
|
("papra", "AUTH_SECRET"): "the template's own comment: stored tokens invalid if it changes; keep the old key",
|
|
("sparkyfitness", "BETTER_AUTH_SECRET"): "signs sessions AND encrypts 2FA/TOTP secrets (template comment)",
|
|
}
|
|
|
|
FIELD_RE = re.compile(r"^ - env_var:\s*['\"]?([A-Za-z0-9_]+)['\"]?\s*(?:#.*)?$")
|
|
DATA_KEY_RE = re.compile(r"^ data_key:\s*(\S+?)\s*(?:#.*)?$")
|
|
TOP_KEY_RE = re.compile(r"^[A-Za-z0-9_]+:")
|
|
|
|
|
|
def fields(meta_text):
|
|
"""[(env_var, data_key_raw_or_None)] from the top-level deploy_fields: block only."""
|
|
out, cur, inside = [], None, False
|
|
for line in meta_text.split("\n"):
|
|
if TOP_KEY_RE.match(line):
|
|
inside = line.startswith("deploy_fields:")
|
|
cur = None
|
|
continue
|
|
if not inside:
|
|
continue
|
|
m = FIELD_RE.match(line)
|
|
if m:
|
|
cur = [m.group(1), None]
|
|
out.append(cur)
|
|
continue
|
|
m = DATA_KEY_RE.match(line)
|
|
if m and cur is not None:
|
|
cur[1] = m.group(1).strip("'\"")
|
|
return [(a, b) for a, b in out]
|
|
|
|
|
|
def main(argv):
|
|
root, apps = ROOT, []
|
|
for a in argv:
|
|
if a.startswith("--root="):
|
|
root = a.split("=", 1)[1]
|
|
elif a == "--all":
|
|
continue
|
|
elif a.startswith("-"):
|
|
print("unknown option: %s" % a)
|
|
return 2
|
|
else:
|
|
apps.append(a)
|
|
tdir = os.path.join(root, "templates")
|
|
if not os.path.isdir(tdir):
|
|
print("data-key: no templates/ under %s" % root)
|
|
return 2
|
|
every = sorted(n for n in os.listdir(tdir) if os.path.isfile(os.path.join(tdir, n, ".felhom.yml")))
|
|
judged = apps or every
|
|
unknown = [a for a in judged if a not in every]
|
|
if unknown:
|
|
print("data-key: no such template: %s" % ", ".join(unknown))
|
|
return 2
|
|
bad, undecided, flagged = [], [], 0
|
|
seen = set()
|
|
for app in judged:
|
|
text = io.open(os.path.join(tdir, app, ".felhom.yml"), encoding="utf-8").read()
|
|
for env, raw in fields(text):
|
|
seen.add((app, env))
|
|
if raw is not None and raw.lower() not in ("true", "false"):
|
|
undecided.append("%s/%s: data_key %r is not true/false" % (app, env, raw))
|
|
continue
|
|
on = raw is not None and raw.lower() == "true"
|
|
flagged += on
|
|
named = bool(NAME_RULE.search(env))
|
|
reg = (app, env) in REGISTRY
|
|
if (named or reg) and not on:
|
|
why = "its name says it encrypts" if named else "registered: " + REGISTRY[(app, env)]
|
|
bad.append("%s/%s is a data key (%s) but carries no `data_key: true` — a restore missing it would "
|
|
"proceed onto data it cannot decrypt" % (app, env, why))
|
|
elif on and not (named or reg):
|
|
bad.append("%s/%s carries `data_key: true` but neither its name nor REGISTRY says why — add it to "
|
|
"REGISTRY in scripts/check-data-key.py with the reason (or drop the flag)" % (app, env))
|
|
for (app, env), why in sorted(REGISTRY.items()):
|
|
if app in judged and (app, env) not in seen:
|
|
bad.append("REGISTRY entry %s/%s is STALE — no such deploy field (%s)" % (app, env, why))
|
|
for b in bad:
|
|
print("REFUSED: " + b)
|
|
for u in undecided:
|
|
print("INCONCLUSIVE: " + u)
|
|
if bad:
|
|
print("data-key: %d problem(s) in %d template(s)" % (len(bad), len(judged)))
|
|
return 1
|
|
if undecided:
|
|
return 2
|
|
print("data-key gate OK: %d template(s), %d data key(s), every flag agrees with the name rule and REGISTRY"
|
|
% (len(judged), flagged))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|