aspera-cli 4.26.2 → 4.27.1

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 (159) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +310 -21
  4. data/CONTRIBUTING.md +99 -14
  5. data/TODO.md +50 -0
  6. data/bin/ascli +3 -3
  7. data/docs/README.md +10977 -0
  8. data/docs/test-mcp-with-ai.md +202 -0
  9. data/lib/aspera/agent/base.rb +29 -7
  10. data/lib/aspera/agent/connect.rb +58 -21
  11. data/lib/aspera/agent/desktop.rb +53 -18
  12. data/lib/aspera/agent/direct.rb +71 -40
  13. data/lib/aspera/agent/factory.rb +3 -3
  14. data/lib/aspera/agent/httpgw.rb +55 -14
  15. data/lib/aspera/agent/node.rb +34 -2
  16. data/lib/aspera/agent/transferd.rb +48 -12
  17. data/lib/aspera/api/alee.rb +1 -1
  18. data/lib/aspera/api/aoc.rb +42 -44
  19. data/lib/aspera/api/cos_node.rb +10 -9
  20. data/lib/aspera/api/faspex.rb +16 -15
  21. data/lib/aspera/api/httpgw.rb +23 -23
  22. data/lib/aspera/api/node.rb +51 -70
  23. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  24. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  25. data/lib/aspera/ascmd.rb +32 -30
  26. data/lib/aspera/ascp/installation.rb +75 -69
  27. data/lib/aspera/ascp/management.rb +7 -8
  28. data/lib/aspera/assert.rb +12 -13
  29. data/lib/aspera/cli/ascp_actions.rb +155 -0
  30. data/lib/aspera/cli/async_transfer_store.rb +125 -0
  31. data/lib/aspera/cli/bootstrapper.rb +197 -0
  32. data/lib/aspera/cli/command_registry.rb +145 -0
  33. data/lib/aspera/cli/command_spec.rb +141 -0
  34. data/lib/aspera/cli/context.rb +19 -6
  35. data/lib/aspera/cli/error.rb +16 -0
  36. data/lib/aspera/cli/extended_value.rb +27 -26
  37. data/lib/aspera/cli/formatter.rb +95 -59
  38. data/lib/aspera/cli/gem_checker.rb +65 -0
  39. data/lib/aspera/cli/hints.rb +3 -3
  40. data/lib/aspera/cli/http.rb +74 -30
  41. data/lib/aspera/cli/info.rb +2 -0
  42. data/lib/aspera/cli/mailer.rb +97 -0
  43. data/lib/aspera/cli/mcp_tool.rb +225 -0
  44. data/lib/aspera/cli/option_declarator.rb +77 -0
  45. data/lib/aspera/cli/options.schema.yaml +634 -7
  46. data/lib/aspera/cli/parser.rb +1217 -0
  47. data/lib/aspera/cli/plugins/alee.rb +20 -22
  48. data/lib/aspera/cli/plugins/aoc.rb +1178 -881
  49. data/lib/aspera/cli/plugins/ats.rb +205 -162
  50. data/lib/aspera/cli/plugins/base.rb +703 -175
  51. data/lib/aspera/cli/plugins/basic_auth.rb +7 -9
  52. data/lib/aspera/cli/plugins/config.rb +434 -757
  53. data/lib/aspera/cli/plugins/console.rb +107 -65
  54. data/lib/aspera/cli/plugins/cos.rb +46 -33
  55. data/lib/aspera/cli/plugins/factory.rb +6 -6
  56. data/lib/aspera/cli/plugins/faspex5.rb +627 -388
  57. data/lib/aspera/cli/plugins/faspio.rb +54 -51
  58. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  59. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  60. data/lib/aspera/cli/plugins/node.rb +988 -840
  61. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  62. data/lib/aspera/cli/plugins/orchestrator.rb +112 -135
  63. data/lib/aspera/cli/plugins/preview.rb +195 -157
  64. data/lib/aspera/cli/plugins/server.rb +141 -91
  65. data/lib/aspera/cli/plugins/shares.rb +343 -110
  66. data/lib/aspera/cli/preset_actions.rb +159 -0
  67. data/lib/aspera/cli/preset_manager.rb +82 -39
  68. data/lib/aspera/cli/result.rb +78 -26
  69. data/lib/aspera/cli/runner.rb +323 -153
  70. data/lib/aspera/cli/secret_finder.rb +40 -0
  71. data/lib/aspera/cli/special_values.rb +1 -0
  72. data/lib/aspera/cli/sync_actions.rb +89 -64
  73. data/lib/aspera/cli/terminal_formatter.rb +3 -3
  74. data/lib/aspera/cli/transfer_actions.rb +90 -0
  75. data/lib/aspera/cli/transfer_agent.rb +129 -64
  76. data/lib/aspera/cli/transfer_progress.rb +9 -9
  77. data/lib/aspera/cli/vault_manager.rb +79 -0
  78. data/lib/aspera/cli/version.rb +1 -1
  79. data/lib/aspera/cli/wizard.rb +28 -30
  80. data/lib/aspera/colors.rb +3 -3
  81. data/lib/aspera/command_line_builder.rb +34 -34
  82. data/lib/aspera/command_line_converter.rb +1 -1
  83. data/lib/aspera/coverage.rb +1 -2
  84. data/lib/aspera/data_repository.rb +2 -1
  85. data/lib/aspera/dot_container.rb +12 -12
  86. data/lib/aspera/environment.rb +30 -23
  87. data/lib/aspera/exec_spec.rb +13 -0
  88. data/lib/aspera/faspex_gw.rb +5 -5
  89. data/lib/aspera/faspex_postproc.rb +16 -10
  90. data/lib/aspera/graphql.rb +37 -0
  91. data/lib/aspera/hash_ext.rb +8 -2
  92. data/lib/aspera/json_rpc/client.rb +62 -0
  93. data/lib/aspera/json_rpc/version.rb +7 -0
  94. data/lib/aspera/keychain/base.rb +10 -3
  95. data/lib/aspera/keychain/encrypted_hash.rb +92 -17
  96. data/lib/aspera/keychain/factory.rb +20 -8
  97. data/lib/aspera/keychain/hashicorp_vault.rb +1 -1
  98. data/lib/aspera/keychain/macos_security.rb +23 -25
  99. data/lib/aspera/keychain/one_password_api.rb +86 -0
  100. data/lib/aspera/keychain/one_password_base.rb +34 -0
  101. data/lib/aspera/keychain/one_password_cli.rb +98 -0
  102. data/lib/aspera/link_header.rb +82 -0
  103. data/lib/aspera/log.rb +29 -15
  104. data/lib/aspera/markdown.rb +90 -7
  105. data/lib/aspera/nagios.rb +8 -8
  106. data/lib/aspera/node_simulator.rb +35 -27
  107. data/lib/aspera/oauth/base.rb +9 -10
  108. data/lib/aspera/oauth/boot.rb +5 -5
  109. data/lib/aspera/oauth/factory.rb +12 -10
  110. data/lib/aspera/oauth/jwt.rb +9 -11
  111. data/lib/aspera/oauth/web.rb +6 -6
  112. data/lib/aspera/persistency_action_once.rb +12 -12
  113. data/lib/aspera/persistency_folder.rb +18 -11
  114. data/lib/aspera/preview/file_types.rb +5 -5
  115. data/lib/aspera/preview/generator.rb +52 -49
  116. data/lib/aspera/preview/options.rb +3 -2
  117. data/lib/aspera/preview/terminal.rb +5 -5
  118. data/lib/aspera/preview/utils.rb +66 -19
  119. data/lib/aspera/products/connect.rb +2 -2
  120. data/lib/aspera/products/desktop.rb +1 -1
  121. data/lib/aspera/products/other.rb +3 -3
  122. data/lib/aspera/products/transferd.rb +4 -4
  123. data/lib/aspera/proxy_auto_config.rb +15 -14
  124. data/lib/aspera/rest.rb +159 -151
  125. data/lib/aspera/rest_error_analyzer.rb +6 -6
  126. data/lib/aspera/rest_errors_aspera.rb +0 -10
  127. data/lib/aspera/rest_list.rb +11 -10
  128. data/lib/aspera/schema/IBM Aspera Node API-4.4.6.yaml +6232 -0
  129. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +249 -0
  130. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
  131. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +4653 -0
  132. data/lib/aspera/schema/documentation.rb +65 -28
  133. data/lib/aspera/schema/reader.rb +136 -13
  134. data/lib/aspera/schema/registry.rb +60 -8
  135. data/lib/aspera/secret_hider.rb +12 -7
  136. data/lib/aspera/ssh.rb +64 -31
  137. data/lib/aspera/ssl.rb +5 -5
  138. data/lib/aspera/sync/conf.schema.yaml +2 -2
  139. data/lib/aspera/sync/database.rb +2 -2
  140. data/lib/aspera/sync/operations.rb +22 -25
  141. data/lib/aspera/temp_file_manager.rb +25 -5
  142. data/lib/aspera/timer_limiter.rb +1 -1
  143. data/lib/aspera/transfer/faux_file.rb +24 -11
  144. data/lib/aspera/transfer/parameters.rb +36 -34
  145. data/lib/aspera/transfer/result.rb +74 -0
  146. data/lib/aspera/transfer/resumer.rb +10 -10
  147. data/lib/aspera/transfer/spec.rb +18 -1
  148. data/lib/aspera/transfer/spec.schema.yaml +12 -3
  149. data/lib/aspera/uri_reader.rb +52 -15
  150. data/lib/aspera/web_auth.rb +8 -7
  151. data/lib/aspera/web_server_simple.rb +15 -12
  152. data/lib/aspera/yaml.rb +5 -4
  153. data.tar.gz.sig +0 -0
  154. metadata +34 -6
  155. metadata.gz.sig +0 -0
  156. data/lib/aspera/cli/manager.rb +0 -766
  157. data/lib/aspera/cli/plugins/faspex.rb +0 -539
  158. data/lib/aspera/json_rpc.rb +0 -52
  159. data/lib/aspera/transfer/uri.rb +0 -56
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # cspell:ignore blankslate jsonrpc
4
+
5
+ require 'aspera/rest_error_analyzer'
6
+ require 'aspera/assert'
7
+ require 'aspera/json_rpc/version'
8
+ require 'blankslate'
9
+
10
+ Aspera::RestErrorAnalyzer.instance.add_simple_handler(name: 'JSON RPC', path: %w[error message], always: true)
11
+
12
+ module Aspera
13
+ module JsonRpc
14
+ # JSON-RPC 2.0 client over an Aspera::Rest HTTP endpoint.
15
+ # Methods are dispatched dynamically via method_missing.
16
+ # Example:
17
+ # client = JsonRpc::Client.new(Rest.new(base_url: 'http://127.0.0.1:33024'))
18
+ # client.get_info
19
+ # client.start_transfer(app_id: '...', transfer_spec: {...})
20
+ class Client < BlankSlate
21
+ reveal :instance_variable_get
22
+ reveal :inspect
23
+ reveal :to_s
24
+
25
+ # @param api [Rest] Aspera REST object pointing at the JSON-RPC endpoint
26
+ # @param namespace [String, nil] optional method prefix, e.g. "myns."
27
+ def initialize(api, namespace = nil)
28
+ super()
29
+ @api = api
30
+ @namespace = namespace
31
+ @request_id = 0
32
+ end
33
+
34
+ def respond_to_missing?(_sym, _include_private = false)
35
+ true
36
+ end
37
+
38
+ # Dispatch any Ruby method call as a JSON-RPC request
39
+ def method_missing(method, *args, &block)
40
+ args = args.first if args.size == 1 && args.first.is_a?(Hash)
41
+ data = @api.create('', {
42
+ jsonrpc: VERSION,
43
+ method: "#{@namespace}#{method}",
44
+ params: args,
45
+ id: @request_id += 1
46
+ })
47
+ Aspera.assert_type(data, Hash) { 'response' }
48
+ Aspera.assert(data['jsonrpc'] == VERSION, 'bad version in response')
49
+ Aspera.assert(data.key?('id'), 'missing id in response')
50
+ Aspera.assert(!(data.key?('error') && data.key?('result')), 'both error and response')
51
+ Aspera.assert(
52
+ !data.key?('error') ||
53
+ data['error'].is_a?(Hash) &&
54
+ data['error']['code'].is_a?(Integer) &&
55
+ data['error']['message'].is_a?(String),
56
+ 'bad error response'
57
+ )
58
+ return data['result']
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aspera
4
+ module JsonRpc
5
+ VERSION = '2.0'
6
+ end
7
+ end
@@ -4,12 +4,19 @@ module Aspera
4
4
  module Keychain
