Skip to content

Commit b051aeb

Browse files
committed
C2PA v2.3: actions v2 + byte-offset exclusions
1 parent 6a26c1a commit b051aeb

7 files changed

Lines changed: 71 additions & 18 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ Our implementation uses the official `c2pa-text` library to encode C2PA manifest
5656

5757
Learn more about [Encypher's relationship with C2PA](https://docs.encypherai.com/package/user-guide/c2pa-relationship/) in our documentation.
5858

59-
### C2PA `@context` compatibility configuration (v3.0.3+)
59+
### C2PA `@context` compatibility configuration (v3.0.4+)
6060

6161
`encypher-ai` allows production services to control the emitted and accepted C2PA schema contexts via environment variables:
6262

docs/package/changelog.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
This document provides a chronological list of notable changes for each version of Encypher.
44

5+
## 3.0.4 (2026-01-30)
6+
7+
### Fixed
8+
- Verified C2PA hard binding exclusions using byte offsets from the text wrapper instead of codepoint positions.
9+
- Accepted `c2pa.actions.v2` assertions during verification (v1 or v2 allowed).
10+
11+
### Changed
12+
- Confirmed claim label emission as `c2pa.claim.v2` for v2.3 manifests.
13+
514
## 3.0.3 (2026-01-16)
615

716
### Fixed

encypher/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
This package provides tools for invisible metadata encoding in AI-generated text.
77
"""
88

9-
__version__ = "3.0.3"
9+
__version__ = "3.0.4"
1010

1111
from encypher.config.settings import Settings
1212
from encypher.core.unicode_metadata import MetadataTarget, UnicodeMetadata

encypher/core/payloads.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class C2PAAssertion(TypedDict):
6767
"@context": str,
6868
"instance_id": str,
6969
"claim_generator": str,
70+
"claim_label": str,
7071
"assertions": list[C2PAAssertion],
7172
"ingredients": list[dict[str, Any]],
7273
},

encypher/core/unicode_metadata.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
import hashlib
1212
import json
1313
import re
14-
import unicodedata
1514
import uuid
1615
from datetime import date, datetime, timezone
1716
from typing import Any, Callable, Literal, Optional, Union, cast
@@ -403,6 +402,7 @@ def embed_metadata(
403402
distribute_across_targets=distribute_across_targets,
404403
add_hard_binding=add_hard_binding,
405404
custom_assertions=custom_assertions,
405+
custom_metadata=custom_metadata,
406406
)
407407
# --- Start: Input Validation ---
408408
if not isinstance(text, str):
@@ -758,6 +758,7 @@ def _embed_c2pa(
758758
claim_generator: Optional[str],
759759
actions: Optional[list[dict[str, Any]]],
760760
ingredients: Optional[list[dict[str, Any]]],
761+
custom_metadata: Optional[dict[str, Any]],
761762
iso_timestamp: Optional[str],
762763
target: Optional[Union[str, MetadataTarget]],
763764
distribute_across_targets: bool,
@@ -779,6 +780,7 @@ def _embed_c2pa(
779780
claim_generator: A string identifying the software agent creating the claim.
780781
actions: A list of action dictionaries to include in the manifest.
781782
ingredients: A list of ingredient dictionaries for provenance chain.
783+
custom_metadata: Custom metadata to emit as a c2pa.metadata assertion.
782784
iso_timestamp: The ISO 8601 formatted timestamp for the actions.
783785
target: The embedding target strategy.
784786
distribute_across_targets: If True, distribute bits across multiple targets.
@@ -837,15 +839,32 @@ def _embed_c2pa(
837839
"@context": c2pa_context_url,
838840
"instance_id": instance_id,
839841
"claim_generator": claim_gen,
842+
"claim_label": "c2pa.claim.v2",
840843
"assertions": [],
841844
}
842845

843846
# Add ingredients for provenance chain (if provided)
844847
if ingredients:
845848
c2pa_manifest["ingredients"] = ingredients
849+
c2pa_manifest["assertions"].append(
850+
{
851+
"label": "c2pa.ingredient.v3",
852+
"data": {"ingredients": copy.deepcopy(ingredients)},
853+
"kind": "Ingredient",
854+
}
855+
)
846856

847857
actions_data: dict[str, Any] = {"actions": copy.deepcopy(base_actions)}
848-
c2pa_manifest["assertions"].append({"label": "c2pa.actions.v1", "data": actions_data, "kind": "Actions"})
858+
c2pa_manifest["assertions"].append({"label": "c2pa.actions.v2", "data": actions_data, "kind": "Actions"})
859+
860+
if custom_metadata:
861+
c2pa_manifest["assertions"].append(
862+
{
863+
"label": "c2pa.metadata",
864+
"data": copy.deepcopy(custom_metadata),
865+
"kind": "Metadata",
866+
}
867+
)
849868

850869
# Add custom assertions if provided
851870
if custom_assertions:
@@ -883,7 +902,7 @@ def _embed_c2pa(
883902
manifest_for_hashing["assertions"].append(placeholder_soft_binding)
884903

885904
actions_data_copy = next(
886-
(a["data"] for a in manifest_for_hashing["assertions"] if a.get("label") == "c2pa.actions.v1"),
905+
(a["data"] for a in manifest_for_hashing["assertions"] if a.get("label") == "c2pa.actions.v2"),
887906
None,
888907
)
889908
if actions_data_copy and isinstance(actions_data_copy.get("actions"), list):
@@ -1007,23 +1026,13 @@ def verify_metadata(
10071026
logger.warning("C2PA format indicated but no text wrapper found.")
10081027
return False, signer_id, None
10091028

1010-
wrapper_segment = text[span[0] : span[1]]
1011-
normalized_full_text = unicodedata.normalize("NFC", text)
1012-
normalized_index = normalized_full_text.rfind(wrapper_segment)
1013-
if normalized_index < 0:
1014-
logger.warning("Unable to locate wrapper segment in normalized text during verification.")
1015-
return False, signer_id, None
1016-
1017-
exclusion_start = len(normalized_full_text[:normalized_index].encode("utf-8"))
1018-
exclusion_length = len(wrapper_segment.encode("utf-8"))
1019-
10201029
return cls._verify_c2pa(
10211030
original_text=text,
10221031
outer_payload=outer_payload,
10231032
public_key_resolver=public_key_resolver,
10241033
return_payload_on_failure=return_payload_on_failure,
10251034
require_hard_binding=require_hard_binding,
1026-
wrapper_exclusion=(exclusion_start, exclusion_length),
1035+
wrapper_exclusion=span,
10271036
)
10281037

10291038
# --- Legacy Format Verification ('basic', 'manifest', 'cbor_manifest') ---
@@ -1222,14 +1231,18 @@ def _verify_c2pa(
12221231
# b) Check for mandatory assertions
12231232
assertions = c2pa_manifest.get("assertions", [])
12241233
assertion_labels = {a.get("label") for a in assertions if isinstance(a, dict)}
1225-
required_assertions = {"c2pa.actions.v1", "c2pa.soft_binding.v1"}
1234+
required_assertions = {"c2pa.soft_binding.v1"}
12261235
if require_hard_binding:
12271236
required_assertions.add("c2pa.hash.data.v1")
12281237
if not required_assertions.issubset(assertion_labels):
12291238
missing = required_assertions - assertion_labels
12301239
logger.warning(f"C2PA verification: Manifest missing required assertions: {missing}")
12311240
return False, signer_id, c2pa_manifest
12321241

1242+
if not ("c2pa.actions.v1" in assertion_labels or "c2pa.actions.v2" in assertion_labels):
1243+
logger.warning("C2PA verification: Manifest missing actions assertion (v1 or v2).")
1244+
return False, signer_id, c2pa_manifest
1245+
12331246
# --- 3. Soft Binding Verification (Deterministic Hashing) ---
12341247
soft_binding_assertion = next((a for a in assertions if isinstance(a, dict) and a.get("label") == "c2pa.soft_binding.v1"), None)
12351248
if soft_binding_assertion is None:

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.0.3"
7+
version = "3.0.4"
88
description = "Embed invisible metadata in AI-generated text using zero-width characters."
99
readme = "README.md"
1010
authors = [{name = "Encypher Team"}]

tests/core/test_unicode_metadata.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,36 @@ def test_unicode_metadata_verify_with_manifest(self, manifest_metadata, key_pair
602602
assert manifest_payload.get("ai_info") == manifest_metadata.get("ai_info")
603603
assert manifest_payload.get("custom_claims") == manifest_metadata.get("custom_claims")
604604

605+
def test_c2pa_manifest_uses_v2_actions_and_metadata(self, key_pair_1, sample_text):
606+
"""C2PA manifests should emit v2 actions and metadata assertions for v2.3 compliance."""
607+
private_key, _ = key_pair_1
608+
signer_id = "signer_1"
609+
now = datetime.now(timezone.utc)
610+
611+
embedded_text = UnicodeMetadata.embed_metadata(
612+
text=sample_text,
613+
private_key=private_key,
614+
signer_id=signer_id,
615+
metadata_format="c2pa",
616+
actions=[
617+
{
618+
"label": "c2pa.created",
619+
"when": now.isoformat(),
620+
"softwareAgent": "pytest",
621+
}
622+
],
623+
custom_metadata={"title": "C2PA v2.3 metadata", "description": "Test metadata assertion"},
624+
)
625+
626+
extracted_manifest = UnicodeMetadata.extract_metadata(embedded_text)
627+
assert isinstance(extracted_manifest, dict)
628+
assertions = extracted_manifest.get("assertions", [])
629+
assertion_labels = {assertion.get("label") for assertion in assertions if isinstance(assertion, dict)}
630+
631+
assert "c2pa.actions.v2" in assertion_labels
632+
assert "c2pa.metadata" in assertion_labels
633+
assert extracted_manifest.get("claim_label") == "c2pa.claim.v2"
634+
605635
def test_embed_metadata_omit_keys(self, key_pair_1, sample_text, public_key_provider):
606636
private_key, _ = key_pair_1
607637
signer_id = "signer_1"

0 commit comments

Comments
 (0)