feat(admission): founder-signed trust and recovery-attempt admission (WP14 T02, WP15 T05)
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s

Reuse the gated-executor approval pattern: ssh-keygen -Y signatures over exact
bytes, verified against a runtime-unwritable allowed_signers anchor with a fixed
namespace per purpose. load_trust now refuses responder trust without a founder
admission; the KeyCape selector preflight gains --admission for one exact,
bounded recovery attempt. WP14 re-pointed to CUST-WP-0082-T06 and split founder
key admission into human-gated T04; WP15 T04 now requires the signed admission.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 809600@bnt-lap001
Assistant-Session: 40ba228f-c8a0-4ec9-bb29-3129951afdc4
This commit is contained in:
tegwick 2026-10-21 00:30:59 +02:00
parent 497040c58c
commit ebd8e49aff
15 changed files with 469 additions and 29 deletions

View file

@ -39,8 +39,12 @@ point, not an identity provider, and not a general secrets API.
- Source now requires independently configured responder trust and a separately
installed Go producer-format verifier on the serving authorization path.
Signatures bind admitted keys to signed evaluator/mode and exact policy
package/version/artifact digest; unsigned responses fail closed. This requires
a reviewed native deployment/key admission under WP14, still outstanding.
package/version/artifact digest; unsigned responses fail closed. The trust
file itself is admitted only by a founder `ssh-keygen -Y` signature over its
exact bytes, checked against a runtime-unwritable `allowed_signers` anchor.
This reuses the gated-executor approval pattern (`docs/founder-admission.md`).
The real key's founder admission and the native rollout are still outstanding
(WP14 T04/T03).
- Resolves a catalog lane by catalog id or `approval.decision_ref`.

View file

@ -89,12 +89,14 @@
| task | SECRETS-WP-0013-T02 | done | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md |
| task | SECRETS-WP-0013-T03 | wait | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md |
| task | SECRETS-WP-0014-T01 | done | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md |
| task | SECRETS-WP-0014-T02 | progress | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md |
| task | SECRETS-WP-0014-T02 | done | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md |
| task | SECRETS-WP-0014-T03 | wait | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md |
| task | SECRETS-WP-0014-T04 | wait | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md |
| task | SECRETS-WP-0015-T01 | done | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md |
| task | SECRETS-WP-0015-T02 | done | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md |
| task | SECRETS-WP-0015-T03 | done | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md |
| task | SECRETS-WP-0015-T04 | wait | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md |
| task | SECRETS-WP-0015-T05 | done | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md |
| intake | SECRETS-IN-0001 | closed | — | intakes/intakes.md |
| intake | SECRETS-IN-0002 | closed | — | intakes/intakes.md |
| intake | SECRETS-IN-0003 | closed | — | intakes/intakes.md |

68
docs/founder-admission.md Normal file
View file

@ -0,0 +1,68 @@
# Founder admission (SSH signature over exact bytes)
Two inputs need authority from outside this process: the Access Engine
responder trust file (SECRETS-WP-0014) and a KeyCape recovery attempt
(SECRETS-WP-0015). The runtime itself must not be able to grant either. Both
now use the approval pattern the estate already accepted in **gated-executor**
(GEXEC-WP-0001, installed on railiance01 2026-10-20). The founder signs exact
bytes with the passphrase-protected key, and the verifier trusts only an
`allowed_signers` file that the runtime account cannot write.
Implementation: `src/secrets_engine/founder_admission.py`. No new approval
service, network call or key type is involved. `ssh-keygen -Y` works offline,
so the recovery case still works when KeyCape/OpenBao are down.
## Anchor
```
/etc/secrets-engine/admission/ root:root 0755
/etc/secrets-engine/admission/allowed_signers root:root 0644
```
`SECRETS_ENGINE_ADMISSION_ANCHOR` may name another directory. The checks stay
the same either way: the directory and file are not symlinks, not group/world
writable, and not owned by the running UID unless that UID is root. The signer
principal is read from the signature (`find-principals`), and it must match
exactly one `allowed_signers` entry.
Namespaces are fixed per purpose, so a signature for one purpose cannot admit
another. That includes a gexec action approval (`gexec-approval`).
| Purpose | Namespace | Signed file | Signature |
| --- | --- | --- | --- |
| PDP responder trust | `secrets-engine-pdp-trust` | `pdp-trust.json` | `pdp-trust.json.sig` |
| Recovery attempt | `secrets-engine-recovery-attempt` | attempt admission JSON | `<file>.sig` |
## Admit or rotate responder trust
```bash
ssh-keygen -Y sign -f ~/.ssh/<founder-key> -n secrets-engine-pdp-trust pdp-trust.json
```
`load_trust` verifies the signature over the same bytes it parses. Any edit
needs a new signature, so rotation means a newly signed file. Overlap is two
keys in one signed file, and removal is a signed file without the old key.
Neither the file's presence nor a loopback URL counts as admission.
## Admit one recovery attempt
```json
{"schema": "secrets-engine.recovery-attempt-admission.v1",
"selector_sha256": "sha256:<exact selector file bytes>",
"source_sha256": "sha256:<service_auth.py>",
"issuer": "https://kc.coulomb.social",
"case": "secrets15-nk49-readiness",
"not_before": "YYYY-MM-DDTHH:MM:SSZ", "not_after": "YYYY-MM-DDTHH:MM:SSZ"}
```
```bash
ssh-keygen -Y sign -f ~/.ssh/<founder-key> -n secrets-engine-recovery-attempt attempt.json
tools/check-keycape-recovery-selector.py --selector docs/keycape-recovery-selector.json \
--source-sha256 sha256:<...> --admission attempt.json
```
The window may be at most 24 hours. A pass reports
`founder_admitted_attempt_not_native_acceptance`. That status proves the
founder admitted this exact attempt. It does not prove the attempt ran, that it
ran only once, or that the native result passed. Single-use consumption and
cleanup stay with the bounded NK-WP-0049 execution process.

