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 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 544698@bnt-lap001 Assistant-Session: 279b8520-335f-4041-ba2a-9491335473db
This commit is contained in:
parent
1732c419d2
commit
9ec4e0bea5
4 changed files with 112 additions and 19 deletions
22
docs/evidence/2026-10-10-service-jwt-no-fallback.json
Normal file
22
docs/evidence/2026-10-10-service-jwt-no-fallback.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -115,6 +115,28 @@ class ScopedTokenSession:
|
||||||
self.closed = True
|
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
|
@dataclass
|
||||||
class OpenBaoClient:
|
class OpenBaoClient:
|
||||||
addr: str
|
addr: str
|
||||||
|
|
@ -135,7 +157,9 @@ class OpenBaoClient:
|
||||||
|
|
||||||
# -- low level ---------------------------------------------------------
|
# -- 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:
|
if not self.bao_bin:
|
||||||
raise BackendError(
|
raise BackendError(
|
||||||
"no 'bao' (or 'vault') CLI on PATH; cannot reach OpenBao backend"
|
"no 'bao' (or 'vault') CLI on PATH; cannot reach OpenBao backend"
|
||||||
|
|
@ -146,22 +170,25 @@ class OpenBaoClient:
|
||||||
if self.token:
|
if self.token:
|
||||||
env["BAO_TOKEN"] = self.token
|
env["BAO_TOKEN"] = self.token
|
||||||
env["VAULT_TOKEN"] = self.token
|
env["VAULT_TOKEN"] = self.token
|
||||||
try:
|
with _no_ambient_token(env) if anonymous else _unchanged(env) as run_env:
|
||||||
return subprocess.run(
|
try:
|
||||||
[self.bao_bin, *args],
|
return subprocess.run(
|
||||||
input=stdin,
|
[self.bao_bin, *args],
|
||||||
env=env,
|
input=stdin,
|
||||||
capture_output=True,
|
env=run_env,
|
||||||
text=True,
|
capture_output=True,
|
||||||
timeout=30,
|
text=True,
|
||||||
)
|
timeout=30,
|
||||||
except FileNotFoundError as e:
|
)
|
||||||
raise BackendError(f"backend binary not runnable: {e}") from e
|
except FileNotFoundError as e:
|
||||||
except subprocess.TimeoutExpired as e:
|
raise BackendError(f"backend binary not runnable: {e}") from e
|
||||||
raise BackendError(f"backend call timed out: {' '.join(args)}") 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:
|
def _run_ok(
|
||||||
proc = self._run(args, stdin=stdin)
|
self, args: list[str], *, stdin: str | None = None, anonymous: bool = False
|
||||||
|
) -> str:
|
||||||
|
proc = self._run(args, stdin=stdin, anonymous=anonymous)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
# stderr from bao does not contain the secret value for these calls.
|
# stderr from bao does not contain the secret value for these calls.
|
||||||
raise BackendError(
|
raise BackendError(
|
||||||
|
|
@ -170,7 +197,9 @@ class OpenBaoClient:
|
||||||
)
|
)
|
||||||
return proc.stdout
|
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."""
|
"""Run a CLI operation with JSON from a strict temporary file reference."""
|
||||||
fd, temp_name = tempfile.mkstemp(prefix="se-bao-input-", suffix=".json")
|
fd, temp_name = tempfile.mkstemp(prefix="se-bao-input-", suffix=".json")
|
||||||
temp_path = Path(temp_name)
|
temp_path = Path(temp_name)
|
||||||
|
|
@ -180,6 +209,8 @@ class OpenBaoClient:
|
||||||
fd = -1
|
fd = -1
|
||||||
json.dump(payload, fh)
|
json.dump(payload, fh)
|
||||||
fh.write("\n")
|
fh.write("\n")
|
||||||
|
if anonymous:
|
||||||
|
return self._run_ok([*args, f"@{temp_path}"], anonymous=True)
|
||||||
return self._run_ok([*args, f"@{temp_path}"])
|
return self._run_ok([*args, f"@{temp_path}"])
|
||||||
finally:
|
finally:
|
||||||
if fd >= 0:
|
if fd >= 0:
|
||||||
|
|
@ -332,10 +363,16 @@ class OpenBaoClient:
|
||||||
self._run_ok(["delete", f"auth/approle/role/{role_name}"])
|
self._run_ok(["delete", f"auth/approle/role/{role_name}"])
|
||||||
|
|
||||||
def login_jwt(self, mount: str, role: str, jwt: str) -> ScopedTokenSession:
|
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(
|
output = self._run_ok_with_json_file(
|
||||||
["write", "-format=json", f"auth/{mount}/login"],
|
["write", "-format=json", f"auth/{mount}/login"],
|
||||||
{"role": role, "jwt": jwt},
|
{"role": role, "jwt": jwt},
|
||||||
|
anonymous=True,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
auth = json.loads(output)["auth"]
|
auth = json.loads(output)["auth"]
|
||||||
|
|
|
||||||
|
|
@ -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")
|
client = OpenBaoClient(addr="http://example.invalid", token="", bao_bin="bao")
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
def fake_json_call(args, payload):
|
def fake_json_call(args, payload, *, anonymous=False):
|
||||||
|
captured["anonymous"] = anonymous
|
||||||
captured["args"] = list(args)
|
captured["args"] = list(args)
|
||||||
captured["payload"] = dict(payload)
|
captured["payload"] = dict(payload)
|
||||||
return '{"auth":{"client_token":"jwt-child-token","accessor":"jwt-accessor"}}'
|
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")
|
session = client.login_jwt("jwt", "secrets-engine", "header.payload.sig")
|
||||||
assert "header.payload.sig" not in " ".join(captured["args"])
|
assert "header.payload.sig" not in " ".join(captured["args"])
|
||||||
assert captured["payload"]["jwt"] == "header.payload.sig"
|
assert captured["payload"]["jwt"] == "header.payload.sig"
|
||||||
|
assert captured["anonymous"] is True
|
||||||
assert "jwt-accessor" not in session.accessor_fingerprint
|
assert "jwt-accessor" not in session.accessor_fingerprint
|
||||||
revoke = []
|
revoke = []
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -151,3 +153,27 @@ def test_provision_jwt_auth_never_reaches_openbao_on_missing_contract(
|
||||||
cfg = _cfg(tmp_path)
|
cfg = _cfg(tmp_path)
|
||||||
with pytest.raises(BackendError, match="JWT mount/role contract is not published"):
|
with pytest.raises(BackendError, match="JWT mount/role contract is not published"):
|
||||||
cli.cmd_provision(cfg, args)
|
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 = "')
|
||||||
|
|
|
||||||
|
|
@ -337,6 +337,14 @@ priority: medium
|
||||||
state_hub_task_id: "d7bc8bdc-a0f8-5058-a640-374ef9859148"
|
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`,
|
Progress 2026-09-02. Named engine auth is shipped: `service-jwt`, `bootstrap`,
|
||||||
and `env` are exclusive providers. `--auth service-jwt` exchanges the KeyCape
|
and `env` are exclusive providers. `--auth service-jwt` exchanges the KeyCape
|
||||||
service JWT and logs in through `OpenBaoClient.login_jwt` (JSON file, never
|
service JWT and logs in through `OpenBaoClient.login_jwt` (JSON file, never
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue