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.
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
- # @param file [String] path to the signed file
103
- # @return [Hash] parsed manifest JSON
104
- # @raise [C2PA::ReadError] if the file has no valid manifest
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)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-c2pa
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carlos Rodriguez