Verify normalized task approval facts and preserve signed source types
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
tegwick 2026-10-16 17:28:03 +02:00
parent 5a2099f400
commit 4dd004a225
18 changed files with 756 additions and 60 deletions

View file

@ -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.

View file

@ -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

View file

@ -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),
)

View file

@ -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

View file

@ -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

View file

@ -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'],

View file

@ -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"
}
]

View file

@ -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)

View file

@ -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']

View file

@ -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']

View file

@ -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.

View file

@ -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
}

View file

@ -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{}

View file

@ -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.

View file

@ -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"
}
}

View file

@ -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 {

View file

@ -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.

View file

@ -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.