From 9ec4e0bea5e4173cb335ff3214174c4433b625db Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 10 Oct 2026 13:26:20 +0200 Subject: [PATCH] Keep ambient tokens out of the service-JWT OpenBao login The anonymous JWT login inherited BAO_TOKEN/VAULT_TOKEN and the bao CLI token helper, so it presented an env/operator token alongside the JWT. Run that login with no env token and a token helper that returns none, proven against the real bao CLI. This closes secrets-engine's part of the CCR-2026-0038 no-fallback check. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 544698@bnt-lap001 Assistant-Session: 279b8520-335f-4041-ba2a-9491335473db --- .../2026-10-10-service-jwt-no-fallback.json | 22 ++++++ src/secrets_engine/openbao.py | 73 ++++++++++++++----- tests/test_engine_auth.py | 28 ++++++- ...-0008-layer-model-lifecycle-conformance.md | 8 ++ 4 files changed, 112 insertions(+), 19 deletions(-) create mode 100644 docs/evidence/2026-10-10-service-jwt-no-fallback.json diff --git a/docs/evidence/2026-10-10-service-jwt-no-fallback.json b/docs/evidence/2026-10-10-service-jwt-no-fallback.json new file mode 100644 index 0000000..9edc9b7 --- /dev/null +++ b/docs/evidence/2026-10-10-service-jwt-no-fallback.json @@ -0,0 +1,22 @@ +{ + "status": "passed", + "check": "CCR-2026-0038 negative: a failed login never falls back to bootstrap/env providers", + "design_ref": "railiance-platform docs/credential-lane-designs/secrets-engine-service-jwt.md (separately prove a failed login never consults bootstrap/env providers)", + "observed_at": "2026-10-10", + "scope": "secrets-engine consumer side; OpenBao claim refusals stay with railiance-platform and key-cape (KEY-WP-0039)", + "finding_fixed": "Before this change the anonymous JWT login inherited BAO_TOKEN/VAULT_TOKEN from the parent environment, and the CLI token helper, and sent that token as X-Vault-Token alongside the JWT. The founder probe on 2026-10-10 was clean only because its command unset those variables.", + "proofs": [ + {"kind": "unit", "test": "tests/test_engine_auth.py::test_service_jwt_refuses_bootstrap_file_and_does_not_read_env", "result": "passed", "shows": "selection refuses a bootstrap file with service-jwt"}, + {"kind": "unit", "test": "tests/test_engine_auth.py::test_jwt_login_failure_does_not_fall_back_to_env", "result": "passed", "shows": "an exchange failure raises without resolving an env/bootstrap client"}, + {"kind": "unit", "test": "tests/test_engine_auth.py::test_login_jwt_presents_no_ambient_env_or_helper_token", "result": "passed", "shows": "the login subprocess has no BAO_TOKEN/VAULT_TOKEN and a token helper that yields none"}, + {"kind": "real-cli", "binary": "OpenBao v2.5.5 bao CLI", "target": "local loopback listener recording X-Vault-Token", "result": "passed", + "observations": { + "env_token_previous_path_sent": true, + "env_token_login_jwt_sent": false, + "helper_token_previous_path_sent": true, + "helper_token_login_jwt_sent": false + }, + "note": "Sentinel strings only; no real token, JWT or OpenBao server was used."} + ], + "full_suite": "passed" +} diff --git a/src/secrets_engine/openbao.py b/src/secrets_engine/openbao.py index 3759300..c82ff66 100644 --- a/src/secrets_engine/openbao.py +++ b/src/secrets_engine/openbao.py @@ -115,6 +115,28 @@ class ScopedTokenSession: self.closed = True +@contextmanager +def _unchanged(env: dict[str, str]): + yield env + + +@contextmanager +def _no_ambient_token(env: dict[str, str]): + """Strip env tokens and point the CLI at a token helper that returns none.""" + helper = shutil.which("true") + if not helper: + raise BackendError("cannot disable the bao token helper; refusing login") + run_env = { + k: v for k, v in env.items() if k not in ("BAO_TOKEN", "VAULT_TOKEN") + } + with tempfile.TemporaryDirectory(prefix="se-bao-anon-") as tmp: + config = Path(tmp) / "cli.hcl" + config.write_text(f'token_helper = "{helper}"\n', encoding="utf-8") + run_env["BAO_CONFIG_PATH"] = str(config) + run_env["VAULT_CONFIG_PATH"] = str(config) + yield run_env + + @dataclass class OpenBaoClient: addr: str @@ -135,7 +157,9 @@ class OpenBaoClient: # -- low level --------------------------------------------------------- - def _run(self, args: list[str], *, stdin: str | None = None) -> subprocess.CompletedProcess: + def _run( + self, args: list[str], *, stdin: str | None = None, anonymous: bool = False + ) -> subprocess.CompletedProcess: if not self.bao_bin: raise BackendError( "no 'bao' (or 'vault') CLI on PATH; cannot reach OpenBao backend" @@ -146,22 +170,25 @@ class OpenBaoClient: if self.token: env["BAO_TOKEN"] = self.token env["VAULT_TOKEN"] = self.token - try: - return subprocess.run( - [self.bao_bin, *args], - input=stdin, - env=env, - capture_output=True, - text=True, - timeout=30, - ) - except FileNotFoundError as e: - raise BackendError(f"backend binary not runnable: {e}") from e - except subprocess.TimeoutExpired as e: - raise BackendError(f"backend call timed out: {' '.join(args)}") from e + with _no_ambient_token(env) if anonymous else _unchanged(env) as run_env: + try: + return subprocess.run( + [self.bao_bin, *args], + input=stdin, + env=run_env, + capture_output=True, + text=True, + timeout=30, + ) + except FileNotFoundError as e: + raise BackendError(f"backend binary not runnable: {e}") from e + except subprocess.TimeoutExpired as e: + raise BackendError(f"backend call timed out: {' '.join(args)}") from e - def _run_ok(self, args: list[str], *, stdin: str | None = None) -> str: - proc = self._run(args, stdin=stdin) + def _run_ok( + self, args: list[str], *, stdin: str | None = None, anonymous: bool = False + ) -> str: + proc = self._run(args, stdin=stdin, anonymous=anonymous) if proc.returncode != 0: # stderr from bao does not contain the secret value for these calls. raise BackendError( @@ -170,7 +197,9 @@ class OpenBaoClient: ) return proc.stdout - def _run_ok_with_json_file(self, args: list[str], payload: dict) -> str: + def _run_ok_with_json_file( + self, args: list[str], payload: dict, *, anonymous: bool = False + ) -> str: """Run a CLI operation with JSON from a strict temporary file reference.""" fd, temp_name = tempfile.mkstemp(prefix="se-bao-input-", suffix=".json") temp_path = Path(temp_name) @@ -180,6 +209,8 @@ class OpenBaoClient: fd = -1 json.dump(payload, fh) fh.write("\n") + if anonymous: + return self._run_ok([*args, f"@{temp_path}"], anonymous=True) return self._run_ok([*args, f"@{temp_path}"]) finally: if fd >= 0: @@ -332,10 +363,16 @@ class OpenBaoClient: self._run_ok(["delete", f"auth/approle/role/{role_name}"]) def login_jwt(self, mount: str, role: str, jwt: str) -> ScopedTokenSession: - """Login through JWT auth without putting the JWT in argv.""" + """Login through JWT auth without putting the JWT in argv. + + The login carries no ambient token: neither BAO_TOKEN/VAULT_TOKEN nor a + CLI token helper is consulted, so a service-jwt login never presents an + env or bootstrap credential alongside the JWT. + """ output = self._run_ok_with_json_file( ["write", "-format=json", f"auth/{mount}/login"], {"role": role, "jwt": jwt}, + anonymous=True, ) try: auth = json.loads(output)["auth"] diff --git a/tests/test_engine_auth.py b/tests/test_engine_auth.py index 71f2535..364b092 100644 --- a/tests/test_engine_auth.py +++ b/tests/test_engine_auth.py @@ -105,7 +105,8 @@ def test_login_jwt_keeps_jwt_out_of_argv_and_revokes(monkeypatch): client = OpenBaoClient(addr="http://example.invalid", token="", bao_bin="bao") captured = {} - def fake_json_call(args, payload): + def fake_json_call(args, payload, *, anonymous=False): + captured["anonymous"] = anonymous captured["args"] = list(args) captured["payload"] = dict(payload) return '{"auth":{"client_token":"jwt-child-token","accessor":"jwt-accessor"}}' @@ -114,6 +115,7 @@ def test_login_jwt_keeps_jwt_out_of_argv_and_revokes(monkeypatch): session = client.login_jwt("jwt", "secrets-engine", "header.payload.sig") assert "header.payload.sig" not in " ".join(captured["args"]) assert captured["payload"]["jwt"] == "header.payload.sig" + assert captured["anonymous"] is True assert "jwt-accessor" not in session.accessor_fingerprint revoke = [] monkeypatch.setattr( @@ -151,3 +153,27 @@ def test_provision_jwt_auth_never_reaches_openbao_on_missing_contract( cfg = _cfg(tmp_path) with pytest.raises(BackendError, match="JWT mount/role contract is not published"): cli.cmd_provision(cfg, args) + + +def test_login_jwt_presents_no_ambient_env_or_helper_token(monkeypatch): + monkeypatch.setenv("BAO_TOKEN", "env-token-must-not-be-sent") + monkeypatch.setenv("VAULT_TOKEN", "env-token-must-not-be-sent") + seen = {} + + def fake_run(argv, **kwargs): + env = kwargs["env"] + seen["env"] = dict(env) + seen["helper_config"] = open(env["BAO_CONFIG_PATH"], encoding="utf-8").read() + return SimpleNamespace( + returncode=0, + stdout='{"auth":{"client_token":"jwt-child-token","accessor":"jwt-accessor"}}', + stderr="", + ) + + monkeypatch.setattr("secrets_engine.openbao.subprocess.run", fake_run) + client = OpenBaoClient(addr="http://example.invalid", token="", bao_bin="bao") + client.login_jwt("jwt", "secrets-engine", "header.payload.sig") + assert "BAO_TOKEN" not in seen["env"] + assert "VAULT_TOKEN" not in seen["env"] + assert seen["env"]["VAULT_CONFIG_PATH"] == seen["env"]["BAO_CONFIG_PATH"] + assert seen["helper_config"].startswith('token_helper = "') diff --git a/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md b/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md index b0df15e..54cf493 100644 --- a/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md +++ b/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md @@ -337,6 +337,14 @@ priority: medium state_hub_task_id: "d7bc8bdc-a0f8-5058-a640-374ef9859148" ``` +Progress 2026-10-10. The CCR-2026-0038 no-fallback check, which is +secrets-engine's to prove, passed. Fixing it exposed a gap: the anonymous JWT +login inherited `BAO_TOKEN`/`VAULT_TOKEN` and the CLI token helper, so it sent an +ambient token alongside the JWT. `OpenBaoClient.login_jwt` now runs with no env +token and with a token helper that returns none. This is verified with the real +`bao` CLI against a header-recording listener. Evidence: +`docs/evidence/2026-10-10-service-jwt-no-fallback.json`. + Progress 2026-09-02. Named engine auth is shipped: `service-jwt`, `bootstrap`, and `env` are exclusive providers. `--auth service-jwt` exchanges the KeyCape service JWT and logs in through `OpenBaoClient.login_jwt` (JSON file, never