c2pie 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
c2pie/__init__.py ADDED
File without changes
c2pie/c2pa/__init__.py ADDED
File without changes
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from c2pie.jumbf_boxes.content_box import ContentBox
6
+ from c2pie.jumbf_boxes.super_box import SuperBox
7
+ from c2pie.utils.assertion_schemas import (
8
+ C2PA_AssertionTypes,
9
+ cbor_to_bytes,
10
+ get_assertion_content_box_type,
11
+ get_assertion_content_type,
12
+ get_assertion_label,
13
+ json_to_bytes,
14
+ )
15
+ from c2pie.utils.content_types import jumbf_content_types
16
+
17
+
18
+ class Assertion(SuperBox):
19
+ """Universal assertion superbox (one content box)."""
20
+
21
+ def __init__(
22
+ self,
23
+ assertion_type: C2PA_AssertionTypes,
24
+ schema: dict[str, Any],
25
+ ):
26
+ self.type = assertion_type
27
+ self.schema = schema
28
+
29
+ payload = self.get_payload_from_schema()
30
+ box_type_hex = get_assertion_content_box_type(self.type)
31
+ content_box = ContentBox(box_type=box_type_hex, payload=payload)
32
+
33
+ super().__init__(
34
+ content_type=get_assertion_content_type(self.type),
35
+ label=get_assertion_label(self.type),
36
+ content_boxes=[content_box],
37
+ )
38
+
39
+ def get_payload_from_schema(self) -> bytes:
40
+ ctype = get_assertion_content_type(self.type)
41
+ if ctype == jumbf_content_types["json"]:
42
+ return json_to_bytes(self.schema)
43
+ if ctype == jumbf_content_types["cbor"]:
44
+ return cbor_to_bytes(self.schema)
45
+ if ctype == jumbf_content_types["codestream"]:
46
+ return self.schema.get("payload", b"")
47
+ return b""
48
+
49
+ def get_data_for_signing(self) -> bytes:
50
+ return self.description_box.serialize() + self.serialize_content_boxes()
51
+
52
+
53
+ class HashDataAssertion(Assertion):
54
+ """c2pa.hash.data hard binding assertion."""
55
+
56
+ def __init__(
57
+ self,
58
+ cai_offset: int,
59
+ hashed_data: bytes,
60
+ additional_exclusions: list[dict[str, int]] | None = None,
61
+ ):
62
+ exclusions: list[dict[str, int]] = [{"start": cai_offset, "length": 65535}]
63
+ if additional_exclusions:
64
+ exclusions.extend(additional_exclusions)
65
+
66
+ schema: dict[str, Any] = {
67
+ "name": "jumbf manifest",
68
+ "exclusions": exclusions,
69
+ "alg": "sha256",
70
+ "hash": hashed_data,
71
+ "pad": [],
72
+ }
73
+ super().__init__(C2PA_AssertionTypes.data_hash, schema)
74
+
75
+ def set_hash_data_length(self, length: int) -> None:
76
+ if self.schema.get("name") != "jumbf manifest":
77
+ raise ValueError("c2pa.hash.data: jumbf manifest is missing")
78
+ exclusions = self.schema.get("exclusions", [])
79
+ if not exclusions:
80
+ raise ValueError("c2pa.hash.data: exclusions are missing")
81
+ exclusions[0]["length"] = int(length)
82
+
83
+ payload = self.get_payload_from_schema()
84
+ if self.content_boxes:
85
+ self.content_boxes[0] = ContentBox(
86
+ box_type=get_assertion_content_box_type(self.type),
87
+ payload=payload,
88
+ )
89
+ else:
90
+ self.content_boxes = [
91
+ ContentBox(
92
+ box_type=get_assertion_content_box_type(self.type),
93
+ payload=payload,
94
+ )
95
+ ]
96
+ self.sync_payload()
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from c2pie.jumbf_boxes.super_box import SuperBox
4
+ from c2pie.utils.assertion_schemas import C2PA_AssertionTypes
5
+ from c2pie.utils.content_types import c2pa_content_types
6
+
7
+
8
+ class AssertionStore(SuperBox):
9
+ def __init__(
10
+ self,
11
+ assertions: list,
12
+ ):
13
+ self.assertions = assertions
14
+ super().__init__(
15
+ content_type=c2pa_content_types["assertions"],
16
+ label="c2pa.assertions",
17
+ content_boxes=self.assertions,
18
+ )
19
+
20
+ def get_assertions(self) -> list:
21
+ return self.assertions
22
+
23
+ def set_hash_data_length(
24
+ self,
25
+ length: int,
26
+ ) -> None:
27
+ for assertion in self.assertions:
28
+ if assertion.type == C2PA_AssertionTypes.data_hash:
29
+ assertion.set_hash_data_length(length)
30
+ self.sync_payload()
c2pie/c2pa/claim.py ADDED
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import uuid
5
+ from typing import Any
6
+
7
+ import cbor2
8
+
9
+ from c2pie.c2pa.assertion_store import AssertionStore
10
+ from c2pie.jumbf_boxes.content_box import ContentBox
11
+ from c2pie.jumbf_boxes.super_box import SuperBox
12
+ from c2pie.utils.content_types import c2pa_content_types
13
+
14
+
15
+ def _sha256(b: bytes) -> bytes:
16
+ return hashlib.sha256(b).digest()
17
+
18
+
19
+ class Claim(SuperBox):
20
+ """Claim (c2pa.claim) as a JUMBF superbox with one CBOR content box."""
21
+
22
+ def __init__(
23
+ self,
24
+ assertion_store: AssertionStore,
25
+ claim_generator: str = "c2pie",
26
+ manifest_label: str = f"urn:uuid:{uuid.uuid4().hex}",
27
+ dc_format: str = None,
28
+ ):
29
+ self.claim_generator = claim_generator
30
+ self.manifest_label = manifest_label
31
+ self.assertion_store = assertion_store
32
+ self.dc_format = dc_format
33
+
34
+ self.claim_signature_label = f"self#jumbf=c2pa/{self.manifest_label}/c2pa.signature"
35
+
36
+ self._instance_id = f"xmp:iid:{uuid.uuid4()}"
37
+
38
+ cbor_payload = self._build_cbor_payload()
39
+
40
+ content_box = ContentBox(
41
+ box_type=b"cbor".hex(),
42
+ payload=cbor_payload,
43
+ )
44
+ super().__init__(
45
+ content_type=c2pa_content_types["claim"],
46
+ label="c2pa.claim",
47
+ content_boxes=[content_box],
48
+ )
49
+
50
+ def get_cbor_payload(self) -> bytes:
51
+ """CBOR bytes, on top of which COSE (detached) is signed."""
52
+ return self.content_boxes[0].get_payload()
53
+
54
+ def get_manifest_label(self) -> str:
55
+ return self.manifest_label
56
+
57
+ def set_assertion_store(self, assertion_store) -> None:
58
+ """
59
+ Called from Manifest when assertions are changed (including when length is updated in c2pa.hash.data).
60
+ Reassemble the CBOR payload of the stamp with the correct hashed URIs.
61
+ """
62
+ self.assertion_store = assertion_store
63
+ self._rebuild_payload()
64
+
65
+ def set_format(self, dc_format: str | None) -> None:
66
+ self.dc_format = dc_format
67
+ self._rebuild_payload()
68
+
69
+ def _build_assertions_array(self) -> list[dict[str, Any]]:
70
+ """
71
+ Build the claim[‘assertions’] array from the current AssertionStore:
72
+ - url: self#jumbf=/c2pa/<manifest_label>/c2pa.assertions/<label>
73
+ - alg: sha256
74
+ - hash: sha256( JUMBF-superbox-content = description + content_boxes ) for this assertion
75
+ """
76
+ out: list[dict[str, Any]] = []
77
+ if not self.assertion_store:
78
+ return out
79
+
80
+ assertions = getattr(self.assertion_store, "assertions", None)
81
+ if assertions is None and hasattr(self.assertion_store, "get_assertions"):
82
+ assertions = self.assertion_store.get_assertions()
83
+ if not assertions:
84
+ return out
85
+
86
+ for assertion in assertions:
87
+ label = getattr(assertion, "label", None)
88
+ if label is None and hasattr(assertion, "get_label"):
89
+ label = assertion.get_label()
90
+
91
+ if hasattr(assertion, "get_data_for_signing"):
92
+ data = assertion.get_data_for_signing()
93
+ else:
94
+ data = assertion.description_box.serialize() + assertion.serialize_content_boxes()
95
+
96
+ out.append(
97
+ {
98
+ "url": f"self#jumbf=/c2pa/{self.manifest_label}/c2pa.assertions/{label}",
99
+ "alg": "sha256",
100
+ "hash": _sha256(data),
101
+ }
102
+ )
103
+ return out
104
+
105
+ def _build_cbor_payload(self) -> bytes:
106
+ """
107
+ Canonical CBOR content claim.
108
+ Include:
109
+ - claim_generator
110
+ - instanceID (stable for the object)
111
+ - signature (reference to c2pa.signature)
112
+ - optional dc:format
113
+ - optional assertions (if there is an assertion_store)
114
+ """
115
+ claim: dict[str, Any] = {
116
+ "claim_generator": self.claim_generator,
117
+ "instanceID": self._instance_id,
118
+ "signature": self.claim_signature_label,
119
+ "alg": "sha256",
120
+ }
121
+ if self.dc_format:
122
+ claim["dc:format"] = self.dc_format
123
+
124
+ assertions_arr = self._build_assertions_array()
125
+ if assertions_arr:
126
+ claim["assertions"] = assertions_arr
127
+
128
+ return cbor2.dumps(claim, canonical=True)
129
+
130
+ def _rebuild_payload(self) -> None:
131
+ new_payload = self._build_cbor_payload()
132
+ self.content_boxes[0] = ContentBox(
133
+ box_type=b"cbor".hex(),
134
+ payload=new_payload,
135
+ )
136
+ self.sync_payload()
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ import cbor2
7
+ from cryptography import x509
8
+ from cryptography.hazmat.primitives import hashes, serialization
9
+ from cryptography.hazmat.primitives.asymmetric import padding
10
+ from cryptography.hazmat.primitives.serialization import Encoding
11
+
12
+ from c2pie.c2pa.claim import Claim
13
+ from c2pie.jumbf_boxes.content_box import ContentBox
14
+ from c2pie.jumbf_boxes.super_box import SuperBox
15
+ from c2pie.utils.content_types import c2pa_content_types
16
+
17
+
18
+ def _split_pem_certs_to_der(pem_bytes: bytes) -> list[bytes]:
19
+ if not pem_bytes:
20
+ return []
21
+ blocks = re.findall(
22
+ b"-----BEGIN CERTIFICATE-----\\s.*?-----END CERTIFICATE-----\\s*",
23
+ pem_bytes,
24
+ flags=re.DOTALL,
25
+ )
26
+ ders: list[bytes] = []
27
+ for blk in blocks:
28
+ cert = x509.load_pem_x509_certificate(blk)
29
+ ders.append(cert.public_bytes(Encoding.DER))
30
+ return ders
31
+
32
+
33
+ class ClaimSignature(SuperBox):
34
+ """
35
+ COSE_Sign1 (PS256), detached:
36
+ - protected: {1:-37, 33:[x5chain DER...]}
37
+ - unprotected: {}
38
+ - COSE payload = nil
39
+ - Sig_structure payload = bstr(Claim CBOR)
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ claim: Claim,
45
+ *,
46
+ private_key: bytes,
47
+ certificate_pem_bundle: bytes = None,
48
+ certificate: bytes = None,
49
+ ):
50
+ if certificate_pem_bundle is None and certificate is not None:
51
+ certificate_pem_bundle = certificate
52
+
53
+ self.claim = claim
54
+ self.private_key = private_key
55
+ self.certificate = certificate_pem_bundle
56
+
57
+ content_boxes = self._generate_payload()
58
+ super().__init__(
59
+ content_type=c2pa_content_types["claim_signature"],
60
+ label="c2pa.signature",
61
+ content_boxes=content_boxes,
62
+ )
63
+
64
+ def _generate_payload(self) -> list[ContentBox]:
65
+ if not (self.claim and self.private_key and self.certificate):
66
+ return []
67
+
68
+ cose_tagged = self._create_cose_sign1()
69
+ return [ContentBox(box_type=b"cbor".hex(), payload=cose_tagged)]
70
+
71
+ def set_claim(self, claim: Claim):
72
+ self.claim = claim
73
+ content_boxes = self._generate_payload()
74
+ super().__init__(
75
+ content_type=c2pa_content_types["claim_signature"],
76
+ label="c2pa.signature",
77
+ content_boxes=content_boxes,
78
+ )
79
+
80
+ def _generate_protected_header(self) -> bytes:
81
+ der_chain = _split_pem_certs_to_der(self.certificate or b"")
82
+ protected: dict[int, Any] = {1: -37}
83
+ if der_chain:
84
+ protected[33] = der_chain
85
+ return cbor2.dumps(protected, canonical=True)
86
+
87
+ def _create_cose_sign1(self) -> bytes:
88
+ phdr_serialized = self._generate_protected_header()
89
+ claim_cbor = self.claim.get_cbor_payload()
90
+ sig_structure = ["Signature1", phdr_serialized, b"", claim_cbor]
91
+ to_sign = cbor2.dumps(sig_structure, canonical=True)
92
+
93
+ key = serialization.load_pem_private_key(self.private_key, password=None)
94
+ signature = key.sign( # type: ignore
95
+ to_sign,
96
+ padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=32), # type: ignore
97
+ hashes.SHA256(), # type: ignore
98
+ )
99
+
100
+ cose_msg = [phdr_serialized, {}, None, signature]
101
+ return cbor2.dumps(cbor2.CBORTag(18, cose_msg), canonical=True)
c2pie/c2pa/config.py ADDED
@@ -0,0 +1 @@
1
+ RETRY_SIGNATURE = 8 # max retries, might be terminated earliers
c2pie/c2pa/manifest.py ADDED
@@ -0,0 +1,56 @@
1
+ import uuid
2
+
3
+ from c2pie.jumbf_boxes.super_box import SuperBox
4
+ from c2pie.utils.content_types import c2pa_content_types
5
+
6
+
7
+ class Manifest(SuperBox):
8
+ """
9
+ C2PA Manifest: Assertion Store + Claim + Claim Signature.
10
+ The manifest label is compatible with c2patool: urn:uuid:<uuid-hex>
11
+ """
12
+
13
+ def __init__(
14
+ self,
15
+ manifest_label: str = f"urn:uuid:{uuid.uuid4().hex}",
16
+ ):
17
+ self.manifest_label = manifest_label
18
+ self.claim = None
19
+ self.claim_signature = None
20
+ self.assertion_store = None
21
+
22
+ super().__init__(content_type=c2pa_content_types["default_manifest"], label=self.manifest_label)
23
+
24
+ def set_claim(self, claim):
25
+ self.claim = claim
26
+ self.add_content_box(self.claim)
27
+
28
+ def set_claim_signature(self, claim_signature):
29
+ self.claim_signature = claim_signature
30
+ self.add_content_box(self.claim_signature)
31
+
32
+ def set_assertion_store(self, assertion_store):
33
+ self.assertion_store = assertion_store
34
+ self.add_content_box(self.assertion_store)
35
+
36
+ def get_manifest_label(self):
37
+ return self.manifest_label
38
+
39
+ def get_assertions(self):
40
+ if self.assertion_store:
41
+ return self.assertion_store.get_assertions()
42
+ return
43
+
44
+ def set_hash_data_length(self, length: int):
45
+ """
46
+ Updates the length of exceptions in HashData, reassembles Claim (assertion hashes)
47
+ and ClaimSignature (COSE Sign1 detached over Claim CBOR).
48
+ """
49
+ if self.assertion_store and self.claim and self.claim_signature:
50
+ self.assertion_store.set_hash_data_length(length)
51
+
52
+ self.claim.set_assertion_store(self.assertion_store)
53
+
54
+ self.claim_signature.set_claim(self.claim)
55
+
56
+ self.sync_payload()
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from c2pie.jumbf_boxes.super_box import SuperBox
4
+ from c2pie.utils.content_types import c2pa_content_types
5
+
6
+
7
+ class ManifestStore(SuperBox):
8
+ """
9
+ C2PA Manifest Store (JUMBF superbox) with one or more Manifest.
10
+ IMPORTANT: here we do NOT "assume" length of specific manifest bytes (JPG/PDF, etc.).
11
+ For PDF, the length of the exception is set by the injector; for JPG, by its own injector.
12
+ """
13
+
14
+ def __init__(self, manifests: list | None = None):
15
+ self.manifests: list = [] if manifests is None else manifests
16
+ super().__init__(
17
+ content_type=c2pa_content_types["manifest_store"],
18
+ label="c2pa",
19
+ content_boxes=self.manifests,
20
+ )
21
+
22
+ def sync_payload(self):
23
+ super().sync_payload()
24
+
25
+ def set_hash_data_length_for_all(
26
+ self,
27
+ length: int,
28
+ ) -> None:
29
+ for manifest in self.manifests:
30
+ manifest.set_hash_data_length(length)
31
+ super().sync_payload()
32
+
33
+ def serialize(self) -> bytes:
34
+ return super().serialize()
File without changes
@@ -0,0 +1,100 @@
1
+ JPG_SEGMENT_MAX_PAYLOAD_LENGTH = 65517
2
+
3
+
4
+ class JpgSegment:
5
+ def __init__(
6
+ self,
7
+ payload_length: int,
8
+ marker: bytes = bytes.fromhex("EB"),
9
+ ): # noqa: B008
10
+ self.marker = marker
11
+ self.payload_length = payload_length
12
+
13
+ def get_segment_length(self):
14
+ return self.payload_length + 2 # payload length + size of payload length
15
+
16
+ def serialize(
17
+ self,
18
+ payload: bytes,
19
+ ):
20
+ serialized_data = b""
21
+
22
+ serialized_data += bytes.fromhex("FF") + self.marker
23
+ serialized_data += self.get_segment_length().to_bytes(2, "big")
24
+ serialized_data += payload
25
+
26
+ return serialized_data
27
+
28
+
29
+ class JpgSegmentApp11(JpgSegment):
30
+ def __init__(self, segment_id, sequence_number, payload_length, payload):
31
+ self.ci = bytes.fromhex(b"JP".hex()) # 2 bytes
32
+ self.en = segment_id # 2 bytes
33
+ self.z = sequence_number # 4 bytes
34
+
35
+ self.app11_payload = payload
36
+
37
+ super().__init__(payload_length=self.get_payload_length(payload_length))
38
+
39
+ def get_payload_length(self, payload_length):
40
+ return 2 + 2 + 4 + 4 + 4 + payload_length
41
+
42
+ def serialize(self, payload=None):
43
+ _en = self.en.to_bytes(2, "big")
44
+ _z = self.z.to_bytes(4, "big")
45
+
46
+ app11_payload = self.ci + _en + _z + self.app11_payload
47
+
48
+ return super().serialize(app11_payload)
49
+
50
+
51
+ class JpgSegmentApp11Storage:
52
+ def __init__(
53
+ self,
54
+ app11_segment_box_length: int,
55
+ app11_segment_box_type: str,
56
+ payload: bytes,
57
+ ):
58
+ self.l_box = app11_segment_box_length
59
+ self.t_box = app11_segment_box_type
60
+ self.payload = payload
61
+ self.serialized_length = 0
62
+
63
+ def get_payload_length(self):
64
+ return self.l_box - 4 - 4 # - t_box_length - l_box_length
65
+
66
+ def get_serialized_length(self):
67
+ return self.serialized_length
68
+
69
+ def serialize(self):
70
+ segment_id = 1
71
+ sequence_number = 0
72
+ payload_offset = 0
73
+
74
+ payload_length = self.get_payload_length()
75
+
76
+ app11_segments = []
77
+
78
+ while payload_length > 0:
79
+ sequence_number += 1
80
+ chunk_length = JPG_SEGMENT_MAX_PAYLOAD_LENGTH
81
+ if payload_length < JPG_SEGMENT_MAX_PAYLOAD_LENGTH:
82
+ chunk_length = payload_length
83
+
84
+ app11_segments.append(
85
+ JpgSegmentApp11(
86
+ segment_id=segment_id,
87
+ sequence_number=sequence_number,
88
+ payload_length=chunk_length,
89
+ payload=self.payload[payload_offset:],
90
+ )
91
+ )
92
+ payload_length -= chunk_length
93
+ payload_offset += chunk_length
94
+
95
+ serialized_storage_data = b""
96
+ for app11_segment in app11_segments:
97
+ serialized_storage_data += app11_segment.serialize()
98
+
99
+ self.serialized_length = len(serialized_storage_data)
100
+ return serialized_storage_data