Skip to content

Commit 6acc040

Browse files
committed
fix: COSE cross-verifier compatibility and spec-correct assertion labels
- Convert ECDSA signatures between DER and raw R||S per RFC 9053 - Unwrap CBOR tag 18 in COSE_Sign1 parsing per RFC 9052 - Handle detached-payload COSE_Sign1 x5chain extraction - Handle single-cert x5chain as bstr (not array) per RFC 9360 - Use spec-correct c2pa.soft-binding label (was c2pa.soft_binding.v1) - Bump version to 3.1.4
1 parent d49bc7b commit 6acc040

4 files changed

Lines changed: 74 additions & 25 deletions

File tree

encypher/core/signing.py

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from cryptography.hazmat.primitives import hashes, serialization
1919
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa
2020
from cryptography.hazmat.primitives.asymmetric.types import PublicKeyTypes
21+
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature, encode_dss_signature
2122
from cryptography.x509 import Certificate, NameOID
2223

2324
from .logging_config import logger
@@ -30,13 +31,33 @@
3031
ALG_PS256 = -37
3132
X5CHAIN_HEADER = 33
3233

33-
# Map COSE algorithm IDs to EC curve / hash pairs for verification
34-
_EC_ALG_MAP: dict[int, tuple[type[ec.EllipticCurve], type[hashes.HashAlgorithm]]] = {
35-
ALG_ES256: (ec.SECP256R1, hashes.SHA256),
36-
ALG_ES384: (ec.SECP384R1, hashes.SHA384),
37-
ALG_ES512: (ec.SECP521R1, hashes.SHA512),
34+
# Map COSE algorithm IDs to (EC curve, hash, R||S component size in bytes)
35+
_EC_ALG_MAP: dict[int, tuple[type[ec.EllipticCurve], type[hashes.HashAlgorithm], int]] = {
36+
ALG_ES256: (ec.SECP256R1, hashes.SHA256, 32),
37+
ALG_ES384: (ec.SECP384R1, hashes.SHA384, 48),
38+
ALG_ES512: (ec.SECP521R1, hashes.SHA512, 66),
3839
}
3940

41+
# Reverse map: EC curve name to R||S component size
42+
_EC_COMPONENT_SIZE: dict[str, int] = {
43+
"secp256r1": 32,
44+
"secp384r1": 48,
45+
"secp521r1": 66,
46+
}
47+
48+
49+
def _raw_to_der_ecdsa(raw_sig: bytes, component_size: int) -> bytes:
50+
"""Convert a COSE raw R||S ECDSA signature to DER format."""
51+
r = int.from_bytes(raw_sig[:component_size], "big")
52+
s = int.from_bytes(raw_sig[component_size:], "big")
53+
return encode_dss_signature(r, s)
54+
55+
56+
def _der_to_raw_ecdsa(der_sig: bytes, component_size: int) -> bytes:
57+
"""Convert a DER-encoded ECDSA signature to COSE raw R||S format."""
58+
r, s = decode_dss_signature(der_sig)
59+
return r.to_bytes(component_size, "big") + s.to_bytes(component_size, "big")
60+
4061

4162
class Signer(Protocol):
4263
"""Protocol for abstract signing implementations (e.g., AWS KMS, Azure Key Vault)."""
@@ -94,6 +115,9 @@ def _sign_with_key(key: SigningKey, data: bytes) -> bytes:
94115
def _verify_with_public_key(pub: PublicKeyTypes, signature: bytes, data: bytes, alg: int) -> None:
95116
"""Verify a signature using the correct algorithm for the key/alg pair.
96117
118+
Handles both COSE raw R||S format (RFC 9053) and DER-encoded ECDSA
119+
signatures for backwards compatibility.
120+
97121
Raises InvalidSignature on failure.
98122
"""
99123
if isinstance(pub, ed25519.Ed25519PublicKey):
@@ -103,8 +127,15 @@ def _verify_with_public_key(pub: PublicKeyTypes, signature: bytes, data: bytes,
103127
ec_entry = _EC_ALG_MAP.get(alg)
104128
if ec_entry is None:
105129
raise ValueError(f"COSE alg {alg} is not an EC algorithm")
106-
_, hash_cls = ec_entry
107-
pub.verify(signature, data, ec.ECDSA(hash_cls()))
130+
_, hash_cls, component_size = ec_entry
131+
# COSE uses raw R||S (RFC 9053). Convert to DER for cryptography lib.
132+
expected_raw_len = component_size * 2
133+
if len(signature) == expected_raw_len:
134+
der_sig = _raw_to_der_ecdsa(signature, component_size)
135+
else:
136+
# Already DER-encoded (legacy Encypher-signed content)
137+
der_sig = signature
138+
pub.verify(der_sig, data, ec.ECDSA(hash_cls()))
108139
return
109140
if isinstance(pub, rsa.RSAPublicKey):
110141
pub.verify(
@@ -131,6 +162,9 @@ def _build_sign1(protected_bstr: bytes, unprotected: dict, payload: bytes, signa
131162

132163
def _parse_sign1(cose_bytes: bytes) -> tuple[bytes, dict, Optional[bytes], bytes]:
133164
arr = cbor2.loads(cose_bytes)
165+
# COSE_Sign1 may be wrapped in CBOR tag 18 per RFC 9052 §4.1.
166+
if isinstance(arr, cbor2.CBORTag) and arr.tag == 18:
167+
arr = arr.value
134168
if not isinstance(arr, list) or len(arr) != 4:
135169
raise ValueError("Invalid COSE_Sign1 structure")
136170
protected_bstr, unprotected, payload, signature = arr
@@ -268,9 +302,16 @@ def sign_c2pa_cose(
268302
protected_bstr = _encode_protected(protected_header)
269303
to_sign = _sig_structure(protected_bstr, payload_bytes)
270304

271-
signature = _sign_with_key(private_key, to_sign)
305+
signature = cast(bytes, _sign_with_key(private_key, to_sign))
306+
307+
# COSE requires raw R||S format for ECDSA (RFC 9053 Section 2.1).
308+
# Python's cryptography library returns DER; convert if EC key.
309+
if isinstance(private_key, ec.EllipticCurvePrivateKey):
310+
component_size = _EC_COMPONENT_SIZE.get(private_key.curve.name)
311+
if component_size:
312+
signature = _der_to_raw_ecdsa(signature, component_size)
272313

273-
encoded_cose = _build_sign1(protected_bstr, unprotected_header, payload_bytes, cast(bytes, signature))
314+
encoded_cose = _build_sign1(protected_bstr, unprotected_header, payload_bytes, signature)
274315

275316
# If timestamp authority URL is provided, request a timestamp
276317
if timestamp_authority_url:
@@ -482,7 +523,10 @@ def verify_c2pa_cose(
482523
x5chain = unprotected.get(X5CHAIN_HEADER)
483524
if not x5chain:
484525
raise ValueError("No public key provided and no x5chain in COSE message")
485-
cert = x509.load_der_x509_certificate(x5chain[0])
526+
# Per RFC 9360, x5chain is a single CBOR bstr for one certificate
527+
# or a CBOR array of bstr for a chain. Extract the leaf (first) cert.
528+
leaf_der = x5chain if isinstance(x5chain, bytes) else x5chain[0]
529+
cert = x509.load_der_x509_certificate(leaf_der)
486530
verify_key = cert.public_key()
487531

488532
try:
@@ -670,17 +714,22 @@ def extract_certificates_from_cose(cose_bytes: bytes) -> list[Certificate]:
670714
ValueError: If the message is not a valid COSE_Sign1 structure or contains no certificates.
671715
"""
672716
protected_bstr, unprotected, payload, _signature = _parse_sign1(cose_bytes)
673-
if payload is None:
674-
raise ValueError("Message is not a COSE_Sign1 structure.")
675717

676-
# Extract certificates from the unprotected header (x5chain)
718+
# Extract certificates from the unprotected header (x5chain).
719+
# The payload may be None for detached-payload COSE_Sign1 structures
720+
# (required by C2PA spec); certificates live in the unprotected header
721+
# regardless of whether the payload is inline or detached.
677722
certificates = []
678723
x5chain = unprotected.get(X5CHAIN_HEADER)
679724

680725
if not x5chain:
681726
raise ValueError("No X.509 certificates found in COSE message.")
682727

683-
# Parse each certificate in the chain
728+
# Per RFC 9360, x5chain is a single CBOR bstr for one certificate
729+
# or a CBOR array of bstr for a chain. Normalise to a list.
730+
if isinstance(x5chain, bytes):
731+
x5chain = [x5chain]
732+
684733
for cert_bytes in x5chain:
685734
try:
686735
cert = x509.load_der_x509_certificate(cert_bytes)

encypher/core/unicode_metadata.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -806,17 +806,17 @@ def _build_jumbf_manifest_store(
806806
# Recompute soft binding from non-soft-binding assertions
807807
soft_binding_input = b""
808808
for _lbl, _data in assertion_tuples:
809-
if _lbl != "c2pa.soft_binding.v1":
809+
if _lbl != "c2pa.soft-binding":
810810
soft_binding_input += cbor2.dumps(_data)
811811
sb_hash = hashlib.sha256(soft_binding_input).digest()
812812

813813
# Replace or append soft binding tuple
814814
sb_idx = next(
815-
(i for i, (lbl, _) in enumerate(assertion_tuples) if lbl == "c2pa.soft_binding.v1"),
815+
(i for i, (lbl, _) in enumerate(assertion_tuples) if lbl == "c2pa.soft-binding"),
816816
None,
817817
)
818818
sb_tuple = (
819-
"c2pa.soft_binding.v1",
819+
"c2pa.soft-binding",
820820
{"alg": "encypher.unicode_variation_selector.v1", "hash": sb_hash},
821821
)
822822
if sb_idx is not None:
@@ -1430,7 +1430,7 @@ def _bytes_to_hex(obj: Any) -> Any:
14301430
assertions = c2pa_manifest.get("assertions", [])
14311431
assertion_labels = {a.get("label") for a in assertions if isinstance(a, dict)}
14321432
required_actions = {"c2pa.actions.v1", "c2pa.actions.v2"}
1433-
required_assertions = {"c2pa.soft_binding.v1"}
1433+
required_assertions = {"c2pa.soft-binding"}
14341434
if require_hard_binding:
14351435
# Accept both v1 label and new label
14361436
required_assertions.add("c2pa.hash.data.v1" if not is_jumbf_format else "c2pa.hash.data")
@@ -1442,7 +1442,7 @@ def _bytes_to_hex(obj: Any) -> Any:
14421442
return False, signer_id, c2pa_manifest
14431443

14441444
# --- 3. Soft Binding Verification (Deterministic Hashing) ---
1445-
soft_binding_assertion = next((a for a in assertions if isinstance(a, dict) and a.get("label") == "c2pa.soft_binding.v1"), None)
1445+
soft_binding_assertion = next((a for a in assertions if isinstance(a, dict) and a.get("label") == "c2pa.soft-binding"), None)
14461446
if soft_binding_assertion is None:
14471447
logger.warning("C2PA verification: Soft binding assertion not found.")
14481448
return False, signer_id if signer_id is not None else None, c2pa_manifest
@@ -1460,7 +1460,7 @@ def _bytes_to_hex(obj: Any) -> Any:
14601460
raw_assertions = outer_payload.get("_jumbf_assertions", [])
14611461
soft_binding_input = b""
14621462
for a in raw_assertions:
1463-
if not isinstance(a, dict) or a.get("label") == "c2pa.soft_binding.v1":
1463+
if not isinstance(a, dict) or a.get("label") == "c2pa.soft-binding":
14641464
continue
14651465
soft_binding_input += cbor2.dumps(a.get("data", {}))
14661466
actual_soft_hash = hashlib.sha256(soft_binding_input).hexdigest()
@@ -1470,7 +1470,7 @@ def _bytes_to_hex(obj: Any) -> Any:
14701470

14711471
# c) Find the soft binding assertion in the copy and replace its hash with a placeholder.
14721472
assertion_to_modify = next(
1473-
(a for a in manifest_for_hashing.get("assertions", []) if isinstance(a, dict) and a.get("label") == "c2pa.soft_binding.v1"), None
1473+
(a for a in manifest_for_hashing.get("assertions", []) if isinstance(a, dict) and a.get("label") == "c2pa.soft-binding"), None
14741474
)
14751475
if assertion_to_modify:
14761476
assertion_to_modify["data"]["hash"] = ""

encypher/interop/c2pa_core.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,13 +223,13 @@ def encypher_manifest_to_c2pa_like_dict(
223223
hash_assertion = {"label": "c2pa.hash.data.v1", "data": {"hash": content_hash, "alg": "sha256", "exclusions": []}}
224224
c2pa_assertions.append(hash_assertion)
225225

226-
# 3. Add c2pa.soft_binding.v1 assertion (soft binding)
226+
# 3. Add c2pa.soft-binding assertion (soft binding)
227227
soft_binding_assertion_id = None
228228
if embedded_data is not None:
229229
# Generate deterministic hash of the embedded data
230230
embedded_data_hash = hashlib.sha256(embedded_data.encode("utf-8")).hexdigest()
231231
soft_binding_assertion = {
232-
"label": "c2pa.soft_binding.v1",
232+
"label": "c2pa.soft-binding",
233233
"data": {"alg": "encypher.unicode_variation_selector.v1", "hash": embedded_data_hash},
234234
}
235235
c2pa_assertions.append(soft_binding_assertion)
@@ -434,7 +434,7 @@ def get_c2pa_manifest_schema() -> dict[str, Any]:
434434
"label": {
435435
"type": "string",
436436
"description": "Type of assertion",
437-
"examples": ["c2pa.created", "c2pa.actions.v1", "c2pa.hash.data.v1", "c2pa.soft_binding.v1", "ai.model.info"],
437+
"examples": ["c2pa.created", "c2pa.actions.v1", "c2pa.hash.data.v1", "c2pa.soft-binding", "ai.model.info"],
438438
},
439439
"data": {
440440
"type": "object",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "encypher-ai"
7-
version = "3.1.3"
7+
version = "3.1.4"
88
description = "Embed invisible metadata in AI-generated text using zero-width characters."
99
readme = "README.md"
1010
authors = [{name = "Encypher Team"}]

0 commit comments

Comments
 (0)