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/lib/c2pa.rb
CHANGED
|
@@ -58,6 +58,12 @@ module C2PA
|
|
|
58
58
|
# @param manifest [C2PA::Manifest] the manifest to embed
|
|
59
59
|
# @param verify [Boolean] read the signed file back and confirm it
|
|
60
60
|
# validates (default: true)
|
|
61
|
+
# @param sidecar [String, nil] write the manifest to this path as a
|
|
62
|
+
# .c2pa file instead of embedding it (must
|
|
63
|
+
# not already exist). The output is the
|
|
64
|
+
# asset as hashed, usually identical to
|
|
65
|
+
# the input, and is only valid alongside
|
|
66
|
+
# the sidecar.
|
|
61
67
|
# @return [String] the output path
|
|
62
68
|
# @raise [C2PA::SigningError] if signing fails, or if the signed file does not validate
|
|
63
69
|
#
|
|
@@ -72,13 +78,17 @@ module C2PA
|
|
|
72
78
|
# key: "key.pem",
|
|
73
79
|
# manifest: manifest
|
|
74
80
|
# )
|
|
75
|
-
def self.sign(file:, output:, certificate:, key:, algorithm: "es256", manifest:, verify: true)
|
|
81
|
+
def self.sign(file:, output:, certificate:, key:, algorithm: "es256", manifest:, verify: true, sidecar: nil)
|
|
76
82
|
manifest_json = manifest.to_json
|
|
77
83
|
|
|
78
84
|
raise SigningError, "Source file not found: '#{file}'" unless File.exist?(file)
|
|
79
85
|
raise SigningError, "Certificate file not found: '#{certificate}'" unless File.exist?(certificate)
|
|
80
86
|
raise SigningError, "Key file not found: '#{key}'" unless File.exist?(key)
|
|
81
87
|
raise SigningError, "Output file already exists: '#{output}'" if File.exist?(output)
|
|
88
|
+
raise SigningError, "Sidecar file already exists: '#{sidecar}'" if sidecar && File.exist?(sidecar)
|
|
89
|
+
if sidecar && File.expand_path(sidecar) == File.expand_path(output)
|
|
90
|
+
raise SigningError, "sidecar and output must be different paths"
|
|
91
|
+
end
|
|
82
92
|
|
|
83
93
|
begin
|
|
84
94
|
# to_json is the only thing genuinely required of a manifest, so an
|
|
@@ -87,28 +97,119 @@ module C2PA
|
|
|
87
97
|
files = manifest.respond_to?(:ingredient_files) ? manifest.ingredient_files : []
|
|
88
98
|
ingredient_files = files.empty? ? nil : JSON.generate(files)
|
|
89
99
|
Native.sign_file(file, output, certificate, key, algorithm, manifest_json,
|
|
90
|
-
intent, ingredient_files)
|
|
100
|
+
intent, ingredient_files, sidecar)
|
|
91
101
|
rescue RuntimeError => e
|
|
92
102
|
raise SigningError, e.message
|
|
93
103
|
end
|
|
94
104
|
|
|
95
|
-
verify_signed_output!(output) if verify
|
|
105
|
+
verify_signed_output!(output, sidecar) if verify
|
|
96
106
|
|
|
97
107
|
output
|
|
98
108
|
end
|
|
99
109
|
|
|
110
|
+
# Sign bytes held in memory, returning the signed bytes.
|
|
111
|
+
#
|
|
112
|
+
# The counterpart to C2PA.sign for data you already have loaded, such as an
|
|
113
|
+
# upload. The format must be given, since there is no filename to infer it
|
|
114
|
+
# from. Input must be binary; a UTF-8-tagged string is rejected rather than
|
|
115
|
+
# transcoded, because that would corrupt the asset.
|
|
116
|
+
#
|
|
117
|
+
# The input, the working copy and the result on both sides of the boundary
|
|
118
|
+
# are resident at once, so budget about four times the asset. Prefer
|
|
119
|
+
# C2PA.sign with paths for large video.
|
|
120
|
+
#
|
|
121
|
+
# @param data [String] the asset, as a binary string
|
|
122
|
+
# @param format [String] MIME type, e.g. "image/jpeg"
|
|
123
|
+
# @param certificate [String] path to a PEM-encoded X.509 certificate chain
|
|
124
|
+
# @param key [String] path to a PEM-encoded private key
|
|
125
|
+
# @param algorithm [String] signing algorithm (default: "es256")
|
|
126
|
+
# @param manifest [C2PA::Manifest] the manifest to embed
|
|
127
|
+
# @param verify [Boolean] read the result back and confirm it validates
|
|
128
|
+
# @return [String] the signed asset, as a binary string
|
|
129
|
+
# @raise [C2PA::SigningError] if signing fails, or if the result does not validate
|
|
130
|
+
#
|
|
131
|
+
# @example
|
|
132
|
+
# signed = C2PA.sign_buffer(
|
|
133
|
+
# data: File.binread("photo.jpg"),
|
|
134
|
+
# format: "image/jpeg",
|
|
135
|
+
# certificate: "cert.pem",
|
|
136
|
+
# key: "key.pem",
|
|
137
|
+
# manifest: manifest
|
|
138
|
+
# )
|
|
139
|
+
def self.sign_buffer(data:, format:, certificate:, key:, algorithm: "es256", manifest:, verify: true)
|
|
140
|
+
manifest_json = manifest.to_json
|
|
141
|
+
data = binary!(data, "data")
|
|
142
|
+
|
|
143
|
+
raise SigningError, "Certificate file not found: '#{certificate}'" unless File.exist?(certificate)
|
|
144
|
+
raise SigningError, "Key file not found: '#{key}'" unless File.exist?(key)
|
|
145
|
+
|
|
146
|
+
signed =
|
|
147
|
+
begin
|
|
148
|
+
intent = manifest.respond_to?(:intent) ? manifest.intent&.to_s : nil
|
|
149
|
+
files = manifest.respond_to?(:ingredient_files) ? manifest.ingredient_files : []
|
|
150
|
+
ingredient_files = files.empty? ? nil : JSON.generate(files)
|
|
151
|
+
Native.sign_buffer(data, format, certificate, key, algorithm, manifest_json,
|
|
152
|
+
intent, ingredient_files)
|
|
153
|
+
rescue RuntimeError => e
|
|
154
|
+
raise SigningError, e.message
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
verify_signed_buffer!(signed, format) if verify
|
|
158
|
+
|
|
159
|
+
signed
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Read the C2PA manifest embedded in bytes held in memory.
|
|
163
|
+
#
|
|
164
|
+
# c2pa-rs identifies most formats from the leading bytes and ignores the
|
|
165
|
+
# hint when the two disagree. The hint matters for formats it cannot tell
|
|
166
|
+
# apart by their bytes: SVG, and the ZIP-based documents (EPUB, DOCX, ODT,
|
|
167
|
+
# OpenXPS), which all begin with the same ZIP header.
|
|
168
|
+
#
|
|
169
|
+
# With manifest_data, the manifest store is taken from there, as the
|
|
170
|
+
# contents of a .c2pa sidecar, and validated against data. The format is
|
|
171
|
+
# then required, since nothing is sniffed.
|
|
172
|
+
#
|
|
173
|
+
# @param data [String] the asset, as a binary string
|
|
174
|
+
# @param format [String, nil] MIME type or extension, e.g. "image/svg+xml"
|
|
175
|
+
# @param manifest_data [String, nil] a detached manifest store, as a binary string
|
|
176
|
+
# @return [Hash] parsed manifest JSON
|
|
177
|
+
# @raise [C2PA::ReadError] if the data has no valid manifest
|
|
178
|
+
def self.read_buffer(data:, format: nil, manifest_data: nil)
|
|
179
|
+
data = binary!(data, "data")
|
|
180
|
+
return JSON.parse(Native.read_buffer(data, format)) if manifest_data.nil?
|
|
181
|
+
|
|
182
|
+
manifest_data = binary!(manifest_data, "manifest_data")
|
|
183
|
+
raise ArgumentError, "format is required with manifest_data" if format.nil?
|
|
184
|
+
|
|
185
|
+
JSON.parse(Native.read_buffer_with_manifest(data, format, manifest_data))
|
|
186
|
+
rescue RuntimeError => e
|
|
187
|
+
raise ReadError, e.message
|
|
188
|
+
end
|
|
189
|
+
|
|
100
190
|
# Read the C2PA manifest embedded in a signed file.
|
|
101
191
|
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
192
|
+
# A file with no embedded manifest is read against a sidecar beside it with
|
|
193
|
+
# the same name and a .c2pa extension, if there is one: photo.c2pa for
|
|
194
|
+
# photo.jpg. Pass manifest_file for a sidecar kept anywhere else.
|
|
195
|
+
#
|
|
196
|
+
# Reading a .c2pa file on its own returns the manifest, but with nothing to
|
|
197
|
+
# check it against its hash never matches and the state is Invalid.
|
|
198
|
+
#
|
|
199
|
+
# @param file [String] path to the signed file
|
|
200
|
+
# @param manifest_file [String, nil] path to a detached manifest (.c2pa)
|
|
201
|
+
# to validate the file against
|
|
202
|
+
# @return [Hash] parsed manifest JSON
|
|
203
|
+
# @raise [C2PA::ReadError] if the file has no valid manifest
|
|
105
204
|
#
|
|
106
205
|
# @example
|
|
107
206
|
# manifest = C2PA.read(file: "photo_signed.jpg")
|
|
108
207
|
# active = manifest["manifests"][manifest["active_manifest"]]
|
|
109
208
|
# puts active["title"]
|
|
110
|
-
def self.read(file:)
|
|
111
|
-
JSON.parse(Native.read_file(file))
|
|
209
|
+
def self.read(file:, manifest_file: nil)
|
|
210
|
+
return JSON.parse(Native.read_file(file)) if manifest_file.nil?
|
|
211
|
+
|
|
212
|
+
JSON.parse(Native.read_file_with_manifest(file, manifest_file))
|
|
112
213
|
rescue RuntimeError => e
|
|
113
214
|
raise ReadError, e.message
|
|
114
215
|
end
|
|
@@ -132,12 +233,13 @@ module C2PA
|
|
|
132
233
|
#
|
|
133
234
|
# @param output [String] path to the signed file
|
|
134
235
|
# @raise [C2PA::SigningError] if the file does not validate
|
|
135
|
-
def self.verify_signed_output!(output)
|
|
236
|
+
def self.verify_signed_output!(output, sidecar = nil)
|
|
136
237
|
result =
|
|
137
238
|
begin
|
|
138
|
-
read(file: output)
|
|
239
|
+
read(file: output, manifest_file: sidecar)
|
|
139
240
|
rescue ReadError => e
|
|
140
241
|
discard(output)
|
|
242
|
+
discard(sidecar) if sidecar
|
|
141
243
|
raise SigningError, "signed file failed verification: could not read it back: #{e.message}"
|
|
142
244
|
end
|
|
143
245
|
|
|
@@ -149,12 +251,53 @@ module C2PA
|
|
|
149
251
|
detail = failures.empty? ? "no failure detail reported" : failures.uniq.join(", ")
|
|
150
252
|
|
|
151
253
|
discard(output)
|
|
254
|
+
discard(sidecar) if sidecar
|
|
152
255
|
raise SigningError,
|
|
153
256
|
"signed file failed verification: validation_state=#{state.inspect}, #{detail}. " \
|
|
154
257
|
"The output file has been removed. Pass verify: false to keep it for inspection."
|
|
155
258
|
end
|
|
156
259
|
private_class_method :verify_signed_output!
|
|
157
260
|
|
|
261
|
+
# The buffer counterpart to verify_signed_output!. Nothing to delete: a
|
|
262
|
+
# rejected result is simply not returned.
|
|
263
|
+
def self.verify_signed_buffer!(signed, format)
|
|
264
|
+
result =
|
|
265
|
+
begin
|
|
266
|
+
read_buffer(data: signed, format: format)
|
|
267
|
+
rescue ReadError => e
|
|
268
|
+
raise SigningError, "signed data failed verification: could not read it back: #{e.message}"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
state = result["validation_state"]
|
|
272
|
+
return if VALID_STATES.include?(state)
|
|
273
|
+
|
|
274
|
+
failures = Array(result.dig("validation_results", "activeManifest", "failure"))
|
|
275
|
+
.map { |failure| "#{failure["code"]} (#{failure["explanation"]})" }
|
|
276
|
+
detail = failures.empty? ? "no failure detail reported" : failures.uniq.join(", ")
|
|
277
|
+
|
|
278
|
+
raise SigningError,
|
|
279
|
+
"signed data failed verification: validation_state=#{state.inspect}, #{detail}. " \
|
|
280
|
+
"Pass verify: false to receive it anyway."
|
|
281
|
+
end
|
|
282
|
+
private_class_method :verify_signed_buffer!
|
|
283
|
+
|
|
284
|
+
# Asset bytes must be binary. The native layer takes the raw bytes whatever
|
|
285
|
+
# the tag says, so a UTF-8-tagged string could be passed through as-is. It
|
|
286
|
+
# is refused instead because the tag means the bytes came through a text
|
|
287
|
+
# path (File.read rather than File.binread), and on Windows that path has
|
|
288
|
+
# already rewritten line endings. Nothing notices until a verifier rejects
|
|
289
|
+
# the result. Refusing early turns a silent corruption into an error with a
|
|
290
|
+
# fix in the message.
|
|
291
|
+
def self.binary!(data, name)
|
|
292
|
+
raise ArgumentError, "#{name} must be a String, got #{data.class}" unless data.is_a?(String)
|
|
293
|
+
return data if data.encoding == Encoding::BINARY
|
|
294
|
+
|
|
295
|
+
raise ArgumentError,
|
|
296
|
+
"#{name} must be a binary string (Encoding::BINARY), got #{data.encoding}. " \
|
|
297
|
+
"Use File.binread, or call .b on the string."
|
|
298
|
+
end
|
|
299
|
+
private_class_method :binary!
|
|
300
|
+
|
|
158
301
|
# Remove a file this library created and is about to reject.
|
|
159
302
|
def self.discard(path)
|
|
160
303
|
File.delete(path) if File.exist?(path)
|