access-engine/tests/test_native_signer_receipt.py
tegwick 919ce25a37
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Failing after 59s
Prepare signed lifecycle runtime admission and receipt verification
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
2026-10-16 10:48:28 +02:00

100 lines
6.6 KiB
Python

"""Offline tool regression using the producer's existing synthetic signer only."""
import copy
from datetime import datetime,timedelta,timezone
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest import mock
ROOT=Path(__file__).resolve().parents[1]
@unittest.skipUnless(os.environ.get('SECRETS_ENGINE_SOURCE_ROOT'), 'requires reviewed Secrets consumer source/bundle for integration checks')
class NativeSignerReceiptTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
global MODULE
spec=importlib.util.spec_from_file_location('signer_receipt',ROOT/'tools/verify_native_signer_receipt.py')
MODULE=importlib.util.module_from_spec(spec);spec.loader.exec_module(MODULE)
consumer=Path(os.environ['SECRETS_ENGINE_SOURCE_ROOT'])
cls.scratch=tempfile.TemporaryDirectory();cls.base=Path(cls.scratch.name)
env={**os.environ,'GOFLAGS':'-buildvcs=false'}
cls.verifier=cls.base/'verify';cls.signer=cls.base/'synthetic-sign'
for binary,package in [(cls.verifier,'.'),(cls.signer,'./testsupport/signer')]:
subprocess.run(['go','build','-p','2','-o',str(binary),package],cwd=consumer/'verifier',env=env,check=True)
cls.now=datetime.now(timezone.utc)
env=json.loads((consumer/'tests/fixtures/pdp-signature/decision_rotate_signed.json').read_text())
env['provenance']['caller']={'mode':'enforce','principal':'system:serviceaccount:secrets-engine:secrets-engine','audience':'flex-auth','not_after':(cls.now+timedelta(minutes=20)).isoformat()}
env['lifetime']['not_before']=(cls.now-timedelta(seconds=1)).isoformat();env['lifetime']['expires_at']=(cls.now+timedelta(minutes=15)).isoformat()
def sign(envelope):
return json.loads(subprocess.check_output([str(cls.signer)],input=json.dumps(envelope).encode()))
env['diagnostics']['target']={'phase':{'uses':1}}
cls.allow=sign(env)
req=json.loads((ROOT/'examples/secrets-engine/check_request_allow_rotate.json').read_text())
(cls.base/'allow-request.json').write_text(json.dumps(req))
denied=copy.deepcopy(req);denied['action']='execute-ccr'
cls.deny=copy.deepcopy(env);cls.deny['effect']='deny';cls.deny['binding']['action']='execute-ccr';cls.deny['binding']['submitted_request_digest']=MODULE.request_digest(denied);cls.deny=sign(cls.deny)
(cls.base/'deny-request.json').write_text(json.dumps(denied))
provenance=cls.allow['provenance'];cls.trust={key:provenance[key] for key in ('evaluator','mode','policy_package','policy_version','policy_package_digest')}
cls.trust.update(schema='secrets-engine.pdp-trust.v1',keys={'testdata-ed25519':'IVL40Zt5HSRFMkLhXy6rbLfP-ntqXtMAl5YOBpiB2xI'})
@classmethod
def tearDownClass(cls):cls.scratch.cleanup()
def setUp(self):
for label,envelope in [('allow',self.allow),('deny',self.deny)]:
for kind in ('envelope','record'):(self.base/f'{label}-{kind}.json').write_text(json.dumps(envelope))
(self.base/'trust.json').write_text(json.dumps(self.trust))
self.config={'schema':'access-engine.signer-receipt-check.v1','trust_file':str(self.base/'trust.json'),'verifier':str(self.verifier),'verifier_sha256':hashlib.sha256(self.verifier.read_bytes()).hexdigest(),'expected_caller':{'mode':'enforce','principal':'system:serviceaccount:secrets-engine:secrets-engine','audience':'flex-auth'}}
for label in ('allow','deny'):
for kind in ('request','envelope','record'):self.config[f'{label}_{kind}']=str(self.base/f'{label}-{kind}.json')
def test_genuine_signed_receipt_and_refusal_matrix(self):
result=MODULE.verify(self.config,now=self.now)
self.assertTrue(all(result['checks'].values()));self.assertEqual(len(result['checks']),7)
def test_changed_source_record_refuses(self):
record=copy.deepcopy(self.allow);record['effect']='deny';(self.base/'allow-record.json').write_text(json.dumps(record))
with self.assertRaises(ValueError):MODULE.verify(self.config,now=self.now)
def test_nested_record_number_type_change_refuses(self):
for replacement in (True,1.0):
with self.subTest(replacement_type=type(replacement).__name__):
record=copy.deepcopy(self.allow)
record['diagnostics']['target']['phase']['uses']=replacement
(self.base/'allow-record.json').write_text(json.dumps(record))
with self.assertRaises(ValueError):MODULE.verify(self.config,now=self.now)
def test_receipt_hashes_use_the_once_read_verified_bytes(self):
actual_read=MODULE.read
seen={}
original={str(self.base/f'{label}-{suffix}.json'):actual_read(str(self.base/f'{label}-{suffix}.json')) for label in ('allow','deny') for suffix in ('request','envelope','record')}
def once(path):
if path in original:
seen[path]=seen.get(path,0)+1
self.assertEqual(seen[path],1,'receipt was reopened after verification')
return actual_read(path)
with mock.patch.object(MODULE,'read',side_effect=once):
result=MODULE.verify(self.config,now=self.now)
self.assertEqual(len(seen),6)
for label in ('allow','deny'):
for suffix in ('request','envelope','record'):
self.assertEqual(result['input_sha256'][label+'_'+suffix],hashlib.sha256(original[str(self.base/f'{label}-{suffix}.json')]).hexdigest())
def test_oversized_file_read_is_bounded(self):
stream=mock.MagicMock();stream.__enter__.return_value=stream;stream.read.return_value=b'x'*524289
with mock.patch.object(Path,'open',return_value=stream):
with self.assertRaises(ValueError):MODULE.read('/absolute/oversized.json')
stream.read.assert_called_once_with(524289)
def test_unadmitted_helper_digest_refuses(self):
self.config['verifier_sha256']='0'*64
with self.assertRaises(ValueError):MODULE.verify(self.config,now=self.now)
def test_disabled_caller_admission_refuses(self):
self.config['expected_caller']['mode']='disabled'
with self.assertRaises(ValueError):MODULE.verify(self.config,now=self.now)
def test_expired_captured_allow_refuses(self):
with self.assertRaises(MODULE.DecisionError):MODULE.verify(self.config,now=self.now+timedelta(minutes=16))
def test_wrong_independent_policy_pin_refuses(self):
wrong=copy.deepcopy(self.trust);wrong['policy_package_digest']='sha256:'+'0'*64;(self.base/'trust.json').write_text(json.dumps(wrong))
with self.assertRaises(MODULE.DecisionError):MODULE.verify(self.config,now=self.now)
if __name__=='__main__':unittest.main()