From 4dd004a2250104ba56293b2d7b0d96f4e076ea99 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 16 Oct 2026 17:28:03 +0200 Subject: [PATCH] Verify normalized task approval facts and preserve signed source types Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786 --- docs/task-execution-handoff.md | 37 ++- .../task-scoped-openbao-execution-contract.md | 20 +- src/secrets_engine/approval_consume.py | 3 +- src/secrets_engine/decision_signature.py | 22 +- src/secrets_engine/execution_contract.py | 12 + src/secrets_engine/task_execution_handoff.py | 136 ++++++++++-- .../approval-native-vectors.json | 210 ++++++++++++++++++ tests/test_execution_context.py | 25 ++- tests/test_task_approval_preparation.py | 89 ++++++++ tests/test_trusted_successor.py | 80 +++++++ verifier/README.md | 22 +- verifier/internal/sign/sign.go | 19 ++ verifier/main.go | 8 +- verifier/pkg/api/canonical.go | 34 ++- verifier/producer-source.json | 12 +- verifier/testsupport/signer/main.go | 10 +- ...S-WP-0013-task-scoped-openbao-execution.md | 16 ++ ...-transport-and-responder-authentication.md | 61 +++++ 18 files changed, 756 insertions(+), 60 deletions(-) create mode 100644 tests/fixtures/pdp-signature/approval-native-vectors.json create mode 100644 tests/test_task_approval_preparation.py create mode 100644 tests/test_trusted_successor.py diff --git a/docs/task-execution-handoff.md b/docs/task-execution-handoff.md index 5821175..c62dbe4 100644 --- a/docs/task-execution-handoff.md +++ b/docs/task-execution-handoff.md @@ -38,7 +38,9 @@ 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. +The later typed execution builder now propagates the fixed Access public +service_actor_binding before approval issue; it is not caller authentication. +No source-policy activation is added. `as_owner_handoff()` returns a detached non-secret mapping with: @@ -76,3 +78,36 @@ 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. + + +2026-10-16 retained-registry preapproval source preparation adds +`prepare_task_approval(cfg, entry, execution_contract=contract)`. It calls the +existing signed Check client with the original claim-free typed proposal and +accepts only a signed DENY normalization record. This neither authorizes nor +consumes an operation. No registry algorithm is copied and no registry is emptied. + +The detached `as_owner_handoff()` returns `original_request`, +`submitted_request_digest`, authoritative `native_binding`, +`approval_binding_digest`, normalization `decision_id`, `execution_contract`, and +signed `registry_snapshot_digest`. Exact original context (including JSON kinds), +submitted digest, recomputed evaluated digest, explicit approval-free digest, +policy/key admission, service/caller identity, decision time and deadline are +checked. Registry digest is evidence from the admitted signer; this method does +not independently grant registry/config admission. The caller uses the returned +native act and approval-free digest for normal Approval issuance and human entry. +It preserves the original proposal; final authorize_task_execution builds that +same proposal and attaches only the original issued claim. Access normalizes +again and independently joins current source facts. Changed evaluated facts or +native target refuse before CAS. A normalization DENY object/mapping is never +accepted by consume_task_execution. + +Normalization decision_time must be valid and nonfuture; there is deliberately +no invented standalone normalization TTL. The DENY receipt is not permission and +cannot discharge final signed ALLOW or source validation. Expired original task/ +caller bounds refuse at preparation; a stale normalization's changed evaluated +facts refuse when Access independently checks the original issued claim against +the current retained registry. Unchanged historical normalization can only supply +public issue metadata; it cannot itself produce a CAS/native admission. The actual +source-server drift proof restarts Access with changed authoritative subject +roles after preapproval and observes final refusal with no new ALLOW record, no +CAS and unconsumed Approval state. diff --git a/docs/task-scoped-openbao-execution-contract.md b/docs/task-scoped-openbao-execution-contract.md index 6128f2e..b9fea01 100644 --- a/docs/task-scoped-openbao-execution-contract.md +++ b/docs/task-scoped-openbao-execution-contract.md @@ -65,7 +65,7 @@ before observing the claim or calling Check. Its fields are: | source_bundle | Absolute owner-resolved files and immutable SHA256 hashes for engine dispatcher, Platform mediator, typed CCR module and policy/auth source | | backend_origin / cluster_uid | Exact reviewed estate OpenBao origin and railiance01 identity | | operations | Ordered exact HTTP method, normalized path and canonical JSON/text payload hash for each read/write/readback; no user supplied body | -| operation_deadline | Upper bound no later than approval, PDP or task-token expiry | +| operation_deadline | Upper bound no later than approval, PDP, caller and original/fresh source validity; task-token expiry may be earlier | | backend_budget | TTL at most 120s, at most twelve uses, no renew/child/wrap/unlimited uses | | expected_before / expected_after | Owner-authenticated revision/state digests, never secret contents | @@ -158,10 +158,20 @@ validation while Platform owns its OpenBao credential and typed native primitive Task-token use budget is declared before issuance: two before-state reads, two writes, two readbacks and one self-revoke (seven uses), plus at most four explicit metadata capability checks and one reserved cleanup use (twelve total). Failed -backend calls count; never retry to evade limits. Task TTL is -`min(120s, approval_remaining, decision_remaining, operation_remaining)` minus -the declared clock uncertainty/cleanup reserve. Reject if fewer than 30s remain -for planned requests. No renewal or remint extends the operation. CCR38 keeps its +backend calls count; never retry to evade limits. The immutable +`backend_budget.ttl_seconds` declares the requested task-token TTL (30–120s). +The native adapter does not dynamically recompute or shorten that declaration. +Before issuance, conservatively require at least the declared TTL plus the 5s +cleanup reserve within the remaining operation/approval/PDP authority, including +clock uncertainty. The requested and explicit maximum native token TTL equal +the declared budget; returned native TTL must be positive and at most that budget. +The token can expire before the operation deadline; failure or expiry stops work +without remint. The signed operation deadline remains no later than approval, +PDP, authenticated caller and both original/fresh source validity. Access's +trusted source path independently requires the same TTL-plus-5s fit after source +I/O and an operation interval no longer than 120s. Therefore a structural 120s +backend budget cannot fit inside a 120s authority interval with the reserve and +refuses. No renewal or remint extends the operation. CCR38 keeps its own 300s/eight-use limit; login-self checks and revoke fit its separate budget. ## Failure, replay and recovery diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py index 1cf4140..e4865bb 100644 --- a/src/secrets_engine/approval_consume.py +++ b/src/secrets_engine/approval_consume.py @@ -382,7 +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}, + artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope, + "authenticated_envelope_json":getattr(envelope,"authenticated_json","")}, sort_keys=True,separators=(",",":"),allow_nan=False), ) diff --git a/src/secrets_engine/decision_signature.py b/src/secrets_engine/decision_signature.py index 24fa8d3..d9bd0b9 100644 --- a/src/secrets_engine/decision_signature.py +++ b/src/secrets_engine/decision_signature.py @@ -7,6 +7,7 @@ from __future__ import annotations import base64 import json +import math from pathlib import Path import stat import subprocess @@ -24,8 +25,15 @@ def _unique_object(pairs): return result +def _finite_float(value): + parsed = float(value) + if not math.isfinite(parsed): + raise ValueError("nonfinite JSON number") + return parsed + + def strict_json(data): - return json.loads(data, object_pairs_hook=_unique_object, + return json.loads(data, object_pairs_hook=_unique_object, parse_float=_finite_float, parse_constant=lambda value: (_ for _ in ()).throw(ValueError("invalid number"))) @@ -66,10 +74,18 @@ def load_trust(path: Path | None) -> dict[str, Any]: raise DecisionError("access-engine responder trust is missing or invalid") from exc +class AuthenticatedEnvelope(dict): + """Verified typed values with exact producer JSON retained for reauthentication.""" + def __init__(self, values, authenticated_json): + super().__init__(values) + self.authenticated_json = authenticated_json + + def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) -> dict: if verifier is None or not Path(verifier).is_absolute(): raise DecisionError("access-engine signature verifier is not installed") try: + strict_json(body.decode("utf-8")) result = subprocess.run([str(verifier)], input=b'{"envelope":'+body+b',"keys":'+json.dumps(trust['keys']).encode()+b'}', capture_output=True, env={}, timeout=3, check=False) if result.returncode != 0 or len(result.stdout) > 524288: @@ -78,6 +94,6 @@ def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) -> provenance = authenticated['provenance'] if any(provenance.get(k) != trust[k] for k in ('evaluator','mode','policy_package','policy_version','policy_package_digest')): raise ValueError("issuer/pin mismatch") - return authenticated - except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError) as exc: + return AuthenticatedEnvelope(authenticated, result.stdout.decode("utf-8")) + except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError, UnicodeDecodeError) as exc: raise DecisionError("access-engine responder authentication failed") from exc diff --git a/src/secrets_engine/execution_contract.py b/src/secrets_engine/execution_contract.py index 5dc1cae..f9b2538 100644 --- a/src/secrets_engine/execution_contract.py +++ b/src/secrets_engine/execution_contract.py @@ -153,6 +153,18 @@ def attach_execution_contract(request: dict, contract: ExecutionContract | None) raise DecisionError('execute-ccr requires platform tenant') if value['catalog_sha256'] != catalog_target_digest(request['context']['catalog_target']): raise DecisionError('execution_contract catalog target does not match') + if request['subject'] != {'id':'secrets-engine','type':'service'}: + raise DecisionError('execute-ccr requires secrets-engine service subject') + # Match the producer's fixed tenant normalization before approval issue. + request['subject']['tenant'] = 'tenant:platform' + request['resource']['tenant'] = 'tenant:platform' + # Public declaration from Access ServicePilotInputs.DeclaredServiceBinding; + # bound before approval issuance, never an authentication assertion. + request['context']['service_actor_binding'] = { + 'version':'proposed-service-task-caller/v1', + 'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine', + 'subject_id':'secrets-engine','subject_type':'service','tenant':'tenant:platform', + } request['context']['execution_contract'] = value request['context']['human_control'] = True diff --git a/src/secrets_engine/task_execution_handoff.py b/src/secrets_engine/task_execution_handoff.py index 9772861..aa998ab 100644 --- a/src/secrets_engine/task_execution_handoff.py +++ b/src/secrets_engine/task_execution_handoff.py @@ -10,14 +10,16 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime import json +import re 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.approval_consume import authorize_action, consume_approval, _expected_request 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.authorization import validate_decision_envelope, request_digest, approval_binding_digest, CONTRACT_VERSION from secrets_engine.decision_signature import authenticate_response, load_trust, strict_json +from secrets_engine.decision_check import check_decision from secrets_engine.errors import DecisionError from secrets_engine.execution_contract import ExecutionContract, require_execution_deadline @@ -77,6 +79,93 @@ class TaskExecutionConsumption: idempotent: bool +def _same_json(left, right): + # Type-sensitive semantic correspondence only; never signature material. + return json.dumps(left,sort_keys=True,allow_nan=False)==json.dumps(right,sort_keys=True,allow_nan=False) + + +def _task_identity(request,envelope,descriptor,upper): + binding=envelope['binding'] + subject=binding['subject']; resource=binding['resource'] + if (request['subject']!={'id':'secrets-engine','type':'service','tenant':'tenant:platform'} + 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 not _same_json(envelope['subject'],subject) or not _same_json(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(not _same_json(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() + + +@dataclass(frozen=True) +class TaskApprovalPreparation: + """Detached public normalization facts; never authorization or consumption.""" + _serialized: str + + def as_owner_handoff(self): + return strict_json(self._serialized) + + +def prepare_task_approval(cfg, entry, *, execution_contract): + """Read signed DENY normalization before Approval issue; never permit an act. + + This read-only normalization record has no independent permission window. + decision_time must be a valid nonfuture instant; original operation/caller + deadlines remain live. Final source Check independently revalidates current + registry/source facts; stale normalized facts cannot replace that Check. + + Use returned authoritative native_binding and approval_binding_digest for + issue, preserving original_request. Final Check carries only the original + issued claim; producer renormalization independently refuses registry drift. + """ + if type(execution_contract) is not ExecutionContract: + _refuse() + request=_expected_request(cfg,entry,'execute-ccr',execution_contract=execution_contract) + window=read_window(cfg) + require_execution_deadline(request,time_window=window) + envelope=check_decision(base_url=str(cfg.pdp_url),token_file=cfg.pdp_token_file,request=request, + trust_file=cfg.pdp_trust_file,verifier=cfg.pdp_verifier) + try: + window=read_window(cfg);lower,upper=validity_bounds(window=window) + descriptor=execution_contract.as_dict();binding=envelope['binding'];provenance=envelope['provenance'] + if (envelope.get('contract_version')!=CONTRACT_VERSION or envelope.get('effect')!='deny' or not isinstance(envelope.get('id'),str) or not envelope['id'] + or provenance.get('policy_package')!=str(cfg.authorization_policy_package) + or provenance.get('policy_version')!=str(cfg.authorization_policy_version) + or not isinstance(provenance.get('registry_snapshot_digest'),str) + or re.fullmatch(r'sha256:[0-9a-f]{64}',provenance['registry_snapshot_digest']) is None + or request['action']!='execute-ccr' or binding.get('action')!='execute-ccr' + or request['tenant']!='tenant:platform' or binding.get('tenant')!='tenant:platform' + or 'approval' in request['context'] or 'approval' in binding['context'] + or not _same_json(binding.get('context'),request['context']) + or binding.get('submitted_request_digest')!=request_digest(request) + or binding.get('request_digest')!=request_digest(binding) + or binding.get('approval_binding_digest')!=binding['request_digest'] + or not _same_json(envelope.get('subject'),binding['subject']) or not _same_json(envelope.get('resource'),binding['resource']) + or _stamp(provenance.get('decision_time'))>upper): + _refuse() + _task_identity(request,envelope,descriptor,upper) + require_execution_deadline(request,time_window=window,bounds=(provenance['caller']['not_after'],)) + native=binding_from_check_request(binding) + claim_binding_digest(**native) # Require native JSON profile, not a translated digest. + result={'original_request':request,'submitted_request_digest':binding['submitted_request_digest'], + 'native_binding':native,'approval_binding_digest':binding['approval_binding_digest'], + 'decision_id':envelope['id'],'execution_contract':descriptor, + 'registry_snapshot_digest':provenance['registry_snapshot_digest']} + return TaskApprovalPreparation(json.dumps(result,sort_keys=True,separators=(',',':'),allow_nan=False)) + except (ValueError,KeyError,TypeError,RecursionError,AttributeError) as exc: + raise DecisionError('task approval preparation malformed') from exc + + def _validate(cfg, action, contract): if type(contract) is not ExecutionContract or not action.artifacts: _refuse() @@ -85,10 +174,10 @@ def _validate(cfg, action, contract): 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(), + authenticated=authenticate_response(artifacts["authenticated_envelope_json"].encode("utf-8"), trust=load_trust(getattr(cfg,'pdp_trust_file',None)), verifier=getattr(cfg,'pdp_verifier',None)) - if authenticated != envelope: + if json.dumps(authenticated,sort_keys=True,allow_nan=False) != json.dumps(envelope,sort_keys=True,allow_nan=False): _refuse() window=read_window(cfg) lower,upper=validity_bounds(window=window) @@ -107,31 +196,14 @@ def _validate(cfg, action, contract): _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 not _same_json(binding.get('context'),request['context']) + or not _same_json(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() + _task_identity(request,envelope,descriptor,upper) native=binding_from_check_request(binding) supplied=claim['binding'] # Recompute the served native act too: Python equality alone conflates @@ -140,6 +212,22 @@ def _validate(cfg, action, contract): 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() + observation=envelope['provenance'].get('approval_observation') + if observation is not None: + # Signed producer source evidence supplements the original carried claim; + # it cannot replace original context, extend freshness, or authorize CAS. + if (not isinstance(observation,dict) + or any(not isinstance(observation.get(k),str) or not observation[k] + for k in ('source_id','source_revision')) + or any(not isinstance(observation.get(k),str) + or re.fullmatch(r'sha256:[0-9a-f]{64}',observation[k]) is None + for k in ('configuration_digest','claim_digest','native_binding_digest')) + or observation['native_binding_digest']!=supplied['digest'] + or _stamp(observation.get('observed_at'))>_stamp(observation.get('validated_at')) + or _stamp(observation.get('validated_at'))>upper + or upper>=_stamp(observation.get('not_after')) + or _stamp(envelope['lifetime']['expires_at'])>_stamp(observation.get('not_after'))): + _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'], diff --git a/tests/fixtures/pdp-signature/approval-native-vectors.json b/tests/fixtures/pdp-signature/approval-native-vectors.json new file mode 100644 index 0000000..46adb26 --- /dev/null +++ b/tests/fixtures/pdp-signature/approval-native-vectors.json @@ -0,0 +1,210 @@ +[ + { + "input_target_json": "{\"value\":true}", + "binding": { + "action": "secrets.kv.destroy", + "target": { + "value": true + }, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key" + }, + "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":true}}", + "native_digest": "sha256:e1f537daa849e5da023efb5f3d62cc33d94cce20f9ca9c5a708c76e225fd702f" + }, + { + "input_target_json": "{\"value\":1}", + "binding": { + "action": "secrets.kv.destroy", + "target": { + "value": 1 + }, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key" + }, + "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1}}", + "native_digest": "sha256:3a3d2da6267e4b917505a7c2247e6ce36fd8ee074eef37251bcc66839b602332" + }, + { + "input_target_json": "{\"value\":1.0}", + "binding": { + "action": "secrets.kv.destroy", + "target": { + "value": 1.0 + }, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key" + }, + "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1.0}}", + "native_digest": "sha256:21c2cdb9b161071ebfe361e50703511b2abf6c6f327f153282d247aa92c3898e" + }, + { + "input_target_json": "{\"value\":false}", + "binding": { + "action": "secrets.kv.destroy", + "target": { + "value": false + }, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key" + }, + "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":false}}", + "native_digest": "sha256:278cbcd013f7923db5258dd860f911d688505a8259f15ccb77983b044f312c74" + }, + { + "input_target_json": "{\"value\":0}", + "binding": { + "action": "secrets.kv.destroy", + "target": { + "value": 0 + }, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key" + }, + "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":0}}", + "native_digest": "sha256:e691b8fb55d4bf82a310dee37c045331100c8031e26ea6f51bdcceeeb3b1f20e" + }, + { + "input_target_json": "{\"value\":-0.0}", + "binding": { + "action": "secrets.kv.destroy", + "target": { + "value": -0.0 + }, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key" + }, + "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":-0.0}}", + "native_digest": "sha256:eccde458c686bff67506bb2d94c750e648a311bf23967cb22b37f11e84b6844f" + }, + { + "input_target_json": "{\"value\":1e-7}", + "binding": { + "action": "secrets.kv.destroy", + "target": { + "value": 1e-07 + }, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key" + }, + "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e-07}}", + "native_digest": "sha256:779042b6c0e675f6aacdcd3a02199aab66f534bb84dd410a95c18657afae563c" + }, + { + "input_target_json": "{\"value\":1e-6}", + "binding": { + "action": "secrets.kv.destroy", + "target": { + "value": 1e-06 + }, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key" + }, + "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e-06}}", + "native_digest": "sha256:d021f402231fcd59e764b31e6f08a5298ebb328dfd93da58ecde8feb11fde0b5" + }, + { + "input_target_json": "{\"value\":1e20}", + "binding": { + "action": "secrets.kv.destroy", + "target": { + "value": 1e+20 + }, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key" + }, + "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e+20}}", + "native_digest": "sha256:14270ffca4ab10e9d9302d52e988b4aa3e2facd494c12a60e2edafef70277a20" + }, + { + "input_target_json": "{\"value\":1e21}", + "binding": { + "action": "secrets.kv.destroy", + "target": { + "value": 1e+21 + }, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key" + }, + "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e+21}}", + "native_digest": "sha256:75a60a595b2578430b0be466b6bf9a46b62ddc99bbbcebd7fa0e8d22e3078c0a" + }, + { + "input_target_json": "{\"value\":2.5e-7}", + "binding": { + "action": "secrets.kv.destroy", + "target": { + "value": 2.5e-07 + }, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key" + }, + "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":2.5e-07}}", + "native_digest": "sha256:50af37ff75a3e7a22629e746f814995c0c2cd96adb7f1ed5b26999b71d0ac498" + }, + { + "input_target_json": "{\"value\":9007199254740993}", + "binding": { + "action": "secrets.kv.destroy", + "target": { + "value": 9007199254740993 + }, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key" + }, + "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":9007199254740993}}", + "native_digest": "sha256:765360dc5a1d949bd114c6b749d83069123d8b4b6b1d939f831428a800f50dab" + }, + { + "input_target_json": "{\"html\":\"<>&\", \"unicode\":\"é雪😀\", \"separator\":\"\\u2028\\u2029\"}", + "binding": { + "action": "secrets.kv.destroy", + "target": { + "html": "<>&", + "unicode": "é雪😀", + "separator": "