5
5
  class Base
6
6
  CONTENT_KEYS = %i[label username password url description].freeze
7
+
8
+ # Returns a lightweight list: one Hash per secret with :label (and :id if the backend has one).
9
+ # Override in subclasses that can provide this cheaply without fetching full item details.
10
+ def ids
11
+ all.map { |s| s.slice(:id, :label).compact }
12
+ end
13
+
7
14
  def validate_set(options)
8
- Aspera.assert_type(options, Hash){'options'}
15
+ Aspera.assert_type(options, Hash) { 'options' }
9
16
  unsupported = options.keys - CONTENT_KEYS
10
- Aspera.assert(unsupported.empty?){"unsupported options: #{unsupported}, use #{CONTENT_KEYS.join(', ')}"}
17
+ Aspera.assert(unsupported.empty?) { "unsupported options: #{unsupported}, use #{CONTENT_KEYS.join(', ')}" }
11
18
  options.each_pair do |k, v|
12
- Aspera.assert_type(v, String){k.to_s}
19
+ Aspera.assert_type(v, String) { k.to_s }
13
20
  end
14
21
  Aspera.assert(options.key?(:label), 'label is required')
15
22
  end
@@ -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
- raise "secret #{label} already exist, delete first" if @all_secrets.key?(label)
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,31 +12,43 @@ 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
25
25
  # this module requires compilation, so it is optional
26
26
  require 'aspera/keychain/encrypted_hash'
27
- @vault = Keychain::EncryptedHash.new(**info)
27
+ Keychain::EncryptedHash.new(**info)
28
28
  when 'system'
29
29
  case Environment.instance.os
30
30
  when Environment::OS_MACOS
31
31
  info[:name] ||= name
32
- @vault = Keychain::MacosSystem.new(**info)
32
+ Keychain::MacosSystem.new(**info)
33
33
  else
34
34
  raise Error, 'not implemented for this OS'
35
35
  end
36
36
  when 'vault'
37
37
  require 'aspera/keychain/hashicorp_vault'
38
38
  info[:token] ||= password
39
- @vault = Keychain::HashicorpVault.new(**info)
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
- raise Error, 'host required in URL' if uri.host.nil?
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
- raise "no such keychain #{name}" if @keychain.nil?
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
- raise Error, 'not found' if info.nil?
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