Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
13d0d1d
test: #79: add tests for implementation of Ingredient Assertion; add …
aasheptunov Jun 1, 2026
3528d3b
test: #79: remove redundant Thumbnail Assertion tests; rename Thumbna…
aasheptunov Jun 1, 2026
1bf9cbf
refactor: #79: formatting changes
aasheptunov Jun 2, 2026
34558f8
feat: #79: add implementation of ingredient thumbnail assertion
aasheptunov Jun 2, 2026
2f1cc21
test: #80: add test to verify that passing thumbnail file in unsuppor…
aasheptunov Jun 2, 2026
62fbb29
feat: #79: add logic for reading and validating thumbnail_file to sig…
aasheptunov Jun 2, 2026
d054c59
feat: #79: implementation of logic for adding ingredient thumbnail as…
aasheptunov Jun 2, 2026
b98f882
refactor: #79: modify logic for retrieving active_manifest within Ing…
aasheptunov Jun 3, 2026
11b7699
feat: #79: add support PNG IANA media type format in supported thumbn…
aasheptunov Jun 3, 2026
0c99629
test: #79: fix tests after applying changes
aasheptunov Jun 3, 2026
c15ab1c
fix: #79: fix supported_extensions array so that it includes only JPG…
aasheptunov Jun 3, 2026
21971f2
feat: #79: change logic of creation Ingredient Thumbnail Assertion so…
aasheptunov Jun 3, 2026
16ed4b8
refactor: #79: move logic for extracting thumbnail bytes from active …
aasheptunov Jun 3, 2026
141b986
Merge branch 'develop' into feature/#79-adding-an-thumbnail-ingredien…
aasheptunov Jun 4, 2026
89072d4
test: #79: add test for check handling thumbnail file sizes
aasheptunov Jun 4, 2026
f3fb278
feat: #79: add thumbnail file size handling
aasheptunov Jun 4, 2026
7941e54
test: #79: add tests using c2pie_GenerateIngredientThumbnailAssertion…
aasheptunov Jun 4, 2026
4fd302f
refactor: #79: remove of redundant condition, since it is unachievable
aasheptunov Jun 4, 2026
1b2f201
fix: #79: fix typo in error message
aasheptunov Jun 4, 2026
f3d2be6
test: #79: refactor tests after applying changes; fix typos
aasheptunov Jun 4, 2026
9e9f7a7
docs(readme): bring test coverage score up to date
Jun 4, 2026
dcea65a
refactor: #79: formatting changes
aasheptunov Jun 4, 2026
7469570
Merge branch 'feature/#79-adding-an-thumbnail-ingredient-assertion' o…
aasheptunov Jun 4, 2026
7d7d1d4
fix: #79: fix error involving undefined values for private_key and ce…
aasheptunov Jun 4, 2026
957b22e
docs(readme): bring test coverage score up to date
Jun 4, 2026
5ea3d32
Merge branch 'develop' into feature/#79-adding-an-thumbnail-ingredien…
aasheptunov Jun 4, 2026
5f4d866
Merge branch 'feature/#79-adding-an-thumbnail-ingredient-assertion' o…
aasheptunov Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@

