robocap-decryption-sdk 1.0.0 → 2.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a5c60c2503cabf98d07480ea6cb28d0068b4ba3b292815c7474cabe6fb6614a6
4
- data.tar.gz: c10616d9b302342d67a70e9e9882befd1bfcce88a7c64963e2429362778140ed
3
+ metadata.gz: 79e885213cb75dc85172a8a8c8eb17baa9b3684ee6dff10e331d3e6262c2aefe
4
+ data.tar.gz: 84077253d5bf58dcf7c658512efc7274bf13c3cf6e30eaa87549ef0f73fa99e0
5
5
  SHA512:
6
- metadata.gz: ea720dfee5ddf6b53acad53ab0e8b91ad0c32f0037712a78da9f7b0a58e6a0b4822d94265f94c42c7a62e6f31c0aa33ad50a6cb3e46be2e2b49ddc87c304deea
7
- data.tar.gz: 9078269ccd590ed466d9912c7df09d42f84e647dd22c7f0edf701f3d753b9c27d29ff13a883aaca3acffc75c52d9868c69c1b7ec13c1a5cf3f8254e48da96f88
6
+ metadata.gz: 1ada52a6ef96f5a2a0726e97365b48c6f0452e84f09d5916269aacc94fc8e3fedfdc8da924396baff5fb45aafe9ed5aad6a364684b31357425491895bd02d7eb
7
+ data.tar.gz: 449d464f2324500cd1409ed6b3d42dcaa639b2d482614db00432671e2a8afc33393711534db9ad9c7cc8f56222dee130a14fae582e8da96ad1836d150367b371
data/README.md CHANGED
@@ -32,6 +32,44 @@ bundle exec exe/robocap-decryption-sdk delete-rsa --help
32
32
  bundle exec exe/robocap-decryption-sdk decrypt-cenc --help
