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.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +46 -1
  4. data/CONTRIBUTING.md +1 -7
  5. data/TODO.md +50 -0
  6. data/docs/README.md +709 -489
  7. data/lib/aspera/agent/base.rb +10 -2
  8. data/lib/aspera/agent/connect.rb +7 -7
  9. data/lib/aspera/agent/desktop.rb +3 -3
  10. data/lib/aspera/agent/direct.rb +48 -19
  11. data/lib/aspera/agent/factory.rb +3 -3
  12. data/lib/aspera/agent/httpgw.rb +55 -14
  13. data/lib/aspera/agent/node.rb +2 -2
  14. data/lib/aspera/agent/transferd.rb +12 -12
  15. data/lib/aspera/api/aoc.rb +28 -28
  16. data/lib/aspera/api/cos_node.rb +10 -9
  17. data/lib/aspera/api/faspex.rb +2 -2
  18. data/lib/aspera/api/httpgw.rb +20 -20
  19. data/lib/aspera/api/node.rb +31 -53
  20. data/lib/aspera/ascmd.rb +13 -13
  21. data/lib/aspera/ascp/installation.rb +43 -36
  22. data/lib/aspera/ascp/management.rb +6 -6
  23. data/lib/aspera/assert.rb +12 -13
  24. data/lib/aspera/cli/ascp_actions.rb +7 -7
  25. data/lib/aspera/cli/async_transfer_store.rb +49 -5
  26. data/lib/aspera/cli/bootstrapper.rb +9 -9
  27. data/lib/aspera/cli/command_registry.rb +1 -1
  28. data/lib/aspera/cli/command_spec.rb +6 -3
  29. data/lib/aspera/cli/context.rb +3 -3
  30. data/lib/aspera/cli/extended_value.rb +26 -26
  31. data/lib/aspera/cli/formatter.rb +76 -54
  32. data/lib/aspera/cli/gem_checker.rb +1 -1
  33. data/lib/aspera/cli/hints.rb +2 -2
  34. data/lib/aspera/cli/http.rb +22 -29
  35. data/lib/aspera/cli/mailer.rb +7 -7
  36. data/lib/aspera/cli/mcp_tool.rb +31 -4
  37. data/lib/aspera/cli/option_declarator.rb +77 -0
  38. data/lib/aspera/cli/options.schema.yaml +273 -3
  39. data/lib/aspera/cli/parser.rb +539 -280
  40. data/lib/aspera/cli/plugins/alee.rb +1 -1
  41. data/lib/aspera/cli/plugins/aoc.rb +303 -314
  42. data/lib/aspera/cli/plugins/ats.rb +25 -21
  43. data/lib/aspera/cli/plugins/base.rb +357 -186
  44. data/lib/aspera/cli/plugins/basic_auth.rb +0 -12
  45. data/lib/aspera/cli/plugins/config.rb +76 -73
  46. data/lib/aspera/cli/plugins/console.rb +8 -8
  47. data/lib/aspera/cli/plugins/cos.rb +4 -3
  48. data/lib/aspera/cli/plugins/factory.rb +2 -2
  49. data/lib/aspera/cli/plugins/faspex5.rb +259 -248
  50. data/lib/aspera/cli/plugins/faspio.rb +7 -2
  51. data/lib/aspera/cli/plugins/httpgw.rb +1 -1
  52. data/lib/aspera/cli/plugins/mcp.rb +11 -11
  53. data/lib/aspera/cli/plugins/node.rb +227 -283
  54. data/lib/aspera/cli/plugins/orchestrator.rb +23 -23
  55. data/lib/aspera/cli/plugins/preview.rb +61 -64
  56. data/lib/aspera/cli/plugins/server.rb +36 -31
  57. data/lib/aspera/cli/plugins/shares.rb +174 -135
  58. data/lib/aspera/cli/preset_actions.rb +39 -19
  59. data/lib/aspera/cli/preset_manager.rb +74 -32
  60. data/lib/aspera/cli/result.rb +18 -16
  61. data/lib/aspera/cli/runner.rb +75 -39
  62. data/lib/aspera/cli/secret_finder.rb +1 -1
  63. data/lib/aspera/cli/sync_actions.rb +36 -35
  64. data/lib/aspera/cli/terminal_formatter.rb +2 -2
  65. data/lib/aspera/cli/transfer_actions.rb +17 -10
  66. data/lib/aspera/cli/transfer_agent.rb +44 -44
  67. data/lib/aspera/cli/transfer_progress.rb +7 -7
  68. data/lib/aspera/cli/vault_manager.rb +34 -12
  69. data/lib/aspera/cli/version.rb +1 -1
  70. data/lib/aspera/cli/wizard.rb +17 -20
  71. data/lib/aspera/colors.rb +3 -3
  72. data/lib/aspera/command_line_builder.rb +17 -16
  73. data/lib/aspera/command_line_converter.rb +1 -1
  74. data/lib/aspera/coverage.rb +1 -2
  75. data/lib/aspera/data_repository.rb +1 -1
  76. data/lib/aspera/dot_container.rb +8 -8
  77. data/lib/aspera/environment.rb +13 -13
  78. data/lib/aspera/faspex_gw.rb +4 -4
  79. data/lib/aspera/faspex_postproc.rb +14 -9
  80. data/lib/aspera/graphql.rb +4 -2
  81. data/lib/aspera/hash_ext.rb +2 -2
  82. data/lib/aspera/json_rpc/client.rb +1 -1
  83. data/lib/aspera/keychain/base.rb +10 -3
  84. data/lib/aspera/keychain/encrypted_hash.rb +92 -17
  85. data/lib/aspera/keychain/factory.rb +17 -5
  86. data/lib/aspera/keychain/hashicorp_vault.rb +1 -1
  87. data/lib/aspera/keychain/macos_security.rb +23 -25
  88. data/lib/aspera/keychain/one_password_api.rb +86 -0
  89. data/lib/aspera/keychain/one_password_base.rb +34 -0
  90. data/lib/aspera/keychain/one_password_cli.rb +98 -0
  91. data/lib/aspera/link_header.rb +2 -2
  92. data/lib/aspera/log.rb +13 -13
  93. data/lib/aspera/markdown.rb +5 -5
  94. data/lib/aspera/nagios.rb +8 -8
  95. data/lib/aspera/node_simulator.rb +33 -27
  96. data/lib/aspera/oauth/base.rb +4 -5
  97. data/lib/aspera/oauth/boot.rb +3 -3
  98. data/lib/aspera/oauth/factory.rb +5 -5
  99. data/lib/aspera/oauth/jwt.rb +5 -7
  100. data/lib/aspera/oauth/web.rb +4 -4
  101. data/lib/aspera/persistency_action_once.rb +4 -4
  102. data/lib/aspera/persistency_folder.rb +8 -8
  103. data/lib/aspera/preview/file_types.rb +4 -4
  104. data/lib/aspera/preview/generator.rb +49 -46
  105. data/lib/aspera/preview/options.rb +3 -2
  106. data/lib/aspera/preview/terminal.rb +5 -5
  107. data/lib/aspera/preview/utils.rb +65 -18
  108. data/lib/aspera/products/connect.rb +2 -2
  109. data/lib/aspera/products/desktop.rb +1 -1
  110. data/lib/aspera/products/other.rb +2 -2
  111. data/lib/aspera/products/transferd.rb +3 -3
  112. data/lib/aspera/proxy_auto_config.rb +10 -10
  113. data/lib/aspera/rest.rb +149 -115
  114. data/lib/aspera/rest_error_analyzer.rb +3 -3
  115. data/lib/aspera/rest_list.rb +10 -9
  116. data/lib/aspera/schema/IBM Aspera Node API-4.4.6.yaml +6232 -0
  117. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +249 -0
  118. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +39 -0
  119. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +4653 -0
  120. data/lib/aspera/schema/documentation.rb +17 -11
  121. data/lib/aspera/schema/reader.rb +78 -4
  122. data/lib/aspera/schema/registry.rb +26 -7
  123. data/lib/aspera/secret_hider.rb +5 -5
  124. data/lib/aspera/ssh.rb +64 -31
  125. data/lib/aspera/ssl.rb +3 -3
  126. data/lib/aspera/sync/database.rb +2 -2
  127. data/lib/aspera/sync/operations.rb +13 -13
  128. data/lib/aspera/temp_file_manager.rb +24 -4
  129. data/lib/aspera/transfer/faux_file.rb +2 -2
  130. data/lib/aspera/transfer/parameters.rb +15 -15
  131. data/lib/aspera/transfer/resumer.rb +9 -9
  132. data/lib/aspera/transfer/spec.rb +1 -1
  133. data/lib/aspera/transfer/spec.schema.yaml +1 -0
  134. data/lib/aspera/uri_reader.rb +52 -15
  135. data/lib/aspera/web_auth.rb +6 -5
  136. data/lib/aspera/web_server_simple.rb +9 -9
  137. data/lib/aspera/yaml.rb +4 -4
  138. data.tar.gz.sig +0 -0
  139. metadata +10 -4
  140. metadata.gz.sig +0 -0
  141. data/lib/aspera/cli/plugins/faspex.rb +0 -576
  142. 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 'symmetric_encryption/core'
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
- private_constant :LEGACY_CIPHER_NAME, :DEFAULT_CIPHER_NAME, :FILE_TYPE, :FILE_KEYS
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
- Aspera.assert(vault_info.keys.sort == FILE_KEYS, 'Invalid vault file')
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 = YAML.load_stream(@cipher.decrypt(vault_encrypted_data)).first if !vault_encrypted_data.nil?
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 list
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
- # set the password and cipher
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
- # derive key from passphrase, add trailing zeros
96
- key = "#{new_password}#{"\x0" * key_bytes}"[0..(key_bytes - 1)]
97
- Log.log.trace1{"secret=[#{key}],#{key.length}"}
98
- SymmetricEncryption.cipher = SymmetricEncryption::Cipher.new(cipher_name: @cipher_name, key: key, encoding: :none)
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.0.0',
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
- # Manage secrets in a Hashicorp Vault
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
@@ -29,7 +29,7 @@ module Aspera
29
29
  }
30
30
  end
31
31
 
32
- def list
32
+ def all
33
33
  metadata_path = STORE_PATH.sub('/data/', '/metadata/')
34
34
  return Vault.logical.list(metadata_path).filter_map do |label|
35
35
  get(label: label).merge(label: label)
@@ -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 list
140
+ def all
141
141
  # the only way to list is `dump-keychain` which triggers security alert
142
- raise Error, 'list not implemented, use macos keychain app'
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(options)
154
- Aspera.assert_type(options, Hash){'options'}
155
- unsupported = options.keys - %i[label]
156
- Aspera.assert(unsupported.empty?){"unsupported options: #{unsupported}"}
157
- info = @keychain.password(:find, :generic, label: options[:label])
158
- Aspera.assert(!info.nil?, type: Error){'not found'}
159
- result = options.clone
160
- result[:secret] = info['password']
161
- result[:description] = info['icmt'] # cspell: disable-line
162
- return result
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(options)
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
@@ -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