[![Linting](https://github.com/TourmalineCore/c2pie/actions/workflows/lint-on-pull-request.yml/badge.svg?branch=develop)](https://github.com/TourmalineCore/c2pie/actions/workflows/lint-on-pull-request.yml)
[![c2pa](https://img.shields.io/badge/c2pa-v1.4-seagreen.svg)](https://c2pa.org/)
[![coverage](https://img.shields.io/badge/e2e_coverage-84.80%25-olivedrab)](https://github.com/TourmalineCore/c2pie/actions/workflows/calculate-tests-coverage-on-pull-request.yml)
[![coverage](https://img.shields.io/badge/units_coverage-85.01%25-olivedrab)](https://github.com/TourmalineCore/c2pie/actions/workflows/calculate-tests-coverage-on-pull-request.yml)
[![coverage](https://img.shields.io/badge/full_coverage-93.02%25-forestgreen)](https://github.com/TourmalineCore/c2pie/actions/workflows/calculate-tests-coverage-on-pull-request.yml)
[![coverage](https://img.shields.io/badge/e2e_coverage-69.63%25-orange)](https://github.com/TourmalineCore/c2pie/actions/workflows/calculate-tests-coverage-on-pull-request.yml)
[![coverage](https://img.shields.io/badge/units_coverage-86.82%25-olivedrab)](https://github.com/TourmalineCore/c2pie/actions/workflows/calculate-tests-coverage-on-pull-request.yml)
[![coverage](https://img.shields.io/badge/full_coverage-91.89%25-forestgreen)](https://github.com/TourmalineCore/c2pie/actions/workflows/calculate-tests-coverage-on-pull-request.yml)
[![latest](https://img.shields.io/pypi/v/c2pie?label=latest&colorB=fc8021)](https://pypi.org/project/c2pie/)

<br>
Expand Down
58 changes: 32 additions & 26 deletions c2pie/c2pa/assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,6 @@ class EmbeddedDataAssertion(Assertion):

Can be used for the following assertions:
- c2pa.thumbnail.claim,
- c2pa.ingredient,
- c2pa.ingredient.thumbnail
- c2pa.embedded-data

Expand Down Expand Up @@ -225,6 +224,21 @@ def __init__(
)


class IngredientThumbnailAssertion(EmbeddedDataAssertion):
"""An assertion (c2pa.thumbnail.ingredient) containing an ingredient thumbnail"""

def __init__(
self,
media_type: str,
image_data: bytes,
):
super().__init__(
media_type=media_type,
image_data=image_data,
assertion_type=C2PA_AssertionTypes.ingredient_thumbnail,
)


class IngredientAssertion(Assertion):
"""c2pa.ingredient.v3 asset-binding assertion."""

Expand All @@ -234,15 +248,26 @@ def __init__(
dc_format: str,
ingredient_bytes: bytes,
active_manifest_urn: str | None,
previous_manifest_boxes: list[Box],
active_manifest: Box | None,
ingredient_thumbnail_assertion: IngredientThumbnailAssertion | None = None,
):
schema: dict[str, Any] = {
"dc:title": title,
"dc:format": dc_format,
"relationship": "parentOf",
}

if active_manifest_urn and previous_manifest_boxes:
if ingredient_thumbnail_assertion:
ingredient_thumbnail_hash = hashlib.sha256(ingredient_thumbnail_assertion.payload).digest()

ingredient_thumbnail: dict[str, str | bytes] = generate_hashed_uri_map(
url=f"self#jumbf=c2pa.assertions/{ingredient_thumbnail_assertion.get_label()}",
hash_value=ingredient_thumbnail_hash,
hash_algorithm="sha256",
)
schema["thumbnail"] = ingredient_thumbnail

if active_manifest_urn and active_manifest:
validation_results = self.validate_ingredient(
ingredient_bytes,
dc_format,
Expand All @@ -256,34 +281,15 @@ def __init__(
)
return

active_manifest_box: Box = None
for box in previous_manifest_boxes:
found_box = find_in_box(
box,
active_manifest_urn,
)

if found_box:
active_manifest_box = found_box
break

# We should not include information about the active manifest if validation was unsuccessful
if not active_manifest_box:
super().__init__(
C2PA_AssertionTypes.ingredient,
schema,
)
return

active_manifest_hash = hashlib.sha256(active_manifest_box.payload).digest()
active_manifest_hash = hashlib.sha256(active_manifest.payload).digest()

active_manifest: dict[str, str | bytes] = generate_hashed_uri_map(
active_manifest_map: dict[str, str | bytes] = generate_hashed_uri_map(
url=f"self#jumbf=/c2pa/{active_manifest_urn}",
hash_value=active_manifest_hash,
hash_algorithm="sha256",
)

claim_signature_box = find_in_box(active_manifest_box, "c2pa.signature")
claim_signature_box = find_in_box(active_manifest, "c2pa.signature")

claim_signature_hash = hashlib.sha256(claim_signature_box.payload).digest()

Expand All @@ -293,7 +299,7 @@ def __init__(
hash_algorithm="sha256",
)

schema["activeManifest"] = active_manifest
schema["activeManifest"] = active_manifest_map
schema["validationResults"] = validation_results
schema["claimSignature"] = claim_signature

Expand Down
60 changes: 57 additions & 3 deletions c2pie/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
Assertion,
HashDataAssertion,
IngredientAssertion,
IngredientThumbnailAssertion,
ThumbnailAssertion,
)
from c2pie.c2pa.assertion_store import AssertionStore
Expand All @@ -14,8 +15,11 @@
from c2pie.c2pa.manifest_store import ManifestStore
from c2pie.c2pa_injection.jpg_injection import emplace_manifest_into_jpeg
from c2pie.c2pa_injection.pdf_injection import emplace_manifest_into_pdf
from c2pie.c2pa_parsing.jumbf_parsing import find_in_box
from c2pie.jumbf_boxes.box import Box
from c2pie.utils.assertion_schemas import C2PA_AssertionTypes
from c2pie.jumbf_boxes.description_box import DescriptionBox
from c2pie.jumbf_boxes.super_box import SuperBox
from c2pie.utils.assertion_schemas import C2PA_AssertionTypes, get_assertion_label
from c2pie.utils.content_types import C2PA_ContentTypes


Expand Down Expand Up @@ -52,19 +56,69 @@ def c2pie_GenerateThumbnailAssertion(
)


# Currently not in use.
# If APP11 exceeds the allowed size (65,535 bytes), an error will occur.
# It is necessary to add logic to handle this case by splitting APP11.
def c2pie_GenerateIngredientThumbnailAssertion(
media_type: str | None = None,
image_data: bytes | None = None,
active_manifest: Box | None = None,
) -> SuperBox | IngredientThumbnailAssertion | None:
if active_manifest:
possibles_thumbnail_assertion_labels = [
"c2pa.thumbnail.claim",
"c2pa.thumbnail.claim.jpeg",
]

previous_thumbnail_assertion = None
for thumbnail_assertion_label in possibles_thumbnail_assertion_labels:
previous_thumbnail_assertion = find_in_box(
active_manifest,
thumbnail_assertion_label,
)

if previous_thumbnail_assertion:
break

if previous_thumbnail_assertion:
ingredient_thumbnail_assertion = SuperBox.from_box(previous_thumbnail_assertion)

# The type field is used when searching for a Data Hash Assertion
# in the Assertion Store when the set_hash_data_length() method is called
ingredient_thumbnail_assertion.type = C2PA_AssertionTypes.ingredient_thumbnail

ingredient_thumbnail_assertion.description_box = DescriptionBox(
content_type=ingredient_thumbnail_assertion.get_content_type(),
label=get_assertion_label(C2PA_AssertionTypes.ingredient_thumbnail),
)
ingredient_thumbnail_assertion.sync_payload()

return ingredient_thumbnail_assertion
else:
if media_type and image_data:
return IngredientThumbnailAssertion(
media_type,
image_data,
)

return None


def c2pie_GenerateIngredientAssertion(
title: str,
dc_format: str,
ingredient_bytes: bytes,
active_manifest_urn: str | None,
previous_manifest_boxes: list[Box],
active_manifest: Box | None,
ingredient_thumbnail_assertion: IngredientThumbnailAssertion | None = None,
) -> IngredientAssertion:
return IngredientAssertion(
title=title,
dc_format=dc_format,
ingredient_bytes=ingredient_bytes,
active_manifest_urn=active_manifest_urn,
previous_manifest_boxes=previous_manifest_boxes,
active_manifest=active_manifest,
ingredient_thumbnail_assertion=ingredient_thumbnail_assertion,
)


Expand Down
28 changes: 24 additions & 4 deletions c2pie/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,22 @@ def parse_arguments() -> argparse.Namespace:
f"supported extensions are: {supported_extensions}.",
)

global_parser.add_argument("-V", "--version", action="version", version=f"c2pie {version('c2pie')}")
global_parser.add_argument(
"-V",
"--version",
action="version",
version=f"c2pie {version('c2pie')}",
)

subparsers = global_parser.add_subparsers(title="subcommands", help="commands")
subparsers = global_parser.add_subparsers(
title="subcommands",
help="commands",
)

sign_parser = subparsers.add_parser("sign", help="embed c2pa signature into a file")
sign_parser = subparsers.add_parser(
"sign",
help="embed c2pa signature into a file",
)

sign_parser.add_argument(
"--input_file",
Expand All @@ -38,11 +49,18 @@ def parse_arguments() -> argparse.Namespace:
help="optional path to save the signed file. If omitted, the program saves to 'signed_' + input_file.",
)

sign_parser.add_argument(
"--thumbnail_file",
type=Path,
default=None,
help="optional path to the file whose bytes are to be written as a thumbnail of the file being signed.",
)

sign_parser.add_argument(
"--tsa_url",
type=str,
default=None,
help="TimeStamp Authority URL for timestamping (e.g. http://timestamp.digicert.com). "
help="time-stamp authority URL for timestamping (e.g. http://timestamp.digicert.com). "
"Falls back to C2PIE_TSA_URL env variable.",
)

Expand Down Expand Up @@ -71,10 +89,12 @@ def sign(arguments: argparse.Namespace) -> None:
tsa_url = arguments.tsa_url or os.getenv("C2PIE_TSA_URL")
require_tsa = arguments.require_tsa or (os.getenv("C2PIE_TSA_REQUIRED", "").lower() == "true")
tsa_log_dir = arguments.tsa_log_dir or os.getenv("C2PIE_TSA_LOG_DIR")
thumbnail_file_path = arguments.thumbnail_file

sign_file(
input_path=input_file_path,
output_path=output_file_path,
thumbnail_file_path=thumbnail_file_path,
tsa_url=tsa_url,
require_tsa=require_tsa,
tsa_log_dir=tsa_log_dir,
Expand Down
64 changes: 61 additions & 3 deletions c2pie/signing.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@
from typing import Literal

from c2pie.c2pa_injection.pdf_injection import prepare_pdf_bytes
from c2pie.c2pa_parsing.jumbf_parsing import extract_manifest_boxes, get_active_manifest_uuid
from c2pie.c2pa_parsing.jumbf_parsing import extract_manifest_boxes, find_in_box, get_active_manifest_uuid
from c2pie.c2pa_parsing.manifest_extractor import extract_manifest_store_bytes
from c2pie.interface import (
c2pie_EmplaceManifest,
c2pie_GenerateActionsAssertion,
c2pie_GenerateHashDataAssertion,
c2pie_GenerateIngredientAssertion,
c2pie_GenerateIngredientThumbnailAssertion,
c2pie_GenerateManifestStore,
c2pie_GenerateThumbnailAssertion,
)
from c2pie.jumbf_boxes.box import Box
from c2pie.utils.content_types import C2PA_ContentTypes
from c2pie.utils.content_types import C2PA_ContentTypes, iana_media_types
from c2pie.utils.generate_hashed_uri_map import generate_hashed_uri_map


Expand All @@ -39,6 +41,7 @@ def _get_content_type_by_filepath(file_path: Path) -> C2PA_ContentTypes:
def _check_file_extension_is_supported(file_path: Path) -> None:
supported_extensions: list[str] = [_type.value for _type in C2PA_ContentTypes]
file_extension = file_path.suffix

if file_extension not in supported_extensions:
raise ValueError(
f"The file has an incorrect extension: {file_extension}"
Expand Down Expand Up @@ -114,9 +117,21 @@ def _load_certificates_and_key(
return key, certificates


def _read_and_check_size_of_thumbnail_file(thumbnail_file_path: Path):
with open(thumbnail_file_path, "rb") as f:
thumbnail_raw_bytes = f.read()

# The 1024x1024 requirement is specified in the C2PA specification.
if len(thumbnail_raw_bytes) > 1024 * 1024:
raise ValueError("The thumbnail file is too large! The size must not exceed 1024x1024. Recommended 512x512.")

return thumbnail_raw_bytes


def sign_file(
input_path: Path | str,
output_path: Path | str | None = None,
thumbnail_file_path: Path | str | None = None,
key_path: str | None = None,
certificates_path: str | None = None,
tsa_url: str | None = None,
Expand Down Expand Up @@ -146,6 +161,32 @@ def sign_file(

assertions = []

thumbnail_media_type = None
thumbnail_raw_bytes = None

if thumbnail_file_path:
thumbnail_file_path = _validate_general_filepath(
file_path=thumbnail_file_path,
file_path_type="other",
)

supported_extensions: list[str] = [".jpeg", ".jpg", ".png"]
if thumbnail_file_path.suffix not in supported_extensions:
raise ValueError(
f"The thumbnail file has an incorrect extension: {thumbnail_file_path.suffix}. "
f"Currently, only the following extensions are supported: {supported_extensions}.",
)
else:
thumbnail_raw_bytes = _read_and_check_size_of_thumbnail_file(thumbnail_file_path)

thumbnail_media_type = iana_media_types[thumbnail_file_path.suffix]

thumbnail_assertion = c2pie_GenerateThumbnailAssertion(
thumbnail_media_type,
thumbnail_raw_bytes,
)
assertions.append(thumbnail_assertion)

hash_data_assertion = c2pie_GenerateHashDataAssertion(
hashed_data=hashlib.sha256(raw_bytes).digest(),
)
Expand All @@ -159,13 +200,30 @@ def sign_file(

active_manifest_urn: str | None = get_active_manifest_uuid(manifest_store_bytes)
previous_manifest_boxes: list[Box] = extract_manifest_boxes(manifest_store_bytes)
active_manifest: Box | None = None

if previous_manifest_boxes:
for manifest in previous_manifest_boxes:
active_manifest = find_in_box(
manifest,
active_manifest_urn,
)

ingredient_thumbnail_assertion = c2pie_GenerateIngredientThumbnailAssertion(
thumbnail_media_type,
thumbnail_raw_bytes,
active_manifest=active_manifest,
)
if ingredient_thumbnail_assertion:
assertions.append(ingredient_thumbnail_assertion)

ingredient_assertion = c2pie_GenerateIngredientAssertion(
title=input_path.name,
dc_format=_DC_FORMAT_BY_CONTENT_TYPE[file_type.name],
ingredient_bytes=raw_bytes,
active_manifest_urn=active_manifest_urn,
previous_manifest_boxes=previous_manifest_boxes,
active_manifest=active_manifest,
ingredient_thumbnail_assertion=ingredient_thumbnail_assertion,
)
assertions.append(ingredient_assertion)

Expand Down
Loading
Loading