diff --git a/README.md b/README.md index 922f840..52e3f1e 100644 --- a/README.md +++ b/README.md @@ -86,3 +86,5 @@ speaks in lanes and guarded plans. not write. - Temporary bootstrap OpenBao credentials must live outside repos, use mode 0600, be revocable, and be removed after narrower auth is working. + +Packaged CLI deployment uses the reviewed [wheel + verifier bundle](docs/cli-release-bundle.md). A Python wheel alone omits the mandatory signature helper. diff --git a/docs/cli-release-bundle.md b/docs/cli-release-bundle.md new file mode 100644 index 0000000..8634eda --- /dev/null +++ b/docs/cli-release-bundle.md @@ -0,0 +1,82 @@ +# CLI release bundle with authenticated decision verifier + +The supported packaged CLI path is an explicit wheel-plus-verifier bundle. The +existing editable `uv` workflow remains for development. Installing the Python +wheel alone omits the Go helper and is incomplete for production authorization; +it continues to fail closed. This is a CLI package, not a service or the retired +`rapp-secrets-engine` deployment. + +The bundle contains the Secrets wheel, the existing PyYAML runtime wheel pinned +by `uv.lock`, the verifier executable, producer license attribution, a narrow CLI +entry point and an offline installer. It never contains the test signer, signing +seed, caller credential, runtime trust/key config or backend entitlement. Optional +`clock` extras are outside this base bundle; configuring clock trust without the +separately reviewed extra continues to refuse, rather than fall back. + +## Build + +Use Python >=3.11, uv and the Go toolchain required by `verifier/go.mod` on the +intended Linux architecture. Supply a local compatible PyYAML wheel whose exact +filename and SHA-256 appear in `uv.lock`. The builder never discovers/downloads a +runtime dependency. The existing Hatchling build backend is now version-pinned. + +```bash +python3 scripts/build_release_bundle.py \ + --output /tmp/secrets-cli-reviewed-build \ + --dependency-wheel /path/to/locked-compatible-pyyaml.whl +``` + +Builds are offline by default. In a reviewed build environment, the explicit +`--allow-build-downloads` flag permits uv to fetch the existing pinned build +backend and its build dependencies. It does not change the runtime dependency +contract. Set writable uv/Go caches as required by that build environment. + +The builder first verifies the exact producer API/signature source and native +fixture hashes, builds only the verifier root package with CGO disabled, explicit +host architecture, trimmed paths, disabled VCS stamping and an empty build ID, +then builds the wheel with commit-derived `SOURCE_DATE_EPOCH`. Two builds using +the same source snapshot/toolchain/dependency wheel produce matching artifacts +and manifest. The manifest records exact source-file and artifact SHA-256s, base +commit, producer snapshot, platform and tool versions. It contains no observed +build timestamp; retain observed execution time in a separate receipt. + +## Review and offline install + +**Checksums prove artifact integrity, not independent package admission.** The +owner must review/admit the source, toolchain, complete bundle and manifest hash, +and control the bundle/install prefix. A manifest obtained from an untrusted +bundle does not establish that admission. Key/trust admission remains a distinct +WP14 gate. None of these steps authorizes protected OpenBao work. + +Execute the reviewed installer with the independently retained manifest digest: + +```bash +python3 /path/to/reviewed-bundle/install.py \ + --prefix /path/to/new-owner-controlled-prefix \ + --manifest-sha256 REVIEWED_MANIFEST_SHA256 +/path/to/new-owner-controlled-prefix/bin/secrets-engine --help +``` + +The parent directory must already be owner controlled. The installer validates +platform, manifest and the entire artifact set before creating a private new +prefix, creates its venv through the existing uv convention, and installs entirely +offline from bundled wheels. It never replaces an existing prefix or installs +into standard user/system tools. A failed partial prefix is retained and marked; +retry with a new prefix after diagnosis. Removing a failed/disposable prefix is +an explicit owner operation, not an automatic cleanup of an existing path. + +Use `PREFIX/bin/secrets-engine` as the admitted CLI entry point. It checks the +bundled helper's SHA-256 before CLI import or credential access and fixes +`SECRETS_ENGINE_PDP_VERIFIER` to that helper; ambient executable overrides do not +select another verifier. Missing or changed helpers refuse. There is no runtime +compilation or unsigned fallback. The internal venv scripts/programmatic API are +not a second admitted entry point and do not create package/trust admission. +Catalog/policy and independently reviewed responder trust configuration still +belong to the deploying owner. Neither is silently copied from a developer repo. + +For source verification, supply `SECRETS_ENGINE_TEST_DEPENDENCY_WHEEL` and run +`pytest tests/test_release_bundle.py`. The disposable proof compares two builds, +installs offline, invokes the installed wheel's signed HTTP boundary with public +synthetic fixtures, rejects unsigned decisions, checks helper/bundle tampering, +source drift and existing-prefix refusal. No native credentials or protected +backend are involved. diff --git a/docs/task-execution-handoff.md b/docs/task-execution-handoff.md new file mode 100644 index 0000000..5821175 --- /dev/null +++ b/docs/task-execution-handoff.md @@ -0,0 +1,78 @@ +# Strict Secrets service task-execution handoff + +This is a narrow source seam for existing SECRETS-WP-0013, not a native grant or +an execute-ccr CLI. Both Access runtime-deny policy variants remain unchanged. +The proposed service subject/transport mapping and the complete native executor +still need independent owner admission. Positive source fixtures do not admit +that mapping, a policy, a caller, a production key or an OpenBao entitlement. + +Platform's trusted owner process calls: + +```python +from secrets_engine.task_execution_handoff import ( + authorize_task_execution, consume_task_execution, +) +authorization = authorize_task_execution(cfg, entry, execution_contract=contract) +record = authorization.as_owner_handoff() +# Independently bind exact owner Plan/descriptor; reserve durable one-shot journal. +# Persist pending strict-CAS intent before calling: +consumption = consume_task_execution(cfg, authorization) +# Independently GET-confirm exact original consumption with Approval Engine's +# ExecutionConfirmationClient before journaling consume-confirmed/native work. +``` + +Preparation uses the existing empty fields/policy_targets/auth_targets defaults +and existing build_action_request/claim/Check validators. No caller-selected +transport, authorizer callback, policy result or raw handoff mapping is accepted. +The normal authorizer retains detached serialized request, claim and authenticated +Go-typed envelope snapshots; its existing evidence output does not expose them. +The narrow adapter reuses the validators, rather than evaluating a policy. + +The adapter checks the exact descriptor in the signed context, full evaluated and +submitted request digests, approval-free digest, exact five-field native act and +native binding digest, service subject/resource/tenant correspondence and original +decision/approval/deadline bounds. Initiating/represented subject is strictly +`secrets-engine` of type `service`; the execution actor is `service:secrets-engine`. +Signed caller provenance must report `enforce`, the proposed exact Secrets +ServiceAccount principal, `flex-auth` audience and live expiry bounding the +operation deadline. This consumes the signed PDP's caller attestation; it does +not independently perform TokenReview or promote the proposed server mapping. +Human/delegation/projected-identity flags cannot substitute for this service seam. +No service_actor_binding request propagation or source-policy activation is added. + +`as_owner_handoff()` returns a detached non-secret mapping with: + +- execution_contract +- approval_id, request_digest, decision_id +- native_binding, approval_binding_digest, submitted_request_digest +- decision_not_before, decision_expires_at, approval_expires_at + +The exact native binding is derived from the signed evaluated request using the +published Approval five-field mapping, and is compared to the served claim. +Descriptor/CCR/grant/source/catalog/target correspondence is retained end to end; +Platform still independently compiles its owner Plan and grant from owner bytes. +The mapping is evidence for that join, never a transferable bearer authorization. + +Consumption accepts only the module-created opaque prepared object in that same +trusted owner process. It fixes source/config coordinates, reauthenticates using +currently admitted responder keys, and rechecks claim/decision/descriptor/caller +freshness before reading the CAS credential. Each object is reserved once before +CAS/revalidation and cannot retry after any failure or unknown outcome. Its +process-local guard complements Platform's durable cross-process journal; it is +not a replacement for that journal or process isolation. + +The strict response path uses the existing CAS transport but rejects duplicate, +nonfinite or excessively deep JSON, missing/mismatched approval and decision IDs, +wrong digest/status, missing or nonboolean idempotence, historical idempotent +success, verdict fields and absent/naive/invalid consumption timestamps. The +consumption instant and post-CAS freshness must remain inside the original +bounds. No field is coerced or filled from the submitted binding. A late/uncertain +CAS may have occurred; it never licenses retry or protected native work. + +The returned immutable TaskExecutionConsumption contains exact approval_id, +request_digest, decision_id, consumed_at and explicit false idempotent. It proves +only the strict CAS response observed by Secrets. Platform must independently +confirm original durable consumption using Approval's real provider client, +journal it, then recheck its exact Plan, owner sources, grant and deadlines before +any native operation. Neither a dataclass nor a caller-supplied boolean admits +execution. Existing generic CLI CAS behavior is otherwise unchanged. diff --git a/pyproject.toml b/pyproject.toml index fd25de6..aa74fda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dev = [ secrets-engine = "secrets_engine.cli:main" [build-system] -requires = ["hatchling"] +requires = ["hatchling==1.27.0"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/scripts/build_release_bundle.py b/scripts/build_release_bundle.py new file mode 100644 index 0000000..7f8f0df --- /dev/null +++ b/scripts/build_release_bundle.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Build wheel + pinned producer verifier; install is a separate reviewed step.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys +import tomllib + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def run(command, **kwargs): + return subprocess.check_output(command,text=True,**kwargs).strip() + + +def build(root, output, dependency_wheel, uv, allow_build_downloads=False): + if platform.system() != 'Linux': + raise ValueError('this bundle supports Linux CLI only') + # No discovered keys, source rewriting, or runtime compilation. + subprocess.run([sys.executable,str(root/'scripts/check_pdp_producer_source.py')],check=True,stdout=sys.stderr) + locked=next(p for p in tomllib.loads((root/'uv.lock').read_text())['package'] if p['name']=='pyyaml') + admitted={Path(item['url']).name:item['hash'].removeprefix('sha256:') for item in locked['wheels']} + if dependency_wheel.name not in admitted or digest(dependency_wheel)!=admitted[dependency_wheel.name]: + raise ValueError('supplied PyYAML wheel does not match uv.lock') + output.mkdir(mode=0o700,parents=False,exist_ok=False) + epoch=run(['git','log','-1','--format=%ct'],cwd=root) + commit=run(['git','rev-parse','HEAD'],cwd=root) + architecture={'x86_64':'amd64','aarch64':'arm64'}.get(platform.machine()) + if architecture is None: + raise ValueError('unsupported Linux architecture') + env={**os.environ,'CGO_ENABLED':'0','GOOS':'linux','GOARCH':architecture, + 'GOFLAGS':'','GOWORK':'off','SOURCE_DATE_EPOCH':epoch} + (output/'bin').mkdir();(output/'wheels').mkdir() + subprocess.run(['go','build','-trimpath','-buildvcs=false','-ldflags=-buildid=', + '-o',str(output/'bin/secrets-engine-pdp-verify'),'.'],cwd=root/'verifier',env=env,check=True) + command=[uv,'--no-config','build','--wheel','--out-dir',str(output/'wheels'),str(root)] + if not allow_build_downloads:command.append('--offline') + subprocess.run(command,env=env,check=True) + wheel,=list((output/'wheels').glob('secrets_engine-*.whl')) + shutil.copyfile(dependency_wheel,output/'wheels'/dependency_wheel.name) + helper_hash=digest(output/'bin/secrets-engine-pdp-verify') + launcher=(root/'scripts/release_cli.py').read_text().replace('@HELPER_SHA256@',helper_hash) + (output/'release_cli.py').write_text(launcher) + shutil.copyfile(root/'scripts/install_release_bundle.py',output/'install.py') + shutil.copyfile(root/'verifier/LICENSE.access-engine',output/'LICENSE.access-engine') + sources=[root/'pyproject.toml',root/'uv.lock',root/'verifier/producer-source.json'] + sources+=list((root/'src/secrets_engine').glob('*.py')) + sources+=list((root/'verifier/pkg/api').glob('*.go'))+list((root/'verifier/internal/sign').glob('*.go')) + sources+=[root/'verifier/main.go',root/'scripts/check_pdp_producer_source.py', + root/'scripts/build_release_bundle.py',root/'scripts/install_release_bundle.py',root/'scripts/release_cli.py'] + source_hashes={str(path.relative_to(root)):digest(path) for path in sorted(sources)} + manifest={"schema":"secrets-engine.cli-bundle.v1","source_commit":commit,"source_files":source_hashes, + "source_date_epoch":int(epoch),"system":platform.system(),"machine":platform.machine(), + "python":platform.python_version(),"go":run(['go','version']),"uv":run([uv,'--version']), + "build_backend":"hatchling==1.27.0","wheel":str(wheel.relative_to(output)), + "producer":json.loads((root/'verifier/producer-source.json').read_text()), + "artifacts":{str(path.relative_to(output)):digest(path) for path in sorted(output.rglob('*')) if path.is_file()}, + "admission":"checksums are integrity evidence; owner must admit the bundle/prefix independently"} + (output/'manifest.json').write_text(json.dumps(manifest,sort_keys=True,indent=2)+'\n') + return {'bundle':str(output),'manifest_sha256':digest(output/'manifest.json'),'helper_sha256':helper_hash} + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output',type=Path,required=True) + parser.add_argument('--dependency-wheel',type=Path,required=True) + parser.add_argument('--uv',default='uv') + parser.add_argument('--allow-build-downloads',action='store_true',help='permit uv to fetch existing build-system dependencies') + args=parser.parse_args() + try: + print(json.dumps(build(Path(__file__).resolve().parents[1],args.output.resolve(),args.dependency_wheel.resolve(), + args.uv,args.allow_build_downloads),sort_keys=True)) + except (OSError,ValueError,KeyError,subprocess.SubprocessError) as exc: + print('bundle build refused: '+str(exc),file=sys.stderr);return 1 + return 0 + + +if __name__=='__main__': + raise SystemExit(main()) diff --git a/scripts/install_release_bundle.py b/scripts/install_release_bundle.py new file mode 100644 index 0000000..9d718fc --- /dev/null +++ b/scripts/install_release_bundle.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Install one reviewed CLI bundle offline into a new owner-controlled prefix.""" +import argparse +import hashlib +import json +from pathlib import Path +import platform +import shutil +import subprocess +import sys + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def install(bundle, prefix, expected_manifest, python, uv): + manifest_path = bundle / "manifest.json" + if digest(manifest_path) != expected_manifest: + raise ValueError("bundle manifest differs from reviewed digest") + manifest = json.loads(manifest_path.read_text()) + if manifest["schema"] != "secrets-engine.cli-bundle.v1": + raise ValueError("unsupported bundle") + if manifest['system'] != platform.system() or manifest['machine'] != platform.machine(): + raise ValueError("bundle platform differs from installer host") + expected_files = set(manifest['artifacts']) | {'manifest.json'} + actual_files = {str(p.relative_to(bundle)) for p in bundle.rglob('*') if p.is_file()} + if actual_files != expected_files: + raise ValueError("bundle file set differs") + for name, expected in manifest['artifacts'].items(): + relative = Path(name) + if relative.is_absolute() or '..' in relative.parts: + raise ValueError("invalid artifact path") + artifact = bundle / relative + if any(parent.is_symlink() for parent in (artifact,*artifact.parents)) or digest(artifact) != expected: + raise ValueError("bundle artifact missing or changed") + # Reserve once; never replace an installed prefix or follow an existing link. + prefix.mkdir(mode=0o700, parents=False, exist_ok=False) + try: + subprocess.run([uv,'--no-config','venv','--python',python,'--no-python-downloads',str(prefix/'venv')],check=True) + subprocess.run([uv,'--no-config','pip','install','--python',str(prefix/'venv/bin/python'), + '--no-index','--find-links',str(bundle/'wheels'),str(bundle/manifest['wheel'])],check=True) + (prefix/'libexec').mkdir(mode=0o700) + helper=prefix/'libexec/secrets-engine-pdp-verify' + shutil.copyfile(bundle/'bin/secrets-engine-pdp-verify',helper);helper.chmod(0o700) + (prefix/'bin').mkdir(mode=0o700) + launcher=prefix/'bin/secrets-engine' + # An absolute venv interpreter fixes the installed dependency environment. + launcher.write_text('#!'+str(prefix/'venv/bin/python')+'\n'+(bundle/'release_cli.py').read_text()) + launcher.chmod(0o700) + receipt={"schema":"secrets-engine.cli-install.v1","manifest_sha256":expected_manifest, + "helper_sha256":digest(helper),"launcher_sha256":digest(launcher),"prefix":str(prefix), + "admission":"artifact integrity only; trust and keys not admitted"} + (prefix/'install-receipt.json').write_text(json.dumps(receipt,sort_keys=True,indent=2)+'\n') + return receipt + except BaseException: + # Preserve a failed prefix as evidence; never retry into a partial install. + (prefix/'INSTALL-FAILED').write_text('Incomplete installation; use a new prefix.\n') + raise + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--bundle',type=Path,default=Path(__file__).resolve().parent) + parser.add_argument('--prefix',type=Path,required=True) + parser.add_argument('--manifest-sha256',required=True) + parser.add_argument('--python',default=sys.executable) + parser.add_argument('--uv',default='uv') + args=parser.parse_args() + try: + if not args.prefix.is_absolute() or args.prefix.parent.is_symlink(): + raise ValueError('prefix must be absolute with an admitted parent') + receipt=install(args.bundle.resolve(),args.prefix,args.manifest_sha256,args.python,args.uv) + print(json.dumps(receipt,sort_keys=True)) + except (OSError,ValueError,KeyError,subprocess.SubprocessError) as exc: + print('bundle install refused: '+str(exc),file=sys.stderr) + return 1 + return 0 + + +if __name__=='__main__': + raise SystemExit(main()) diff --git a/scripts/release_cli.py b/scripts/release_cli.py new file mode 100644 index 0000000..c7a2d08 --- /dev/null +++ b/scripts/release_cli.py @@ -0,0 +1,29 @@ +"""Narrow installed CLI entry point; rendered by build_release_bundle.py.""" +import hashlib +import os +from pathlib import Path +import sys + +EXPECTED_HELPER_SHA256 = "@HELPER_SHA256@" + + +def main(): + prefix = Path(__file__).resolve().parent.parent + helper = prefix / "libexec" / "secrets-engine-pdp-verify" + try: + if helper.is_symlink() or not helper.is_file(): + raise ValueError("missing helper") + if hashlib.sha256(helper.read_bytes()).hexdigest() != EXPECTED_HELPER_SHA256: + raise ValueError("changed helper") + except (OSError, ValueError): + print("secrets-engine: installed verifier missing or changed", file=sys.stderr) + return 1 + # The bundle always uses its reviewed helper; ambient overrides do not select + # another executable. The owner admits runtime trust separately. + os.environ["SECRETS_ENGINE_PDP_VERIFIER"] = str(helper) + from secrets_engine.cli import main as cli_main + return cli_main() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py index bb1e766..1cf4140 100644 --- a/src/secrets_engine/approval_consume.py +++ b/src/secrets_engine/approval_consume.py @@ -23,7 +23,7 @@ from secrets_engine.execution_contract import ExecutionContract, require_executi from secrets_engine.approval_claim import observe_pdp_approval_claim, validate_approval_claim from secrets_engine.catalog import human_control_required from secrets_engine.decision_check import check_decision, require_supported_pdp_address -from secrets_engine.decision_signature import load_trust +from secrets_engine.decision_signature import load_trust, strict_json from secrets_engine.authorization import ( build_action_request, validate_decision_envelope, @@ -70,6 +70,8 @@ class AuthorizedAction: binding: ConsumeBinding decision_id: str expires_at: str + # Detached original served artifacts for the narrow task-execution handoff. + artifacts: str = field(default="", repr=False, compare=False) @property def id(self) -> str: @@ -380,6 +382,8 @@ def authorize_action( ), decision_id=validated.decision_id, expires_at=validated.expires_at, + artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope}, + sort_keys=True,separators=(",",":"),allow_nan=False), ) @@ -391,6 +395,7 @@ def consume_approval( binding: ConsumeBinding, timeout_seconds: float = 3, opener: Callable[..., Any] = credential_urlopen, + strict_response: bool = False, ) -> ConsumedApproval: """POST /v1/approvals/{id}/consume. Fail closed on anything but confirmed use.""" if not base_url or not base_url.startswith(("http://", "https://")): @@ -438,8 +443,8 @@ def consume_approval( if status != 200 or len(raw) > _MAX_BODY: raise DecisionError(_status_message(status if status != 200 else 502)) try: - payload = json.loads(raw.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: + payload = (strict_json if strict_response else json.loads)(raw.decode("utf-8")) + except (UnicodeDecodeError, ValueError, RecursionError) as exc: raise DecisionError("approval consume returned invalid JSON") from exc if not isinstance(payload, dict): raise DecisionError("approval consume returned invalid payload") @@ -447,6 +452,19 @@ def consume_approval( raise DecisionError("approval consumption was not confirmed") if payload.get("request_digest") != binding.request_digest: raise DecisionError("approval consume digest does not match the request") + if strict_response: + from datetime import datetime + if (payload.get("approval_id") != approval_id + or payload.get("decision_id") != binding.decision_id + or payload.get("idempotent") is not False + or any(key in payload for key in ("effect","allow","deny","decision"))): + raise DecisionError("strict task consumption correspondence refused") + try: + stamp = datetime.fromisoformat(payload["consumed_at"].replace("Z","+00:00")) + if stamp.tzinfo is None or stamp.utcoffset() is None: + raise ValueError("timezone required") + except (KeyError,TypeError,AttributeError,ValueError): + raise DecisionError("strict task consumption timestamp refused") from None decision_id = payload.get("decision_id") if decision_id is not None and ( not isinstance(decision_id, str) or not decision_id diff --git a/src/secrets_engine/task_execution_handoff.py b/src/secrets_engine/task_execution_handoff.py new file mode 100644 index 0000000..9772861 --- /dev/null +++ b/src/secrets_engine/task_execution_handoff.py @@ -0,0 +1,194 @@ +"""Narrow service-task PEP handoff; records never grant native execution. + +Platform calls preparation itself, binds its independently compiled owner Plan, +reserves/journals once, then calls strict consumption and independent Approval +GET confirmation. No inbound mapping/boolean or constructor creates authority. +Actual policy/server input mapping and runtime entitlement remain unadmitted. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import json +from threading import Lock + +from secrets_engine.approval_auth import approval_token, credential_urlopen +from secrets_engine.approval_claim import binding_from_check_request, claim_binding_digest, validate_approval_claim +from secrets_engine.approval_consume import authorize_action, consume_approval +from secrets_engine.application_time import read_window, validity_bounds +from secrets_engine.authorization import validate_decision_envelope, request_digest, approval_binding_digest +from secrets_engine.decision_signature import authenticate_response, load_trust, strict_json +from secrets_engine.errors import DecisionError +from secrets_engine.execution_contract import ExecutionContract, require_execution_deadline + +_CALLER = 'system:serviceaccount:secrets-engine:secrets-engine' +_NATIVE_FIELDS = ('action','actor','principal','purpose','target') +_COORDINATES = ('approval_url','approval_token_file','approval_client_secret_file','keycape_token_url','keycape_issuer', + 'pdp_url','pdp_token_file','pdp_trust_file','pdp_verifier','clock_trust_file', + 'authorization_subject_id','authorization_subject_type','authorization_policy_package','authorization_policy_version') +_ISSUED = object() + + +def _refuse(): + raise DecisionError('strict task execution handoff correspondence refused') + + +def _coordinates(cfg): + return tuple(str(getattr(cfg,name,'') or '') for name in _COORDINATES) + + +def _stamp(value): + try: + parsed=datetime.fromisoformat(value.replace('Z','+00:00')) + if parsed.tzinfo is None or parsed.utcoffset() is None: + _refuse() + return parsed + except (AttributeError,TypeError,ValueError): + _refuse() + + +class TaskExecutionAuthorization: + """Opaque process-local prepared state; not a bearer credential or allow.""" + __slots__=('_action','_contract','_coordinates','_handoff','_used','_lock') + + def __init__(self, issuer=None, *, action=None, contract=None, coordinates=None, handoff=None): + if issuer is not _ISSUED: + _refuse() + object.__setattr__(self,'_action',action) + object.__setattr__(self,'_contract',contract) + object.__setattr__(self,'_coordinates',coordinates) + object.__setattr__(self,'_handoff',json.dumps(handoff,sort_keys=True,separators=(',',':'),allow_nan=False)) + object.__setattr__(self,'_used',False) + object.__setattr__(self,'_lock',Lock()) + + def __setattr__(self,name,value): + raise AttributeError('prepared task handoff is immutable') + + def as_owner_handoff(self): + return strict_json(self._handoff) + + +@dataclass(frozen=True) +class TaskExecutionConsumption: + approval_id: str + request_digest: str + decision_id: str + consumed_at: str + idempotent: bool + + +def _validate(cfg, action, contract): + if type(contract) is not ExecutionContract or not action.artifacts: + _refuse() + descriptor=contract.as_dict() + artifacts=strict_json(action.artifacts) + request,claim,envelope=(artifacts[name] for name in ('request','claim','envelope')) + # Rotation/removal and installed-helper changes cannot hide behind a cached + # prepared label. Reauthenticate before each strict CAS, without networking. + authenticated=authenticate_response(json.dumps(envelope,allow_nan=False).encode(), + trust=load_trust(getattr(cfg,'pdp_trust_file',None)), + verifier=getattr(cfg,'pdp_verifier',None)) + if authenticated != envelope: + _refuse() + window=read_window(cfg) + lower,upper=validity_bounds(window=window) + validated=validate_decision_envelope(envelope,request, + accepted_policy_packages={str(cfg.authorization_policy_package)}, + accepted_policy_versions={str(cfg.authorization_policy_version)}, + expected_approval_binding_digest=claim['binding']['pdp_digest'],time_window=window) + validate_approval_claim(claim,approval_id=action.binding.approval_id, + expected_pdp_digest=envelope['binding']['approval_binding_digest'], + require_human_control=True,time_window=window) + require_execution_deadline(request,time_window=window, + bounds=(claim['validity']['expires_at'],envelope['lifetime']['expires_at'])) + binding=envelope['binding'] + if (envelope['lifetime'].get('kind')!='ttl' + or _stamp(envelope['lifetime'].get('not_before'))>lower): + _refuse() + if (request['action']!='execute-ccr' or binding.get('action')!='execute-ccr' + or request['tenant']!='tenant:platform' or binding.get('tenant')!='tenant:platform' + or binding.get('context')!=request['context'] + or request['context'].get('execution_contract')!=descriptor + or binding.get('request_digest')!=request_digest(binding) + or binding.get('approval_binding_digest')!=approval_binding_digest(binding) + or action.binding.request_digest!=validated.request_digest + or action.binding.decision_id!=validated.decision_id): + _refuse() + subject=binding['subject']; resource=binding['resource'] + if (request['subject']!={'id':'secrets-engine','type':'service'} + or subject.get('id')!='secrets-engine' or subject.get('type')!='service' + or subject.get('tenant') not in (None,'tenant:platform') + or 'principal' in subject.get('attributes',{}) + or envelope['subject']!=subject or envelope['resource']!=resource + or descriptor['execution_actor']!='service:secrets-engine' + or any(resource.get(key)!=request['resource'].get(key) for key in ('id','type','system')) + or resource.get('tenant') not in (None,'tenant:platform') + or any(resource.get('attributes',{}).get(key)!=value for key,value in request['resource']['attributes'].items()) + or any(key in binding['context'] for key in ('task_execution_projection','initiator','represented_principal','delegation','verified','owner_admission'))): + _refuse() + caller=envelope['provenance'].get('caller') + if (not isinstance(caller,dict) or caller.get('mode')!='enforce' + or caller.get('principal')!=_CALLER or caller.get('audience')!='flex-auth' + or _stamp(caller.get('not_after')) < _stamp(descriptor['operation_deadline']) + or upper >= _stamp(caller.get('not_after'))): + _refuse() + native=binding_from_check_request(binding) + supplied=claim['binding'] + # Recompute the served native act too: Python equality alone conflates + # boolean/integer enrichment and cannot establish canonical correspondence. + if (any(supplied.get(key)!=native[key] for key in _NATIVE_FIELDS) + or supplied.get('digest')!=claim_binding_digest(**native) + or supplied.get('digest')!=claim_binding_digest(**{key:supplied.get(key) for key in _NATIVE_FIELDS})): + _refuse() + return {'execution_contract':descriptor,'approval_id':action.binding.approval_id, + 'request_digest':validated.request_digest,'decision_id':validated.decision_id, + 'native_binding':native,'approval_binding_digest':binding['approval_binding_digest'], + 'submitted_request_digest':validated.submitted_request_digest, + 'decision_not_before':envelope['lifetime'].get('not_before') or '', + 'decision_expires_at':envelope['lifetime']['expires_at'], + 'approval_expires_at':claim['validity']['expires_at']} + + +def authorize_task_execution(cfg, entry, *, execution_contract): + if type(execution_contract) is not ExecutionContract: + _refuse() + # No injected transports or policy callbacks: use the actual existing PIP/PDP + # clients and mandatory signature validators in the owner process. + action=authorize_action(cfg,entry,'execute-ccr',execution_contract=execution_contract) + if action is None: + _refuse() + try: + handoff=_validate(cfg,action,execution_contract) + return TaskExecutionAuthorization(_ISSUED,action=action,contract=ExecutionContract.parse(execution_contract.as_dict()), + coordinates=_coordinates(cfg),handoff=handoff) + except (ValueError,KeyError,TypeError,RecursionError) as exc: + raise DecisionError('strict task execution handoff malformed') from exc + + +def consume_task_execution(cfg, authorization): + if type(authorization) is not TaskExecutionAuthorization: + _refuse() + with authorization._lock: + if authorization._used or _coordinates(cfg)!=authorization._coordinates: + _refuse() + # Reserve once before all work; failures/unknown outcomes never retry this + # prepared object. Platform's durable journal is the cross-process guard. + object.__setattr__(authorization,'_used',True) + try: + handoff=_validate(cfg,authorization._action,authorization._contract) + if handoff!=authorization.as_owner_handoff(): + _refuse() + result=consume_approval(base_url=str(cfg.approval_url), + token_provider=lambda:approval_token(cfg,scope='approval:consume'), + binding=authorization._action.binding,opener=credential_urlopen,strict_response=True) + stamp=_stamp(result.consumed_at) + window=read_window(cfg);_,upper=validity_bounds(window=window) + if (stamp>upper or (handoff['decision_not_before'] and stamp<_stamp(handoff['decision_not_before'])) + or any(stamp>=_stamp(value) for value in (handoff['decision_expires_at'],handoff['approval_expires_at'], + handoff['execution_contract']['operation_deadline']))): + _refuse() + # Late CAS completion is historical evidence only; refuse native dispatch. + _validate(cfg,authorization._action,authorization._contract) + return TaskExecutionConsumption(result.approval_id,result.request_digest,result.decision_id,result.consumed_at,result.idempotent) + except (ValueError,KeyError,TypeError,RecursionError) as exc: + raise DecisionError('strict task execution consumption malformed') from exc diff --git a/tests/test_release_bundle.py b/tests/test_release_bundle.py new file mode 100644 index 0000000..c481cd5 --- /dev/null +++ b/tests/test_release_bundle.py @@ -0,0 +1,137 @@ +"""Actual offline installation proof; only supplied locked public dependency wheel. + +Set SECRETS_ENGINE_TEST_DEPENDENCY_WHEEL to a local PyYAML wheel from uv.lock. +No production install, trust admission, backend or credential is used. +""" +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + +from tests.test_decision_signature import FIXTURE, PUBLIC +from tests.test_decision_transport import server + +ROOT = Path(__file__).resolve().parents[1] + + +def run(command, **kwargs): + return subprocess.run(command, text=True, capture_output=True, timeout=120, **kwargs) + + +@pytest.fixture(scope='module') +def bundles(tmp_path_factory): + dependency=os.environ.get('SECRETS_ENGINE_TEST_DEPENDENCY_WHEEL') + if not dependency: + pytest.skip('provide the locked public PyYAML wheel for actual bundle build/install tests') + directory=tmp_path_factory.mktemp('cli-bundles') + result=[] + for name in ['one','two']: + bundle=directory/name + built=run([sys.executable,str(ROOT/'scripts/build_release_bundle.py'),'--output',str(bundle), + '--dependency-wheel',dependency]) + assert built.returncode==0,built.stderr + result.append(bundle) + return result + + +def manifest_hash(bundle): + return hashlib.sha256((bundle/'manifest.json').read_bytes()).hexdigest() + + +def install(bundle,prefix): + return run([sys.executable,str(bundle/'install.py'),'--bundle',str(bundle),'--prefix',str(prefix), + '--manifest-sha256',manifest_hash(bundle)]) + + +@pytest.fixture +def installed(bundles,tmp_path): + prefix=tmp_path/'installed' + result=install(bundles[0],prefix) + assert result.returncode==0,result.stderr + return prefix + + +def test_two_builds_match_and_contain_no_signer(bundles): + one,two=bundles + names={str(path.relative_to(one)) for path in one.rglob('*') if path.is_file()} + assert names=={str(path.relative_to(two)) for path in two.rglob('*') if path.is_file()} + for name in names: + assert (one/name).read_bytes()==(two/name).read_bytes(),name + assert not any('signer' in name or 'testsupport' in name for name in names) + assert (one/'bin/secrets-engine-pdp-verify').is_file() + assert len(list((one/'wheels').glob('secrets_engine-*.whl')))==1 + + +def test_clean_install_uses_installed_wheel_and_signed_boundary(installed,tmp_path): + env={**os.environ,'SECRETS_ENGINE_PDP_VERIFIER':'/must-not-use-ambient-override'} + help_result=run([str(installed/'bin/secrets-engine'),'--help'],env=env,cwd='/tmp') + assert help_result.returncode==0,help_result.stderr + envelope=json.loads(FIXTURE.read_bytes()) + trust={name:envelope['provenance'][name] for name in ('evaluator','mode','policy_package','policy_version','policy_package_digest')} + trust.update(schema='secrets-engine.pdp-trust.v1',keys={'testdata-ed25519':PUBLIC}) + trust_file=tmp_path/'synthetic-trust.json';trust_file.write_text(json.dumps(trust)) + token=tmp_path/'synthetic.token';token.write_text('synthetic-pdp-credential');token.chmod(0o600) + script='''import pathlib,sys +import secrets_engine.decision_check as installed +from secrets_engine.errors import DecisionError +assert str(pathlib.Path(installed.__file__).resolve()).startswith(sys.argv[1]+'/venv/') +try: + result=installed.check_decision(base_url=sys.argv[2],token_file=pathlib.Path(sys.argv[3]),request={},trust_file=pathlib.Path(sys.argv[4]),verifier=pathlib.Path(sys.argv[1]+'/libexec/secrets-engine-pdp-verify')) +except DecisionError: + if sys.argv[5]=='refuse':sys.exit(0) + raise +assert sys.argv[5]=='accept' and result['effect']=='allow' +''' + clean_env={key:value for key,value in os.environ.items() if key not in ('PYTHONPATH','PYTHONHOME')} + for body,expect in [(FIXTURE.read_bytes(),'accept'),(b'{"effect":"allow"}','refuse')]: + with server(body=body) as (origin,seen): + result=run([str(installed/'venv/bin/python'),'-c',script,str(installed),origin,str(token),str(trust_file),expect],env=clean_env,cwd='/tmp') + assert result.returncode==0,result.stderr + assert seen==[True] + + +@pytest.mark.parametrize('change',['missing','tampered']) +def test_installed_cli_refuses_missing_or_tampered_helper(installed,change): + helper=installed/'libexec/secrets-engine-pdp-verify' + if change=='missing':helper.rename(helper.with_suffix('.held')) + else:helper.write_bytes(helper.read_bytes()+b'tampered') + result=run([str(installed/'bin/secrets-engine'),'--help'],cwd='/tmp') + assert result.returncode==1 + assert 'verifier missing or changed' in result.stderr + assert not result.stdout + + +def test_installer_refuses_changed_artifact_and_existing_prefix(bundles,tmp_path): + altered=tmp_path/'altered';shutil.copytree(bundles[0],altered) + helper=altered/'bin/secrets-engine-pdp-verify';helper.write_bytes(helper.read_bytes()+b'tampered') + prefix=tmp_path/'must-not-create' + result=install(altered,prefix) + assert result.returncode==1 and not prefix.exists() + existing=tmp_path/'existing';existing.mkdir();marker=existing/'keep';marker.write_text('unchanged') + result=install(bundles[0],existing) + assert result.returncode==1 and marker.read_text()=='unchanged' + assert sorted(path.name for path in existing.iterdir())==['keep'] + + +def test_installer_requires_exact_reviewed_manifest(bundles,tmp_path): + prefix=tmp_path/'must-not-create' + result=run([sys.executable,str(bundles[0]/'install.py'),'--prefix',str(prefix),'--manifest-sha256','0'*64]) + assert result.returncode==1 and not prefix.exists() + + +def test_changed_producer_source_stops_before_build(tmp_path): + root=tmp_path/'source';(root/'scripts').mkdir(parents=True) + for name in ['build_release_bundle.py','check_pdp_producer_source.py']: + shutil.copyfile(ROOT/'scripts'/name,root/'scripts'/name) + shutil.copytree(ROOT/'verifier',root/'verifier') + shutil.copytree(ROOT/'tests/fixtures/pdp-signature',root/'tests/fixtures/pdp-signature') + source=root/'verifier/internal/sign/sign.go';source.write_bytes(source.read_bytes()+b'// drift\n') + output=tmp_path/'must-not-build' + result=run([sys.executable,str(root/'scripts/build_release_bundle.py'),'--output',str(output), + '--dependency-wheel',str(tmp_path/'not-reached.whl')]) + assert result.returncode==1 and not output.exists() diff --git a/tests/test_task_execution_handoff.py b/tests/test_task_execution_handoff.py new file mode 100644 index 0000000..2204da2 --- /dev/null +++ b/tests/test_task_execution_handoff.py @@ -0,0 +1,231 @@ +"""Real local HTTP/producer signatures, synthetic approval/caller only. + +These source fixtures never enable Access's runtime-deny policy or admit a real +caller, key, owner grant or native backend. Whole-provider integration is separate. +""" +import copy +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +from threading import Thread + +import pytest + +from secrets_engine.approval_claim import binding_from_check_request, claim_binding_digest +from secrets_engine.approval_consume import _expected_request +from secrets_engine.authorization import request_digest, approval_binding_digest +from secrets_engine.errors import DecisionError +from secrets_engine.execution_contract import ExecutionContract +from secrets_engine.task_execution_handoff import (authorize_task_execution,consume_task_execution, + TaskExecutionAuthorization) +from tests.authorization_stub import AuthorizationStub +from tests.signature_support import signature_tools, admit_tools, sign_envelope +from tests.test_consume_binding_join import _Cfg,_token,APPROVAL_ID +from tests.test_execution_context import entry,descriptor + + +def stamp(moment): + return moment.isoformat() + + +@pytest.fixture +def lane(tmp_path,signature_tools): + e=entry();contract=ExecutionContract.parse(descriptor(e)) + cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service') + admit_tools(cfg,tmp_path,signature_tools) + original=_expected_request(cfg,e,'execute-ccr',execution_contract=contract) + stub=AuthorizationStub(approval_id=APPROVAL_ID,package=cfg.authorization_policy_package,version=cfg.authorization_policy_version) + stub.bind_request(original) + state={'calls':[],'claim_change':None,'decision_change':None,'response_change':None,'raw_response':None} + claim=stub.claim();native=binding_from_check_request(original) + claim['binding'].update(native,digest=claim_binding_digest(**native),human_control=True) + state['claim']=claim + + class Handler(BaseHTTPRequestHandler): + def log_message(self,*_args):pass + def reply(self,payload): + data=json.dumps(payload).encode() if isinstance(payload,dict) else payload + self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data) + def do_GET(self): + state['calls'].append('claim') + value=copy.deepcopy(state['claim']) + if state['claim_change']:state['claim_change'](value) + self.reply(value) + def do_POST(self): + submitted=json.loads(self.rfile.read(int(self.headers['Content-Length']))) + if self.path=='/v1/check': + state['calls'].append('check') + envelope=stub.decision(submitted) + envelope['provenance']['caller']={'mode':'enforce','principal':'system:serviceaccount:secrets-engine:secrets-engine', + 'audience':'flex-auth','not_after':stamp(datetime.now(timezone.utc)+timedelta(minutes=20))} + if state['decision_change']:state['decision_change'](envelope) + self.reply(sign_envelope(envelope,signature_tools)) + else: + state['calls'].append('consume') + payload={'approval_id':APPROVAL_ID,'status':'consumed','request_digest':submitted['request_digest'], + 'decision_id':submitted['decision_id'],'consumed_at':stamp(datetime.now(timezone.utc)),'idempotent':False} + if state['response_change']:state['response_change'](payload) + self.reply(state['raw_response'] if state['raw_response'] is not None else payload) + httpd=ThreadingHTTPServer(('127.0.0.1',0),Handler) + thread=Thread(target=httpd.serve_forever,kwargs={'poll_interval':0.01},daemon=True);thread.start() + cfg.approval_url=cfg.pdp_url=f'http://127.0.0.1:{httpd.server_port}' + try:yield cfg,e,contract,state + finally:httpd.shutdown();httpd.server_close();thread.join(2) + + +def prepared(lane): + cfg,e,contract,state=lane + return authorize_task_execution(cfg,e,execution_contract=contract) + + +def test_exact_preparation_then_one_strict_cas(lane): + cfg,e,contract,state=lane + result=prepared(lane) + assert state['calls']==['claim','check'] + handoff=result.as_owner_handoff() + assert set(handoff)=={'execution_contract','approval_id','request_digest','decision_id','native_binding','approval_binding_digest', + 'submitted_request_digest','decision_not_before','decision_expires_at','approval_expires_at'} + assert handoff['execution_contract']==contract.as_dict() + assert handoff['native_binding']==binding_from_check_request(_expected_request(cfg,e,'execute-ccr',execution_contract=contract)) + changed=result.as_owner_handoff();changed['execution_contract']['ccr_id']='changed' + assert result.as_owner_handoff()==handoff + with pytest.raises(AttributeError):result._handoff='changed' + receipt=consume_task_execution(cfg,result) + assert receipt.approval_id==handoff['approval_id'] and receipt.request_digest==handoff['request_digest'] + assert receipt.decision_id==handoff['decision_id'] and receipt.idempotent is False + assert state['calls']==['claim','check','consume'] + with pytest.raises(DecisionError):consume_task_execution(cfg,result) + assert state['calls'].count('consume')==1 + + +@pytest.mark.parametrize('field',['action','actor','principal','purpose','target','digest']) +def test_changed_native_claim_refuses_before_cas(lane,field): + cfg,e,contract,state=lane + def change(claim):claim['binding'][field]={'id':'other'} if field=='target' else 'other' + state['claim_change']=change + with pytest.raises(DecisionError):prepared(lane) + assert 'consume' not in state['calls'] + + +@pytest.mark.parametrize('field',['mode','principal','audience','not_after']) +def test_signed_wrong_caller_refuses_before_cas(lane,field): + cfg,e,contract,state=lane + state['decision_change']=lambda envelope:envelope['provenance']['caller'].__setitem__(field,'wrong') + with pytest.raises(DecisionError):prepared(lane) + assert 'consume' not in state['calls'] + + +@pytest.mark.parametrize('field',['execution_actor','execution_grant_ccr_id','grant_immutable_sha256','ccr_id','backend_origin']) +def test_signed_changed_descriptor_refuses_before_cas(lane,field): + cfg,e,contract,state=lane + def change(envelope):envelope['binding']['context']['execution_contract'][field]='wrong' + state['decision_change']=change + with pytest.raises(DecisionError):prepared(lane) + assert 'consume' not in state['calls'] + + +@pytest.mark.parametrize('field',['subject','resource','request_digest','submitted_request_digest','approval_binding_digest']) +def test_signed_changed_request_correspondence_refuses_before_cas(lane,field): + cfg,e,contract,state=lane + def change(envelope): + envelope['binding'][field]={'id':'other'} if field in ('subject','resource') else 'sha256:'+'0'*64 + state['decision_change']=change + with pytest.raises(DecisionError):prepared(lane) + assert 'consume' not in state['calls'] + + +@pytest.mark.parametrize('field',['approval_id','decision_id','request_digest','status','consumed_at','idempotent']) +def test_cas_correspondence_never_coerces_or_falls_back(lane,field): + cfg,e,contract,state=lane;result=prepared(lane) + state['response_change']=lambda payload:payload.__setitem__(field,'wrong') + with pytest.raises(DecisionError):consume_task_execution(cfg,result) + with pytest.raises(DecisionError):consume_task_execution(cfg,result) + assert state['calls'].count('consume')==1 + + +@pytest.mark.parametrize('value',[True,1,0,'false',None]) +def test_nonfresh_or_nonboolean_idempotence_refuses(lane,value): + cfg,e,contract,state=lane;result=prepared(lane) + state['response_change']=lambda payload:payload.__setitem__('idempotent',value) + with pytest.raises(DecisionError):consume_task_execution(cfg,result) + + +@pytest.mark.parametrize('body',[b'{"status":"consumed","status":"consumed"}',b'{"status":NaN}',b'['*2000+b'0'+b']'*2000]) +def test_duplicate_nonfinite_or_deep_consume_body_refuses(lane,body): + cfg,e,contract,state=lane;result=prepared(lane) + state['raw_response']=body + with pytest.raises(DecisionError):consume_task_execution(cfg,result) + + +@pytest.mark.parametrize('field',['pdp_url','approval_url','authorization_subject_id','authorization_policy_version','pdp_verifier']) +def test_changed_owner_coordinates_refuse_before_cas(lane,field): + cfg,e,contract,state=lane;result=prepared(lane);setattr(cfg,field,'changed') + with pytest.raises(DecisionError):consume_task_execution(cfg,result) + assert 'consume' not in state['calls'] + + +def test_removed_signer_refuses_before_cas(lane): + cfg,e,contract,state=lane;result=prepared(lane) + trust=json.loads(cfg.pdp_trust_file.read_text());trust['keys']={'other':next(iter(trust['keys'].values()))} + cfg.pdp_trust_file.write_text(json.dumps(trust)) + with pytest.raises(DecisionError):consume_task_execution(cfg,result) + assert 'consume' not in state['calls'] + + +def test_mapping_boolean_and_public_constructor_are_not_authority(lane): + cfg,e,contract,state=lane + for value in [True,{},object()]: + with pytest.raises(DecisionError):consume_task_execution(cfg,value) + with pytest.raises(DecisionError):TaskExecutionAuthorization() + assert not state['calls'] + + +@pytest.mark.parametrize('field',['approval_id','decision_id','consumed_at','idempotent']) +def test_absent_cas_fields_cannot_fall_back(lane,field): + cfg,e,contract,state=lane;result=prepared(lane) + state['response_change']=lambda payload:payload.pop(field) + with pytest.raises(DecisionError):consume_task_execution(cfg,result) + assert state['calls'].count('consume')==1 + + +def test_expired_preparation_refuses_before_cas(lane,monkeypatch): + from types import SimpleNamespace + import secrets_engine.task_execution_handoff as module + cfg,e,contract,state=lane;result=prepared(lane) + later=int((datetime.now(timezone.utc)+timedelta(minutes=2)).timestamp()*1_000_000_000) + monkeypatch.setattr(module,'read_window',lambda cfg:SimpleNamespace(lower_ns=later,upper_ns=later)) + with pytest.raises(DecisionError):consume_task_execution(cfg,result) + assert 'consume' not in state['calls'] + + +@pytest.mark.parametrize('change',['deny','expired','missing-caller','missing-start']) +def test_signed_decision_missing_authority_refuses_before_cas(lane,change): + cfg,e,contract,state=lane + def alter(envelope): + if change=='deny':envelope['effect']='deny' + elif change=='expired':envelope['lifetime']['expires_at']=stamp(datetime.now(timezone.utc)-timedelta(seconds=1)) + elif change=='missing-start':envelope['lifetime'].pop('not_before') + else:envelope['provenance'].pop('caller') + state['decision_change']=alter + with pytest.raises(DecisionError):prepared(lane) + assert 'consume' not in state['calls'] + + +def test_native_binding_equality_does_not_coerce_boolean_enrichment(lane): + cfg,e,contract,state=lane + evaluated=_expected_request(cfg,e,'execute-ccr',execution_contract=contract) + evaluated['resource']['attributes']['synthetic_rank']=1 + native=binding_from_check_request(evaluated) + state['claim']['binding'].update(copy.deepcopy(native),digest=claim_binding_digest(**native), + pdp_digest=approval_binding_digest(evaluated)) + # Python dict equality treats True == 1. This malformed provider fact must + # refuse even when its reported native digest names the integer counterpart. + state['claim']['binding']['target']['attributes']['synthetic_rank']=True + def enrich(envelope): + envelope['resource']['attributes']['synthetic_rank']=1 + envelope['binding']['request_digest']=request_digest(envelope['binding']) + envelope['binding']['approval_binding_digest']=approval_binding_digest(envelope['binding']) + state['decision_change']=enrich + with pytest.raises(DecisionError):prepared(lane) + assert 'consume' not in state['calls'] diff --git a/verifier/README.md b/verifier/README.md index 073cace..a63b2a0 100644 --- a/verifier/README.md +++ b/verifier/README.md @@ -28,3 +28,8 @@ The Check API defaults to mandatory authentication. Its deliberately named `allow_unsigned_test_fixture` argument is used only by synthetic transport tests; production `authorize_action` never passes it. The helper runs with an empty environment, so ambient backend and identity credentials cannot reach it. + +For the complete reproducible CLI build and offline fresh-prefix installation, +use [the release bundle contract](../docs/cli-release-bundle.md). This replaces +manual helper copying for packaged deployments; package and key admission remain +separate owner decisions. diff --git a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md index 01be511..a8a3845 100644 --- a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md +++ b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md @@ -9,7 +9,7 @@ flavor: planning owner: codex topic_slug: netkingdom created: "2026-10-11" -updated: "2026-10-11" +updated: "2026-10-16" depends_on: [] state_hub_workstream_id: "5e38aecc-85ca-5f55-94c6-9fbb08604819" --- @@ -110,3 +110,17 @@ This is source preparation awaiting independent technical review/publication, not a formal owner assent, policy deployment or native execution milestone. T01 remains progress; T03 and SECRETS-WP-0008-T06 remain open. Detailed limitations and retained failed test observations are in the source preparation receipt. + + +2026-10-16 bounded source handoff preparation: `task_execution_handoff.py` uses +the actual serving approval/mandatory signed Check path and retained detached +artifacts to check exact descriptor/request/native approval/caller/lifetime joins, +then performs one strict exact-response CAS only after Platform journal admission. +It accepts no inbound boolean/mapping/authorizer callback and grants no native +execution. Platform independently confirms durable consumption with the actual +Approval provider client before native dispatch. Focused local HTTP/native Go +producer-signature source checks cover altered joins/caller/grant, missing fields, +coercion/idempotence, expiry, signer removal and replay refusal. No service binding +propagation, policy gate flip, identity mapping admission or production trust/key +change was made. Independent source integration/review and actual whole-provider +proof remain required. T01/T03 and WP8-T06 retain their open native obligations. diff --git a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md index a3e55a9..886a0f6 100644 --- a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +++ b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md @@ -77,6 +77,15 @@ key admission remains part of its scope; T03 stays wait. WP13 execution and identity-only CCR38 are unchanged. Custodian source/verification handoff: `docs/changes/netkingdom-target-2026-10-16/decision-auth/` in the Custodian repo. +2026-10-16 packaging preparation: `scripts/build_release_bundle.py` and its +separate offline installer deliver the existing wheel plus the reviewed producer +verifier together into a new owner-controlled prefix. Source/hash drift checks, +artifact/toolchain receipts and a narrow fixed-helper launcher remove the silent +wheel-only deployment gap. Disposable build/install tests prove deterministic +artifacts, signed HTTP consumption and missing/tampered artifact refusal; they do +not admit packages, keys or a production prefix. T02 remains progress and T03 +wait; no native installation, trust change or protected entitlement is claimed. + ## Verify the admitted native consumer and rotation path ```task