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.
@@ -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