diff --git a/docs/cli.md b/docs/cli.md index a9c76c7..ad3751b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -235,3 +235,12 @@ secrets-engine verify whynot-design-npm-publish --field npm_token --positive --n # 6. publish with the token injected into the child only secrets-engine exec --catalog whynot-design-npm-publish -- npm publish ``` + +Wrapped handoff files must be fresh and inside an owned private directory outside +Git. Existing files, symlinks and shared directories refuse before minting. A +failed or uncertain mint leaves its private reservation for owner reconciliation; +never delete it merely to retry. The backend must return the exact native KV-v2 +data/AppRole creation path, requested bounded TTL and an accessor. Handoff-file +creation verifies those native fields; a requested path alone is not evidence. +These checks do not enable production wrapping or the separate execute-ccr +metadata entitlement. diff --git a/docs/evidence/2026-10-12-wrapped-issuer-hardening/validation.json b/docs/evidence/2026-10-12-wrapped-issuer-hardening/validation.json new file mode 100644 index 0000000..cdb3b9b --- /dev/null +++ b/docs/evidence/2026-10-12-wrapped-issuer-hardening/validation.json @@ -0,0 +1,54 @@ +{ + "schema": "wrapped-issuer-source-validation/v1", + "task": "SECRETS-WP-0013-T01", + "captured_at": "2026-10-11T22:07:09.996760+00:00", + "source_sha256": { + "src/secrets_engine/openbao.py": "c1d17ca30367fd2c856927e8ce4021599a992c6c22509f82a6d8d4588ea46199", + "src/secrets_engine/wrap.py": "a37c491a93c8803015d2f1fca6ea502b8da28a9279900404d8f9ebf06a0c0a70", + "tests/test_wrap.py": "40b4511f6f5c2515e8ff70095e8239a4f589a97697fa1827298ec3be89ba57d4", + "tests/test_wrapped_issuer_native.py": "e24d86da4e9b0c971aa39c81f34c1bced76d3def1727ee38fe69a3769c80e5c1" + }, + "full_suite": { + "collected": 654, + "passed": 653, + "skipped": 1, + "exit": 0, + "execution": "isolated tracked-source Git checkout, real filesystem ownership, disposable local OpenBao" + }, + "final_wrap_and_native_tests": { + "passed": 24, + "exit": 0 + }, + "native_local_semantics": { + "creation_path": "secret/data/d3-wrap-fixture", + "ttl_seconds": 60, + "first_unwrap_success": true, + "consumed_token_replay": "specific native HTTP400 wrapping-token-invalid denial" + }, + "production_lane_proven": false, + "production_authority_enabled": false, + "raw_secret_values_in_receipt": false, + "negative_cases": [ + "changed/missing native path", + "wrong/missing/noninteger TTL", + "missing accessor", + "unwrapped material alongside wrap", + "overwrite", + "symlink leaf/ancestor", + "shared parent", + "uncertain mint remint", + "concurrent duplicate", + "reservation replacement" + ], + "preparation_failures": [ + { + "kind": "missing local clock module in isolated test collection", + "recovery": "explicit owner clock source dependency" + }, + { + "kind": "full sandbox suite had17 unrelated custody/path failures", + "recovery": "created actual isolated Git worktree and reran with real filesystem ownership; no custody guard relaxed" + } + ], + "pending": "Independent Platform/identity/approval/PDP review, separate exact actor/path/audience delivery entitlement and estate-native proof" +} diff --git a/docs/task-scoped-openbao-execution-contract.md b/docs/task-scoped-openbao-execution-contract.md index cfc9bb2..6128f2e 100644 --- a/docs/task-scoped-openbao-execution-contract.md +++ b/docs/task-scoped-openbao-execution-contract.md @@ -311,3 +311,24 @@ this change does not make them verified human representation. Existing generic consume receipt validation limitations remain explicitly separate. service-jwt readiness guard/default and ordinary actions remain unchanged. T01 stays in progress, T03 stays wait, WP13 stays proposed, and WP8-T06 stays open. + +## D3 wrapped-delivery primitive and its separate entitlement + +D3 P3=b chooses response-wrapped issuer delivery with one-time unwrap. The +existing wrapped-handoff primitive now verifies native TTL/accessor/creation-path +metadata and reserves a private immutable recipient file before minting. Native +KV-v2 creation paths include the `data/` endpoint; caller-requested CLI paths +cannot substitute for returned metadata. Failed mint/write or an altered +reservation retains an uncertain attempt and never silently overwrites/remints. + +A disposable local OpenBao integration proves the exact source path,60-second +TTL, successful first unwrap and specific HTTP400 consumed-token replay denial. +This proves backend semantics, not the estate's first delivered lane, audited +initiating principal, consumer identity, independent owner acceptance or cleanup +under crash/revocation. Production CLI wrapping remains fail-closed. Existing +metadata execute-ccr contracts expressly exclude wrap/KV value delivery and cannot +be borrowed for this purpose. A separate exact owner-admitted delivery operation +must bind actor,path,audience,deadline,authorization/consume state,destination, +audit,replay/crash recovery and rollback before native adoption. The prepared +D2 repository-issuer lane remains attended until its own reviewed execution and +independent destination/adoption proofs pass. diff --git a/src/secrets_engine/openbao.py b/src/secrets_engine/openbao.py index c82ff66..31e08a9 100644 --- a/src/secrets_engine/openbao.py +++ b/src/secrets_engine/openbao.py @@ -16,6 +16,7 @@ from __future__ import annotations import hashlib import json import os +import re import shutil import stat import subprocess @@ -400,23 +401,34 @@ class OpenBaoClient: self._run_ok(["lease", "revoke", lease_id]) def _parse_wrap_response(self, stdout: str, *, ttl: str, creation_path: str) -> WrappedResponse: + # Verify native wrapping metadata. Never reconstruct a purported source + # path or TTL from the caller when the backend omitted or changed it. try: payload = json.loads(stdout) - except json.JSONDecodeError as exc: - raise BackendError("wrapped response is not JSON") from exc + except (json.JSONDecodeError, TypeError): + raise BackendError("wrapped response is not JSON") from None if not isinstance(payload, dict): raise BackendError("wrapped response is invalid") wrap = payload.get("wrap_info") if not isinstance(wrap, dict) or not wrap.get("token"): raise BackendError("response wrapping was not applied") - token = str(wrap["token"]) - accessor = str(wrap.get("accessor") or "") - wrap_ttl = wrap.get("ttl") + if payload.get("data") or payload.get("auth"): + raise BackendError("wrapped response unexpectedly included unwrapped material") + match = re.fullmatch(r"([1-9][0-9]*)([smh])", ttl) + seconds = int(match[1]) * {"s": 1, "m": 60, "h": 3600}[match[2]] if match else 0 + if not 0 < seconds <= 900: + raise BackendError("wrap TTL is outside the supported bound") + token, accessor = wrap.get("token"), wrap.get("accessor") + if (not isinstance(token, str) or not token or any(ch.isspace() for ch in token) + or not isinstance(accessor, str) or not accessor + or type(wrap.get("ttl")) is not int or wrap["ttl"] != seconds + or wrap.get("creation_path") != creation_path): + raise BackendError("native wrap identity, TTL or creation path unverified") return WrappedResponse( wrap_token=token, - accessor_fingerprint=accessor_fingerprint(accessor) if accessor else "", - ttl=str(wrap_ttl if wrap_ttl is not None else ttl), - creation_path=creation_path, + accessor_fingerprint=accessor_fingerprint(accessor), + ttl=str(seconds), + creation_path=wrap["creation_path"], ) def wrap_kv_get(self, mount: str, path: str, *, ttl: str) -> WrappedResponse: @@ -425,7 +437,7 @@ class OpenBaoClient: proc = self._run(["kv", "get", f"-wrap-ttl={ttl}", "-format=json", target]) if proc.returncode != 0: raise BackendError("wrapped KV read failed") - return self._parse_wrap_response(proc.stdout, ttl=ttl, creation_path=target) + return self._parse_wrap_response(proc.stdout, ttl=ttl, creation_path=f"{mount}/data/{path}") def wrap_approle_secret_id(self, role_name: str, *, ttl: str) -> WrappedResponse: """Wrap a single-use AppRole secret_id. Fail if returned unwrapped.""" diff --git a/src/secrets_engine/wrap.py b/src/secrets_engine/wrap.py index 9b72c02..1e4f2e7 100644 --- a/src/secrets_engine/wrap.py +++ b/src/secrets_engine/wrap.py @@ -7,6 +7,7 @@ from __future__ import annotations import os import re +import stat from dataclasses import dataclass from pathlib import Path @@ -43,33 +44,48 @@ def normalize_wrap_ttl(value: str) -> str: def _validate_output_path(path: Path) -> Path: - resolved = path.expanduser().resolve() - worktree = containing_git_worktree(resolved) + # Do not follow a leaf/parent symlink into another recipient's private file. + absolute = Path(os.path.abspath(path.expanduser())) + if absolute.resolve() != absolute or absolute.is_symlink(): + raise ProvisioningError("wrap output cannot use redirected paths") + worktree = containing_git_worktree(absolute) if worktree is not None: - raise ProvisioningError( - f"wrap file {resolved} is inside a Git worktree ({worktree}); " - "keep wrap tokens outside repos" - ) - if resolved.exists() and resolved.stat().st_mode & 0o077: - raise ProvisioningError( - f"wrap file {resolved} is group/other-accessible " - f"(mode {oct(resolved.stat().st_mode & 0o777)}); must be 0600" - ) - return resolved + raise ProvisioningError("wrap output is inside a Git worktree; keep tokens outside repos") + if os.path.lexists(absolute): + raise ProvisioningError("wrap output already exists; reconcile the prior attempt, never overwrite or remint") + return absolute -def _write_mode_0600(path: Path, value: str) -> None: +def _reserve_mode_0600(path: Path) -> int: path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - fd: int | None = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + directory_fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY) try: - os.fchmod(fd, 0o600) - with os.fdopen(fd, "w", encoding="utf-8") as fh: - fd = None - fh.write(value) - fh.write("\n") + # Walk using directory descriptors so a concurrent symlink swap cannot + # redirect either an ancestor or the final file creation. + for component in path.parent.parts[1:]: + next_fd = os.open(component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=directory_fd) + os.close(directory_fd) + directory_fd = next_fd + parent = os.fstat(directory_fd) + if parent.st_uid != os.geteuid() or stat.S_IMODE(parent.st_mode) & 0o077: + raise ProvisioningError("wrap output parent must be owned and private") + # Reserve before the backend call. Failure/crash leaves this private + # marker for reconciliation, never automatic remint. + file_fd = os.open(path.name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=directory_fd) + try: + os.fchmod(file_fd, 0o600) + os.fsync(file_fd) + os.fsync(directory_fd) + except BaseException: + os.close(file_fd) + raise + return file_fd + except OSError: + raise ProvisioningError("wrap output reservation failed; no backend retry") from None finally: - if fd is not None: - os.close(fd) + os.close(directory_fd) def write_wrapped_handoff( @@ -82,20 +98,26 @@ def write_wrapped_handoff( """Mint a wrap token and write it without printing it.""" ttl = normalize_wrap_ttl(ttl) out_path = _validate_output_path(out_file) - wrapped: WrappedResponse - if entry.kind == "auth-capability": - wrapped = client.wrap_approle_secret_id(entry.role_name, ttl=ttl) - elif entry.stores_kv_value(): - wrapped = client.wrap_kv_get(entry.mount, entry.path, ttl=ttl) - else: - raise ProvisioningError( - f"lane '{entry.id}' kind {entry.kind} has no wrapped handoff" - ) - token = wrapped.wrap_token + if entry.kind != "auth-capability" and not entry.stores_kv_value(): + raise ProvisioningError(f"lane '{entry.id}' kind {entry.kind} has no wrapped handoff") + fd = _reserve_mode_0600(out_path) try: - _write_mode_0600(out_path, token) + if entry.kind == "auth-capability": + wrapped: WrappedResponse = client.wrap_approle_secret_id(entry.role_name, ttl=ttl) + else: + wrapped = client.wrap_kv_get(entry.mount, entry.path, ttl=ttl) + visible, reserved = out_path.lstat(), os.fstat(fd) + if (not stat.S_ISREG(visible.st_mode) + or (visible.st_dev, visible.st_ino) != (reserved.st_dev, reserved.st_ino)): + raise ProvisioningError("wrap output reservation changed; retain and reconcile the mint") + with os.fdopen(fd, "w", encoding="utf-8") as handle: + fd = None + handle.write(wrapped.wrap_token + "\n") + handle.flush() + os.fsync(handle.fileno()) finally: - token = "" + if fd is not None: + os.close(fd) return WrapHandoffResult( catalog_id=entry.id, kind=entry.kind, diff --git a/tests/test_wrap.py b/tests/test_wrap.py index 670eae6..f25d4d8 100644 --- a/tests/test_wrap.py +++ b/tests/test_wrap.py @@ -65,13 +65,17 @@ def test_wrap_kv_writes_0600_file_and_omits_token_from_result(tmp_path): assert WRAP_ACCESSOR not in result.wrap_handle -def test_wrap_rejects_repo_paths(): +def test_wrap_rejects_repo_paths(tmp_path): + import subprocess + repository = tmp_path / "repository" + repository.mkdir() + subprocess.run(["git", "init", str(repository)], check=True, capture_output=True) entry = validate_entry(copy.deepcopy(VALID)) with pytest.raises(ProvisioningError, match="Git worktree"): write_wrapped_handoff( FakeWrapClient(), entry, - out_file=__import__("pathlib").Path("wrap.token"), + out_file=repository / "wrap.token", ttl="15m", ) @@ -96,6 +100,7 @@ def test_parse_wrap_accepts_wrap_info_only(): "token": WRAP_TOKEN, "accessor": WRAP_ACCESSOR, "ttl": 900, + "creation_path": "secret/x", } } ) @@ -116,7 +121,7 @@ def test_wrap_kv_get_uses_wrap_ttl_flag(monkeypatch): return SimpleNamespace( returncode=0, stdout=json.dumps( - {"wrap_info": {"token": WRAP_TOKEN, "accessor": WRAP_ACCESSOR}} + {"wrap_info": {"token": WRAP_TOKEN, "accessor": WRAP_ACCESSOR, "ttl": 900, "creation_path": "secret/data/test/team/thing"}} ), stderr="", ) @@ -158,3 +163,93 @@ def test_production_wrap_fails_closed(tmp_path, monkeypatch): ) with pytest.raises(DecisionError, match="production action 'wrap'"): cli.cmd_wrap(cfg, args) + +@pytest.mark.parametrize('change', [ + {'creation_path':'secret/data/other-recipient'}, {'creation_path':None}, + {'ttl':901}, {'ttl':60}, {'ttl':True}, {'ttl':None}, + {'token':42}, {'token':'value with whitespace'}, {'accessor':None}, +]) +def test_native_wrap_binding_cannot_be_inferred_from_request(change): + client=OpenBaoClient(addr='http://example.invalid',token='t',bao_bin='bao') + value={'token':WRAP_TOKEN,'accessor':WRAP_ACCESSOR,'ttl':900,'creation_path':'secret/data/test/team/thing'} + value.update(change) + with pytest.raises(BackendError) as raised: + client._parse_wrap_response(json.dumps({'wrap_info':value}),ttl='15m',creation_path='secret/data/test/team/thing') + assert WRAP_TOKEN not in str(raised.value) + + +def test_valid_wrap_info_cannot_mask_unwrapped_payload(): + client=OpenBaoClient(addr='http://example.invalid',token='t',bao_bin='bao') + value={'wrap_info':{'token':WRAP_TOKEN,'accessor':WRAP_ACCESSOR,'ttl':900,'creation_path':'secret/x'},'data':{'secret':'NEVER-LOG-ME'}} + with pytest.raises(BackendError) as raised: + client._parse_wrap_response(json.dumps(value),ttl='15m',creation_path='secret/x') + assert 'NEVER-LOG-ME' not in str(raised.value) + + +def test_existing_output_prevents_mint_and_preserves_original(tmp_path): + out=tmp_path/'wrap.token';out.write_text('previous trial');out.chmod(0o600) + client=FakeWrapClient() + with pytest.raises(ProvisioningError,match='already exists'): + write_wrapped_handoff(client,validate_entry(copy.deepcopy(VALID)),out_file=out,ttl='60s') + assert client.calls==[] and out.read_text()=='previous trial' + + +def test_leaf_and_parent_symlink_cannot_redirect_handoff(tmp_path): + from pathlib import Path + target=tmp_path/'retained';target.write_text('private existing');target.chmod(0o600) + out=tmp_path/'wrap.token';out.symlink_to(target) + client=FakeWrapClient();entry=validate_entry(copy.deepcopy(VALID)) + with pytest.raises(ProvisioningError):write_wrapped_handoff(client,entry,out_file=out,ttl='60s') + folder=tmp_path/'private';folder.mkdir(mode=0o700) + parent=tmp_path/'redirect';parent.symlink_to(folder,target_is_directory=True) + with pytest.raises(ProvisioningError):write_wrapped_handoff(client,entry,out_file=parent/'wrap.token',ttl='60s') + assert client.calls==[] and target.read_text()=='private existing' + + +def test_backend_failure_retains_private_reservation_and_prevents_remint(tmp_path): + class Failed(FakeWrapClient): + def wrap_kv_get(self,*args,**kwargs): + self.calls.append('attempt');raise BackendError('unverified native mint') + client=Failed();out=tmp_path/'wrap.token';entry=validate_entry(copy.deepcopy(VALID)) + with pytest.raises(BackendError):write_wrapped_handoff(client,entry,out_file=out,ttl='60s') + assert out.exists() and out.read_bytes()==b'' and out.stat().st_mode&0o077==0 + with pytest.raises(ProvisioningError):write_wrapped_handoff(client,entry,out_file=out,ttl='60s') + assert client.calls==['attempt'] + + +def test_shared_output_directory_refuses_before_backend(tmp_path): + parent=tmp_path/'shared';parent.mkdir(mode=0o755);client=FakeWrapClient() + with pytest.raises(ProvisioningError,match='owned and private'): + write_wrapped_handoff(client,validate_entry(copy.deepcopy(VALID)),out_file=parent/'wrap.token',ttl='60s') + assert client.calls==[] + + +def test_concurrent_handoff_reserves_before_only_one_backend_mint(tmp_path): + from concurrent.futures import ThreadPoolExecutor + import threading + entered,release=threading.Event(),threading.Event() + class Paused(FakeWrapClient): + def wrap_kv_get(self,*args,**kwargs): + entered.set();assert release.wait(5) + return super().wrap_kv_get(*args,**kwargs) + client=Paused();entry=validate_entry(copy.deepcopy(VALID));out=tmp_path/'wrap.token' + with ThreadPoolExecutor(max_workers=2) as pool: + first=pool.submit(write_wrapped_handoff,client,entry,out_file=out,ttl='60s') + assert entered.wait(5) + try: + with pytest.raises(ProvisioningError):write_wrapped_handoff(client,entry,out_file=out,ttl='60s') + finally:release.set() + first.result() + assert len(client.calls)==1 and out.read_text().strip()==WRAP_TOKEN + + +def test_output_replacement_during_backend_call_does_not_overwrite_other_recipient(tmp_path): + out=tmp_path/'wrap.token';other=tmp_path/'other';other.write_text('retained recipient') + class Replaced(FakeWrapClient): + def wrap_kv_get(self,*args,**kwargs): + out.unlink();out.symlink_to(other) + return super().wrap_kv_get(*args,**kwargs) + client=Replaced() + with pytest.raises(ProvisioningError,match='reservation changed'): + write_wrapped_handoff(client,validate_entry(copy.deepcopy(VALID)),out_file=out,ttl='60s') + assert other.read_text()=='retained recipient' and len(client.calls)==1 diff --git a/tests/test_wrapped_issuer_native.py b/tests/test_wrapped_issuer_native.py new file mode 100644 index 0000000..e28e0f9 --- /dev/null +++ b/tests/test_wrapped_issuer_native.py @@ -0,0 +1,24 @@ +"""Real wrapping semantics in a disposable local OpenBao, not estate authority.""" +import json +import re + +from secrets_engine.openbao import OpenBaoClient +from tests.test_integration_bao import bao_dev, pytestmark # noqa: F401 + + +def test_native_wrap_path_ttl_and_one_time_unwrap(bao_dev): + written=bao_dev._run(['kv','put','-format=json','secret/d3-wrap-fixture', + 'marker=synthetic-wrapped-integration-value']) + assert written.returncode==0 + wrapped=bao_dev.wrap_kv_get('secret','d3-wrap-fixture',ttl='60s') + assert wrapped.creation_path=='secret/data/d3-wrap-fixture' + assert wrapped.ttl=='60' + assert wrapped.wrap_token not in repr(wrapped) and wrapped.wrap_token not in json.dumps(wrapped.evidence()) + recipient=OpenBaoClient(addr=bao_dev.addr,token=wrapped.wrap_token,bao_bin=bao_dev.bao_bin) + first=recipient._run(['unwrap','-format=json']) + assert first.returncode==0 + assert json.loads(first.stdout)['data']['data']['marker']=='synthetic-wrapped-integration-value' + replay=recipient._run(['unwrap','-format=json']) + assert replay.returncode==2 and not replay.stdout.strip() + assert re.search(r'Code:\s*400\b',replay.stderr) + assert '* wrapping token is not valid or does not exist' in replay.stderr diff --git a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md index e1b18c7..01be511 100644 --- a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md +++ b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md @@ -24,7 +24,10 @@ unchanged identity-only login and is not a protected execution entitlement. ```task id: SECRETS-WP-0013-T01 -status: progress +status: wait +needs_human: false +blocked_on: message-from:railiance-platform +blocking_reason: "Independent Platform mediated-contract review and identity/approval/PDP owner returns remain required; D3 wrapped primitive source is implemented, no native entitlement is inferred." priority: high state_hub_task_id: "b2f0d462-a542-518b-a26b-b7ec07b3c7c1" ``` @@ -45,6 +48,8 @@ in the execution-contract document. No source owner assent, initiating-principal admission, new policy package, ledger/mediator/backend or native acceptance is claimed. Independent source review remains required. Task statuses are unchanged. +2026-10-11: D3 response-wrapped primitive now verifies native creation path and bounded TTL/accessor, rejects overwrite/symlink/shared-parent paths, and reserves/fsyncs the private recipient before mint. Backend/write uncertainty retains a non-replayable reservation. Disposable OpenBao proves one successful unwrap and native consumed-token HTTP400 denial, not a production lane or entitlement. Separate delivery contracts cannot reuse metadata execute-ccr authority. Source design is ready for independent owner review; RPF-IN-0012 requests the Platform return. Identity/approval/PDP returns and the complete native mediated path remain open; no founder decision or foreign owner assent is fabricated. + ## Prevent consumption without execution readiness ```task