Skip to content

feat(tls): let the HTTP egress family and both Vault hops name an internal CA (BACKLOG #1180) - #878

Open
wshallwshall wants to merge 3 commits into
mainfrom
claude/builder-1180-http-anchor-expressibility
Open

feat(tls): let the HTTP egress family and both Vault hops name an internal CA (BACKLOG #1180)#878
wshallwshall wants to merge 3 commits into
mainfrom
claude/builder-1180-http-anchor-expressibility

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

What this is

The expressibility limb of BACKLOG #1180 (ASVS 12.3.4). resolve_trust_anchor reached mllp, dicom and remotefile and nothing else, so an operator who set [tls].internal_ca_file had it honoured on some hops and silently ignored on every https one. This makes the anchor speakable there. It does not flip a default and it does not settle the posture question.

The measurement, and its positive control

Re-measured by execution at HEAD a2eef0f37, because this item's research is dated 2026-08-20 and a probe that finds nothing everywhere is indistinguishable from a clean repo.

module anchor imported in source context builder policy plumbing
transports/rest.py False False False False
transports/soap.py False False False False
transports/fhir.py False False False False
transports/dicomweb.py False False False False
transports/mllp.py True True True True (control)
transports/dicom.py True True True True (control)
transports/remotefile.py True True True True (control)

The three controls fire on all four probes, so the probe can see the thing.

For the Vault hops, a recording fake hvac was installed and the real factories called. Both _build_client sites passed exactly {url, token, allow_redirects} and no CA-bearing keyword — and the recorder is demonstrably working, because it saw those three. Peer PR 760/#1317's assert_hvac_tls_suites is not on main.

Both stated shortfalls reproduce.

What was wired

One construction point: build_anchored_https_handler in config/tls_policy.py now builds every HTTP-family https handler, so the call sites cannot drift.

  • no CA (system / loopback / nothing configured) — build_asserted_https_handler verbatim: urllib's OWN context, asserted in place.
  • augment — urllib's own context with the internal CA loaded into it. Adding a root needs no new context, so nothing is rebuilt and nothing is replayed.
  • pinned (and a per-connection CA, the same shape) — the one arm that substitutes a context, because it must trust ONLY the internal CA and an SSLContext cannot unload roots urllib has already loaded. That arm replays urllib's two measured deltas (set_alpn_protocols(["http/1.1"]), post_handshake_auth) so pinning a hop does not silently change its handshake.

Reached by RestDestination, SoapDestination (including the mutual-TLS opener, which builds its own context), FhirDestination, DicomWebDestination, and the fhir_lookup executor's per-connection opener map. That last has no Destination to carry the policy, so RegistryRunner threads it in explicitly at both the live and build_check sites.

Both hvac hops take MEFOR_STORE_VAULT_CA_FILE / MEFOR_SECRETS_VAULT_CA_FILE, resolved through the same resolve_trust_anchor and mapped onto requests' single verify= bundle path. augment is refused there rather than silently narrowed — one path cannot say "public roots plus this CA".

Note the direction on that hop: hvac rides requests, whose default bundle is the PUBLIC certifi roots, not the OS store. An internal-CA Vault failed closed rather than being broadly trusted, so what was missing was the ability to reach one at all. That corrects this item's severity clause, as the 2026-08-20 research already noted.

How the default is proved unchanged

Not asserted about — asserted.

  • A stock https destination is still handed the shared _NO_REDIRECT_OPENER by object identity, including under a [tls] block in system mode and under a pinned policy on a loopback host.
  • An unanchored opener's context still matches urllib's own on post-handshake auth, cipher list, verify mode, hostname checking and minimum version.
  • Both hvac clients are constructed with exactly {url, token, allow_redirects} when no CA is configured — the keyword is omitted, not passed as an explicit default.
  • A hop takes a per-connection opener only when it carries an extra handler or an anchor that narrows.

Mutation proof

Twelve mutations, each applied and restored in turn, full suite re-run each time. All twelve go red, and the baseline and restored runs are green.

mutation result
M1 central helper ignores the anchor RED
M2 augment arm drops the extra root RED
M3 REST destination drops the anchor RED
M4 SOAP mTLS opener ignores the anchor RED
M5 fhir_lookup executor ignores the policy RED
M6 expiry-relaxed opener ignores the anchor RED
M7 shared vault helper drops the CA argument RED
M8 requests augment silently narrows instead of refusing RED
M9 store vault CA env var is never read RED
M10 secrets vault CA env var is never read RED
M11 NEGATIVE CONTROL: unanchored hops start substituting a context RED
M12 NEGATIVE CONTROL: every hop takes a per-connection opener RED

M11 and M12 are the ones that matter for the negative claim: they break the default, and the default assertions catch them.

Checks run

Locally, on this branch:

  • ruff check and ruff format --check over messagefoundry and tests — clean.
  • mypy messagefoundry strict — clean, 267 files.
  • pytest: test_tls_trust_anchor, test_tls_cipher_assertion_sites, test_rest_transport, test_soap_transport, test_soap_wssecurity, test_soap_body_secrets, test_fhir_transport, test_fhir_lookup, test_dicomweb, test_keyprovider_vault, test_keyprovider, test_secretprovider, test_smart_backend, test_outbound_forward_proxy, test_ech_egress, test_hop_refusal_http, test_hop_refusal_revocation, test_hop_refusal_wiring, test_connection_tls_loosenings, test_connection_schema, test_egress_allowlist, test_api_tls, test_mllp_tls, test_alert_smtp_tls, test_harness_tls_anchor — all green.
  • scripts/docs/backlog_status_check.py — OK, 664 items, each declaring exactly one status.

Not run locally, please read on CI: the full suite (the box is contended; a full local run reached 4 percent in 18 minutes when measured), the SQL Server and Postgres store legs, the load leg, the IDE leg, and windows-service-smoke. The store legs are worth a look — store/keyprovider_vault.py gained a module-scope import of config/tls_policy (stdlib-only, and config/settings.py already imports it at module scope, so it is warm before this module loads by name).

One pre-existing test needed a one-line fix

tests/test_soap_wssecurity.py::test_client_cert_opener_loads_chain_and_floors_tls monkeypatched ssl.create_default_context with a zero-argument lambda. The SOAP mTLS opener now delegates to build_verifying_client_context, which passes the ssl.Purpose positionally the way the stdlib is called everywhere else. The stub now takes the real signature's arguments. No assertion was weakened.

Measured and deliberately NOT done

Named so nobody reads this PR as the limb closed.

  1. The tls_ca_file parameter on Rest(), Soap(), FHIR(), DICOMweb() and FhirLookup(). The code reads that key, precedence rule 1 of resolve_trust_anchor honours it, and no authoring surface can set it — exactly what this item's own factory probe found for Ftp(). The instance [tls] anchor is what this pass made expressible; the connection-scoped half is a separate signature change that flows into the introspected GUI schema.
  2. The SMART token-endpoint hop (transports/smart.py), built from inside the very FhirDestination.__init__ this pass changes. In pinned mode a FHIR destination now anchors its data hop while the hop carrying its client_assertion still verifies against the OS store. It is not a one-line thread-through: the anchor must resolve for the TOKEN host, which has its own loopback and proxy decisions (ADR 0126 already splits it), so it needs the policy passed into token_provider_from_settings, not the destination's resolved anchor.
  3. The alert webhook opener in pipeline/alert_sinks.py, whose notifier_from_settings already receives a trust_anchor_policy and threads it to EmailTransport only.
  4. [tls].internal_ca_file reaching the two Vault hops. Neither provider has that section in scope — they hold a StoreSettings / SecretsSettings and build from the environment — so it needs threading through resolve_key_provider / resolve_secret_provider. That is the "new settings" this item's research already named. The env vars are the honest minimum today, and vault_client_verify_kwargs says so in its docstring rather than claiming more.
  5. A per-anchor context cache. With an internal CA set, each anchored lane builds its own SSLContext at construction, and build_check builds a second it discards. Startup-only, and caching a mutable SSLContext across connectors is its own hazard, so it was left alone rather than done quietly.

Out of scope, untouched

  • The pinned default was not shipped. trust_anchor_mode still defaults to "system"; the pinned-without-a-CA validator is untouched.
  • The refuse-versus-fall-back posture decision is not built. Recommendation below, and it is only that.
  • No declared-internal-PKI attestation was added.
  • No argument anywhere that a hospital's OS trust store is the enterprise CA.

The trap this PR fires by construction — please read before re-scoring

This item records that cell 12.3.4's own re-score trigger watches for the anchor resolver or a CA parameter appearing on these HTTP factories. This change fires that trigger deliberately. Expressibility is not a pass. Re-scoring 12.3.4 on this alone, while every default is still permissive, would be wiring an existing toolkit into the pipeline so an absence claim stops firing — which the record already rules out. The BACKLOG note says the same thing in the same words, and the status banner is not flipped.

Recommendation on the posture ruling (a recommendation only — this is an owner call)

Building this changed my view of the question, so it is worth recording.

Do not refuse an anchor-absent internal hop. The shipped ladder ALLOWs a verified-and-authenticated hop by written decision (forward_hop_disposition's first branch, with the in-code comment "an encrypted+authenticated hop, nothing to gate"), and refusing an unnarrowed but verified hop inverts that. That is a new policy, not an extension of an existing one.

Report it instead. The cheaper and more honest control is the public-chain probe this item's research already identifies, and the work here makes it reachable on almost every hop it covers: validate the peer chain against a public-only bundle. If it validates, the verb's condition demonstrably does not obtain. If it does not, an internal or self-signed certificate is demonstrably in use and a specific anchor is required. That is a measurable predicate, and it needs no operator attestation, which matters because checks.py:1529 already records the pressure a blocking declaration creates.

One thing the build makes concrete for whoever takes the ruling: augment does not satisfy the word ONLY. It keeps the public roots, so a hop in augment mode is not narrowed in the verb's sense. This PR refuses augment on the two requests-based hops because it is not expressible there, but on the urllib hops it is accepted and it does not meet the verb. That disposition is on this item's proposed-work list and is still owed.

wshallwshall added 3 commits September 4, 2026 18:33
…ernal CA (BACKLOG #1180)

resolve_trust_anchor reached mllp, dicom and remotefile and nothing else. The
whole HTTP egress family exposed only a verify_tls boolean, and both
hvac.Client sites passed no CA argument at all, so an operator who set
[tls].internal_ca_file had it honoured on some hops and silently ignored on
every https one. This is the expressibility limb of ASVS 12.3.4: the anchor
could not be spoken there, not merely that it ships off.

Threaded through one construction point. build_anchored_https_handler in
config/tls_policy.py is the only place an HTTP-family https handler is now
built, so the five call sites cannot drift.

  - system / loopback / no internal CA -> build_asserted_https_handler
    verbatim: urllib's OWN context, asserted in place, unchanged.
  - augment -> urllib's own context with the internal CA loaded into it.
    Adding a root needs no new context, so nothing is rebuilt or replayed.
  - pinned (and a per-connection CA, the same shape) -> the one arm that
    substitutes a context, because it must trust ONLY the internal CA and an
    SSLContext cannot unload roots urllib already loaded. It replays urllib's
    two deltas over create_default_context so pinning a hop does not silently
    drop its ALPN advertisement and post-handshake auth.

Reached by RestDestination, SoapDestination (including the mutual-TLS opener,
which builds its own context), FhirDestination, DicomWebDestination and the
fhir_lookup executor's per-connection opener map. The lookup executor has no
Destination to carry the policy, so the runner threads it in explicitly at
both the live and build-check construction sites.

The Vault hops take MEFOR_STORE_VAULT_CA_FILE and MEFOR_SECRETS_VAULT_CA_FILE,
resolved through the same resolve_trust_anchor and mapped to requests' single
verify= bundle path. augment is REFUSED there rather than silently narrowed:
one path cannot say "public roots plus this CA". The keyword is omitted when
nothing is configured, so the stock client is constructed exactly as before.

Note the direction on that hop: hvac rides requests, whose default bundle is
the PUBLIC certifi roots, not the OS store. An internal-CA Vault failed closed
rather than being broadly trusted, so what was missing was the ability to
reach such a Vault at all.

Nothing changes for an operator who configures nothing. A stock https
destination is still handed the shared _NO_REDIRECT_OPENER by identity; a hop
takes a per-connection opener only when it carries an extra handler or an
anchor that narrows.

Twelve mutations were run against the new assertions and all twelve go red,
including two negative controls: one that makes an unanchored hop substitute a
context, and one that gives every hop a per-connection opener.
The connected-systems table said the two hvac hops carry "hvac/requests' own
default -- the engine sets no explicit client TLS options here". That is no
longer true when MEFOR_STORE_VAULT_CA_FILE or MEFOR_SECRETS_VAULT_CA_FILE is
set, and a security table asserting the engine sets nothing where it now sets
an anchor is the stale-doc shape section 11 forbids.

Both rows now say what the default is (requests' PUBLIC bundle, not the OS
store) and name the env var that narrows it. The secrets-provider section of
CONFIGURATION.md gains the same pointer beside the address and token it
belongs with.
…still open

Appends a progress note to #1180: what was re-measured (and with which
positive control), what was wired, the two of the item's own claims the work
corrects, and the five things measured and deliberately not done.

The status banner is NOT flipped. This item closes by a vault scorecard
re-score plus a lander flip, neither of which a builder may do, and the note
says in terms that expressibility is not a pass: the cell's own re-score
trigger fires on exactly this change, so re-scoring on it while every default
is still permissive is the move the record already rules out.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Manager note. No reviewed label: the owner retired the requirement and the context was removed from branch protection at about 23:31Z. The workflow still runs, so expect a red check named a reviewer has read this on this pull request. It blocks nothing. Do not chase it.

Read this for, named rather than left to be found:

  • It is BEHIND main. Full suite, database legs, load, IDE and windows-service-smoke were all skipped on a contended box.
  • This pull request fires cell 12.3.4's own re-score trigger by construction. Expressibility is not a pass. Re-scoring on it while every default is still permissive is the move the record rules out, and the Builder said so in both the body and the ledger note rather than leaving it to be discovered.
  • augment does not satisfy the requirement's word ONLY. That disposition is still owed.
  • The item's severity clause overstated the Vault hop: requests defaults to the certifi bundle, not the OS store, so an internal-CA Vault failed closed. What was missing was reaching one at all.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant