140 lines
8.3 KiB
Diff
140 lines
8.3 KiB
Diff
diff --git a/tools/verify_native_signer_receipt.py b/tools/verify_native_signer_receipt.py
|
|
--- a/tools/verify_native_signer_receipt.py
|
|
+++ b/tools/verify_native_signer_receipt.py
|
|
@@ -25,10 +25,22 @@
|
|
target = Path(path)
|
|
if not target.is_absolute():
|
|
raise ValueError('absolute input path required')
|
|
- data = target.read_bytes()
|
|
+ with target.open("rb") as stream:
|
|
+ data = stream.read(524289)
|
|
if len(data) > 524288:
|
|
raise ValueError('receipt input too large')
|
|
return data
|
|
+
|
|
+def same_json_types(left, right) -> bool:
|
|
+ """JSON value correspondence only; never signature canonicalization."""
|
|
+ if type(left) is not type(right):
|
|
+ return False
|
|
+ if isinstance(left, dict):
|
|
+ return left.keys() == right.keys() and all(same_json_types(value,right[key]) for key,value in left.items())
|
|
+ if isinstance(left, list):
|
|
+ return len(left) == len(right) and all(same_json_types(a,b) for a,b in zip(left,right))
|
|
+ return left == right
|
|
+
|
|
|
|
def stamp(value: str) -> datetime:
|
|
parsed = datetime.fromisoformat(value.replace('Z','+00:00'))
|
|
@@ -46,20 +58,25 @@
|
|
executable = Path(config['verifier'])
|
|
if not executable.is_absolute() or not re.fullmatch('[a-f0-9]{64}',config['verifier_sha256']) or hashlib.sha256(executable.read_bytes()).hexdigest() != config['verifier_sha256']:
|
|
raise ValueError('installed verifier digest mismatch')
|
|
+ trust_bytes = read(config['trust_file'])
|
|
trust = load_trust(Path(config['trust_file']))
|
|
+ if not same_json_types(trust,strict_json(trust_bytes)):
|
|
+ raise ValueError('trust input changed during verification')
|
|
if (trust['evaluator'],trust['mode'],trust['policy_package'],trust['policy_version']) != ('flex-auth/local','standalone','secrets-engine.catalog-lane.lifecycle','v2'):
|
|
raise ValueError('signer-only lifecycle package required')
|
|
checks = {}
|
|
envelopes = {}
|
|
inputs = {}
|
|
+ requests = {}
|
|
for label in ('allow','deny'):
|
|
- body = read(config[label+'_envelope'])
|
|
+ blobs = {suffix:read(config[label+'_'+suffix]) for suffix in ('request','envelope','record')}
|
|
+ body = blobs['envelope']
|
|
# Strict decode before helper; authenticated typed return alone is acted on.
|
|
strict_json(body.decode('utf-8'))
|
|
envelope = authenticate_response(body,trust=trust,verifier=executable)
|
|
- request = strict_json(read(config[label+'_request']))
|
|
- record = strict_json(read(config[label+'_record']))
|
|
- if record != envelope:
|
|
+ request = strict_json(blobs['request'])
|
|
+ record = strict_json(blobs['record'])
|
|
+ if not same_json_types(record,envelope):
|
|
raise ValueError('captured original source record differs from authenticated response')
|
|
provenance = envelope['provenance']['caller']
|
|
if any(provenance.get(key) != value for key,value in caller.items()) or stamp(provenance['not_after']) <= current:
|
|
@@ -70,8 +87,9 @@
|
|
if envelope['effect'] != 'deny' or envelope['binding']['submitted_request_digest'] != request_digest(request):
|
|
raise ValueError('deny receipt does not bind exact negative request')
|
|
envelopes[label] = envelope
|
|
+ requests[label] = request
|
|
for suffix in ('request','envelope','record'):
|
|
- inputs[label+'_'+suffix] = hashlib.sha256(read(config[label+'_'+suffix])).hexdigest()
|
|
+ inputs[label+'_'+suffix] = hashlib.sha256(blobs[suffix]).hexdigest()
|
|
checks[label+'_authenticated_bound_and_record_matched'] = True
|
|
# These are local refusals against the captured real producer envelope. They
|
|
# do not claim a rejected caller was exercised on the native HTTP endpoint.
|
|
@@ -89,13 +107,13 @@
|
|
raise ValueError('negative authentication case accepted')
|
|
try:
|
|
expiry=stamp(envelopes['allow']['lifetime']['expires_at'])
|
|
- request=strict_json(read(config['allow_request']))
|
|
+ request=requests['allow']
|
|
validate_decision_envelope(envelopes['allow'],request,accepted_policy_packages={trust['policy_package']},accepted_policy_versions={trust['policy_version']},now=expiry)
|
|
except DecisionError:
|
|
checks['expiry_boundary_refused'] = True
|
|
else:
|
|
raise ValueError('expired allow accepted')
|
|
- return {'schema':'access-engine.signer-receipt-result.v1','checks':checks,'input_sha256':inputs,'verifier_sha256':config['verifier_sha256'],'trust_file_sha256':hashlib.sha256(read(config['trust_file'])).hexdigest(),'boundary':'offline captured signed receipt checks only; no native caller-denial, record durability, receiver custody, trust admission, rotation or protected effect inferred'}
|
|
+ return {'schema':'access-engine.signer-receipt-result.v1','checks':checks,'input_sha256':inputs,'verifier_sha256':config['verifier_sha256'],'trust_file_sha256':hashlib.sha256(trust_bytes).hexdigest(),'boundary':'offline captured signed receipt checks only; no native caller-denial, record durability, receiver custody, trust admission, rotation or protected effect inferred'}
|
|
|
|
def main() -> int:
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
diff --git a/tests/test_native_signer_receipt.py b/tests/test_native_signer_receipt.py
|
|
--- a/tests/test_native_signer_receipt.py
|
|
+++ b/tests/test_native_signer_receipt.py
|
|
@@ -9,6 +9,7 @@
|
|
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')
|
|
@@ -30,6 +31,7 @@
|
|
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))
|
|
@@ -53,6 +55,36 @@
|
|
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)
|