feat(admission): founder-signed trust and recovery-attempt admission (WP14 T02, WP15 T05)
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:
parent
497040c58c
commit
ebd8e49aff
15 changed files with 469 additions and 29 deletions
21
tests/conftest.py
Normal file
21
tests/conftest.py
Normal 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}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
68
tests/test_founder_admission.py
Normal file
68
tests/test_founder_admission.py
Normal 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)
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue