Skip to content

Commit ca1ea31

Browse files
committed
feat(signer): add direct COSE callback binding
Expose Signer.from_cose_callback through the native direct COSE API.
1 parent 192023c commit ca1ea31

2 files changed

Lines changed: 284 additions & 2 deletions

File tree

‎src/c2pa/c2pa.py‎

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
'c2pa_format_embeddable',
8383
# Signer bindings
8484
'c2pa_signer_create',
85+
'c2pa_signer_create_direct_cose',
8586
'c2pa_signer_from_info',
8687
'c2pa_signer_reserve_size',
8788
'c2pa_ed25519_sign',
@@ -1424,6 +1425,9 @@ def _setup_function(func, argtypes, restype=None):
14241425
ctypes.c_char_p,
14251426
ctypes.c_char_p],
14261427
ctypes.POINTER(C2paSigner))
1428+
_setup_function(_lib.c2pa_signer_create_direct_cose,
1429+
[ctypes.c_void_p, SignerCallback, ctypes.c_size_t],
1430+
ctypes.POINTER(C2paSigner))
14271431
_setup_function(_lib.c2pa_signer_from_info,
14281432
[ctypes.POINTER(C2paSignerInfo)],
14291433
ctypes.POINTER(C2paSigner))
@@ -3885,9 +3889,84 @@ def wrapped_callback(
38853889

38863890
return signer_instance
38873891

3892+
@classmethod
3893+
def from_cose_callback(
3894+
cls,
3895+
callback: Callable[[bytes], bytes],
3896+
reserve_size: int,
3897+
) -> 'Signer':
3898+
"""Create a signer whose callback returns a complete tagged COSE_Sign1.
3899+
3900+
The callback receives the serialized C2PA claim bytes and must return
3901+
exactly reserve_size bytes, including any required COSE padding. It
3902+
supplies the signing algorithm, certificate chain, and any timestamps.
3903+
The SDK bypasses normal COSE construction and post-signing verification
3904+
for this signer. Callback failures are reported by the signing operation
3905+
as a native C2PA error.
3906+
3907+
Callbacks must be safe for concurrent calls and remote callbacks must
3908+
enforce their own timeout. The callback is kept alive for the lifetime
3909+
of the native signer, including when transferred to a Context.
3910+
3911+
Args:
3912+
callback: Function receiving claim bytes and returning COSE bytes.
3913+
reserve_size: Positive size of both the signature box and output
3914+
buffer, representable by the native callback's signed size type.
3915+
3916+
Returns:
3917+
A new Signer instance.
3918+
3919+
Raises:
3920+
TypeError: If callback is not callable or reserve_size is not an int.
3921+
ValueError: If reserve_size is outside the supported range.
3922+
C2paError: If native signer creation fails.
3923+
"""
3924+
if not callable(callback):
3925+
raise TypeError("callback must be callable")
3926+
if isinstance(reserve_size, bool) or not isinstance(reserve_size, int):
3927+
raise TypeError("reserve_size must be an integer")
3928+
if not 0 < reserve_size <= sys.maxsize:
3929+
raise ValueError(
3930+
"reserve_size must be positive and fit in a native signed size")
3931+
3932+
def wrapped_callback(context, data_ptr, data_len, output_ptr, capacity):
3933+
try:
3934+
if (
3935+
not data_ptr
3936+
or data_len <= 0
3937+
or not output_ptr
3938+
or capacity != reserve_size
3939+
):
3940+
return -1
3941+
claim = ctypes.string_at(data_ptr, data_len)
3942+
cose = callback(claim)
3943+
if not isinstance(cose, bytes) or len(cose) != capacity:
3944+
return -1
3945+
ctypes.memmove(output_ptr, cose, capacity)
3946+
return len(cose)
3947+
except Exception as e:
3948+
logger.error(cls._ERROR_MESSAGES['callback_error'].format(str(e)))
3949+
return -1
3950+
3951+
callback_cb = SignerCallback(wrapped_callback)
3952+
with _native_section():
3953+
signer_ptr = _lib.c2pa_signer_create_direct_cose(
3954+
None, callback_cb, reserve_size)
3955+
_check_ffi_operation_result(
3956+
signer_ptr, "Failed to create direct COSE signer")
3957+
3958+
try:
3959+
signer_instance = cls(signer_ptr)
3960+
signer_instance._callback_cb = callback_cb
3961+
except Exception:
3962+
ManagedResource._free_native_ptr(signer_ptr)
3963+
raise
3964+
return signer_instance
3965+
38883966
def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)):
38893967
"""Initialize a new Signer instance. This constructor is not meant
3890-
to be called directly. Use from_info() or from_callback() instead.
3968+
to be called directly. Use from_info(), from_callback(), or
3969+
from_cose_callback() instead.
38913970
38923971
Args:
38933972
signer_ptr: Pointer to the native C2PA signer
@@ -3905,7 +3984,7 @@ def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)):
39053984

39063985
def _init_attrs(self):
39073986
super()._init_attrs()
3908-
# from_callback() replaces this with the real callback, which has to
3987+
# Callback constructors replace this with the real callback, which has to
39093988
# outlive the signer that calls it.
39103989
self._callback_cb = None
39113990

‎tests/test_unit_tests.py‎

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
import unittest
2525
import ctypes
2626
import warnings
27+
import weakref
28+
from cryptography import x509
2729
from cryptography.hazmat.primitives import hashes, serialization
2830
from cryptography.hazmat.primitives.asymmetric import ec
2931
from cryptography.hazmat.backends import default_backend
@@ -6102,6 +6104,207 @@ def test_instance_id_as_ingredient_identifier_in_catalog(self):
61026104
archive.close()
61036105

61046106

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+
61056308
class TestStream(unittest.TestCase):
61066309
def setUp(self):
61076310
self.temp_file = io.BytesIO()

0 commit comments

Comments
 (0)