From 728fd68f370e9eeb61b079d15cc3b9f8d2b02dbf Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Tue, 8 Sep 2026 21:49:10 -0700 Subject: [PATCH] feat: read a witness's signed iat and grade, and refuse anything else The witness is adding two optional protected headers: a CWT Claims map (label 15, RFC 9597) carrying iat (label 6, RFC 8392), which is the witness clock at registration, and a private-use label -65537 carrying its grade. Receipts built with neither are byte-for-byte the shape we already verify. Our verifier compared the protected header against one fixed encoding, so the first receipt carrying either field would have been refused as an unsupported profile. It now parses the header, still requires alg -8 and vds 1, accepts those two labels, and refuses any other: unreviewed signed metadata is not neutral just because it is signed. Two limits stop being permanently false. witness_time_established follows a signed iat. grade_cryptographically_bound requires the signed grade to be the grade the response reports, since a private-use label carries no registered meaning on its own; a signed value that disagrees with the untrusted one binds nothing. Six tests mint receipts over the captured inclusion proof with our own key, so nothing here asserts the witness has deployed. The existing negative test for submitter-supplied metadata is untouched, because a signed iat is not that. README and LIMITATIONS said the grade is unsigned and there is no witness time. That is true of the captured receipt and becomes false the day a receipt carries either, so both now point at the two verifier fields. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RMuXK1es7dsRsPtvC5Tkpd --- LIMITATIONS.md | 10 +++-- README.md | 4 +- tests/test_witness_receipt.py | 71 +++++++++++++++++++++++++++++++++ tools/verify_witness_receipt.py | 55 ++++++++++++++++++++++++- 4 files changed, 134 insertions(+), 6 deletions(-) diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 72a7256..ef27b19 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -46,9 +46,13 @@ key pin, and matches the separately fetched receipt. See This is a one-checkpoint demonstration, not continuous or reciprocal witnessing. It does not prove registry continuity, prevent split views, cover the June entry, or prove payload retention. The JSON response reports `countersigned-observed`, but that grade is not signed inside this -receipt. The receipt signs a Merkle root without a witness timestamp: the checkpoint timestamp -is the registry signer's assertion, and our capture time is an observer's local record. Do not -present either as a cryptographically authenticated witness time. The receipt binds the +receipt, and this receipt signs a Merkle root without a witness timestamp: the checkpoint +timestamp is the registry signer's assertion, and our capture time is an observer's local +record. Do not present either as a cryptographically authenticated witness time. Both are +properties of a receipt rather than of the profile. A witness may sign a registration time as +a CWT `iat` and its grade under a private-use label; where it does, `verify_witness_receipt` +reports `witness_time_established` and `grade_cryptographically_bound`, and where it does not, +both stay false. Read those two fields rather than this paragraph. The receipt binds the checkpoint's nine-field signing body by digest; its signature and optional consistency proof are not included in that witnessed digest. Registry signature verification is a separate check. diff --git a/README.md b/README.md index 31ad80b..a296be9 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,9 @@ Checkpoint 1 now has an offline-verifiable receipt from an independently operate The [September 7 evidence packet](docs/evidence/witness-2026-09-07/README.md) includes the original checkpoint, returned receipt, separately fetched copies, key provenance and verifier. It proves inclusion of that checkpoint signing-body digest under the pinned witness key. -It does not certify continuity or a witness time; the response's grade is unsigned metadata. +It does not certify continuity. Whether a receipt carries a witness time or a signed grade is +a property of that receipt, so the verifier reports `witness_time_established` and +`grade_cryptographically_bound` rather than asserting either. Both are false for this capture. The pipeline does not yet submit future checkpoints automatically. Parallel independent witnesses remain supported as a deployment choice; only one operator is demonstrated here. diff --git a/tests/test_witness_receipt.py b/tests/test_witness_receipt.py index fa2d34e..01473ec 100644 --- a/tests/test_witness_receipt.py +++ b/tests/test_witness_receipt.py @@ -124,3 +124,74 @@ def test_extra_cbor_bytes_rejected(self): if __name__ == '__main__': unittest.main() + + +class SignedReceiptMetadataTests(unittest.TestCase): + """The post-fix wire shape: a witness clock and a grade inside the signature. + + These receipts are minted here, unlike every test above, because the + captured one predates the change. What is borrowed from the capture is + the inclusion proof and the root it commits to; the protected header and + the signature over it are ours, so nothing here asserts that the witness + has deployed anything. + """ + + setUp = WitnessReceiptTests.setUp + check = WitnessReceiptTests.check + + def mint(self, headers, *, grade=None): + import cbor2 + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + root = bytes.fromhex(self.check()['root']) + envelope = cbor2.loads(base64.b64decode(self.response['receipt_b64'])).value + protected = cbor2.dumps(headers) + key = Ed25519PrivateKey.generate() + signature = key.sign(cbor2.dumps(['Signature1', protected, b'', root])) + receipt = cbor2.dumps(cbor2.CBORTag(18, [protected, envelope[1], None, signature])) + response = copy.deepcopy(self.response) + response['receipt_b64'] = base64.b64encode(receipt).decode() + if grade is not None: + response['grade'] = grade + public = key.public_key().public_bytes_raw().hex() + return self.check(response=response, witness_key=public) + + def test_captured_receipt_carries_no_signed_metadata(self): + result = self.check() + self.assertIsNone(result['signed_iat']) + self.assertIsNone(result['signed_grade']) + self.assertFalse(result['limits']['witness_time_established']) + + def test_signed_iat_is_surfaced_and_establishes_witness_time(self): + result = self.mint({1: -8, 395: 1, 15: {6: 1788914655}}) + self.assertTrue(result['verified'], result) + self.assertEqual(result['signed_iat'], 1788914655) + self.assertTrue(result['limits']['witness_time_established']) + self.assertFalse(result['limits']['grade_cryptographically_bound']) + + def test_signed_grade_binds_only_when_it_matches_the_response(self): + agreeing = self.mint({1: -8, 395: 1, -65537: 'countersigned-observed'}, + grade='countersigned-observed') + self.assertTrue(agreeing['verified'], agreeing) + self.assertTrue(agreeing['limits']['grade_cryptographically_bound']) + disagreeing = self.mint({1: -8, 395: 1, -65537: 'countersigned-observed'}, + grade='mmr-verified') + self.assertTrue(disagreeing['verified'], disagreeing) + self.assertFalse(disagreeing['limits']['grade_cryptographically_bound']) + + def test_unreviewed_signed_header_is_refused(self): + result = self.mint({1: -8, 395: 1, 1234: 'anything'}) + self.assertFalse(result['verified']) + self.assertIn('unreviewed signed receipt headers', result['error']) + + def test_cwt_claims_map_carries_iat_and_nothing_else(self): + for claims in ({6: 1788914655, 1: 'issuer'}, {}, {6: -1}, {6: 'today'}): + with self.subTest(claims=claims): + result = self.mint({1: -8, 395: 1, 15: claims}) + self.assertFalse(result['verified'], result) + + def test_neither_field_present_is_the_pre_fix_shape(self): + result = self.mint({1: -8, 395: 1}) + self.assertTrue(result['verified'], result) + self.assertIsNone(result['signed_iat']) + self.assertIsNone(result['signed_grade']) + self.assertFalse(any(result['limits'].values())) diff --git a/tools/verify_witness_receipt.py b/tools/verify_witness_receipt.py index 8319a70..7d4d48d 100644 --- a/tools/verify_witness_receipt.py +++ b/tools/verify_witness_receipt.py @@ -29,6 +29,49 @@ def unique(pairs): return json.loads(Path(path).read_bytes(), object_pairs_hook=unique) + +# COSE protected-header labels this adapter accepts. alg and vds are the +# stage-1 profile. 15 is the CWT Claims map (RFC 9597 s2), carrying 6, iat +# (RFC 8392 s3.1.6), the witness clock at registration. -65537 is private +# use: a witness may put its grade there, and a private-use label has no +# registered meaning, so it is read as an opaque string and only counts +# once it agrees with the grade the response reports. Anything else is +# refused rather than ignored: unreviewed signed metadata is not neutral. +# Duplicate labels collapse in the CBOR decoder rather than raising; that is +# tolerable here only because these bytes are inside the witness signature. +CWT_CLAIMS = 15 +CWT_IAT = 6 +PRIVATE_GRADE = -65537 +ALLOWED_PROTECTED = {1, 395, CWT_CLAIMS, PRIVATE_GRADE} + + +def read_protected(protected, decode): + """Return (iat, grade) from the protected header, refusing anything else.""" + if not isinstance(protected, (bytes, bytearray)) or not protected: + raise ValueError('receipt protected header must be a non-empty bstr') + headers = decode(protected) + if not isinstance(headers, dict): + raise ValueError('receipt protected header must decode to a map') + if headers.get(1) != -8 or headers.get(395) != 1: + raise ValueError('unsupported receipt algorithm or verifiable data structure') + unknown = set(headers) - ALLOWED_PROTECTED + if unknown: + raise ValueError('unreviewed signed receipt headers: ' + ', '.join(map(str, sorted(unknown, key=str)))) + iat = None + if CWT_CLAIMS in headers: + claims = headers[CWT_CLAIMS] + if not isinstance(claims, dict) or set(claims) != {CWT_IAT}: + raise ValueError('CWT claims map must carry iat and nothing else') + iat = claims[CWT_IAT] + if type(iat) is not int or iat <= 0: + raise ValueError('CWT iat must be a positive integer') + grade = None + if PRIVATE_GRADE in headers: + grade = headers[PRIVATE_GRADE] + if not isinstance(grade, str) or not grade: + raise ValueError('private-use grade label must be a non-empty text string') + return iat, grade + def verify(checkpoint, response, *, registry_key, witness_key, expected_log_id): import cbor2 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey @@ -84,8 +127,9 @@ def verify(checkpoint, response, *, registry_key, witness_key, expected_log_id): if envelope is None or len(envelope) != 4: raise ValueError('receipt must be tagged COSE_Sign1') protected, unprotected, payload, signature = envelope - if protected != cbor2.dumps({1: -8, 395: 1}) or payload is not None: + if payload is not None: raise ValueError('unsupported receipt protected headers or attached payload') + signed_iat, signed_grade = read_protected(protected, cbor2.loads) if set(unprotected) != {396} or set(unprotected[396]) != {-1} or len(unprotected[396][-1]) != 1: raise ValueError('expected exactly one inclusion proof') checks['receipt_profile'] = True @@ -101,7 +145,14 @@ def verify(checkpoint, response, *, registry_key, witness_key, expected_log_id): checks['response_coordinates'] = True result.update(verified=True, checkpoint_signing_digest=digest.hex(), entry_hash=entry_hash, root=verified.root, leaf_index=verified.leaf_index, tree_size=verified.tree_size, - reported_grade=response.get('grade'), witness_key=witness_key) + reported_grade=response.get('grade'), signed_iat=signed_iat, + signed_grade=signed_grade, witness_key=witness_key) + # A signed iat is a witness clock inside the covered bytes. A signed + # grade counts only when it is the grade the response reports: a + # private-use label agreeing with untrusted metadata is what binds it. + result['limits']['witness_time_established'] = signed_iat is not None + result['limits']['grade_cryptographically_bound'] = ( + signed_grade is not None and signed_grade == response.get('grade')) except Exception as exc: result['error'] = type(exc).__name__ + ': ' + str(exc) return result