ruby-c2pa 0.3.0 → 0.5.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 +87 -1
- data/CONTRIBUTING.md +19 -2
- data/README.md +236 -28
- data/ext/c2pa_native/Cargo.lock +898 -666
- data/ext/c2pa_native/Cargo.toml +4 -1
- data/ext/c2pa_native/src/lib.rs +276 -30
- data/lib/c2pa/config.rb +122 -0
- data/lib/c2pa/error.rb +3 -0
- data/lib/c2pa/manifest.rb +50 -14
- data/lib/c2pa/version.rb +1 -1
- data/lib/c2pa.rb +147 -1
- metadata +2 -1
data/ext/c2pa_native/Cargo.toml
CHANGED
|
@@ -9,7 +9,10 @@ crate-type = ["cdylib"]
|
|
|
9
9
|
|
|
10
10
|
[dependencies]
|
|
11
11
|
magnus = { version = "0.8", features = [] }
|
|
12
|
-
|
|
12
|
+
# Only for rb_thread_call_without_gvl, which magnus does not wrap. Kept at the
|
|
13
|
+
# range magnus itself depends on so Cargo resolves a single copy.
|
|
14
|
+
rb-sys = { version = "0.9.113", default-features = false }
|
|
15
|
+
c2pa = { version = "0.90", features = ["file_io", "pdf", "add_thumbnails"] }
|
|
13
16
|
serde_json = "1"
|
|
14
17
|
|
|
15
18
|
[profile.release]
|
data/ext/c2pa_native/src/lib.rs
CHANGED
|
@@ -1,9 +1,65 @@
|
|
|
1
|
+
use std::fs::File;
|
|
2
|
+
use std::io::Cursor;
|
|
1
3
|
use std::path::Path;
|
|
2
|
-
use
|
|
3
|
-
use
|
|
4
|
+
use std::sync::{Arc, RwLock, OnceLock};
|
|
5
|
+
use c2pa::{create_signer, Builder, BuilderIntent, Context, Reader, SigningAlg};
|
|
6
|
+
use magnus::{function, prelude::*, Error, RString, Ruby};
|
|
4
7
|
|
|
5
8
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
6
9
|
|
|
10
|
+
// A Context carries the settings c2pa-rs validates against — trust anchors,
|
|
11
|
+
// what to verify, whether to fetch remote manifests. The older entry points
|
|
12
|
+
// read those from thread-local state, which is why they are deprecated: a
|
|
13
|
+
// threaded Ruby application could see different settings depending on which
|
|
14
|
+
// thread it signed from.
|
|
15
|
+
//
|
|
16
|
+
// One shared Context is built lazily and reused. It is Send + Sync, so an Arc
|
|
17
|
+
// is all that sharing requires, and reusing it avoids re-reading configuration
|
|
18
|
+
// on every call.
|
|
19
|
+
//
|
|
20
|
+
// It currently carries defaults. Exposing settings to Ruby is a separate piece
|
|
21
|
+
// of work; this is the seam that makes it possible.
|
|
22
|
+
// The one place this gem departs from c2pa-rs's defaults. c2pa-rs generates
|
|
23
|
+
// thumbnails when the feature is compiled in, scaling to a 1024px long edge —
|
|
24
|
+
// and it upscales, so a 160x120 source gets a 1024x768 "thumbnail" ten times
|
|
25
|
+
// its size. Off unless asked for; Config::to_json always states the choice.
|
|
26
|
+
const DEFAULT_SETTINGS: &str = r#"{"builder":{"thumbnail":{"enabled":false}}}"#;
|
|
27
|
+
|
|
28
|
+
fn context_slot() -> &'static RwLock<Arc<Context>> {
|
|
29
|
+
static CONTEXT: OnceLock<RwLock<Arc<Context>>> = OnceLock::new();
|
|
30
|
+
CONTEXT.get_or_init(|| {
|
|
31
|
+
let context = Context::new()
|
|
32
|
+
.with_settings(DEFAULT_SETTINGS)
|
|
33
|
+
.expect("built-in default settings are valid")
|
|
34
|
+
.into_shared();
|
|
35
|
+
RwLock::new(context)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
fn shared_context() -> Arc<Context> {
|
|
40
|
+
context_slot()
|
|
41
|
+
.read()
|
|
42
|
+
.expect("context lock poisoned")
|
|
43
|
+
.clone()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Replace the shared Context with one built from the supplied settings.
|
|
47
|
+
//
|
|
48
|
+
// c2pa-rs takes settings as JSON, so the Ruby side assembles the document and
|
|
49
|
+
// this only has to validate and install it. Rebuilding rather than mutating
|
|
50
|
+
// keeps the Context immutable once shared: signing already in flight on another
|
|
51
|
+
// thread continues against the settings it started with.
|
|
52
|
+
fn do_configure(settings_json: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
53
|
+
let context = Context::new()
|
|
54
|
+
.with_settings(settings_json)
|
|
55
|
+
.map_err(|e| format!("Invalid settings: {}", e))?
|
|
56
|
+
.into_shared();
|
|
57
|
+
|
|
58
|
+
*context_slot().write().map_err(|_| "context lock poisoned")? = context;
|
|
59
|
+
|
|
60
|
+
Ok(())
|
|
61
|
+
}
|
|
62
|
+
|
|
7
63
|
fn alg_from_str(alg: &str) -> Result<SigningAlg, String> {
|
|
8
64
|
match alg.to_lowercase().as_str() {
|
|
9
65
|
"ps256" => Ok(SigningAlg::Ps256),
|
|
@@ -38,53 +94,193 @@ fn intent_from_str(intent: &str) -> Result<BuilderIntent, String> {
|
|
|
38
94
|
}
|
|
39
95
|
}
|
|
40
96
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
97
|
+
// Ingredients supplied as files rather than as descriptions. c2pa-rs reads the
|
|
98
|
+
// bytes to hash them, generate a thumbnail, and carry forward any manifest the
|
|
99
|
+
// file already holds; the JSON description takes precedence over anything
|
|
100
|
+
// derived from the stream.
|
|
101
|
+
//
|
|
102
|
+
// Expects a JSON array of {"json": "<ingredient JSON>", "format": "<mime>",
|
|
103
|
+
// "path": "<file>"}.
|
|
104
|
+
fn add_ingredient_files(
|
|
105
|
+
builder: &mut Builder,
|
|
106
|
+
ingredient_files_json: &str,
|
|
49
107
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
108
|
+
let entries: serde_json::Value = serde_json::from_str(ingredient_files_json)
|
|
109
|
+
.map_err(|e| format!("Invalid ingredient list: {}", e))?;
|
|
110
|
+
let entries = entries
|
|
111
|
+
.as_array()
|
|
112
|
+
.ok_or("Invalid ingredient list: expected an array")?;
|
|
113
|
+
|
|
114
|
+
for entry in entries {
|
|
115
|
+
let field = |name: &str| -> Result<&str, String> {
|
|
116
|
+
entry[name]
|
|
117
|
+
.as_str()
|
|
118
|
+
.ok_or_else(|| format!("Invalid ingredient list: missing '{}'", name))
|
|
119
|
+
};
|
|
120
|
+
let (json, format, path) = (field("json")?, field("format")?, field("path")?);
|
|
121
|
+
|
|
122
|
+
let mut stream = File::open(path)
|
|
123
|
+
.map_err(|e| format!("Cannot read ingredient '{}': {}", path, e))?;
|
|
124
|
+
builder
|
|
125
|
+
.add_ingredient_from_stream(json, format, &mut stream)
|
|
126
|
+
.map_err(|e| format!("Cannot add ingredient '{}': {}", path, e))?;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
Ok(())
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type BoxError = Box<dyn std::error::Error>;
|
|
133
|
+
|
|
134
|
+
// The description of what to sign, shared by the file and buffer paths.
|
|
135
|
+
struct SigningRequest<'a> {
|
|
136
|
+
cert_path: &'a str,
|
|
137
|
+
key_path: &'a str,
|
|
138
|
+
alg: &'a str,
|
|
139
|
+
manifest_json: Option<&'a str>,
|
|
140
|
+
intent: Option<&'a str>,
|
|
141
|
+
ingredient_files_json: Option<&'a str>,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fn build_signer(cert_path: &str, key_path: &str, alg_str: &str) -> Result<Box<dyn c2pa::Signer + Send + Sync>, BoxError> {
|
|
50
145
|
let cert = std::fs::read(cert_path)
|
|
51
146
|
.map_err(|e| format!("Cannot read certificate '{}': {}", cert_path, e))?;
|
|
52
147
|
let key = std::fs::read(key_path)
|
|
53
148
|
.map_err(|e| format!("Cannot read key '{}': {}", key_path, e))?;
|
|
54
149
|
|
|
55
150
|
let alg = alg_from_str(alg_str)?;
|
|
56
|
-
|
|
57
|
-
.map_err(|e| format!("Failed to create signer: {}", e))
|
|
151
|
+
create_signer::from_keys(&cert, &key, alg, None)
|
|
152
|
+
.map_err(|e| format!("Failed to create signer: {}", e).into())
|
|
153
|
+
}
|
|
58
154
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
let default_json = format!(r#"{{"title": "{}"}}"#, title);
|
|
65
|
-
let json = manifest_json.unwrap_or(&default_json);
|
|
155
|
+
// A Builder carrying the manifest, intent and ingredients, ready to sign.
|
|
156
|
+
// `fallback_title` is used only when no manifest JSON was supplied.
|
|
157
|
+
fn build_builder(request: &SigningRequest, fallback_title: &str) -> Result<Builder, BoxError> {
|
|
158
|
+
let default_json = format!(r#"{{"title": "{}"}}"#, fallback_title.replace('"', "\\\""));
|
|
159
|
+
let json = request.manifest_json.unwrap_or(&default_json);
|
|
66
160
|
|
|
67
|
-
let mut builder = Builder::
|
|
161
|
+
let mut builder = Builder::from_shared_context(&shared_context())
|
|
162
|
+
.with_definition(json)
|
|
68
163
|
.map_err(|e| format!("Invalid manifest JSON: {}", e))?;
|
|
69
164
|
|
|
70
|
-
if let Some(intent) =
|
|
165
|
+
if let Some(intent) = request.intent {
|
|
71
166
|
builder.set_intent(intent_from_str(intent)?);
|
|
72
167
|
}
|
|
73
168
|
|
|
74
|
-
|
|
169
|
+
if let Some(files) = request.ingredient_files_json {
|
|
170
|
+
add_ingredient_files(&mut builder, files)?;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
Ok(builder)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
fn do_sign_file(source_path: &str, dest_path: &str, request: &SigningRequest) -> Result<(), BoxError> {
|
|
177
|
+
let signer = build_signer(request.cert_path, request.key_path, request.alg)?;
|
|
178
|
+
|
|
179
|
+
let title = Path::new(source_path)
|
|
180
|
+
.file_name()
|
|
181
|
+
.and_then(|n| n.to_str())
|
|
182
|
+
.unwrap_or("unknown");
|
|
183
|
+
let mut builder = build_builder(request, title)?;
|
|
184
|
+
|
|
185
|
+
builder
|
|
186
|
+
.sign_file(&*signer, source_path, dest_path)
|
|
75
187
|
.map_err(|e| format!("Signing failed: {}", e))?;
|
|
76
188
|
|
|
77
189
|
Ok(())
|
|
78
190
|
}
|
|
79
191
|
|
|
192
|
+
// Sign bytes held in memory. The source is read through a Cursor, and the
|
|
193
|
+
// destination has to be one too: c2pa-rs writes the asset and then seeks back
|
|
194
|
+
// to hash it and patch the manifest in, so a write-only sink will not do.
|
|
195
|
+
fn do_sign_buffer(data: &[u8], format: &str, request: &SigningRequest) -> Result<Vec<u8>, BoxError> {
|
|
196
|
+
let signer = build_signer(request.cert_path, request.key_path, request.alg)?;
|
|
197
|
+
let mut builder = build_builder(request, "buffer")?;
|
|
198
|
+
|
|
199
|
+
let mut source = Cursor::new(data);
|
|
200
|
+
let mut dest = Cursor::new(Vec::new());
|
|
201
|
+
builder
|
|
202
|
+
.sign(&*signer, format, &mut source, &mut dest)
|
|
203
|
+
.map_err(|e| format!("Signing failed: {}", e))?;
|
|
204
|
+
|
|
205
|
+
Ok(dest.into_inner())
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
fn do_read_buffer(data: &[u8], format: &str) -> Result<String, BoxError> {
|
|
209
|
+
let reader = Reader::from_shared_context(&shared_context())
|
|
210
|
+
.with_stream(format, Cursor::new(data))
|
|
211
|
+
.map_err(|e| format!("Failed to read manifest from buffer: {}", e))?;
|
|
212
|
+
Ok(reader.json())
|
|
213
|
+
}
|
|
214
|
+
|
|
80
215
|
fn do_read_file(path: &str) -> Result<String, Box<dyn std::error::Error>> {
|
|
81
|
-
let reader = Reader::
|
|
216
|
+
let reader = Reader::from_shared_context(&shared_context())
|
|
217
|
+
.with_file(path)
|
|
82
218
|
.map_err(|e| format!("Failed to read manifest from '{}': {}", path, e))?;
|
|
83
219
|
Ok(reader.json())
|
|
84
220
|
}
|
|
85
221
|
|
|
222
|
+
// ─── Running without the GVL ──────────────────────────────────────────────────
|
|
223
|
+
//
|
|
224
|
+
// Signing and reading are CPU-bound Rust with no need of the interpreter, so
|
|
225
|
+
// they run with Ruby's global VM lock released and other Ruby threads make
|
|
226
|
+
// progress meanwhile. magnus does not wrap rb_thread_call_without_gvl, hence
|
|
227
|
+
// the trampoline: the closure travels through the void pointer, its result
|
|
228
|
+
// travels back the same way, and nothing inside may touch Ruby.
|
|
229
|
+
//
|
|
230
|
+
// A panic must not unwind across the extern "C" frame (Rust aborts if it
|
|
231
|
+
// does), so it is caught on the far side and resumed once the lock is held.
|
|
232
|
+
|
|
233
|
+
type NoGvlSlot<F, R> = (Option<F>, Option<std::thread::Result<R>>);
|
|
234
|
+
|
|
235
|
+
unsafe extern "C" fn no_gvl_trampoline<F, R>(arg: *mut std::ffi::c_void) -> *mut std::ffi::c_void
|
|
236
|
+
where
|
|
237
|
+
F: FnOnce() -> R,
|
|
238
|
+
{
|
|
239
|
+
let slot = &mut *(arg as *mut NoGvlSlot<F, R>);
|
|
240
|
+
let f = slot.0.take().expect("closure taken twice");
|
|
241
|
+
slot.1 = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)));
|
|
242
|
+
std::ptr::null_mut()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
fn without_gvl<F, R>(f: F) -> R
|
|
246
|
+
where
|
|
247
|
+
F: FnOnce() -> R,
|
|
248
|
+
{
|
|
249
|
+
let mut slot: NoGvlSlot<F, R> = (Some(f), None);
|
|
250
|
+
|
|
251
|
+
// RUBY_UBF_IO is a macro, not a symbol, so bindgen has no name for it. It
|
|
252
|
+
// is the sentinel (rb_unblock_function_t *)-1, which tells Ruby to use
|
|
253
|
+
// its own IO unblocker: a Thread#kill or Timeout aimed at this thread
|
|
254
|
+
// interrupts a blocking syscall (a remote manifest fetch, say) rather
|
|
255
|
+
// than waiting for the call to finish. Option<fn> has the null niche, so
|
|
256
|
+
// a non-null bit pattern is a valid Some that Ruby compares by value and
|
|
257
|
+
// never calls.
|
|
258
|
+
let ubf: rb_sys::rb_unblock_function_t = unsafe { std::mem::transmute(-1isize) };
|
|
259
|
+
|
|
260
|
+
unsafe {
|
|
261
|
+
rb_sys::rb_thread_call_without_gvl(
|
|
262
|
+
Some(no_gvl_trampoline::<F, R>),
|
|
263
|
+
&mut slot as *mut NoGvlSlot<F, R> as *mut std::ffi::c_void,
|
|
264
|
+
ubf,
|
|
265
|
+
std::ptr::null_mut(),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
match slot.1.expect("closure did not run") {
|
|
270
|
+
Ok(value) => value,
|
|
271
|
+
Err(panic) => std::panic::resume_unwind(panic),
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
86
275
|
// ─── Ruby-facing functions ────────────────────────────────────────────────────
|
|
87
276
|
|
|
277
|
+
fn runtime_error(e: BoxError) -> Error {
|
|
278
|
+
Error::new(
|
|
279
|
+
Ruby::get().expect("called from Ruby thread").exception_runtime_error(),
|
|
280
|
+
e.to_string(),
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
88
284
|
fn sign_file(
|
|
89
285
|
source: String,
|
|
90
286
|
dest: String,
|
|
@@ -93,18 +289,65 @@ fn sign_file(
|
|
|
93
289
|
alg: Option<String>,
|
|
94
290
|
manifest_json: Option<String>,
|
|
95
291
|
intent: Option<String>,
|
|
292
|
+
ingredient_files: Option<String>,
|
|
96
293
|
) -> Result<String, Error> {
|
|
97
|
-
let
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
.
|
|
294
|
+
let request = SigningRequest {
|
|
295
|
+
cert_path: &cert,
|
|
296
|
+
key_path: &key,
|
|
297
|
+
alg: alg.as_deref().unwrap_or("es256"),
|
|
298
|
+
manifest_json: manifest_json.as_deref(),
|
|
299
|
+
intent: intent.as_deref(),
|
|
300
|
+
ingredient_files_json: ingredient_files.as_deref(),
|
|
301
|
+
};
|
|
101
302
|
|
|
303
|
+
without_gvl(|| do_sign_file(&source, &dest, &request)).map_err(runtime_error)?;
|
|
102
304
|
Ok(dest)
|
|
103
305
|
}
|
|
104
306
|
|
|
307
|
+
// Takes an RString rather than a String so the bytes arrive untouched: a
|
|
308
|
+
// String argument would be transcoded to UTF-8, which is wrong for a JPEG.
|
|
309
|
+
// The slice is copied out at once, since Ruby may move or free the backing
|
|
310
|
+
// store the moment control returns to it.
|
|
311
|
+
fn sign_buffer(
|
|
312
|
+
ruby: &Ruby,
|
|
313
|
+
data: RString,
|
|
314
|
+
format: String,
|
|
315
|
+
cert: String,
|
|
316
|
+
key: String,
|
|
317
|
+
alg: Option<String>,
|
|
318
|
+
manifest_json: Option<String>,
|
|
319
|
+
intent: Option<String>,
|
|
320
|
+
ingredient_files: Option<String>,
|
|
321
|
+
) -> Result<RString, Error> {
|
|
322
|
+
let bytes = unsafe { data.as_slice() }.to_vec();
|
|
323
|
+
let request = SigningRequest {
|
|
324
|
+
cert_path: &cert,
|
|
325
|
+
key_path: &key,
|
|
326
|
+
alg: alg.as_deref().unwrap_or("es256"),
|
|
327
|
+
manifest_json: manifest_json.as_deref(),
|
|
328
|
+
intent: intent.as_deref(),
|
|
329
|
+
ingredient_files_json: ingredient_files.as_deref(),
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
let signed = without_gvl(|| do_sign_buffer(&bytes, &format, &request)).map_err(runtime_error)?;
|
|
333
|
+
Ok(ruby.str_from_slice(&signed))
|
|
334
|
+
}
|
|
335
|
+
|
|
105
336
|
fn read_file(path: String) -> Result<String, Error> {
|
|
106
|
-
do_read_file(&path)
|
|
107
|
-
|
|
337
|
+
without_gvl(|| do_read_file(&path)).map_err(runtime_error)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// c2pa-rs sniffs the container from the leading bytes and lets the hint win
|
|
341
|
+
// only when it agrees; the hint carries the decision alone when sniffing
|
|
342
|
+
// fails, as it does for SVG.
|
|
343
|
+
fn read_buffer(data: RString, format: Option<String>) -> Result<String, Error> {
|
|
344
|
+
let bytes = unsafe { data.as_slice() }.to_vec();
|
|
345
|
+
let format = format.as_deref().unwrap_or("application/octet-stream");
|
|
346
|
+
without_gvl(|| do_read_buffer(&bytes, format)).map_err(runtime_error)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
fn configure(settings_json: String) -> Result<(), Error> {
|
|
350
|
+
do_configure(&settings_json).map_err(runtime_error)
|
|
108
351
|
}
|
|
109
352
|
|
|
110
353
|
fn sdk_version() -> String {
|
|
@@ -118,8 +361,11 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
118
361
|
let c2pa = ruby.define_module("C2PA")?;
|
|
119
362
|
let native = c2pa.define_module("Native")?;
|
|
120
363
|
|
|
121
|
-
native.define_singleton_method("sign_file", function!(sign_file,
|
|
364
|
+
native.define_singleton_method("sign_file", function!(sign_file, 8))?;
|
|
365
|
+
native.define_singleton_method("sign_buffer", function!(sign_buffer, 8))?;
|
|
122
366
|
native.define_singleton_method("read_file", function!(read_file, 1))?;
|
|
367
|
+
native.define_singleton_method("read_buffer", function!(read_buffer, 2))?;
|
|
368
|
+
native.define_singleton_method("configure", function!(configure, 1))?;
|
|
123
369
|
native.define_singleton_method("sdk_version", function!(sdk_version, 0))?;
|
|
124
370
|
|
|
125
371
|
Ok(())
|
data/lib/c2pa/config.rb
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
# Whether to embed a thumbnail of the asset in its manifest, and of each
|
|
42
|
+
# ingredient supplied as a file.
|
|
43
|
+
#
|
|
44
|
+
# Off by default, which departs from c2pa-rs. Its thumbnail generation
|
|
45
|
+
# scales to a fixed long edge and upscales to reach it, so a 160x120 image
|
|
46
|
+
# gets a 1024x768 thumbnail ten times its own size. Turn it on for assets
|
|
47
|
+
# that are larger than thumbnail_size, or set thumbnail_size to suit.
|
|
48
|
+
#
|
|
49
|
+
# Thumbnails are produced for JPEG, PNG, WebP and TIFF. Other formats are
|
|
50
|
+
# signed without one; c2pa-rs treats that as non-fatal.
|
|
51
|
+
attr_accessor :thumbnails
|
|
52
|
+
|
|
53
|
+
# Longest edge of the thumbnail in pixels. c2pa-rs's default is 1024.
|
|
54
|
+
attr_accessor :thumbnail_size
|
|
55
|
+
|
|
56
|
+
# :jpeg, :png or :gif. Left unset, c2pa-rs picks the smaller encoding.
|
|
57
|
+
attr_accessor :thumbnail_format
|
|
58
|
+
|
|
59
|
+
# :low, :medium or :high. c2pa-rs's default is :medium.
|
|
60
|
+
attr_accessor :thumbnail_quality
|
|
61
|
+
|
|
62
|
+
def initialize
|
|
63
|
+
@trust_anchors = nil
|
|
64
|
+
@trust_list = nil
|
|
65
|
+
@allowed_certificates = nil
|
|
66
|
+
@verify_trust = nil
|
|
67
|
+
@remote_manifest_fetch = nil
|
|
68
|
+
@ocsp_fetch = nil
|
|
69
|
+
@thumbnails = false
|
|
70
|
+
@thumbnail_size = nil
|
|
71
|
+
@thumbnail_format = nil
|
|
72
|
+
@thumbnail_quality = nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The settings document c2pa-rs expects.
|
|
76
|
+
#
|
|
77
|
+
# Only values that were actually set are included, so anything left alone
|
|
78
|
+
# keeps the SDK's default rather than being pinned to ours.
|
|
79
|
+
#
|
|
80
|
+
# @return [String] JSON
|
|
81
|
+
def to_json
|
|
82
|
+
trust = {}
|
|
83
|
+
trust["user_anchors"] = read_pem(@trust_anchors) unless @trust_anchors.nil?
|
|
84
|
+
trust["trust_anchors"] = read_pem(@trust_list) unless @trust_list.nil?
|
|
85
|
+
trust["allowed_list"] = read_pem(@allowed_certificates) unless @allowed_certificates.nil?
|
|
86
|
+
|
|
87
|
+
verify = {}
|
|
88
|
+
verify["verify_trust"] = @verify_trust unless @verify_trust.nil?
|
|
89
|
+
verify["remote_manifest_fetch"] = @remote_manifest_fetch unless @remote_manifest_fetch.nil?
|
|
90
|
+
verify["ocsp_fetch"] = @ocsp_fetch unless @ocsp_fetch.nil?
|
|
91
|
+
|
|
92
|
+
# enabled is always sent: this gem's default differs from c2pa-rs's, so
|
|
93
|
+
# leaving it out would mean inheriting the wrong one.
|
|
94
|
+
thumbnail = { "enabled" => @thumbnails == true }
|
|
95
|
+
thumbnail["long_edge"] = Integer(@thumbnail_size) unless @thumbnail_size.nil?
|
|
96
|
+
thumbnail["format"] = @thumbnail_format.to_s.downcase unless @thumbnail_format.nil?
|
|
97
|
+
thumbnail["quality"] = @thumbnail_quality.to_s.downcase unless @thumbnail_quality.nil?
|
|
98
|
+
|
|
99
|
+
settings = { "builder" => { "thumbnail" => thumbnail } }
|
|
100
|
+
settings["trust"] = trust unless trust.empty?
|
|
101
|
+
settings["verify"] = verify unless verify.empty?
|
|
102
|
+
|
|
103
|
+
JSON.generate(settings)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# Accept either PEM text or a path to a file containing it, since callers
|
|
109
|
+
# naturally have one or the other.
|
|
110
|
+
def read_pem(value)
|
|
111
|
+
string = value.to_s
|
|
112
|
+
return string if string.include?("BEGIN CERTIFICATE")
|
|
113
|
+
|
|
114
|
+
unless File.exist?(string)
|
|
115
|
+
raise InvalidSettingsError,
|
|
116
|
+
"expected PEM text or a readable file, got #{string.inspect}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
File.read(string)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
data/lib/c2pa/error.rb
CHANGED
data/lib/c2pa/manifest.rb
CHANGED
|
@@ -4,17 +4,15 @@ module C2PA
|
|
|
4
4
|
class Manifest
|
|
5
5
|
# Intents this gem can express.
|
|
6
6
|
#
|
|
7
|
-
# :edit
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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.
|
|
10
13
|
#
|
|
11
14
|
# Omitting the intent produces a manifest for a newly created asset.
|
|
12
|
-
|
|
13
|
-
# c2pa-rs also has an :update intent, a restricted edit for non-editorial
|
|
14
|
-
# changes. It is not offered here because it requires an ingredient with
|
|
15
|
-
# real content, and add_ingredient records metadata only — signing with it
|
|
16
|
-
# fails with "ingredient file not found". Tracked separately.
|
|
17
|
-
INTENTS = %i[edit].freeze
|
|
15
|
+
INTENTS = %i[edit update].freeze
|
|
18
16
|
|
|
19
17
|
# c2pa-rs records itself in a namespaced field alongside the generator
|
|
20
18
|
# name, so the gem does the same when an application supplies its own.
|
|
@@ -43,8 +41,16 @@ module C2PA
|
|
|
43
41
|
@actions = []
|
|
44
42
|
@assertions = []
|
|
45
43
|
@ingredients = []
|
|
44
|
+
@ingredient_files = []
|
|
46
45
|
end
|
|
47
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
|
+
|
|
48
54
|
# Add a C2PA action to this manifest.
|
|
49
55
|
#
|
|
50
56
|
# @param action [String] one of the C2PA::Actions constants
|
|
@@ -115,18 +121,48 @@ module C2PA
|
|
|
115
121
|
|
|
116
122
|
# Add an ingredient (source asset) to this manifest.
|
|
117
123
|
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
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
|
|
121
140
|
# @param relationship [String] relationship to this asset; defaults to "parentOf"
|
|
141
|
+
# @param file [String, nil] path to the ingredient file
|
|
122
142
|
# @return [self]
|
|
123
|
-
|
|
124
|
-
|
|
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 = {
|
|
125
146
|
"title" => title,
|
|
126
147
|
"format" => format,
|
|
127
148
|
"instance_id" => instance_id,
|
|
128
149
|
"relationship" => relationship
|
|
129
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
|
+
}
|
|
130
166
|
self
|
|
131
167
|
end
|
|
132
168
|
|
data/lib/c2pa/version.rb
CHANGED