diff --git a/creds-matrix.tsv b/creds-matrix.tsv index 73ae301..b4a0100 100644 --- a/creds-matrix.tsv +++ b/creds-matrix.tsv @@ -42,7 +42,7 @@ # ---------------------------------------------------------------- vr1-office1 (headend region + jumphost) maas-region-admin singleton vr1-office1 headend admin.pass gui human source-of-record stage2 script:scripts/site-headend-install.sh:450 SEC-020 n-maas-region-admin -maas-region-admin singleton vr1-office1 jumphost maas-admin-password gui human consolidated stage2 script:scripts/site-headend-install.sh:450 SEC-020 n-maas-region-admin +maas-region-admin singleton vr1-office1 jumphost maas-admin-password gui human verbatim-copy stage2 script:scripts/site-headend-install.sh:450 SEC-020 n-maas-region-admin maas-region-admin singleton vr1-office1 headend admin.apikey api service not-consolidated-ruled stage2 script:scripts/site-headend-install.sh:453 SEC-020 n-maas-admin-apikey maas-region-admin singleton vr1-office1 headend .maascli.db cli-profile service off-manifest-known stage2 script:scripts/site-headend-install.sh:455 SEC-020 n-maas-cli-profile maas-region-operator singleton vr1-office1 jumphost maas-operator-password gui human consolidated adhoc operator-terminal SEC-020 n-maas-region-operator diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be69d4c..e28b24f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -969,7 +969,8 @@ custody-gated and the siblings were undeclared. **LOGGED, NOT ACTIONED (hard rule 1)** -- the `chmod` and the retain-or-delete question are mutations outside this step. **STILL OUTSTANDING (not done, not silently dropped):** ruling 1's TIER 2 `Pn` (tier 1 - is wired -- see above); tier 3 + is wired -- see above); tier 3 (BUILT 2026-07-26 -- see below); tier 3's behavioural-probe + half (VALIDITY); `--render`'s source-field derivation; and the `cardinality` field remains largely inert with a one-token S5 bypass (`per-DC` -> `singleton`). diff --git a/docs/audit/d137-tier3-provenance-20260726.txt b/docs/audit/d137-tier3-provenance-20260726.txt new file mode 100644 index 0000000..c055034 --- /dev/null +++ b/docs/audit/d137-tier3-provenance-20260726.txt @@ -0,0 +1,45 @@ +# D-137 tier-3 VALIDITY sweep -- live + privileged, 2026-07-26 +# python3 scripts/creds-matrix.py --tier2 --tier3 --remote --privileged \ +# --pending-stage vr0-phase01 --pending-stage vr0-phase02 \ +# --pending-stage vr0-phase03 --pending-stage tenant-onboard +# +# DIGESTS ONLY. sha256sum is invoked as an external command on both sides, so only a +# 64-hex digest ever enters the checker process. No credential content is transferred. +# This is the check SEC-020's stale-trap warning has needed since it was written: the +# sha256 equality it depends on was a ONE-TIME manual act on 2026-07-25. + +=== creds-matrix: tier 1 (STATIC) === +=== creds-matrix: tier 2 (EXISTENCE) === +=== creds-matrix: tier 3 (VALIDITY) === + [ok] S1 schema: 81 rows, all enums valid, site-keys region-qualified, no duplicate (id,site,host-role,filename) + [ok] S3 render: 2 source field(s) SKIPPED -- rendering them needs the declared path from creds-manifests/vm-secret-locations (ruling 3); the list now EXISTS but the source-field derivation is not wired + [ok] S3 render drift: rendered row fields (mode, source) match checked-in; header prose and non-jumphost rows are OUT OF SCOPE of this compare + [ok] S4 mint-ref: every script:/runbook: reference resolves to a real location + [ok] S4 provenance debt: 29 row(s) are mint-ref=operator-terminal -- NOT reproducible from the repo (research FINDING 1). Admitted by design; converting them is remediation, not a checker fix. + [ok] S7 notes: 34 note key(s) referenced, all resolve, none orphaned + [ok] E0 jumphost location '~/vault-init/*' does not exist -- SKIPPED (typically a mint stage this deployment has not reached) + [ok] E0 jumphost location '~/octavia-pki/*' does not exist -- SKIPPED (typically a mint stage this deployment has not reached) + [ok] E0 jumphost location '~/octavia-pki/issuing-ca/*' does not exist -- SKIPPED (typically a mint stage this deployment has not reached) + [ok] E0 jumphost location '~/octavia-pki/controller-ca/*' does not exist -- SKIPPED (typically a mint stage this deployment has not reached) + [ok] E0 jumphost location '~/octavia-pki/controller/*' does not exist -- SKIPPED (typically a mint stage this deployment has not reached) + [ok] E0 headend location '/root/maas-secrets/*' read via ESCALATION (sudo -n, metadata only) after an unprivileged probe could not open it + [ok] E0 headend location '/var/snap/maas/current/root/.ssh/*' read via ESCALATION (sudo -n, metadata only) after an unprivileged probe could not open it + [ok] E0 netbox location '/root/netbox-secrets/*' read via ESCALATION (sudo -n, metadata only) after an unprivileged probe could not open it + [ok] E1 18 expected artifact(s) deferred as not-yet-minted (--pending-stage) + [ok] V1 1 multi-copy identity(ies) SKIPPED -- fewer than two copies could be digested (unresolvable path, instance template, or a location not probed). A digest that could not be taken is never a match. + [ok] V1 provenance: all copies byte-identical for 4 group(s): dc0-svc-key; dc0-svc-key; dc1-svc-key; maas-region-admin (declared verbatim copies) + [FAIL] S2 vr1-dc0 EXPECTED-BUT-ABSENT: 'opnsense-api.txt' (id dc0-edge-api, SEC-021) is expected by the matrix but NOT declared in the manifest -- the credential is either missing or undeclared + [FAIL] S2 vr1-dc0 EXPECTED-BUT-ABSENT: 'vr1-dc0-maas-power_ed25519' (id dc0-maas-power-key, SEC-021) is expected by the matrix but NOT declared in the manifest -- the credential is either missing or undeclared + [FAIL] S2 vr1-dc0 EXPECTED-BUT-ABSENT: 'vr1-dc0-maas-power_ed25519.pub' (id dc0-maas-power-key, SEC-021) is expected by the matrix but NOT declared in the manifest -- the credential is either missing or undeclared + [FAIL] S5 ASYMMETRY: vr1-dc0 declares id=dcN-maas-power-key file=id_ed25519 on headend (custody=off-manifest-known) with no counterpart in vr1-dc1 -- a per-DC credential must exist at BOTH DCs in the same shape + [FAIL] S5 ASYMMETRY: vr1-dc0 declares id=dcN-maas-power-key file=maas-virsh_ed25519 on headend (custody=off-manifest-known) with no counterpart in vr1-dc1 -- a per-DC credential must exist at BOTH DCs in the same shape + [FAIL] S5 ASYMMETRY: vr1-dc1 declares id=dcN-maas-power-key file=id_dcN_power on headend (custody=off-manifest-known) with no counterpart in vr1-dc0 -- a per-DC credential must exist at BOTH DCs in the same shape + [FAIL] S6 IDENTITY CONFLATION: id 'maas-region-admin' serves 2 principal types (human via gui; service via api, cli-profile) -- ruling 5 requires one identity to serve one principal type + [FAIL] E1 EXPECTED-BUT-ABSENT: dc0-edge-api 'opnsense-api.txt' expected at jumphost/vr1-dc0, not found (mint-stage stage3, SEC-021) + [FAIL] E1 EXPECTED-BUT-ABSENT: dc0-maas-power-key 'vr1-dc0-maas-power_ed25519' expected at jumphost/vr1-dc0, not found (mint-stage stage3, SEC-021) + [FAIL] E1 EXPECTED-BUT-ABSENT: dc0-maas-power-key 'vr1-dc0-maas-power_ed25519.pub' expected at jumphost/vr1-dc0, not found (mint-stage stage3, SEC-021) + [FAIL] E1 EXPECTED-BUT-ABSENT: dc1-svc-key 'vr1-dc1_svc_ed25519.pub' expected at headend/vr1-dc1, not found (mint-stage stage3, SEC-022) + [FAIL] E2 WORLD-READABLE: office1-tofu-maas-apikey 'terraform.tfstate.backup' at jumphost/vr1-office1 is mode 664 + [FAIL] E4 UNCHECKABLE: 2 row(s) have no declared location for their (role, site) and can never be verified -- add a location row or correct the matrix: capi-mgmt-kubeconfig 'config' (cloud/-); rbd-mirror-peer-token 'rbd-mirror-bootstrap-token' (unit/-) + +FAIL: creds-matrix tier 1 -- 81 row(s), 17 check group(s) clean, 13 finding(s) diff --git a/docs/changelog-20260726-d137-tier1.md b/docs/changelog-20260726-d137-tier1.md index 8cae794..4773341 100644 --- a/docs/changelog-20260726-d137-tier1.md +++ b/docs/changelog-20260726-d137-tier1.md @@ -545,3 +545,58 @@ **Revert:** `git revert` this commit; the rule text returns to the ledger and D-137 stops being the policy authority in practice while remaining so on paper. + +## Item 18 -- tier 3 (VALIDITY) BUILT: cross-copy sha256 provenance + +Closes the gap the plan names: `creds-audit` PARSES declared provenance and NEVER verifies +it, so SEC-020's stale-trap warning -- *"any future rotation MUST update both copies in one +operation or the region copy becomes a stale trap"* -- has been unchecked prose since it was +written, and the sha256 equality behind it was a ONE-TIME manual act on 2026-07-25. + +**Mechanism.** `--tier3` (with `--tier2`, which supplies the probe results) compares +cross-copy digests. `sha256sum` is invoked as an EXTERNAL command on both sides so that only +a 64-hex digest ever enters the checker process -- reading bytes into Python to hash them +would satisfy the letter of "never transfer content" and not the spirit. Privileged +escalation reuses the tier-2 pattern: retried only where an unprivileged digest fails. + +**RESULT (capture `docs/audit/d137-tier3-provenance-20260726.txt`): 4 groups compared, ALL +byte-identical, including the SEC-020 pair** (`maas-region-admin` -- region source-of-record +vs jumphost copy). That equality is now machine-verified instead of remembered. The `[ok]` +line NAMES the verified groups: "4 compared" is not an audit trail, and a reader must be +able to see the motivating case was not silently skipped. + +**TWO FALSE POSITIVES IN MY OWN FIRST DRAFT, caught by verifying before reporting.** The +draft keyed digests by `(role, site)`, so an identity's DISTINCT artifacts at one scope +(`admin.pass`, `admin.apikey`, `.maascli.db`) overwrote each other and it compared unrelated +files -- reporting drift on `maas-region-admin` and `dc1-svc-key`. Manual `sha256sum` +comparison showed both were IDENTICAL. Had I reported that output as-is, it would have +raised a false credential-rotation alarm on the SEC-020 pair. + +Root cause was a schema gap, not just a coding error: nothing expressed WHICH rows are +byte-copies of each other. Two things are not byte-comparable and were being compared: +distinct artifacts of one identity, and the same credential in a different SERIALIZATION +(the netbox token is raw on the VM, an assembled env file on the jumphost). + +**`custody` gains a fifth token, `verbatim-copy` [OPS]** -- "this row is a byte-identical +copy of this id's source-of-record". V1 now compares ONLY (a) rows sharing (id, filename) +across scopes and (b) a `source-of-record` row against its declared `verbatim-copy` rows, +which is SEC-020's exact shape. Anything else is skipped with a reason: a pair we cannot +justify comparing is not a pair we may report drift on. `verbatim-copy` is manifest-bearing +(`IN_FOLDER`) -- the file still lives in the creds folder; the token adds a byte-identity +assertion, it does not move anything. + +**Harness 44 -> 52.** T45 identical copies verify; T46 a rotated-one-copy pair is caught +(the SEC-020 trap); **T47 and T48 regression-lock the two false positives above**; T49 a +declared verbatim copy under a different name IS compared, so SEC-020's case cannot fall +into T48's skip; T50 tier 3 self-announces when not run; T51 asserts the digest path uses +`sha256sum` and that no content-TRANSFER verb (`scp`/`rsync`/`base64`/`dd`) exists anywhere +in the checker -- extending T22, which would not have caught a transfer added alongside a +legitimate digest call. + +Gauntlet ALL GREEN (80), repo-lint 0-fail, finding-class baseline UNCHANGED. + +**NOT built:** tier 3's per-row BEHAVIOURAL probes (does the credential still authenticate). +Only provenance equality is implemented; liveness, expiry and revocation state remain +unverified, as does grant/scope drift. + +**Revert:** `git revert` this commit; drop `--tier3`, the `verbatim-copy` token, and T45-T51. diff --git a/scripts/creds-matrix.py b/scripts/creds-matrix.py index f7d7e57..c7bbd5e 100644 --- a/scripts/creds-matrix.py +++ b/scripts/creds-matrix.py @@ -46,7 +46,11 @@ ACCESS_TYPE = {"gui", "api", "ssh", "cli-profile", "console", "none"} PRINCIPAL = {"human", "service", "-"} CUSTODY = {"consolidated", "source-of-record", "off-manifest-known", - "not-consolidated-ruled"} + "not-consolidated-ruled", "verbatim-copy"} +# Custodies whose files live IN the site creds folder, and therefore appear in its +# (generated) manifest. `verbatim-copy` adds a byte-identity assertion for tier 3; it does +# not move the file out of the folder. +IN_FOLDER = {"consolidated", "verbatim-copy"} # site-key discipline imported from scripts/lib-net.sh:160-162 / lib-hosts.sh:153-155: # region-qualified only. A bare `dc0` is REJECTED -- it is the drifting form that has @@ -218,7 +222,7 @@ continue # A manifest declares exactly the CONSOLIDATED jumphost copies for its site. expected = {r.filename: r for r in rows - if r.site_key == site and r.custody == "consolidated" + if r.site_key == site and r.custody in IN_FOLDER and r.host_role == "jumphost" and r.has_artifact()} # bound 1: expected-but-absent for fname, r in sorted(expected.items()): @@ -247,7 +251,7 @@ # rows the matrix marks as deliberately NOT in the folder must not appear in it for r in rows: if (r.site_key == site and r.host_role == "jumphost" and r.has_artifact() - and r.custody != "consolidated" and r.filename in manifest): + and r.custody not in IN_FOLDER and r.filename in manifest): total_drift += 1 fails.append("S2 %s FIELD DRIFT: '%s' is custody=%s yet declared in the " "manifest" % (site, r.filename, r.custody)) @@ -304,7 +308,7 @@ src_of_record.setdefault(r.id, r) out = [] for r in rows: - if (r.site_key == site and r.custody == "consolidated" + if (r.site_key == site and r.custody in IN_FOLDER and r.host_role == "jumphost" and r.has_artifact()): source = "PENDING-TIER2" if r.id in src_of_record else "local" out.append((r.filename, derived_mode(r.filename), source, r)) @@ -690,7 +694,7 @@ break if hit: want = derived_mode(r.filename) - if r.custody == "consolidated" and got != want: + if r.custody in IN_FOLDER and got != want: fails.append("E2 MODE: %s '%s' at %s/%s is %s, want %s" % (r.id, r.filename, r.host_role, r.site_key, got, want)) elif r.principal != "-" and want == "600" and int(got, 8) & 0o007: @@ -753,6 +757,127 @@ oks.append("E1/E3 existence: every expected artifact present and nothing " "undeclared, across %d fully-probed role(s)" % len([c for c in observed if c not in incomplete])) + return observed + + + +# --------------------------------------------------------------------------- tier 3 +# VALIDITY. Motivated by a measured gap: `creds-audit` PARSES declared provenance and +# NEVER verifies it, so SEC-020's stale-trap warning -- "any future rotation MUST update +# both copies in one operation or the region copy becomes a stale trap" -- has been +# unchecked prose since the day it was written. The sha256 equality it depends on was a +# ONE-TIME manual act on 2026-07-25 and nothing has re-verified it since. +# +# DIGESTS ONLY, NEVER CONTENT. `sha256sum` is invoked as an external command on BOTH sides +# so that only a 64-hex digest ever enters this process -- the plan's hard constraint is +# "compare sha256sum digests over ssh -- never transfer content", and reading bytes into +# Python to hash them locally would satisfy the letter and not the spirit. +SHA_RE = re.compile(r"^([0-9a-f]{64})\s") + + +def digest_local(path): + try: + p = subprocess.run(["sha256sum", path], capture_output=True, text=True, timeout=60) + except (OSError, subprocess.SubprocessError): + return None + m = SHA_RE.match(p.stdout) + return m.group(1) if m else None + + +def digest_remote(target, path, privileged=False): + cmd = "sha256sum %s" % path # path validated by SAFE_PATH_RE + if privileged: + cmd = "sudo -n sh -c " + shlex.quote(cmd) + try: + p = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", + target, cmd], capture_output=True, text=True, timeout=60) + except (OSError, subprocess.SubprocessError): + return None + m = SHA_RE.match(p.stdout) + return m.group(1) if m else None + + +def tier3_validity(rows, locations, use_remote, use_privileged, observed, oks, fails): + """Copies of one identity must be byte-identical, and the authority must be knowable.""" + # where each (role, site) lives, so a row can be resolved to a concrete path + loc_by_scope = {} + for role, site, target, pat in locations: + loc_by_scope.setdefault((role, site), []).append((target, pat)) + + # WHAT IS COMPARABLE. An identity's rows are not all byte-copies of one another: + # `maas-region-admin` covers a password, an API key and a CLI profile -- three distinct + # artifacts -- and `netbox-sandbox-token` is a raw token on the VM but an assembled ENV + # FILE on the jumphost, same credential, different serialization. Comparing either pair + # is a false positive, and the first draft of this check produced exactly those two. + # Only two groupings are byte-comparable: + # (a) rows sharing (id, filename) at DIFFERENT scopes -- the same artifact, copied; + # (b) an id's `source-of-record` row against its `verbatim-copy` rows -- a declared + # byte-identical copy under a different name (SEC-020's admin.pass shape). + # Anything else is skipped with a reason. A pair we cannot justify comparing is not a + # pair we may report drift on. + groups = {} + for r in rows: + if r.has_artifact(): + groups.setdefault((r.id, r.filename), []).append(r) + comparable = [(k, v) for k, v in groups.items() if len(v) > 1] + for ident in sorted({r.id for r in rows}): + src = [r for r in rows if r.id == ident and r.custody == "source-of-record" + and r.has_artifact()] + cps = [r for r in rows if r.id == ident and r.custody == "verbatim-copy" + and r.has_artifact()] + if len(src) == 1 and cps: + comparable.append((("%s (declared verbatim copies)" % ident, ""), src + cps)) + + compared = matched = skipped = 0 + verified = [] + for (gid, _fn), copies in sorted(comparable, key=lambda t: str(t[0])): + digests = {} + for c in copies: + scope = (c.host_role, c.site_key) + paths = loc_by_scope.get(scope) or loc_by_scope.get((c.host_role, "-")) or [] + got = None + for target, pat in paths: + base = os.path.dirname(pat) + if glob.has_magic(os.path.expanduser(base)) or "<" in c.filename: + continue # instance-templated: no single resolvable path + full = base + "/" + c.filename + if target == "local": + if os.path.isfile(os.path.expanduser(full)): + got = digest_local(os.path.expanduser(full)) + elif use_remote: + got = digest_remote(target, full) + if got is None and use_privileged: + got = digest_remote(target, full, privileged=True) + if got: + break + if got: + digests["%s/%s:%s" % (c.host_role, c.site_key, c.filename)] = got + if len(digests) < 2: + skipped += 1 + continue + compared += 1 + if len(set(digests.values())) == 1: + matched += 1 + verified.append(str(gid)) + continue + fails.append("V1 PROVENANCE DRIFT: '%s' -- %d copies that MUST be byte-identical " + "have %d different digests (%s). A rotation updated one copy and not " + "the other; the un-updated copy is a STALE TRAP (SEC-020)." + % (gid, len(digests), len(set(digests.values())), + ", ".join(sorted(digests)))) + if skipped: + oks.append("V1 %d multi-copy identity(ies) SKIPPED -- fewer than two copies could " + "be digested (unresolvable path, instance template, or a location not " + "probed). A digest that could not be taken is never a match." + % skipped) + if compared and matched == compared: + # Name what was verified. "4 compared" is not an audit trail -- a reader must be + # able to see that the SEC-020 pair is among them and not silently skipped. + oks.append("V1 provenance: all copies byte-identical for %d group(s): %s" + % (compared, "; ".join(sorted(verified)))) + elif not compared: + oks.append("V1 provenance: no identity had two digestible copies -- NOTHING was " + "verified here; this is a skip, not a pass") def main(): @@ -772,6 +897,9 @@ ap.add_argument("--remote", action="store_true", help="include ssh locations, bounded ABSOLUTELY by vm-secret-locations " "(ruling 3). Metadata only; no file content is ever read.") + ap.add_argument("--tier3", action="store_true", + help="run tier 3 (VALIDITY): cross-copy sha256 provenance. Implies " + "--tier2, which supplies the probe results.") ap.add_argument("--privileged", action="store_true", help="allow a `sudo -n` ESCALATION RETRY on a remote location the " "unprivileged probe could not open (the root-owned region and " @@ -812,7 +940,7 @@ s6_principal_invariant(rows, oks, fails) s7_notes_ref(rows, notes, args.notes, oks, fails) - if args.tier2 or args.all: + if args.tier2 or args.tier3 or args.all: print("=== creds-matrix: tier 2 (EXISTENCE) ===") locations = load_locations(args.locations, args.repo, fails) if locations is None: @@ -824,16 +952,20 @@ "one rather than affirming existence over zero locations" % args.locations) else: - tier2_existence(rows, locations, args.remote, args.privileged, - set(args.pending_stage), oks, fails) + observed = tier2_existence(rows, locations, args.remote, args.privileged, + set(args.pending_stage), oks, fails) + if args.tier3 or args.all: + print("=== creds-matrix: tier 3 (VALIDITY) ===") + tier3_validity(rows, locations, args.remote, args.privileged, + observed, oks, fails) else: # SELF-SKIP WITH AN EXPLICIT [ok]: a tier that did not run must be visible. oks.append("tier 2 (EXISTENCE) NOT RUN -- pass --tier2 (add --remote for the " "headend shadow stores and the region secrets dir)") - if args.all: - oks.append("tier 3 (VALIDITY) NOT BUILT -- needs cross-host sha256 provenance " - "comparison (never content transfer) and per-row behavioral probes") + if not (args.tier3 or args.all): + oks.append("tier 3 (VALIDITY) NOT RUN -- pass --tier3 (with --tier2) to compare " + "cross-copy sha256 provenance") for o in oks: print(" [ok] %s" % o) diff --git a/tests/creds-matrix/run-tests.sh b/tests/creds-matrix/run-tests.sh index 1cbcede..fb001b9 100644 --- a/tests/creds-matrix/run-tests.sh +++ b/tests/creds-matrix/run-tests.sh @@ -218,9 +218,9 @@ # false-green this design exists to prevent. Tier 2 is built, so --all RUNS it and only # tier 3 self-announces; a default run announces tier 2 as NOT RUN. D=$(mkenv); mkloc "$D"; run "$D" --all -[ "$RC" = 0 ] && grep -q 'tier 3 (VALIDITY) NOT BUILT' "$D/out" \ - && grep -q 'tier 2 (EXISTENCE)' "$D/out" \ - && ok "T21a --all runs tier 2 and names tier 3 NOT BUILT -> no silent pass" \ +[ "$RC" = 0 ] && grep -q 'tier 2 (EXISTENCE)' "$D/out" \ + && grep -q 'tier 3 (VALIDITY)' "$D/out" \ + && ok "T21a --all runs every built tier (2 and 3) -> no silent skip" \ || { no "T21a --all tier reporting (rc=$RC)"; sed 's/^/ /' "$D/out"; } D=$(mkenv); mkloc "$D"; run "$D" [ "$RC" = 0 ] && grep -q 'tier 2 (EXISTENCE) NOT RUN' "$D/out" \ @@ -440,6 +440,96 @@ || { no "T44 instance templating must match (rc=$RC)"; sed 's/^/ /' "$D/out"; } +# ---- tier 3 (VALIDITY) ------------------------------------------------------------------ + +# T45 -- the same artifact copied to two scopes, byte-identical -> clean. +mk3() { # mk3 + local d="$1"; mkdir -p "$d/s1" "$d/s2" + printf '%s\n' "$2" > "$d/s1/twin.key"; chmod 600 "$d/s1/twin.key" + printf '%s\n' "$3" > "$d/s2/twin.key"; chmod 600 "$d/s2/twin.key" + printf 'jumphost vr1-dc0 local %s/s1/*\n' "$d" >> "$d/locations" + printf 'jumphost vr1-dc1 local %s/s2/*\n' "$d" >> "$d/locations" + row 'twin per-DC vr1-dc0 jumphost twin.key ssh service off-manifest-known stage3 operator-terminal - -' "$d" + row 'twin per-DC vr1-dc1 jumphost twin.key ssh service off-manifest-known stage3 operator-terminal - -' "$d" +} +D=$(mkenv); mkloc "$D"; mk3 "$D" same same; run "$D" --tier2 --tier3 +grep -q 'V1 provenance: all copies byte-identical' "$D/out" \ + && ok "T45 identical copies of one artifact -> V1 clean" \ + || { no "T45 identical copies should verify"; sed 's/^/ /' "$D/out"; } + +# T46 -- THE POINT OF TIER 3. SEC-020: "any future rotation MUST update both copies in one +# operation or the region copy becomes a stale trap." That warning was unchecked prose from +# the day it was written; the sha256 equality behind it was a ONE-TIME manual act. +D=$(mkenv); mkloc "$D"; mk3 "$D" original rotated; run "$D" --tier2 --tier3 +[ "$RC" = 1 ] && grep -q 'V1 PROVENANCE DRIFT' "$D/out" && grep -q 'STALE TRAP' "$D/out" \ + && ok "T46 one copy rotated and the other not -> V1 PROVENANCE DRIFT (the SEC-020 trap)" \ + || { no "T46 drift must be caught (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T47 -- REGRESSION for a false positive this check shipped with in draft: an identity's +# DISTINCT artifacts (a password, its API key, its CLI profile) are not byte-copies of each +# other. Keying digests by scope let them overwrite one another and report drift between +# unrelated files. Verified by hand against the live hosts: the digests actually matched. +D=$(mkenv); mkloc "$D"; mkdir -p "$D/multi" +printf 'pw\n' > "$D/multi/thing.pass"; chmod 600 "$D/multi/thing.pass" +printf 'key\n' > "$D/multi/thing.apikey"; chmod 600 "$D/multi/thing.apikey" +printf 'jumphost vr1-office1 local %s/multi/*\n' "$D" >> "$D/locations" +row 'one-ident singleton vr1-office1 jumphost thing.pass gui human off-manifest-known stage2 operator-terminal - -' "$D" +row 'one-ident singleton vr1-office1 jumphost thing.apikey api human off-manifest-known stage2 operator-terminal - -' "$D" +run "$D" --tier2 --tier3 +! grep -q 'V1 PROVENANCE DRIFT' "$D/out" \ + && ok "T47 distinct artifacts of ONE identity are not compared (draft false positive)" \ + || { no "T47 distinct artifacts must not be diffed"; sed 's/^/ /' "$D/out"; } + +# T48 -- REGRESSION for the second draft false positive: the same credential in a DIFFERENT +# SERIALIZATION. The netbox token is a raw file on the VM and an assembled env file on the +# jumphost; they are the same secret and are NOT byte-identical by design. +D=$(mkenv); mkloc "$D"; mkdir -p "$D/raw" "$D/wrapped" +printf 'tok\n' > "$D/raw/api.token"; chmod 600 "$D/raw/api.token" +printf 'URL=x\nTOK=tok\n' > "$D/wrapped/w.env"; chmod 600 "$D/wrapped/w.env" +printf 'netbox vr1-office1 local %s/raw/*\n' "$D" >> "$D/locations" +printf 'jumphost vr1-office1 local %s/wrapped/*\n' "$D" >> "$D/locations" +row 'wrapped-tok singleton vr1-office1 netbox api.token api service source-of-record stage2 operator-terminal - -' "$D" +row 'wrapped-tok singleton vr1-office1 jumphost w.env api service consolidated stage2 operator-terminal - -' "$D" +printf 'w.env 600 local\n' >> "$D/creds-manifests/vr1-office1.manifest" +run "$D" --tier2 --tier3 +! grep -q 'V1 PROVENANCE DRIFT' "$D/out" \ + && ok "T48 same credential, different SERIALIZATION -> not compared (draft false positive)" \ + || { no "T48 re-serialized copies must not be diffed"; sed 's/^/ /' "$D/out"; } + +# T49 -- but a copy DECLARED byte-identical under a different name IS compared. This is +# SEC-020's exact shape (admin.pass on the region, maas-admin-password on the jumphost) +# and it is the case tier 3 exists for, so it must not fall into T48's skip. +D=$(mkenv); mkloc "$D"; mkdir -p "$D/src" "$D/cpy" +printf 'v1\n' > "$D/src/orig.pass"; chmod 600 "$D/src/orig.pass" +printf 'v2\n' > "$D/cpy/work.pass"; chmod 600 "$D/cpy/work.pass" +printf 'netbox vr1-office1 local %s/src/*\n' "$D" >> "$D/locations" +printf 'jumphost vr1-office1 local %s/cpy/*\n' "$D" >> "$D/locations" +row 'declared-twin singleton vr1-office1 netbox orig.pass gui human source-of-record stage2 operator-terminal - -' "$D" +row 'declared-twin singleton vr1-office1 jumphost work.pass gui human verbatim-copy stage2 operator-terminal - -' "$D" +printf 'work.pass 600 local\n' >> "$D/creds-manifests/vr1-office1.manifest" +run "$D" --tier2 --tier3 +[ "$RC" = 1 ] && grep -q 'V1 PROVENANCE DRIFT' "$D/out" \ + && ok "T49 a DECLARED verbatim copy under a different name IS compared (SEC-020 shape)" \ + || { no "T49 verbatim-copy pairs must be compared (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T50 -- a tier that did not run must announce itself, like tiers 2 and 3 elsewhere. +D=$(mkenv); mkloc "$D"; run "$D" --tier2 +grep -q 'tier 3 (VALIDITY) NOT RUN' "$D/out" \ + && ok "T50 tier 3 omitted -> announced, never a silent skip" \ + || { no "T50 tier 3 must self-announce"; sed 's/^/ /' "$D/out"; } + +# T51 -- DIGESTS ONLY, NEVER CONTENT. The plan's hard constraint. sha256sum is the mandated +# mechanism and is allowed; what must never appear is a verb that brings bytes back. This +# extends T22's list, which would not have caught a content transfer added alongside a +# legitimate digest call. +grep -q 'sha256sum' "$CHECK" \ + && ok "T51a the provenance path uses sha256sum (digest, not content)" \ + || no "T51a tier 3 must compare digests" +grep -qE '\b(scp|rsync|base64|dd)\b' "$CHECK" \ + && no "T51b the checker gained a content-TRANSFER verb -- digests only" \ + || ok "T51b no content-transfer verb anywhere in the checker" + + echo if [ "$F" = 0 ]; then echo "creds-matrix: $P/$P PASS"; exit 0; fi echo "creds-matrix: FAILURES: $F (passed $P)"; exit 1