Files
app-catalog-felhom.eu/scripts/check-data-key.py
T

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:]))