aspera-cli 4.27.0 → 4.27.2
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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +46 -1
- data/CONTRIBUTING.md +1 -7
- data/TODO.md +50 -0
- data/docs/README.md +709 -489
- data/lib/aspera/agent/base.rb +10 -2
- data/lib/aspera/agent/connect.rb +7 -7
- data/lib/aspera/agent/desktop.rb +3 -3
- data/lib/aspera/agent/direct.rb +48 -19
- data/lib/aspera/agent/factory.rb +3 -3
- data/lib/aspera/agent/httpgw.rb +55 -14
- data/lib/aspera/agent/node.rb +2 -2
- data/lib/aspera/agent/transferd.rb +12 -12
- data/lib/aspera/api/aoc.rb +28 -28
- data/lib/aspera/api/cos_node.rb +10 -9
- data/lib/aspera/api/faspex.rb +2 -2
- data/lib/aspera/api/httpgw.rb +20 -20
- data/lib/aspera/api/node.rb +31 -53
- data/lib/aspera/ascmd.rb +13 -13
- data/lib/aspera/ascp/installation.rb +43 -36
- data/lib/aspera/ascp/management.rb +6 -6
- data/lib/aspera/assert.rb +12 -13
- data/lib/aspera/cli/ascp_actions.rb +7 -7
- data/lib/aspera/cli/async_transfer_store.rb +49 -5
- data/lib/aspera/cli/bootstrapper.rb +9 -9
- data/lib/aspera/cli/command_registry.rb +1 -1
- data/lib/aspera/cli/command_spec.rb +6 -3
- data/lib/aspera/cli/context.rb +3 -3
- data/lib/aspera/cli/extended_value.rb +26 -26
- data/lib/aspera/cli/formatter.rb +76 -54
- data/lib/aspera/cli/gem_checker.rb +1 -1
- data/lib/aspera/cli/hints.rb +2 -2
- data/lib/aspera/cli/http.rb +22 -29
- data/lib/aspera/cli/mailer.rb +7 -7
- data/lib/aspera/cli/mcp_tool.rb +31 -4
- data/lib/aspera/cli/option_declarator.rb +77 -0
- data/lib/aspera/cli/options.schema.yaml +273 -3
- data/lib/aspera/cli/parser.rb +539 -280
- data/lib/aspera/cli/plugins/alee.rb +1 -1
- data/lib/aspera/cli/plugins/aoc.rb +303 -314
- data/lib/aspera/cli/plugins/ats.rb +25 -21
- data/lib/aspera/cli/plugins/base.rb +357 -186
- data/lib/aspera/cli/plugins/basic_auth.rb +0 -12
- data/lib/aspera/cli/plugins/config.rb +76 -73
- data/lib/aspera/cli/plugins/console.rb +8 -8
- data/lib/aspera/cli/plugins/cos.rb +4 -3
- data/lib/aspera/cli/plugins/factory.rb +2 -2
- data/lib/aspera/cli/plugins/faspex5.rb +259 -248
- data/lib/aspera/cli/plugins/faspio.rb +7 -2
- data/lib/aspera/cli/plugins/httpgw.rb +1 -1
- data/lib/aspera/cli/plugins/mcp.rb +11 -11
- data/lib/aspera/cli/plugins/node.rb +227 -283
- data/lib/aspera/cli/plugins/orchestrator.rb +23 -23
- data/lib/aspera/cli/plugins/preview.rb +61 -64
- data/lib/aspera/cli/plugins/server.rb +36 -31
- data/lib/aspera/cli/plugins/shares.rb +174 -135
- data/lib/aspera/cli/preset_actions.rb +39 -19
- data/lib/aspera/cli/preset_manager.rb +74 -32
- data/lib/aspera/cli/result.rb +18 -16
- data/lib/aspera/cli/runner.rb +75 -39
- data/lib/aspera/cli/secret_finder.rb +1 -1
- data/lib/aspera/cli/sync_actions.rb +36 -35
- data/lib/aspera/cli/terminal_formatter.rb +2 -2
- data/lib/aspera/cli/transfer_actions.rb +17 -10
- data/lib/aspera/cli/transfer_agent.rb +44 -44
- data/lib/aspera/cli/transfer_progress.rb +7 -7
- data/lib/aspera/cli/vault_manager.rb +34 -12
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/wizard.rb +17 -20
- data/lib/aspera/colors.rb +3 -3
- data/lib/aspera/command_line_builder.rb +17 -16
- data/lib/aspera/command_line_converter.rb +1 -1
- data/lib/aspera/coverage.rb +1 -2
- data/lib/aspera/data_repository.rb +1 -1
- data/lib/aspera/dot_container.rb +8 -8
- data/lib/aspera/environment.rb +13 -13
- data/lib/aspera/faspex_gw.rb +4 -4
- data/lib/aspera/faspex_postproc.rb +14 -9
- data/lib/aspera/graphql.rb +4 -2
- data/lib/aspera/hash_ext.rb +2 -2
- data/lib/aspera/json_rpc/client.rb +1 -1
- data/lib/aspera/keychain/base.rb +10 -3
- data/lib/aspera/keychain/encrypted_hash.rb +92 -17
- data/lib/aspera/keychain/factory.rb +17 -5
- data/lib/aspera/keychain/hashicorp_vault.rb +1 -1
- data/lib/aspera/keychain/macos_security.rb +23 -25
- data/lib/aspera/keychain/one_password_api.rb +86 -0
- data/lib/aspera/keychain/one_password_base.rb +34 -0
- data/lib/aspera/keychain/one_password_cli.rb +98 -0
- data/lib/aspera/link_header.rb +2 -2
- data/lib/aspera/log.rb +13 -13
- data/lib/aspera/markdown.rb +5 -5
- data/lib/aspera/nagios.rb +8 -8
- data/lib/aspera/node_simulator.rb +33 -27
- data/lib/aspera/oauth/base.rb +4 -5
- data/lib/aspera/oauth/boot.rb +3 -3
- data/lib/aspera/oauth/factory.rb +5 -5
- data/lib/aspera/oauth/jwt.rb +5 -7
- data/lib/aspera/oauth/web.rb +4 -4
- data/lib/aspera/persistency_action_once.rb +4 -4
- data/lib/aspera/persistency_folder.rb +8 -8
- data/lib/aspera/preview/file_types.rb +4 -4
- data/lib/aspera/preview/generator.rb +49 -46
- data/lib/aspera/preview/options.rb +3 -2
- data/lib/aspera/preview/terminal.rb +5 -5
- data/lib/aspera/preview/utils.rb +65 -18
- data/lib/aspera/products/connect.rb +2 -2
- data/lib/aspera/products/desktop.rb +1 -1
- data/lib/aspera/products/other.rb +2 -2
- data/lib/aspera/products/transferd.rb +3 -3
- data/lib/aspera/proxy_auto_config.rb +10 -10
- data/lib/aspera/rest.rb +149 -115
- data/lib/aspera/rest_error_analyzer.rb +3 -3
- data/lib/aspera/rest_list.rb +10 -9
- data/lib/aspera/schema/IBM Aspera Node API-4.4.6.yaml +6232 -0
- data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +249 -0
- data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +39 -0
- data/lib/aspera/schema/IBM_Aspera_Shares.yaml +4653 -0
- data/lib/aspera/schema/documentation.rb +17 -11
- data/lib/aspera/schema/reader.rb +78 -4
- data/lib/aspera/schema/registry.rb +26 -7
- data/lib/aspera/secret_hider.rb +5 -5
- data/lib/aspera/ssh.rb +64 -31
- data/lib/aspera/ssl.rb +3 -3
- data/lib/aspera/sync/database.rb +2 -2
- data/lib/aspera/sync/operations.rb +13 -13
- data/lib/aspera/temp_file_manager.rb +24 -4
- data/lib/aspera/transfer/faux_file.rb +2 -2
- data/lib/aspera/transfer/parameters.rb +15 -15
- data/lib/aspera/transfer/resumer.rb +9 -9
- data/lib/aspera/transfer/spec.rb +1 -1
- data/lib/aspera/transfer/spec.schema.yaml +1 -0
- data/lib/aspera/uri_reader.rb +52 -15
- data/lib/aspera/web_auth.rb +6 -5
- data/lib/aspera/web_server_simple.rb +9 -9
- data/lib/aspera/yaml.rb +4 -4
- data.tar.gz.sig +0 -0
- metadata +10 -4
- metadata.gz.sig +0 -0
- data/lib/aspera/cli/plugins/faspex.rb +0 -576
- data/lib/aspera/transfer/uri.rb +0 -56
|
@@ -5,7 +5,8 @@ require 'aspera/environment'
|
|
|
5
5
|
require 'aspera/log'
|
|
6
6
|
require 'aspera/assert'
|
|
7
7
|
require 'aspera/keychain/base'
|
|
8
|
-
require '
|
|
8
|
+
require 'aspera/yaml'
|
|
9
|
+
require 'openssl'
|
|
9
10
|
require 'yaml'
|
|
10
11
|
|
|
11
12
|
module Aspera
|
|
@@ -16,30 +17,47 @@ module Aspera
|
|
|
16
17
|
DEFAULT_CIPHER_NAME = 'aes-256-cbc'
|
|
17
18
|
FILE_TYPE = 'encrypted_hash_vault'
|
|
18
19
|
FILE_KEYS = %w[version type cipher data].sort.freeze
|
|
19
|
-
|
|
20
|
+
FILE_KEYS_KDF = (%w[kdf] + FILE_KEYS).sort.freeze
|
|
21
|
+
# PBKDF2 parameters for new vaults
|
|
22
|
+
KDF_ITERATIONS = 200_000
|
|
23
|
+
KDF_DIGEST = 'SHA256'
|
|
24
|
+
KDF_SALT_BYTES = 16
|
|
25
|
+
private_constant :LEGACY_CIPHER_NAME, :DEFAULT_CIPHER_NAME, :FILE_TYPE, :FILE_KEYS, :FILE_KEYS_KDF,
|
|
26
|
+
:KDF_ITERATIONS, :KDF_DIGEST, :KDF_SALT_BYTES
|
|
27
|
+
|
|
20
28
|
def initialize(file:, password:)
|
|
21
29
|
super()
|
|
22
|
-
Aspera.assert_type(file, String){'path to vault file'}
|
|
30
|
+
Aspera.assert_type(file, String) { 'path to vault file' }
|
|
23
31
|
@path = file
|
|
32
|
+
Log.dump(:vault_file, @path)
|
|
24
33
|
@all_secrets = {}
|
|
25
34
|
@cipher_name = DEFAULT_CIPHER_NAME
|
|
35
|
+
@kdf_params = nil
|
|
36
|
+
# true when the vault was created by v4.26.0 (password padded with zeros, no KDF)
|
|
37
|
+
@legacy_key = false
|
|
26
38
|
vault_encrypted_data = nil
|
|
39
|
+
Log.dump(:vault_file, File.expand_path(@path))
|
|
27
40
|
if File.exist?(@path)
|
|
28
41
|
vault_file = File.read(@path)
|
|
29
42
|
if vault_file.start_with?('---')
|
|
30
43
|
vault_info = YAML.parse(vault_file).to_ruby
|
|
31
|
-
|
|
44
|
+
sorted_keys = vault_info.keys.sort
|
|
45
|
+
Aspera.assert(sorted_keys == FILE_KEYS || sorted_keys == FILE_KEYS_KDF) { "Invalid vault file: #{@path}: #{sorted_keys}" }
|
|
32
46
|
@cipher_name = vault_info['cipher']
|
|
47
|
+
@kdf_params = vault_info['kdf']
|
|
48
|
+
# vault created before PBKDF2 was introduced (v4.26.0 format: no kdf field)
|
|
49
|
+
@legacy_key = @kdf_params.nil?
|
|
33
50
|
vault_encrypted_data = vault_info['data']
|
|
34
51
|
else
|
|
35
|
-
# legacy vault file
|
|
52
|
+
# legacy vault file (binary, pre-YAML format)
|
|
36
53
|
@cipher_name = LEGACY_CIPHER_NAME
|
|
54
|
+
@legacy_key = true
|
|
37
55
|
vault_encrypted_data = File.read(@path, mode: 'rb')
|
|
38
56
|
end
|
|
39
57
|
end
|
|
40
58
|
# setting password also creates the cipher
|
|
41
59
|
@cipher = cipher(password)
|
|
42
|
-
@all_secrets =
|
|
60
|
+
@all_secrets = Yaml.safe_load(@cipher.decrypt(vault_encrypted_data)) || {} if !vault_encrypted_data.nil?
|
|
43
61
|
end
|
|
44
62
|
|
|
45
63
|
def info
|
|
@@ -48,12 +66,12 @@ module Aspera
|
|
|
48
66
|
}
|
|
49
67
|
end
|
|
50
68
|
|
|
51
|
-
def
|
|
69
|
+
def all
|
|
52
70
|
result = []
|
|
53
71
|
@all_secrets.each do |label, values|
|
|
54
72
|
normal = values.symbolize_keys
|
|
55
73
|
normal[:label] = label
|
|
56
|
-
CONTENT_KEYS.each{ |k| normal[k] = '' unless normal.key?(k)}
|
|
74
|
+
CONTENT_KEYS.each { |k| normal[k] = '' unless normal.key?(k) }
|
|
57
75
|
result.push(normal)
|
|
58
76
|
end
|
|
59
77
|
return result
|
|
@@ -64,13 +82,13 @@ module Aspera
|
|
|
64
82
|
def set(options)
|
|
65
83
|
validate_set(options)
|
|
66
84
|
label = options.delete(:label)
|
|
67
|
-
Aspera.assert(!@all_secrets.key?(label)){"secret #{label} already exist, delete first"}
|
|
85
|
+
Aspera.assert(!@all_secrets.key?(label)) { "secret #{label} already exist, delete first" }
|
|
68
86
|
@all_secrets[label] = options.symbolize_keys
|
|
69
87
|
save
|
|
70
88
|
end
|
|
71
89
|
|
|
72
90
|
def get(label:, exception: true)
|
|
73
|
-
Aspera.assert(@all_secrets.key?(label)){"Label not found: #{label}"} if exception
|
|
91
|
+
Aspera.assert(@all_secrets.key?(label)) { "Label not found: #{label}" } if exception
|
|
74
92
|
result = @all_secrets[label].clone
|
|
75
93
|
result[:label] = label if result.is_a?(Hash)
|
|
76
94
|
return result
|
|
@@ -82,28 +100,85 @@ module Aspera
|
|
|
82
100
|
end
|
|
83
101
|
|
|
84
102
|
def change_password(password)
|
|
103
|
+
# Generate new KDF params so a password change also re-salts the vault
|
|
104
|
+
@kdf_params = nil
|
|
85
105
|
@cipher = cipher(password)
|
|
86
106
|
save
|
|
87
107
|
end
|
|
88
108
|
|
|
89
109
|
private
|
|
90
110
|
|
|
91
|
-
#
|
|
111
|
+
# Minimal cipher wrapper using OpenSSL, compatible with the SymmetricEncryption
|
|
112
|
+
# binary format: a 6-byte header "@EnC\x00\x00" followed by AES ciphertext
|
|
113
|
+
# produced with a zero IV (encoding: :none, no random IV in header).
|
|
114
|
+
class OsslCipher
|
|
115
|
+
HEADER = "@EnC\x00\x00".b.freeze
|
|
116
|
+
HEADER_SIZE = HEADER.bytesize
|
|
117
|
+
|
|
118
|
+
def initialize(cipher_name, key)
|
|
119
|
+
@cipher_name = cipher_name
|
|
120
|
+
@key = key
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def encrypt(plaintext)
|
|
124
|
+
c = OpenSSL::Cipher.new(@cipher_name)
|
|
125
|
+
c.encrypt
|
|
126
|
+
c.key = @key
|
|
127
|
+
c.iv = "\x00".b * c.iv_len
|
|
128
|
+
HEADER + c.update(plaintext.b) + c.final
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def decrypt(ciphertext)
|
|
132
|
+
body = ciphertext.b[HEADER_SIZE..]
|
|
133
|
+
c = OpenSSL::Cipher.new(@cipher_name)
|
|
134
|
+
c.decrypt
|
|
135
|
+
c.key = @key
|
|
136
|
+
c.iv = "\x00".b * c.iv_len
|
|
137
|
+
(c.update(body) + c.final).force_encoding(Encoding::UTF_8)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Derive an AES key from +new_password+.
|
|
142
|
+
# @legacy_key is true for vaults created by v4.26.0 or earlier (no kdf field):
|
|
143
|
+
# key = password truncated/zero-padded to key_bytes (same as symmetric-encryption did).
|
|
144
|
+
# Otherwise PBKDF2 is used. When @kdf_params is nil (new vault or password change)
|
|
145
|
+
# a fresh salt is generated; when already set (existing vault) it is reused.
|
|
92
146
|
def cipher(new_password)
|
|
93
|
-
# number of bits in second position
|
|
94
147
|
key_bytes = @cipher_name.split('-')[1].to_i / Environment::BITS_PER_BYTE
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
148
|
+
key =
|
|
149
|
+
if @legacy_key
|
|
150
|
+
# Replicate the v4.26.0 key derivation: password + NUL padding, truncated to key_bytes
|
|
151
|
+
(new_password + ("\x00" * key_bytes))[0, key_bytes]
|
|
152
|
+
else
|
|
153
|
+
if @kdf_params.nil?
|
|
154
|
+
# New vault or password change: generate a fresh salt
|
|
155
|
+
salt = OpenSSL::Random.random_bytes(KDF_SALT_BYTES)
|
|
156
|
+
@kdf_params = {
|
|
157
|
+
'algo' => 'PBKDF2',
|
|
158
|
+
'digest' => KDF_DIGEST,
|
|
159
|
+
'iterations' => KDF_ITERATIONS,
|
|
160
|
+
'salt' => [salt].pack('m0') # base64, no newlines
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
salt = @kdf_params['salt'].unpack1('m0')
|
|
164
|
+
OpenSSL::KDF.pbkdf2_hmac(
|
|
165
|
+
new_password,
|
|
166
|
+
salt: salt,
|
|
167
|
+
iterations: @kdf_params['iterations'],
|
|
168
|
+
length: key_bytes,
|
|
169
|
+
hash: @kdf_params['digest']
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
OsslCipher.new(@cipher_name, key)
|
|
99
173
|
end
|
|
100
174
|
|
|
101
175
|
# save current data to file with format
|
|
102
176
|
def save
|
|
103
177
|
vault_info = {
|
|
104
|
-
'version' => '1.
|
|
178
|
+
'version' => '1.1.0',
|
|
105
179
|
'type' => FILE_TYPE,
|
|
106
180
|
'cipher' => @cipher_name,
|
|
181
|
+
'kdf' => @kdf_params,
|
|
107
182
|
'data' => @cipher.encrypt(YAML.dump(@all_secrets))
|
|
108
183
|
}
|
|
109
184
|
File.write(@path, YAML.dump(vault_info))
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Aspera
|
|
4
4
|
module Keychain
|
|
5
|
-
#
|
|
5
|
+
# Factory to create vault/keychain instances
|
|
6
6
|
class Factory
|
|
7
|
-
LIST = %i[file system vault].freeze
|
|
7
|
+
LIST = %i[file system vault 1password].freeze
|
|
8
8
|
class << self
|
|
9
9
|
# Create a vault instance
|
|
10
10
|
# @param info [Hash] vault options
|
|
@@ -12,13 +12,13 @@ module Aspera
|
|
|
12
12
|
# @param folder [String] folder to store the vault (if needed)
|
|
13
13
|
# @param password [String] password to open the vault
|
|
14
14
|
def create(info, name, folder, password)
|
|
15
|
-
Aspera.assert_hash_all(info, Symbol, String){'vault info shall have only string values'}
|
|
15
|
+
Aspera.assert_hash_all(info, Symbol, String) { 'vault info shall have only string values' }
|
|
16
16
|
info = info.symbolize_keys
|
|
17
17
|
vault_type = info.delete(:type)
|
|
18
|
-
Aspera.assert_values(vault_type, LIST.map(&:to_s)){'vault.type'}
|
|
18
|
+
Aspera.assert_values(vault_type, LIST.map(&:to_s)) { 'vault.type' }
|
|
19
19
|
case vault_type
|
|
20
20
|
when 'file'
|
|
21
|
-
info[:file] = name || 'vault.bin'
|
|
21
|
+
info[:file] = info.delete(:name) || 'vault.bin' if info.key?(:name)
|
|
22
22
|
info[:file] = File.join(folder, info[:file]) unless File.absolute_path?(info[:file])
|
|
23
23
|
Aspera.assert(!password.nil?, 'please provide password')
|
|
24
24
|
info[:password] = password
|
|
@@ -37,6 +37,18 @@ module Aspera
|
|
|
37
37
|
require 'aspera/keychain/hashicorp_vault'
|
|
38
38
|
info[:token] ||= password
|
|
39
39
|
Keychain::HashicorpVault.new(**info)
|
|
40
|
+
when '1password'
|
|
41
|
+
source = info.delete(:source) || 'api'
|
|
42
|
+
Aspera.assert_values(source, %w[api cli]) { 'vault.source' }
|
|
43
|
+
case source
|
|
44
|
+
when 'api'
|
|
45
|
+
require 'aspera/keychain/one_password_api'
|
|
46
|
+
info[:token] ||= password
|
|
47
|
+
Keychain::OnePasswordApi.new(**info)
|
|
48
|
+
when 'cli'
|
|
49
|
+
require 'aspera/keychain/one_password_cli'
|
|
50
|
+
Keychain::OnePasswordCli.new(**info)
|
|
51
|
+
end
|
|
40
52
|
else Aspera.error_unexpected_value(vault_type)
|
|
41
53
|
end
|
|
42
54
|
end
|
|
@@ -44,14 +44,14 @@ module Aspera
|
|
|
44
44
|
uri = URI.parse(url)
|
|
45
45
|
Aspera.assert(uri.scheme.eql?('https'), 'only https')
|
|
46
46
|
options[:protocol] = 'htps' # cspell: disable-line
|
|
47
|
-
Aspera.assert(!uri.host.nil?, type: Error){'host required in URL'}
|
|
47
|
+
Aspera.assert(!uri.host.nil?, type: Error) { 'host required in URL' }
|
|
48
48
|
options[:server] = uri.host
|
|
49
49
|
options[:path] = uri.path unless ['', '/'].include?(uri.path)
|
|
50
50
|
options[:port] = uri.port unless uri.port.eql?(443) && !url.include?(':443/')
|
|
51
51
|
end
|
|
52
52
|
command_args = [SECURITY_UTILITY, command]
|
|
53
53
|
options&.each do |k, v|
|
|
54
|
-
Aspera.assert(supported.key?(k)){"unknown option: #{k}"}
|
|
54
|
+
Aspera.assert(supported.key?(k)) { "unknown option: #{k}" }
|
|
55
55
|
next if v.nil?
|
|
56
56
|
command_args.push("-#{supported[k]}")
|
|
57
57
|
command_args.push(v.shellescape) unless v.empty?
|
|
@@ -61,7 +61,7 @@ module Aspera
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
def key_chains(output)
|
|
64
|
-
output.split("\n").collect{ |line| new(line.strip.gsub(/^"|"$/, ''))}
|
|
64
|
+
output.split("\n").collect { |line| new(line.strip.gsub(/^"|"$/, '')) }
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def default
|
|
@@ -73,12 +73,12 @@ module Aspera
|
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
def list(options = {})
|
|
76
|
-
Aspera.assert_values(options[:domain], DOMAINS, type: ParameterError){'domain'} unless options[:domain].nil?
|
|
76
|
+
Aspera.assert_values(options[:domain], DOMAINS, type: ParameterError) { 'domain' } unless options[:domain].nil?
|
|
77
77
|
key_chains(execute('list-keychains', options, LIST_OPTIONS))
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
def by_name(name)
|
|
81
|
-
list.find{ |kc| kc.path.end_with?("/#{name}.keychain-db")}
|
|
81
|
+
list.find { |kc| kc.path.end_with?("/#{name}.keychain-db") }
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
attr_reader :path
|
|
@@ -92,11 +92,11 @@ module Aspera
|
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
def password(operation, pass_type, options)
|
|
95
|
-
Aspera.assert_values(operation, %i[add find delete]){'operation'}
|
|
96
|
-
Aspera.assert_values(pass_type, %i[generic internet]){'pass_type'}
|
|
95
|
+
Aspera.assert_values(operation, %i[add find delete]) { 'operation' }
|
|
96
|
+
Aspera.assert_values(pass_type, %i[generic internet]) { 'pass_type' }
|
|
97
97
|
Aspera.assert_type(options, Hash)
|
|
98
98
|
missing = (operation.eql?(:add) ? %i[account service password] : %i[label]) - options.keys
|
|
99
|
-
Aspera.assert(missing.empty?){"missing options: #{missing}"}
|
|
99
|
+
Aspera.assert(missing.empty?) { "missing options: #{missing}" }
|
|
100
100
|
options[:getpass] = '' if operation.eql?(:find)
|
|
101
101
|
output = self.class.execute("#{operation}-#{pass_type}-password", options, ADD_PASS_OPTIONS, @path)
|
|
102
102
|
raise output.gsub(/^.*: /, '') if output.start_with?('security: ')
|
|
@@ -128,7 +128,7 @@ module Aspera
|
|
|
128
128
|
super()
|
|
129
129
|
@keychain_name = name.nil? ? 'default keychain' : name
|
|
130
130
|
@keychain = name.nil? ? MacosSecurity::Keychain.default : MacosSecurity::Keychain.by_name(name)
|
|
131
|
-
Aspera.assert(!@keychain.nil?){"no such keychain #{name}"}
|
|
131
|
+
Aspera.assert(!@keychain.nil?) { "no such keychain #{name}" }
|
|
132
132
|
end
|
|
133
133
|
|
|
134
134
|
def info
|
|
@@ -137,9 +137,9 @@ module Aspera
|
|
|
137
137
|
}
|
|
138
138
|
end
|
|
139
139
|
|
|
140
|
-
def
|
|
140
|
+
def all
|
|
141
141
|
# the only way to list is `dump-keychain` which triggers security alert
|
|
142
|
-
raise Error, '
|
|
142
|
+
raise Error, 'overview not implemented, use macos keychain app'
|
|
143
143
|
end
|
|
144
144
|
|
|
145
145
|
def set(options)
|
|
@@ -150,22 +150,20 @@ module Aspera
|
|
|
150
150
|
)
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
-
def get(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
153
|
+
def get(label:, exception: true)
|
|
154
|
+
info = @keychain.password(:find, :generic, label: label)
|
|
155
|
+
if info.nil?
|
|
156
|
+
raise "Secret '#{label}' not found" if exception
|
|
157
|
+
return
|
|
158
|
+
end
|
|
159
|
+
return {
|
|
160
|
+
label: label,
|
|
161
|
+
password: info['password'],
|
|
162
|
+
description: info['icmt'] # cspell: disable-line
|
|
163
|
+
}
|
|
163
164
|
end
|
|
164
165
|
|
|
165
|
-
def delete(
|
|
166
|
-
Aspera.assert_type(options, Hash){'options'}
|
|
167
|
-
unsupported = options.keys - %i[label]
|
|
168
|
-
Aspera.assert(unsupported.empty?){"unsupported options: #{unsupported}"}
|
|
166
|
+
def delete(label:)
|
|
169
167
|
raise Error, 'delete not implemented, use macos keychain app'
|
|
170
168
|
end
|
|
171
169
|
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/rest'
|
|
4
|
+
require 'aspera/keychain/one_password_base'
|
|
5
|
+
|
|
6
|
+
module Aspera
|
|
7
|
+
module Keychain
|
|
8
|
+
# Manage secrets using the 1Password Connect REST API
|
|
9
|
+
# https://developer.1password.com/docs/connect/connect-api-reference/
|
|
10
|
+
class OnePasswordApi < Base
|
|
11
|
+
include OnePasswordBase
|
|
12
|
+
|
|
13
|
+
# @param url [String] Base URL of the 1Password Connect server (e.g. http://localhost:8080)
|
|
14
|
+
# @param token [String] Bearer token (Connect token or service-account token)
|
|
15
|
+
# @param vault_id [String] ID of the 1Password vault to operate on
|
|
16
|
+
def initialize(url:, token:, vault_id:)
|
|
17
|
+
super()
|
|
18
|
+
@vault_id = vault_id
|
|
19
|
+
@api = Rest.new(
|
|
20
|
+
base_url: url,
|
|
21
|
+
headers: {'Authorization' => "Bearer #{token}"}
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def info
|
|
26
|
+
{
|
|
27
|
+
url: @api.base_url,
|
|
28
|
+
vault_id: @vault_id
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def all
|
|
33
|
+
items = @api.read("v1/vaults/#{@vault_id}/items")
|
|
34
|
+
return items.map do |item|
|
|
35
|
+
item_to_secret(@api.read("v1/vaults/#{@vault_id}/items/#{item['id']}")).merge(id: item['id'])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ids
|
|
40
|
+
@api.read("v1/vaults/#{@vault_id}/items").map { |item| {id: item['id'], label: item['title']} }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def set(options)
|
|
44
|
+
validate_set(options)
|
|
45
|
+
@api.create("v1/vaults/#{@vault_id}/items", build_item(options))
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def get(label:, id: nil, exception: true)
|
|
50
|
+
iid = id || resolve_id(label, exception: exception)
|
|
51
|
+
return if iid.nil?
|
|
52
|
+
return item_to_secret(@api.read("v1/vaults/#{@vault_id}/items/#{iid}")).merge(id: iid)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def delete(label:, id: nil)
|
|
56
|
+
iid = id || resolve_id(label, exception: true)
|
|
57
|
+
@api.delete("v1/vaults/#{@vault_id}/items/#{iid}")
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Resolve an item id from its title; raises if ambiguous or missing
|
|
64
|
+
def resolve_id(label, exception: true)
|
|
65
|
+
items = @api.read("v1/vaults/#{@vault_id}/items", {filter: "title eq \"#{label}\""})
|
|
66
|
+
raise "Multiple secrets found with label '#{label}': use id to disambiguate" if items.length > 1
|
|
67
|
+
raise "Secret '#{label}' not found" if items.empty? && exception
|
|
68
|
+
items.first&.fetch('id')
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_item(options)
|
|
72
|
+
fields = []
|
|
73
|
+
fields << {'id' => FIELD_USERNAME, 'type' => 'STRING', 'value' => options[:username]} if options[:username]
|
|
74
|
+
fields << {'id' => FIELD_PASSWORD, 'type' => 'CONCEALED', 'value' => options[:password]} if options[:password]
|
|
75
|
+
fields << {'id' => FIELD_URL, 'type' => 'URL', 'value' => options[:url]} if options[:url]
|
|
76
|
+
fields << {'id' => FIELD_NOTES_ID, 'type' => 'STRING', 'value' => options[:description]} if options[:description]
|
|
77
|
+
{
|
|
78
|
+
'title' => options[:label],
|
|
79
|
+
'category' => ITEM_CATEGORY.upcase,
|
|
80
|
+
'vault' => {'id' => @vault_id},
|
|
81
|
+
'fields' => fields
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/keychain/base'
|
|
4
|
+
|
|
5
|
+
module Aspera
|
|
6
|
+
module Keychain
|
|
7
|
+
# Shared field constants and item_to_secret conversion for 1Password backends.
|
|
8
|
+
# Both the REST (Connect) and CLI (`op`) backends use the same JSON item schema.
|
|
9
|
+
module OnePasswordBase
|
|
10
|
+
FIELD_USERNAME = 'username'
|
|
11
|
+
FIELD_PASSWORD = 'password'
|
|
12
|
+
FIELD_URL = 'url'
|
|
13
|
+
FIELD_NOTES_ID = 'notesPlain'
|
|
14
|
+
ITEM_CATEGORY = 'Login'
|
|
15
|
+
|
|
16
|
+
private_constant :FIELD_USERNAME, :FIELD_PASSWORD, :FIELD_URL, :FIELD_NOTES_ID, :ITEM_CATEGORY
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
# Convert a 1Password item JSON object to a keychain secret Hash.
|
|
21
|
+
# Accepts both API Connect items (field keyed by 'id') and CLI items
|
|
22
|
+
# (field keyed by 'id' falling back to 'label').
|
|
23
|
+
def item_to_secret(item)
|
|
24
|
+
fields = Array(item['fields']).to_h { |f| [f['id'] || f['label'], f['value']] }
|
|
25
|
+
secret = {label: item['title']}
|
|
26
|
+
secret[:username] = fields[FIELD_USERNAME] unless fields[FIELD_USERNAME].nil?
|
|
27
|
+
secret[:password] = fields[FIELD_PASSWORD] unless fields[FIELD_PASSWORD].nil?
|
|
28
|
+
secret[:url] = fields[FIELD_URL] unless fields[FIELD_URL].nil?
|
|
29
|
+
secret[:description] = fields[FIELD_NOTES_ID] unless fields[FIELD_NOTES_ID].nil?
|
|
30
|
+
return secret
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/environment'
|
|
4
|
+
require 'aspera/keychain/one_password_base'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module Aspera
|
|
8
|
+
module Keychain
|
|
9
|
+
# Manage secrets using the 1Password CLI (`op`)
|
|
10
|
+
# https://developer.1password.com/docs/cli/
|
|
11
|
+
class OnePasswordCli < Base
|
|
12
|
+
include OnePasswordBase
|
|
13
|
+
|
|
14
|
+
OP_EXECUTABLE = 'op'
|
|
15
|
+
private_constant :OP_EXECUTABLE
|
|
16
|
+
|
|
17
|
+
# @param vault [String, nil] Name or ID of the 1Password vault (optional, uses default vault if omitted)
|
|
18
|
+
# @param account [String, nil] Account shorthand for `op` (optional, uses default account if omitted)
|
|
19
|
+
def initialize(vault: nil, account: nil)
|
|
20
|
+
super()
|
|
21
|
+
@vault = vault
|
|
22
|
+
@account = account
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def info
|
|
26
|
+
h = {}
|
|
27
|
+
h[:vault] = @vault unless @vault.nil?
|
|
28
|
+
h[:account] = @account unless @account.nil?
|
|
29
|
+
h
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def all
|
|
33
|
+
items = op_json('item', 'list', '--categories', ITEM_CATEGORY)
|
|
34
|
+
return items.map do |item|
|
|
35
|
+
item_to_secret(op_json('item', 'get', item['id'])).merge(id: item['id'])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ids
|
|
40
|
+
op_json('item', 'list', '--categories', ITEM_CATEGORY).map { |item| {id: item['id'], label: item['title']} }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def set(options)
|
|
44
|
+
validate_set(options)
|
|
45
|
+
args = ['item', 'create', '--category', ITEM_CATEGORY, "--title=#{options[:label]}"]
|
|
46
|
+
args << "#{FIELD_USERNAME}=#{options[:username]}" if options[:username]
|
|
47
|
+
args << "#{FIELD_PASSWORD}[password]=#{options[:password]}" if options[:password]
|
|
48
|
+
args << "#{FIELD_URL}[url]=#{options[:url]}" if options[:url]
|
|
49
|
+
args << "#{FIELD_NOTES_ID}=#{options[:description]}" if options[:description]
|
|
50
|
+
op_run(*args)
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def get(label:, id: nil, exception: true)
|
|
55
|
+
identifier = id || label
|
|
56
|
+
stdout, _stderr, status = op_capture('item', 'get', identifier, '--format=json')
|
|
57
|
+
if !status.success?
|
|
58
|
+
raise "Secret '#{label}' not found" if exception
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
full = JSON.parse(stdout)
|
|
62
|
+
return item_to_secret(full).merge(id: full['id'])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def delete(label:, id: nil)
|
|
66
|
+
op_run('item', 'delete', id || label)
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def common_flags
|
|
73
|
+
flags = []
|
|
74
|
+
flags.push('--vault', @vault) unless @vault.nil?
|
|
75
|
+
flags.push('--account', @account) unless @account.nil?
|
|
76
|
+
flags
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def op_json(*args)
|
|
80
|
+
stdout, = op_capture(*args, '--format=json', exception: true)
|
|
81
|
+
JSON.parse(stdout)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def op_run(*args)
|
|
85
|
+
op_capture(*args, exception: true)
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def op_capture(*args, exception: true)
|
|
90
|
+
Environment.instance.class.secure_execute(
|
|
91
|
+
OP_EXECUTABLE, *args, *common_flags,
|
|
92
|
+
mode: :capture,
|
|
93
|
+
exception: exception
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/aspera/link_header.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Aspera
|
|
|
20
20
|
|
|
21
21
|
# Retrieve an attribute value by name, case-insensitively.
|
|
22
22
|
def [](key)
|
|
23
|
-
pair = @attr_pairs.detect{ |k, _v| k.casecmp?(key)}
|
|
23
|
+
pair = @attr_pairs.detect { |k, _v| k.casecmp?(key) }
|
|
24
24
|
pair&.last
|
|
25
25
|
end
|
|
26
26
|
end
|
|
@@ -37,7 +37,7 @@ module Aspera
|
|
|
37
37
|
# @param rel [String]
|
|
38
38
|
# @return [String, nil]
|
|
39
39
|
def find_href(rel: 'next')
|
|
40
|
-
@links.detect{ |link| link['rel']&.casecmp?(rel)}&.href
|
|
40
|
+
@links.detect { |link| link['rel']&.casecmp?(rel) }&.href
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
class << self
|