ruby-c2pa 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +153 -0
- data/CONTRIBUTING.md +117 -0
- data/README.md +276 -22
- data/Rakefile +29 -1
- data/ext/c2pa_native/Cargo.lock +4215 -0
- data/ext/c2pa_native/Cargo.toml +1 -1
- data/ext/c2pa_native/src/lib.rs +124 -5
- data/lib/c2pa/config.rb +90 -0
- data/lib/c2pa/digital_source_types.rb +72 -0
- data/lib/c2pa/error.rb +3 -0
- data/lib/c2pa/manifest.rb +156 -9
- data/lib/c2pa/version.rb +1 -1
- data/lib/c2pa.rb +105 -4
- data/ruby-c2pa.gemspec +28 -3
- metadata +11 -12
- data/ext/c2pa_native/target/release/build/oid-registry-c350c75504c969dc/out/oid_db.rs +0 -540
- data/ext/c2pa_native/target/release/build/pix-f303ab89d734032b/out/gamma_lut.rs +0 -68
- data/ext/c2pa_native/target/release/build/serde-2e9abb4d9d73cae4/out/private.rs +0 -6
- data/ext/c2pa_native/target/release/build/serde-f471616b462b0caf/out/private.rs +0 -6
- data/ext/c2pa_native/target/release/build/serde_core-2b94e9134dc44065/out/private.rs +0 -5
- data/ext/c2pa_native/target/release/build/serde_core-3dc945fa0ab21dd5/out/private.rs +0 -5
- data/ext/c2pa_native/target/release/build/thiserror-a1f4c63469c326b9/out/private.rs +0 -5
- data/ext/c2pa_native/target/release/build/typenum-4eb7d34b9fb695ef/out/tests.rs +0 -20563
- data/ext/c2pa_native/target/release/build/typenum-b8de96984639a942/out/tests.rs +0 -20563
data/ext/c2pa_native/Cargo.toml
CHANGED
data/ext/c2pa_native/src/lib.rs
CHANGED
|
@@ -1,9 +1,52 @@
|
|
|
1
|
+
use std::fs::File;
|
|
1
2
|
use std::path::Path;
|
|
2
|
-
use
|
|
3
|
+
use std::sync::{Arc, RwLock, OnceLock};
|
|
4
|
+
use c2pa::{create_signer, Builder, BuilderIntent, Context, Reader, SigningAlg};
|
|
3
5
|
use magnus::{function, prelude::*, Error, Ruby};
|
|
4
6
|
|
|
5
7
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
6
8
|
|
|
9
|
+
// A Context carries the settings c2pa-rs validates against — trust anchors,
|
|
10
|
+
// what to verify, whether to fetch remote manifests. The older entry points
|
|
11
|
+
// read those from thread-local state, which is why they are deprecated: a
|
|
12
|
+
// threaded Ruby application could see different settings depending on which
|
|
13
|
+
// thread it signed from.
|
|
14
|
+
//
|
|
15
|
+
// One shared Context is built lazily and reused. It is Send + Sync, so an Arc
|
|
16
|
+
// is all that sharing requires, and reusing it avoids re-reading configuration
|
|
17
|
+
// on every call.
|
|
18
|
+
//
|
|
19
|
+
// It currently carries defaults. Exposing settings to Ruby is a separate piece
|
|
20
|
+
// of work; this is the seam that makes it possible.
|
|
21
|
+
fn context_slot() -> &'static RwLock<Arc<Context>> {
|
|
22
|
+
static CONTEXT: OnceLock<RwLock<Arc<Context>>> = OnceLock::new();
|
|
23
|
+
CONTEXT.get_or_init(|| RwLock::new(Context::new().into_shared()))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
fn shared_context() -> Arc<Context> {
|
|
27
|
+
context_slot()
|
|
28
|
+
.read()
|
|
29
|
+
.expect("context lock poisoned")
|
|
30
|
+
.clone()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Replace the shared Context with one built from the supplied settings.
|
|
34
|
+
//
|
|
35
|
+
// c2pa-rs takes settings as JSON, so the Ruby side assembles the document and
|
|
36
|
+
// this only has to validate and install it. Rebuilding rather than mutating
|
|
37
|
+
// keeps the Context immutable once shared: signing already in flight on another
|
|
38
|
+
// thread continues against the settings it started with.
|
|
39
|
+
fn do_configure(settings_json: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
40
|
+
let context = Context::new()
|
|
41
|
+
.with_settings(settings_json)
|
|
42
|
+
.map_err(|e| format!("Invalid settings: {}", e))?
|
|
43
|
+
.into_shared();
|
|
44
|
+
|
|
45
|
+
*context_slot().write().map_err(|_| "context lock poisoned")? = context;
|
|
46
|
+
|
|
47
|
+
Ok(())
|
|
48
|
+
}
|
|
49
|
+
|
|
7
50
|
fn alg_from_str(alg: &str) -> Result<SigningAlg, String> {
|
|
8
51
|
match alg.to_lowercase().as_str() {
|
|
9
52
|
"ps256" => Ok(SigningAlg::Ps256),
|
|
@@ -22,6 +65,57 @@ fn alg_from_str(alg: &str) -> Result<SigningAlg, String> {
|
|
|
22
65
|
|
|
23
66
|
// ─── Core logic ──────────────────────────────────────────────────────────────
|
|
24
67
|
|
|
68
|
+
// A c2pa.opened action has to reference its parent ingredient by hashed URI,
|
|
69
|
+
// and that hash is computed over the ingredient assertion as c2pa-rs
|
|
70
|
+
// serialises it. Ruby cannot construct one. Declaring the intent instead lets
|
|
71
|
+
// the SDK derive the parent ingredient from the source and wire the action to
|
|
72
|
+
// it, which is the only way the edit workflow can be expressed.
|
|
73
|
+
fn intent_from_str(intent: &str) -> Result<BuilderIntent, String> {
|
|
74
|
+
match intent.to_lowercase().as_str() {
|
|
75
|
+
"edit" => Ok(BuilderIntent::Edit),
|
|
76
|
+
"update" => Ok(BuilderIntent::Update),
|
|
77
|
+
_ => Err(format!(
|
|
78
|
+
"Unknown intent: '{}'. Valid options: edit, update",
|
|
79
|
+
intent
|
|
80
|
+
)),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Ingredients supplied as files rather than as descriptions. c2pa-rs reads the
|
|
85
|
+
// bytes to hash them, generate a thumbnail, and carry forward any manifest the
|
|
86
|
+
// file already holds; the JSON description takes precedence over anything
|
|
87
|
+
// derived from the stream.
|
|
88
|
+
//
|
|
89
|
+
// Expects a JSON array of {"json": "<ingredient JSON>", "format": "<mime>",
|
|
90
|
+
// "path": "<file>"}.
|
|
91
|
+
fn add_ingredient_files(
|
|
92
|
+
builder: &mut Builder,
|
|
93
|
+
ingredient_files_json: &str,
|
|
94
|
+
) -> Result<(), Box<dyn std::error::Error>> {
|
|
95
|
+
let entries: serde_json::Value = serde_json::from_str(ingredient_files_json)
|
|
96
|
+
.map_err(|e| format!("Invalid ingredient list: {}", e))?;
|
|
97
|
+
let entries = entries
|
|
98
|
+
.as_array()
|
|
99
|
+
.ok_or("Invalid ingredient list: expected an array")?;
|
|
100
|
+
|
|
101
|
+
for entry in entries {
|
|
102
|
+
let field = |name: &str| -> Result<&str, String> {
|
|
103
|
+
entry[name]
|
|
104
|
+
.as_str()
|
|
105
|
+
.ok_or_else(|| format!("Invalid ingredient list: missing '{}'", name))
|
|
106
|
+
};
|
|
107
|
+
let (json, format, path) = (field("json")?, field("format")?, field("path")?);
|
|
108
|
+
|
|
109
|
+
let mut stream = File::open(path)
|
|
110
|
+
.map_err(|e| format!("Cannot read ingredient '{}': {}", path, e))?;
|
|
111
|
+
builder
|
|
112
|
+
.add_ingredient_from_stream(json, format, &mut stream)
|
|
113
|
+
.map_err(|e| format!("Cannot add ingredient '{}': {}", path, e))?;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
Ok(())
|
|
117
|
+
}
|
|
118
|
+
|
|
25
119
|
fn do_sign_file(
|
|
26
120
|
source_path: &str,
|
|
27
121
|
dest_path: &str,
|
|
@@ -29,6 +123,8 @@ fn do_sign_file(
|
|
|
29
123
|
key_path: &str,
|
|
30
124
|
alg_str: &str,
|
|
31
125
|
manifest_json: Option<&str>,
|
|
126
|
+
intent_str: Option<&str>,
|
|
127
|
+
ingredient_files_json: Option<&str>,
|
|
32
128
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
33
129
|
let cert = std::fs::read(cert_path)
|
|
34
130
|
.map_err(|e| format!("Cannot read certificate '{}': {}", cert_path, e))?;
|
|
@@ -47,9 +143,18 @@ fn do_sign_file(
|
|
|
47
143
|
let default_json = format!(r#"{{"title": "{}"}}"#, title);
|
|
48
144
|
let json = manifest_json.unwrap_or(&default_json);
|
|
49
145
|
|
|
50
|
-
let mut builder = Builder::
|
|
146
|
+
let mut builder = Builder::from_shared_context(&shared_context())
|
|
147
|
+
.with_definition(json)
|
|
51
148
|
.map_err(|e| format!("Invalid manifest JSON: {}", e))?;
|
|
52
149
|
|
|
150
|
+
if let Some(intent) = intent_str {
|
|
151
|
+
builder.set_intent(intent_from_str(intent)?);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if let Some(files) = ingredient_files_json {
|
|
155
|
+
add_ingredient_files(&mut builder, files)?;
|
|
156
|
+
}
|
|
157
|
+
|
|
53
158
|
builder.sign_file(&*signer, source_path, dest_path)
|
|
54
159
|
.map_err(|e| format!("Signing failed: {}", e))?;
|
|
55
160
|
|
|
@@ -57,7 +162,8 @@ fn do_sign_file(
|
|
|
57
162
|
}
|
|
58
163
|
|
|
59
164
|
fn do_read_file(path: &str) -> Result<String, Box<dyn std::error::Error>> {
|
|
60
|
-
let reader = Reader::
|
|
165
|
+
let reader = Reader::from_shared_context(&shared_context())
|
|
166
|
+
.with_file(path)
|
|
61
167
|
.map_err(|e| format!("Failed to read manifest from '{}': {}", path, e))?;
|
|
62
168
|
Ok(reader.json())
|
|
63
169
|
}
|
|
@@ -71,10 +177,13 @@ fn sign_file(
|
|
|
71
177
|
key: String,
|
|
72
178
|
alg: Option<String>,
|
|
73
179
|
manifest_json: Option<String>,
|
|
180
|
+
intent: Option<String>,
|
|
181
|
+
ingredient_files: Option<String>,
|
|
74
182
|
) -> Result<String, Error> {
|
|
75
183
|
let alg_str = alg.as_deref().unwrap_or("es256");
|
|
76
184
|
|
|
77
|
-
do_sign_file(&source, &dest, &cert, &key, alg_str, manifest_json.as_deref()
|
|
185
|
+
do_sign_file(&source, &dest, &cert, &key, alg_str, manifest_json.as_deref(),
|
|
186
|
+
intent.as_deref(), ingredient_files.as_deref())
|
|
78
187
|
.map_err(|e| Error::new(Ruby::get().expect("called from Ruby thread").exception_runtime_error(), e.to_string()))?;
|
|
79
188
|
|
|
80
189
|
Ok(dest)
|
|
@@ -85,6 +194,15 @@ fn read_file(path: String) -> Result<String, Error> {
|
|
|
85
194
|
.map_err(|e| Error::new(Ruby::get().expect("called from Ruby thread").exception_runtime_error(), e.to_string()))
|
|
86
195
|
}
|
|
87
196
|
|
|
197
|
+
fn configure(settings_json: String) -> Result<(), Error> {
|
|
198
|
+
do_configure(&settings_json).map_err(|e| {
|
|
199
|
+
Error::new(
|
|
200
|
+
Ruby::get().expect("called from Ruby thread").exception_runtime_error(),
|
|
201
|
+
e.to_string(),
|
|
202
|
+
)
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
88
206
|
fn sdk_version() -> String {
|
|
89
207
|
c2pa::VERSION.to_string()
|
|
90
208
|
}
|
|
@@ -96,8 +214,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
96
214
|
let c2pa = ruby.define_module("C2PA")?;
|
|
97
215
|
let native = c2pa.define_module("Native")?;
|
|
98
216
|
|
|
99
|
-
native.define_singleton_method("sign_file", function!(sign_file,
|
|
217
|
+
native.define_singleton_method("sign_file", function!(sign_file, 8))?;
|
|
100
218
|
native.define_singleton_method("read_file", function!(read_file, 1))?;
|
|
219
|
+
native.define_singleton_method("configure", function!(configure, 1))?;
|
|
101
220
|
native.define_singleton_method("sdk_version", function!(sdk_version, 0))?;
|
|
102
221
|
|
|
103
222
|
Ok(())
|
data/lib/c2pa/config.rb
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module C2PA
|
|
4
|
+
# Settings that govern how c2pa-rs validates.
|
|
5
|
+
#
|
|
6
|
+
# These reach the SDK through its Context, which is rebuilt whenever they
|
|
7
|
+
# change. Signing already in flight on another thread continues against the
|
|
8
|
+
# settings it started with.
|
|
9
|
+
#
|
|
10
|
+
# Defaults are c2pa-rs's own, so a gem that never calls C2PA.configure
|
|
11
|
+
# behaves exactly as it did before this existed.
|
|
12
|
+
class Config
|
|
13
|
+
# Additional root certificates to trust, as a PEM bundle. Use this for a
|
|
14
|
+
# private or enterprise CA: a certificate chaining to one of these
|
|
15
|
+
# validates as "Trusted" rather than carrying signingCredential.untrusted.
|
|
16
|
+
attr_accessor :trust_anchors
|
|
17
|
+
|
|
18
|
+
# The trust list proper — normally the C2PA-recognised anchors. Setting
|
|
19
|
+
# this replaces that list rather than adding to it, so prefer
|
|
20
|
+
# trust_anchors unless you mean to substitute the whole thing.
|
|
21
|
+
attr_accessor :trust_list
|
|
22
|
+
|
|
23
|
+
# Explicitly allowed certificates, as a PEM bundle.
|
|
24
|
+
attr_accessor :allowed_certificates
|
|
25
|
+
|
|
26
|
+
# Whether to check certificates against the trust list at all.
|
|
27
|
+
#
|
|
28
|
+
# Turning this off means nothing is ever reported as untrusted, which in a
|
|
29
|
+
# library for establishing provenance is rarely what you want. It exists
|
|
30
|
+
# for offline and air-gapped environments.
|
|
31
|
+
attr_accessor :verify_trust
|
|
32
|
+
|
|
33
|
+
# Whether reading an asset may fetch a manifest over the network.
|
|
34
|
+
# c2pa-rs defaults this to true, so reading can make an outbound request.
|
|
35
|
+
attr_accessor :remote_manifest_fetch
|
|
36
|
+
|
|
37
|
+
# Whether to check certificate revocation over OCSP, which also makes
|
|
38
|
+
# network requests.
|
|
39
|
+
attr_accessor :ocsp_fetch
|
|
40
|
+
|
|
41
|
+
def initialize
|
|
42
|
+
@trust_anchors = nil
|
|
43
|
+
@trust_list = nil
|
|
44
|
+
@allowed_certificates = nil
|
|
45
|
+
@verify_trust = nil
|
|
46
|
+
@remote_manifest_fetch = nil
|
|
47
|
+
@ocsp_fetch = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The settings document c2pa-rs expects.
|
|
51
|
+
#
|
|
52
|
+
# Only values that were actually set are included, so anything left alone
|
|
53
|
+
# keeps the SDK's default rather than being pinned to ours.
|
|
54
|
+
#
|
|
55
|
+
# @return [String] JSON
|
|
56
|
+
def to_json
|
|
57
|
+
trust = {}
|
|
58
|
+
trust["user_anchors"] = read_pem(@trust_anchors) unless @trust_anchors.nil?
|
|
59
|
+
trust["trust_anchors"] = read_pem(@trust_list) unless @trust_list.nil?
|
|
60
|
+
trust["allowed_list"] = read_pem(@allowed_certificates) unless @allowed_certificates.nil?
|
|
61
|
+
|
|
62
|
+
verify = {}
|
|
63
|
+
verify["verify_trust"] = @verify_trust unless @verify_trust.nil?
|
|
64
|
+
verify["remote_manifest_fetch"] = @remote_manifest_fetch unless @remote_manifest_fetch.nil?
|
|
65
|
+
verify["ocsp_fetch"] = @ocsp_fetch unless @ocsp_fetch.nil?
|
|
66
|
+
|
|
67
|
+
settings = {}
|
|
68
|
+
settings["trust"] = trust unless trust.empty?
|
|
69
|
+
settings["verify"] = verify unless verify.empty?
|
|
70
|
+
|
|
71
|
+
JSON.generate(settings)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Accept either PEM text or a path to a file containing it, since callers
|
|
77
|
+
# naturally have one or the other.
|
|
78
|
+
def read_pem(value)
|
|
79
|
+
string = value.to_s
|
|
80
|
+
return string if string.include?("BEGIN CERTIFICATE")
|
|
81
|
+
|
|
82
|
+
unless File.exist?(string)
|
|
83
|
+
raise InvalidSettingsError,
|
|
84
|
+
"expected PEM text or a readable file, got #{string.inspect}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
File.read(string)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module C2PA
|
|
2
|
+
# URIs from the IPTC Digital Source Type NewsCodes vocabulary.
|
|
3
|
+
#
|
|
4
|
+
# The C2PA specification requires every c2pa.created action to declare one of
|
|
5
|
+
# these, so consumers can tell how the asset came into being. Pick the one that
|
|
6
|
+
# honestly describes the asset — this is a provenance claim, and c2pa-rs will
|
|
7
|
+
# not second-guess the value you supply.
|
|
8
|
+
#
|
|
9
|
+
# @see https://cv.iptc.org/newscodes/digitalsourcetype/
|
|
10
|
+
module DigitalSourceTypes
|
|
11
|
+
BASE = "http://cv.iptc.org/newscodes/digitalsourcetype"
|
|
12
|
+
|
|
13
|
+
# Declining to claim a source type, rather than guessing at one.
|
|
14
|
+
#
|
|
15
|
+
# c2pa-rs requires c2pa.created to carry a digitalSourceType but accepts
|
|
16
|
+
# any string, so a wrong value validates silently. When the origin is not
|
|
17
|
+
# known — a generic signing service, say — this says so, instead of
|
|
18
|
+
# asserting something untrue. It is the value c2pa-rs uses throughout its
|
|
19
|
+
# own fixtures.
|
|
20
|
+
UNSPECIFIED = "http://c2pa.org/digitalsourcetype/empty"
|
|
21
|
+
|
|
22
|
+
# Captured from real life
|
|
23
|
+
DIGITAL_CAPTURE = "#{BASE}/digitalCapture"
|
|
24
|
+
COMPUTATIONAL_CAPTURE = "#{BASE}/computationalCapture"
|
|
25
|
+
SCREEN_CAPTURE = "#{BASE}/screenCapture"
|
|
26
|
+
VIRTUAL_RECORDING = "#{BASE}/virtualRecording"
|
|
27
|
+
|
|
28
|
+
# Digitised from a physical medium
|
|
29
|
+
NEGATIVE_FILM = "#{BASE}/negativeFilm"
|
|
30
|
+
POSITIVE_FILM = "#{BASE}/positiveFilm"
|
|
31
|
+
PRINT = "#{BASE}/print"
|
|
32
|
+
|
|
33
|
+
# Human- or software-authored
|
|
34
|
+
HUMAN_EDITS = "#{BASE}/humanEdits"
|
|
35
|
+
DIGITAL_CREATION = "#{BASE}/digitalCreation"
|
|
36
|
+
ALGORITHMICALLY_ENHANCED = "#{BASE}/algorithmicallyEnhanced"
|
|
37
|
+
DATA_DRIVEN_MEDIA = "#{BASE}/dataDrivenMedia"
|
|
38
|
+
ALGORITHMIC_MEDIA = "#{BASE}/algorithmicMedia"
|
|
39
|
+
|
|
40
|
+
# Generative AI
|
|
41
|
+
TRAINED_ALGORITHMIC_MEDIA = "#{BASE}/trainedAlgorithmicMedia"
|
|
42
|
+
COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA = "#{BASE}/compositeWithTrainedAlgorithmicMedia"
|
|
43
|
+
COMPOSITE_SYNTHETIC = "#{BASE}/compositeSynthetic"
|
|
44
|
+
|
|
45
|
+
# Composites
|
|
46
|
+
COMPOSITE = "#{BASE}/composite"
|
|
47
|
+
COMPOSITE_CAPTURE = "#{BASE}/compositeCapture"
|
|
48
|
+
|
|
49
|
+
# Every type defined above. Supplying a URI outside this list is allowed —
|
|
50
|
+
# the vocabulary is extensible — but the values here cover the standard set.
|
|
51
|
+
ALL = [
|
|
52
|
+
UNSPECIFIED,
|
|
53
|
+
DIGITAL_CAPTURE,
|
|
54
|
+
COMPUTATIONAL_CAPTURE,
|
|
55
|
+
SCREEN_CAPTURE,
|
|
56
|
+
VIRTUAL_RECORDING,
|
|
57
|
+
NEGATIVE_FILM,
|
|
58
|
+
POSITIVE_FILM,
|
|
59
|
+
PRINT,
|
|
60
|
+
HUMAN_EDITS,
|
|
61
|
+
DIGITAL_CREATION,
|
|
62
|
+
ALGORITHMICALLY_ENHANCED,
|
|
63
|
+
DATA_DRIVEN_MEDIA,
|
|
64
|
+
ALGORITHMIC_MEDIA,
|
|
65
|
+
TRAINED_ALGORITHMIC_MEDIA,
|
|
66
|
+
COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA,
|
|
67
|
+
COMPOSITE_SYNTHETIC,
|
|
68
|
+
COMPOSITE,
|
|
69
|
+
COMPOSITE_CAPTURE
|
|
70
|
+
].freeze
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/c2pa/error.rb
CHANGED
data/lib/c2pa/manifest.rb
CHANGED
|
@@ -2,14 +2,55 @@ require "json"
|
|
|
2
2
|
|
|
3
3
|
module C2PA
|
|
4
4
|
class Manifest
|
|
5
|
-
#
|
|
6
|
-
|
|
5
|
+
# Intents this gem can express.
|
|
6
|
+
#
|
|
7
|
+
# :edit — this asset derives from a parent. c2pa-rs generates the parent
|
|
8
|
+
# ingredient from the source file and adds a c2pa.opened action
|
|
9
|
+
# wired to it by hashed URI.
|
|
10
|
+
# :update — a restricted edit for non-editorial changes, such as fixing
|
|
11
|
+
# metadata. The parent is the source file itself; an explicit
|
|
12
|
+
# ingredient, if given, must be that same file.
|
|
13
|
+
#
|
|
14
|
+
# Omitting the intent produces a manifest for a newly created asset.
|
|
15
|
+
INTENTS = %i[edit update].freeze
|
|
16
|
+
|
|
17
|
+
# c2pa-rs records itself in a namespaced field alongside the generator
|
|
18
|
+
# name, so the gem does the same when an application supplies its own.
|
|
19
|
+
GEM_FIELD = "org.rubygems.ruby_c2pa".freeze
|
|
20
|
+
|
|
21
|
+
# @return [Symbol, nil] the builder intent, if any
|
|
22
|
+
attr_reader :intent
|
|
23
|
+
|
|
24
|
+
# @param title [String] human-readable title for this asset
|
|
25
|
+
# @param intent [Symbol, nil] :edit; omit for a new creation
|
|
26
|
+
# @param generator_name [String, nil] the application doing the signing.
|
|
27
|
+
# Defaults to this gem. Supplying it credits your application as the
|
|
28
|
+
# claim generator, with the gem recorded alongside.
|
|
29
|
+
# @param generator_version [String, nil] version of that application
|
|
30
|
+
# @raise [C2PA::InvalidManifestError] if the intent is not recognised
|
|
31
|
+
def initialize(title:, intent: nil, generator_name: nil, generator_version: nil)
|
|
32
|
+
unless intent.nil? || INTENTS.include?(intent)
|
|
33
|
+
raise InvalidManifestError,
|
|
34
|
+
"unknown intent #{intent.inspect}. Valid options: #{INTENTS.map(&:inspect).join(', ')}"
|
|
35
|
+
end
|
|
36
|
+
|
|
7
37
|
@title = title
|
|
38
|
+
@intent = intent
|
|
39
|
+
@generator_name = generator_name
|
|
40
|
+
@generator_version = generator_version
|
|
8
41
|
@actions = []
|
|
9
42
|
@assertions = []
|
|
10
43
|
@ingredients = []
|
|
44
|
+
@ingredient_files = []
|
|
11
45
|
end
|
|
12
46
|
|
|
47
|
+
# Ingredients supplied as files, for the signing layer to hand to c2pa-rs.
|
|
48
|
+
# Each is a Hash with the ingredient's JSON description, its format, and
|
|
49
|
+
# the path to read.
|
|
50
|
+
#
|
|
51
|
+
# @return [Array<Hash>]
|
|
52
|
+
attr_reader :ingredient_files
|
|
53
|
+
|
|
13
54
|
# Add a C2PA action to this manifest.
|
|
14
55
|
#
|
|
15
56
|
# @param action [String] one of the C2PA::Actions constants
|
|
@@ -26,6 +67,38 @@ module C2PA
|
|
|
26
67
|
digital_source_type: nil,
|
|
27
68
|
changed: nil,
|
|
28
69
|
parameters: nil)
|
|
70
|
+
if action == Actions::OPENED
|
|
71
|
+
raise InvalidManifestError,
|
|
72
|
+
"#{Actions::OPENED} cannot be added directly. The specification requires it to " \
|
|
73
|
+
"reference a parentOf ingredient by hashed URI, and that hash is computed over " \
|
|
74
|
+
"the ingredient as c2pa-rs serialises it, so Ruby cannot construct one. Pass " \
|
|
75
|
+
"intent: :edit to C2PA::Manifest.new instead and the action will be added for you."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Required as of c2pa-rs 0.90. Earlier versions accepted its absence, so
|
|
79
|
+
# manifests signed by releases before 0.3.0 are rejected by current
|
|
80
|
+
# verifiers. No default is supplied: c2pa-rs accepts any string here, so
|
|
81
|
+
# a guess would validate while asserting something untrue about where the
|
|
82
|
+
# asset came from. Use DigitalSourceTypes::UNSPECIFIED to decline.
|
|
83
|
+
if action == Actions::CREATED && to_s_or_nil(digital_source_type).nil?
|
|
84
|
+
raise InvalidManifestError,
|
|
85
|
+
"#{Actions::CREATED} requires a digital_source_type. Choose the value that " \
|
|
86
|
+
"describes how the asset was produced — for example " \
|
|
87
|
+
"C2PA::DigitalSourceTypes::DIGITAL_CAPTURE for a camera original, or " \
|
|
88
|
+
"TRAINED_ALGORITHMIC_MEDIA for generative AI. If the origin is genuinely " \
|
|
89
|
+
"unknown, use C2PA::DigitalSourceTypes::UNSPECIFIED rather than guessing."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Also new in c2pa-rs 0.90.
|
|
93
|
+
if action == Actions::TRANSLATED
|
|
94
|
+
missing = %w[sourceLanguage targetLanguage].reject { |key| param_present?(parameters, key) }
|
|
95
|
+
unless missing.empty?
|
|
96
|
+
raise InvalidManifestError,
|
|
97
|
+
"#{Actions::TRANSLATED} requires #{missing.join(' and ')} in parameters, " \
|
|
98
|
+
"as RFC 5646 language codes"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
29
102
|
entry = { "action" => action }
|
|
30
103
|
entry["when"] = when_time if when_time
|
|
31
104
|
entry["softwareAgent"] = software_agent || "ruby-c2pa/#{VERSION}"
|
|
@@ -48,30 +121,62 @@ module C2PA
|
|
|
48
121
|
|
|
49
122
|
# Add an ingredient (source asset) to this manifest.
|
|
50
123
|
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
124
|
+
# With `file:`, c2pa-rs reads the ingredient itself. If that file carries
|
|
125
|
+
# content credentials, its manifest is embedded and the ingredient points
|
|
126
|
+
# at it, so provenance chains from the original through to this asset. A
|
|
127
|
+
# verifier can then follow and check the whole history.
|
|
128
|
+
#
|
|
129
|
+
# For a file with no credentials there is nothing to carry forward, and
|
|
130
|
+
# the result is the same as the description alone. (Thumbnails would be
|
|
131
|
+
# the other contribution, but they need c2pa-rs's `add_thumbnails`
|
|
132
|
+
# feature, which this gem does not enable.)
|
|
133
|
+
#
|
|
134
|
+
# Without `file:`, only the description is recorded. Nothing binds it to
|
|
135
|
+
# any actual bytes. This form is kept for compatibility.
|
|
136
|
+
#
|
|
137
|
+
# @param title [String] human-readable title of the ingredient
|
|
138
|
+
# @param format [String] MIME type of the ingredient, e.g. "image/jpeg"
|
|
139
|
+
# @param instance_id [String] unique identifier for the ingredient instance
|
|
54
140
|
# @param relationship [String] relationship to this asset; defaults to "parentOf"
|
|
141
|
+
# @param file [String, nil] path to the ingredient file
|
|
55
142
|
# @return [self]
|
|
56
|
-
|
|
57
|
-
|
|
143
|
+
# @raise [C2PA::InvalidManifestError] if `file` is given but cannot be read
|
|
144
|
+
def add_ingredient(title:, format:, instance_id:, relationship: "parentOf", file: nil)
|
|
145
|
+
description = {
|
|
58
146
|
"title" => title,
|
|
59
147
|
"format" => format,
|
|
60
148
|
"instance_id" => instance_id,
|
|
61
149
|
"relationship" => relationship
|
|
62
150
|
}
|
|
151
|
+
|
|
152
|
+
if file.nil?
|
|
153
|
+
@ingredients << description
|
|
154
|
+
return self
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
unless File.file?(file) && File.readable?(file)
|
|
158
|
+
raise InvalidManifestError, "ingredient file not readable: #{file.inspect}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
@ingredient_files << {
|
|
162
|
+
"json" => JSON.generate(description),
|
|
163
|
+
"format" => format,
|
|
164
|
+
"path" => File.expand_path(file)
|
|
165
|
+
}
|
|
63
166
|
self
|
|
64
167
|
end
|
|
65
168
|
|
|
66
169
|
# Serialize to the JSON structure expected by c2pa-rs.
|
|
67
170
|
#
|
|
68
171
|
# @return [String]
|
|
69
|
-
# @raise [C2PA::InvalidManifestError] if no actions have been added
|
|
172
|
+
# @raise [C2PA::InvalidManifestError] if no actions have been added, or if
|
|
173
|
+
# any value cannot be represented as JSON
|
|
70
174
|
def to_json
|
|
71
175
|
raise InvalidManifestError, "at least one action is required" if @actions.empty?
|
|
72
176
|
|
|
73
177
|
manifest = {
|
|
74
178
|
"title" => @title,
|
|
179
|
+
"claim_generator_info" => [claim_generator_info],
|
|
75
180
|
"assertions" => [
|
|
76
181
|
{ "label" => "c2pa.actions.v2", "data" => { "actions" => @actions } },
|
|
77
182
|
*@assertions
|
|
@@ -79,7 +184,49 @@ module C2PA
|
|
|
79
184
|
}
|
|
80
185
|
manifest["ingredients"] = @ingredients unless @ingredients.empty?
|
|
81
186
|
|
|
82
|
-
|
|
187
|
+
begin
|
|
188
|
+
JSON.generate(manifest)
|
|
189
|
+
rescue JSON::GeneratorError => e
|
|
190
|
+
# Typically a string that is not valid UTF-8 — a filename or caption
|
|
191
|
+
# read in another encoding and passed through untouched. Without this
|
|
192
|
+
# the caller gets a JSON::GeneratorError, which is not a C2PA::Error
|
|
193
|
+
# and so escapes `rescue C2PA::Error`.
|
|
194
|
+
raise InvalidManifestError,
|
|
195
|
+
"manifest contains text that cannot be encoded as JSON: #{e.message}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
# Parameters may be keyed with strings or symbols depending on the caller.
|
|
202
|
+
def param_present?(parameters, key)
|
|
203
|
+
return false unless parameters.is_a?(Hash)
|
|
204
|
+
|
|
205
|
+
!to_s_or_nil(parameters[key] || parameters[key.to_sym]).nil?
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def to_s_or_nil(value)
|
|
209
|
+
return nil if value.nil?
|
|
210
|
+
|
|
211
|
+
string = value.to_s
|
|
212
|
+
string.empty? ? nil : string
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Who signed this. Without it c2pa-rs names itself, so every asset this gem
|
|
216
|
+
# produced credited "c2pa-rs" and nothing identified the gem or the
|
|
217
|
+
# application using it.
|
|
218
|
+
#
|
|
219
|
+
# c2pa-rs 0.78 permits exactly one entry — supplying two fails with "only 1
|
|
220
|
+
# claim_generator_info allowed" — so an application name replaces the gem
|
|
221
|
+
# rather than preceding it, and the gem moves into a namespaced field. That
|
|
222
|
+
# mirrors how c2pa-rs records itself as org.contentauth.c2pa_rs.
|
|
223
|
+
def claim_generator_info
|
|
224
|
+
return { "name" => "ruby-c2pa", "version" => VERSION } if @generator_name.nil?
|
|
225
|
+
|
|
226
|
+
info = { "name" => @generator_name }
|
|
227
|
+
info["version"] = @generator_version if @generator_version
|
|
228
|
+
info[GEM_FIELD] = VERSION
|
|
229
|
+
info
|
|
83
230
|
end
|
|
84
231
|
end
|
|
85
232
|
end
|
data/lib/c2pa/version.rb
CHANGED