" + }, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key" + }, + "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"html\":\"<>&\",\"separator\":\"

\",\"unicode\":\"é雪😀\"}}", + "native_digest": "sha256:c203d934743e2954342038a3746b6c37a0f3101e886ef855b1c6ad824adf5dea" + }, + { + "input_target_json": "{\"b\":[true,1,1.0,null,{\"z\":\"x\",\"a\":\"y\"}],\"a\":{}}", + "binding": { + "action": "secrets.kv.destroy", + "target": { + "b": [ + true, + 1, + 1.0, + null, + { + "z": "x", + "a": "y" + } + ], + "a": {} + }, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key" + }, + "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"a\":{},\"b\":[true,1,1.0,null,{\"a\":\"y\",\"z\":\"x\"}]}}", + "native_digest": "sha256:56306505c882af6edfae5f3d2a5b42e92737a0859236fe5574a21d1874655d51" + } +] diff --git a/tests/test_execution_context.py b/tests/test_execution_context.py index 23b6419..70d9415 100644 --- a/tests/test_execution_context.py +++ b/tests/test_execution_context.py @@ -133,7 +133,7 @@ def test_closed_schema_refuses(mutation): @pytest.mark.parametrize('action', ['apply','rotate','exec','execute-ccr']) def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action): - e=entry(); cfg=_Cfg(_token(tmp_path)); calls=[] + e=entry(); cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[] def fetch(*args,**kwargs): calls.append('claim'); raise AssertionError contract=None if action=='execute-ccr' else ExecutionContract.parse(descriptor(e)) with pytest.raises(DecisionError): @@ -143,7 +143,7 @@ def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action) def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path): e=entry(); typed=ExecutionContract.parse(descriptor(e)); e=replace(e,path='changed/path') - cfg=_Cfg(_token(tmp_path)); calls=[] + cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[] with pytest.raises(DecisionError,match='catalog'): resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=typed,opener=lambda *a,**k:calls.append('claim')) assert not calls @@ -154,7 +154,7 @@ def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path): def test_expired_contract_refuses_before_claim(tmp_path): e=entry(); raw=descriptor(e); raw['operation_deadline']=(datetime.now(timezone.utc)-timedelta(seconds=1)).isoformat() - cfg=_Cfg(_token(tmp_path)); calls=[] + cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[] with pytest.raises(DecisionError,match='expired'): authorize_action(cfg,e,'execute-ccr',execution_contract=ExecutionContract.parse(raw),opener=lambda *a,**k:calls.append('claim')) assert not calls @@ -200,3 +200,22 @@ def test_deadline_beyond_claim_refuses_before_pdp(tmp_path): from tests.test_consume_binding_join import admitted_native_signer from tests.signature_support import signature_tools + + +def test_execution_service_declaration_is_fixed_before_issue(): + e=entry();contract=ExecutionContract.parse(descriptor(e)) + built=request(e,contract) + assert built['context']['service_actor_binding']=={ + 'version':'proposed-service-task-caller/v1', + 'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine', + 'subject_id':'secrets-engine','subject_type':'service','tenant':'tenant:platform'} + assert built['subject']=={'id':'secrets-engine','type':'service','tenant':'tenant:platform'} + assert built['resource']['tenant']=='tenant:platform' + assert 'service_actor_binding' not in build_action_request(e,'apply',subject_id='secrets-engine',subject_type='service',purpose='metadata-test')['context'] + + +@pytest.mark.parametrize('subject_id,subject_type',[('other','service'),('secrets-engine','Human'),('user:alice','Human')]) +def test_execution_declaration_cannot_assert_foreign_service(subject_id,subject_type): + e=entry();contract=ExecutionContract.parse(descriptor(e)) + with pytest.raises(DecisionError,match='service subject'): + build_action_request(e,'execute-ccr',subject_id=subject_id,subject_type=subject_type,purpose='metadata-test',execution_contract=contract) diff --git a/tests/test_task_approval_preparation.py b/tests/test_task_approval_preparation.py new file mode 100644 index 0000000..96b254f --- /dev/null +++ b/tests/test_task_approval_preparation.py @@ -0,0 +1,89 @@ +"""Signed DENY supplies preapproval facts; it never supplies execution authority.""" +import copy +import pytest +from secrets_engine.authorization import request_digest +from secrets_engine.errors import DecisionError +from secrets_engine.task_execution_handoff import prepare_task_approval,consume_task_execution +from tests.test_task_execution_handoff import lane,prepared +from tests.signature_support import signature_tools + + +def normalize(envelope): + envelope['effect']='deny' + envelope['provenance']['registry_snapshot_digest']='sha256:'+'4'*64 + envelope['binding']['subject']['attributes']={'roles':['Operator'],'display_name':'registry service'} + envelope['binding']['request_digest']=request_digest(envelope['binding']) + envelope['binding']['approval_binding_digest']=envelope['binding']['request_digest'] + + +def test_signed_registry_normalization_is_detached_and_cannot_consume(lane): + cfg,e,contract,state=lane + state['decision_change']=normalize + result=prepare_task_approval(cfg,e,execution_contract=contract) + output=result.as_owner_handoff() + assert state['calls']==['check'] + assert 'approval' not in output['original_request']['context'] + assert output['approval_binding_digest']!=output['submitted_request_digest'] + assert 'attributes' not in output['original_request']['subject'] + assert output['execution_contract']==contract.as_dict() + assert output['native_binding']['target']==output['original_request']['resource'] + output['original_request']['context']['purpose']='mutated' + assert result.as_owner_handoff()['original_request']['context']['purpose']!='mutated' + with pytest.raises(DecisionError):consume_task_execution(cfg,result) + assert state['calls']==['check'] + + +@pytest.mark.parametrize('case',['allow','context','descriptor-type','submitted','evaluated','approval-digest','caller','native','future-time','policy']) +def test_signed_normalization_refuses_changed_correspondence(lane,case): + cfg,e,contract,state=lane + def change(envelope): + normalize(envelope) + if case=='allow':envelope['effect']='allow' + elif case=='context':envelope['binding']['context']['purpose']='other' + elif case=='descriptor-type':envelope['binding']['context']['execution_contract']['backend_budget']['ttl_seconds']=120.0 + elif case=='submitted':envelope['binding']['submitted_request_digest']='sha256:'+'0'*64 + elif case=='evaluated':envelope['binding']['request_digest']='sha256:'+'0'*64 + elif case=='approval-digest':envelope['binding']['approval_binding_digest']='sha256:'+'0'*64 + elif case=='caller':envelope['provenance']['caller']['mode']='disabled' + elif case=='native':envelope['binding']['resource']['system']='other' + elif case=='future-time':envelope['provenance']['decision_time']='2099-01-01T00:00:00Z' + else:envelope['provenance']['policy_version']='other' + state['decision_change']=change + with pytest.raises(DecisionError):prepare_task_approval(cfg,e,execution_contract=contract) + assert 'claim' not in state['calls'] and 'consume' not in state['calls'] + + +def test_signed_context_numeric_kind_cannot_change(lane): + cfg,e,contract,state=lane + state['decision_change']=lambda envelope:envelope['binding']['context']['execution_contract']['backend_budget'].__setitem__('ttl_seconds',120.0) + with pytest.raises(DecisionError):prepared(lane) + assert 'consume' not in state['calls'] + + +@pytest.mark.parametrize('original,served',[(1,True),(1,1.0),(1.0,1),(False,0)]) +def test_shared_original_target_subset_is_type_sensitive(lane,original,served): + from datetime import datetime,timedelta,timezone + from secrets_engine.task_execution_handoff import _task_identity + from secrets_engine.approval_consume import _expected_request + cfg,e,contract,state=lane + request=_expected_request(cfg,e,'execute-ccr',execution_contract=contract) + request['resource']['attributes']['requested_number']=original + binding=copy.deepcopy(request);binding['resource']['attributes']['requested_number']=served + envelope={'binding':binding,'subject':binding['subject'],'resource':binding['resource'], + 'provenance':{'caller':{'mode':'enforce','principal':'system:serviceaccount:secrets-engine:secrets-engine', + 'audience':'flex-auth','not_after':(datetime.now(timezone.utc)+timedelta(minutes=20)).isoformat()}}} + with pytest.raises(DecisionError):_task_identity(request,envelope,contract.as_dict(),datetime.now(timezone.utc)) + binding['resource']['attributes']['requested_number']=original + binding['resource']['attributes']['legitimate_extra_registry_fact']=1.0 + _task_identity(request,envelope,contract.as_dict(),datetime.now(timezone.utc)) + + +def test_old_normalization_record_has_no_permission_window(lane): + cfg,e,contract,state=lane + def old(envelope): + normalize(envelope) + envelope['provenance']['decision_time']='2000-01-01T00:00:00Z' + state['decision_change']=old + normalization=prepare_task_approval(cfg,e,execution_contract=contract) + with pytest.raises(DecisionError):consume_task_execution(cfg,normalization) + assert state['calls']==['check'] diff --git a/tests/test_trusted_successor.py b/tests/test_trusted_successor.py new file mode 100644 index 0000000..3c47a1d --- /dev/null +++ b/tests/test_trusted_successor.py @@ -0,0 +1,80 @@ +"""Owner signature interoperability, independent synthetic admission only.""" +import copy +import json +from pathlib import Path +import subprocess + +import pytest +from secrets_engine.decision_signature import authenticate_response,load_trust +from secrets_engine.errors import DecisionError +from tests.test_decision_signature import FIXTURE,trust_file,verifier +from tests.signature_support import signature_tools +from tests.test_task_execution_handoff import lane,prepared,consume_task_execution + +VECTORS=json.loads((Path(__file__).parent/'fixtures/pdp-signature/approval-native-vectors.json').read_text()) + +@pytest.mark.parametrize('vector',VECTORS) +def test_fourteen_owner_native_types_retain_signed_bytes(vector,trust_file,signature_tools): + env=json.loads(FIXTURE.read_text());env.pop('signature',None) + env['diagnostics']={'native_vector':'REPLACE_RAW_TARGET'} + raw=json.dumps(env).replace('"REPLACE_RAW_TARGET"',vector['input_target_json']).encode() + signed=subprocess.run([str(signature_tools/'sign')],input=raw,capture_output=True,check=True).stdout + result=authenticate_response(signed,trust=load_trust(trust_file),verifier=signature_tools/'verify') + again=authenticate_response(result.authenticated_json.encode(),trust=load_trust(trust_file),verifier=signature_tools/'verify') + assert again['diagnostics']['native_vector']==json.loads(vector['input_target_json']) + tampered=signed.replace(b'"action":"rotate"',b'"action":"destroy"') + assert tampered!=signed + with pytest.raises(DecisionError):authenticate_response(tampered,trust=load_trust(trust_file),verifier=signature_tools/'verify') + + +def test_unknown_provenance_refuses(trust_file,signature_tools): + env=json.loads(FIXTURE.read_text());env['provenance']['unknown_source_authority']=True + with pytest.raises(DecisionError): + authenticate_response(json.dumps(env).encode(),trust=load_trust(trust_file),verifier=signature_tools/'verify') + + +def test_exact_original_numeric_lexeme_survives_strict_cas(lane,monkeypatch,signature_tools): + import tests.test_task_execution_handoff as fixtures + cfg,e,contract,state=lane + def raw_sign(env,tools): + env['diagnostics']={'numeric':'LEXEME'} + raw=json.dumps(env).replace('"LEXEME"','1e-7').encode() + return subprocess.run([str(tools/'sign')],input=raw,capture_output=True,check=True).stdout + monkeypatch.setattr(fixtures,'sign_envelope',raw_sign) + authorization=prepared(lane) + assert '1e-7' in authorization._action.artifacts + assert consume_task_execution(cfg,authorization).idempotent is False + + +def source_evidence(envelope,claim): + from datetime import datetime,timezone + return {'source_id':'synthetic-source','source_revision':'synthetic-revision', + 'configuration_digest':'sha256:'+'2'*64,'claim_digest':'sha256:'+'3'*64, + 'native_binding_digest':claim['binding']['digest'], + 'observed_at':datetime.now(timezone.utc).isoformat(), + 'validated_at':datetime.now(timezone.utc).isoformat(), + 'not_after':envelope['lifetime']['expires_at']} + + +def test_signed_source_evidence_keeps_original_context_and_cas(lane): + cfg,e,contract,state=lane + state['decision_change']=lambda env:env['provenance'].__setitem__('approval_observation',source_evidence(env,state['claim'])) + authorization=prepared(lane) + snapshot=json.loads(authorization._action.artifacts) + assert snapshot['envelope']['binding']['context']==snapshot['request']['context'] + assert snapshot['request']['context']['approval']==state['claim'] + assert consume_task_execution(cfg,authorization).idempotent is False + + +@pytest.mark.parametrize('field,value',[ + ('source_id',''),('source_revision',''),('configuration_digest','wrong'),('claim_digest','wrong'), + ('native_binding_digest','sha256:'+'0'*64),('observed_at','2099-01-01T00:00:00Z'), + ('validated_at','2099-01-01T00:00:00Z'),('not_after','2000-01-01T00:00:00Z')]) +def test_signed_invalid_source_evidence_refuses_before_cas(lane,field,value): + cfg,e,contract,state=lane + def change(env): + evidence=source_evidence(env,state['claim']);evidence[field]=value + env['provenance']['approval_observation']=evidence + state['decision_change']=change + with pytest.raises(DecisionError):prepared(lane) + assert 'consume' not in state['calls'] diff --git a/verifier/README.md b/verifier/README.md index a63b2a0..fe7ec93 100644 --- a/verifier/README.md +++ b/verifier/README.md @@ -1,7 +1,7 @@ # Access Engine decision verifier The API types and `internal/sign/sign.go` are unchanged producer source, pinned -by revision and SHA-256 in `producer-source.json`. License attribution and the +by source revision/proposal and SHA-256 in `producer-source.json`. License attribution and the original license text are in `LICENSE.access-engine`. This is source vendoring necessitated by the producer's Go `internal` package boundary, not a separately maintained crypto implementation. There are no external Go dependencies. @@ -33,3 +33,23 @@ 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. + +The trusted-service successor snapshot follows the frozen Access B2/B3 proposal lineage, +identified by its patch SHA-256 in `producer-source.json`; this is source review, +not a claim of published producer revision or runtime/key admission. The wrapper +uses producer-owned `sign.DecodeEnvelope` to preserve JSON numeric lexemes in +signed maps. It retains exact authenticated producer JSON through strict +preparation and CAS reauthentication; Python does not recreate signed bytes. +Approval's fourteen native binding vectors are signature interoperability cases, +not an expansion of existing request numeric admission. Signed +`approval_observation` supplements the original immutable carried claim; +strict handoff checks its native digest and observation/deadline bounds. It never +replaces original context or freshness. + +Successor R2 execution builder correspondence emits the producer's fixed public +DeclaredServiceBinding only for the typed prod/platform secrets-engine service +lane before approval issuance. Both reference tenants are explicit to match +existing producer normalization. It is not caller authentication; independently +signed enforced caller, exact original/native/descriptor joins and all existing +approval, expiry and CAS gates still apply. Actual production source-server +interoperability receipts are separate from native runtime admission. diff --git a/verifier/internal/sign/sign.go b/verifier/internal/sign/sign.go index fc607f7..011f8f7 100644 --- a/verifier/internal/sign/sign.go +++ b/verifier/internal/sign/sign.go @@ -4,10 +4,12 @@ package sign import ( + "bytes" "crypto/ed25519" "encoding/base64" "encoding/json" "fmt" + "io" "github.com/netkingdom/access-engine/pkg/api" ) @@ -74,3 +76,20 @@ func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) er } return nil } + +// DecodeEnvelope preserves JSON numeric kinds/lexemes emitted by task inputs. +// Legacy producer envelopes remain byte-compatible. It does not invent another +// signature canonicalization: CanonicalBytes still calls typed json.Marshal. +func DecodeEnvelope(raw []byte) (api.DecisionEnvelope, error) { + var envelope api.DecisionEnvelope + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + decoder.DisallowUnknownFields() + if err := decoder.Decode(&envelope); err != nil { + return envelope, err + } + if _, err := decoder.Token(); err != io.EOF { + return api.DecisionEnvelope{}, fmt.Errorf("trailing envelope data") + } + return envelope, nil +} diff --git a/verifier/main.go b/verifier/main.go index 1f3f9de..7449c64 100644 --- a/verifier/main.go +++ b/verifier/main.go @@ -2,13 +2,11 @@ package main import ( - "bytes" "crypto/ed25519" "encoding/base64" "encoding/json" "fmt" "github.com/netkingdom/access-engine/internal/sign" - "github.com/netkingdom/access-engine/pkg/api" "io" "os" ) @@ -23,10 +21,8 @@ func run() error { if err := json.NewDecoder(io.LimitReader(os.Stdin, 1048577)).Decode(&in); err != nil { return err } - var envelope api.DecisionEnvelope - decoder := json.NewDecoder(bytes.NewReader(in.Envelope)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&envelope); err != nil { + envelope, err := sign.DecodeEnvelope(in.Envelope) + if err != nil { return err } keys := map[string]ed25519.PublicKey{} diff --git a/verifier/pkg/api/canonical.go b/verifier/pkg/api/canonical.go index 3baa784..ae11a2d 100644 --- a/verifier/pkg/api/canonical.go +++ b/verifier/pkg/api/canonical.go @@ -635,16 +635,30 @@ type Obligation struct { // DecisionProvenance captures evaluator and policy provenance. type DecisionProvenance struct { - Evaluator string `json:"evaluator" yaml:"evaluator"` - Mode string `json:"mode" yaml:"mode"` - PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"` - PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` - PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"` - RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"` - DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"` - InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"` - DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"` - Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"` + Evaluator string `json:"evaluator" yaml:"evaluator"` + Mode string `json:"mode" yaml:"mode"` + PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"` + PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` + PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"` + RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"` + DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"` + InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"` + DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"` + ApprovalObservation *ApprovalObservationProvenance `json:"approval_observation,omitempty" yaml:"approval_observation,omitempty"` + Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"` +} + +// ApprovalObservationProvenance binds fresh server-owned fact evidence without +// rewriting the original carried claim/context or either PDP request digest. +type ApprovalObservationProvenance struct { + SourceID string `json:"source_id"` + SourceRevision string `json:"source_revision"` + ConfigurationDigest string `json:"configuration_digest"` + ClaimDigest string `json:"claim_digest"` + NativeBindingDigest string `json:"native_binding_digest"` + ObservedAt string `json:"observed_at"` + NotAfter string `json:"not_after"` + ValidatedAt string `json:"validated_at"` } // CallerProvenance records how the request was authenticated to flex-auth. diff --git a/verifier/producer-source.json b/verifier/producer-source.json index b6b0cba..d34b5a6 100644 --- a/verifier/producer-source.json +++ b/verifier/producer-source.json @@ -1,15 +1,21 @@ { "upstream": "https://github.com/netkingdom/access-engine", - "revision": "62ac2b4e99dec2005c7c407fe633612578a7907d", + "revision": "B2/B3 R3 retained-registry source proposal over B1 R2; canonical native-prep base 919ce25", "files": { - "pkg/api/canonical.go": "21e754617c2b82d673cef31b7075f8d177e4367782b63b20b37125587bf9e300", + "pkg/api/canonical.go": "a122a2e8cd524333b13c5939c7a3685e41efbe47497a64e0c7297c92b7f10d19", "pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3", "pkg/api/resource_manifest.go": "f0fa4080287d389ed48c2b1d6af96cc62e57ef72c902333f677f964420be133f", "pkg/api/doc.go": "0638f06ae47369899dd1e2dd5dd23a7200e7dbb3b505b04f4d3c78c0a074def9", - "internal/sign/sign.go": "dad2d0c9f6d1fb2fd68e774949ccdf954382acd075191b5ddfea2c3cc2f4cb62" + "internal/sign/sign.go": "0df9515922e90775ebbfac798361dcafb20da8a4213796b44e246b8a92046cde" }, "fixtures": { "decision_rotate_signed.json": "a7bc499f3583f53551cf163109a3c108188345100176092adc31eccefc222fd8", "decision_rotate_signed_tampered.json": "7f2d2e9372a9617bd4b197033f09a5e33a08b202046de8109582da1dc90cb527" + }, + "source_proposal": { + "patch_sha256": "46bfeb706d3a88bf6054309cc9303eb360a91e57dda3b0fcbb7b80d551a75db6", + "predecessor_patch_sha256": "ed85a55b54f8380a657c0688d6f216699a0873b1b94b4c0c2e09332620c8ebf4", + "api_sign_bytes_unchanged": true, + "status": "source proposal; no runtime or key admission" } } diff --git a/verifier/testsupport/signer/main.go b/verifier/testsupport/signer/main.go index 2a7fecd..994ea82 100644 --- a/verifier/testsupport/signer/main.go +++ b/verifier/testsupport/signer/main.go @@ -6,7 +6,7 @@ import ( "crypto/ed25519" "encoding/json" "github.com/netkingdom/access-engine/internal/sign" - "github.com/netkingdom/access-engine/pkg/api" + "io" "os" ) @@ -17,8 +17,12 @@ func main() { seed = 0x43 kid = "next-admitted" } - var env api.DecisionEnvelope - if json.NewDecoder(os.Stdin).Decode(&env) != nil { + raw, err := io.ReadAll(os.Stdin) + if err != nil { + os.Exit(1) + } + env, err := sign.DecodeEnvelope(raw) + if err != nil { os.Exit(1) } if sign.Sign(&env, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, 32)), kid) != nil { diff --git a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md index a8a3845..a7e1e3a 100644 --- a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md +++ b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md @@ -124,3 +124,19 @@ coercion/idempotence, expiry, signer removal and replay refusal. No service bind 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. + +2026-10-16 retained-registry successor source preparation adds only the existing +signed Check/DENY preapproval flow. The original typed service proposal is sent +unchanged; normalized native act and evaluated approval-free digest from the +admitted producer record are used for normal Approval issuance. The original +proposal then receives only the original issued claim. Final Check re-normalizes +against retained owner registry/source facts, so authoritative drift refuses +before CAS. No local registry algorithm, new endpoint, empty-registry workaround +or consumer digest waiver is used. Signed DENY preparation has no independent +permission/freshness window and cannot consume or execute; current caller and +operation bounds are checked, and final signed ALLOW/source checks remain +mandatory. Exact original JSON kinds and requested-target subsets refuse +bool/int/float substitution while allowing extra authoritative registry facts. +Actual provider proof preparation is separately waiting on frozen Access source +and Approval's timestamp/transaction-clock repair; source native/trust/custody and +actual task admission remain open. T01/T03 and WP8-T06 are not closed. 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 28102cf..f65ac96 100644 --- a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +++ b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md @@ -114,3 +114,64 @@ producer candidate OCI and actual runtime/config placement remain missing. The empty-key trust template deliberately refuses. No install, key read, caller mint, protected effect, policy switch or trust admission occurred. T02 remains progress, T03 wait; task source/live obligations remain separately in SECRETS-WP-0013. + +2026-10-16 trusted successor source preparation: refreshed exact API/sign source +from frozen Access B2/B3 R1 proposal (patch SHA-256 +6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1). +Producer DecodeEnvelope preserves signed numeric lexemes; strict CAS retains and +reauthenticates original producer JSON. Existing request/claim/native/caller joins +remain mandatory; signed source observation additionally binds the native digest +and caps decision lifetime. Fourteen Approval owner vectors, legacy signatures, +tamper/unknown provenance and source deadline refusals are locally checked. +Actual ApprovalApp-to-Access v2 signed response verifies with the consumer helper; +actual source revocation refuses. Actual builder-to-v2 strict positive execution +is separately blocked on missing owner-reviewed service_actor_binding mapping; +no caller context injection or equality relaxation hides that gap. +Artifacts: Custodian `decision-trusted-successor/`. T02 remains progress, T03 +wait; no package/key/policy/native admission or runtime installation occurred. + +2026-10-16 successor R2 source preparation resolves the preceding builder gap: +only typed prod/platform execute-ccr with exact secrets-engine service subject +emits Access's fixed five-field public service_actor_binding before approval +issue. Subject/resource platform tenants are explicit before issue, matching the +producer's existing normalization and native act; lifecycle requests are unchanged. +Original carried context is still immutable. Actual builder -> Approval App -> +Access v2 signed ALLOW -> strict preparation/CAS -> independent Approval confirmation +GET passes with synthetic scoped identities/trust. Missing declaration, descriptor/ +native changes, stale original claim, signature tamper and replay refuse. The +operation deadline is explicit whole seconds within v2's 15-second allow TTL; +no deadline is extended or waived. This resolves source correspondence only; +T02 remains progress/T03 wait, with no native trust/grant/policy admission. + +2026-10-16 temporal successor source proof: Access B2/B3 R2 replaces its earlier +15s lifetime with the exact immutable operation deadline, bounded by its 120s +hard cap, original/fresh source and caller bounds, and post-I/O backend TTL+5s +fit. Verifier API/sign bytes are unchanged; source provenance records R2. Secrets +runtime and strict entire-operation deadline checks are unchanged. Contract prose +now states the actual fixed backend TTL and conservative fit rather than dynamic +TTL recomputation, and permits native token expiry earlier than operation deadline. +The actual source proof uses explicit synthetic Approval freshness120/backendTTL30/ +operationdeadline60, without a consumer timing waiver or native admission. + +Temporal proof precision observation: Approval consumption receipts use whole +seconds while Access R2 not_before preserves subsecond precision. Fast CAS can +therefore refuse receipt-before-start even though live calls are correctly +ordered. The positive proof explicitly waits real time until the next whole +second before CAS, without injected clocks or relaxed checks. Fast-CAS failure +is retained for owner resolution; no Secrets runtime change conceals the seam. + +2026-10-16 retained-registry successor source preparation adds only the existing +signed Check/DENY preapproval flow. The original typed service proposal is sent +unchanged; normalized native act and evaluated approval-free digest from the +admitted producer record are used for normal Approval issuance. The original +proposal then receives only the original issued claim. Final Check re-normalizes +against retained owner registry/source facts, so authoritative drift refuses +before CAS. No local registry algorithm, new endpoint, empty-registry workaround +or consumer digest waiver is used. Signed DENY preparation has no independent +permission/freshness window and cannot consume or execute; current caller and +operation bounds are checked, and final signed ALLOW/source checks remain +mandatory. Exact original JSON kinds and requested-target subsets refuse +bool/int/float substitution while allowing extra authoritative registry facts. +Actual provider proof preparation is separately waiting on frozen Access source +and Approval's timestamp/transaction-clock repair; source native/trust/custody and +actual task admission remain open. T02 remains progress and T03 wait; no native adoption is claimed.