Bind wrapped issuance to native metadata and preserve immutable recipient attempts
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
This commit is contained in:
parent
58129de8f1
commit
0db6adef69
8 changed files with 288 additions and 46 deletions
|
|
@ -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
|
# 6. publish with the token injected into the child only
|
||||||
secrets-engine exec --catalog whynot-design-npm-publish -- npm publish
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -311,3 +311,24 @@ this change does not make them verified human representation. Existing generic
|
||||||
consume receipt validation limitations remain explicitly separate. service-jwt
|
consume receipt validation limitations remain explicitly separate. service-jwt
|
||||||
readiness guard/default and ordinary actions remain unchanged. T01 stays in
|
readiness guard/default and ordinary actions remain unchanged. T01 stays in
|
||||||
progress, T03 stays wait, WP13 stays proposed, and WP8-T06 stays open.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from __future__ import annotations
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
@ -400,23 +401,34 @@ class OpenBaoClient:
|
||||||
self._run_ok(["lease", "revoke", lease_id])
|
self._run_ok(["lease", "revoke", lease_id])
|
||||||
|
|
||||||
def _parse_wrap_response(self, stdout: str, *, ttl: str, creation_path: str) -> WrappedResponse:
|
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:
|
try:
|
||||||
payload = json.loads(stdout)
|
payload = json.loads(stdout)
|
||||||
except json.JSONDecodeError as exc:
|
except (json.JSONDecodeError, TypeError):
|
||||||
raise BackendError("wrapped response is not JSON") from exc
|
raise BackendError("wrapped response is not JSON") from None
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise BackendError("wrapped response is invalid")
|
raise BackendError("wrapped response is invalid")
|
||||||
wrap = payload.get("wrap_info")
|
wrap = payload.get("wrap_info")
|
||||||
if not isinstance(wrap, dict) or not wrap.get("token"):
|
if not isinstance(wrap, dict) or not wrap.get("token"):
|
||||||
raise BackendError("response wrapping was not applied")
|
raise BackendError("response wrapping was not applied")
|
||||||
token = str(wrap["token"])
|
if payload.get("data") or payload.get("auth"):
|
||||||
accessor = str(wrap.get("accessor") or "")
|
raise BackendError("wrapped response unexpectedly included unwrapped material")
|
||||||
wrap_ttl = wrap.get("ttl")
|
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(
|
return WrappedResponse(
|
||||||
wrap_token=token,
|
wrap_token=token,
|
||||||
accessor_fingerprint=accessor_fingerprint(accessor) if accessor else "",
|
accessor_fingerprint=accessor_fingerprint(accessor),
|
||||||
ttl=str(wrap_ttl if wrap_ttl is not None else ttl),
|
ttl=str(seconds),
|
||||||
creation_path=creation_path,
|
creation_path=wrap["creation_path"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def wrap_kv_get(self, mount: str, path: str, *, ttl: str) -> WrappedResponse:
|
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])
|
proc = self._run(["kv", "get", f"-wrap-ttl={ttl}", "-format=json", target])
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
raise BackendError("wrapped KV read failed")
|
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:
|
def wrap_approle_secret_id(self, role_name: str, *, ttl: str) -> WrappedResponse:
|
||||||
"""Wrap a single-use AppRole secret_id. Fail if returned unwrapped."""
|
"""Wrap a single-use AppRole secret_id. Fail if returned unwrapped."""
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import stat
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -43,33 +44,48 @@ def normalize_wrap_ttl(value: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _validate_output_path(path: Path) -> Path:
|
def _validate_output_path(path: Path) -> Path:
|
||||||
resolved = path.expanduser().resolve()
|
# Do not follow a leaf/parent symlink into another recipient's private file.
|
||||||
worktree = containing_git_worktree(resolved)
|
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:
|
if worktree is not None:
|
||||||
raise ProvisioningError(
|
raise ProvisioningError("wrap output is inside a Git worktree; keep tokens outside repos")
|
||||||
f"wrap file {resolved} is inside a Git worktree ({worktree}); "
|
if os.path.lexists(absolute):
|
||||||
"keep wrap tokens outside repos"
|
raise ProvisioningError("wrap output already exists; reconcile the prior attempt, never overwrite or remint")
|
||||||
)
|
return absolute
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
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:
|
try:
|
||||||
os.fchmod(fd, 0o600)
|
# Walk using directory descriptors so a concurrent symlink swap cannot
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
# redirect either an ancestor or the final file creation.
|
||||||
fd = None
|
for component in path.parent.parts[1:]:
|
||||||
fh.write(value)
|
next_fd = os.open(component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
||||||
fh.write("\n")
|
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:
|
finally:
|
||||||
if fd is not None:
|
os.close(directory_fd)
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
|
|
||||||
def write_wrapped_handoff(
|
def write_wrapped_handoff(
|
||||||
|
|
@ -82,20 +98,26 @@ def write_wrapped_handoff(
|
||||||
"""Mint a wrap token and write it without printing it."""
|
"""Mint a wrap token and write it without printing it."""
|
||||||
ttl = normalize_wrap_ttl(ttl)
|
ttl = normalize_wrap_ttl(ttl)
|
||||||
out_path = _validate_output_path(out_file)
|
out_path = _validate_output_path(out_file)
|
||||||
wrapped: WrappedResponse
|
if entry.kind != "auth-capability" and not entry.stores_kv_value():
|
||||||
if entry.kind == "auth-capability":
|
raise ProvisioningError(f"lane '{entry.id}' kind {entry.kind} has no wrapped handoff")
|
||||||
wrapped = client.wrap_approle_secret_id(entry.role_name, ttl=ttl)
|
fd = _reserve_mode_0600(out_path)
|
||||||
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
|
|
||||||
try:
|
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:
|
finally:
|
||||||
token = ""
|
if fd is not None:
|
||||||
|
os.close(fd)
|
||||||
return WrapHandoffResult(
|
return WrapHandoffResult(
|
||||||
catalog_id=entry.id,
|
catalog_id=entry.id,
|
||||||
kind=entry.kind,
|
kind=entry.kind,
|
||||||
|
|
|
||||||
|
|
@ -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
|
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))
|
entry = validate_entry(copy.deepcopy(VALID))
|
||||||
with pytest.raises(ProvisioningError, match="Git worktree"):
|
with pytest.raises(ProvisioningError, match="Git worktree"):
|
||||||
write_wrapped_handoff(
|
write_wrapped_handoff(
|
||||||
FakeWrapClient(),
|
FakeWrapClient(),
|
||||||
entry,
|
entry,
|
||||||
out_file=__import__("pathlib").Path("wrap.token"),
|
out_file=repository / "wrap.token",
|
||||||
ttl="15m",
|
ttl="15m",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -96,6 +100,7 @@ def test_parse_wrap_accepts_wrap_info_only():
|
||||||
"token": WRAP_TOKEN,
|
"token": WRAP_TOKEN,
|
||||||
"accessor": WRAP_ACCESSOR,
|
"accessor": WRAP_ACCESSOR,
|
||||||
"ttl": 900,
|
"ttl": 900,
|
||||||
|
"creation_path": "secret/x",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -116,7 +121,7 @@ def test_wrap_kv_get_uses_wrap_ttl_flag(monkeypatch):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
returncode=0,
|
returncode=0,
|
||||||
stdout=json.dumps(
|
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="",
|
stderr="",
|
||||||
)
|
)
|
||||||
|
|
@ -158,3 +163,93 @@ def test_production_wrap_fails_closed(tmp_path, monkeypatch):
|
||||||
)
|
)
|
||||||
with pytest.raises(DecisionError, match="production action 'wrap'"):
|
with pytest.raises(DecisionError, match="production action 'wrap'"):
|
||||||
cli.cmd_wrap(cfg, args)
|
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
|
||||||
|
|
|
||||||
24
tests/test_wrapped_issuer_native.py
Normal file
24
tests/test_wrapped_issuer_native.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -24,7 +24,10 @@ unchanged identity-only login and is not a protected execution entitlement.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: SECRETS-WP-0013-T01
|
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
|
priority: high
|
||||||
state_hub_task_id: "b2f0d462-a542-518b-a26b-b7ec07b3c7c1"
|
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
|
admission, new policy package, ledger/mediator/backend or native acceptance is
|
||||||
claimed. Independent source review remains required. Task statuses are unchanged.
|
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
|
## Prevent consumption without execution readiness
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue