|
24 | 24 | import unittest |
25 | 25 | import ctypes |
26 | 26 | import warnings |
| 27 | +import weakref |
| 28 | +from cryptography import x509 |
27 | 29 | from cryptography.hazmat.primitives import hashes, serialization |
28 | 30 | from cryptography.hazmat.primitives.asymmetric import ec |
29 | 31 | from cryptography.hazmat.backends import default_backend |
@@ -6102,6 +6104,207 @@ def test_instance_id_as_ingredient_identifier_in_catalog(self): |
6102 | 6104 | archive.close() |
6103 | 6105 |
|
6104 | 6106 |
|
| 6107 | +class TestDirectCoseSigner(unittest.TestCase): |
| 6108 | + """Direct COSE callback contract and native signing integration tests.""" |
| 6109 | + |
| 6110 | + @staticmethod |
| 6111 | + def _invoke_callback(signer, claim=b"claim bytes", capacity=4): |
| 6112 | + data = (ctypes.c_ubyte * len(claim)).from_buffer_copy(claim) |
| 6113 | + # A sentinel after the advertised capacity detects out-of-bounds writes. |
| 6114 | + output = (ctypes.c_ubyte * (capacity + 1))(*([0xaa] * (capacity + 1))) |
| 6115 | + written = signer._callback_cb(None, data, len(claim), output, capacity) |
| 6116 | + return written, bytes(output) |
| 6117 | + |
| 6118 | + @staticmethod |
| 6119 | + def _cbor_bytes(value): |
| 6120 | + """Encode a CBOR byte string for the test's detached COSE object.""" |
| 6121 | + length = len(value) |
| 6122 | + if length < 24: |
| 6123 | + header = bytes([0x40 + length]) |
| 6124 | + elif length <= 0xff: |
| 6125 | + header = b"\x58" + bytes([length]) |
| 6126 | + elif length <= 0xffff: |
| 6127 | + header = b"\x59" + length.to_bytes(2, "big") |
| 6128 | + else: |
| 6129 | + header = b"\x5a" + length.to_bytes(4, "big") |
| 6130 | + return header + value |
| 6131 | + |
| 6132 | + @classmethod |
| 6133 | + def _make_cose_callback(cls, reserve_size, claims): |
| 6134 | + """Use fixture credentials to simulate a service constructing COSE.""" |
| 6135 | + key = serialization.load_pem_private_key( |
| 6136 | + (Path(FIXTURES_DIR) / "ed25519.pem").read_bytes(), password=None) |
| 6137 | + pem_certs = re.findall( |
| 6138 | + rb"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", |
| 6139 | + (Path(FIXTURES_DIR) / "ed25519.pub").read_bytes(), re.DOTALL) |
| 6140 | + certs = [x509.load_pem_x509_certificate(cert).public_bytes( |
| 6141 | + serialization.Encoding.DER) for cert in pem_certs] |
| 6142 | + assert 0 < len(certs) < 24 |
| 6143 | + chain = (cls._cbor_bytes(certs[0]) if len(certs) == 1 else |
| 6144 | + bytes([0x80 + len(certs)]) + b"".join(map(cls._cbor_bytes, certs))) |
| 6145 | + # Protected map: algorithm = EdDSA (-8), x5chain = fixture certificates. |
| 6146 | + protected = b"\xa2\x01\x27\x18\x21" + chain |
| 6147 | + |
| 6148 | + def callback(claim): |
| 6149 | + claims.append(claim) |
| 6150 | + # Sign the detached claim as the COSE Sig_structure payload. |
| 6151 | + to_sign = (b"\x84\x6aSignature1" + cls._cbor_bytes(protected) |
| 6152 | + + b"\x40" + cls._cbor_bytes(claim)) |
| 6153 | + signature = key.sign(to_sign) |
| 6154 | + prefix = b"\xd2\x84" + cls._cbor_bytes(protected) + b"\xa1\x63pad" |
| 6155 | + suffix = b"\xf6" + cls._cbor_bytes(signature) |
| 6156 | + # This reservation puts pad's length in the CBOR uint16 range. |
| 6157 | + padding = reserve_size - len(prefix) - len(suffix) - 3 |
| 6158 | + assert 0xff < padding <= 0xffff |
| 6159 | + cose = prefix + cls._cbor_bytes(bytes(padding)) + suffix |
| 6160 | + assert len(cose) == reserve_size |
| 6161 | + return cose |
| 6162 | + |
| 6163 | + return callback |
| 6164 | + |
| 6165 | + def test_creation_and_reserve_size(self): |
| 6166 | + with Signer.from_cose_callback(lambda claim: b"1234", 4) as signer: |
| 6167 | + self.assertEqual(signer.reserve_size(), 4) |
| 6168 | + |
| 6169 | + def test_invalid_reserve_size_rejected_before_native_call(self): |
| 6170 | + with patch.object(c2pa_module._lib, "c2pa_signer_create_direct_cose") as create: |
| 6171 | + for size in (True, False, 1.5, "4", None): |
| 6172 | + with self.subTest(size=size), self.assertRaises(TypeError): |
| 6173 | + Signer.from_cose_callback(lambda claim: b"1234", size) |
| 6174 | + for size in (0, -1, sys.maxsize + 1): |
| 6175 | + with self.subTest(size=size), self.assertRaises(ValueError): |
| 6176 | + Signer.from_cose_callback(lambda claim: b"1234", size) |
| 6177 | + create.assert_not_called() |
| 6178 | + |
| 6179 | + def test_callback_must_be_callable(self): |
| 6180 | + with self.assertRaises(TypeError): |
| 6181 | + Signer.from_cose_callback(None, 4) |
| 6182 | + |
| 6183 | + def test_claim_and_cose_bytes_are_preserved(self): |
| 6184 | + claims = [] |
| 6185 | + |
| 6186 | + def callback(claim): |
| 6187 | + claims.append(claim) |
| 6188 | + return b"\xd2\x84\x00\x00" |
| 6189 | + |
| 6190 | + with Signer.from_cose_callback(callback, 4) as signer: |
| 6191 | + written, output = self._invoke_callback(signer) |
| 6192 | + self.assertEqual(claims, [b"claim bytes"]) |
| 6193 | + self.assertEqual(written, 4) |
| 6194 | + self.assertEqual(output, b"\xd2\x84\x00\x00\xaa") |
| 6195 | + |
| 6196 | + def test_invalid_results_do_not_write_or_truncate(self): |
| 6197 | + for result in (b"", b"123", b"12345", None, "1234", |
| 6198 | + bytearray(b"1234"), memoryview(b"1234")): |
| 6199 | + with self.subTest(result=result): |
| 6200 | + with Signer.from_cose_callback(lambda claim: result, 4) as signer: |
| 6201 | + written, output = self._invoke_callback(signer) |
| 6202 | + self.assertEqual(written, -1) |
| 6203 | + self.assertEqual(output, b"\xaa" * 5) |
| 6204 | + |
| 6205 | + def test_native_capacity_must_match_reservation(self): |
| 6206 | + with Signer.from_cose_callback(lambda claim: b"1234", 4) as signer: |
| 6207 | + for capacity in (3, 5): |
| 6208 | + written, output = self._invoke_callback(signer, capacity=capacity) |
| 6209 | + self.assertEqual(written, -1) |
| 6210 | + self.assertEqual(output, b"\xaa" * (capacity + 1)) |
| 6211 | + |
| 6212 | + def test_callback_exception_returns_failure(self): |
| 6213 | + def fail(claim): |
| 6214 | + raise RuntimeError("remote signing failed") |
| 6215 | + |
| 6216 | + with Signer.from_cose_callback(fail, 4) as signer: |
| 6217 | + with self.assertLogs(c2pa_module.logger, level="ERROR"): |
| 6218 | + written, output = self._invoke_callback(signer) |
| 6219 | + self.assertEqual(written, -1) |
| 6220 | + self.assertEqual(output, b"\xaa" * 5) |
| 6221 | + |
| 6222 | + def test_callback_is_retained_until_signer_closes(self): |
| 6223 | + class Callback: |
| 6224 | + def __call__(self, claim): |
| 6225 | + return b"1234" |
| 6226 | + |
| 6227 | + callback = Callback() |
| 6228 | + ref = weakref.ref(callback) |
| 6229 | + with Signer.from_cose_callback(callback, 4) as signer: |
| 6230 | + del callback |
| 6231 | + gc.collect() |
| 6232 | + self.assertIsNotNone(ref()) |
| 6233 | + self.assertEqual(self._invoke_callback(signer)[0], 4) |
| 6234 | + gc.collect() |
| 6235 | + self.assertIsNone(ref()) |
| 6236 | + self.assertIsNone(signer._callback_cb) |
| 6237 | + |
| 6238 | + def test_context_retains_consumed_signer_callback(self): |
| 6239 | + signer = Signer.from_cose_callback(lambda claim: b"1234", 4) |
| 6240 | + callback = signer._callback_cb |
| 6241 | + with Context(signer=signer) as context: |
| 6242 | + self.assertIsNone(signer._callback_cb) |
| 6243 | + self.assertIs(context._signer_callback_cb, callback) |
| 6244 | + del signer |
| 6245 | + gc.collect() |
| 6246 | + self.assertIs(context._signer_callback_cb, callback) |
| 6247 | + self.assertIsNone(context._signer_callback_cb) |
| 6248 | + |
| 6249 | + def test_native_pointer_freed_if_wrapping_fails(self): |
| 6250 | + class BrokenSigner(Signer): |
| 6251 | + def __init__(self, pointer): |
| 6252 | + raise RuntimeError("wrapping failed") |
| 6253 | + |
| 6254 | + with patch.object(ManagedResource, "_free_native_ptr", |
| 6255 | + wraps=ManagedResource._free_native_ptr) as free: |
| 6256 | + with self.assertRaisesRegex(RuntimeError, "wrapping failed"): |
| 6257 | + BrokenSigner.from_cose_callback(lambda claim: b"1234", 4) |
| 6258 | + free.assert_called_once() |
| 6259 | + |
| 6260 | + def test_callback_failure_reaches_native_signing_error(self): |
| 6261 | + claims = [] |
| 6262 | + |
| 6263 | + def callback(claim): |
| 6264 | + claims.append(claim) |
| 6265 | + return b"too short" |
| 6266 | + |
| 6267 | + with Signer.from_cose_callback(callback, 16384) as signer: |
| 6268 | + with Builder({"title": "Direct COSE failure"}) as builder: |
| 6269 | + with (Path(FIXTURES_DIR) / "C.jpg").open("rb") as source: |
| 6270 | + with self.assertRaisesRegex(Error, "COSE signature invalid"): |
| 6271 | + builder.sign(signer, "image/jpeg", source, io.BytesIO()) |
| 6272 | + self.assertEqual(len(claims), 1) |
| 6273 | + |
| 6274 | + def test_sign_and_read_valid_cose_after_context_transfer(self): |
| 6275 | + claims = [] |
| 6276 | + signer = Signer.from_cose_callback(self._make_cose_callback(16384, claims), 16384) |
| 6277 | + settings_json = json.loads((Path(__file__).parent / |
| 6278 | + "trust_config_test_settings.json").read_text()) |
| 6279 | + manifest = { |
| 6280 | + "title": "Direct COSE integration", |
| 6281 | + "claim_generator": "direct_cose_test/1.0", |
| 6282 | + "assertions": [{"label": "c2pa.actions", "data": {"actions": [{ |
| 6283 | + "action": "c2pa.created", |
| 6284 | + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", |
| 6285 | + }]}}], |
| 6286 | + } |
| 6287 | + with Settings.from_json(json.dumps(settings_json)) as settings: |
| 6288 | + with Context(settings=settings, signer=signer) as context: |
| 6289 | + del signer |
| 6290 | + gc.collect() |
| 6291 | + # Repeated signing also exercises callback lifetime after transfer. |
| 6292 | + for _ in range(2): |
| 6293 | + output = io.BytesIO() |
| 6294 | + with Builder(manifest, context=context) as builder: |
| 6295 | + with (Path(FIXTURES_DIR) / "C.jpg").open("rb") as source: |
| 6296 | + builder.sign("image/jpeg", source, output) |
| 6297 | + output.seek(0) |
| 6298 | + with Reader("image/jpeg", output, context=context) as reader: |
| 6299 | + report = json.loads(reader.json()) |
| 6300 | + active = report["manifests"][report["active_manifest"]] |
| 6301 | + self.assertEqual(active["title"], manifest["title"]) |
| 6302 | + self.assertEqual(active["signature_info"]["alg"], "Ed25519") |
| 6303 | + self.assertIn(report["validation_state"], ("Valid", "Trusted")) |
| 6304 | + self.assertEqual(len(claims), 2) |
| 6305 | + # CBOR maps identify full serialized claims, unlike COSE Sig_structure arrays. |
| 6306 | + self.assertTrue(all(claim and claim[0] >> 5 == 5 for claim in claims)) |
| 6307 | + |
6105 | 6308 | class TestStream(unittest.TestCase): |
6106 | 6309 | def setUp(self): |
6107 | 6310 | self.temp_file = io.BytesIO() |
|
0 commit comments