ruby-c2pa 0.4.0 → 0.6.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 +93 -1
- data/CONTRIBUTING.md +30 -4
- data/README.md +244 -30
- data/ext/c2pa_native/Cargo.lock +202 -290
- data/ext/c2pa_native/Cargo.toml +9 -1
- data/ext/c2pa_native/src/lib.rs +271 -40
- data/lib/c2pa/config.rb +65 -3
- data/lib/c2pa/manifest.rb +42 -6
- data/lib/c2pa/version.rb +1 -1
- data/lib/c2pa.rb +153 -10
- metadata +1 -1
data/ext/c2pa_native/Cargo.toml
CHANGED
|
@@ -9,8 +9,16 @@ 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.91.1", features = ["file_io", "pdf", "add_thumbnails"] }
|
|
13
16
|
serde_json = "1"
|
|
17
|
+
# Not used directly. c2pa-rs depends on zip with default features off, which
|
|
18
|
+
# leaves it able to read only stored entries, and real EPUB, Office and
|
|
19
|
+
# OpenDocument files deflate theirs. Cargo unifies features, so enabling
|
|
20
|
+
# deflate here gives it to c2pa-rs. flate2 is already in the tree.
|
|
21
|
+
zip = { version = "8.6", default-features = false, features = ["deflate-flate2"] }
|
|
14
22
|
|
|
15
23
|
[profile.release]
|
|
16
24
|
opt-level = 3
|
data/ext/c2pa_native/src/lib.rs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
use std::fs::File;
|
|
2
|
+
use std::io::Cursor;
|
|
2
3
|
use std::path::Path;
|
|
3
4
|
use std::sync::{Arc, RwLock, OnceLock};
|
|
4
|
-
use c2pa::{create_signer, Builder, BuilderIntent, Context, Reader, SigningAlg};
|
|
5
|
-
use magnus::{function, prelude::*, Error, Ruby};
|
|
5
|
+
use c2pa::{create_signer, format_from_path, Builder, BuilderIntent, Context, Reader, SigningAlg};
|
|
6
|
+
use magnus::{function, prelude::*, Error, RString, Ruby};
|
|
6
7
|
|
|
7
8
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
8
9
|
|
|
@@ -18,9 +19,30 @@ use magnus::{function, prelude::*, Error, Ruby};
|
|
|
18
19
|
//
|
|
19
20
|
// It currently carries defaults. Exposing settings to Ruby is a separate piece
|
|
20
21
|
// of work; this is the seam that makes it possible.
|
|
22
|
+
// The two places this gem departs from c2pa-rs's defaults; Config::to_json
|
|
23
|
+
// always states both.
|
|
24
|
+
//
|
|
25
|
+
// Thumbnails: c2pa-rs generates them when the feature is compiled in, scaling
|
|
26
|
+
// to a 1024px long edge — and it upscales, so a 160x120 source gets a
|
|
27
|
+
// 1024x768 "thumbnail" ten times its size. Off unless asked for.
|
|
28
|
+
//
|
|
29
|
+
// verify_after_sign: since 0.91 c2pa-rs validates the manifest before writing
|
|
30
|
+
// and refuses an invalid one. The gem's own guard reads the finished output
|
|
31
|
+
// back, which also checks the hashes on the bytes written (upstream's check
|
|
32
|
+
// skips them by default), and honours verify: false for inspecting an invalid
|
|
33
|
+
// file. Running both would read every output twice, so upstream's is off.
|
|
34
|
+
const DEFAULT_SETTINGS: &str =
|
|
35
|
+
r#"{"builder":{"thumbnail":{"enabled":false}},"verify":{"verify_after_sign":false}}"#;
|
|
36
|
+
|
|
21
37
|
fn context_slot() -> &'static RwLock<Arc<Context>> {
|
|
22
38
|
static CONTEXT: OnceLock<RwLock<Arc<Context>>> = OnceLock::new();
|
|
23
|
-
CONTEXT.get_or_init(||
|
|
39
|
+
CONTEXT.get_or_init(|| {
|
|
40
|
+
let context = Context::new()
|
|
41
|
+
.with_settings(DEFAULT_SETTINGS)
|
|
42
|
+
.expect("built-in default settings are valid")
|
|
43
|
+
.into_shared();
|
|
44
|
+
RwLock::new(context)
|
|
45
|
+
})
|
|
24
46
|
}
|
|
25
47
|
|
|
26
48
|
fn shared_context() -> Arc<Context> {
|
|
@@ -116,51 +138,122 @@ fn add_ingredient_files(
|
|
|
116
138
|
Ok(())
|
|
117
139
|
}
|
|
118
140
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
141
|
+
type BoxError = Box<dyn std::error::Error>;
|
|
142
|
+
|
|
143
|
+
// The description of what to sign, shared by the file and buffer paths.
|
|
144
|
+
struct SigningRequest<'a> {
|
|
145
|
+
cert_path: &'a str,
|
|
146
|
+
key_path: &'a str,
|
|
147
|
+
alg: &'a str,
|
|
148
|
+
manifest_json: Option<&'a str>,
|
|
149
|
+
intent: Option<&'a str>,
|
|
150
|
+
ingredient_files_json: Option<&'a str>,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
fn build_signer(cert_path: &str, key_path: &str, alg_str: &str) -> Result<Box<dyn c2pa::Signer + Send + Sync>, BoxError> {
|
|
129
154
|
let cert = std::fs::read(cert_path)
|
|
130
155
|
.map_err(|e| format!("Cannot read certificate '{}': {}", cert_path, e))?;
|
|
131
156
|
let key = std::fs::read(key_path)
|
|
132
157
|
.map_err(|e| format!("Cannot read key '{}': {}", key_path, e))?;
|
|
133
158
|
|
|
134
159
|
let alg = alg_from_str(alg_str)?;
|
|
135
|
-
|
|
136
|
-
.map_err(|e| format!("Failed to create signer: {}", e))
|
|
160
|
+
create_signer::from_keys(&cert, &key, alg, None)
|
|
161
|
+
.map_err(|e| format!("Failed to create signer: {}", e).into())
|
|
162
|
+
}
|
|
137
163
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
let default_json = format!(r#"{{"title": "{}"}}"#, title);
|
|
144
|
-
let json = manifest_json.unwrap_or(&default_json);
|
|
164
|
+
// A Builder carrying the manifest, intent and ingredients, ready to sign.
|
|
165
|
+
// `fallback_title` is used only when no manifest JSON was supplied.
|
|
166
|
+
fn build_builder(request: &SigningRequest, fallback_title: &str) -> Result<Builder, BoxError> {
|
|
167
|
+
let default_json = format!(r#"{{"title": "{}"}}"#, fallback_title.replace('"', "\\\""));
|
|
168
|
+
let json = request.manifest_json.unwrap_or(&default_json);
|
|
145
169
|
|
|
146
170
|
let mut builder = Builder::from_shared_context(&shared_context())
|
|
147
171
|
.with_definition(json)
|
|
148
172
|
.map_err(|e| format!("Invalid manifest JSON: {}", e))?;
|
|
149
173
|
|
|
150
|
-
if let Some(intent) =
|
|
174
|
+
if let Some(intent) = request.intent {
|
|
151
175
|
builder.set_intent(intent_from_str(intent)?);
|
|
152
176
|
}
|
|
153
177
|
|
|
154
|
-
if let Some(files) = ingredient_files_json {
|
|
178
|
+
if let Some(files) = request.ingredient_files_json {
|
|
155
179
|
add_ingredient_files(&mut builder, files)?;
|
|
156
180
|
}
|
|
157
181
|
|
|
158
|
-
builder
|
|
182
|
+
Ok(builder)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
fn do_sign_file(source_path: &str, dest_path: &str, request: &SigningRequest) -> Result<(), BoxError> {
|
|
186
|
+
let signer = build_signer(request.cert_path, request.key_path, request.alg)?;
|
|
187
|
+
|
|
188
|
+
let title = Path::new(source_path)
|
|
189
|
+
.file_name()
|
|
190
|
+
.and_then(|n| n.to_str())
|
|
191
|
+
.unwrap_or("unknown");
|
|
192
|
+
let mut builder = build_builder(request, title)?;
|
|
193
|
+
|
|
194
|
+
builder
|
|
195
|
+
.sign_file(&*signer, source_path, dest_path)
|
|
196
|
+
.map_err(|e| format!("Signing failed: {}", e))?;
|
|
197
|
+
|
|
198
|
+
Ok(())
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Sign without embedding: the asset is hashed as it is, the output carries no
|
|
202
|
+
// manifest, and the manifest store goes to its own file. Hashing the asset
|
|
203
|
+
// unmodified is what makes the pair valid; stripping an embedded manifest out
|
|
204
|
+
// afterwards does not work, because embedding moves offsets elsewhere in the
|
|
205
|
+
// file (chunk offsets in an MP4's moov, for one).
|
|
206
|
+
fn do_sign_file_detached(source_path: &str, dest_path: &str, sidecar_path: &str, request: &SigningRequest)
|
|
207
|
+
-> Result<(), BoxError> {
|
|
208
|
+
let signer = build_signer(request.cert_path, request.key_path, request.alg)?;
|
|
209
|
+
|
|
210
|
+
let title = Path::new(source_path)
|
|
211
|
+
.file_name()
|
|
212
|
+
.and_then(|n| n.to_str())
|
|
213
|
+
.unwrap_or("unknown");
|
|
214
|
+
let mut builder = build_builder(request, title)?;
|
|
215
|
+
builder.set_no_embed(true);
|
|
216
|
+
|
|
217
|
+
let format = format_from_path(source_path)
|
|
218
|
+
.ok_or_else(|| format!("Signing failed: cannot tell the format of '{}' from its extension", source_path))?;
|
|
219
|
+
let mut source = File::open(source_path)
|
|
220
|
+
.map_err(|e| format!("Signing failed: cannot open '{}': {}", source_path, e))?;
|
|
221
|
+
let mut dest = std::fs::OpenOptions::new().read(true).write(true).create_new(true).open(dest_path)
|
|
222
|
+
.map_err(|e| format!("Signing failed: cannot create '{}': {}", dest_path, e))?;
|
|
223
|
+
|
|
224
|
+
let manifest = builder
|
|
225
|
+
.sign(&*signer, &format, &mut source, &mut dest)
|
|
159
226
|
.map_err(|e| format!("Signing failed: {}", e))?;
|
|
160
227
|
|
|
228
|
+
std::fs::write(sidecar_path, manifest)
|
|
229
|
+
.map_err(|e| format!("Signing failed: cannot write '{}': {}", sidecar_path, e))?;
|
|
230
|
+
|
|
161
231
|
Ok(())
|
|
162
232
|
}
|
|
163
233
|
|
|
234
|
+
// Sign bytes held in memory. The source is read through a Cursor, and the
|
|
235
|
+
// destination has to be one too: c2pa-rs writes the asset and then seeks back
|
|
236
|
+
// to hash it and patch the manifest in, so a write-only sink will not do.
|
|
237
|
+
fn do_sign_buffer(data: &[u8], format: &str, request: &SigningRequest) -> Result<Vec<u8>, BoxError> {
|
|
238
|
+
let signer = build_signer(request.cert_path, request.key_path, request.alg)?;
|
|
239
|
+
let mut builder = build_builder(request, "buffer")?;
|
|
240
|
+
|
|
241
|
+
let mut source = Cursor::new(data);
|
|
242
|
+
let mut dest = Cursor::new(Vec::new());
|
|
243
|
+
builder
|
|
244
|
+
.sign(&*signer, format, &mut source, &mut dest)
|
|
245
|
+
.map_err(|e| format!("Signing failed: {}", e))?;
|
|
246
|
+
|
|
247
|
+
Ok(dest.into_inner())
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
fn do_read_buffer(data: &[u8], format: &str) -> Result<String, BoxError> {
|
|
251
|
+
let reader = Reader::from_shared_context(&shared_context())
|
|
252
|
+
.with_stream(format, Cursor::new(data))
|
|
253
|
+
.map_err(|e| format!("Failed to read manifest from buffer: {}", e))?;
|
|
254
|
+
Ok(reader.json())
|
|
255
|
+
}
|
|
256
|
+
|
|
164
257
|
fn do_read_file(path: &str) -> Result<String, Box<dyn std::error::Error>> {
|
|
165
258
|
let reader = Reader::from_shared_context(&shared_context())
|
|
166
259
|
.with_file(path)
|
|
@@ -168,8 +261,89 @@ fn do_read_file(path: &str) -> Result<String, Box<dyn std::error::Error>> {
|
|
|
168
261
|
Ok(reader.json())
|
|
169
262
|
}
|
|
170
263
|
|
|
264
|
+
// A manifest store kept apart from its asset, as a .c2pa sidecar or fetched
|
|
265
|
+
// from a remote URL. The store is validated against the asset's bytes; read
|
|
266
|
+
// on its own, a store's hard binding hashes the store itself and never
|
|
267
|
+
// matches.
|
|
268
|
+
fn do_read_detached(manifest: &[u8], format: &str, asset: impl std::io::Read + std::io::Seek + Send)
|
|
269
|
+
-> Result<String, BoxError> {
|
|
270
|
+
let reader = Reader::from_shared_context(&shared_context())
|
|
271
|
+
.with_manifest_data_and_stream(manifest, format, asset)
|
|
272
|
+
.map_err(|e| format!("Failed to read detached manifest: {}", e))?;
|
|
273
|
+
Ok(reader.json())
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
fn do_read_file_with_manifest(path: &str, manifest_path: &str) -> Result<String, BoxError> {
|
|
277
|
+
let manifest = std::fs::read(manifest_path)
|
|
278
|
+
.map_err(|e| format!("Failed to read manifest file '{}': {}", manifest_path, e))?;
|
|
279
|
+
let format = format_from_path(path)
|
|
280
|
+
.ok_or_else(|| format!("Failed to read '{}': cannot tell its format from the extension", path))?;
|
|
281
|
+
let asset = File::open(path).map_err(|e| format!("Failed to open '{}': {}", path, e))?;
|
|
282
|
+
do_read_detached(&manifest, &format, asset)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─── Running without the GVL ──────────────────────────────────────────────────
|
|
286
|
+
//
|
|
287
|
+
// Signing and reading are CPU-bound Rust with no need of the interpreter, so
|
|
288
|
+
// they run with Ruby's global VM lock released and other Ruby threads make
|
|
289
|
+
// progress meanwhile. magnus does not wrap rb_thread_call_without_gvl, hence
|
|
290
|
+
// the trampoline: the closure travels through the void pointer, its result
|
|
291
|
+
// travels back the same way, and nothing inside may touch Ruby.
|
|
292
|
+
//
|
|
293
|
+
// A panic must not unwind across the extern "C" frame (Rust aborts if it
|
|
294
|
+
// does), so it is caught on the far side and resumed once the lock is held.
|
|
295
|
+
|
|
296
|
+
type NoGvlSlot<F, R> = (Option<F>, Option<std::thread::Result<R>>);
|
|
297
|
+
|
|
298
|
+
unsafe extern "C" fn no_gvl_trampoline<F, R>(arg: *mut std::ffi::c_void) -> *mut std::ffi::c_void
|
|
299
|
+
where
|
|
300
|
+
F: FnOnce() -> R,
|
|
301
|
+
{
|
|
302
|
+
let slot = &mut *(arg as *mut NoGvlSlot<F, R>);
|
|
303
|
+
let f = slot.0.take().expect("closure taken twice");
|
|
304
|
+
slot.1 = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)));
|
|
305
|
+
std::ptr::null_mut()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
fn without_gvl<F, R>(f: F) -> R
|
|
309
|
+
where
|
|
310
|
+
F: FnOnce() -> R,
|
|
311
|
+
{
|
|
312
|
+
let mut slot: NoGvlSlot<F, R> = (Some(f), None);
|
|
313
|
+
|
|
314
|
+
// RUBY_UBF_IO is a macro, not a symbol, so bindgen has no name for it. It
|
|
315
|
+
// is the sentinel (rb_unblock_function_t *)-1, which tells Ruby to use
|
|
316
|
+
// its own IO unblocker: a Thread#kill or Timeout aimed at this thread
|
|
317
|
+
// interrupts a blocking syscall (a remote manifest fetch, say) rather
|
|
318
|
+
// than waiting for the call to finish. Option<fn> has the null niche, so
|
|
319
|
+
// a non-null bit pattern is a valid Some that Ruby compares by value and
|
|
320
|
+
// never calls.
|
|
321
|
+
let ubf: rb_sys::rb_unblock_function_t = unsafe { std::mem::transmute(-1isize) };
|
|
322
|
+
|
|
323
|
+
unsafe {
|
|
324
|
+
rb_sys::rb_thread_call_without_gvl(
|
|
325
|
+
Some(no_gvl_trampoline::<F, R>),
|
|
326
|
+
&mut slot as *mut NoGvlSlot<F, R> as *mut std::ffi::c_void,
|
|
327
|
+
ubf,
|
|
328
|
+
std::ptr::null_mut(),
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
match slot.1.expect("closure did not run") {
|
|
333
|
+
Ok(value) => value,
|
|
334
|
+
Err(panic) => std::panic::resume_unwind(panic),
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
171
338
|
// ─── Ruby-facing functions ────────────────────────────────────────────────────
|
|
172
339
|
|
|
340
|
+
fn runtime_error(e: BoxError) -> Error {
|
|
341
|
+
Error::new(
|
|
342
|
+
Ruby::get().expect("called from Ruby thread").exception_runtime_error(),
|
|
343
|
+
e.to_string(),
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
173
347
|
fn sign_file(
|
|
174
348
|
source: String,
|
|
175
349
|
dest: String,
|
|
@@ -179,28 +353,81 @@ fn sign_file(
|
|
|
179
353
|
manifest_json: Option<String>,
|
|
180
354
|
intent: Option<String>,
|
|
181
355
|
ingredient_files: Option<String>,
|
|
356
|
+
sidecar: Option<String>,
|
|
182
357
|
) -> Result<String, Error> {
|
|
183
|
-
let
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
358
|
+
let request = SigningRequest {
|
|
359
|
+
cert_path: &cert,
|
|
360
|
+
key_path: &key,
|
|
361
|
+
alg: alg.as_deref().unwrap_or("es256"),
|
|
362
|
+
manifest_json: manifest_json.as_deref(),
|
|
363
|
+
intent: intent.as_deref(),
|
|
364
|
+
ingredient_files_json: ingredient_files.as_deref(),
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
without_gvl(|| match &sidecar {
|
|
368
|
+
Some(sidecar) => do_sign_file_detached(&source, &dest, sidecar, &request),
|
|
369
|
+
None => do_sign_file(&source, &dest, &request),
|
|
370
|
+
})
|
|
371
|
+
.map_err(runtime_error)?;
|
|
189
372
|
Ok(dest)
|
|
190
373
|
}
|
|
191
374
|
|
|
375
|
+
// Takes an RString rather than a String so the bytes arrive untouched: a
|
|
376
|
+
// String argument would be transcoded to UTF-8, which is wrong for a JPEG.
|
|
377
|
+
// The slice is copied out at once, since Ruby may move or free the backing
|
|
378
|
+
// store the moment control returns to it.
|
|
379
|
+
fn sign_buffer(
|
|
380
|
+
ruby: &Ruby,
|
|
381
|
+
data: RString,
|
|
382
|
+
format: String,
|
|
383
|
+
cert: String,
|
|
384
|
+
key: String,
|
|
385
|
+
alg: Option<String>,
|
|
386
|
+
manifest_json: Option<String>,
|
|
387
|
+
intent: Option<String>,
|
|
388
|
+
ingredient_files: Option<String>,
|
|
389
|
+
) -> Result<RString, Error> {
|
|
390
|
+
let bytes = unsafe { data.as_slice() }.to_vec();
|
|
391
|
+
let request = SigningRequest {
|
|
392
|
+
cert_path: &cert,
|
|
393
|
+
key_path: &key,
|
|
394
|
+
alg: alg.as_deref().unwrap_or("es256"),
|
|
395
|
+
manifest_json: manifest_json.as_deref(),
|
|
396
|
+
intent: intent.as_deref(),
|
|
397
|
+
ingredient_files_json: ingredient_files.as_deref(),
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
let signed = without_gvl(|| do_sign_buffer(&bytes, &format, &request)).map_err(runtime_error)?;
|
|
401
|
+
Ok(ruby.str_from_slice(&signed))
|
|
402
|
+
}
|
|
403
|
+
|
|
192
404
|
fn read_file(path: String) -> Result<String, Error> {
|
|
193
|
-
do_read_file(&path)
|
|
194
|
-
|
|
405
|
+
without_gvl(|| do_read_file(&path)).map_err(runtime_error)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// c2pa-rs sniffs the container from the leading bytes and lets the hint win
|
|
409
|
+
// only when it agrees; the hint carries the decision alone when sniffing
|
|
410
|
+
// fails, as it does for SVG.
|
|
411
|
+
fn read_buffer(data: RString, format: Option<String>) -> Result<String, Error> {
|
|
412
|
+
let bytes = unsafe { data.as_slice() }.to_vec();
|
|
413
|
+
let format = format.as_deref().unwrap_or("application/octet-stream");
|
|
414
|
+
without_gvl(|| do_read_buffer(&bytes, format)).map_err(runtime_error)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
fn read_file_with_manifest(path: String, manifest_path: String) -> Result<String, Error> {
|
|
418
|
+
without_gvl(|| do_read_file_with_manifest(&path, &manifest_path)).map_err(runtime_error)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Unlike read_buffer, nothing is sniffed from the asset here, so the format
|
|
422
|
+
// is required.
|
|
423
|
+
fn read_buffer_with_manifest(data: RString, format: String, manifest: RString) -> Result<String, Error> {
|
|
424
|
+
let bytes = unsafe { data.as_slice() }.to_vec();
|
|
425
|
+
let manifest = unsafe { manifest.as_slice() }.to_vec();
|
|
426
|
+
without_gvl(|| do_read_detached(&manifest, &format, Cursor::new(bytes))).map_err(runtime_error)
|
|
195
427
|
}
|
|
196
428
|
|
|
197
429
|
fn configure(settings_json: String) -> Result<(), Error> {
|
|
198
|
-
do_configure(&settings_json).map_err(
|
|
199
|
-
Error::new(
|
|
200
|
-
Ruby::get().expect("called from Ruby thread").exception_runtime_error(),
|
|
201
|
-
e.to_string(),
|
|
202
|
-
)
|
|
203
|
-
})
|
|
430
|
+
do_configure(&settings_json).map_err(runtime_error)
|
|
204
431
|
}
|
|
205
432
|
|
|
206
433
|
fn sdk_version() -> String {
|
|
@@ -214,8 +441,12 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
214
441
|
let c2pa = ruby.define_module("C2PA")?;
|
|
215
442
|
let native = c2pa.define_module("Native")?;
|
|
216
443
|
|
|
217
|
-
native.define_singleton_method("sign_file", function!(sign_file,
|
|
444
|
+
native.define_singleton_method("sign_file", function!(sign_file, 9))?;
|
|
445
|
+
native.define_singleton_method("sign_buffer", function!(sign_buffer, 8))?;
|
|
218
446
|
native.define_singleton_method("read_file", function!(read_file, 1))?;
|
|
447
|
+
native.define_singleton_method("read_buffer", function!(read_buffer, 2))?;
|
|
448
|
+
native.define_singleton_method("read_file_with_manifest", function!(read_file_with_manifest, 2))?;
|
|
449
|
+
native.define_singleton_method("read_buffer_with_manifest", function!(read_buffer_with_manifest, 3))?;
|
|
219
450
|
native.define_singleton_method("configure", function!(configure, 1))?;
|
|
220
451
|
native.define_singleton_method("sdk_version", function!(sdk_version, 0))?;
|
|
221
452
|
|
data/lib/c2pa/config.rb
CHANGED
|
@@ -38,6 +38,47 @@ module C2PA
|
|
|
38
38
|
# network requests.
|
|
39
39
|
attr_accessor :ocsp_fetch
|
|
40
40
|
|
|
41
|
+
# Whether network requests made while reading and validating (remote
|
|
42
|
+
# manifests, OCSP, timestamps) may follow HTTP redirects.
|
|
43
|
+
#
|
|
44
|
+
# c2pa-rs defaults this to true but refuses a redirect to an internal
|
|
45
|
+
# address, such as loopback, a private network or a cloud metadata
|
|
46
|
+
# endpoint, because the URL can come from untrusted content. false stops
|
|
47
|
+
# redirects altogether. Neither setting affects a URL that names an
|
|
48
|
+
# internal host directly.
|
|
49
|
+
attr_accessor :allow_redirects
|
|
50
|
+
|
|
51
|
+
# Whether signing an MP4, MOV or other BMFF file leaves its /free and /skip
|
|
52
|
+
# boxes out of the hash. c2pa-rs defaults this to true: those boxes are
|
|
53
|
+
# padding that tools commonly rewrite after signing, and the specification
|
|
54
|
+
# allows excluding them. Set false and any later change to them breaks the
|
|
55
|
+
# signature, like a change to the media itself.
|
|
56
|
+
#
|
|
57
|
+
# It affects signing only. The choice is recorded in the signed file, and
|
|
58
|
+
# readers follow the file whatever their own setting.
|
|
59
|
+
attr_accessor :exclude_free_and_skip_boxes
|
|
60
|
+
|
|
61
|
+
# Whether to embed a thumbnail of the asset in its manifest, and of each
|
|
62
|
+
# ingredient supplied as a file.
|
|
63
|
+
#
|
|
64
|
+
# Off by default, which departs from c2pa-rs. Its thumbnail generation
|
|
65
|
+
# scales to a fixed long edge and upscales to reach it, so a 160x120 image
|
|
66
|
+
# gets a 1024x768 thumbnail ten times its own size. Turn it on for assets
|
|
67
|
+
# that are larger than thumbnail_size, or set thumbnail_size to suit.
|
|
68
|
+
#
|
|
69
|
+
# Thumbnails are produced for JPEG, PNG, WebP and TIFF. Other formats are
|
|
70
|
+
# signed without one; c2pa-rs treats that as non-fatal.
|
|
71
|
+
attr_accessor :thumbnails
|
|
72
|
+
|
|
73
|
+
# Longest edge of the thumbnail in pixels. c2pa-rs's default is 1024.
|
|
74
|
+
attr_accessor :thumbnail_size
|
|
75
|
+
|
|
76
|
+
# :jpeg, :png or :gif. Left unset, c2pa-rs picks the smaller encoding.
|
|
77
|
+
attr_accessor :thumbnail_format
|
|
78
|
+
|
|
79
|
+
# :low, :medium or :high. c2pa-rs's default is :medium.
|
|
80
|
+
attr_accessor :thumbnail_quality
|
|
81
|
+
|
|
41
82
|
def initialize
|
|
42
83
|
@trust_anchors = nil
|
|
43
84
|
@trust_list = nil
|
|
@@ -45,6 +86,12 @@ module C2PA
|
|
|
45
86
|
@verify_trust = nil
|
|
46
87
|
@remote_manifest_fetch = nil
|
|
47
88
|
@ocsp_fetch = nil
|
|
89
|
+
@allow_redirects = nil
|
|
90
|
+
@exclude_free_and_skip_boxes = nil
|
|
91
|
+
@thumbnails = false
|
|
92
|
+
@thumbnail_size = nil
|
|
93
|
+
@thumbnail_format = nil
|
|
94
|
+
@thumbnail_quality = nil
|
|
48
95
|
end
|
|
49
96
|
|
|
50
97
|
# The settings document c2pa-rs expects.
|
|
@@ -59,14 +106,29 @@ module C2PA
|
|
|
59
106
|
trust["trust_anchors"] = read_pem(@trust_list) unless @trust_list.nil?
|
|
60
107
|
trust["allowed_list"] = read_pem(@allowed_certificates) unless @allowed_certificates.nil?
|
|
61
108
|
|
|
62
|
-
|
|
109
|
+
# verify_after_sign is always sent: C2PA.sign runs its own check on the
|
|
110
|
+
# finished output, so c2pa-rs's pre-write check stays off.
|
|
111
|
+
verify = { "verify_after_sign" => false }
|
|
63
112
|
verify["verify_trust"] = @verify_trust unless @verify_trust.nil?
|
|
64
113
|
verify["remote_manifest_fetch"] = @remote_manifest_fetch unless @remote_manifest_fetch.nil?
|
|
65
114
|
verify["ocsp_fetch"] = @ocsp_fetch unless @ocsp_fetch.nil?
|
|
66
115
|
|
|
67
|
-
|
|
116
|
+
# enabled is always sent: this gem's default differs from c2pa-rs's, so
|
|
117
|
+
# leaving it out would mean inheriting the wrong one.
|
|
118
|
+
thumbnail = { "enabled" => @thumbnails == true }
|
|
119
|
+
thumbnail["long_edge"] = Integer(@thumbnail_size) unless @thumbnail_size.nil?
|
|
120
|
+
thumbnail["format"] = @thumbnail_format.to_s.downcase unless @thumbnail_format.nil?
|
|
121
|
+
thumbnail["quality"] = @thumbnail_quality.to_s.downcase unless @thumbnail_quality.nil?
|
|
122
|
+
|
|
123
|
+
builder = { "thumbnail" => thumbnail }
|
|
124
|
+
unless @exclude_free_and_skip_boxes.nil?
|
|
125
|
+
builder["bmff_hash_exclude_free_and_skip_boxes"] = @exclude_free_and_skip_boxes
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
settings = { "builder" => builder }
|
|
129
|
+
settings["core"] = { "allow_redirects" => @allow_redirects } unless @allow_redirects.nil?
|
|
68
130
|
settings["trust"] = trust unless trust.empty?
|
|
69
|
-
settings["verify"] = verify
|
|
131
|
+
settings["verify"] = verify
|
|
70
132
|
|
|
71
133
|
JSON.generate(settings)
|
|
72
134
|
end
|
data/lib/c2pa/manifest.rb
CHANGED
|
@@ -75,6 +75,17 @@ module C2PA
|
|
|
75
75
|
"intent: :edit to C2PA::Manifest.new instead and the action will be added for you."
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# Same limit as c2pa.opened: each entry is a hashed URI that only c2pa-rs
|
|
79
|
+
# can compute, and in 0.91 its builder does not compute them, only
|
|
80
|
+
# checks them when reading. A hand-written hash would pass that check
|
|
81
|
+
# today while claiming a binding nothing verified.
|
|
82
|
+
if parameters.is_a?(Hash) && (parameters.key?("relatedAssertions") || parameters.key?(:relatedAssertions))
|
|
83
|
+
raise InvalidManifestError,
|
|
84
|
+
"relatedAssertions cannot be set. Each entry must be a hashed URI computed over " \
|
|
85
|
+
"the assertion as c2pa-rs serialises it, and c2pa-rs #{C2PA.sdk_version} does not " \
|
|
86
|
+
"compute them when building a manifest, so Ruby cannot produce a genuine one."
|
|
87
|
+
end
|
|
88
|
+
|
|
78
89
|
# Required as of c2pa-rs 0.90. Earlier versions accepted its absence, so
|
|
79
90
|
# manifests signed by releases before 0.3.0 are rejected by current
|
|
80
91
|
# verifiers. No default is supplied: c2pa-rs accepts any string here, so
|
|
@@ -126,10 +137,9 @@ module C2PA
|
|
|
126
137
|
# at it, so provenance chains from the original through to this asset. A
|
|
127
138
|
# verifier can then follow and check the whole history.
|
|
128
139
|
#
|
|
129
|
-
# For a file with no credentials there is
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
# feature, which this gem does not enable.)
|
|
140
|
+
# For a file with no credentials there is no manifest to carry forward.
|
|
141
|
+
# The file still contributes a thumbnail when C2PA::Config#thumbnails is
|
|
142
|
+
# on.
|
|
133
143
|
#
|
|
134
144
|
# Without `file:`, only the description is recorded. Nothing binds it to
|
|
135
145
|
# any actual bytes. This form is kept for compatibility.
|
|
@@ -139,15 +149,23 @@ module C2PA
|
|
|
139
149
|
# @param instance_id [String] unique identifier for the ingredient instance
|
|
140
150
|
# @param relationship [String] relationship to this asset; defaults to "parentOf"
|
|
141
151
|
# @param file [String, nil] path to the ingredient file
|
|
152
|
+
# @param digital_source_type [String, nil] URI from the C2PA digitalSourceType
|
|
153
|
+
# vocabulary, for an ingredient with no content credentials of its own,
|
|
154
|
+
# such as a generated image composited into this asset
|
|
142
155
|
# @return [self]
|
|
143
|
-
# @raise [C2PA::InvalidManifestError] if `file` is given but cannot be read
|
|
144
|
-
|
|
156
|
+
# @raise [C2PA::InvalidManifestError] if `file` is given but cannot be read,
|
|
157
|
+
# or if `digital_source_type` is given for a file that carries content
|
|
158
|
+
# credentials
|
|
159
|
+
def add_ingredient(title:, format:, instance_id:, relationship: "parentOf", file: nil,
|
|
160
|
+
digital_source_type: nil)
|
|
145
161
|
description = {
|
|
146
162
|
"title" => title,
|
|
147
163
|
"format" => format,
|
|
148
164
|
"instance_id" => instance_id,
|
|
149
165
|
"relationship" => relationship
|
|
150
166
|
}
|
|
167
|
+
digital_source_type = to_s_or_nil(digital_source_type)
|
|
168
|
+
description["digital_source_type"] = digital_source_type if digital_source_type
|
|
151
169
|
|
|
152
170
|
if file.nil?
|
|
153
171
|
@ingredients << description
|
|
@@ -158,6 +176,16 @@ module C2PA
|
|
|
158
176
|
raise InvalidManifestError, "ingredient file not readable: #{file.inspect}"
|
|
159
177
|
end
|
|
160
178
|
|
|
179
|
+
# The specification forbids a source type alongside an embedded manifest:
|
|
180
|
+
# an ingredient with credentials already records its own origin. c2pa-rs
|
|
181
|
+
# refuses the combination only at signing, as "unable to encode assertion
|
|
182
|
+
# data", so it is caught here where the cause is clear.
|
|
183
|
+
if digital_source_type && content_credentials?(file)
|
|
184
|
+
raise InvalidManifestError,
|
|
185
|
+
"digital_source_type cannot be set for #{file.inspect}: it carries content " \
|
|
186
|
+
"credentials, which already record where it came from"
|
|
187
|
+
end
|
|
188
|
+
|
|
161
189
|
@ingredient_files << {
|
|
162
190
|
"json" => JSON.generate(description),
|
|
163
191
|
"format" => format,
|
|
@@ -198,6 +226,14 @@ module C2PA
|
|
|
198
226
|
|
|
199
227
|
private
|
|
200
228
|
|
|
229
|
+
# A file with no manifest, or one c2pa-rs cannot read, counts as having no
|
|
230
|
+
# credentials; if it does carry some, signing still fails upstream.
|
|
231
|
+
def content_credentials?(file)
|
|
232
|
+
!C2PA.read(file: file)["active_manifest"].nil?
|
|
233
|
+
rescue ReadError
|
|
234
|
+
false
|
|
235
|
+
end
|
|
236
|
+
|
|
201
237
|
# Parameters may be keyed with strings or symbols depending on the caller.
|
|
202
238
|
def param_present?(parameters, key)
|
|
203
239
|
return false unless parameters.is_a?(Hash)
|
data/lib/c2pa/version.rb
CHANGED