View file

@ -14,6 +14,9 @@ import subprocess
from typing import Any
from secrets_engine.errors import DecisionError
from secrets_engine.founder_admission import read_signature, verify_admission
TRUST_NAMESPACE = "secrets-engine-pdp-trust"
def _unique_object(pairs):
@ -38,11 +41,13 @@ def strict_json(data):
def load_trust(path: Path | None) -> dict[str, Any]:
"""Read an operator-owned public admission file; missing trust is a refusal.
"""Read a founder-admitted public trust file; missing trust is a refusal.
Admission config is a local authority input like the existing policy pin.
File provenance/approval and the executable's installation are rollout gates,
not established by a successful local signature test.
The exact file bytes must carry a sibling ``<trust>.sig`` made with
``ssh-keygen -Y sign -n secrets-engine-pdp-trust`` by a principal in the
runtime-unwritable admission anchor (``founder_admission``). Rotation is a
new signed file; an unsigned or edited file refuses. The executable's
installation remains a rollout gate.
"""
try:
if path is None or not Path(path).is_absolute() or Path(path).is_symlink():
@ -55,6 +60,7 @@ def load_trust(path: Path | None) -> dict[str, Any]:
raw = stream.read(65537)
if len(raw) > 65536:
raise ValueError("trust is oversized")
admission = verify_admission(raw, read_signature(Path(str(path) + ".sig")), namespace=TRUST_NAMESPACE)
trust = strict_json(raw)
if set(trust) != {'schema', 'evaluator', 'mode', 'policy_package', 'policy_version', 'policy_package_digest', 'keys'}:
raise ValueError("invalid trust fields")
@ -69,11 +75,18 @@ def load_trust(path: Path | None) -> dict[str, Any]:
for key in trust["keys"].values():
if len(key) != 43 or len(base64.b64decode(key + "=", altchars=b"-_", validate=True)) != 32:
raise ValueError("invalid Ed25519 public key")
return trust
except (OSError, ValueError, TypeError, KeyError, RecursionError) as exc:
return AdmittedTrust(trust, admission)
except (OSError, ValueError, TypeError, KeyError, RecursionError, DecisionError) as exc:
raise DecisionError("access-engine responder trust is missing or invalid") from exc
class AdmittedTrust(dict):
"""Trust fields plus the non-secret founder admission (principal, key fingerprint)."""
def __init__(self, values, admission):
super().__init__(values)
self.admission = admission
class AuthenticatedEnvelope(dict):
"""Verified typed values with exact producer JSON retained for reauthentication."""
def __init__(self, values, authenticated_json):

View file

@ -0,0 +1,96 @@
"""Founder SSH-signature admission over exact bytes, never runtime self-approval.
Reuses the gated-executor approval pattern (GEXEC-WP-0001, accepted on
railiance01 2026-10-20): ``ssh-keygen -Y verify`` against an ``allowed_signers``
file the running account cannot write, with a fixed namespace per purpose so a
signature for one purpose can never admit another (including a gexec action).
The anchor is ``/etc/secrets-engine/admission/allowed_signers`` unless
``SECRETS_ENGINE_ADMISSION_ANCHOR`` names another directory. Relocating the
anchor does not weaken it: the directory and file must still be owned by root
or another account than the runtime, and not group/world writable. The signer
principal is derived from the signature and must be listed in that file.
"""
from __future__ import annotations
import os
from pathlib import Path
import re
import stat
import subprocess
import tempfile
from secrets_engine.errors import DecisionError
DEFAULT_ANCHOR = Path("/etc/secrets-engine/admission")
NAMESPACES = frozenset({"secrets-engine-pdp-trust", "secrets-engine-recovery-attempt"})
SIG_HEADER = b"-----BEGIN SSH SIGNATURE-----"
SIG_LIMIT = 8192
PRINCIPAL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@-]{0,127}$")
FINGERPRINT_RE = re.compile(r"key (SHA256:[A-Za-z0-9+/=]+)")
SSH_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C"}
def anchor_dir() -> Path:
return Path(os.environ.get("SECRETS_ENGINE_ADMISSION_ANCHOR") or DEFAULT_ANCHOR)
def _writable_by_runtime(info: os.stat_result) -> bool:
"""The runtime must not be able to rewrite its own trust anchor."""
euid = os.geteuid()
return bool(info.st_mode & 0o022) or (info.st_uid == euid and euid != 0)
def _read_anchor() -> Path:
directory = anchor_dir()
signers = directory / "allowed_signers"
if not directory.is_absolute() or directory.is_symlink() or signers.is_symlink():
raise ValueError("anchor must be absolute and not a symlink")
for path, kind in ((directory, stat.S_ISDIR), (signers, stat.S_ISREG)):
info = os.stat(path, follow_symlinks=False)
if not kind(info.st_mode) or _writable_by_runtime(info):
raise ValueError("anchor is writable by the runtime")
return signers
def read_signature(path: Path) -> bytes:
if path.is_symlink():
raise DecisionError("founder admission signature is missing or invalid")
try:
with path.open("rb") as stream:
raw = stream.read(SIG_LIMIT + 1)
except OSError as exc:
raise DecisionError("founder admission signature is missing or invalid") from exc
if len(raw) > SIG_LIMIT or not raw.startswith(SIG_HEADER):
raise DecisionError("founder admission signature is missing or invalid")
return raw
def verify_admission(data: bytes, signature: bytes, *, namespace: str) -> dict[str, str]:
"""Verify an exact-byte founder signature; returns principal and key fingerprint."""
try:
if namespace not in NAMESPACES:
raise ValueError("unknown admission namespace")
if len(signature) > SIG_LIMIT or not signature.startswith(SIG_HEADER):
raise ValueError("unsigned admission")
signers = _read_anchor()
with tempfile.TemporaryDirectory(prefix="se-admission-") as tmp:
sig = Path(tmp) / "admission.sig"
sig.write_bytes(signature)
found = subprocess.run(
["ssh-keygen", "-Y", "find-principals", "-f", str(signers), "-n", namespace, "-s", str(sig)],
capture_output=True, env=SSH_ENV, timeout=5, check=False,
)
principals = found.stdout.decode("utf-8", "replace").split()
if found.returncode != 0 or len(principals) != 1 or not PRINCIPAL_RE.fullmatch(principals[0]):
raise ValueError("signer is not exactly one admitted principal")
checked = subprocess.run(
["ssh-keygen", "-Y", "verify", "-f", str(signers), "-I", principals[0], "-n", namespace, "-s", str(sig)],
input=data, capture_output=True, env=SSH_ENV, timeout=5, check=False,
)
match = FINGERPRINT_RE.search(checked.stdout.decode("utf-8", "replace"))
if checked.returncode != 0 or not match:
raise ValueError("signature verification failed")
return {"principal": principals[0], "key_fingerprint": match.group(1), "namespace": namespace}
except (OSError, ValueError, subprocess.SubprocessError) as exc:
raise DecisionError("founder admission is missing or invalid") from exc

21
tests/conftest.py Normal file
View file

@ -0,0 +1,21 @@
"""Synthetic founder admission anchor for every test; never a verification bypass."""
import subprocess
import pytest
from tests.signature_support import FOUNDER, admission_anchor
@pytest.fixture(scope='session')
def founder_key(tmp_path_factory):
return admission_anchor(tmp_path_factory.mktemp('founder-admission'))
@pytest.fixture(autouse=True)
def founder_anchor(founder_key, monkeypatch):
import secrets_engine.founder_admission as admission
monkeypatch.setenv('SECRETS_ENGINE_ADMISSION_ANCHOR', str(founder_key['anchor']))
# Tests run as the anchor's owner; keep the mode-bit half of the rule.
real_rule = admission._writable_by_runtime
monkeypatch.setattr(admission, '_writable_by_runtime', lambda info: bool(info.st_mode & 0o022))
return {**founder_key, 'real_rule': real_rule}

View file

@ -6,6 +6,26 @@ import subprocess
import pytest
PUBLIC='IVL40Zt5HSRFMkLhXy6rbLfP-ntqXtMAl5YOBpiB2xI'
FOUNDER='founder@synthetic'
def admission_anchor(directory):
"""Synthetic founder key plus allowed_signers anchor, like /etc/gexec."""
key=directory/'founder'
subprocess.run(['ssh-keygen','-q','-t','ed25519','-N','','-C',FOUNDER,'-f',str(key)],check=True,timeout=30)
anchor=directory/'anchor';anchor.mkdir(mode=0o755)
(anchor/'allowed_signers').write_text(FOUNDER+' '+(directory/'founder.pub').read_text())
(anchor/'allowed_signers').chmod(0o644)
return {'key':key,'anchor':anchor}
def admit(path,namespace='secrets-engine-pdp-trust',key=None):
"""Founder-sign exact file bytes as `<path>.sig` (ssh-keygen -Y sign)."""
import os as _os
key=key or Path(_os.environ['SECRETS_ENGINE_ADMISSION_ANCHOR']).parent/'founder'
Path(str(path)+'.sig').unlink(missing_ok=True)
subprocess.run(['ssh-keygen','-q','-Y','sign','-f',str(key),'-n',namespace,str(path)],check=True,timeout=30,capture_output=True)
return path
@pytest.fixture(scope='session')
def signature_tools(tmp_path_factory):
@ -22,7 +42,7 @@ def admit_tools(cfg, directory, tools):
trust={'schema':'secrets-engine.pdp-trust.v1','evaluator':'stub/local','mode':'standalone',
'policy_package':cfg.authorization_policy_package,'policy_version':cfg.authorization_policy_version,
'policy_package_digest':'sha256:'+'1'*64,'keys':{'testdata-ed25519':PUBLIC}}
path=directory/'pdp-trust.json';path.write_text(json.dumps(trust))
path=directory/'pdp-trust.json';path.write_text(json.dumps(trust));admit(path)
from dataclasses import is_dataclass, replace
if is_dataclass(cfg):
return replace(cfg,pdp_trust_file=path,pdp_verifier=tools/'verify')

View file

@ -17,7 +17,7 @@ from tests.test_decision_transport import server, credential, clear_proxies
FIXTURE = Path(__file__).parent / 'fixtures/pdp-signature/decision_rotate_signed.json'
PUBLIC = 'IVL40Zt5HSRFMkLhXy6rbLfP-ntqXtMAl5YOBpiB2xI'
from tests.signature_support import signature_tools
from tests.signature_support import signature_tools, admit
@pytest.fixture(scope='module')
def verifier(signature_tools):
@ -29,7 +29,7 @@ def trust_file(tmp_path):
provenance = envelope['provenance']
trust = {k:provenance[k] for k in ('evaluator','mode','policy_package','policy_version','policy_package_digest')}
trust.update(schema='secrets-engine.pdp-trust.v1', keys={'testdata-ed25519':PUBLIC})
path = tmp_path/'trust.json'; path.write_text(json.dumps(trust)); return path
path = tmp_path/'trust.json'; path.write_text(json.dumps(trust)); return admit(path)
def checked(body, credential, trust_file, verifier):
@ -63,17 +63,17 @@ def test_response_authentication_refuses(credential, trust_file, verifier, chang
@pytest.mark.parametrize('field',['evaluator','mode','policy_package','policy_version','policy_package_digest'])
def test_independent_issuer_and_pin_refusal(credential, trust_file, verifier, field):
trust=json.loads(trust_file.read_text()); trust[field]='wrong'; trust_file.write_text(json.dumps(trust))
trust=json.loads(trust_file.read_text()); trust[field]='wrong'; trust_file.write_text(json.dumps(trust)); admit(trust_file)
with pytest.raises(DecisionError): checked(FIXTURE.read_bytes(),credential,trust_file,verifier)
def test_rotation_overlap_then_removal(credential, trust_file, verifier, signature_tools):
trust=json.loads(trust_file.read_text()); trust['keys']['next-admitted']='Ivwpd5Lwtv_Av8_bftsMCqFOAlo2XsDjQuhuOCnLdLY'
trust_file.write_text(json.dumps(trust)); checked(FIXTURE.read_bytes(),credential,trust_file,verifier)
trust_file.write_text(json.dumps(trust)); admit(trust_file); checked(FIXTURE.read_bytes(),credential,trust_file,verifier)
from tests.signature_support import sign_envelope
next_body=json.dumps(sign_envelope(json.loads(FIXTURE.read_bytes()),signature_tools,next_key=True)).encode()
checked(next_body,credential,trust_file,verifier)
del trust['keys']['testdata-ed25519']; trust_file.write_text(json.dumps(trust))
del trust['keys']['testdata-ed25519']; trust_file.write_text(json.dumps(trust)); admit(trust_file)
with pytest.raises(DecisionError): checked(FIXTURE.read_bytes(),credential,trust_file,verifier)
checked(next_body,credential,trust_file,verifier)

View file

@ -0,0 +1,68 @@
"""Founder admission of the responder trust file (SECRETS-WP-0014-T02)."""
import json
import os
import subprocess
import pytest
from secrets_engine import founder_admission
from secrets_engine.decision_signature import load_trust
from secrets_engine.errors import DecisionError
from tests.signature_support import PUBLIC, FOUNDER, admit
TRUST = {'schema':'secrets-engine.pdp-trust.v1','evaluator':'stub/local','mode':'standalone',
'policy_package':'p','policy_version':'v','policy_package_digest':'sha256:'+'1'*64,
'keys':{'testdata-ed25519':PUBLIC}}
@pytest.fixture
def trust(tmp_path):
path=tmp_path/'pdp-trust.json';path.write_text(json.dumps(TRUST));return path
def test_admitted_trust_carries_founder_identity_only(trust):
loaded=load_trust(admit(trust))
assert dict(loaded)==TRUST
assert loaded.admission['principal']==FOUNDER
assert loaded.admission['key_fingerprint'].startswith('SHA256:')
assert loaded.admission['namespace']=='secrets-engine-pdp-trust'
@pytest.mark.parametrize('change',['unsigned','edited','other-namespace','gexec-namespace','garbage-signature'])
def test_unadmitted_trust_refuses(trust,change):
if change=='unsigned':pass
elif change=='edited':
admit(trust);trust.write_text(json.dumps({**TRUST,'keys':{'attacker':PUBLIC}}))
elif change=='other-namespace':admit(trust,namespace='secrets-engine-recovery-attempt')
elif change=='gexec-namespace':admit(trust,namespace='gexec-approval')
else:(trust.parent/(trust.name+'.sig')).write_text('-----BEGIN SSH SIGNATURE-----\nAAAA\n')
with pytest.raises(DecisionError):load_trust(trust)
def test_key_outside_anchor_refuses(trust,tmp_path):
key=tmp_path/'intruder'
subprocess.run(['ssh-keygen','-q','-t','ed25519','-N','','-f',str(key)],check=True,timeout=30)
with pytest.raises(DecisionError):load_trust(admit(trust,key=key))
def test_runtime_writable_anchor_refuses(trust,monkeypatch,founder_anchor):
admit(trust)
# The anchor is owned by the test's own uid, so the production rule must refuse.
monkeypatch.setattr(founder_admission,'_writable_by_runtime',founder_anchor['real_rule'])
assert os.stat(founder_anchor['anchor']).st_uid==os.geteuid()!=0
with pytest.raises(DecisionError):load_trust(trust)
def test_group_writable_anchor_refuses(trust,founder_anchor):
admit(trust);signers=founder_anchor['anchor']/'allowed_signers'
signers.chmod(0o664)
try:
with pytest.raises(DecisionError):load_trust(trust)
finally:signers.chmod(0o644)
def test_relocated_anchor_without_signers_refuses(trust,tmp_path,monkeypatch):
admit(trust);empty=tmp_path/'empty';empty.mkdir()
monkeypatch.setenv('SECRETS_ENGINE_ADMISSION_ANCHOR',str(empty))
with pytest.raises(DecisionError):load_trust(trust)

View file

@ -29,3 +29,39 @@ def test_oversize_public_input_refused(tmp_path):
def test_symlink_public_input_refused(tmp_path):
p=tmp_path/'selector.json';p.write_text('{}');link=tmp_path/'link';link.symlink_to(p)
with pytest.raises(ValueError):m.read(link)
# Founder attempt admission (SECRETS-WP-0015-T05), gated-executor signature pattern.
from datetime import datetime, timezone
from secrets_engine.errors import DecisionError
from tests.signature_support import admit, FOUNDER
NOW=datetime(2026,10,21,12,0,0,tzinfo=timezone.utc)
ISSUER='https://kc.coulomb.social'
SELECTOR_RAW=json.dumps({'issuer':ISSUER,'consumer':SELECTOR}).encode()
ADMISSION={'schema':'secrets-engine.recovery-attempt-admission.v1','selector_sha256':'sha256:'+hashlib.sha256(SELECTOR_RAW).hexdigest(),
'source_sha256':DIGEST,'issuer':ISSUER,'case':'secrets15-nk49-readiness',
'not_before':'2026-10-21T11:00:00Z','not_after':'2026-10-21T13:00:00Z'}
def signed(tmp_path,payload=ADMISSION,namespace='secrets-engine-recovery-attempt'):
p=tmp_path/'attempt.json';p.write_text(json.dumps(payload));admit(p,namespace=namespace)
return p.read_bytes(),(tmp_path/'attempt.json.sig').read_bytes()
def attempt(data,sig,selector_raw=SELECTOR_RAW,now=NOW):
return m.verify_attempt(data,sig,selector_raw=selector_raw,issuer=ISSUER,source_sha256=DIGEST,now=now)
def test_founder_admitted_attempt_binds_exact_inputs(tmp_path):
result=attempt(*signed(tmp_path))
assert result['principal']==FOUNDER and result['case']=='secrets15-nk49-readiness'
assert result['namespace']=='secrets-engine-recovery-attempt'
@pytest.mark.parametrize('field,value',[('selector_sha256','sha256:'+'0'*64),('source_sha256','sha256:'+'0'*64),
('issuer','https://other'),('case','Bad Case'),('schema','other'),('not_after','2026-10-22T12:00:01Z'),
('not_before','2026-10-21T12:00:01Z'),('not_after','2026-10-21T12:00:00Z')])
def test_attempt_binding_mismatch_refused(tmp_path,field,value):
with pytest.raises((ValueError,DecisionError)):attempt(*signed(tmp_path,{**ADMISSION,field:value}))
def test_attempt_signature_negatives_refused(tmp_path):
data,sig=signed(tmp_path)
for bad in [(data.replace(b'secrets15',b'secrets16'),sig),signed(tmp_path,namespace='secrets-engine-pdp-trust'),
signed(tmp_path,namespace='gexec-approval'),(data,b'')]:
with pytest.raises((ValueError,DecisionError)):attempt(*bad)
def test_attempt_refuses_changed_selector(tmp_path):
with pytest.raises((ValueError,DecisionError)):attempt(*signed(tmp_path),selector_raw=SELECTOR_RAW+b' ')
def test_cli_admission_refusal_is_suppressed(tmp_path,capsys,monkeypatch):
sel=tmp_path/'selector.json';sel.write_bytes(SELECTOR_RAW);(tmp_path/'a.json').write_text('{}')
monkeypatch.setattr('sys.argv',['x','--selector',str(sel),'--source-sha256',DIGEST,'--admission',str(tmp_path/'a.json')])
assert m.main()==1 and 'refused' in capsys.readouterr().out

View file

@ -15,6 +15,7 @@ import pytest
from tests.test_decision_signature import FIXTURE, PUBLIC
from tests.test_decision_transport import server
from tests.signature_support import admit
ROOT = Path(__file__).resolve().parents[1]
@ -74,11 +75,13 @@ def test_clean_install_uses_installed_wheel_and_signed_boundary(installed,tmp_pa
envelope=json.loads(FIXTURE.read_bytes())
trust={name:envelope['provenance'][name] for name in ('evaluator','mode','policy_package','policy_version','policy_package_digest')}
trust.update(schema='secrets-engine.pdp-trust.v1',keys={'testdata-ed25519':PUBLIC})
trust_file=tmp_path/'synthetic-trust.json';trust_file.write_text(json.dumps(trust))
trust_file=tmp_path/'synthetic-trust.json';trust_file.write_text(json.dumps(trust));admit(trust_file)
token=tmp_path/'synthetic.token';token.write_text('synthetic-pdp-credential');token.chmod(0o600)
script='''import pathlib,sys
import secrets_engine.decision_check as installed
from secrets_engine.errors import DecisionError
import secrets_engine.founder_admission as admission
admission._writable_by_runtime=lambda info: bool(info.st_mode & 0o022) # test runs as anchor owner
assert str(pathlib.Path(installed.__file__).resolve()).startswith(sys.argv[1]+'/venv/')
try:
result=installed.check_decision(base_url=sys.argv[2],token_file=pathlib.Path(sys.argv[3]),request={},trust_file=pathlib.Path(sys.argv[4]),verifier=pathlib.Path(sys.argv[1]+'/libexec/secrets-engine-pdp-verify'))

View file

@ -20,7 +20,7 @@ from secrets_engine.execution_contract import ExecutionContract
from secrets_engine.task_execution_handoff import (authorize_task_execution,consume_task_execution,
TaskExecutionAuthorization)
from tests.authorization_stub import AuthorizationStub
from tests.signature_support import signature_tools, admit_tools, sign_envelope
from tests.signature_support import signature_tools, admit_tools, sign_envelope, admit
from tests.test_consume_binding_join import _Cfg,_token,APPROVAL_ID
from tests.test_execution_context import entry,descriptor
@ -168,7 +168,7 @@ def test_changed_owner_coordinates_refuse_before_cas(lane,field):
def test_removed_signer_refuses_before_cas(lane):
cfg,e,contract,state=lane;result=prepared(lane)
trust=json.loads(cfg.pdp_trust_file.read_text());trust['keys']={'other':next(iter(trust['keys'].values()))}
cfg.pdp_trust_file.write_text(json.dumps(trust))
cfg.pdp_trust_file.write_text(json.dumps(trust));admit(cfg.pdp_trust_file)
with pytest.raises(DecisionError):consume_task_execution(cfg,result)
assert 'consume' not in state['calls']

View file

@ -3,9 +3,16 @@
Checks current consumer source constants and the producer's already-deployed
public env/denial selector. This is source metadata, not runtime/owner assent.
With --admission, additionally requires a founder SSH signature (gated-executor
pattern, namespace secrets-engine-recovery-attempt) over an attempt admission
that binds these exact selector bytes, source hash, issuer, case and a bounded
window. That is checkable founder authority for one attempt, still not native
acceptance; single-use consumption stays with the bounded NK49 execution.
"""
import argparse
import ast
from datetime import datetime, timezone
import hashlib
import json
import os
@ -15,6 +22,8 @@ import stat
ROOT=Path(__file__).resolve().parents[1]
LIMIT=128*1024
ADMISSION_FIELDS={'schema','selector_sha256','source_sha256','issuer','case','not_before','not_after'}
MAX_WINDOW=86400
def read(path):
path=Path(os.path.abspath(path))
@ -51,13 +60,38 @@ def verify(selector,source,source_sha256):
return {'schema':'secrets-engine.recovery-selector-preflight.v1','status':'source_selector_matches_not_native_assent',
'source_sha256':source_sha256,'consumer':expected,'credential_source_exercised':False,'network_contacted':False}
def ts(value):
if not isinstance(value,str) or not re.fullmatch(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z',value):raise ValueError()
return datetime.strptime(value,'%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc)
def verify_attempt(admission,signature,*,selector_raw,issuer,source_sha256,now=None):
"""Founder-signed exact attempt binding; the signature covers the admission bytes."""
import sys;sys.path.insert(0,str(ROOT/'src'))
from secrets_engine.founder_admission import verify_admission
payload=json.loads(admission,object_pairs_hook=unique)
if not isinstance(payload,dict) or set(payload)!=ADMISSION_FIELDS:raise ValueError()
if payload['schema']!='secrets-engine.recovery-attempt-admission.v1':raise ValueError()
if payload['selector_sha256']!='sha256:'+hashlib.sha256(selector_raw).hexdigest():raise ValueError()
if payload['source_sha256']!=source_sha256 or payload['issuer']!=issuer:raise ValueError()
if not isinstance(payload['case'],str) or not re.fullmatch(r'[a-z0-9][a-z0-9.-]{0,127}',payload['case']):raise ValueError()
start,end=ts(payload['not_before']),ts(payload['not_after']);now=now or datetime.now(timezone.utc)
if not start<=now<end or (end-start).total_seconds()>MAX_WINDOW:raise ValueError()
signer=verify_admission(admission,signature,namespace='secrets-engine-recovery-attempt')
return {'case':payload['case'],'not_after':payload['not_after'],**signer}
def main():
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--selector',required=True);p.add_argument('--source-sha256',required=True)
p.add_argument('--admission',help='founder-signed attempt admission JSON; signature at <admission>.sig')
args=p.parse_args()
try:
proposal=json.loads(read(args.selector),object_pairs_hook=unique)
selector_raw=read(args.selector)
proposal=json.loads(selector_raw,object_pairs_hook=unique)
if not isinstance(proposal,dict) or not isinstance(proposal.get('consumer'),dict):raise ValueError()
result=verify(proposal['consumer'],read(ROOT/'src/secrets_engine/service_auth.py'),args.source_sha256)
if args.admission:
result['attempt_admission']=verify_attempt(read(args.admission),read(args.admission+'.sig'),
selector_raw=selector_raw,issuer=proposal.get('issuer'),source_sha256=args.source_sha256)
result['status']='founder_admitted_attempt_not_native_acceptance'
except Exception:print('{"status":"source_selector_refused","private_diagnostics_suppressed":true}');return 1
print(json.dumps(result,indent=2));return 0

View file

@ -8,18 +8,29 @@ status: active
owner: codex
topic_slug: secrets-engine
created: "2026-10-12"
updated: "2026-10-16"
updated: "2026-10-21"
flavor: implementation
depends_on: []
related: [CUST-WP-0082, FLEX-WP-0024, GEXEC-WP-0001, SECRETS-WP-0013]
state_hub_workstream_id: "7252469d-d7a5-5c18-8911-b511b8c6a736"
---
Native owner work for CUST-WP-0080 G13. Reuses the accepted Check contract and
Native owner work for G13, now tracked as CUST-WP-0082-T06. CUST-WP-0080 finished
on 2026-10-19 and transferred its T21 native acceptance obligation to WP-82 without
implementing it. Reuses the accepted Check contract and
FLEX-WP-0024 producer signature surface; does not create another PDP or grant
any catalog-lane execution authority. SECRETS-WP-0013 still owns task-scoped
protected execution. Transport review is complete; key admission and native
rollout remain separate gates below.
2026-10-21 review against gated-executor (GEXEC-WP-0001, accepted on railiance01
2026-10-20): the estate now has a working, founder-held exact-byte approval
mechanism (`ssh-keygen -Y`, runtime-unwritable `allowed_signers`, per-purpose
namespace). T02's open "independent admission" gap had no concrete anchor before
this; it now reuses that pattern. T02 closes at source scope. The founder's actual
signature over the real producer trust file is split out as human-gated T04, and
T03's native rollout depends on it.
## Prevent the Check credential from leaving the admitted local transport
```task
@ -44,7 +55,7 @@ This closes the source transport defects only; T02/T03 remain mandatory.
```task
id: SECRETS-WP-0014-T02
status: progress
status: done
priority: high
state_hub_task_id: "877782e5-045b-5186-8b95-82e25db21a5a"
```
@ -86,21 +97,52 @@ artifacts, signed HTTP consumption and missing/tampered artifact refusal; they d
not admit packages, keys or a production prefix. T02 remains progress and T03
wait; no native installation, trust change or protected entitlement is claimed.
2026-10-21 founder admission anchor (closes T02 source scope): `load_trust` now
refuses any trust file without a sibling `<trust>.sig`. The signature must be made
with `ssh-keygen -Y sign -n secrets-engine-pdp-trust` by exactly one principal in
`/etc/secrets-engine/admission/allowed_signers`, and is verified over the exact
bytes that are parsed. That anchor and directory must not be writable by, or owned
by, the running UID (gated-executor rule), which closes the same-UID trust
self-admission path recorded above. `src/secrets_engine/founder_admission.py`;
operator procedure `docs/founder-admission.md`. Tests cover unsigned, edited after
signing, wrong namespace (including `gexec-approval`), unknown signer key,
runtime-owned or group-writable anchor and missing anchor, plus re-signed
rotation overlap/removal in the existing signature and installed-bundle tests.
No production key, anchor installation or trust admission occurred. That work is T04.
## Admit the real producer verification key by founder signature
```task
id: SECRETS-WP-0014-T04
status: wait
priority: high
needs_human: true
depends_on: [SECRETS-WP-0014-T02]
blocking_reason: "Needs Access's real producer kid/public key/custody receipt (FLEX-WP-0024) and the founder's signature over the exact trust file; the anchor install under /etc needs root."
```
Install `/etc/secrets-engine/admission/allowed_signers` (root-owned) with the
founder's approver public key, the same key that signs gexec approvals. Write
the exact `pdp-trust.json` for the real producer kid/key and pin. The founder
signs it in namespace `secrets-engine-pdp-trust`. Record only the principal, key
fingerprint and trust-file hash. This is founder action, not worker
work. A local signature test is not admission.
## Verify the admitted native consumer and rotation path
```task
id: SECRETS-WP-0014-T03
status: wait
priority: high
depends_on: [SECRETS-WP-0014-T02]
blocking_reason: "Requires reviewed verification-key admission, source enforcement and exact native rollout receipt; no production trust change is proposed yet."
depends_on: [SECRETS-WP-0014-T02, SECRETS-WP-0014-T04]
blocking_reason: "Requires the founder-signed key admission (T04) and an exact native rollout receipt; source enforcement is done (T02)."
state_hub_task_id: "85aa4c78-b66d-5201-8e31-0ed50e7eaeec"
```
Under an exact reviewed rollout, prove the real pin and consumer accept the
admitted signer, reject forgery/downgrade, rotate without accepting removed keys,
and preserve request/approval binding, lifetime and single-use consumption.
Retain rollback and failure evidence. Update CUST-WP-0080 G13 only with these
Retain rollback and failure evidence. Update CUST-WP-0082-T06 (G13) only with these
native receipts; do not equate passing transport tests with closure.

View file

@ -8,10 +8,10 @@ status: active
owner: codex
topic_slug: custodian
created: "2026-10-17"
updated: "2026-10-17"
updated: "2026-10-21"
flavor: implementation
depends_on: []
related: [CUST-WP-0080, NK-WP-0049, SECRETS-WP-0012, SECRETS-WP-0008, SECRETS-WP-0013]
related: [CUST-WP-0082, NK-WP-0049, SECRETS-WP-0012, SECRETS-WP-0008, SECRETS-WP-0013, SECRETS-WP-0014, GEXEC-WP-0001]
state_hub_workstream_id: "68a0642e-4908-5260-89c9-3adce60e6a4d"
---
@ -22,6 +22,14 @@ it is not native assent. Finished0012 remains finished; functional adoption/back
entitlement stay0008T06/0013. No new grant,
secret fetch, provider login, production redirect or external message occurred.
2026-10-21 review against gated-executor (GEXEC-WP-0001, accepted on railiance01
2026-10-20): the T03 plan requires "surviving offline founder authorization" bound
to the exact source/issuer/case/deadline, with "no new auth or approval service".
The gexec approval pattern (founder `ssh-keygen -Y` signature, runtime-unwritable
`allowed_signers`) is that existing mechanism, and it verifies offline during an
outage. T05 makes it a checkable precondition of the selector preflight, and T04
now requires it. CUST-WP-0080 finished; its remaining native gates live in CUST-WP-0082.
## Establish exact current intended source selector
```task
@ -94,6 +102,29 @@ original OpenBao and its custody/OIDC/MFA graph for full consumer continuity;
exact native action approval and resource-server negative/revocation receipts.
Protected backend operation entitlement stays in existing0008T06/0013.
## Require founder-signed exact attempt admission
```task
id: SECRETS-WP-0015-T05
status: done
priority: high
assignee: claude
depends_on: [SECRETS-WP-0015-T03]
```
Completed 2026-10-21 as source. `tools/check-keycape-recovery-selector.py --admission`
verifies a `secrets-engine.recovery-attempt-admission.v1` document. It binds the
exact selector-file SHA-256, the consumer source hash, the issuer, the case id and
a window of at most 24 hours. The founder signs it in namespace
`secrets-engine-recovery-attempt` against the shared runtime-unwritable anchor
(`src/secrets_engine/founder_admission.py`, `docs/founder-admission.md`; same
anchor as SECRETS-WP-0014). A pass reports
`founder_admitted_attempt_not_native_acceptance`. Synthetic tests cover every
binding mismatch, the expired/early/overlong window, edits after signing, the
trust/gexec namespaces, an empty signature, a changed selector and suppressed CLI
diagnostics. No private archive, token, endpoint or real founder signature was
touched. Single-use consumption and cleanup remain in the bounded NK49 execution.
## Verify the separately admitted actual isolated consumer result
```task
@ -102,11 +133,13 @@ status: wait
priority: high
assignee: codex
needs_human: true
depends_on: [SECRETS-WP-0015-T03, NK-WP-0049-T33]
blocking_reason: "Source plan delivered in T03. Await NK-WP-0049-T33 original TLS/archive/private admission, exact native owner authority, and original OpenBao/custody recovery for actual consumer continuity."
depends_on: [SECRETS-WP-0015-T03, SECRETS-WP-0015-T05, NK-WP-0049-T33]
blocking_reason: "Source plan (T03) and signed-admission check (T05) delivered. Await NK-WP-0049-T33 original TLS/archive/private admission, the founder's signed attempt admission for the exact case, and original OpenBao/custody recovery for actual consumer continuity."
state_hub_task_id: "aeec6eb0-39e2-5f7c-8f5d-778133edfc73"
```
Before any key prompt, the selector preflight must pass with `--admission` on the
founder-signed attempt document for this exact case (T05).
Only a separately admitted owner action can establish actual archived registration,
issuer trust, native negative checks and consumer continuity. Source selector and
historical provider proof do not fill this gate. No production consumer redirect,