diff --git a/SCOPE.md b/SCOPE.md index c14b3ba..3b7491e 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -119,8 +119,8 @@ cross-system object is documented in secret-catalog-lane`, twelve actions delivered by secrets-engine rather than inferred, `destroy` gated on a dual-control approval claim, and a dedicated `flex-auth-secrets-engine` pin at -`http://flex-auth-secrets-engine.flex-auth.svc.cluster.local:8080` with -`callerAuth.mode: warn`. Adoption is not complete: secrets-engine is a CLI +`http://flex-auth-secrets-engine.flex-auth.svc.cluster.local.:8080` — +trailing dot required, in-cluster only — with `callerAuth.mode: warn`. Adoption is not complete: secrets-engine is a CLI rather than a workload, and the pin's default-deny ingress admits a pod, so an operator-run access path is still undecided. diff --git a/decisions/decisions.md b/decisions/decisions.md index 1db2a96..a8ab93e 100644 --- a/decisions/decisions.md +++ b/decisions/decisions.md @@ -1304,3 +1304,133 @@ the one real code change here. - Does not block the access path. The `TokenRequest` shape stands on its own; this makes its result visible in the artifact rather than only in the pin's logs. + +--- + +## FLEX-DEC-2026-010 — The response channel is unauthenticated: stated as a stance, and the envelope will be signed + +**Date:** 2026-09-06 +**Status:** accepted, stance recorded, fix open +**Workplan:** `FLEX-WP-0024` +**Raised by:** `secrets-engine`, who asked rather than mitigating it locally + +## Context + +`secrets-engine` probed the address this repo handed them under +`FLEX-WP-0021-T05` and found that on the workstation a bare +`*.svc.cluster.local` name resolves through `search ad.binect.de` to one +unrelated public host. Reproduced here: + +```text +$ getent hosts flex-auth-secrets-engine.flex-auth.svc.cluster.local +80.158.43.29 ...svc.cluster.local.ad.binect.de +$ getent hosts this-service-does-not-exist.flex-auth.svc.cluster.local +80.158.43.29 (identical — suffix expansion, not a record) +$ getent hosts flex-auth-secrets-engine.flex-auth.svc.cluster.local. +(no resolution) +``` + +They then asked the question the misdirection makes urgent: **how is the +response channel authenticated?** And they declined to answer it locally, on the +grounds that choosing a transport control for flex-auth's service is not a +consumer's call and inventing one is how a consumer ends up authoring semantics +it does not own. That is the right boundary — the same one that kept them from +authoring a tenant mapping — and it is why this record exists instead of a +mitigation in their repo. + +## The stance, stated because an unstated stance is an assumption + +**The response channel is unauthenticated.** The pin serves plain HTTP on +`:8080`; `flex-auth.decision-record.v1` carries no signature field. A responder +that knows the package id and version — both published in this repo — can return +a well-formed `effect: allow` with a matching binding, and every check a +consumer performs will pass it. + +`callerAuth` authenticates the caller to the PDP. Nothing authenticates the PDP +to the caller. Those are not two views of one control. + +## Why the digests do not help, which is the part that would be misread + +A consumer that recomputes `request_digest`, `policy_package_digest`, and +`registry_snapshot_digest`, finds all three correct, and concludes the responder +is genuine has verified **nothing about who answered**. + +Every input to those digests is either sent by the caller or published. The +request material is what the caller just transmitted. Both the package and +registry digests are computable from files in a public repo. A forger reproduces +all three exactly, and the more digests flex-auth publishes the more +authenticated a forged envelope looks. + +**The digests establish integrity of the binding, never authenticity of the +source.** This is the third instance of one seam in as many decisions: +`FLEX-DEC-2026-008` had a tenant carried into the digest and never compared — +visible, not enforced; `FLEX-DEC-2026-009` has a caller authenticated and never +recorded — enforced, not visible; here the record is verifiable and unauthentic — +checkable, but not evidence of anything. Each time, an artifact invited a +conclusion one step stronger than it supports. + +## The consequence for a fail-closed consumer + +`secrets-engine` runs fail-closed in production, and stated that its posture +"silently assumes the PDP is the PDP". Precisely: + +**Fail-closed protects against a PDP that is absent, not against one that +lies.** An unreachable PDP denies. A lying PDP allows. The posture that makes +the first safe does nothing about the second, and a consumer reading its own +fail-closed configuration as covering both has drawn exactly the wrong +inference. + +Today that risk is carried entirely by their pin being unset — which is to say, +by nothing structural. + +## Decision + +**Three parts, in increasing durability.** + +**1. Never publish a bare Service name again.** Publish the trailing-dot FQDN +and say in-cluster only. The trailing dot makes resolution *fail* rather than +succeed at the wrong place, which is the behaviour a fail-closed consumer needs +from a name. Corrected in `examples/secrets-engine/README.md`, `SCOPE.md`, and +the `FLEX-WP-0021-T05` handover note. + +**2. For the operator shape, `kubectl port-forward` is the recommended path, and +not only for reachability.** It resolves no DNS name, targets one named pod +explicitly, and runs over the API server's TLS with the operator's cluster +credentials — so it is currently the **only** path on which a consumer knows it +is talking to the real pin. + +Note this is the exact reverse of the caller direction, where the port-forward +bypasses the `NetworkPolicy` entirely (`FLEX-WP-0023`). The two properties are +independent and point opposite ways. They must be stated separately; summarising +either as "the network protects it" gets one of them backwards. + +**3. Sign the decision envelope.** §17 makes the decision-record schema +flex-auth's, so responder authenticity is ours to provide rather than a +deployment concern to delegate. A detached signature over the canonical envelope +is preferred to transport-only mTLS, for two reasons: + +- It survives the transport. A decision recorded now must still be + attributable when it is audited later, and mTLS authenticates a connection + that no longer exists. +- It composes with `FLEX-DEC-2026-009`. A record that names its authenticated + caller *and* carries flex-auth's signature is an audit artifact. Either alone + is a claim. + +mTLS remains worth having for the live channel and is not excluded; it is not a +substitute. + +## Consequences + +- **flex-auth published a hazardous address.** The handover was ours, the probe + was theirs, and the finding is against this repo. Recorded that way. +- Until signing lands, a consumer must not treat digest recomputation as + responder verification. `docs/decision-record-contract.md` says so + explicitly — the contract is the only place a consumer is guaranteed to read. +- `FLEX-WP-0024` carries signing. Key custody is not flex-auth's to invent: + route it through `warden route` and OpenBao rather than minting a key in this + repo, per the estate credential-routing rule. +- Worth carrying into the v0.8 assent round. §6.4 obligates the PEP to act on a + decision and §9.3 splits degraded mode by owner, but nothing in the standard + requires a PDP's response to be authenticable, so a conformant PEP can be + fully conformant while trusting a forged allow. That is a gap in the standard + and not only in this implementation. diff --git a/docs/decision-record-contract.md b/docs/decision-record-contract.md index cf625be..e731a5a 100644 --- a/docs/decision-record-contract.md +++ b/docs/decision-record-contract.md @@ -56,3 +56,28 @@ See [`canonical-request-digest.md`](canonical-request-digest.md) and This is contract version 1. Additive optional fields may appear. Removing or redefining a required field requires a new `contract_version` value and a new schema id. + +## What this record does not prove: who answered + +**A consumer must not read digest recomputation as verification of the +responder.** Recomputing `binding.request_digest`, +`provenance.policy_package_digest`, and `provenance.registry_snapshot_digest` +and finding all three correct says nothing about who produced the envelope. + +Every input to those digests is either sent by the caller or published: the +request material is what the caller just transmitted, and both the package and +registry digests are computable from files in this repo. A responder that knows +the package id and version can reproduce all three exactly. The digests +establish **integrity of the binding**, never **authenticity of the source**. + +`flex-auth.decision-record.v1` carries no signature today, and pins serve plain +HTTP. So the response channel is unauthenticated, stated as a stance rather than +left as an assumption (`FLEX-DEC-2026-010`). For a fail-closed consumer the +distinction that matters is this: **fail-closed protects against a PDP that is +absent, not against one that lies.** An unreachable PDP denies; a lying PDP +allows. + +A detached signature over the canonical envelope is the intended fix +(`FLEX-WP-0024`). Until it lands, responder authenticity comes from the channel +alone — and of the available channels only `kubectl port-forward` supplies it, +by targeting one named pod over the API server's TLS with no DNS name resolved. diff --git a/docs/operator-caller-access-path.md b/docs/operator-caller-access-path.md index 39e46d3..7452b16 100644 --- a/docs/operator-caller-access-path.md +++ b/docs/operator-caller-access-path.md @@ -11,6 +11,41 @@ half transfers unexamined, and `glas-harness` ruled out the two shortcuts by name — **Service DNS is not connectivity, and a permanent operator token is not an identity**. Both refusals are correct. +## The address itself is a hazard from a workstation + +`secrets-engine` probed the address this repo handed them, and flex-auth +reproduced it: + +```text +$ getent hosts flex-auth-secrets-engine.flex-auth.svc.cluster.local +80.158.43.29 flex-auth-secrets-engine.flex-auth.svc.cluster.local.ad.binect.de + +$ getent hosts this-service-does-not-exist.flex-auth.svc.cluster.local +80.158.43.29 this-service-does-not-exist.flex-auth.svc.cluster.local.ad.binect.de + +$ getent hosts flex-auth-secrets-engine.flex-auth.svc.cluster.local. +(no resolution) +``` + +`resolv.conf` carries `search ad.binect.de`, which answers wildcard. A name for a +service that does not exist resolves to the same address as one that does, which +is the proof that this is suffix expansion and not a record. So on this +workstation **every `*.svc.cluster.local` name resolves to one unrelated public +host**, and the bare Service name flex-auth published was not merely unreachable +from there — it was a live misdirection. + +Had a deployment pointed at it, the CheckRequest body would have gone to that +host: subject, tenant, lane and resource ids, stage, declared field names, +purpose, plus the caller's bearer token. No secret values, but a structural map +of the estate's credential lanes and a credential. `secrets-engine` declined to +mitigate it locally on the grounds that choosing a transport control for +flex-auth's service is not a consumer's call. That is the correct boundary and +the same one that kept them from authoring a tenant mapping. + +**Publish the trailing-dot FQDN, and say in-cluster only.** The trailing dot +makes resolution fail instead of succeeding at the wrong place, which is the +behaviour a fail-closed consumer needs from a name. + ## The two gates are not one gate `FLEX-WP-0021-T04` noted that `callerAuth` "becomes the real boundary" for an @@ -118,6 +153,45 @@ it should be. The commands above are exact and the expectations are derived from `internal/callerauth/auth.go`, not guessed; running them is an operator action. Nothing below the design line is claimed as verified. +## Which end of the channel is authenticated + +`callerAuth` authenticates the **caller to the PDP**. Nothing authenticates the +**PDP to the caller**, and `secrets-engine` was right to ask rather than assume. + +Stated plainly, because a stance is only a stance if it is recorded: the pin +serves plain HTTP on `:8080` and `flex-auth.decision-record.v1` carries no +signature. **A responder that knows the package id and version — both published +in this repo — can return a well-formed `effect: allow` that passes every check +a consumer performs.** + +The trap worth naming is that the digests look like they help and do not. A +consumer that recomputes `request_digest`, `policy_package_digest`, and +`registry_snapshot_digest` and finds them all correct has verified nothing about +who answered, because **every input to those digests is either sent by the +caller or published**: the request material is what the caller just transmitted, +and both package and registry digests are computable from files in a repo. A +forger reproduces all three exactly. The digests establish integrity of the +binding, never authenticity of the source, and reading a matching digest as +evidence of a genuine PDP is the same visible-but-not-verifying seam as +`FLEX-DEC-2026-008`. + +Consequence for a fail-closed consumer, which is the part that matters: +**fail-closed protects against a PDP that is absent, not against one that +lies.** A forged allow defeats the posture entirely rather than degrading it. + +`FLEX-DEC-2026-010` records the stance and `FLEX-WP-0024` carries the fix. One +nuance belongs here, though, because it changes the operator recommendation: + +**A `kubectl port-forward` path does authenticate the responder, transitively.** +It resolves no DNS name, targets one named pod explicitly, and runs over the +API server's TLS with the operator's cluster credentials. So for the operator +shape the port-forward is not merely a workaround for reachability — it is +currently the *only* path where the consumer knows it is talking to the real +pin. That is the reverse of the caller direction, where the port-forward +bypasses the `NetworkPolicy` entirely. The two properties are independent and +point opposite ways, which is why they have to be stated separately rather than +summarised as "the network protects it". + ## What the record will not show Even with all four negatives passing, **the decision record does not say who diff --git a/examples/secrets-engine/README.md b/examples/secrets-engine/README.md index ba500e4..ef541b0 100644 --- a/examples/secrets-engine/README.md +++ b/examples/secrets-engine/README.md @@ -32,12 +32,22 @@ go run ./cmd/flex-auth load-registry -file examples/secrets-engine/registry_snap `FLEX-WP-0021-T04` deployed the `flex-auth-secrets-engine` pin on 2026-09-06: ```text -Service: http://flex-auth-secrets-engine.flex-auth.svc.cluster.local:8080 +Service: http://flex-auth-secrets-engine.flex-auth.svc.cluster.local.:8080 + ^ trailing dot, required Package: secrets-engine.catalog-lane.lifecycle Version: v2 callerAuth.mode: warn (not enforced caller authentication) ``` +**The trailing dot is not cosmetic, and this address is in-cluster only.** +`secrets-engine` reported and flex-auth reproduced that on the workstation a +bare `*.svc.cluster.local` name resolves through `search ad.binect.de` to one +unrelated public host — a name for a service that does not exist resolves to the +same address, which proves it is suffix expansion rather than a record. The +trailing-dot form correctly fails to resolve instead. A bare Service name in a +handover is therefore not merely unreachable from a workstation, it is a live +misdirection. See `../../docs/operator-caller-access-path.md`. + Ingress admits namespace `secrets-engine` with pod label `app.kubernetes.io/name=secrets-engine` and default-denies everything else. A workstation CLI process is not that, and Service DNS is not workstation diff --git a/workplans/FLEX-WP-0021-secrets-engine-consumer-policy-gate.md b/workplans/FLEX-WP-0021-secrets-engine-consumer-policy-gate.md index 897b61e..c2de024 100644 --- a/workplans/FLEX-WP-0021-secrets-engine-consumer-policy-gate.md +++ b/workplans/FLEX-WP-0021-secrets-engine-consumer-policy-gate.md @@ -250,12 +250,21 @@ reach a pin; nothing about the fallback-free shape of that configuration changed Handed back to `secrets-engine` and `glas-harness`: ```text -Service: http://flex-auth-secrets-engine.flex-auth.svc.cluster.local:8080 +Service: http://flex-auth-secrets-engine.flex-auth.svc.cluster.local.:8080 Package: secrets-engine.catalog-lane.lifecycle Version: v2 <- not v1; v1 is deployed and superseded callerAuth.mode: warn (not enforced caller authentication) ``` +**Corrected 2026-09-06: the address originally handed over was a bare Service +name, which is a live misdirection from a workstation.** `secrets-engine` probed +it and flex-auth reproduced: `search ad.binect.de` expands any +`*.svc.cluster.local` name to one unrelated public host, including names for +services that do not exist. The trailing dot is required and the address is +in-cluster only. `FLEX-WP-0024` carries the consequence — a published address a +consumer can be misdirected from, reaching an unsigned decision envelope over +plain HTTP. + `SCOPE.md` lists secrets-engine among the shipped consumers and `examples/secrets-engine/README.md` carries the coordinates at source. diff --git a/workplans/FLEX-WP-0024-decision-envelope-authenticity.md b/workplans/FLEX-WP-0024-decision-envelope-authenticity.md new file mode 100644 index 0000000..cb9deca --- /dev/null +++ b/workplans/FLEX-WP-0024-decision-envelope-authenticity.md @@ -0,0 +1,139 @@ +--- +id: FLEX-WP-0024 +type: workplan +title: "Sign the decision envelope: the response channel is unauthenticated" +domain: infotech +repo: flex-auth +status: ready +owner: claude +topic_slug: netkingdom +planning_priority: P1 +planning_order: 240 +depends_on_workplans: + - FLEX-WP-0021 +related_workplans: + - FLEX-WP-0019 + - FLEX-WP-0023 +created: "2026-09-06" +updated: "2026-09-06" +--- + +# FLEX-WP-0024 — Sign the decision envelope: the response channel is unauthenticated + +Opened by `FLEX-DEC-2026-010`, from a question `secrets-engine` asked rather +than answering locally: **how is the response channel authenticated?** They +declined to choose a transport control for flex-auth's service on the grounds +that it is not a consumer's call. Correct, and it is why this is a flex-auth +workplan and not a mitigation in their repo. + +The stance today: pins serve plain HTTP, `flex-auth.decision-record.v1` carries +no signature, and a responder that knows the package id and version — both +published — can return a well-formed `effect: allow` that passes every check a +consumer performs. + +## The two things not to get backwards + +**The digests do not authenticate the responder and cannot.** Every input to +them is either sent by the caller or published, so a forger reproduces +`request_digest`, `policy_package_digest`, and `registry_snapshot_digest` +exactly. Publishing more digests makes a forged envelope look *more* +authenticated, not less. + +**Fail-closed does not cover this.** It protects against a PDP that is absent, +not against one that lies. An unreachable PDP denies; a lying PDP allows. + +## 1. Publish the stance and stop publishing bare Service names + +```task +id: FLEX-WP-0024-T01 +status: done +priority: high +``` + +Owner: `flex-auth`. + +**Done 2026-09-06.** `docs/decision-record-contract.md` states that digest +recomputation is not responder verification, because the contract is the only +document a consumer is guaranteed to read. +`docs/operator-caller-access-path.md` carries the reproduced DNS probe and the +channel analysis. The trailing-dot FQDN replaced the bare Service name in +`examples/secrets-engine/README.md`, `SCOPE.md`, and the `FLEX-WP-0021-T05` +handover note — a bare `*.svc.cluster.local` name resolves through +`search ad.binect.de` to one unrelated public host, and a name for a service +that does not exist resolves to the same address. + +## 2. Choose the signature shape and key custody + +```task +id: FLEX-WP-0024-T02 +status: todo +priority: high +``` + +Owner: `flex-auth`; key custody is **not** ours to invent. + +- Detached signature over the canonical envelope, preferred over transport-only + mTLS for two reasons: it survives the transport, so a decision audited later + is still attributable, where mTLS authenticates a connection that no longer + exists; and it composes with `FLEX-DEC-2026-009`, since a record that names + its authenticated caller *and* carries flex-auth's signature is an audit + artifact where either alone is a claim. +- Decide the canonical form to sign. The existing canonical-JSON rules from + `docs/canonical-request-digest.md` are the obvious basis, but the signed + material is the **envelope**, not the request, and it must exclude the + signature field itself. Write it down before implementing: an implicit + canonicalisation rule is what `FLEX-DEC-2026-007` had to correct. +- **Do not mint a key in this repo.** Route custody via + `warden route find "decision envelope signing key"` and OpenBao, per the + estate credential-routing rule. flex-auth decides the shape; it does not own + key material. +- State key rotation and how a consumer discovers the current public key, since + a consumer that cannot rotate trust has a pinned key that will one day be + wrong. + +Gate: the canonical form, the algorithm, and the custody path are written down +and reviewed before any signing code exists. + +## 3. Implement signing and verification + +```task +id: FLEX-WP-0024-T03 +status: wait +priority: high +``` + +Owner: `flex-auth`. + +- Add the signature to `schemas/decision_envelope.schema.json` as additive, and + state in the contract that `request_digest` is unaffected so no consumer + re-pins — the same note that was needed for v2 and for + `approval_binding_digest`. +- Emit unsigned when no key is configured, and say so in the envelope rather + than omitting the field silently. Absence stated, not implied — the + `pdp_digest` precedent. +- Ship a verification example and a fixture pair in + `examples/secrets-engine/replay/`: a genuine signed envelope and one whose + payload was altered after signing, so a consumer's verifier is tested against + a real failure rather than only a real success. **A verifier that has never + seen an invalid signature is untested**, which is the lesson from every + fixture suite in `FLEX-DEC-2026-008`. + +Gate: an altered envelope fails verification, an unaltered one passes, and a +consumer can perform both from the fixtures alone. + +## 4. Report the gap to gate-house + +```task +id: FLEX-WP-0024-T04 +status: wait +priority: medium +``` + +Owner: `flex-auth`. + +§6.4 obligates the PEP to act on a decision and §9.3 splits degraded mode by +owner, but **nothing in the standard requires a PDP's response to be +authenticable**. So a fully conformant PEP can trust a forged allow while +meeting every published obligation. That is a gap in the standard, not only in +this implementation, and it belongs in the outstanding v0.8 assent round +alongside the `FLEX-DEC-2026-009` finding.