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
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,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
|