From 79ffcdbc3648ccad76ab6673edbde31c096f303e Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Thu, 31 Oct 2024 10:22:07 +0000 Subject: [PATCH] Cose receipts verify (#6603) --- CHANGELOG.md | 8 +++++++ python/pyproject.toml | 2 +- python/src/ccf/cose.py | 54 ++++++++++++++++++++++++++++++++++++++++++ tests/e2e_logging.py | 7 ++++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df874e469ba2..de56f581d9e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [6.0.0-dev4] + +[6.0.0-dev4]: https://github.com/microsoft/CCF/releases/tag/6.0.0-dev4 + +### Added + +- `ccf.cose.verify_receipt()` to support verifiying [draft COSE receipts](https://datatracker.ietf.org/doc/draft-ietf-cose-merkle-tree-proofs/) (#6603). + ### Removed - Remove SECP256K1 support as a part of the migration to Azure Linux (#6592). diff --git a/python/pyproject.toml b/python/pyproject.toml index b8fadadf3d89..ffeba41bd035 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ccf" -version = "6.0.0-dev3" +version = "6.0.0-dev4" authors = [ { name="CCF Team", email="CCF-Sec@microsoft.com" }, ] diff --git a/python/src/ccf/cose.py b/python/src/ccf/cose.py index 48b3898140a9..4c9d3821153c 100644 --- a/python/src/ccf/cose.py +++ b/python/src/ccf/cose.py @@ -9,6 +9,7 @@ import base64 import cbor2 import json +from hashlib import sha256 from datetime import datetime import pycose.headers # type: ignore from pycose.keys.ec2 import EC2Key # type: ignore @@ -24,6 +25,8 @@ from cryptography.x509 import load_pem_x509_certificate from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.x509.base import CertificatePublicKeyTypes +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat Pem = str @@ -37,6 +40,17 @@ "encrypted_recovery_share", ] + GOV_MSG_TYPES_WITH_PROPOSAL_ID +# See https://datatracker.ietf.org/doc/draft-ietf-cose-merkle-tree-proofs/ +# should move to a pycose.header value after RFC publication + +COSE_PHDR_VDP_LABEL = 396 +COSE_RECEIPT_INCLUSION_PROOF_LABEL = -1 + +# See https://datatracker.ietf.org/doc/draft-birkholz-cose-receipts-ccf-profile/ + +CCF_PROOF_LEAF_LABEL = 1 +CCF_PROOF_PATH_LABEL = 2 + def from_cryptography_eckey_obj(ext_key) -> EC2Key: """ @@ -187,6 +201,46 @@ def validate_cose_sign1(pubkey, cose_sign1, payload=None): raise ValueError("signature is invalid") +def verify_receipt(receipt_bytes: bytes, key: CertificatePublicKeyTypes): + """ + Verify a COSE Sign1 receipt as defined in https://datatracker.ietf.org/doc/draft-ietf-cose-merkle-tree-proofs/, + using the CCF tree algorithm defined in https://datatracker.ietf.org/doc/draft-birkholz-cose-receipts-ccf-profile/ + """ + # Extract the expected KID from the public key used for verification, + # and check it against the value set in the COSE header before using + # it to verify the proofs. + expected_kid = sha256( + key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + ).digest() + receipt = Sign1Message.decode(receipt_bytes) + cose_key = from_cryptography_eckey_obj(key) + assert receipt.phdr[pycose.headers.KID] == expected_kid + receipt.key = cose_key + + assert COSE_PHDR_VDP_LABEL in receipt.uhdr, "Verifiable data proof is required" + proof = receipt.uhdr[COSE_PHDR_VDP_LABEL] + assert COSE_RECEIPT_INCLUSION_PROOF_LABEL in proof, "Inclusion proof is required" + inclusion_proofs = proof[COSE_RECEIPT_INCLUSION_PROOF_LABEL] + assert inclusion_proofs, "At least one inclusion proof is required" + for inclusion_proof in inclusion_proofs: + assert isinstance(inclusion_proof, bytes), "Inclusion proof must be bstr" + proof = cbor2.loads(inclusion_proof) + assert CCF_PROOF_LEAF_LABEL in proof, "Leaf must be present" + leaf = proof[CCF_PROOF_LEAF_LABEL] + accumulator = sha256( + leaf[0] + sha256(leaf[1].encode()).digest() + leaf[2] + ).digest() + assert CCF_PROOF_PATH_LABEL in proof, "Path must be present" + path = proof[CCF_PROOF_PATH_LABEL] + for left, digest in path: + if left: + accumulator = sha256(digest + accumulator).digest() + else: + accumulator = sha256(accumulator + digest).digest() + if not receipt.verify_signature(accumulator): + raise ValueError("Signature verification failed") + + _SIGN_DESCRIPTION = """Create and sign a COSE Sign1 message for CCF governance Note that this tool writes binary COSE Sign1 to standard output. diff --git a/tests/e2e_logging.py b/tests/e2e_logging.py index ce9918e38ad6..5b3b3f834d74 100644 --- a/tests/e2e_logging.py +++ b/tests/e2e_logging.py @@ -1015,6 +1015,12 @@ def test_cose_signature_schema(network, args): def test_cose_receipt_schema(network, args): primary, _ = network.find_nodes() + service_cert_path = os.path.join(network.common_dir, "service_cert.pem") + service_cert = load_pem_x509_certificate( + open(service_cert_path, "rb").read(), default_backend() + ) + service_key = service_cert.public_key() + with primary.client("user0") as client: r = client.get("/commit") assert r.status_code == http.HTTPStatus.OK @@ -1033,6 +1039,7 @@ def test_cose_receipt_schema(network, args): ) if r.status_code == http.HTTPStatus.OK: cbor_proof = r.body.data() + ccf.cose.verify_receipt(cbor_proof, service_key) cbor_proof_filename = os.path.join( network.common_dir, f"receipt_{txid}.cose" )