robocap-decryption-sdk 1.0.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 +7 -0
- data/README.md +39 -0
- data/exe/robocap-decryption-sdk +6 -0
- data/lib/robocap/sdk/cli.rb +154 -0
- data/lib/robocap/sdk/config.rb +67 -0
- data/lib/robocap/sdk/decrypt_cenc.rb +64 -0
- data/lib/robocap/sdk/errors.rb +69 -0
- data/lib/robocap/sdk/ffmpeg_cli.rb +109 -0
- data/lib/robocap/sdk/key_vault.rb +308 -0
- data/lib/robocap/sdk/mp4_cenc.rb +154 -0
- data/lib/robocap/sdk/ownership.rb +69 -0
- data/lib/robocap/sdk/rsa_delete.rb +41 -0
- data/lib/robocap/sdk/rsa_import.rb +32 -0
- data/lib/robocap/sdk/rsa_key_meta.rb +55 -0
- data/lib/robocap/sdk/rsa_oaep.rb +93 -0
- data/lib/robocap/sdk/vault_layout.rb +50 -0
- data/lib/robocap/sdk/version.rb +7 -0
- data/lib/robocap/sdk.rb +41 -0
- metadata +99 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
require_relative 'config'
|
|
7
|
+
require_relative 'errors'
|
|
8
|
+
require_relative 'rsa_key_meta'
|
|
9
|
+
require_relative 'rsa_oaep'
|
|
10
|
+
require_relative 'vault_layout'
|
|
11
|
+
|
|
12
|
+
module Robocap
|
|
13
|
+
module SDK
|
|
14
|
+
CekTrialUnwrapResult = Data.define(:cek, :rsa_key_version)
|
|
15
|
+
|
|
16
|
+
class KeyVault
|
|
17
|
+
RSA_VERSION_DIR_RE = /\Av(\d+)\z/
|
|
18
|
+
|
|
19
|
+
def initialize(sdk_root)
|
|
20
|
+
@sdk_root = Pathname(sdk_root)
|
|
21
|
+
@keys_root = Config.keys_vault_root(@sdk_root)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :sdk_root
|
|
25
|
+
|
|
26
|
+
def customer_root(customer_id)
|
|
27
|
+
Config.validate_customer_id!(customer_id)
|
|
28
|
+
@keys_root.join(customer_id)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def rsa_version_dir(customer_id, version)
|
|
32
|
+
customer_root(customer_id).join('rsa', "v#{version}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def public_pem(customer_id, version)
|
|
36
|
+
rsa_version_dir(customer_id, version).join('public.pem')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def private_pem(customer_id, version)
|
|
40
|
+
rsa_version_dir(customer_id, version).join('private.pem')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def rsa_meta_path(customer_id, version)
|
|
44
|
+
rsa_version_dir(customer_id, version).join('meta.json')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.public_pem_path_for_dir(key_dir)
|
|
48
|
+
Pathname(key_dir).join('public.pem')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.private_pem_path_for_dir(key_dir)
|
|
52
|
+
Pathname(key_dir).join('private.pem')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def rsa_dir(customer_id)
|
|
56
|
+
customer_root(customer_id).join('rsa')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def exists_customer?(customer_id)
|
|
60
|
+
customer_root(customer_id).directory?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def list_rsa_versions(customer_id)
|
|
64
|
+
rsa_dir = customer_root(customer_id).join('rsa')
|
|
65
|
+
return [] unless rsa_dir.directory?
|
|
66
|
+
rsa_dir.children.each_with_object([]) do |child, acc|
|
|
67
|
+
next unless child.directory?
|
|
68
|
+
m = RSA_VERSION_DIR_RE.match(child.basename.to_s)
|
|
69
|
+
acc << m[1].to_i if m
|
|
70
|
+
end.sort
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def get_latest_rsa_version(customer_id)
|
|
74
|
+
versions = list_rsa_versions(customer_id)
|
|
75
|
+
if versions.empty?
|
|
76
|
+
raise Error.new(
|
|
77
|
+
code: ErrorCode::ERR_RSA_NOT_IMPORTED,
|
|
78
|
+
message: "No RSA keys imported for customer #{customer_id}",
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
versions.max
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def get_public_key(customer_id, version)
|
|
85
|
+
path = public_pem(customer_id, version)
|
|
86
|
+
unless path.file?
|
|
87
|
+
raise Error.new(
|
|
88
|
+
code: ErrorCode::ERR_RSA_VERSION_MISSING,
|
|
89
|
+
message: "RSA public key v#{version} not found for #{customer_id}",
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
load_public_key_from(path)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def get_private_key(customer_id, version)
|
|
96
|
+
path = private_pem(customer_id, version)
|
|
97
|
+
unless path.file?
|
|
98
|
+
raise Error.new(
|
|
99
|
+
code: ErrorCode::ERR_RSA_VERSION_MISSING,
|
|
100
|
+
message: "RSA private key v#{version} not found for #{customer_id}",
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
load_private_key_from(path)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def get_latest_public_key(customer_id)
|
|
107
|
+
get_public_key(customer_id, get_latest_rsa_version(customer_id))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def load_rsa_meta(customer_id, version)
|
|
111
|
+
path = rsa_meta_path(customer_id, version)
|
|
112
|
+
unless path.file?
|
|
113
|
+
raise Error.new(
|
|
114
|
+
code: ErrorCode::ERR_RSA_VERSION_MISSING,
|
|
115
|
+
message: "RSA meta v#{version} not found for #{customer_id}",
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
RsaKeyMeta.from_json(path.read(encoding: 'UTF-8'))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def import_rsa_version(customer_id:, public_pem:, private_pem:, meta:)
|
|
122
|
+
Config.validate_customer_id!(customer_id)
|
|
123
|
+
version = meta.rsa_key_version
|
|
124
|
+
pub_key = OpenSSL::PKey::RSA.new(public_pem)
|
|
125
|
+
priv_key = OpenSSL::PKey::RSA.new(private_pem)
|
|
126
|
+
|
|
127
|
+
if pub_key.private? || !priv_key.private?
|
|
128
|
+
raise Error.new(code: ErrorCode::ERR_RSA_IMPORT_INVALID, message: 'Invalid RSA key pair PEM')
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
unless Config::RSA_BITS_ALLOWED.include?(meta.rsa_bits)
|
|
132
|
+
raise Error.new(
|
|
133
|
+
code: ErrorCode::ERR_INVALID_RSA_BITS,
|
|
134
|
+
message: "RSA rsa_bits must be one of #{Config::RSA_BITS_ALLOWED}",
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
actual_bits_pub = pub_key.n.num_bits
|
|
139
|
+
actual_bits_priv = priv_key.n.num_bits
|
|
140
|
+
if actual_bits_pub != meta.rsa_bits || actual_bits_priv != meta.rsa_bits
|
|
141
|
+
raise Error.new(
|
|
142
|
+
code: ErrorCode::ERR_INVALID_RSA_BITS,
|
|
143
|
+
message: "RSA keys must be #{meta.rsa_bits} bits",
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
if pub_key.n != priv_key.n || pub_key.e != priv_key.e
|
|
148
|
+
raise Error.new(
|
|
149
|
+
code: ErrorCode::ERR_RSA_IMPORT_INVALID,
|
|
150
|
+
message: 'Public and private key do not match',
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
dir = rsa_version_dir(customer_id, version)
|
|
155
|
+
if dir.exist?
|
|
156
|
+
raise Error.new(
|
|
157
|
+
code: ErrorCode::ERR_CUSTOMER_ALREADY_EXISTS,
|
|
158
|
+
message: "RSA version v#{version} already exists for #{customer_id}",
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
VaultLayout.ensure_private_dir(dir)
|
|
163
|
+
VaultLayout.atomic_write_bytes(self.public_pem(customer_id, version), public_pem)
|
|
164
|
+
VaultLayout.atomic_write_bytes(self.private_pem(customer_id, version), private_pem)
|
|
165
|
+
VaultLayout.atomic_write_text(rsa_meta_path(customer_id, version), meta.to_pretty_json)
|
|
166
|
+
VaultLayout.ensure_private_dir(customer_root(customer_id))
|
|
167
|
+
version
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def delete_rsa_version(customer_id, version)
|
|
171
|
+
Config.validate_customer_id!(customer_id)
|
|
172
|
+
unless exists_customer?(customer_id)
|
|
173
|
+
raise Error.new(
|
|
174
|
+
code: ErrorCode::ERR_CUSTOMER_NOT_FOUND,
|
|
175
|
+
message: "Customer not found: #{customer_id}",
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
dir = rsa_version_dir(customer_id, version)
|
|
179
|
+
unless dir.directory?
|
|
180
|
+
raise Error.new(
|
|
181
|
+
code: ErrorCode::ERR_RSA_VERSION_MISSING,
|
|
182
|
+
message: "RSA version v#{version} not found for #{customer_id}",
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
begin
|
|
186
|
+
FileUtils.remove_entry_secure(dir)
|
|
187
|
+
rescue SystemCallError => exc
|
|
188
|
+
raise Error.new(
|
|
189
|
+
code: ErrorCode::ERR_VAULT_IO,
|
|
190
|
+
message: "Failed to delete RSA version v#{version}: #{exc.message}",
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def list_rsa_key_dirs(customer_id)
|
|
196
|
+
Config.validate_customer_id!(customer_id)
|
|
197
|
+
dir = rsa_dir(customer_id)
|
|
198
|
+
return [] unless dir.directory?
|
|
199
|
+
dir.children.sort_by { |c| c.basename.to_s.downcase }.filter_map do |child|
|
|
200
|
+
next nil unless valid_rsa_key_dir?(child)
|
|
201
|
+
Pathname(child.realpath)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def delete_rsa_key_dir(customer_id, key_dir)
|
|
206
|
+
Config.validate_customer_id!(customer_id)
|
|
207
|
+
unless exists_customer?(customer_id)
|
|
208
|
+
raise Error.new(
|
|
209
|
+
code: ErrorCode::ERR_CUSTOMER_NOT_FOUND,
|
|
210
|
+
message: "Customer not found: #{customer_id}",
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
resolved_rsa_dir = Pathname(rsa_dir(customer_id).realpath)
|
|
214
|
+
resolved = Pathname(key_dir).expand_path
|
|
215
|
+
resolved = Pathname(resolved.realpath) if resolved.exist?
|
|
216
|
+
unless resolved.parent == resolved_rsa_dir
|
|
217
|
+
raise Error.new(
|
|
218
|
+
code: ErrorCode::ERR_RSA_VERSION_MISSING,
|
|
219
|
+
message: "Key folder is not under rsa/ for #{customer_id}",
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
unless valid_rsa_key_dir?(resolved)
|
|
223
|
+
raise Error.new(
|
|
224
|
+
code: ErrorCode::ERR_RSA_VERSION_MISSING,
|
|
225
|
+
message: "Key folder missing public.pem or private.pem: #{resolved.basename}",
|
|
226
|
+
)
|
|
227
|
+
end
|
|
228
|
+
begin
|
|
229
|
+
FileUtils.remove_entry_secure(resolved)
|
|
230
|
+
rescue SystemCallError => exc
|
|
231
|
+
raise Error.new(
|
|
232
|
+
code: ErrorCode::ERR_VAULT_IO,
|
|
233
|
+
message: "Failed to delete key folder #{resolved.basename}: #{exc.message}",
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def trial_unwrap_cek(customer_id, cek_wrapped)
|
|
239
|
+
Config.validate_customer_id!(customer_id)
|
|
240
|
+
unless cek_wrapped.bytesize == Config::RSA_2048_CIPHERTEXT_BYTES
|
|
241
|
+
raise Error.new(
|
|
242
|
+
code: ErrorCode::ERR_CENC_CEKA_WRAP,
|
|
243
|
+
message: "Wrapped CEK must be #{Config::RSA_2048_CIPHERTEXT_BYTES} bytes",
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
eligible = list_rsa_versions(customer_id).filter_map do |version|
|
|
248
|
+
meta = load_rsa_meta(customer_id, version)
|
|
249
|
+
next nil unless meta.rsa_bits == 2048
|
|
250
|
+
priv = get_private_key(customer_id, version)
|
|
251
|
+
next nil unless priv.n.num_bits == 2048
|
|
252
|
+
[version, priv]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
if eligible.empty?
|
|
256
|
+
raise Error.new(
|
|
257
|
+
code: ErrorCode::ERR_CENC_CEKA_TRIAL_FAILED,
|
|
258
|
+
message: "No 2048-bit RSA keys available for trial unwrap: #{customer_id}",
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
first_success = nil
|
|
263
|
+
eligible.each do |version, priv|
|
|
264
|
+
begin
|
|
265
|
+
cek = RSAOAEP.unwrap_cek(cek_wrapped, priv)
|
|
266
|
+
rescue Error
|
|
267
|
+
next
|
|
268
|
+
end
|
|
269
|
+
return first_success if first_success
|
|
270
|
+
first_success = CekTrialUnwrapResult.new(cek: cek, rsa_key_version: version)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
unless first_success
|
|
274
|
+
raise Error.new(
|
|
275
|
+
code: ErrorCode::ERR_CENC_CEKA_TRIAL_FAILED,
|
|
276
|
+
message: "CEK trial unwrap failed for all vault versions: #{customer_id}",
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
first_success
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private
|
|
283
|
+
|
|
284
|
+
def valid_rsa_key_dir?(path)
|
|
285
|
+
path = Pathname(path)
|
|
286
|
+
path.directory? &&
|
|
287
|
+
self.class.public_pem_path_for_dir(path).file? &&
|
|
288
|
+
self.class.private_pem_path_for_dir(path).file?
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def load_public_key_from(path)
|
|
292
|
+
key = OpenSSL::PKey::RSA.new(path.binread)
|
|
293
|
+
if key.private?
|
|
294
|
+
raise Error.new(code: ErrorCode::ERR_RSA_IMPORT_INVALID, message: 'PEM is not an RSA public key')
|
|
295
|
+
end
|
|
296
|
+
key
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def load_private_key_from(path)
|
|
300
|
+
key = OpenSSL::PKey::RSA.new(path.binread)
|
|
301
|
+
unless key.private?
|
|
302
|
+
raise Error.new(code: ErrorCode::ERR_RSA_IMPORT_INVALID, message: 'PEM is not an RSA private key')
|
|
303
|
+
end
|
|
304
|
+
key
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
require_relative 'config'
|
|
7
|
+
require_relative 'errors'
|
|
8
|
+
require_relative 'ffmpeg_cli'
|
|
9
|
+
|
|
10
|
+
module Robocap
|
|
11
|
+
module SDK
|
|
12
|
+
CencMp4Metadata = Data.define(:customer_id, :cek_wrapped, :kid_hex)
|
|
13
|
+
|
|
14
|
+
module Mp4Cenc
|
|
15
|
+
CEKA_TAG = 'cenc_cek_wrapped_b64'
|
|
16
|
+
CUSTOMER_ID_TAG = 'cenc_customer_id'
|
|
17
|
+
CUSTOMER_ID_FALLBACK_TAG = 'username'
|
|
18
|
+
KID_TAG = 'cenc_kid_hex'
|
|
19
|
+
|
|
20
|
+
CENC_WRAPPED_ALGO_TAG = 'cenc_wrapped_algo'
|
|
21
|
+
|
|
22
|
+
CENC_STRIP_TAGS_ON_DECRYPT = [
|
|
23
|
+
CEKA_TAG,
|
|
24
|
+
CENC_WRAPPED_ALGO_TAG,
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def read_format_tags(mp4_path, ffprobe_executable: nil)
|
|
30
|
+
path = Pathname(mp4_path).expand_path
|
|
31
|
+
unless path.file?
|
|
32
|
+
raise Error.new(
|
|
33
|
+
code: ErrorCode::ERR_CENC_TAGS_MISSING,
|
|
34
|
+
message: "MP4 file not found: #{path}",
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
exe = FfmpegCli.resolve_ffprobe_executable(ffprobe_executable)
|
|
38
|
+
stdout, stderr, status = FfmpegCli.open3_capture3(
|
|
39
|
+
exe, '-v', 'error', '-show_format', '-print_format', 'json', path.to_s,
|
|
40
|
+
)
|
|
41
|
+
unless status.exitstatus.zero?
|
|
42
|
+
raise Error.new(
|
|
43
|
+
code: ErrorCode::ERR_CENC_FFPROBE_FAILED,
|
|
44
|
+
message: "ffprobe failed: #{stderr}",
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
payload = parse_ffprobe_json(stdout)
|
|
49
|
+
fmt = payload['format']
|
|
50
|
+
unless fmt.is_a?(Hash)
|
|
51
|
+
raise Error.new(
|
|
52
|
+
code: ErrorCode::ERR_CENC_TAGS_MISSING,
|
|
53
|
+
message: 'ffprobe output missing format section',
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
tags = fmt['tags']
|
|
57
|
+
unless tags.is_a?(Hash)
|
|
58
|
+
raise Error.new(
|
|
59
|
+
code: ErrorCode::ERR_CENC_TAGS_MISSING,
|
|
60
|
+
message: 'MP4 has no format metadata tags',
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
tags.transform_keys(&:to_s).transform_values(&:to_s)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parse_cenc_metadata_from_tags(tags)
|
|
67
|
+
unless tags[CEKA_TAG] && !tags[CEKA_TAG].empty?
|
|
68
|
+
raise Error.new(
|
|
69
|
+
code: ErrorCode::ERR_CENC_TAGS_MISSING,
|
|
70
|
+
message: "Missing CENC tags: #{CEKA_TAG}",
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
customer_id = resolve_customer_id(tags)
|
|
75
|
+
|
|
76
|
+
begin
|
|
77
|
+
cek_wrapped = Base64.strict_decode64(tags[CEKA_TAG])
|
|
78
|
+
rescue ArgumentError
|
|
79
|
+
raise Error.new(
|
|
80
|
+
code: ErrorCode::ERR_CENC_TAGS_MISSING,
|
|
81
|
+
message: 'Invalid cenc_cek_wrapped_b64 Base64',
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
unless cek_wrapped.bytesize == Config::RSA_2048_CIPHERTEXT_BYTES
|
|
86
|
+
raise Error.new(
|
|
87
|
+
code: ErrorCode::ERR_CENC_CEKA_WRAP,
|
|
88
|
+
message: "Wrapped CEK must be #{Config::RSA_2048_CIPHERTEXT_BYTES} bytes",
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
kid = tags[KID_TAG]
|
|
93
|
+
kid = kid.strip.downcase if kid
|
|
94
|
+
kid = nil if kid && kid.empty?
|
|
95
|
+
|
|
96
|
+
CencMp4Metadata.new(customer_id: customer_id, cek_wrapped: cek_wrapped, kid_hex: kid)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def load_cenc_metadata(mp4_path, ffprobe_executable: nil)
|
|
100
|
+
parse_cenc_metadata_from_tags(
|
|
101
|
+
read_format_tags(mp4_path, ffprobe_executable: ffprobe_executable),
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def has_cenc_tags(mp4_path, ffprobe_executable: nil)
|
|
106
|
+
tags = read_format_tags(mp4_path, ffprobe_executable: ffprobe_executable)
|
|
107
|
+
return false unless tags[CEKA_TAG] && !tags[CEKA_TAG].empty?
|
|
108
|
+
has_customer_id_source?(tags)
|
|
109
|
+
rescue Error
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
class << self
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def parse_ffprobe_json(raw)
|
|
117
|
+
JSON.parse(raw)
|
|
118
|
+
rescue JSON::ParserError
|
|
119
|
+
raise Error.new(
|
|
120
|
+
code: ErrorCode::ERR_CENC_FFPROBE_FAILED,
|
|
121
|
+
message: 'ffprobe returned invalid JSON',
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def resolve_customer_id(tags)
|
|
126
|
+
raw = tags[CUSTOMER_ID_TAG] || tags[CUSTOMER_ID_FALLBACK_TAG]
|
|
127
|
+
if raw.nil? || raw.strip.empty?
|
|
128
|
+
raise Error.new(
|
|
129
|
+
code: ErrorCode::ERR_CENC_TAGS_MISSING,
|
|
130
|
+
message: "Missing CENC customer id: #{CUSTOMER_ID_TAG} or #{CUSTOMER_ID_FALLBACK_TAG} tag required",
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
customer = raw.strip
|
|
134
|
+
begin
|
|
135
|
+
Config.validate_customer_id!(customer)
|
|
136
|
+
rescue ArgumentError
|
|
137
|
+
raise Error.new(
|
|
138
|
+
code: ErrorCode::ERR_CENC_CUSTOMER_ID_INVALID,
|
|
139
|
+
message: "Invalid customer id: #{customer.inspect}",
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
customer
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def has_customer_id_source?(tags)
|
|
146
|
+
[CUSTOMER_ID_TAG, CUSTOMER_ID_FALLBACK_TAG].any? do |k|
|
|
147
|
+
v = tags[k]
|
|
148
|
+
v && !v.strip.empty?
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require_relative 'config'
|
|
6
|
+
require_relative 'errors'
|
|
7
|
+
require_relative 'key_vault'
|
|
8
|
+
|
|
9
|
+
module Robocap
|
|
10
|
+
module SDK
|
|
11
|
+
VerifiedKeyVersion = Data.define(:customer_id, :matched_rsa_key_version, :public_fingerprint)
|
|
12
|
+
|
|
13
|
+
module Ownership
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def spki_fingerprint(public_key)
|
|
17
|
+
der = public_key.public_to_der
|
|
18
|
+
Digest::SHA256.hexdigest(der)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def verify(customer_id:, user_private_pem:, key_vault: nil, sdk_root: nil)
|
|
22
|
+
vault = key_vault || KeyVault.new(sdk_root || Config.default_sdk_root)
|
|
23
|
+
|
|
24
|
+
unless vault.exists_customer?(customer_id)
|
|
25
|
+
raise Error.new(
|
|
26
|
+
code: ErrorCode::ERR_CUSTOMER_NOT_FOUND,
|
|
27
|
+
message: "Customer #{customer_id} not found in key vault",
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
priv = begin
|
|
32
|
+
OpenSSL::PKey::RSA.new(user_private_pem)
|
|
33
|
+
rescue OpenSSL::PKey::RSAError, OpenSSL::PKey::PKeyError => exc
|
|
34
|
+
raise Error.new(
|
|
35
|
+
code: ErrorCode::ERR_KEY_OWNERSHIP_FAILED,
|
|
36
|
+
message: 'Failed to parse user private key',
|
|
37
|
+
detail: { reason: exc.message },
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
unless priv.private?
|
|
42
|
+
raise Error.new(
|
|
43
|
+
code: ErrorCode::ERR_KEY_OWNERSHIP_FAILED,
|
|
44
|
+
message: 'User key is not an RSA private key',
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
user_fp = spki_fingerprint(priv.public_key)
|
|
49
|
+
|
|
50
|
+
vault.list_rsa_versions(customer_id).each do |version|
|
|
51
|
+
archived = vault.get_public_key(customer_id, version)
|
|
52
|
+
archived_fp = spki_fingerprint(archived)
|
|
53
|
+
if archived_fp == user_fp
|
|
54
|
+
return VerifiedKeyVersion.new(
|
|
55
|
+
customer_id: customer_id,
|
|
56
|
+
matched_rsa_key_version: version,
|
|
57
|
+
public_fingerprint: user_fp,
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
raise Error.new(
|
|
63
|
+
code: ErrorCode::ERR_KEY_OWNERSHIP_FAILED,
|
|
64
|
+
message: "User private key does not match any archived key for #{customer_id}",
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative 'config'
|
|
5
|
+
require_relative 'key_vault'
|
|
6
|
+
|
|
7
|
+
module Robocap
|
|
8
|
+
module SDK
|
|
9
|
+
DeleteRsaResult = Data.define(:customer_id, :folder_name, :key_dir)
|
|
10
|
+
|
|
11
|
+
module RsaDelete
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def call(customer_id:, rsa_key_version:, sdk_root: nil)
|
|
15
|
+
root = Pathname(sdk_root || Config.default_sdk_root)
|
|
16
|
+
vault = KeyVault.new(root)
|
|
17
|
+
key_dir = vault.rsa_version_dir(customer_id, rsa_key_version)
|
|
18
|
+
vault.delete_rsa_version(customer_id, rsa_key_version)
|
|
19
|
+
DeleteRsaResult.new(
|
|
20
|
+
customer_id: customer_id,
|
|
21
|
+
folder_name: key_dir.basename.to_s,
|
|
22
|
+
key_dir: key_dir,
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call_with_dir(customer_id:, key_dir:, sdk_root: nil)
|
|
27
|
+
root = Pathname(sdk_root || Config.default_sdk_root)
|
|
28
|
+
vault = KeyVault.new(root)
|
|
29
|
+
resolved = Pathname(key_dir).expand_path
|
|
30
|
+
resolved = Pathname(resolved.realpath) if resolved.exist?
|
|
31
|
+
folder_name = resolved.basename.to_s
|
|
32
|
+
vault.delete_rsa_key_dir(customer_id, resolved)
|
|
33
|
+
DeleteRsaResult.new(
|
|
34
|
+
customer_id: customer_id,
|
|
35
|
+
folder_name: folder_name,
|
|
36
|
+
key_dir: resolved,
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative 'config'
|
|
5
|
+
require_relative 'key_vault'
|
|
6
|
+
require_relative 'rsa_key_meta'
|
|
7
|
+
|
|
8
|
+
module Robocap
|
|
9
|
+
module SDK
|
|
10
|
+
ImportRsaResult = Data.define(:customer_id, :rsa_key_version, :vault_rsa_dir)
|
|
11
|
+
|
|
12
|
+
module RsaImport
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def call(customer_id:, public_pem:, private_pem:, meta:, sdk_root: nil)
|
|
16
|
+
root = Pathname(sdk_root || Config.default_sdk_root)
|
|
17
|
+
vault = KeyVault.new(root)
|
|
18
|
+
version = vault.import_rsa_version(
|
|
19
|
+
customer_id: customer_id,
|
|
20
|
+
public_pem: public_pem,
|
|
21
|
+
private_pem: private_pem,
|
|
22
|
+
meta: meta,
|
|
23
|
+
)
|
|
24
|
+
ImportRsaResult.new(
|
|
25
|
+
customer_id: customer_id,
|
|
26
|
+
rsa_key_version: version,
|
|
27
|
+
vault_rsa_dir: vault.rsa_version_dir(customer_id, version),
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
require_relative 'config'
|
|
6
|
+
|
|
7
|
+
module Robocap
|
|
8
|
+
module SDK
|
|
9
|
+
class RsaKeyMeta < Data.define(:rsa_key_version, :effective_at, :device_id, :rsa_bits)
|
|
10
|
+
def initialize(rsa_key_version:, effective_at:, device_id:, rsa_bits: Config::RSA_BITS)
|
|
11
|
+
unless rsa_key_version.is_a?(Integer) && rsa_key_version >= 1
|
|
12
|
+
raise ArgumentError, "rsa_key_version must be an integer >= 1 (got #{rsa_key_version.inspect})"
|
|
13
|
+
end
|
|
14
|
+
unless effective_at.is_a?(Time)
|
|
15
|
+
raise ArgumentError, "effective_at must be a Time (got #{effective_at.class})"
|
|
16
|
+
end
|
|
17
|
+
unless device_id.is_a?(String)
|
|
18
|
+
raise ArgumentError, "device_id must be a String (got #{device_id.class})"
|
|
19
|
+
end
|
|
20
|
+
unless rsa_bits.is_a?(Integer)
|
|
21
|
+
raise ArgumentError, "rsa_bits must be an Integer (got #{rsa_bits.class})"
|
|
22
|
+
end
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_json(*_args)
|
|
27
|
+
JSON.generate(
|
|
28
|
+
rsa_key_version: rsa_key_version,
|
|
29
|
+
effective_at: effective_at.getutc.iso8601,
|
|
30
|
+
device_id: device_id,
|
|
31
|
+
rsa_bits: rsa_bits,
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_pretty_json
|
|
36
|
+
JSON.pretty_generate(
|
|
37
|
+
'rsa_key_version' => rsa_key_version,
|
|
38
|
+
'effective_at' => effective_at.getutc.iso8601,
|
|
39
|
+
'device_id' => device_id,
|
|
40
|
+
'rsa_bits' => rsa_bits,
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.from_json(str)
|
|
45
|
+
h = JSON.parse(str)
|
|
46
|
+
new(
|
|
47
|
+
rsa_key_version: h.fetch('rsa_key_version'),
|
|
48
|
+
effective_at: Time.iso8601(h.fetch('effective_at')),
|
|
49
|
+
device_id: h.fetch('device_id'),
|
|
50
|
+
rsa_bits: h.fetch('rsa_bits', Config::RSA_BITS),
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|