33
33
  ```
34
34
 
35
+ ## Ruby API
36
+
37
+ ```ruby
38
+ require 'robocap/sdk'
39
+
40
+ RobocapCenc::SDK.decrypt_cenc_mp4(...)
41
+ ```
42
+
43
+ ## Changelog
44
+
45
+ ### 2.1.0
46
+
47
+ Brings the Ruby SDK to parity with the Python SDK's device/host vault
48
+ resolution. See [`../spec/cenc-tags.md`](../spec/cenc-tags.md) for the full
49
+ contract.
50
+
51
+ BREAKING: the `username` MP4 tag is no longer a customer-id source. The
52
+ fallback tag is now `deviceid`. A capture whose only customer-id source is
53
+ `username` now fails `ERR_CENC_TAGS_MISSING` — re-tag it with `deviceid`.
54
+
55
+ - Product line is detected from the MP4 filename stem (`robowrist_` /
56
+ `robocap_` / otherwise legacy) via `Mp4Cenc.detect_product_line`.
57
+ - `robowrist` captures resolve their vault customer id from the `host` tag
58
+ instead of the device id. Import robowrist keys under the **host**.
59
+ - `CencMp4Metadata` gained `product_line`, `tag_deviceid`, and `tag_host`.
60
+ - New `ERR_DEVICE_BINDING_MISMATCH` (7012) plus
61
+ `Mp4Cenc.verify_session_device_id` / `verify_session_device_id_from_metadata`.
62
+ - `DecryptCenc.call` accepts `metadata:` (skip re-probing an already-parsed
63
+ file) and `session_device_id:` (bind decryption to one device).
64
+ - `parse_cenc_metadata_from_tags` now takes optional `mp4_path:` /
65
+ `product_line:` keywords. Callers passing a **literal** bare hash must brace
66
+ it — `parse_cenc_metadata_from_tags({ 'k' => 'v' })` — since Ruby would
67
+ otherwise read it as keyword arguments. Passing a hash variable is unaffected.
68
+
69
+ ### 2.0.0
70
+
71
+ BREAKING: Ruby namespace renamed `Robocap::SDK` -> `RobocapCenc::SDK` to avoid colliding with host apps that define a top-level `Robocap` constant. Gem name, require path, and CLI executable are unchanged. Ruby API consumers must update `Robocap::SDK::*` references to `RobocapCenc::SDK::*`.
72
+
35
73
  ## Verified against the Python SDK
36
74
 
37
75
  This Ruby gem reads and writes the same on-disk vault and CENC MP4 format
@@ -3,4 +3,4 @@
3
3
 
4
4
  require 'robocap/sdk/cli'
5
5
 
6
- exit Robocap::SDK::CLI.run(ARGV.dup)
6
+ exit RobocapCenc::SDK::CLI.run(ARGV.dup)
@@ -6,7 +6,7 @@ require 'pathname'
6
6
  require 'time'
7
7
  require_relative '../sdk'
8
8
 
9
- module Robocap
9
+ module RobocapCenc
10
10
  module SDK
11
11
  module CLI
12
12
  module_function
@@ -75,7 +75,7 @@ module Robocap
75
75
  device_id: opts[:device_id] || opts[:customer_id],
76
76
  rsa_bits: opts[:rsa_bits],
77
77
  )
78
- result = Robocap::SDK.import_rsa_key_version(
78
+ result = RobocapCenc::SDK.import_rsa_key_version(
79
79
  customer_id: opts[:customer_id],
80
80
  public_pem: opts[:public_key].binread,
81
81
  private_pem: opts[:private_key].binread,
@@ -100,7 +100,7 @@ module Robocap
100
100
  end.parse!(argv)
101
101
 
102
102
  require_opts!(opts, %i[customer_id rsa_key_version])
103
- result = Robocap::SDK.delete_rsa_key_version(
103
+ result = RobocapCenc::SDK.delete_rsa_key_version(
104
104
  customer_id: opts[:customer_id],
105
105
  rsa_key_version: opts[:rsa_key_version],
106
106
  sdk_root: opts[:sdk_root],
@@ -127,7 +127,7 @@ module Robocap
127
127
 
128
128
  require_opts!(opts, %i[mp4_path private_key output_dir])
129
129
  opts[:output_dir].mkpath
130
- result = Robocap::SDK.decrypt_cenc_mp4(
130
+ result = RobocapCenc::SDK.decrypt_cenc_mp4(
131
131
  mp4_path: opts[:mp4_path],
132
132
  user_private_pem: opts[:private_key].binread,
133
133
  output_dir: opts[:output_dir],
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'pathname'
4
4
 
5
- module Robocap
5
+ module RobocapCenc
6
6
  module SDK
7
7
  module Config
8
8
  VAULT_KEYS_DIR = 'vault/keys'
@@ -9,19 +9,25 @@ require_relative 'ownership'
9
9
  require_relative 'ffmpeg_cli'
10
10
  require_relative 'vault_layout'
11
11
 
12
- module Robocap
12
+ module RobocapCenc
13
13
  module SDK
14
14
  DecryptCencResult = Data.define(:output_path, :customer_id, :rsa_key_version, :kid_hex)
15
15
 
16
16
  module DecryptCenc
17
17
  module_function
18
18
 
19
- def call(mp4_path:, user_private_pem:, output_dir:,
20
- sdk_root: nil, ffprobe_executable: nil, ffmpeg_executable: nil)
19
+ def call(mp4_path:, user_private_pem:, output_dir:, metadata: nil,
20
+ sdk_root: nil, session_device_id: nil,
21
+ ffprobe_executable: nil, ffmpeg_executable: nil)
21
22
  mp4_path = Pathname(mp4_path)
22
23
  output_dir = Pathname(output_dir)
23
24
 
24
- meta = Mp4Cenc.load_cenc_metadata(mp4_path, ffprobe_executable: ffprobe_executable)
25
+ meta = metadata ||
26
+ Mp4Cenc.load_cenc_metadata(mp4_path, ffprobe_executable: ffprobe_executable)
27
+
28
+ unless session_device_id.nil?
29
+ Mp4Cenc.verify_session_device_id_from_metadata(session_device_id, meta)
30
+ end
25
31
 
26
32
  root = Pathname(sdk_root || Config.default_sdk_root)
27
33
  vault = KeyVault.new(root)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Robocap
3
+ module RobocapCenc
4
4
  module SDK
5
5
  module ErrorCode
6
6
  ERR_CUSTOMER_NOT_FOUND = 1001
@@ -33,6 +33,7 @@ module Robocap
33
33
  ERR_CENC_FFPROBE_FAILED = 7009
34
34
  ERR_CENC_CUSTOMER_ID_INVALID = 7010
35
35
  ERR_CENC_CEKA_TRIAL_FAILED = 7011
36
+ ERR_DEVICE_BINDING_MISMATCH = 7012
36
37
 
37
38
  NAMES = constants.each_with_object({}) do |c, h|
38
39
  v = const_get(c)
@@ -6,7 +6,7 @@ require 'pathname'
6
6
  require_relative 'config'
7
7
  require_relative 'errors'
8
8
 
9
- module Robocap
9
+ module RobocapCenc
10
10
  module SDK
11
11
  module FfmpegCli
12
12
  module_function
@@ -9,7 +9,7 @@ require_relative 'rsa_key_meta'
9
9
  require_relative 'rsa_oaep'
10
10
  require_relative 'vault_layout'
11
11
 
12
- module Robocap
12
+ module RobocapCenc
13
13
  module SDK
14
14
  CekTrialUnwrapResult = Data.define(:cek, :rsa_key_version)
15
15
 
@@ -7,14 +7,32 @@ require_relative 'config'
7
7
  require_relative 'errors'
8
8
  require_relative 'ffmpeg_cli'
9
9
 
10
- module Robocap
10
+ module RobocapCenc
11
11
  module SDK
12
- CencMp4Metadata = Data.define(:customer_id, :cek_wrapped, :kid_hex)
12
+ PRODUCT_LINE_ROBOCAP = 'robocap'
13
+ PRODUCT_LINE_ROBOWRIST = 'robowrist'
14
+ PRODUCT_LINE_LEGACY = 'legacy'
15
+
16
+ PRODUCT_LINES = [
17
+ PRODUCT_LINE_ROBOCAP,
18
+ PRODUCT_LINE_ROBOWRIST,
19
+ PRODUCT_LINE_LEGACY,
20
+ ].freeze
21
+
22
+ CencMp4Metadata = Data.define(
23
+ :customer_id, :cek_wrapped, :kid_hex, :product_line, :tag_deviceid, :tag_host,
24
+ ) do
25
+ def initialize(customer_id:, cek_wrapped:, kid_hex: nil,
26
+ product_line: PRODUCT_LINE_LEGACY, tag_deviceid: nil, tag_host: nil)
27
+ super
28
+ end
29
+ end
13
30
 
14
31
  module Mp4Cenc
15
32
  CEKA_TAG = 'cenc_cek_wrapped_b64'
16
33
  CUSTOMER_ID_TAG = 'cenc_customer_id'
17
- CUSTOMER_ID_FALLBACK_TAG = 'username'
34
+ CUSTOMER_ID_FALLBACK_TAG = 'deviceid'
35
+ HOST_TAG = 'host'
18
36
  KID_TAG = 'cenc_kid_hex'
19
37
 
20
38
  CENC_WRAPPED_ALGO_TAG = 'cenc_wrapped_algo'
@@ -63,7 +81,16 @@ module Robocap
63
81
  tags.transform_keys(&:to_s).transform_values(&:to_s)
64
82
  end
65
83
 
66
- def parse_cenc_metadata_from_tags(tags)
84
+ # Product line is derived from the filename prefix, which decides whether
85
+ # the vault is keyed by the +deviceid+ or the +host+ tag.
86
+ def detect_product_line(mp4_path)
87
+ stem = Pathname(mp4_path).basename('.*').to_s.downcase
88
+ return PRODUCT_LINE_ROBOWRIST if stem.start_with?('robowrist_')
89
+ return PRODUCT_LINE_ROBOCAP if stem.start_with?('robocap_')
90
+ PRODUCT_LINE_LEGACY
91
+ end
92
+
93
+ def parse_cenc_metadata_from_tags(tags, mp4_path: nil, product_line: nil)
67
94
  unless tags[CEKA_TAG] && !tags[CEKA_TAG].empty?
68
95
  raise Error.new(
69
96
  code: ErrorCode::ERR_CENC_TAGS_MISSING,
@@ -71,7 +98,10 @@ module Robocap
71
98
  )
72
99
  end
73
100
 
74
- customer_id = resolve_customer_id(tags)
101
+ line = product_line
102
+ line ||= mp4_path ? detect_product_line(mp4_path) : PRODUCT_LINE_LEGACY
103
+
104
+ customer_id = resolve_vault_customer_id(tags, line)
75
105
 
76
106
  begin
77
107
  cek_wrapped = Base64.strict_decode64(tags[CEKA_TAG])
@@ -93,23 +123,62 @@ module Robocap
93
123
  kid = kid.strip.downcase if kid
94
124
  kid = nil if kid && kid.empty?
95
125
 
96
- CencMp4Metadata.new(customer_id: customer_id, cek_wrapped: cek_wrapped, kid_hex: kid)
126
+ CencMp4Metadata.new(
127
+ customer_id: customer_id,
128
+ cek_wrapped: cek_wrapped,
129
+ kid_hex: kid,
130
+ product_line: line,
131
+ tag_deviceid: tag_value(tags, CUSTOMER_ID_FALLBACK_TAG) || tag_value(tags, CUSTOMER_ID_TAG),
132
+ tag_host: tag_value(tags, HOST_TAG),
133
+ )
97
134
  end
98
135
 
99
136
  def load_cenc_metadata(mp4_path, ffprobe_executable: nil)
137
+ path = Pathname(mp4_path).expand_path
138
+ tags = read_format_tags(path, ffprobe_executable: ffprobe_executable)
100
139
  parse_cenc_metadata_from_tags(
101
- read_format_tags(mp4_path, ffprobe_executable: ffprobe_executable),
140
+ tags, mp4_path: path, product_line: detect_product_line(path),
102
141
  )
103
142
  end
104
143
 
105
144
  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)
145
+ path = Pathname(mp4_path).expand_path
146
+ tags = read_format_tags(path, ffprobe_executable: ffprobe_executable)
147
+ has_required_tags_for_product?(tags, detect_product_line(path))
109
148
  rescue Error
110
149
  false
111
150
  end
112
151
 
152
+ # Binds a caller-supplied device id to the tags embedded in the MP4 so a
153
+ # capture cannot be decrypted under a different device's session.
154
+ def verify_session_device_id(session_device_id, tags, product_line)
155
+ normalized = normalize_session_device_id(session_device_id)
156
+
157
+ if product_line == PRODUCT_LINE_ROBOWRIST
158
+ expected = tag_value(tags, HOST_TAG)
159
+ field = HOST_TAG
160
+ else
161
+ expected = tag_value(tags, CUSTOMER_ID_TAG) || tag_value(tags, CUSTOMER_ID_FALLBACK_TAG)
162
+ field = CUSTOMER_ID_TAG
163
+ end
164
+
165
+ assert_session_device_id_matches(expected, normalized, field)
166
+ end
167
+
168
+ def verify_session_device_id_from_metadata(session_device_id, meta)
169
+ normalized = normalize_session_device_id(session_device_id)
170
+
171
+ if meta.product_line == PRODUCT_LINE_ROBOWRIST
172
+ expected = meta.tag_host
173
+ field = HOST_TAG
174
+ else
175
+ expected = meta.tag_deviceid
176
+ field = CUSTOMER_ID_FALLBACK_TAG
177
+ end
178
+
179
+ assert_session_device_id_matches(expected, normalized, field)
180
+ end
181
+
113
182
  class << self
114
183
  private
115
184
 
@@ -122,7 +191,24 @@ module Robocap
122
191
  )
123
192
  end
124
193
 
125
- def resolve_customer_id(tags)
194
+ def tag_value(tags, key)
195
+ raw = tags[key]
196
+ return nil if raw.nil?
197
+ stripped = raw.strip
198
+ stripped.empty? ? nil : stripped
199
+ end
200
+
201
+ def validate_customer_id_string(customer_id)
202
+ Config.validate_customer_id!(customer_id)
203
+ customer_id
204
+ rescue ArgumentError
205
+ raise Error.new(
206
+ code: ErrorCode::ERR_CENC_CUSTOMER_ID_INVALID,
207
+ message: "Invalid customer id: #{customer_id.inspect}",
208
+ )
209
+ end
210
+
211
+ def resolve_deviceid_from_tags(tags)
126
212
  raw = tags[CUSTOMER_ID_TAG] || tags[CUSTOMER_ID_FALLBACK_TAG]
127
213
  if raw.nil? || raw.strip.empty?
128
214
  raise Error.new(
@@ -130,23 +216,60 @@ module Robocap
130
216
  message: "Missing CENC customer id: #{CUSTOMER_ID_TAG} or #{CUSTOMER_ID_FALLBACK_TAG} tag required",
131
217
  )
132
218
  end
133
- customer = raw.strip
219
+ validate_customer_id_string(raw.strip)
220
+ end
221
+
222
+ def resolve_vault_customer_id(tags, product_line)
223
+ if product_line == PRODUCT_LINE_ROBOWRIST
224
+ host = tag_value(tags, HOST_TAG)
225
+ if host.nil?
226
+ raise Error.new(
227
+ code: ErrorCode::ERR_CENC_TAGS_MISSING,
228
+ message: "Missing CENC host tag for robowrist: #{HOST_TAG} required",
229
+ )
230
+ end
231
+ return validate_customer_id_string(host)
232
+ end
233
+ resolve_deviceid_from_tags(tags)
234
+ end
235
+
236
+ def has_customer_id_source?(tags)
237
+ [CUSTOMER_ID_TAG, CUSTOMER_ID_FALLBACK_TAG].any? { |k| !tag_value(tags, k).nil? }
238
+ end
239
+
240
+ def has_required_tags_for_product?(tags, product_line)
241
+ return false unless tags[CEKA_TAG] && !tags[CEKA_TAG].empty?
242
+ if product_line == PRODUCT_LINE_ROBOWRIST
243
+ return !tag_value(tags, HOST_TAG).nil? && has_customer_id_source?(tags)
244
+ end
245
+ has_customer_id_source?(tags)
246
+ end
247
+
248
+ def normalize_session_device_id(session_device_id)
249
+ normalized = session_device_id.to_s.strip
250
+ if normalized.empty?
251
+ raise Error.new(
252
+ code: ErrorCode::ERR_DEVICE_BINDING_MISMATCH,
253
+ message: 'Session device id is empty',
254
+ )
255
+ end
134
256
  begin
135
- Config.validate_customer_id!(customer)
257
+ Config.validate_customer_id!(normalized)
136
258
  rescue ArgumentError
137
259
  raise Error.new(
138
- code: ErrorCode::ERR_CENC_CUSTOMER_ID_INVALID,
139
- message: "Invalid customer id: #{customer.inspect}",
260
+ code: ErrorCode::ERR_DEVICE_BINDING_MISMATCH,
261
+ message: "Invalid session device id: #{normalized.inspect}",
140
262
  )
141
263
  end
142
- customer
264
+ normalized
143
265
  end
144
266
 
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
267
+ def assert_session_device_id_matches(expected, normalized, field)
268
+ return if !expected.nil? && expected == normalized
269
+ raise Error.new(
270
+ code: ErrorCode::ERR_DEVICE_BINDING_MISMATCH,
271
+ message: "Session device id does not match MP4 #{field} tag",
272
+ )
150
273
  end
151
274
  end
152
275
  end
@@ -6,7 +6,7 @@ require_relative 'config'
6
6
  require_relative 'errors'
7
7
  require_relative 'key_vault'
8
8
 
9
- module Robocap
9
+ module RobocapCenc
10
10
  module SDK
11
11
  VerifiedKeyVersion = Data.define(:customer_id, :matched_rsa_key_version, :public_fingerprint)
12
12
 
@@ -4,7 +4,7 @@ require 'pathname'
4
4
  require_relative 'config'
5
5
  require_relative 'key_vault'
6
6
 
7
- module Robocap
7
+ module RobocapCenc
8
8
  module SDK
9
9
  DeleteRsaResult = Data.define(:customer_id, :folder_name, :key_dir)
10
10
 
@@ -5,7 +5,7 @@ require_relative 'config'
5
5
  require_relative 'key_vault'
6
6
  require_relative 'rsa_key_meta'
7
7
 
8
- module Robocap
8
+ module RobocapCenc
9
9
  module SDK
10
10
  ImportRsaResult = Data.define(:customer_id, :rsa_key_version, :vault_rsa_dir)
11
11
 
@@ -4,7 +4,7 @@ require 'json'
4
4
  require 'time'
5
5
  require_relative 'config'
6
6
 
7
- module Robocap
7
+ module RobocapCenc
8
8
  module SDK
9
9
  class RsaKeyMeta < Data.define(:rsa_key_version, :effective_at, :device_id, :rsa_bits)
10
10
  def initialize(rsa_key_version:, effective_at:, device_id:, rsa_bits: Config::RSA_BITS)
@@ -5,7 +5,7 @@ require 'securerandom'
5
5
  require_relative 'config'
6
6
  require_relative 'errors'
7
7
 
8
- module Robocap
8
+ module RobocapCenc
9
9
  module SDK
10
10
  module RSAOAEP
11
11
  module_function
@@ -5,7 +5,7 @@ require 'pathname'
5
5
  require 'tempfile'
6
6
  require_relative 'errors'
7
7
 
8
- module Robocap
8
+ module RobocapCenc
9
9
  module SDK
10
10
  module VaultLayout
11
11
  module_function
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Robocap
3
+ module RobocapCenc
4
4
  module SDK
5
- VERSION = '1.0.0'
5
+ VERSION = '2.1.0'
6
6
  end
7
7
  end
data/lib/robocap/sdk.rb CHANGED
@@ -14,7 +14,7 @@ require_relative 'sdk/rsa_import'
14
14
  require_relative 'sdk/rsa_delete'
15
15
  require_relative 'sdk/decrypt_cenc'
16
16
 
17
- module Robocap
17
+ module RobocapCenc
18
18
  module SDK
19
19
  module_function
20
20
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: robocap-decryption-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Frodobots