ruby-c2pa
A Ruby gem for signing and reading C2PA content credentials in media files. Built on top of the official c2pa-rs Rust library via a native extension.
What is C2PA?
C2PA (Coalition for Content Provenance and Authenticity) is an open technical standard for attaching cryptographically signed provenance metadata to media files. It lets you prove:
- Who created or edited a file
- What tools were used
- When and where it was created
- Whether the content has been tampered with since signing
It is backed by Adobe, Microsoft, Google, the BBC, and others, and is increasingly required by platforms and publishers to establish trust in digital media — particularly in an era of AI-generated content.
Why Rust bindings?
The C2PA specification is complex and security-sensitive. The reference implementation is c2pa-rs, an official Rust library maintained by the Content Authenticity Initiative. Rather than re-implementing the specification in Ruby (which would risk diverging from the spec or introducing security bugs), this gem wraps c2pa-rs directly.
The binding layer is a native Ruby extension written in Rust using magnus, which compiles directly into a .bundle/.so that Ruby loads like any other native extension. This means:
- Correctness — you get the reference implementation, not a reimplementation
- Security — cryptographic signing and manifest validation are handled by audited Rust code
- Performance — signing large video files happens in native code with no Ruby overhead
- Spec compliance — as c2pa-rs is updated to track the spec, you get those updates by bumping the Rust dependency
Requirements
- Ruby >= 3.0
- Rust and Cargo (to compile the native library)
- OpenSSL (usually already present on macOS and Linux)
Installing Rust
The compilation happens automatically during gem install, but Rust must be present on your system first.
The recommended way is via mise, which can manage both Ruby and Rust in one place:
mise use --global rust@latest
Or via the official rustup installer:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Installation
Add to your Gemfile:
gem "ruby-c2pa"
Then run:
bundle install
The native Rust library is compiled automatically during installation. This takes a few minutes the first time as it downloads and compiles the c2pa-rs dependency tree.
Preparing your certificate and key
C2PA signing requires an X.509 certificate chain and private key in PEM format. The certificate must chain to a CA that is trusted by the C2PA ecosystem — self-signed certificates are rejected by c2pa-rs.
Development and testing
The c2pa-rs project publishes test certificates that work for local development:
curl -sL -o test_cert.pem https://raw.githubusercontent.com/contentauth/c2pa-rs/main/sdk/tests/fixtures/certs/es256.pub
curl -sL -o test_key.pem https://raw.githubusercontent.com/contentauth/c2pa-rs/main/sdk/tests/fixtures/certs/es256.pem
Files signed with these test certificates will include a signingCredential.untrusted validation warning since the test CA is not in the public trust list, but are otherwise valid for development purposes.
Production
Obtain a certificate from a CA trusted by the C2PA ecosystem. The certificate file must contain the full chain (end-entity certificate first, then any intermediates), but must not include the root CA.
The supported signing algorithms are: es256, es384, es512, ps256, ps384, ps512, ed25519.
Usage
Building a manifest
Every signed file requires a C2PA::Manifest with at least one action. Actions describe what happened to the asset and are drawn from the C2PA::Actions constants, which cover the full vocabulary defined in the C2PA specification.
require "c2pa"
manifest = C2PA::Manifest.new(title: "Sunset over the bay")
manifest.add_action(
C2PA::Actions::CREATED,
digital_source_type: C2PA::DigitalSourceTypes::DIGITAL_CAPTURE
)
c2pa.created must declare how the asset came into being. c2pa-rs rejects a
manifest without it, and accepts any string you supply — so a wrong value
validates while asserting something untrue. The gem therefore requires you to
choose rather than defaulting on your behalf:
| Constant | Use for |
|---|---|
DIGITAL_CAPTURE |
a camera original |
TRAINED_ALGORITHMIC_MEDIA |
generative AI output |
COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA |
edited using generative AI |
SCREEN_CAPTURE |
a screenshot |
HUMAN_EDITS |
human-edited media |
UNSPECIFIED |
the origin is genuinely unknown |
C2PA::DigitalSourceTypes::ALL lists them all. Reach for UNSPECIFIED when you
do not know — it is what c2pa-rs uses in its own fixtures, and it is honest in a
way that guessing is not.
This requirement arrived in c2pa-rs 0.90. Manifests signed by releases before 0.3.0 omit the field and are rejected by current verifiers.
Actions can be chained:
manifest = C2PA::Manifest.new(title: "Sunset over the bay")
.add_action(C2PA::Actions::CREATED,
digital_source_type: C2PA::DigitalSourceTypes::DIGITAL_CAPTURE)
.add_action(C2PA::Actions::PUBLISHED)
Editing an existing asset
When the file you are signing derives from another one, declare the intent
rather than adding c2pa.opened yourself:
manifest = C2PA::Manifest.new(title: "Edited photo", intent: :edit)
.add_action(C2PA::Actions::EDITED)
.add_action(C2PA::Actions::PUBLISHED)
c2pa-rs derives the parent ingredient from the source file and adds a
c2pa.opened action tied to it, so the signed manifest records
c2pa.opened, c2pa.edited, c2pa.published and a parentOf ingredient.
c2pa.opened cannot be added by hand. The specification requires it to
reference its parent ingredient by hashed URI, and that hash is computed over
the ingredient as c2pa-rs serialises it — so add_action(C2PA::Actions::OPENED)
raises and points here. Earlier releases of this gem documented adding it
directly; manifests built that way never validated.
Each action accepts optional fields from the C2PA specification:
manifest.add_action(
C2PA::Actions::CREATED,
when_time: "2026-03-17T10:00:00Z", # ISO 8601 timestamp
software_agent: "Acme Editor/2.0", # defaults to "ruby-c2pa/<version>"
digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
changed: ["region_of_interest"],
parameters: { "description" => "Generated by AI" }
)
Available actions
All actions defined in the C2PA specification are available as constants on C2PA::Actions:
| Constant | Value |
|---|---|
C2PA::Actions::CREATED |
c2pa.created |
C2PA::Actions::OPENED |
c2pa.opened |
C2PA::Actions::EDITED |
c2pa.edited |
C2PA::Actions::EDITED_METADATA |
c2pa.edited.metadata |
C2PA::Actions::ADJUSTED_COLOR |
c2pa.adjustedColor |
C2PA::Actions::CHANGED_SPEED |
c2pa.changedSpeed |
C2PA::Actions::CONVERTED |
c2pa.converted |
C2PA::Actions::CROPPED |
c2pa.cropped |
C2PA::Actions::DELETED |
c2pa.deleted |
C2PA::Actions::DRAWING |
c2pa.drawing |
C2PA::Actions::DUBBED |
c2pa.dubbed |
C2PA::Actions::ENHANCED |
c2pa.enhanced |
C2PA::Actions::FILTERED |
c2pa.filtered |
C2PA::Actions::ORIENTATION |
c2pa.orientation |
C2PA::Actions::PLACED |
c2pa.placed |
C2PA::Actions::PUBLISHED |
c2pa.published |
C2PA::Actions::REDACTED |
c2pa.redacted |
C2PA::Actions::REMOVED |
c2pa.removed |
C2PA::Actions::REPACKAGED |
c2pa.repackaged |
C2PA::Actions::RESIZED |
c2pa.resized |
C2PA::Actions::TRANSLATED |
c2pa.translated |
C2PA::Actions::TRANSCODED |
c2pa.transcoded |
C2PA::Actions::TRIMMED |
c2pa.trimmed |
C2PA::Actions::UNKNOWN |
c2pa.unknown |
C2PA::Actions::WATERMARKED |
c2pa.watermarked |
Adding other assertions
Use add_assertion for any assertion type beyond actions, such as schema.org metadata or AI training preferences:
manifest.add_assertion(
label: "stds.schema-org.CreativeWork",
data: {
"@context" => "https://schema.org",
"@type" => "CreativeWork",
"author" => [{ "@type" => "Person", "name" => "Jane Smith" }]
}
)
Adding ingredients
Ingredients record the source assets a file was derived from:
manifest.add_ingredient(
title: "Original photo",
format: "image/jpeg",
instance_id: "xmp:iid:original-uuid-here"
)
Signing a file
The output path must not already exist — C2PA.sign will raise a C2PA::SigningError if the file is already there.
manifest = C2PA::Manifest.new(title: "Sunset over the bay")
.add_action(C2PA::Actions::CREATED,
digital_source_type: C2PA::DigitalSourceTypes::DIGITAL_CAPTURE)
C2PA.sign(
file: "photo.jpg",
output: "photo_signed.jpg",
certificate: "test_cert.pem",
key: "test_key.pem",
manifest: manifest
)
C2PA.sign reads the signed file back and confirms it validates before
returning. If it does not, the output file is deleted and a
C2PA::SigningError is raised naming the failure codes.
This matters because c2pa-rs applies its rules when reading, not when writing. Signing reports success for manifests that every verifier rejects, which is exactly what earlier versions of this gem did — silently, for months. The check costs one extra read of the output.
Turn it off with verify: false if you want the file kept for inspection:
C2PA.sign(
file: "photo.jpg",
output: "photo_signed.jpg",
certificate: "cert.pem",
key: "key.pem",
manifest: manifest,
verify: false
)
Specify a different signing algorithm with algorithm: (default is "es256"):
C2PA.sign(
file: "photo.jpg",
output: "photo_signed.jpg",
certificate: "cert.pem",
key: "key.pem",
algorithm: "ps256",
manifest: manifest
)
Reading a manifest
result = C2PA.read(file: "photo_signed.jpg")
active = result["manifests"][result["active_manifest"]]
puts active["title"]
puts active["claim_generator_info"].first["name"] # => "ruby-c2pa"
Naming your application
Signed files credit ruby-c2pa by default. To credit your own application
instead:
manifest = C2PA::Manifest.new(
title: "Sunset over the bay",
generator_name: "Acme Editor",
generator_version: "2.0"
).add_action(
C2PA::Actions::CREATED,
digital_source_type: C2PA::DigitalSourceTypes::DIGITAL_CAPTURE
)
The signed manifest then reads:
{
"name": "Acme Editor",
"version": "2.0",
"org.rubygems.ruby_c2pa": "0.3.0",
"org.contentauth.c2pa_rs": "0.78.8"
}
c2pa-rs permits exactly one claim generator entry, so your application replaces the gem as the name rather than preceding it. The gem is recorded in a namespaced field alongside it, which is how c2pa-rs records itself.
Releases before 0.3.0 credited c2pa-rs and named neither the gem nor the
calling application.
Checking the SDK version
puts C2PA.sdk_version # => "0.78.3" (depends on the c2pa-rs version bundled with the gem)
Error handling
All errors inherit from C2PA::Error, so you can rescue broadly or narrowly:
begin
C2PA.sign(file: "photo.jpg", output: "photo_signed.jpg", certificate: "cert.pem", key: "key.pem", manifest: manifest)
rescue C2PA::InvalidManifestError => e
puts "Manifest is invalid: #{e.}"
rescue C2PA::SigningError => e
puts "Signing failed: #{e.}"
end
begin
C2PA.read(file: "photo_signed.jpg")
rescue C2PA::ReadError => e
puts "Could not read manifest: #{e.}"
end
# Or rescue any C2PA error broadly
begin
C2PA.sign(file: "photo.jpg", output: "photo_signed.jpg", certificate: "cert.pem", key: "key.pem", manifest: manifest)
rescue C2PA::Error => e
puts "C2PA error: #{e.}"
end
Supported file formats
Each format below has a fixture and a signing test in the suite: the file is signed, read back, and asserted to validate.
| Format | MIME type |
|---|---|
| JPEG | image/jpeg |
| PNG | image/png |
| WebP | image/webp |
| TIFF | image/tiff |
| AVIF | image/avif |
| JPEG XL | image/jxl |
| MP4 | video/mp4 |
| MOV | video/quicktime |
| MP3 | audio/mpeg |
| WAV | audio/wav |
The format is detected automatically from the file extension.
JPEG XL must be in the ISOBMFF container form. A bare codestream has no boxes to hold a manifest, and c2pa-rs rejects it.
Not supported
PDF cannot be signed. c2pa-rs can read C2PA data out of a PDF but has no
writer for it — get_writer returns None and save_cai_store returns
NotImplemented — so C2PA.sign raises C2PA::SigningError with
type is unsupported. This is true at every c2pa-rs version. Earlier releases
of this gem listed PDF as supported; that was never correct.
Test fixtures
The media fixtures are generated by
test/fixtures/generate.sh from ffmpeg's built-in
sources, so they carry no third-party content and no licence obligations. They
are deliberately real files rather than placeholders — 160×120 images with
actual detail, real audio samples, real video frames, and EXIF metadata on the
JPEG — because C2PA writes into container structures that an empty file would
not exercise. All ten total 92 KB.
Regenerating them needs ffmpeg, cjxl and exiftool; running the tests does
not.
Signing certificates are generated on demand, one chain per key type, so every supported algorithm is covered:
bundle exec rake fixtures:certs
rake test does this for you. The certificates are not committed — they are
development material, and regenerating costs a fraction of a second. Ruby's
OpenSSL binding is used rather than the openssl command because macOS ships
LibreSSL, which cannot generate Ed25519 keys.
How it works
Ruby (C2PA.sign)
│
│ native extension (magnus)
▼
Rust (C2PA::Native.sign_file)
│
│ calls c2pa-rs Builder API
▼
c2pa-rs — embeds signed manifest into the file
The Rust extension (ext/c2pa_native/src/lib.rs) defines C2PA::Native with three methods:
| Method | Description |
|---|---|
C2PA::Native.sign_file |
Sign a file and write the result |
C2PA::Native.read_file |
Read and return the manifest JSON |
C2PA::Native.sdk_version |
Return the c2pa-rs version string |
Input validation (missing files, invalid manifests) is handled in Ruby before calling into Rust. Errors from the native layer are caught and re-raised as typed C2PA::Error subclasses.
Contributing
See CONTRIBUTING.md. The short version: every test must be shown to fail before it is merged. The suite that shipped with 0.2.1 passed while the gem crashed the Ruby process on TIFF input, so a green run is only worth what its assertions can catch.
License
MIT