#!/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=] (`--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:]))