diff --git a/SCOPE.md b/SCOPE.md index 3cd9d77..3a7382d 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -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`. diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 4b16619..9fb6173 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/docs/founder-admission.md b/docs/founder-admission.md new file mode 100644 index 0000000..3f9e088 --- /dev/null +++ b/docs/founder-admission.md @@ -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 | `.sig` | + +## Admit or rotate responder trust + +```bash +ssh-keygen -Y sign -f ~/.ssh/ -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:", + "source_sha256": "sha256:", + "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/ -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. diff --git a/src/secrets_engine/decision_signature.py b/src/secrets_engine/decision_signature.py index d9bd0b9..4459d7a 100644 --- a/src/secrets_engine/decision_signature.py +++ b/src/secrets_engine/decision_signature.py @@ -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 ``.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): diff --git a/src/secrets_engine/founder_admission.py b/src/secrets_engine/founder_admission.py new file mode 100644 index 0000000..22d136a --- /dev/null +++ b/src/secrets_engine/founder_admission.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9c06909 --- /dev/null +++ b/tests/conftest.py @@ -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} diff --git a/tests/signature_support.py b/tests/signature_support.py index 9f227f1..5a196ce 100644 --- a/tests/signature_support.py +++ b/tests/signature_support.py @@ -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 `.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') diff --git a/tests/test_decision_signature.py b/tests/test_decision_signature.py index 5d7ffba..8391452 100644 --- a/tests/test_decision_signature.py +++ b/tests/test_decision_signature.py @@ -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) diff --git a/tests/test_founder_admission.py b/tests/test_founder_admission.py new file mode 100644 index 0000000..f8b37ee --- /dev/null +++ b/tests/test_founder_admission.py @@ -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) + diff --git a/tests/test_keycape_recovery_selector.py b/tests/test_keycape_recovery_selector.py index 46667a6..07909c8 100644 --- a/tests/test_keycape_recovery_selector.py +++ b/tests/test_keycape_recovery_selector.py @@ -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 diff --git a/tests/test_release_bundle.py b/tests/test_release_bundle.py index c481cd5..f0054d9 100644 --- a/tests/test_release_bundle.py +++ b/tests/test_release_bundle.py @@ -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')) diff --git a/tests/test_task_execution_handoff.py b/tests/test_task_execution_handoff.py index 2204da2..e46547a 100644 --- a/tests/test_task_execution_handoff.py +++ b/tests/test_task_execution_handoff.py @@ -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'] diff --git a/tools/check-keycape-recovery-selector.py b/tools/check-keycape-recovery-selector.py index 88d5754..52e39b2 100644 --- a/tools/check-keycape-recovery-selector.py +++ b/tools/check-keycape-recovery-selector.py @@ -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<=nowMAX_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 .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 diff --git a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md index 814fea4..2e49ff0 100644 --- a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +++ b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md @@ -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 `.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. diff --git a/workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md b/workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md index 9e1005e..fd7e0ab 100644 --- a/workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md +++ b/workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md @@ -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,