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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a5c60c2503cabf98d07480ea6cb28d0068b4ba3b292815c7474cabe6fb6614a6
4
+ data.tar.gz: c10616d9b302342d67a70e9e9882befd1bfcce88a7c64963e2429362778140ed
5
+ SHA512:
6
+ metadata.gz: ea720dfee5ddf6b53acad53ab0e8b91ad0c32f0037712a78da9f7b0a58e6a0b4822d94265f94c42c7a62e6f31c0aa33ad50a6cb3e46be2e2b49ddc87c304deea
7
+ data.tar.gz: 9078269ccd590ed466d9912c7df09d42f84e647dd22c7f0edf701f3d753b9c27d29ff13a883aaca3acffc75c52d9868c69c1b7ec13c1a5cf3f8254e48da96f88
data/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # Robocap Decryption SDK — Ruby
2
+
3
+ Offline import of RSA keys and decryption of CENC-encrypted MP4 files.
4
+
5
+ This is the Ruby implementation. The shared format spec and test fixtures
6
+ live one directory up at the monorepo root — see [`../README.md`](../README.md).
7
+
8
+ ## Requirements
9
+
10
+ - Ruby 3.2+
11
+ - OpenSSL 3.x (system or brewed) — required for RSA-OAEP-SHA256
12
+ - `ffmpeg` and `ffprobe` on `PATH` (for `decrypt-cenc`)
13
+
14
+ ## Install (from git checkout)
15
+
16
+ ```bash
17
+ cd ruby
18
+ bundle install
19
+ ```
20
+
21
+ ## Tests
22
+
23
+ ```bash
24
+ bundle exec rake test
25
+ ```
26
+
27
+ ## CLI
28
+
29
+ ```bash
30
+ bundle exec exe/robocap-decryption-sdk import-rsa --help
31
+ bundle exec exe/robocap-decryption-sdk delete-rsa --help
32
+ bundle exec exe/robocap-decryption-sdk decrypt-cenc --help
33
+ ```
34
+
35
+ ## Verified against the Python SDK
36
+
37
+ This Ruby gem reads and writes the same on-disk vault and CENC MP4 format
38
+ as `python/`. To re-verify byte-compatibility, follow the manual checks
39
+ in [`../spec/cross-sdk-verification.md`](../spec/cross-sdk-verification.md).
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'robocap/sdk/cli'
5
+
6
+ exit Robocap::SDK::CLI.run(ARGV.dup)
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'optparse'
5
+ require 'pathname'
6
+ require 'time'
7
+ require_relative '../sdk'
8
+
9
+ module Robocap
10
+ module SDK
11
+ module CLI
12
+ module_function
13
+
14
+ USAGE = <<~TXT
15
+ Usage: robocap-decryption-sdk <command> [options]
16
+
17
+ Commands:
18
+ import-rsa Import an RSA key pair into the vault
19
+ delete-rsa Delete one RSA key version from the vault
20
+ decrypt-cenc Decrypt a CENC MP4 (RSA-OAEP CEK unwrap + ffmpeg)
21
+
22
+ Run `robocap-decryption-sdk <command> --help` for command-specific options.
23
+ TXT
24
+
25
+ def run(argv)
26
+ command = argv.shift
27
+ case command
28
+ when 'import-rsa' then with_error_handling { cmd_import_rsa(argv) }
29
+ when 'delete-rsa' then with_error_handling { cmd_delete_rsa(argv) }
30
+ when 'decrypt-cenc' then with_error_handling { cmd_decrypt_cenc(argv) }
31
+ when '--help', '-h', nil
32
+ warn(USAGE)
33
+ 0
34
+ else
35
+ warn("Unknown command: #{command}\n\n#{USAGE}")
36
+ 2
37
+ end
38
+ end
39
+
40
+ def with_error_handling
41
+ yield
42
+ 0
43
+ rescue Error => exc
44
+ warn(JSON.generate(exc.to_h))
45
+ 1
46
+ rescue OptionParser::ParseError => exc
47
+ warn(exc.message)
48
+ 2
49
+ end
50
+
51
+ def emit_json(data)
52
+ puts JSON.generate(data)
53
+ end
54
+
55
+ def cmd_import_rsa(argv)
56
+ opts = {}
57
+ OptionParser.new do |o|
58
+ o.banner = 'Usage: robocap-decryption-sdk import-rsa [options]'
59
+ o.on('--customer-id CID') { |v| opts[:customer_id] = v }
60
+ o.on('--public-key PATH') { |v| opts[:public_key] = Pathname(v) }
61
+ o.on('--private-key PATH') { |v| opts[:private_key] = Pathname(v) }
62
+ o.on('--rsa-key-version N', Integer) { |v| opts[:rsa_key_version] = v }
63
+ o.on('--rsa-bits N', ['2048', '4096']) { |v| opts[:rsa_bits] = v.to_i }
64
+ o.on('--device-id ID') { |v| opts[:device_id] = v }
65
+ o.on('--effective-at ISO8601') { |v| opts[:effective_at] = v }
66
+ o.on('--sdk-root PATH') { |v| opts[:sdk_root] = Pathname(v) }
67
+ end.parse!(argv)
68
+
69
+ require_opts!(opts, %i[customer_id public_key private_key rsa_key_version])
70
+ opts[:rsa_bits] ||= 2048
71
+ eff = opts[:effective_at] ? Time.iso8601(opts[:effective_at]) : Time.now.utc
72
+ meta = RsaKeyMeta.new(
73
+ rsa_key_version: opts[:rsa_key_version],
74
+ effective_at: eff,
75
+ device_id: opts[:device_id] || opts[:customer_id],
76
+ rsa_bits: opts[:rsa_bits],
77
+ )
78
+ result = Robocap::SDK.import_rsa_key_version(
79
+ customer_id: opts[:customer_id],
80
+ public_pem: opts[:public_key].binread,
81
+ private_pem: opts[:private_key].binread,
82
+ meta: meta,
83
+ sdk_root: opts[:sdk_root],
84
+ )
85
+ emit_json(
86
+ status: 'ok',
87
+ customer_id: result.customer_id,
88
+ rsa_key_version: result.rsa_key_version,
89
+ vault_rsa_dir: result.vault_rsa_dir.to_s,
90
+ )
91
+ end
92
+
93
+ def cmd_delete_rsa(argv)
94
+ opts = {}
95
+ OptionParser.new do |o|
96
+ o.banner = 'Usage: robocap-decryption-sdk delete-rsa [options]'
97
+ o.on('--customer-id CID') { |v| opts[:customer_id] = v }
98
+ o.on('--rsa-key-version N', Integer) { |v| opts[:rsa_key_version] = v }
99
+ o.on('--sdk-root PATH') { |v| opts[:sdk_root] = Pathname(v) }
100
+ end.parse!(argv)
101
+
102
+ require_opts!(opts, %i[customer_id rsa_key_version])
103
+ result = Robocap::SDK.delete_rsa_key_version(
104
+ customer_id: opts[:customer_id],
105
+ rsa_key_version: opts[:rsa_key_version],
106
+ sdk_root: opts[:sdk_root],
107
+ )
108
+ emit_json(
109
+ status: 'ok',
110
+ customer_id: result.customer_id,
111
+ rsa_key_version: opts[:rsa_key_version],
112
+ folder_name: result.folder_name,
113
+ )
114
+ end
115
+
116
+ def cmd_decrypt_cenc(argv)
117
+ opts = {}
118
+ OptionParser.new do |o|
119
+ o.banner = 'Usage: robocap-decryption-sdk decrypt-cenc [options]'
120
+ o.on('--mp4-path PATH') { |v| opts[:mp4_path] = Pathname(v) }
121
+ o.on('--private-key PATH') { |v| opts[:private_key] = Pathname(v) }
122
+ o.on('--output-dir PATH') { |v| opts[:output_dir] = Pathname(v) }
123
+ o.on('--sdk-root PATH') { |v| opts[:sdk_root] = Pathname(v) }
124
+ o.on('--ffmpeg PATH') { |v| opts[:ffmpeg_executable] = v }
125
+ o.on('--ffprobe PATH') { |v| opts[:ffprobe_executable] = v }
126
+ end.parse!(argv)
127
+
128
+ require_opts!(opts, %i[mp4_path private_key output_dir])
129
+ opts[:output_dir].mkpath
130
+ result = Robocap::SDK.decrypt_cenc_mp4(
131
+ mp4_path: opts[:mp4_path],
132
+ user_private_pem: opts[:private_key].binread,
133
+ output_dir: opts[:output_dir],
134
+ sdk_root: opts[:sdk_root],
135
+ ffmpeg_executable: opts[:ffmpeg_executable],
136
+ ffprobe_executable: opts[:ffprobe_executable],
137
+ )
138
+ emit_json(
139
+ status: 'ok',
140
+ output_path: result.output_path.to_s,
141
+ customer_id: result.customer_id,
142
+ rsa_key_version: result.rsa_key_version,
143
+ kid_hex: result.kid_hex,
144
+ )
145
+ end
146
+
147
+ def require_opts!(opts, keys)
148
+ missing = keys.reject { |k| opts.key?(k) }
149
+ return if missing.empty?
150
+ raise OptionParser::ParseError, "missing required option(s): #{missing.map { |k| "--#{k.to_s.tr('_', '-')}" }.join(', ')}"
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Robocap
6
+ module SDK
7
+ module Config
8
+ VAULT_KEYS_DIR = 'vault/keys'
9
+ VAULT_FILES_DIR = 'vault/files'
10
+
11
+ RSA_BITS = 4096
12
+ RSA_BITS_ALLOWED = [2048, 4096].freeze
13
+ RSA_PUBLIC_EXPONENT = 65_537
14
+ RSA_PADDING_SCHEME = 'OAEP_SHA256'
15
+ RSA_OAEP_HASH = 'sha256'
16
+ RSA_OAEP_MGF_HASH = 'sha256'
17
+ RSA_OAEP_LABEL = nil
18
+ RSA_CIPHERTEXT_BYTES = 512
19
+ RSA_2048_CIPHERTEXT_BYTES = 256
20
+
21
+ CEK_BYTES = 16
22
+ AES_KEY_BYTES = 32
23
+ AES_NONCE_BYTES = 12
24
+ AES_TAG_BYTES = 16
25
+
26
+ K2_MAGIC = "RCK2".b.freeze
27
+ K2_FORMAT_VERSION = 1
28
+ K2_HEADER_BYTES = 9
29
+ K2_BLOB_MIN_BYTES = K2_HEADER_BYTES + RSA_CIPHERTEXT_BYTES
30
+ META_FORMAT_VERSION = 1
31
+
32
+ DEFAULT_AES_BACKEND = 'cryptography'
33
+ AES_BACKEND_OPENSSL = 'openssl_cli'
34
+ OPENSSL_ENV_VAR = 'ROBOCAP_OPENSSL'
35
+ FFMPEG_ENV_VAR = 'ROBOCAP_FFMPEG'
36
+ FFPROBE_ENV_VAR = 'ROBOCAP_FFPROBE'
37
+
38
+ MASTER_KEY_FILENAME = '.master_key'
39
+ DEVICE_AES_FILENAME = 'device_aes.enc.pem'
40
+
41
+ CUSTOMER_ID_PATTERN = /\A[A-Za-z0-9_\-]+\z/
42
+
43
+ module_function
44
+
45
+ def default_sdk_root
46
+ path = ENV['ROBOCAP_SDK_ROOT']
47
+ path && !path.empty? ? Pathname(path) : Pathname(Dir.home).join('.robocap-sdk')
48
+ end
49
+
50
+ def validate_customer_id!(customer_id)
51
+ unless customer_id.is_a?(String) && customer_id.match?(CUSTOMER_ID_PATTERN)
52
+ raise ArgumentError,
53
+ "Invalid customer_id: #{customer_id.inspect} " \
54
+ '(allowed: alphanumeric, underscore, hyphen)'
55
+ end
56
+ end
57
+
58
+ def keys_vault_root(sdk_root)
59
+ Pathname(sdk_root).join(VAULT_KEYS_DIR)
60
+ end
61
+
62
+ def files_vault_root(sdk_root)
63
+ Pathname(sdk_root).join(VAULT_FILES_DIR)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative 'config'
5
+ require_relative 'errors'
6
+ require_relative 'key_vault'
7
+ require_relative 'mp4_cenc'
8
+ require_relative 'ownership'
9
+ require_relative 'ffmpeg_cli'
10
+ require_relative 'vault_layout'
11
+
12
+ module Robocap
13
+ module SDK
14
+ DecryptCencResult = Data.define(:output_path, :customer_id, :rsa_key_version, :kid_hex)
15
+
16
+ module DecryptCenc
17
+ module_function
18
+
19
+ def call(mp4_path:, user_private_pem:, output_dir:,
20
+ sdk_root: nil, ffprobe_executable: nil, ffmpeg_executable: nil)
21
+ mp4_path = Pathname(mp4_path)
22
+ output_dir = Pathname(output_dir)
23
+
24
+ meta = Mp4Cenc.load_cenc_metadata(mp4_path, ffprobe_executable: ffprobe_executable)
25
+
26
+ root = Pathname(sdk_root || Config.default_sdk_root)
27
+ vault = KeyVault.new(root)
28
+
29
+ unless vault.exists_customer?(meta.customer_id)
30
+ raise Error.new(
31
+ code: ErrorCode::ERR_CUSTOMER_NOT_FOUND,
32
+ message: "Customer not found in vault: #{meta.customer_id}",
33
+ )
34
+ end
35
+
36
+ Ownership.verify(
37
+ customer_id: meta.customer_id,
38
+ user_private_pem: user_private_pem,
39
+ key_vault: vault,
40
+ )
41
+
42
+ trial = vault.trial_unwrap_cek(meta.customer_id, meta.cek_wrapped)
43
+ cek_hex = trial.cek.unpack1('H*')
44
+
45
+ VaultLayout.ensure_private_dir(output_dir)
46
+ out_path = output_dir.join(mp4_path.basename)
47
+ FfmpegCli.decrypt_cenc_copy(
48
+ input_mp4: mp4_path,
49
+ output_mp4: out_path,
50
+ cek_hex: cek_hex,
51
+ kid_hex: meta.kid_hex,
52
+ ffmpeg_executable: ffmpeg_executable,
53
+ )
54
+
55
+ DecryptCencResult.new(
56
+ output_path: out_path,
57
+ customer_id: meta.customer_id,
58
+ rsa_key_version: trial.rsa_key_version,
59
+ kid_hex: meta.kid_hex,
60
+ )
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Robocap
4
+ module SDK
5
+ module ErrorCode
6
+ ERR_CUSTOMER_NOT_FOUND = 1001
7
+ ERR_CUSTOMER_ALREADY_EXISTS = 1002
8
+ ERR_KEY_OWNERSHIP_FAILED = 2001
9
+ ERR_CUSTOMER_MISMATCH = 2002
10
+ ERR_RSA_VERSION_MISSING = 3001
11
+ ERR_K2_META_VERSION_MISMATCH = 3002
12
+ ERR_K2_DECODE = 3003
13
+ ERR_K2_PLAINTEXT_LENGTH = 3004
14
+ ERR_SIDECAR_INCOMPLETE = 4001
15
+ ERR_META_VALIDATION = 4002
16
+ ERR_DECRYPT_AUTH_TAG = 5001
17
+ ERR_SHA256_MISMATCH = 5002
18
+ ERR_VAULT_IO = 6001
19
+ ERR_MASTER_KEY = 6002
20
+ ERR_OPENSSL_CLI_UNAVAILABLE = 6003
21
+ ERR_INVALID_RSA_BITS = 6004
22
+ ERR_OPENSSL_CLI_FAILED = 6005
23
+ ERR_RSA_IMPORT_INVALID = 6006
24
+ ERR_RSA_NOT_IMPORTED = 6007
25
+ ERR_DEVICE_AES_EXISTS = 6008
26
+ ERR_RSA_V1_REQUIRED = 6009
27
+ ERR_CENC_TAGS_MISSING = 7001
28
+ ERR_CENC_CEKA_WRAP = 7004
29
+ ERR_CENC_CEKA_LENGTH = 7005
30
+ ERR_FFMPEG_NOT_FOUND = 7006
31
+ ERR_FFPROBE_NOT_FOUND = 7007
32
+ ERR_CENC_DECRYPT_FAILED = 7008
33
+ ERR_CENC_FFPROBE_FAILED = 7009
34
+ ERR_CENC_CUSTOMER_ID_INVALID = 7010
35
+ ERR_CENC_CEKA_TRIAL_FAILED = 7011
36
+
37
+ NAMES = constants.each_with_object({}) do |c, h|
38
+ v = const_get(c)
39
+ h[v] = c.to_s if v.is_a?(Integer)
40
+ end.freeze
41
+
42
+ module_function
43
+
44
+ def name_for(code)
45
+ NAMES.fetch(code) { "ERR_UNKNOWN_#{code}" }
46
+ end
47
+ end
48
+
49
+ class Error < StandardError
50
+ attr_reader :code, :detail
51
+
52
+ def initialize(code:, message:, detail: nil)
53
+ @code = code
54
+ @detail = detail
55
+ @plain_message = message
56
+ super("[#{ErrorCode.name_for(code)}] #{message}")
57
+ end
58
+
59
+ def to_h
60
+ {
61
+ code: @code,
62
+ error: ErrorCode.name_for(@code),
63
+ message: @plain_message,
64
+ detail: @detail,
65
+ }
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+ require 'pathname'
6
+ require_relative 'config'
7
+ require_relative 'errors'
8
+
9
+ module Robocap
10
+ module SDK
11
+ module FfmpegCli
12
+ module_function
13
+
14
+ def open3_capture3(*args)
15
+ Open3.capture3(*args)
16
+ end
17
+
18
+ def resolve_ffprobe_executable(explicit = nil)
19
+ resolve_executable(
20
+ explicit: explicit,
21
+ tool: 'ffprobe',
22
+ sibling_for: ENV['ROBOCAP_FFMPEG'],
23
+ missing_code: ErrorCode::ERR_FFPROBE_NOT_FOUND,
24
+ env_key: Config::FFPROBE_ENV_VAR,
25
+ )
26
+ end
27
+
28
+ def resolve_ffmpeg_executable(explicit = nil)
29
+ resolve_executable(
30
+ explicit: explicit,
31
+ tool: 'ffmpeg',
32
+ sibling_for: nil,
33
+ missing_code: ErrorCode::ERR_FFMPEG_NOT_FOUND,
34
+ env_key: Config::FFMPEG_ENV_VAR,
35
+ )
36
+ end
37
+
38
+ def decrypt_cenc_copy(input_mp4:, output_mp4:, cek_hex:, kid_hex: nil, ffmpeg_executable: nil)
39
+ exe = resolve_ffmpeg_executable(ffmpeg_executable)
40
+ FileUtils.mkdir_p(Pathname(output_mp4).parent)
41
+ cmd = [exe, '-y', '-decryption_key', cek_hex]
42
+ cmd.push('-decryption_kid', kid_hex) if kid_hex && !kid_hex.empty?
43
+ cmd.push(
44
+ '-i', input_mp4.to_s,
45
+ '-map', '0',
46
+ '-map_metadata', '0',
47
+ '-c', 'copy',
48
+ '-movflags', '+use_metadata_tags',
49
+ )
50
+ Mp4Cenc::CENC_STRIP_TAGS_ON_DECRYPT.each do |tag|
51
+ cmd.push('-metadata', "#{tag}=")
52
+ end
53
+ cmd.push(output_mp4.to_s)
54
+
55
+ begin
56
+ _stdout, stderr, status = open3_capture3(*cmd)
57
+ rescue SystemCallError => exc
58
+ raise Error.new(
59
+ code: ErrorCode::ERR_CENC_DECRYPT_FAILED,
60
+ message: 'ffmpeg CENC decrypt subprocess failed',
61
+ detail: { reason: exc.message },
62
+ )
63
+ end
64
+
65
+ return if status.exitstatus.zero?
66
+ raise Error.new(
67
+ code: ErrorCode::ERR_CENC_DECRYPT_FAILED,
68
+ message: "ffmpeg CENC decrypt failed: #{stderr}",
69
+ )
70
+ end
71
+
72
+ class << self
73
+ private
74
+
75
+ def resolve_executable(explicit:, tool:, sibling_for:, missing_code:, env_key:)
76
+ if explicit && !explicit.empty?
77
+ path = Pathname(explicit)
78
+ return path.to_s if path.file?
79
+ return explicit unless explicit.include?('/') || explicit.include?('\\')
80
+ raise Error.new(code: missing_code, message: "#{tool} executable not found: #{explicit}")
81
+ end
82
+ env_path = ENV[env_key]
83
+ return env_path if env_path && Pathname(env_path).file?
84
+ if sibling_for
85
+ sib = Pathname(sibling_for)
86
+ if sib.file?
87
+ sibling = sib.dirname.join(tool + (sib.extname.downcase == '.exe' ? '.exe' : ''))
88
+ return sibling.to_s if sibling.file?
89
+ end
90
+ end
91
+ found = which(tool)
92
+ return found if found
93
+ raise Error.new(code: missing_code, message: "#{tool} executable not found in PATH")
94
+ end
95
+
96
+ def which(name)
97
+ exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(File::PATH_SEPARATOR) : ['']
98
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |dir|
99
+ exts.each do |ext|
100
+ candidate = File.join(dir, name + ext)
101
+ return candidate if File.executable?(candidate) && !File.directory?(candidate)
102
+ end
103
+ end
104
+ nil
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end