diff --git a/docs/context.md b/docs/context.md new file mode 100644 index 00000000..ccc84145 --- /dev/null +++ b/docs/context.md @@ -0,0 +1,588 @@ +# Using Context to configure the SDK + +Use the `Context` class to configure how `Reader`, `Builder`, and other aspects of the SDK operate. + +## What is Context? + +Context encapsulates SDK configuration: + +- **Settings**: Verification options, [Builder behavior](#configuring-builder), [Reader trust configuration](#configuring-reader), thumbnail configuration, and more. See [Using settings](settings.md) for complete details. +- [**Signer configuration**](#configuring-a-signer): Optional signer credentials that can be stored in the Context for reuse. +- **State isolation**: Each `Context` is independent, allowing different configurations to coexist in the same application. + +### Why use Context? + +`Context` is better than the deprecated global `load_settings()` function because it: + +- **Makes dependencies explicit**: Configuration is passed directly to `Reader` and `Builder`, not hidden in global state. +- **Enables multiple configurations**: Run different configurations simultaneously. For example, one for development with test certificates, another for production with strict validation. +- **Eliminates global state**: Each `Reader` and `Builder` gets its configuration from the `Context` you pass, avoiding subtle bugs from shared state. +- **Simplifies testing**: Create isolated configurations for tests without worrying about cleanup or interference between them. +- **Improves code clarity**: Reading `Builder(manifest_json, context=ctx)` immediately shows that configuration is being used. + +### Class diagram + +This diagram shows the public classes in the SDK and their relationships. + +```mermaid +classDiagram + direction LR + + class Settings { + +from_json(json_str) Settings$ + +from_dict(config) Settings$ + +set(path, value) Settings + +update(data, format) Settings + +close() + +is_valid bool + } + + class ContextProvider { + <> + +is_valid bool + } + + class Context { + +from_json(json_str, signer) Context$ + +from_dict(config, signer) Context$ + +has_signer bool + +is_valid bool + +close() + } + + class Reader { + +get_supported_mime_types() list~str~$ + +try_create(format_or_path, stream, manifest_data, context) Reader | None$ + +json() str + +detailed_json() str + +get_active_manifest() dict | None + +get_manifest(label) dict + +get_validation_state() str | None + +get_validation_results() dict | None + +resource_to_stream(uri, stream) int + +is_embedded() bool + +get_remote_url() str | None + +close() + } + + class Builder { + +from_json(manifest_json, context) Builder$ + +from_archive(stream, context) Builder$ + +get_supported_mime_types() list~str~$ + +set_no_embed() + +set_remote_url(url) + +set_intent(intent, digital_source_type) + +add_resource(uri, stream) + +add_ingredient(json, format, source) + +add_action(action_json) + +to_archive(stream) + +sign(signer, format, source, dest) bytes + +sign_file(source_path, dest_path, signer) bytes + +close() + } + + class Signer { + +from_info(signer_info) Signer$ + +from_callback(callback, alg, certs, tsa_url) Signer$ + +reserve_size() int + +close() + } + + class C2paSignerInfo { + <> + +alg + +sign_cert + +private_key + +ta_url + } + + class C2paSigningAlg { + <> + ES256 + ES384 + ES512 + PS256 + PS384 + PS512 + ED25519 + } + + class C2paBuilderIntent { + <> + CREATE + EDIT + UPDATE + } + + class C2paDigitalSourceType { + <> + DIGITAL_CAPTURE + DIGITAL_CREATION + TRAINED_ALGORITHMIC_MEDIA + ... + } + + class C2paError { + <> + +message str + } + + class C2paError_Subtypes { + <> + ManifestNotFound + NotSupported + Json + Io + Verify + Signature + ... + } + + ContextProvider <|.. Context : satisfies + Settings --> Context : optional input + Signer --> Context : optional, consumed + C2paSignerInfo --> Signer : creates via from_info + C2paSigningAlg --> C2paSignerInfo : alg field + C2paSigningAlg --> Signer : from_callback alg + Context --> Reader : optional context= + Context --> Builder : optional context= + Signer --> Builder : sign(signer) + C2paBuilderIntent --> Builder : set_intent + C2paDigitalSourceType --> Builder : set_intent + C2paError --> C2paError_Subtypes : subclasses +``` + +> [!NOTE] +> The deprecated `load_settings()` function still works for backward compatibility but you are encouraged to migrate your code to use `Context`. See [Migrating from load_settings](#migrating-from-load_settings). + +## Creating a Context + +There are several ways to create a `Context`, depending on your needs: + +- [Using SDK default settings](#using-sdk-default-settings) +- [From a JSON string](#from-a-json-string) +- [From a dictionary](#from-a-dictionary) +- [From a Settings object](#from-a-settings-object) + +### Using SDK default settings + +The simplest approach is using [SDK default settings](settings.md#default-configuration). + +**When to use:** For quick prototyping, or when you're happy with default behavior (verification enabled, thumbnails enabled at 1024px, and so on). + +```py +from c2pa import Context + +ctx = Context() # Uses SDK defaults +``` + +### From a JSON string + +You can create a `Context` directly from a JSON configuration string. + +**When to use:** For simple configuration that doesn't need to be shared across the codebase, or when hard-coding settings for a specific purpose (for example, a utility script). + +```py +ctx = Context.from_json('''{ + "verify": {"verify_after_sign": true}, + "builder": { + "thumbnail": {"enabled": false}, + "claim_generator_info": {"name": "An app", "version": "0.1.0"} + } +}''') +``` + +### From a dictionary + +You can create a `Context` from a Python dictionary. + +**When to use:** When you want to build configuration programmatically using native Python data structures. + +```py +ctx = Context.from_dict({ + "verify": {"verify_after_sign": True}, + "builder": { + "thumbnail": {"enabled": False}, + "claim_generator_info": {"name": "An app", "version": "0.1.0"} + } +}) +``` + +### From a Settings object + +You can build a `Settings` object programmatically, then create a `Context` from that. + +**When to use:** For configuration that needs runtime logic (such as conditional settings based on environment), or when you want to build settings incrementally. + +```py +from c2pa import Settings, Context + +settings = Settings() +settings.set("builder.thumbnail.enabled", "false") +settings.set("verify.verify_after_sign", "true") +settings.update({ + "builder": { + "claim_generator_info": {"name": "An app", "version": "0.1.0"} + } +}) + +ctx = Context(settings=settings) +``` + +## Common configuration patterns + +### Development environment with test certificates + +During development, you often need to trust self-signed or custom CA certificates: + +```py +# Load your test root CA +with open("test-ca.pem", "r") as f: + test_ca = f.read() + +ctx = Context.from_dict({ + "trust": { + "user_anchors": test_ca + }, + "verify": { + "verify_after_reading": True, + "verify_after_sign": True, + "remote_manifest_fetch": False, + "ocsp_fetch": False + }, + "builder": { + "claim_generator_info": {"name": "Dev Build", "version": "dev"}, + "thumbnail": {"enabled": False} + } +}) +``` + +### Configuration from environment variables + +Adapt configuration based on the runtime environment: + +```py +import os + +env = os.environ.get("ENVIRONMENT", "dev") + +settings = Settings() +if env == "production": + settings.update({"verify": {"strict_v1_validation": True}}) +else: + settings.update({"verify": {"remote_manifest_fetch": False}}) + +ctx = Context(settings=settings) +``` + +### Layered configuration + +Load base configuration and apply runtime overrides: + +```py +import json + +# Load base configuration from a file +with open("config/base.json", "r") as f: + base_config = json.load(f) + +settings = Settings.from_dict(base_config) + +# Apply environment-specific overrides +settings.update({"builder": {"claim_generator_info": {"version": app_version}}}) + +ctx = Context(settings=settings) +``` + +For the full list of settings and defaults, see [Using settings](settings.md). + +## Configuring Reader + +Use `Context` to control how `Reader` validates manifests and handles remote resources, including: + +- **Verification behavior**: Whether to verify after reading, check trust, and so on. +- [**Trust configuration**](#trust-configuration): Which certificates to trust when validating signatures. +- [**Network access**](#offline-operation): Whether to fetch remote manifests or OCSP responses. + +> [!IMPORTANT] +> `Context` is used only at construction. `Reader` copies the configuration it needs internally, so the `Context` object does not need to outlive the `Reader`. + +```py +ctx = Context.from_dict({"verify": {"remote_manifest_fetch": False}}) +reader = Reader("image.jpg", context=ctx) +``` + +### Reading from a file + +```py +ctx = Context.from_dict({ + "verify": { + "remote_manifest_fetch": False, + "ocsp_fetch": False + } +}) + +reader = Reader("image.jpg", context=ctx) +print(reader.json()) +``` + +### Reading from a stream + +```py +with open("image.jpg", "rb") as stream: + reader = Reader("image/jpeg", stream, context=ctx) + print(reader.json()) +``` + +### Trust configuration + +Example of trust configuration in a settings dictionary: + +```py +ctx = Context.from_dict({ + "trust": { + "user_anchors": "-----BEGIN CERTIFICATE-----\nMIICEzCCA...\n-----END CERTIFICATE-----", + "trust_config": "1.3.6.1.4.1.311.76.59.1.9\n1.3.6.1.4.1.62558.2.1" + } +}) + +reader = Reader("signed_asset.jpg", context=ctx) +``` + +### Full validation + +To configure full validation, with all verification features enabled: + +```py +ctx = Context.from_dict({ + "verify": { + "verify_after_reading": True, + "verify_trust": True, + "verify_timestamp_trust": True, + "remote_manifest_fetch": True + } +}) + +reader = Reader("asset.jpg", context=ctx) +``` + +For more information, see [Settings - Verify](settings.md#verify). + +### Offline operation + +To configure `Reader` to work with no network access: + +```py +ctx = Context.from_dict({ + "verify": { + "remote_manifest_fetch": False, + "ocsp_fetch": False + } +}) + +reader = Reader("local_asset.jpg", context=ctx) +``` + +For more information, see [Settings - Offline or air-gapped environments](settings.md#offline-or-air-gapped-environments). + +## Configuring Builder + +`Builder` uses `Context` to control how to create and sign C2PA manifests. The `Context` affects: + +- **Claim generator information**: Application name, version, and metadata embedded in the manifest. +- **Thumbnail generation**: Whether to create thumbnails, size, quality, and format. +- **Action tracking**: Auto-generation of actions like `c2pa.created`, `c2pa.opened`, `c2pa.placed`. +- **Intent**: The purpose of the claim (create, edit, or update). +- **Verification after signing**: Whether to validate the manifest immediately after signing. +- **Signer configuration** (optional): Credentials can be stored in the context for reuse. + +> [!IMPORTANT] +> The `Context` is used only when constructing the `Builder`. The `Builder` copies the configuration it needs internally, so the `Context` object does not need to outlive the `Builder`. + +### Basic use + +```py +ctx = Context.from_dict({ + "builder": { + "claim_generator_info": { + "name": "An app", + "version": "0.1.0" + }, + "intent": {"Create": "digitalCapture"} + } +}) + +builder = Builder(manifest_json, context=ctx) + +# Pass signer explicitly at signing time +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +### Controlling thumbnail generation + +```py +# Disable thumbnails for faster processing +no_thumbnails_ctx = Context.from_dict({ + "builder": { + "claim_generator_info": {"name": "Batch Processor"}, + "thumbnail": {"enabled": False} + } +}) + +# Or customize thumbnail size and quality for mobile +mobile_ctx = Context.from_dict({ + "builder": { + "claim_generator_info": {"name": "Mobile App"}, + "thumbnail": { + "enabled": True, + "long_edge": 512, + "quality": "low", + "prefer_smallest_format": True + } + } +}) +``` + +## Configuring a signer + +You can configure a signer in two ways: + +- [From Settings (signer-on-context)](#from-settings) +- [Explicit signer passed to sign()](#explicit-signer) + +### From Settings + +Create a `Signer` and pass it to the `Context`. The signer is **consumed** — the `Signer` object becomes invalid after this call and must not be reused. The `Context` takes ownership of the underlying native signer. + +```py +from c2pa import Context, Settings, Builder, Signer, C2paSignerInfo, C2paSigningAlg + +# Create a signer +signer_info = C2paSignerInfo( + alg=C2paSigningAlg.ES256, + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" +) +signer = Signer.from_info(signer_info) + +# Create context with signer (signer is consumed) +ctx = Context(settings=settings, signer=signer) +# signer is now invalid and must not be used again + +# Build and sign — no signer argument needed +builder = Builder(manifest_json, context=ctx) +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign(format="image/jpeg", source=src, dest=dst) +``` + +> [!NOTE] +> Signer-on-context requires a compatible version of the native c2pa-c library. If the library does not support this feature, a `C2paError` is raised when passing a `Signer` to `Context`. + +### Explicit signer + +For full programmatic control, create a `Signer` and pass it directly to `Builder.sign()`: + +```py +signer = Signer.from_info(signer_info) +builder = Builder(manifest_json, context=ctx) + +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +If both an explicit signer and a context signer are available, the explicit signer always takes precedence: + +```py +# Explicit signer wins over context signer +builder.sign(explicit_signer, "image/jpeg", source, dest) +``` + +## Context lifetime and usage + +### Context as a context manager + +`Context` supports the `with` statement for automatic resource cleanup: + +```py +with Context() as ctx: + reader = Reader("image.jpg", context=ctx) + print(reader.json()) +# Resources are automatically released +``` + +### Reusable contexts + +You can reuse the same `Context` to create multiple readers and builders: + +```py +ctx = Context(settings=settings) + +# All three use the same configuration +builder1 = Builder(manifest1, context=ctx) +builder2 = Builder(manifest2, context=ctx) +reader = Reader("image.jpg", context=ctx) + +# Context can be closed after construction; readers/builders still work +``` + +### Multiple contexts for different purposes + +Use different `Context` objects when you need different settings; for example, for development vs. production, or different trust configurations: + +```py +dev_ctx = Context(settings=dev_settings) +prod_ctx = Context(settings=prod_settings) + +# Different builders with different configurations +dev_builder = Builder(manifest, context=dev_ctx) +prod_builder = Builder(manifest, context=prod_ctx) +``` + +### ContextProvider protocol + +The `ContextProvider` protocol allows third-party implementations of custom context providers. Any class that implements `is_valid` and `_c_context` properties satisfies the protocol and can be passed to `Reader` or `Builder` as `context`. + +```py +from c2pa import ContextProvider, Context + +# The built-in Context satisfies ContextProvider +ctx = Context() +assert isinstance(ctx, ContextProvider) # True +``` + +## Migrating from load_settings + +The `load_settings()` function is deprecated. Replace it with `Settings` and `Context`: + +| Aspect | load_settings (legacy) | Context | +|--------|------------------------|---------| +| Scope | Global state | Per Reader/Builder, passed explicitly | +| Multiple configs | Not supported | One context per configuration | +| Testing | Shared global state | Isolated contexts per test | + +**Deprecated:** + +```py +from c2pa import load_settings, Reader + +load_settings({"builder": {"thumbnail": {"enabled": False}}}) +reader = Reader("image.jpg") # uses global settings +``` + +**Using current APIs:** + +```py +from c2pa import Settings, Context, Reader + +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) +ctx = Context(settings=settings) +reader = Reader("image.jpg", context=ctx) +``` + +## See also + +- [Using settings](settings.md) — schema, property reference, and examples. +- [Usage](usage.md) — reading and signing with Reader and Builder. +- [CAI settings schema](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/): full schema reference. diff --git a/docs/intents.md.md b/docs/intents.md.md new file mode 100644 index 00000000..6f7595bb --- /dev/null +++ b/docs/intents.md.md @@ -0,0 +1,515 @@ +# Using Builder intents + +Intents tell the `Builder` what kind of manifest is being created. They enable validation, add required default actions, and help prevent invalid operations. + +Intents can be used for any asset type. Intents are about the **operation** (create, edit, update), not the asset type. + +## Why use intents? + +Without intents, the caller must manually construct the correct manifest structure: adding the right actions (`c2pa.created` or `c2pa.opened`), setting digital source types, managing parent ingredients, and linking actions to ingredients. Getting any of this wrong produces an invalid manifest. + +With intents, the caller declares *what is being done* and the Builder handles the rest: + +```py +# Without intents: the caller must manually wire everything up +with Builder({ + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia", + } + ] + }, + } + ], +}) as builder: + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) + +# With intents: the Builder generates the actions automatically +with Builder({}) as builder: + builder.set_intent( + C2paBuilderIntent.CREATE, + C2paDigitalSourceType.TRAINED_ALGORITHMIC_MEDIA, + ) + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +Both produce the same signed manifest, but with intents the Builder validates the setup and fills in the required structure. + +## Setting the intent + +There are three ways to set the intent on a `Builder`. The intent determines which actions the Builder auto-generates at sign time. + +### Using Context (recommended) + +Pass the intent through a `Context` object when creating the `Builder`. This is the recommended approach because it keeps intent configuration alongside other builder settings such as `claim_generator_info` and `thumbnail`: + +```py +from c2pa import Context, Builder + +ctx = Context.from_dict({ + "builder": { + "intent": {"Create": "digitalCapture"}, + "claim_generator_info": {"name": "My App", "version": "1.0.0"}, + } +}) + +with Builder({}, context=ctx) as builder: + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +The same `Context` can be reused across multiple `Builder` instances, ensuring consistent configuration: + +```py +ctx = Context.from_dict({ + "builder": { + "intent": "edit", + "claim_generator_info": {"name": "Batch Editor"}, + } +}) + +for path in image_paths: + with Builder({}, context=ctx) as builder: + builder.sign_file(path, output_path(path), signer) +``` + +For more on `Context` and `Settings`, see [Using Context](context.md). + +### Using `set_intent` on the Builder + +Call `set_intent` directly on a `Builder` instance. This is useful for one-off operations or when the intent needs to be determined at runtime: + +```py +with Builder({}) as builder: + builder.set_intent( + C2paBuilderIntent.CREATE, + C2paDigitalSourceType.TRAINED_ALGORITHMIC_MEDIA, + ) + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +If both a `Context` intent and a `set_intent` call are present, the `set_intent` call takes precedence. + +### Using `load_settings` (deprecated) + +The global `load_settings` function can configure the intent for all subsequent `Builder` instances. This approach is deprecated in favor of `Context`: + +```py +from c2pa import load_settings, Builder + +# Deprecated: sets intent globally +load_settings({"builder": {"intent": "edit"}}) + +with Builder({}) as builder: + with open("original.jpg", "rb") as source, open("edited.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +To migrate from `load_settings` to `Context`, see [Migrating from load_settings](context.md#migrating-from-load_settings). + +### Intent setting precedence + +When an intent is configured in multiple places, the most specific setting wins: + +```mermaid +flowchart TD + Check{Was set_intent called + on the Builder?} + Check --> |Yes| UseSetIntent["Use set_intent value"] + Check --> |No| CheckCtx{Was a Context with + builder.intent provided?} + CheckCtx --> |Yes| UseCtx["Use Context intent"] + CheckCtx --> |No| CheckGlobal{Was load_settings called + with builder.intent?} + CheckGlobal --> |Yes| UseGlobal["Use global intent + (deprecated)"] + CheckGlobal --> |No| NoIntent["No intent set. + Caller must define actions + manually in manifest JSON."] +``` + +## How intents relate to the source stream + +The intent operates on the source stream passed to `sign()`. It does not target a specific ingredient added via `add_ingredient`; it targets the source asset itself. + +The following diagram shows what happens at sign time for each intent: + +```mermaid +flowchart LR + subgraph CREATE + S1[source stream] --> B1[Builder] + B1 --> O1[signed output] + B1 -. adds .-> A1["c2pa.created action + + digital source type"] + end +``` + +```mermaid +flowchart LR + subgraph EDIT + S2[source stream] --> B2[Builder] + B2 --> O2[signed output] + S2 -. auto-created as .-> P2[parentOf ingredient] + P2 --> B2 + B2 -. adds .-> A2["c2pa.opened action + linked to parent"] + end +``` + +```mermaid +flowchart LR + subgraph UPDATE + S3[source stream] --> B3[Builder] + B3 --> O3[signed output] + S3 -. auto-created as .-> P3[parentOf ingredient] + P3 --> B3 + B3 -. adds .-> A3["c2pa.opened action + linked to parent"] + B3 -. restricts .-> R3[content must not change] + end +``` + +For **EDIT** and **UPDATE**, the Builder looks at the source stream, and if no `parentOf` ingredient has been added manually, it automatically creates one from that stream. The source stream *becomes* the parent ingredient. + +If a `parentOf` ingredient has already been added manually (via `add_ingredient`), the Builder uses that one instead and does not auto-create one from the source. + +### How intent relates to `add_ingredient` + +The intent and manually-added ingredients serve different roles. The intent controls what the Builder does with the source stream at sign time. The `add_ingredient` method adds extra ingredients (parent or component) explicitly. + +```mermaid +flowchart TD + Intent["Intent + (via Context, set_intent, + or load_settings)"] --> Q{Intent type?} + Q --> |CREATE| CreateFlow["No parent allowed + Source stream is new content"] + Q --> |EDIT or UPDATE| EditFlow{Was a parentOf ingredient + added via add_ingredient?} + EditFlow --> |No| Auto["Builder auto-creates + parentOf from source stream"] + EditFlow --> |Yes| Manual["Builder uses the + manually-added parent"] + Auto --> Opened["Builder adds c2pa.opened + action linked to parent"] + Manual --> Opened + CreateFlow --> Created["Builder adds c2pa.created + action + digital source type"] + + AddIngredient["add_ingredient()"] --> IngType{relationship?} + IngType --> |parentOf| ParentIng["Overrides auto-parent + for EDIT/UPDATE"] + IngType --> |componentOf| CompIng["Additional ingredient + not affected by intent"] + ParentIng --> EditFlow +``` + +## Import + +```py +from c2pa import ( + Builder, + Reader, + Signer, + Context, + Settings, + C2paSignerInfo, + C2paSigningAlg, + C2paBuilderIntent, + C2paDigitalSourceType, +) +``` + +## Intent types + +| Intent | Operation | Parent ingredient | Auto-generated action | +| -------- | -------------------------- | ----------------------------------------------------- | -------------------------------- | +| `CREATE` | Brand-new content | Must NOT have one | `c2pa.created` | +| `EDIT` | Modifying existing content | Auto-created from the source stream if not provided | `c2pa.opened` (linked to parent) | +| `UPDATE` | Metadata-only changes | Auto-created from the source stream if not provided | `c2pa.opened` (linked to parent) | + +## Choosing the right intent + +```mermaid +flowchart TD + Start([Start]) --> HasParent{Does the asset have + prior history?} + HasParent --> |No| IsNew[Brand-new content] + IsNew --> CREATE["Use CREATE + + C2paDigitalSourceType"] + HasParent --> |Yes| ContentChanged{Will the content + itself change?} + ContentChanged --> |Yes| EDIT[Use EDIT] + ContentChanged --> |No, metadata only| UPDATE[Use UPDATE] + ContentChanged --> |Need full manual control| MANUAL["Skip intents. + Define actions and ingredients + directly in manifest JSON."] +``` + +## CREATE intent + +Use `CREATE` when the asset is a brand-new digital creation with no prior history. The source stream is raw content, not a derivative of something else. + +A `C2paDigitalSourceType` is required to describe how the asset was produced. The Builder will: + +- Add a `c2pa.created` action with the specified digital source type. +- Reject the operation if a `parentOf` ingredient exists (new creations cannot have parents). + +### Common digital source types + +| Source type | When to use | +|-------------|-------------| +| `DIGITAL_CAPTURE` | Photo or video from a camera/device | +| `DIGITAL_CREATION` | Human-created using non-generative tools (e.g., Photoshop drawing) | +| `TRAINED_ALGORITHMIC_MEDIA` | AI-generated from a trained model | +| `COMPOSITE_SYNTHETIC` | Mix of elements with at least one generative AI element | +| `SCREEN_CAPTURE` | Screen recording or screenshot | +| `ALGORITHMIC_MEDIA` | Algorithm-generated without training data | + +See `C2paDigitalSourceType` for the full list. + +### Example: New digital creation + +Using `Context`: + +```py +ctx = Context.from_dict({ + "builder": {"intent": {"Create": "digitalCreation"}} +}) + +with Builder({}, context=ctx) as builder: + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +Using `set_intent`: + +```py +with Builder({}) as builder: + builder.set_intent( + C2paBuilderIntent.CREATE, + C2paDigitalSourceType.DIGITAL_CREATION, + ) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +### Example: Marking AI-generated content + +```py +ctx = Context.from_dict({ + "builder": {"intent": {"Create": "trainedAlgorithmicMedia"}} +}) + +with Builder({}, context=ctx) as builder: + with open("ai_output.jpg", "rb") as source, open("signed_ai_output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +### Example: CREATE with additional manifest metadata + +`Context` and a manifest definition can be combined. The context handles the intent, while the manifest definition provides additional metadata and assertions: + +```py +ctx = Context.from_dict({ + "builder": { + "intent": {"Create": "digitalCapture"}, + "claim_generator_info": {"name": "my_app", "version": "1.0.0"}, + } +}) + +manifest_def = { + "title": "My New Image", + "assertions": [ + { + "label": "cawg.training-mining", + "data": { + "entries": { + "cawg.ai_inference": {"use": "notAllowed"}, + "cawg.ai_generative_training": {"use": "notAllowed"}, + } + }, + } + ], +} + +with Builder(manifest_def, context=ctx) as builder: + with open("photo.jpg", "rb") as source, open("signed_photo.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +## EDIT intent + +Use `EDIT` when modifying an existing asset. The Builder will: + +1. Check if a `parentOf` ingredient has already been added. If not, it **automatically creates one from the source stream** passed to `sign()`. +2. Add a `c2pa.opened` action linked to the parent ingredient. + +No `digital_source_type` parameter is needed. + +### Example: Editing an asset (auto-parent) + +The simplest case: the source stream becomes the parent ingredient automatically. + +Using `Context`: + +```py +ctx = Context.from_dict({"builder": {"intent": "edit"}}) + +with Builder({}, context=ctx) as builder: + with open("original.jpg", "rb") as source, open("edited.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +Using `set_intent`: + +```py +with Builder({}) as builder: + builder.set_intent(C2paBuilderIntent.EDIT) + + # The Builder reads "original.jpg" as the parent ingredient, + # then writes the new manifest into "edited.jpg" + with open("original.jpg", "rb") as source, open("edited.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +The resulting manifest contains: + +- One ingredient with `relationship: "parentOf"` pointing to `original.jpg`. +- A `c2pa.opened` action referencing that ingredient. + +If the source file already has a C2PA manifest, the ingredient preserves the full provenance chain. + +### Example: Editing with a manually-added parent + +To control the parent ingredient (for example, to set a title or use a different source), add it explicitly. The Builder will use that ingredient instead of auto-creating one: + +```py +ctx = Context.from_dict({"builder": {"intent": "edit"}}) + +with Builder({}, context=ctx) as builder: + with open("original.jpg", "rb") as original: + builder.add_ingredient( + {"title": "Original Photo", "relationship": "parentOf"}, + "image/jpeg", + original, + ) + + with open("canvas.jpg", "rb") as source, open("edited.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +### Example: Editing with additional component ingredients + +A parent ingredient can be combined with component ingredients. The intent creates the `c2pa.opened` action for the parent; additional actions can be added for the components: + +```py +ctx = Context.from_dict({"builder": {"intent": "edit"}}) + +with Builder({ + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.placed", + "parameters": {"ingredientIds": ["overlay_label"]}, + } + ] + }, + } + ], +}, context=ctx) as builder: + + # The Builder auto-creates a parent from the source stream + # and generates a c2pa.opened action for it + + # Add a component ingredient manually + with open("overlay.png", "rb") as overlay: + builder.add_ingredient( + { + "title": "overlay.png", + "relationship": "componentOf", + "label": "overlay_label", + }, + "image/png", + overlay, + ) + + with open("original.jpg", "rb") as source, open("composite.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +## UPDATE intent + +Use `UPDATE` for non-editorial changes where the asset content itself is not modified, for example adding or changing metadata. This is a restricted form of `EDIT`: + +- Allows exactly one ingredient (the parent). +- Does not allow changes to the parent's hashed content. +- Produces a more compact manifest than `EDIT`. +- Suitable for metadata-only updates. + +Like `EDIT`, the Builder auto-creates a parent ingredient from the source stream if one is not provided. + +### Example: Adding metadata to a signed asset + +Using `Context`: + +```py +ctx = Context.from_dict({"builder": {"intent": "update"}}) + +with Builder({}, context=ctx) as builder: + with open("signed_asset.jpg", "rb") as source, open("updated_asset.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +Using `set_intent`: + +```py +with Builder({}) as builder: + builder.set_intent(C2paBuilderIntent.UPDATE) + + with open("signed_asset.jpg", "rb") as source, open("updated_asset.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +## Intent values in settings + +When configuring the intent through `Context` or `load_settings`, the intent is specified as a string or object in the `builder.intent` field: + +| Intent | Settings value | With digital source type | +|--------|---------------|--------------------------| +| Create | `{"Create": ""}` | Required. E.g., `{"Create": "digitalCapture"}` | +| Edit | `"edit"` | Not applicable | +| Update | `"update"` | Not applicable | + +Available digital source type values for Create: `"digitalCapture"`, `"digitalCreation"`, `"trainedAlgorithmicMedia"`, `"compositeSynthetic"`, `"screenCapture"`, `"algorithmicMedia"`, and others. + +## API reference + +### `Builder.set_intent(intent, digital_source_type=C2paDigitalSourceType.EMPTY)` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `intent` | `C2paBuilderIntent` | The intent: `CREATE`, `EDIT`, or `UPDATE`. | +| `digital_source_type` | `C2paDigitalSourceType` | Required for `CREATE`. Describes how the asset was made. Defaults to `EMPTY`. | + +**Raises:** `C2paError` if the intent cannot be set (e.g., a `parentOf` ingredient exists with `CREATE`, or no parent can be found for `EDIT`/`UPDATE`). + +## See also + +- [Using Context](context.md) for configuring `Builder` and `Reader` with `Context` and `Settings`. +- [Using settings](settings.md) for the full settings schema reference. +- [Usage](usage.md) for reading and signing with `Reader` and `Builder`. diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md new file mode 100644 index 00000000..b7131a33 --- /dev/null +++ b/docs/selective-manifests.md @@ -0,0 +1,708 @@ +# Selective manifest construction + +You can use `Builder` and `Reader` together to selectively construct manifests—keeping only the parts you need and omitting the rest. This is useful when you don't want to include all ingredients in a working store (for example, when some ingredient assets are not visible). + +This process is best described as *filtering* or *rebuilding* a working store: + +1. Read an existing manifest. +2. Choose which elements to retain. +3. Build a new manifest containing only those elements. + +A manifest is a signed data structure attached to an asset that records provenance and which source assets (ingredients) contributed to it. It contains assertions (statements about the asset), ingredients (references to other assets), and references to binary resources (such as thumbnails). + +Since both `Reader` and `Builder` are **read-only** by design (neither has a `remove()` method), to exclude content you must **read what exists, filter to keep what you need, and create a new** `Builder` **with only that information**. This produces a new `Builder` instance—a "rebuild." + +> [!IMPORTANT] +> This process always creates a new `Builder`. The original signed asset and its manifest are never modified, neither is the starting working store. The `Reader` extracts data without side effects, and the `Builder` constructs a new manifest based on extracted data. + +## Core concepts + +```mermaid +flowchart LR + A[Signed Asset] -->|Reader| B[JSON + Resources] + B -->|Filter| C[Filtered Data] + C -->|new Builder| D[New Builder] + D -->|sign| E[New Asset] +``` + + + +The fundamental workflow is: + +1. **Read** the existing manifest with `Reader` to get JSON and binary resources +2. **Identify and filter** the parts to keep (parse the JSON, select and gather elements) +3. **Create a new `Builder`** with only the selected parts based on the applied filtering rules +4. **Sign** the new `Builder` into the output asset + +## Reading an existing manifest + +Use `Reader` to extract the manifest store JSON and any binary resources (thumbnails, manifest data). The source asset is never modified. + +```py +with open("signed_asset.jpg", "rb") as source: + with Reader("image/jpeg", source) as reader: + # Get the full manifest store as JSON + manifest_store = json.loads(reader.json()) + + # Identify the active manifest, which is the current/latest manifest + active_label = manifest_store["active_manifest"] + manifest = manifest_store["manifests"][active_label] + + # Access specific parts + ingredients = manifest["ingredients"] + assertions = manifest["assertions"] + thumbnail_id = manifest["thumbnail"]["identifier"] +``` + +### Extracting binary resources + +The JSON returned by `reader.json()` only contains string identifiers (JUMBF URIs) for binary data like thumbnails and ingredient manifest stores. Extract the actual binary content by using `resource_to_stream()`: + +```py +# Extract a thumbnail to an in-memory stream +thumb_stream = io.BytesIO() +reader.resource_to_stream(thumbnail_id, thumb_stream) + +# Or extract to a file +with open("thumbnail.jpg", "wb") as f: + reader.resource_to_stream(thumbnail_id, f) +``` + +## Filtering into a new Builder + +Each example below creates a **new `Builder`** from filtered data. The original asset and its manifest store are never modified. + +When transferring ingredients from a `Reader` to a new `Builder`, you must transfer both the JSON metadata and the associated binary resources (thumbnails, manifest data). The JSON contains identifiers that reference those resources; the same identifiers must be used when calling `builder.add_resource()`. + +### Transferring binary resources + +Since ingredients reference binary data (thumbnails, manifest stores), you need to copy those resources from the `Reader` to the new `Builder`. This helper function encapsulates the pattern: + +```py +def transfer_ingredient_resources(reader, builder, ingredients): + """Copy binary resources for a list of ingredients from reader to builder.""" + for ingredient in ingredients: + for key in ("thumbnail", "manifest_data"): + if key in ingredient: + uri = ingredient[key]["identifier"] + buf = io.BytesIO() + reader.resource_to_stream(uri, buf) + buf.seek(0) + builder.add_resource(uri, buf) +``` + +This function is used throughout the examples below. + +### Keep only specific ingredients + +```py +with open("signed_asset.jpg", "rb") as source: + with Reader("image/jpeg", source) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Filter: keep only ingredients with a specific relationship + kept = [ + ing for ing in active["ingredients"] + if ing["relationship"] == "parentOf" + ] + + # Create a new Builder with only the kept ingredients + with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "ingredients": kept, + }) as new_builder: + transfer_ingredient_resources(reader, new_builder, kept) + + # Sign the new Builder into an output asset + source.seek(0) + with open("output.jpg", "wb") as dest: + new_builder.sign(signer, "image/jpeg", source, dest) +``` + +### Keep only specific assertions + +```py +with open("signed_asset.jpg", "rb") as source: + with Reader("image/jpeg", source) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Keep training-mining assertions, filter out everything else + kept = [ + a for a in active["assertions"] + if a["label"] == "cawg.training-mining" + ] + + with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": kept, + }) as new_builder: + source.seek(0) + with open("output.jpg", "wb") as dest: + new_builder.sign(signer, "image/jpeg", source, dest) +``` + +### Start fresh and preserve provenance + +Sometimes all existing assertions and ingredients may need to be discarded but the provenance chain should be maintained nevertheless. This is done by creating a new `Builder` with a new manifest definition and adding the original signed asset as an ingredient using `add_ingredient()`. + +The function `add_ingredient()` does not copy the original's assertions into the new manifest. Instead, it stores the original's entire manifest store as opaque binary data inside the ingredient record. This means: + +- The new manifest has its own, independent set of assertions +- The original's full manifest is preserved inside the ingredient, so validators can inspect the full provenance history +- The provenance chain is unbroken: anyone reading the new asset can follow the ingredient link back to the original + +```mermaid +flowchart TD + subgraph Original["Original Signed Asset"] + OA["Assertions: A, B, C"] + OI["Ingredients: X, Y"] + end + subgraph NewBuilder["New Builder"] + NA["Assertions: (empty or new)"] + NI["Ingredient: original.jpg (contains full original manifest as binary data)"] + end + Original -->|"add_ingredient()"| NI + NI -.->|"validators can trace back"| Original + + style NA fill:#efe,stroke:#090 + style NI fill:#efe,stroke:#090 +``` + + + +```py +with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [], +}) as new_builder: + # Add the original as an ingredient to preserve provenance chain. + # add_ingredient() stores the original's manifest as binary data inside + # the ingredient, but does NOT copy the original's assertions. + with open("original_signed.jpg", "rb") as original: + new_builder.add_ingredient( + {"title": "original.jpg", "relationship": "parentOf"}, + "image/jpeg", + original, + ) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + new_builder.sign(signer, "image/jpeg", source, dest) +``` + +## Adding actions to a working store + +Actions record what was done to an asset (e.g., color adjustments, cropping, placing content). Use `builder.add_action()` to add them to a working store. + +```py +builder.add_action({ + "action": "c2pa.color_adjustments", + "parameters": {"name": "brightnesscontrast"}, +}) + +builder.add_action({ + "action": "c2pa.filtered", + "parameters": {"name": "A filter"}, + "description": "Filtering applied", +}) +``` + +### Action JSON fields + + +| Field | Required | Description | +| --- | --- | --- | +| `action` | Yes | Action identifier, e.g. `"c2pa.created"`, `"c2pa.opened"`, `"c2pa.placed"`, `"c2pa.color_adjustments"`, `"c2pa.filtered"` | +| `parameters` | No | Free-form object with action-specific data (including `ingredientIds` for linking ingredients, for instance) | +| `description` | No | Human-readable description of what happened | +| `digitalSourceType` | Sometimes, depending on action | URI describing the digital source type (typically for `c2pa.created`) | + + +### Linking actions to ingredients + +When an action involves a specific ingredient, the ingredient is linked to the action using `ingredientIds` (in the action's `parameters`), referencing a matching key in the ingredient. + +#### How `ingredientIds` resolution works + +The SDK matches each value in `ingredientIds` against ingredients using this priority: + +1. `label` on the ingredient (primary): if set and non-empty, this is used as the linking key. +2. `instance_id` on the ingredient (fallback): used when `label` is absent or empty. + +#### Linking with `label` + +The `label` field on an ingredient is the **primary** linking key. Set a `label` on the ingredient and reference it in the action's `ingredientIds`. The label can be any string: it acts as a linking key between the ingredient and the action. + +```py +manifest_json = { + "claim_generator_info": [{"name": "an-application", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + }, + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["c2pa.ingredient.v3"] + }, + }, + ] + }, + } + ], +} + +with Builder(manifest_json) as builder: + # The label on the ingredient matches the value in ingredientIds + with open("photo.jpg", "rb") as photo: + builder.add_ingredient( + { + "title": "photo.jpg", + "format": "image/jpeg", + "relationship": "componentOf", + "label": "c2pa.ingredient.v3", + }, + "image/jpeg", + photo, + ) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +##### Linking multiple ingredients + +When linking multiple ingredients, each ingredient needs a unique label. + +> [!NOTE] +> The labels used for linking in the working store may not be the exact labels that appear in the signed manifest. They are indicators for the SDK to know which ingredient to link with which action. The SDK assigns final labels during signing. + +```py +manifest_json = { + "claim_generator_info": [{"name": "an-application", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + "parameters": { + "ingredientIds": ["c2pa.ingredient.v3_1"] + }, + }, + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["c2pa.ingredient.v3_2"] + }, + }, + ] + }, + } + ], +} + +with Builder(manifest_json) as builder: + # parentOf ingredient linked to c2pa.opened + with open("original.jpg", "rb") as original: + builder.add_ingredient( + { + "title": "original.jpg", + "format": "image/jpeg", + "relationship": "parentOf", + "label": "c2pa.ingredient.v3_1", + }, + "image/jpeg", + original, + ) + + # componentOf ingredient linked to c2pa.placed + with open("overlay.jpg", "rb") as overlay: + builder.add_ingredient( + { + "title": "overlay.jpg", + "format": "image/jpeg", + "relationship": "componentOf", + "label": "c2pa.ingredient.v3_2", + }, + "image/jpeg", + overlay, + ) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +#### Linking with `instance_id` + +When no `label` is set on an ingredient, the SDK matches `ingredientIds` against `instance_id`. + +```py +# instance_id is used as the linking identifier and must be unique +instance_id = "xmp:iid:939a4c48-0dff-44ec-8f95-61f52b11618f" + +manifest_json = { + "claim_generator_info": [{"name": "an-application", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "parameters": { + "ingredientIds": [instance_id] + }, + } + ] + }, + } + ], +} + +with Builder(manifest_json) as builder: + # No label set: instance_id is used as the linking key + with open("source_photo.jpg", "rb") as photo: + builder.add_ingredient( + { + "title": "source_photo.jpg", + "relationship": "parentOf", + "instance_id": instance_id, + }, + "image/jpeg", + photo, + ) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +> [!NOTE] +> The `instance_id` can be read back from the ingredient JSON after signing. + +#### Reading linked ingredients + +After signing, `ingredientIds` is gone. The action's `parameters.ingredients[]` contains hashed JUMBF URIs pointing to ingredient assertions. To match an action to its ingredient, extract the label from the URL: + +```py +with open("signed_asset.jpg", "rb") as signed: + with Reader("image/jpeg", signed) as reader: + manifest_store = json.loads(reader.json()) + active_label = manifest_store["active_manifest"] + manifest = manifest_store["manifests"][active_label] + + # Build a map: label -> ingredient + label_to_ingredient = { + ing["label"]: ing for ing in manifest["ingredients"] + } + + # Match each action to its ingredients by extracting labels from URLs + for assertion in manifest["assertions"]: + if assertion["label"] != "c2pa.actions.v2": + continue + for action in assertion["data"]["actions"]: + for ref in action.get("parameters", {}).get("ingredients", []): + label = ref["url"].rsplit("/", 1)[-1] + matched = label_to_ingredient.get(label) + # matched is the ingredient linked to this action +``` + +#### When to use `label` vs `instance_id` + +| Property | `label` | `instance_id` | +| --- | --- | --- | +| **Who controls it** | Caller (any string) | Caller (any string, or from XMP metadata) | +| **Priority for linking** | Primary: checked first | Fallback: used when label is absent/empty | +| **When to use** | JSON-defined manifests where the caller controls the ingredient definition | Programmatic workflows using XMP-based IDs | +| **Survives signing** | SDK may reassign the actual assertion label | Unchanged | +| **Stable across rebuilds** | The caller controls the build-time value; the post-signing label may change | Yes, always the same set value | + + +**Use `label`** when defining manifests in JSON. +**Use `instance_id`** when working programmatically with ingredients whose identity comes from other sources, or when a stable identifier that persists unchanged across rebuilds is needed. + +## Working with archives + +A `Builder` represents a **working store**: a manifest that is being assembled but has not yet been signed. Archives serialize this working store (definition + resources) to a `.c2pa` binary format, allowing to save, transfer, or resume the work later. For more background on working stores and archives, see [Working stores](https://opensource.contentauthenticity.org/docs/rust-sdk/docs/working-stores). + +There are two distinct types of archives, sharing the same binary format but being conceptually different: builder archives (working store archives) and ingredient archives. + +### Builder archives vs. ingredient archives + +A **builder archive** (also called a working store archive) is a serialized snapshot of a `Builder`. It contains the manifest definition, all resources, and any ingredients that were added. It is created by `builder.to_archive()` and restored with `Builder.from_archive()`. + +An **ingredient archive** contains the manifest store from an asset that was added as an ingredient. + +The key difference: a builder archive is a work-in-progress (unsigned). An ingredient archive carries the provenance history of a source asset for reuse as an ingredient in other working stores. + +### The ingredients catalog pattern + +An **ingredients catalog** is a collection of archived ingredients that can be selected when constructing a final manifest. Each archive holds ingredients; at build time the caller selects only the ones needed. + +```mermaid +flowchart TD + subgraph Catalog["Ingredients Catalog (archived)"] + A1["Archive: photos.c2pa (ingredients from photo shoot)"] + A2["Archive: graphics.c2pa (ingredients from design assets)"] + A3["Archive: audio.c2pa (ingredients from audio tracks)"] + end + subgraph Build["Final Builder"] + direction TB + SEL["Pick and choose ingredients from any archive in the catalog"] + FB["New Builder with selected ingredients only"] + end + A1 -->|"select photo_1, photo_3"| SEL + A2 -->|"select logo"| SEL + A3 -. "skip (not needed)" .-> X((not used)) + SEL --> FB + FB -->|sign| OUT[Signed Output Asset] + + style A3 fill:#eee,stroke:#999 + style X fill:#f99,stroke:#c00 +``` + + + +```py +# Read from a catalog of archived ingredients +archive_stream.seek(0) +with Reader("application/c2pa", archive_stream) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Pick only the needed ingredients + selected = [ + ing for ing in active["ingredients"] + if ing["title"] in {"photo_1.jpg", "logo.png"} + ] + + with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "ingredients": selected, + }) as new_builder: + transfer_ingredient_resources(reader, new_builder, selected) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + new_builder.sign(signer, "image/jpeg", source, dest) +``` + +### Overriding ingredient properties + +When adding an ingredient from an archive or from a file, the JSON passed to `add_ingredient()` can override properties like `title` and `relationship`. This is useful when reusing archived ingredients in a different context: + +```py +with open("signed_asset.jpg", "rb") as signed: + builder.add_ingredient( + { + "title": "my-custom-title.jpg", + "relationship": "parentOf", + "instance_id": "my-tracking-id:asset-example-id", + }, + "image/jpeg", + signed, + ) +``` + +The `title`, `relationship`, and `instance_id` fields in the provided JSON take priority. The library fills in the rest (thumbnail, manifest_data, format) from the source. This works with signed assets, `.c2pa` archives, or unsigned files. + +### Using custom vendor parameters in actions + +The C2PA specification allows **vendor-namespaced parameters** on actions using reverse domain notation. These parameters survive signing and can be read back, useful for tagging actions with IDs that support filtering. + +```py +manifest_json = { + "claim_generator_info": [{"name": "an-application", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeCapture", + "parameters": { + "com.mycompany.tool": "my-editor", + "com.mycompany.session_id": "session-abc-123", + }, + }, + { + "action": "c2pa.placed", + "description": "Placed an image", + "parameters": { + "com.mycompany.layer_id": "layer-42", + "ingredientIds": ["c2pa.ingredient.v3"], + }, + }, + ] + }, + } + ], +} +``` + +After signing, these custom parameters appear alongside the standard fields: + +```json +{ + "action": "c2pa.placed", + "parameters": { + "com.mycompany.layer_id": "layer-42", + "ingredients": [{"url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"}] + } +} +``` + +Custom vendor parameters can be used to filter actions. For example, to find all actions related to a specific layer: + +```py +layer_actions = [ + action for action in actions + if action.get("parameters", {}).get("com.mycompany.layer_id") == "layer-42" +] +``` + +> **Naming convention:** Vendor parameters must use reverse domain notation with period-separated components (e.g., `com.mycompany.tool`, `net.example.session_id`). Some namespaces (e.g., `c2pa` or `cawg`) may be reserved. + +### Extracting ingredients from a working store + +An example workflow is to build up a working store with multiple ingredients, archive it, and then later extract specific ingredients from that archive to use in a new working store. + +```mermaid +flowchart TD + subgraph Step1["Step 1: Build a working store with ingredients"] + IA["add_ingredient(A.jpg)"] --> B1[Builder] + IB["add_ingredient(B.jpg)"] --> B1 + B1 -->|"to_archive()"| AR["archive.c2pa"] + end + subgraph Step2["Step 2: Extract ingredients from archive"] + AR -->|"Reader(application/c2pa)"| RD[JSON + resources] + RD -->|"pick ingredients"| SEL[Selected ingredients] + end + subgraph Step3["Step 3: Reuse in a new Builder"] + SEL -->|"new Builder + add_resource()"| B2[New Builder] + B2 -->|sign| OUT[Signed Output] + end +``` + + + +**Step 1:** Build a working store and archive it: + +```py +with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], +}) as builder: + # Add ingredients to the working store + with open("A.jpg", "rb") as ing_a: + builder.add_ingredient( + {"title": "A.jpg", "relationship": "componentOf"}, + "image/jpeg", + ing_a, + ) + + with open("B.jpg", "rb") as ing_b: + builder.add_ingredient( + {"title": "B.jpg", "relationship": "componentOf"}, + "image/jpeg", + ing_b, + ) + + # Save the working store as an archive + archive_stream = io.BytesIO() + builder.to_archive(archive_stream) +``` + +**Step 2:** Read the archive and extract ingredients: + +```py +archive_stream.seek(0) +with Reader("application/c2pa", archive_stream) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + ingredients = active["ingredients"] +``` + +**Step 3:** Create a new Builder with the extracted ingredients: + +```py + # Pick the desired ingredients + selected = [ing for ing in ingredients if ing["title"] == "A.jpg"] + + with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "ingredients": selected, + }) as new_builder: + transfer_ingredient_resources(reader, new_builder, selected) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + new_builder.sign(signer, "image/jpeg", source, dest) +``` + +### Merging multiple working stores + +In some cases you may need to merge ingredients from multiple working stores (builder archives) into a single `Builder`. This should be a **fallback strategy**—the recommended practice is to maintain a single active working store and add ingredients incrementally (archived ingredient catalogs help with this). Merging is available when multiple working stores must be consolidated. + +When merging from multiple sources, resource identifier URIs can collide. Rename identifiers with a unique suffix when needed. Use two passes: (1) collect ingredients with collision handling, build the manifest, create the builder; (2) re-read each archive and transfer resources (use original ID for `resource_to_stream()`, renamed ID for `add_resource()` when collisions occurred). + +```py +used_ids: set[str] = set() +suffix_counter = 0 +all_ingredients = [] +archive_ingredient_counts = [] + +# Pass 1: Collect ingredients, renaming IDs on collision +for archive_stream in archives: + archive_stream.seek(0) + with Reader("application/c2pa", archive_stream) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + ingredients = active["ingredients"] + + for ingredient in ingredients: + for key in ("thumbnail", "manifest_data"): + if key not in ingredient: + continue + uri = ingredient[key]["identifier"] + if uri in used_ids: + suffix_counter += 1 + ingredient[key]["identifier"] = f"{uri}__{suffix_counter}" + used_ids.add(ingredient[key]["identifier"]) + all_ingredients.append(ingredient) + + archive_ingredient_counts.append(len(ingredients)) + +with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "ingredients": all_ingredients, +}) as builder: + # Pass 2: Transfer resources (match by ingredient index) + offset = 0 + for archive_stream, count in zip(archives, archive_ingredient_counts): + archive_stream.seek(0) + with Reader("application/c2pa", archive_stream) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + originals = active["ingredients"] + + for original, merged in zip(originals, all_ingredients[offset:offset + count]): + for key in ("thumbnail", "manifest_data"): + if key not in original: + continue + buf = io.BytesIO() + reader.resource_to_stream(original[key]["identifier"], buf) + buf.seek(0) + builder.add_resource(merged[key]["identifier"], buf) + + offset += count + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 00000000..89f8c859 --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,449 @@ +# Using settings + +You can configure SDK settings using a JSON format that controls many aspects of the library's behavior. +The settings JSON format is the same across all languages for the C2PA SDKs (Rust, C/C++, Python, and so on). + +This document describes how to use settings in Python. The Settings schema is the same as the [Rust library](https://github.com/contentauth/c2pa-rs); for the complete JSON schema, see the [Settings reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/). + +## Using settings with Context + +The recommended approach is to pass settings to a `Context` object and then use the `Context` with `Reader` and `Builder`. This gives you explicit, isolated configuration with no global state. For details on creating and using contexts, see [Using Context to configure the SDK](context.md). + +**Legacy approach:** The deprecated `load_settings()` function sets global settings. Don't use that approach; instead pass a `Context` (with settings) to `Reader` and `Builder`. See [Using Context with Reader](context.md#configuring-reader) and [Using Context with Builder](context.md#configuring-builder). + +## Settings API + +Create and configure settings: + +| Method | Description | +|--------|-------------| +| `Settings()` | Create default settings with SDK defaults. | +| `Settings.from_json(json_str)` | Create settings from a JSON string. Raises `C2paError` on parse error. | +| `Settings.from_dict(config)` | Create settings from a Python dictionary. | +| `set(path, value)` | Set a single value by dot-separated path (e.g. `"verify.verify_after_sign"`). Value must be a string. Returns `self` for chaining. Use this for programmatic configuration. | +| `update(data, format="json")` | Merge JSON configuration into existing settings. `data` can be a JSON string or a dict. Later keys override earlier ones. Use this to apply configuration files or JSON strings. Only `"json"` format is supported. | +| `settings["path"] = "value"` | Dict-like setter. Equivalent to `set(path, value)`. | +| `is_valid` | Property that returns `True` if the object holds valid resources (not closed). | +| `close()` | Release native resources. Called automatically when used as a context manager. | + +**Important notes:** + +- The `set()` and `update()` methods can be chained for sequential configuration. +- When using multiple configuration methods, later calls override earlier ones (last wins). +- Use the `with` statement for automatic resource cleanup. +- Only JSON format is supported for settings in the Python SDK. + +```py +from c2pa import Settings + +# Create with defaults +settings = Settings() + +# Set individual values by dot-notation path +settings.set("builder.thumbnail.enabled", "false") + +# Method chaining +settings.set("builder.thumbnail.enabled", "false").set("verify.verify_after_sign", "true") + +# Dict-like access +settings["builder.thumbnail.enabled"] = "false" + +# Create from JSON string +settings = Settings.from_json('{"builder": {"thumbnail": {"enabled": false}}}') + +# Create from a dictionary +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) + +# Merge additional configuration +settings.update({"verify": {"remote_manifest_fetch": True}}) +``` + +## Overview of the Settings structure + +The Settings JSON has this top-level structure: + +```json +{ + "version": 1, + "trust": { ... }, + "cawg_trust": { ... }, + "core": { ... }, + "verify": { ... }, + "builder": { ... }, + "signer": { ... }, + "cawg_x509_signer": { ... } +} +``` + +### Settings format + +Settings are provided in **JSON** only. Pass JSON strings to `Settings.from_json()` or dictionaries to `Settings.from_dict()`. + +```py +# From JSON string +settings = Settings.from_json('{"verify": {"verify_after_sign": true}}') + +# From dict +settings = Settings.from_dict({"verify": {"verify_after_sign": True}}) + +# Context from JSON string +ctx = Context.from_json('{"verify": {"verify_after_sign": true}}') + +# Context from dict +ctx = Context.from_dict({"verify": {"verify_after_sign": True}}) +``` + +To load from a file, read the file contents and pass them to `Settings.from_json()`: + +```py +import json + +with open("config/settings.json", "r") as f: + settings = Settings.from_json(f.read()) +``` + +## Default configuration + +The settings JSON schema — including the complete default configuration with all properties and their default values — is shared with all languages in the SDK: + +```json +{ + "version": 1, + "builder": { + "claim_generator_info": null, + "created_assertion_labels": null, + "certificate_status_fetch": null, + "certificate_status_should_override": null, + "generate_c2pa_archive": true, + "intent": null, + "actions": { + "all_actions_included": null, + "templates": null, + "actions": null, + "auto_created_action": { + "enabled": true, + "source_type": "empty" + }, + "auto_opened_action": { + "enabled": true, + "source_type": null + }, + "auto_placed_action": { + "enabled": true, + "source_type": null + } + }, + "thumbnail": { + "enabled": true, + "ignore_errors": true, + "long_edge": 1024, + "format": null, + "prefer_smallest_format": true, + "quality": "medium" + } + }, + "cawg_trust": { + "verify_trust_list": true, + "user_anchors": null, + "trust_anchors": null, + "trust_config": null, + "allowed_list": null + }, + "cawg_x509_signer": null, + "core": { + "merkle_tree_chunk_size_in_kb": null, + "merkle_tree_max_proofs": 5, + "backing_store_memory_threshold_in_mb": 512, + "decode_identity_assertions": true, + "allowed_network_hosts": null + }, + "signer": null, + "trust": { + "user_anchors": null, + "trust_anchors": null, + "trust_config": null, + "allowed_list": null + }, + "verify": { + "verify_after_reading": true, + "verify_after_sign": true, + "verify_trust": true, + "verify_timestamp_trust": true, + "ocsp_fetch": false, + "remote_manifest_fetch": true, + "skip_ingredient_conflict_resolution": false, + "strict_v1_validation": false + } +} +``` + +## Overview of Settings + +For a complete reference to all the Settings properties, see the [SDK object reference - Settings](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema). + +| Property | Description | +|----------|-------------| +| `version` | Settings format version (integer). The default and only supported value is 1. | +| [`builder`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#buildersettings) | Configuration for Builder. | +| [`cawg_trust`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#trust) | Configuration for CAWG trust lists. | +| [`cawg_x509_signer`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#signersettings) | Configuration for the CAWG x.509 signer. | +| [`core`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#core) | Configuration for core features. | +| [`signer`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#signersettings) | Configuration for the base C2PA signer. | +| [`trust`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#trust) | Configuration for C2PA trust lists. | +| [`verify`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#verify) | Configuration for verification (validation). | + +The top-level `version` property must be `1`. All other properties are optional. + +For Boolean values, use JSON Booleans `true` and `false` in JSON strings, or Python `True` and `False` when using `from_dict()` or `update()` with a dict. + +> [!IMPORTANT] +> If you don't specify a value for a property, the SDK uses the default value. If you specify a value of `null` (or `None` in a dict), the property is explicitly set to `null`, not the default. This distinction is important when you want to override a default behavior. + +### Trust configuration + +The [`trust` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#trust) control which certificates are trusted when validating C2PA manifests. + +- Using `user_anchors`: recommended for development +- Using `allowed_list` (bypass chain validation) + +| Property | Type | Description | Default | +|----------|------|-------------|---------| +| `trust.allowed_list` | string | Explicitly allowed certificates (PEM format). These certificates are trusted regardless of chain validation. Use for development/testing. | — | +| `trust.trust_anchors` | string | Default trust anchor root certificates (PEM format). **Replaces** the SDK's built-in trust anchors entirely. | — | +| `trust.trust_config` | string | Allowed Extended Key Usage (EKU) OIDs. Controls which certificate purposes are accepted (e.g., document signing: `1.3.6.1.4.1.311.76.59.1.9`). | — | +| `trust.user_anchors` | string | Additional user-provided root certificates (PEM format). Adds custom certificate authorities without replacing the SDK's built-in trust anchors. | — | + +When using self-signed certificates or custom certificate authorities during development, you need to configure trust settings so the SDK can validate your test signatures. + +#### Using `user_anchors` + +For development, you can add your test root CA to the trusted anchors without replacing the SDK's default trust store. +For example: + +```py +with open("test-ca.pem", "r") as f: + test_root_ca = f.read() + +ctx = Context.from_dict({ + "trust": { + "user_anchors": test_root_ca + } +}) + +reader = Reader("signed_asset.jpg", context=ctx) +``` + +#### Using `allowed_list` + +To bypass chain validation, for quick testing, explicitly allow a specific certificate without validating the chain. +For example: + +```py +with open("test_cert.pem", "r") as f: + test_cert = f.read() + +settings = Settings() +settings.update({ + "trust": { + "allowed_list": test_cert + } +}) + +ctx = Context(settings=settings) +reader = Reader("signed_asset.jpg", context=ctx) +``` + +### CAWG trust configuration + +The `cawg_trust` properties configure CAWG (Creator Assertions Working Group) validation of identity assertions in C2PA manifests. The `cawg_trust` object has the same properties as [`trust`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#trust). + +> [!NOTE] +> CAWG trust settings are only used when processing identity assertions with X.509 certificates. If your workflow doesn't use CAWG identity assertions, these settings have no effect. + +### Core + +The [`core` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#core) specify core SDK behavior and performance tuning options. + +Use cases: + +- **Performance tuning for large files**: Set `core.backing_store_memory_threshold_in_mb` to `2048` or higher if processing large video files with sufficient RAM. +- **Restricted network environments**: Set `core.allowed_network_hosts` to limit which domains the SDK can contact. + +### Verify + +The [`verify` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#verify) specify how the SDK validates C2PA manifests. These settings affect both reading existing manifests and verifying newly signed content. + +Common use cases include: + +- [Offline or air-gapped environments](#offline-or-air-gapped-environments). +- [Fast development iteration](#fast-development-iteration) with verification disabled. +- [Strict validation](#strict-validation) for certification or compliance testing. + +By default, the following `verify` properties are `true`, which enables verification: + +- `remote_manifest_fetch` - Fetch remote manifests referenced in the asset. Disable in offline or air-gapped environments. +- `verify_after_reading` - Automatically verify manifests when reading assets. Disable only if you want to manually control verification timing. +- `verify_after_sign` - Automatically verify manifests after signing. Recommended to keep enabled to catch signing errors immediately. +- `verify_timestamp_trust` - Verify timestamp authority (TSA) certificates. WARNING: Disabling this setting makes verification non-compliant. +- `verify_trust` - Verify signing certificates against configured trust anchors. WARNING: Disabling this setting makes verification non-compliant. + +> [!WARNING] +> Disabling verification options (changing `true` to `false`) can make verification non-compliant with the C2PA specification. Only modify these settings in controlled environments or when you have specific requirements. + +#### Offline or air-gapped environments + +Set `remote_manifest_fetch` and `ocsp_fetch` to `false` to disable network-dependent verification features: + +```py +ctx = Context.from_dict({ + "verify": { + "remote_manifest_fetch": False, + "ocsp_fetch": False + } +}) + +reader = Reader("signed_asset.jpg", context=ctx) +``` + +See also [Using Context with Reader](context.md#configuring-reader). + +#### Fast development iteration + +During active development, you can disable verification for faster iteration: + +```py +# WARNING: Only use during development, not in production! +settings = Settings() +settings.set("verify.verify_after_reading", "false") +settings.set("verify.verify_after_sign", "false") + +dev_ctx = Context(settings=settings) +``` + +#### Strict validation + +For certification or compliance testing, enable strict validation: + +```py +ctx = Context.from_dict({ + "verify": { + "strict_v1_validation": True, + "ocsp_fetch": True, + "verify_trust": True, + "verify_timestamp_trust": True + } +}) + +reader = Reader("asset_to_validate.jpg", context=ctx) +validation_result = reader.json() +``` + +### Builder + +The [`builder` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#buildersettings) control how the SDK creates and embeds C2PA manifests in assets. + +#### Claim generator information + +The `claim_generator_info` object identifies your application in the C2PA manifest. **Recommended fields:** + +- `name` (string, required): Your application name (e.g., `"My Photo Editor"`) +- `version` (string, recommended): Application version (e.g., `"2.1.0"`) +- `icon` (string, optional): Icon in C2PA format +- `operating_system` (string, optional): OS identifier or `"auto"` to auto-detect + +**Example:** + +```py +ctx = Context.from_dict({ + "builder": { + "claim_generator_info": { + "name": "My Photo Editor", + "version": "2.1.0", + "operating_system": "auto" + } + } +}) +``` + +#### Thumbnail settings + +The [`builder.thumbnail`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#thumbnailsettings) properties control automatic thumbnail generation. + +For examples of configuring thumbnails for mobile bandwidth or disabling them for batch processing, see [Controlling thumbnail generation](context.md#controlling-thumbnail-generation). + +#### Action tracking settings + +| Property | Type | Description | Default | +|----------|------|-------------|---------| +| `builder.actions.auto_created_action.enabled` | Boolean | Automatically add a `c2pa.created` action when creating new content. | `true` | +| `builder.actions.auto_created_action.source_type` | string | Source type for the created action. Usually `"empty"` for new content. | `"empty"` | +| `builder.actions.auto_opened_action.enabled` | Boolean | Automatically add a `c2pa.opened` action when opening/reading content. | `true` | +| `builder.actions.auto_placed_action.enabled` | Boolean | Automatically add a `c2pa.placed` action when placing content as an ingredient. | `true` | + +#### Other builder settings + +| Property | Type | Description | Default | +|----------|------|-------------|---------| +| `builder.intent` | object | Claim intent: `{"Create": "digitalCapture"}`, `{"Edit": null}`, or `{"Update": null}`. Describes the purpose of the claim. | `null` | +| `builder.generate_c2pa_archive` | Boolean | Generate content in C2PA archive format. Keep enabled for standard C2PA compliance. | `true` | + +##### Setting Builder intent + +You can use `Context` to set `Builder` intent for different workflows. + +For example, for original digital capture (photos from camera): + +```py +camera_ctx = Context.from_dict({ + "builder": { + "intent": {"Create": "digitalCapture"}, + "claim_generator_info": {"name": "Camera App", "version": "1.0"} + } +}) +``` + +Or for editing existing content: + +```py +editor_ctx = Context.from_dict({ + "builder": { + "intent": {"Edit": None}, + "claim_generator_info": {"name": "Photo Editor", "version": "2.0"} + } +}) +``` + +### Signer + +The [`signer` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signersettings) configure the primary C2PA signer configuration. Set it to `null` if you provide the signer at runtime, or configure as either a **local** or **remote** signer in settings. + +> [!NOTE] +> The typical approach in Python is to create a `Signer` object with `Signer.from_info()` and pass it directly to `Builder.sign()`. Alternatively, pass a `Signer` to `Context` for the signer-on-context pattern. See [Configuring a signer](context.md#configuring-a-signer) for details. + +#### Local signer + +Use a local signer when you have direct access to the private key and certificate. +For information on all `signer.local` properties, see [signer.local](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signerlocal) in the SDK object reference. + +#### Remote signer + +Use a remote signer when the private key is stored on a secure signing service (HSM, cloud KMS, and so on). +For information on all `signer.remote` properties, see [signer.remote](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signerremote) in the SDK object reference. + +### CAWG X.509 signer configuration + +The `cawg_x509_signer` property specifies configuration for identity assertions. This has the same structure as `signer` (can be local or remote). + +**When to use:** If you need to sign identity assertions separately from the main C2PA claim. When both `signer` and `cawg_x509_signer` are configured, the SDK uses a dual signer: + +- Main claim signature comes from `signer` +- Identity assertions are signed with `cawg_x509_signer` + +For additional JSON configuration examples (minimal configuration, local/remote signer, development/production configurations), see the [Rust SDK settings examples](https://github.com/contentauth/c2pa-rs/blob/main/docs/settings.md#examples). + +## See also + +- [Using Context to configure the SDK](context.md): how to create and use contexts with settings. +- [Usage](usage.md): reading and signing with `Reader` and `Builder`. +- [Rust SDK settings](https://github.com/contentauth/c2pa-rs/blob/main/docs/settings.md): the shared settings schema, default configuration, and JSON examples (language-independent). +- [CAI settings schema reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/): full schema reference. diff --git a/docs/working-stores.md b/docs/working-stores.md new file mode 100644 index 00000000..801816dd --- /dev/null +++ b/docs/working-stores.md @@ -0,0 +1,648 @@ +# Manifests, working stores, and archives + +This table summarizes the fundamental entities that you work with when using the CAI SDK. + +| Object | Description | Where it is | Primary API | +|--------|-------------|-------------|-------------| +| [**Manifest store**](#manifest-store) | Final signed provenance data. Contains one or more manifests. | Embedded in asset or remotely in cloud | `Reader` class | +| [**Working store**](#working-store) | Editable in-progress manifest. | `Builder` object | `Builder` class | +| [**Archive**](#archive) | Serialized working store | `.c2pa` file/stream | `Builder.to_archive()` / `Builder.from_archive()` | +| [**Resources**](#working-with-resources) | Binary assets referenced by manifest assertions, such as thumbnails or ingredient thumbnails. | In manifest. | `Builder.add_resource()` / `Reader.resource_to_stream()` | +| [**Ingredients**](#working-with-ingredients) | Source materials used to create an asset. | In manifest. | `Builder.add_ingredient()` | + +This diagram summarizes the relationships among these entities. + +```mermaid +graph TD + subgraph MS["Manifest Store"] + subgraph M1["Manifests"] + R1[Resources] + I1[Ingredients] + end + end + + A[Working Store
Builder object] -->|sign| MS + A -->|to_archive| C[C2PA Archive
.c2pa file] + C -->|from_archive| A +``` + +## Key entities + +### Manifest store + +A _manifest store_ is the data structure that's embedded in (or attached to) a signed asset. It contains one or more manifests that contain provenance data and cryptographic signatures. + +**Characteristics:** + +- Final, immutable signed data embedded in or attached to an asset. +- Contains one or more manifests (identified by URIs). +- Has exactly one `active_manifest` property pointing to the most recent manifest. +- Read it by using a `Reader` object. + +**Example:** When you open a signed JPEG file, the C2PA data embedded in it is the manifest store. + +For more information, see: + +- [Reading manifest stores from assets](#reading-manifest-stores-from-assets) +- [Creating and signing manifests](#creating-and-signing-manifests) +- [Embedded vs external manifests](#embedded-vs-external-manifests) + +### Working store + +A _working store_ is a `Builder` object representing an editable, in-progress manifest that has not yet been signed and bound to an asset. Think of it as a manifest in progress, or a manifest being built. + +**Characteristics:** + +- Editable, mutable state in memory (a Builder object). +- Contains claims, ingredients, and assertions that can be modified. +- Can be saved to a C2PA archive (`.c2pa` JUMBF binary format) for later use. + +**Example:** When you create a `Builder` object and add assertions to it, you're dealing with a working store, as it is an "in progress" manifest being built. + +For more information, see [Using Working stores](#using-working-stores). + +### Archive + +A _C2PA archive_ (or just _archive_) contains the serialized bytes of a working store saved to a file or stream (typically a `.c2pa` file). It uses the standard JUMBF `application/c2pa` format. + +**Characteristics:** + +- Portable serialization of a working store (Builder). +- Save an archive by using `Builder.to_archive()` and restore a full working store from an archive by using `Builder.from_archive()`. +- Useful for separating manifest preparation ("work in progress") from final signing. + +For more information, see [Working with archives](#working-with-archives). + +## Reading manifest stores from assets + +Use the `Reader` class to read manifest stores from signed assets. + +### Reading from a file + +```py +from c2pa import Reader + +try: + # Create a Reader from a signed asset file + reader = Reader("signed_image.jpg") + + # Get the manifest store as JSON + manifest_store_json = reader.json() +except Exception as e: + print(f"C2PA Error: {e}") +``` + +### Reading from a stream + +```py +with open("signed_image.jpg", "rb") as stream: + # Create Reader from stream with MIME type + reader = Reader("image/jpeg", stream) + manifest_json = reader.json() +``` + +### Using Context for configuration + +For more control over validation and trust settings, use a `Context`: + +```py +from c2pa import Context, Reader + +# Create context with custom validation settings +ctx = Context.from_dict({ + "verify": { + "verify_after_sign": True + } +}) + +# Use context when creating Reader +reader = Reader("signed_image.jpg", context=ctx) +manifest_json = reader.json() +``` + +## Using working stores + +A **working store** is represented by a `Builder` object. It contains "live" manifest data as you add information to it. + +### Creating a working store + +```py +import json +from c2pa import Builder, Context + +# Create a working store with a manifest definition +manifest_json = json.dumps({ + "claim_generator_info": [{ + "name": "example-app", + "version": "0.1.0" + }], + "title": "Example asset", + "assertions": [] +}) + +builder = Builder(manifest_json) + +# Or with custom context +ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": True} + } +}) +builder = Builder(manifest_json, context=ctx) +``` + +### Modifying a working store + +Before signing, you can modify the working store (Builder): + +```py +import io + +# Add binary resources (like thumbnails) +with open("thumbnail.jpg", "rb") as thumb: + builder.add_resource("thumbnail", thumb) + +# Add ingredients (source files) +ingredient_json = json.dumps({ + "title": "Original asset", + "relationship": "parentOf" +}) +with open("source.jpg", "rb") as ingredient: + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) + +# Add actions +action_json = { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" +} +builder.add_action(action_json) + +# Configure embedding behavior +builder.set_no_embed() # Don't embed manifest in asset +builder.set_remote_url("https://example.com/manifests/") +``` + +### From working store to manifest store + +When you sign an asset, the working store (Builder) becomes a manifest store embedded in the output: + +```py +from c2pa import Signer, C2paSignerInfo, C2paSigningAlg + +# Create a signer +signer_info = C2paSignerInfo( + alg=C2paSigningAlg.ES256, + sign_cert=certs, + private_key=private_key, + ta_url=b"http://timestamp.digicert.com" +) +signer = Signer.from_info(signer_info) + +# Sign the asset - working store becomes a manifest store +with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + +# Now "signed.jpg" contains a manifest store +# You can read it back with Reader +reader = Reader("signed.jpg") +manifest_store_json = reader.json() +``` + +## Creating and signing manifests + +### Creating a Builder (working store) + +```py +# Create with manifest definition +builder = Builder(manifest_json) + +# Or with custom context +ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": True} + } +}) +builder = Builder(manifest_json, context=ctx) +``` + +### Creating a Signer + +For testing, create a `Signer` with certificates and private key: + +```py +from c2pa import Signer, C2paSignerInfo, C2paSigningAlg + +# Load credentials +with open("certs.pem", "rb") as f: + certs = f.read() +with open("private_key.pem", "rb") as f: + private_key = f.read() + +# Create signer +signer_info = C2paSignerInfo( + alg=C2paSigningAlg.ES256, # ES256, ES384, ES512, PS256, PS384, PS512, ED25519 + sign_cert=certs, # Certificate chain in PEM format + private_key=private_key, # Private key in PEM format + ta_url=b"http://timestamp.digicert.com" # Optional timestamp authority URL +) +signer = Signer.from_info(signer_info) +``` + +**WARNING**: Never hard-code or directly access private keys in production. Use a Hardware Security Module (HSM) or Key Management Service (KMS). + +### Signing an asset + +```py +try: + # Sign using streams + with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + manifest_bytes = builder.sign(signer, "image/jpeg", src, dst) + + print("Signed successfully!") + +except Exception as e: + print(f"Signing failed: {e}") +``` + +### Signing with file paths + +You can also sign using file paths directly: + +```py +# Sign using file paths (uses native Rust file I/O for better performance) +manifest_bytes = builder.sign_file( + "source.jpg", "signed.jpg", signer +) +``` + +### Complete example + +This code combines the above examples to create, sign, and read a manifest. + +```py +import json +from c2pa import Builder, Reader, Signer, C2paSignerInfo, C2paSigningAlg + +try: + # 1. Define manifest for working store + manifest_json = json.dumps({ + "claim_generator_info": [{"name": "demo-app", "version": "0.1.0"}], + "title": "Signed image", + "assertions": [] + }) + + # 2. Load credentials + with open("certs.pem", "rb") as f: + certs = f.read() + with open("private_key.pem", "rb") as f: + private_key = f.read() + + # 3. Create signer + signer_info = C2paSignerInfo( + alg=C2paSigningAlg.ES256, + sign_cert=certs, + private_key=private_key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + # 4. Create working store (Builder) and sign + builder = Builder(manifest_json) + with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + + print("Asset signed - working store is now a manifest store") + + # 5. Read back the manifest store + reader = Reader("signed.jpg") + print(reader.json()) + +except Exception as e: + print(f"Error: {e}") +``` + +## Working with resources + +_Resources_ are binary assets referenced by manifest assertions, such as thumbnails or ingredient thumbnails. + +### Understanding resource identifiers + +When you add a resource to a working store (Builder), you assign it an identifier string. When the manifest store is created during signing, the SDK automatically converts this to a proper JUMBF URI. + +**Resource identifier workflow:** + +```mermaid +graph LR + A[Simple identifier
'thumbnail'] -->|add_resource| B[Working Store
Builder] + B -->|sign| C[JUMBF URI
'self#jumbf=...'] + C --> D[Manifest Store
in asset] +``` + +1. **During manifest creation**: You use a string identifier (e.g., `"thumbnail"`, `"thumbnail1"`). +2. **During signing**: The SDK converts these to JUMBF URIs (e.g., `"self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg"`). +3. **After signing**: The manifest store contains the full JUMBF URI that you use to extract the resource. + +### Extracting resources from a manifest store + +To extract a resource, you need its JUMBF URI from the manifest store: + +```py +import json + +reader = Reader("signed_image.jpg") +manifest_store = json.loads(reader.json()) + +# Get active manifest +active_uri = manifest_store["active_manifest"] +manifest = manifest_store["manifests"][active_uri] + +# Extract thumbnail if it exists +if "thumbnail" in manifest: + # The identifier is the JUMBF URI + thumbnail_uri = manifest["thumbnail"]["identifier"] + # Example: "self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg" + + # Extract to a stream + with open("thumbnail.jpg", "wb") as f: + reader.resource_to_stream(thumbnail_uri, f) + print("Thumbnail extracted") +``` + +### Adding resources to a working store + +When building a manifest, you add resources using identifiers. The SDK will reference these in your manifest JSON and convert them to JUMBF URIs during signing. + +```py +builder = Builder(manifest_json) + +# Add resource from a stream +with open("thumbnail.jpg", "rb") as thumb: + builder.add_resource("thumbnail", thumb) + +# Sign: the "thumbnail" identifier becomes a JUMBF URI in the manifest store +with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +## Working with ingredients + +Ingredients represent source materials used to create an asset, preserving the provenance chain. Ingredients themselves can be turned into ingredient archives (`.c2pa`). + +An ingredient archive is a serialized `Builder` with _exactly one_ ingredient. Once archived with only one ingredient, the Builder archive is an ingredient archive. Such ingredient archives can be used as ingredient in other working stores. + +### Adding ingredients to a working store + +When creating a manifest, add ingredients to preserve the provenance chain: + +```py +builder = Builder(manifest_json) + +# Define ingredient metadata +ingredient_json = json.dumps({ + "title": "Original asset", + "relationship": "parentOf" +}) + +# Add ingredient from a stream +with open("source.jpg", "rb") as ingredient: + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) + +# Or add ingredient from a file path +builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", "source.jpg") + +# Sign: ingredients become part of the manifest store +with open("new_asset.jpg", "rb") as src, open("signed_asset.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +### Ingredient relationships + +Specify the relationship between the ingredient and the current asset: + +| Relationship | Meaning | +|--------------|---------| +| `parentOf` | The ingredient is a direct parent of this asset | +| `componentOf` | The ingredient is a component used in this asset | +| `inputTo` | The ingredient was an input to creating this asset | + +Example with explicit relationship: + +```py +ingredient_json = json.dumps({ + "title": "Base layer", + "relationship": "componentOf" +}) + +with open("base_layer.png", "rb") as ingredient: + builder.add_ingredient(ingredient_json, "image/png", ingredient) +``` + +## Working with archives + +An _archive_ (C2PA archive) is a serialized working store (`Builder` object) saved to a stream. + +Using archives provides these advantages: + +- **Save work-in-progress**: Persist a working store between sessions. +- **Separate creation from signing**: Prepare manifests on one machine, sign on another. +- **Share manifests**: Transfer working stores between systems. +- **Offline preparation**: Build manifests offline, sign them later. + +The default binary format of an archive is the **C2PA JUMBF binary format** (`application/c2pa`), which is the standard way to save and restore working stores. + +### Saving a working store to archive + +```py +import io + +# Create and configure a working store +builder = Builder(manifest_json) +with open("thumbnail.jpg", "rb") as thumb: + builder.add_resource("thumbnail", thumb) +with open("source.jpg", "rb") as ingredient: + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) + +# Save working store to archive stream +archive = io.BytesIO() +builder.to_archive(archive) + +# Or save to a file +with open("manifest.c2pa", "wb") as f: + archive.seek(0) + f.write(archive.read()) + +print("Working store saved to archive") +``` + +A Builder containing **only one ingredient and only the ingredient data** (no other ingredient, no other actions) is an ingredient archive. Ingredient archives can be added directly as ingredient to other working stores too. + +### Restoring a working store from archive + +Create a new `Builder` (working store) from an archive: + +```py +# Restore from stream +with open("manifest.c2pa", "rb") as archive: + builder = Builder.from_archive(archive) + +# Now you can sign with the restored working store +with open("asset.jpg", "rb") as src, open("signed_asset.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +### Restoring with context preservation + +Pass a `context` to `from_archive()` to preserve custom settings: + +```py +# Create context with custom settings +ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } +}) + +# Load archive with context +with open("manifest.c2pa", "rb") as archive: + builder = Builder.from_archive(archive, context=ctx) + +# The builder has the archived manifest but keeps the custom context +with open("asset.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +### Two-phase workflow example + +#### Phase 1: Prepare manifest + +```py +import io +import json + +manifest_json = json.dumps({ + "title": "Artwork draft", + "assertions": [] +}) + +builder = Builder(manifest_json) +with open("thumb.jpg", "rb") as thumb: + builder.add_resource("thumbnail", thumb) +with open("sketch.png", "rb") as sketch: + builder.add_ingredient( + json.dumps({"title": "Sketch"}), "image/png", sketch + ) + +# Save working store as archive +with open("artwork_manifest.c2pa", "wb") as f: + builder.to_archive(f) + +print("Working store saved to artwork_manifest.c2pa") +``` + +#### Phase 2: Sign the asset + +```py +# Restore the working store +with open("artwork_manifest.c2pa", "rb") as archive: + builder = Builder.from_archive(archive) + +# Sign +with open("artwork.jpg", "rb") as src, open("signed_artwork.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + +print("Asset signed with manifest store") +``` + +## Embedded vs external manifests + +By default, manifest stores are **embedded** directly into the asset file. You can also use **external** or **remote** manifest stores. + +### Default: embedded manifest stores + +```py +builder = Builder(manifest_json) + +# Default behavior: manifest store is embedded in the output +with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + +# Read it back — manifest store is embedded +reader = Reader("signed.jpg") +``` + +### External manifest stores (no embed) + +Prevent embedding the manifest store in the asset: + +```py +builder = Builder(manifest_json) +builder.set_no_embed() # Don't embed the manifest store + +# Sign: manifest store is NOT embedded, manifest bytes are returned +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + manifest_bytes = builder.sign(signer, "image/jpeg", src, dst) + +# manifest_bytes contains the manifest store +# Save it separately (as a sidecar file or upload to server) +with open("output.c2pa", "wb") as f: + f.write(manifest_bytes) + +print("Manifest store saved externally to output.c2pa") +``` + +### Remote manifest stores + +Reference a manifest store stored at a remote URL: + +```py +builder = Builder(manifest_json) +builder.set_remote_url("https://example.com/manifests/") + +# The asset will contain a reference to the remote manifest store +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +## Best practices + +### Use Context for configuration + +Always use `Context` objects for SDK configuration: + +```py +ctx = Context.from_dict({ + "verify": { + "verify_after_sign": True + }, + "trust": { + "user_anchors": trust_anchors_pem + } +}) + +builder = Builder(manifest_json, context=ctx) +reader = Reader("asset.jpg", context=ctx) +``` + +### Use ingredients to build provenance chains + +Add ingredients to your manifests to maintain a clear provenance chain: + +```py +ingredient_json = json.dumps({ + "title": "Original source", + "relationship": "parentOf" +}) + +with open("original.jpg", "rb") as ingredient: + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) + +with open("edited.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +## Additional resources + +- [Manifest reference](https://opensource.contentauthenticity.org/docs/manifest/manifest-ref) +- [X.509 certificates](https://opensource.contentauthenticity.org/docs/c2patool/x_509) +- [Trust lists](https://opensource.contentauthenticity.org/docs/conformance/trust-lists/) +- [CAWG identity](https://cawg.io/identity/) diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 00000000..8cbaec31 --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,764 @@ +# Copyright 2023 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +""" +Tests for documentation examples. +These tests verify that all code examples in the docs/ folder work correctly. +""" + +import os +import io +import json +import unittest +import warnings + +warnings.filterwarnings("ignore", category=DeprecationWarning) + +from c2pa import ( + Builder, + Reader, + Signer, + C2paSignerInfo, + C2paBuilderIntent, + C2paDigitalSourceType, +) + +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") + +ACTIONS_LABELS = {"c2pa.actions", "c2pa.actions.v2"} + + +def transfer_ingredient_resources(reader, builder, ingredients): + """Copy binary resources for a list of ingredients from reader to builder.""" + for ingredient in ingredients: + for key in ("thumbnail", "manifest_data"): + if key in ingredient: + uri = ingredient[key]["identifier"] + buf = io.BytesIO() + reader.resource_to_stream(uri, buf) + buf.seek(0) + builder.add_resource(uri, buf) + + +def get_active_manifest(manifest_store): + """Return the active manifest from a parsed manifest store.""" + return manifest_store["manifests"][manifest_store["active_manifest"]] + + +def find_actions(manifest): + """Return the actions list from a manifest's assertions, or None.""" + for assertion in manifest["assertions"]: + if assertion["label"] in ACTIONS_LABELS: + return assertion["data"]["actions"] + return None + + +class BaseDocTest(unittest.TestCase): + """Base class with shared setUp for doc tests.""" + + def setUp(self): + with open(os.path.join(FIXTURES_DIR, "es256_certs.pem"), "rb") as f: + self.certs = f.read() + with open(os.path.join(FIXTURES_DIR, "es256_private.key"), "rb") as f: + self.key = f.read() + + self.signer = Signer.from_info(C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com", + )) + + # A.jpg has no existing manifest + self.source_file = os.path.join(FIXTURES_DIR, "A.jpg") + # C.jpg has an existing manifest + self.signed_file = os.path.join(FIXTURES_DIR, "C.jpg") + # cloud.jpg - another unsigned file for ingredient use + self.ingredient_file = os.path.join(FIXTURES_DIR, "cloud.jpg") + + def _sign_to_buffer(self, builder, source_path=None): + """Sign a builder using source_path (defaults to self.source_file), return BytesIO at position 0.""" + path = source_path or self.source_file + with open(path, "rb") as source: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", source, output) + output.seek(0) + return output + + def _sign_and_read(self, builder, source_path=None): + """Sign and return the parsed active manifest.""" + output = self._sign_to_buffer(builder, source_path) + with Reader("image/jpeg", output) as reader: + return get_active_manifest(json.loads(reader.json())) + + def _create_signed_asset(self): + """Helper: create a signed JPEG asset in memory.""" + with Builder({ + "claim_generator_info": [{"name": "test_app", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + } + ] + }, + } + ], + }) as builder: + return self._sign_to_buffer(builder) + + +# ============================================================ +# Intent docs tests (docs/tbd_intents.md.md) +# ============================================================ + +class TestIntentDocs(BaseDocTest): + """Tests for intent documentation examples.""" + + def test_create_intent_digital_creation(self): + """Example: Creating a brand-new digital asset with CREATE intent.""" + with Builder({}) as builder: + builder.set_intent( + C2paBuilderIntent.CREATE, + C2paDigitalSourceType.DIGITAL_CREATION, + ) + active = self._sign_and_read(builder) + + actions = find_actions(active) + self.assertIsNotNone(actions) + + created = [a for a in actions if a["action"] == "c2pa.created"] + self.assertEqual(len(created), 1) + self.assertEqual(len(active.get("ingredients", [])), 0) + + def test_create_intent_trained_algorithmic_media(self): + """Example: Marking AI-generated content with CREATE intent.""" + with Builder({}) as builder: + builder.set_intent( + C2paBuilderIntent.CREATE, + C2paDigitalSourceType.TRAINED_ALGORITHMIC_MEDIA, + ) + active = self._sign_and_read(builder) + + actions = find_actions(active) + created = [a for a in actions if a["action"] == "c2pa.created"] + self.assertEqual(len(created), 1) + self.assertIn("digitalSourceType", created[0]) + self.assertIn("trainedAlgorithmicMedia", created[0]["digitalSourceType"]) + + def test_create_intent_with_manifest_definition(self): + """Example: CREATE intent with additional manifest metadata.""" + manifest_def = { + "claim_generator_info": [{"name": "my_app", "version": "1.0.0"}], + "title": "My New Image", + "assertions": [ + { + "label": "cawg.training-mining", + "data": { + "entries": { + "cawg.ai_inference": {"use": "notAllowed"}, + "cawg.ai_generative_training": {"use": "notAllowed"}, + } + }, + } + ], + } + + with Builder(manifest_def) as builder: + builder.set_intent( + C2paBuilderIntent.CREATE, + C2paDigitalSourceType.DIGITAL_CAPTURE, + ) + active = self._sign_and_read(builder) + + self.assertEqual(active["title"], "My New Image") + + def test_edit_intent(self): + """Example: Editing an existing asset with EDIT intent.""" + with Builder({}) as builder: + builder.set_intent(C2paBuilderIntent.EDIT) + active = self._sign_and_read(builder) + + ingredients = active.get("ingredients", []) + self.assertEqual(len(ingredients), 1) + self.assertEqual(ingredients[0]["relationship"], "parentOf") + + actions = find_actions(active) + opened = [a for a in actions if a["action"] == "c2pa.opened"] + self.assertEqual(len(opened), 1) + self.assertIn("ingredients", opened[0].get("parameters", {})) + + def test_edit_intent_with_manual_parent(self): + """Example: Editing with a manually-added parent ingredient.""" + with Builder({}) as builder: + builder.set_intent(C2paBuilderIntent.EDIT) + + # Manually add a parent instead of letting the Builder auto-create one + with open(self.signed_file, "rb") as original: + builder.add_ingredient( + {"title": "Original Photo", "relationship": "parentOf"}, + "image/jpeg", + original, + ) + + # Sign using a different source (the parent was added manually) + active = self._sign_and_read(builder) + + ingredients = active.get("ingredients", []) + self.assertEqual(len(ingredients), 1) + self.assertEqual(ingredients[0]["relationship"], "parentOf") + self.assertEqual(ingredients[0]["title"], "Original Photo") + + actions = find_actions(active) + opened = [a for a in actions if a["action"] == "c2pa.opened"] + self.assertEqual(len(opened), 1) + + def test_edit_intent_with_component_ingredients(self): + """Example: EDIT intent with auto-parent plus component ingredients.""" + with Builder({ + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.placed", + "parameters": {"ingredientIds": ["overlay_label"]}, + } + ] + }, + } + ], + }) as builder: + builder.set_intent(C2paBuilderIntent.EDIT) + + # Add a component ingredient manually + with open(self.ingredient_file, "rb") as overlay: + builder.add_ingredient( + { + "title": "overlay.png", + "relationship": "componentOf", + "label": "overlay_label", + }, + "image/jpeg", + overlay, + ) + + # The Builder auto-creates a parent from the source stream + active = self._sign_and_read(builder) + + ingredients = active.get("ingredients", []) + relationships = {ing["relationship"] for ing in ingredients} + self.assertIn("parentOf", relationships) + self.assertIn("componentOf", relationships) + + actions = find_actions(active) + # Intent auto-added c2pa.opened for the parent + opened = [a for a in actions if a["action"] == "c2pa.opened"] + self.assertEqual(len(opened), 1) + # Our manual c2pa.placed for the component + placed = [a for a in actions if a["action"] == "c2pa.placed"] + self.assertEqual(len(placed), 1) + + def test_edit_intent_with_existing_manifest(self): + """Example: Editing a file that already has a C2PA manifest.""" + with Builder({}) as builder: + builder.set_intent(C2paBuilderIntent.EDIT) + output = self._sign_to_buffer(builder, self.signed_file) + + with Reader("image/jpeg", output) as reader: + manifest_store = json.loads(reader.json()) + + self.assertGreater(len(manifest_store["manifests"]), 1) + + active = get_active_manifest(manifest_store) + ingredients = active.get("ingredients", []) + self.assertEqual(len(ingredients), 1) + self.assertEqual(ingredients[0]["relationship"], "parentOf") + + def test_update_intent(self): + """Example: Non-editorial update with UPDATE intent.""" + initial_output = self._create_signed_asset() + + with Builder({}) as builder: + builder.set_intent(C2paBuilderIntent.UPDATE) + + final_output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", initial_output, final_output) + + final_output.seek(0) + with Reader("image/jpeg", final_output) as reader: + active = get_active_manifest(json.loads(reader.json())) + + ingredients = active.get("ingredients", []) + self.assertEqual(len(ingredients), 1) + self.assertEqual(ingredients[0]["relationship"], "parentOf") + + +# ============================================================ +# Selective manifest docs tests (docs/tbd_selective-manifests.md) +# ============================================================ + +class TestSelectiveManifestDocs(BaseDocTest): + """Tests for selective manifest documentation examples.""" + + def test_reading_existing_manifest(self): + """Example: Reading an existing manifest with Reader.""" + with open(self.signed_file, "rb") as source: + with Reader("image/jpeg", source) as reader: + manifest_store = json.loads(reader.json()) + manifest = get_active_manifest(manifest_store) + + assertions = manifest["assertions"] + self.assertIsInstance(assertions, list) + self.assertGreater(len(assertions), 0) + + def test_extracting_binary_resources(self): + """Example: Extracting binary resources (thumbnail) from a manifest.""" + with open(self.signed_file, "rb") as source: + with Reader("image/jpeg", source) as reader: + manifest = get_active_manifest(json.loads(reader.json())) + + if "thumbnail" in manifest: + thumbnail_id = manifest["thumbnail"]["identifier"] + thumb_stream = io.BytesIO() + reader.resource_to_stream(thumbnail_id, thumb_stream) + self.assertGreater(thumb_stream.tell(), 0) + + def test_keep_only_specific_ingredients(self): + """Example: Filtering ingredients to keep only parentOf.""" + # First create an asset with multiple ingredients + with Builder({ + "claim_generator_info": [{"name": "test_app", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "parameters": {"ingredientIds": ["parent_label"]}, + }, + { + "action": "c2pa.placed", + "parameters": {"ingredientIds": ["component_label"]}, + }, + ] + }, + } + ], + }) as builder: + with open(self.signed_file, "rb") as parent: + builder.add_ingredient( + {"title": "parent.jpg", "relationship": "parentOf", "label": "parent_label"}, + "image/jpeg", + parent, + ) + + with open(self.ingredient_file, "rb") as component: + builder.add_ingredient( + {"title": "overlay.jpg", "relationship": "componentOf", "label": "component_label"}, + "image/jpeg", + component, + ) + + multi_output = self._sign_to_buffer(builder) + + # Now read it back and filter to keep only parentOf ingredients + with Reader("image/jpeg", multi_output) as reader: + manifest_store = json.loads(reader.json()) + active = get_active_manifest(manifest_store) + + kept = [ + ing for ing in active["ingredients"] + if ing["relationship"] == "parentOf" + ] + self.assertEqual(len(kept), 1) + + with Builder({ + "claim_generator_info": [{"name": "test_app", "version": "1.0"}], + "ingredients": kept, + }) as new_builder: + transfer_ingredient_resources(reader, new_builder, kept) + + multi_output.seek(0) + final_output = io.BytesIO(bytearray()) + new_builder.sign(self.signer, "image/jpeg", multi_output, final_output) + + # Verify only parentOf ingredient remains + final_output.seek(0) + with Reader("image/jpeg", final_output) as reader: + final_active = get_active_manifest(json.loads(reader.json())) + self.assertTrue( + all(ing["relationship"] == "parentOf" for ing in final_active.get("ingredients", [])) + ) + + def test_keep_only_specific_assertions(self): + """Example: Filtering assertions to keep only training-mining.""" + with Builder({ + "claim_generator_info": [{"name": "test_app", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + } + ] + }, + }, + { + "label": "cawg.training-mining", + "data": { + "entries": { + "cawg.ai_inference": {"use": "notAllowed"}, + "cawg.ai_generative_training": {"use": "notAllowed"}, + } + }, + }, + ], + }) as builder: + signed_output = self._sign_to_buffer(builder) + + # Read it back and filter assertions + with Reader("image/jpeg", signed_output) as reader: + active = get_active_manifest(json.loads(reader.json())) + + kept = [a for a in active["assertions"] if a["label"] == "cawg.training-mining"] + + with Builder({ + "claim_generator_info": [{"name": "test_app", "version": "1.0"}], + "assertions": kept, + }) as new_builder: + signed_output.seek(0) + final_output = io.BytesIO(bytearray()) + new_builder.sign(self.signer, "image/jpeg", signed_output, final_output) + + final_output.seek(0) + with Reader("image/jpeg", final_output) as reader: + final_active = get_active_manifest(json.loads(reader.json())) + labels = [a["label"] for a in final_active["assertions"]] + self.assertIn("cawg.training-mining", labels) + + def test_preserve_provenance_with_add_ingredient(self): + """Example: Starting fresh while preserving provenance chain.""" + with Builder({ + "claim_generator_info": [{"name": "test_app", "version": "1.0"}], + "assertions": [], + }) as new_builder: + with open(self.signed_file, "rb") as original: + new_builder.add_ingredient( + {"title": "original.jpg", "relationship": "parentOf"}, + "image/jpeg", + original, + ) + + active = self._sign_and_read(new_builder) + + ingredients = active.get("ingredients", []) + self.assertEqual(len(ingredients), 1) + self.assertEqual(ingredients[0]["relationship"], "parentOf") + + def test_adding_actions(self): + """Example: Adding actions to a builder using add_action.""" + with Builder({ + "claim_generator_info": [{"name": "test_app", "version": "1.0"}], + }) as builder: + builder.add_action({ + "action": "c2pa.color_adjustments", + "parameters": {"name": "brightnesscontrast"}, + }) + builder.add_action({ + "action": "c2pa.filtered", + "parameters": {"name": "A filter"}, + "description": "Filtering applied", + }) + + active = self._sign_and_read(builder) + + actions = find_actions(active) + self.assertIsNotNone(actions) + action_types = {a["action"] for a in actions} + self.assertIn("c2pa.color_adjustments", action_types) + self.assertIn("c2pa.filtered", action_types) + + def test_linking_action_to_ingredient_with_label(self): + """Example: Linking an action to an ingredient using label.""" + with Builder({ + "claim_generator_info": [{"name": "test_app", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + }, + { + "action": "c2pa.placed", + "parameters": {"ingredientIds": ["c2pa.ingredient.v3"]}, + }, + ] + }, + } + ], + }) as builder: + with open(self.ingredient_file, "rb") as photo: + builder.add_ingredient( + { + "title": "photo.jpg", + "format": "image/jpeg", + "relationship": "componentOf", + "label": "c2pa.ingredient.v3", + }, + "image/jpeg", + photo, + ) + + active = self._sign_and_read(builder) + + self.assertGreater(len(active.get("ingredients", [])), 0) + + actions = find_actions(active) + placed = [a for a in actions if a["action"] == "c2pa.placed"] + self.assertEqual(len(placed), 1) + self.assertIn("ingredients", placed[0].get("parameters", {})) + + def test_linking_multiple_ingredients(self): + """Example: Linking multiple ingredients to different actions.""" + with Builder({ + "claim_generator_info": [{"name": "test_app", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + "parameters": {"ingredientIds": ["c2pa.ingredient.v3_1"]}, + }, + { + "action": "c2pa.placed", + "parameters": {"ingredientIds": ["c2pa.ingredient.v3_2"]}, + }, + ] + }, + } + ], + }) as builder: + with open(self.signed_file, "rb") as original: + builder.add_ingredient( + { + "title": "original.jpg", + "format": "image/jpeg", + "relationship": "parentOf", + "label": "c2pa.ingredient.v3_1", + }, + "image/jpeg", + original, + ) + + with open(self.ingredient_file, "rb") as overlay: + builder.add_ingredient( + { + "title": "overlay.jpg", + "format": "image/jpeg", + "relationship": "componentOf", + "label": "c2pa.ingredient.v3_2", + }, + "image/jpeg", + overlay, + ) + + active = self._sign_and_read(builder) + + ingredients = active.get("ingredients", []) + self.assertEqual(len(ingredients), 2) + relationships = {ing["relationship"] for ing in ingredients} + self.assertEqual(relationships, {"parentOf", "componentOf"}) + + def test_reading_linked_ingredients_after_signing(self): + """Example: Reading back linked ingredients from a signed manifest.""" + with Builder({ + "claim_generator_info": [{"name": "test_app", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.placed", + "parameters": {"ingredientIds": ["c2pa.ingredient.v3"]}, + } + ] + }, + } + ], + }) as builder: + with open(self.ingredient_file, "rb") as photo: + builder.add_ingredient( + { + "title": "photo.jpg", + "relationship": "componentOf", + "label": "c2pa.ingredient.v3", + }, + "image/jpeg", + photo, + ) + + output = self._sign_to_buffer(builder) + + # Read back and match actions to ingredients + with Reader("image/jpeg", output) as reader: + manifest = get_active_manifest(json.loads(reader.json())) + + label_to_ingredient = { + ing["label"]: ing for ing in manifest["ingredients"] + } + + for assertion in manifest["assertions"]: + if assertion["label"] != "c2pa.actions.v2": + continue + for action in assertion["data"]["actions"]: + for ref in action.get("parameters", {}).get("ingredients", []): + label = ref["url"].rsplit("/", 1)[-1] + self.assertIn(label, label_to_ingredient) + + def test_custom_vendor_parameters_in_actions(self): + """Example: Using vendor-namespaced parameters in actions.""" + with Builder({ + "claim_generator_info": [{"name": "test_app", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeCapture", + "parameters": { + "com.mycompany.tool": "my-editor", + "com.mycompany.session_id": "session-abc-123", + }, + }, + { + "action": "c2pa.placed", + "description": "Placed an image", + "parameters": { + "com.mycompany.layer_id": "layer-42", + "ingredientIds": ["c2pa.ingredient.v3"], + }, + }, + ] + }, + } + ], + }) as builder: + with open(self.ingredient_file, "rb") as photo: + builder.add_ingredient( + { + "title": "photo.jpg", + "relationship": "componentOf", + "label": "c2pa.ingredient.v3", + }, + "image/jpeg", + photo, + ) + + active = self._sign_and_read(builder) + + actions = find_actions(active) + placed = [a for a in actions if a["action"] == "c2pa.placed"] + self.assertEqual(len(placed), 1) + self.assertEqual(placed[0]["parameters"]["com.mycompany.layer_id"], "layer-42") + + def test_archive_round_trip(self): + """Example: Saving and restoring a Builder via archive.""" + # Step 1: Build a working store and archive it + with Builder({ + "claim_generator_info": [{"name": "test_app", "version": "1.0"}], + }) as builder: + with open(self.source_file, "rb") as ing_a: + builder.add_ingredient( + {"title": "A.jpg", "relationship": "componentOf"}, + "image/jpeg", + ing_a, + ) + + with open(self.ingredient_file, "rb") as ing_b: + builder.add_ingredient( + {"title": "B.jpg", "relationship": "componentOf"}, + "image/jpeg", + ing_b, + ) + + archive_stream = io.BytesIO() + builder.to_archive(archive_stream) + self.assertGreater(archive_stream.tell(), 0) + + # Step 2: Read the archive and pick specific ingredients + archive_stream.seek(0) + with Reader("application/c2pa", archive_stream) as reader: + active = get_active_manifest(json.loads(reader.json())) + ingredients = active["ingredients"] + self.assertEqual(len(ingredients), 2) + + # Step 3: Create a new Builder with only selected ingredients + selected = [ing for ing in ingredients if ing["title"] == "A.jpg"] + self.assertEqual(len(selected), 1) + + with Builder({ + "claim_generator_info": [{"name": "test_app", "version": "1.0"}], + "ingredients": selected, + }) as new_builder: + transfer_ingredient_resources(reader, new_builder, selected) + + final_active = self._sign_and_read(new_builder) + + self.assertEqual(len(final_active.get("ingredients", [])), 1) + + def test_override_ingredient_properties(self): + """Example: Overriding ingredient properties when adding.""" + with Builder({ + "claim_generator_info": [{"name": "test_app", "version": "1.0"}], + }) as builder: + with open(self.signed_file, "rb") as signed: + builder.add_ingredient( + { + "title": "my-custom-title.jpg", + "relationship": "parentOf", + "instance_id": "my-tracking-id:asset-example-id", + }, + "image/jpeg", + signed, + ) + + active = self._sign_and_read(builder) + + ingredients = active.get("ingredients", []) + self.assertEqual(len(ingredients), 1) + self.assertEqual(ingredients[0]["title"], "my-custom-title.jpg") + self.assertEqual(ingredients[0]["relationship"], "parentOf") + self.assertEqual(ingredients[0]["instance_id"], "my-tracking-id:asset-example-id") + + +if __name__ == "__main__": + unittest.main()