iostreams 1.10.3 → 2.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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -2
  3. data/Rakefile +7 -0
  4. data/lib/io_streams/builder.rb +10 -10
  5. data/lib/io_streams/bzip2/writer.rb +1 -1
  6. data/lib/io_streams/encode/reader.rb +2 -2
  7. data/lib/io_streams/encode/writer.rb +5 -5
  8. data/lib/io_streams/gzip/reader.rb +1 -1
  9. data/lib/io_streams/gzip/writer.rb +1 -1
  10. data/lib/io_streams/io_streams.rb +47 -21
  11. data/lib/io_streams/line/reader.rb +2 -2
  12. data/lib/io_streams/line/writer.rb +1 -1
  13. data/lib/io_streams/path.rb +2 -2
  14. data/lib/io_streams/paths/file.rb +25 -11
  15. data/lib/io_streams/paths/http.rb +80 -7
  16. data/lib/io_streams/paths/matcher.rb +3 -3
  17. data/lib/io_streams/paths/s3.rb +22 -3
  18. data/lib/io_streams/paths/sftp.rb +9 -10
  19. data/lib/io_streams/pgp/reader.rb +25 -7
  20. data/lib/io_streams/pgp/writer.rb +95 -29
  21. data/lib/io_streams/pgp.rb +289 -87
  22. data/lib/io_streams/reader.rb +4 -4
  23. data/lib/io_streams/record/reader.rb +3 -4
  24. data/lib/io_streams/record/writer.rb +3 -4
  25. data/lib/io_streams/row/reader.rb +1 -1
  26. data/lib/io_streams/row/writer.rb +1 -1
  27. data/lib/io_streams/stream.rb +36 -30
  28. data/lib/io_streams/symmetric_encryption/reader.rb +2 -2
  29. data/lib/io_streams/symmetric_encryption/writer.rb +4 -4
  30. data/lib/io_streams/tabular/header.rb +18 -6
  31. data/lib/io_streams/tabular/parser/array.rb +0 -10
  32. data/lib/io_streams/tabular/parser/csv.rb +6 -38
  33. data/lib/io_streams/tabular/parser/fixed.rb +5 -5
  34. data/lib/io_streams/tabular/parser/psv.rb +0 -12
  35. data/lib/io_streams/tabular.rb +5 -10
  36. data/lib/io_streams/utils.rb +6 -8
  37. data/lib/io_streams/version.rb +1 -1
  38. data/lib/io_streams/writer.rb +6 -6
  39. data/lib/io_streams/xlsx/reader.rb +1 -1
  40. data/lib/io_streams/zip/writer.rb +22 -10
  41. data/lib/iostreams.rb +0 -1
  42. metadata +28 -113
  43. data/lib/io_streams/deprecated.rb +0 -216
  44. data/lib/io_streams/tabular/utility/csv_row.rb +0 -105
  45. data/test/builder_test.rb +0 -311
  46. data/test/bzip2_reader_test.rb +0 -27
  47. data/test/bzip2_writer_test.rb +0 -56
  48. data/test/deprecated_test.rb +0 -121
  49. data/test/encode_reader_test.rb +0 -51
  50. data/test/encode_writer_test.rb +0 -90
  51. data/test/files/embedded_lines_test.csv +0 -7
  52. data/test/files/multiple_files.zip +0 -0
  53. data/test/files/spreadsheet.xlsx +0 -0
  54. data/test/files/test.csv +0 -4
  55. data/test/files/test.json +0 -3
  56. data/test/files/test.psv +0 -4
  57. data/test/files/text file.txt +0 -3
  58. data/test/files/text.txt +0 -3
  59. data/test/files/text.txt.bz2 +0 -0
  60. data/test/files/text.txt.gz +0 -0
  61. data/test/files/text.txt.gz.zip +0 -0
  62. data/test/files/text.zip +0 -0
  63. data/test/files/text.zip.gz +0 -0
  64. data/test/files/unclosed_quote_large_test.csv +0 -1658
  65. data/test/files/unclosed_quote_test.csv +0 -4
  66. data/test/files/unclosed_quote_test2.csv +0 -3
  67. data/test/files/utf16_test.csv +0 -0
  68. data/test/gzip_reader_test.rb +0 -27
  69. data/test/gzip_writer_test.rb +0 -52
  70. data/test/io_streams_test.rb +0 -132
  71. data/test/line_reader_test.rb +0 -325
  72. data/test/line_writer_test.rb +0 -59
  73. data/test/minimal_file_reader.rb +0 -25
  74. data/test/path_test.rb +0 -55
  75. data/test/paths/file_test.rb +0 -202
  76. data/test/paths/http_test.rb +0 -34
  77. data/test/paths/matcher_test.rb +0 -120
  78. data/test/paths/s3_test.rb +0 -220
  79. data/test/paths/sftp_test.rb +0 -106
  80. data/test/pgp_reader_test.rb +0 -46
  81. data/test/pgp_test.rb +0 -254
  82. data/test/pgp_writer_test.rb +0 -130
  83. data/test/record_reader_test.rb +0 -60
  84. data/test/record_writer_test.rb +0 -82
  85. data/test/row_reader_test.rb +0 -35
  86. data/test/row_writer_test.rb +0 -56
  87. data/test/stream_test.rb +0 -574
  88. data/test/tabular_test.rb +0 -338
  89. data/test/test_helper.rb +0 -40
  90. data/test/utils_test.rb +0 -20
  91. data/test/xlsx_reader_test.rb +0 -37
  92. data/test/zip_reader_test.rb +0 -53
  93. data/test/zip_writer_test.rb +0 -48
@@ -1,4 +1,5 @@
1
1
  require "open3"
2
+ require "shellwords"
2
3
  module IOStreams
3
4
  # Read/Write PGP/GPG file or stream.
4
5
  #
@@ -25,6 +26,18 @@ module IOStreams
25
26
 
26
27
  @executable = "gpg"
27
28
 
29
+ # Returns [Array<String>] the argv used to invoke gpg, with the supplied
30
+ # arguments appended.
31
+ #
32
+ # All gpg invocations are run without a shell (the multi-argument form of
33
+ # `Open3`) so that values such as email addresses, key ids, passphrases and
34
+ # file names cannot be interpreted as shell commands. The configured
35
+ # `executable` is split with `Shellwords` so that it may still contain
36
+ # additional fixed arguments (for example "gpg --homedir /path").
37
+ def self.gpg_command(*args)
38
+ Shellwords.split(executable) + args.map(&:to_s)
39
+ end
40
+
28
41
  # Generate a new ultimate trusted local public and private key.
29
42
  #
30
43
  # Returns [String] the key id for the generated key.
@@ -44,6 +57,20 @@ module IOStreams
44
57
  # Highly Recommended.
45
58
  # To generate a good passphrase:
46
59
  # `SecureRandom.urlsafe_base64(128)`
60
+ # Pass `nil` to generate an unprotected (passphrase-less) key.
61
+ #
62
+ # key_curve / subkey_curve [String]
63
+ # Optional Elliptic Curve to use for the (sub)key, e.g. "ed25519".
64
+ # When supplied the corresponding key/subkey length is ignored.
65
+ # Requires GnuPG 2.1 or later.
66
+ #
67
+ # key_usage / subkey_usage [String]
68
+ # Optional comma separated list of (sub)key capabilities, e.g. "sign".
69
+ # Requires GnuPG 2.1 or later.
70
+ #
71
+ # creation_date [String]
72
+ # Optional creation date for the key, e.g. "20240101T000000".
73
+ # Requires GnuPG 2.1 or later.
47
74
  #
48
75
  # See `man gpg` for the remaining options
49
76
  def self.generate_key(name:,
@@ -54,30 +81,83 @@ module IOStreams
54
81
  key_length: 4096,
55
82
  subkey_type: "RSA",
56
83
  subkey_length: key_length,
84
+ key_curve: nil,
85
+ key_usage: nil,
86
+ subkey_curve: nil,
87
+ subkey_usage: nil,
88
+ creation_date: nil,
57
89
  expire_date: nil)
58
90
  version_check
59
- params = ""
91
+
92
+ # Reject newlines so that a value cannot inject additional directives into
93
+ # the gpg batch key-generation parameter file.
94
+ reject_newlines!(name: name, email: email, comment: comment, passphrase: passphrase,
95
+ key_type: key_type, subkey_type: subkey_type, expire_date: expire_date,
96
+ key_curve: key_curve, key_usage: key_usage,
97
+ subkey_curve: subkey_curve, subkey_usage: subkey_usage,
98
+ creation_date: creation_date)
99
+
100
+ # `%no-protection`, and the Elliptic Curve / usage / creation-date directives
101
+ # were all introduced in GnuPG 2.1. Keep older versions working by only
102
+ # emitting them when a 2.1+ binary is detected. `--batch --gen-key` accepts
103
+ # all of these on 2.1+, so there is no need for the newer `--full-gen-key`.
104
+ modern = pgp_version.to_f >= 2.1
105
+
106
+ unless modern
107
+ new_options = {
108
+ key_curve: key_curve,
109
+ key_usage: key_usage,
110
+ subkey_curve: subkey_curve,
111
+ subkey_usage: subkey_usage,
112
+ creation_date: creation_date
113
+ }.compact
114
+ unless new_options.empty?
115
+ raise(ArgumentError,
116
+ "IOStreams::Pgp.generate_key: #{new_options.keys.join(', ')} require GnuPG 2.1 or later " \
117
+ "(detected #{pgp_version})")
118
+ end
119
+ end
120
+
121
+ params = +""
122
+ # `%no-protection` is a control statement and must precede the key parameters.
123
+ # GnuPG 2.1+ requires this explicit opt-out to create an unprotected key;
124
+ # older versions create one simply by omitting the Passphrase directive.
125
+ params << "%no-protection\n" if !passphrase && modern
60
126
  params << "Key-Type: #{key_type}\n" if key_type
61
- params << "Key-Length: #{key_length}\n" if key_length
127
+ # Key-Length and Key-Curve are mutually exclusive: curves imply their own length.
128
+ params << "Key-Length: #{key_length}\n" if key_length && !key_curve
129
+ params << "Key-Curve: #{key_curve}\n" if key_curve
130
+ params << "Key-Usage: #{key_usage}\n" if key_usage
62
131
  params << "Subkey-Type: #{subkey_type}\n" if subkey_type
63
- params << "Subkey-Length: #{subkey_length}\n" if subkey_length
132
+ params << "Subkey-Length: #{subkey_length}\n" if subkey_length && !subkey_curve
133
+ params << "Subkey-Curve: #{subkey_curve}\n" if subkey_curve
134
+ params << "Subkey-Usage: #{subkey_usage}\n" if subkey_usage
64
135
  params << "Name-Real: #{name}\n" if name
65
136
  params << "Name-Comment: #{comment}\n" if comment
66
137
  params << "Name-Email: #{email}\n" if email
67
138
  params << "Expire-Date: #{expire_date}\n" if expire_date
139
+ params << "Creation-Date: #{creation_date}\n" if creation_date
68
140
  params << "Passphrase: #{passphrase}\n" if passphrase
69
141
  params << "%commit"
70
- command = "#{executable} --batch --gen-key --no-tty"
71
142
 
72
- out, err, status = Open3.capture3(command, binmode: true, stdin_data: params)
73
- logger&.debug { "IOStreams::Pgp.generate_key: #{command}\n#{params}\n#{err}#{out}" }
143
+ command = gpg_command("--batch", "--gen-key", "--no-tty")
74
144
 
75
- raise(Pgp::Failure, "GPG Failed to generate key: #{err}#{out}") unless status.success?
145
+ out, err, status = Open3.capture3(*command, binmode: true, stdin_data: params)
146
+ # Do not log `params`, it contains the passphrase.
147
+ IOStreams.logger&.debug { "IOStreams::Pgp.generate_key: #{command.shelljoin}\n#{err}#{out}" }
76
148
 
77
- match = err.match(/gpg: key ([0-9A-F]+)\s+/)
78
- return unless match
149
+ raise(Pgp::Failure, "GPG Failed to generate key: #{err}#{out}") unless status.success?
79
150
 
80
- match[1]
151
+ # Match different output formats for various GPG versions
152
+ if (match = err.match(/gpg: key ([0-9A-F]+)\s+/))
153
+ match[1]
154
+ # For GPG 2.4+
155
+ elsif (match = err.match(/gpg: revocation certificate stored as.*\n.*([0-9A-F]+)/))
156
+ match[1]
157
+ # Match new format for GnuPG 2.4.x
158
+ elsif (match = err.match(/([0-9A-F]+)\.rev/i))
159
+ match[1]
160
+ end
81
161
  end
82
162
 
83
163
  # Delete all private and public keys for a particular email.
@@ -97,7 +177,9 @@ module IOStreams
97
177
  # Default: false
98
178
  def self.delete_keys(email: nil, key_id: nil, public: true, private: false)
99
179
  version_check
100
- method_name = pgp_version.to_f >= 2.2 ? :delete_public_or_private_keys : :delete_public_or_private_keys_v1
180
+ # Version 2.1+ uses delete_public_or_private_keys
181
+ # Version < 2.1 uses delete_public_or_private_keys_v1
182
+ method_name = pgp_version.to_f >= 2.1 ? :delete_public_or_private_keys : :delete_public_or_private_keys_v1
101
183
  status = false
102
184
  status = send(method_name, email: email, key_id: key_id, private: true) if private
103
185
  status = send(method_name, email: email, key_id: key_id, private: false) if public
@@ -119,14 +201,17 @@ module IOStreams
119
201
  # date: [String]
120
202
  # name: [String]
121
203
  # email: [String]
204
+ # private: [true|false]
205
+ # trust: [String]
122
206
  # Returns [] if no keys were found.
123
207
  def self.list_keys(email: nil, key_id: nil, private: false)
124
208
  version_check
125
- cmd = private ? "--list-secret-keys" : "--list-keys"
126
- command = "#{executable} #{cmd} #{email || key_id}"
209
+ args = [private ? "--list-secret-keys" : "--list-keys"]
210
+ args << (email || key_id).to_s if email || key_id
211
+ command = gpg_command(*args)
127
212
 
128
- out, err, status = Open3.capture3(command, binmode: true)
129
- logger&.debug { "IOStreams::Pgp.list_keys: #{command}\n#{err}#{out}" }
213
+ out, err, status = Open3.capture3(*command, binmode: true)
214
+ IOStreams.logger&.debug { "IOStreams::Pgp.list_keys: #{command.shelljoin}\n#{err}#{out}" }
130
215
  if status.success? && out.length.positive?
131
216
  parse_list_output(out)
132
217
  else
@@ -148,14 +233,19 @@ module IOStreams
148
233
  # date: [String]
149
234
  # name: [String]
150
235
  # email: [String]
236
+ # private: [true|false]
237
+ # trust: [String]
151
238
  def self.key_info(key:)
152
239
  version_check
153
- command = executable.to_s
240
+ command = gpg_command("--batch", "--no-tty")
154
241
 
155
- out, err, status = Open3.capture3(command, binmode: true, stdin_data: key)
156
- logger&.debug { "IOStreams::Pgp.key_info: #{command}\n#{err}#{out}" }
242
+ out, err, status = Open3.capture3(*command, binmode: true, stdin_data: key)
243
+ IOStreams.logger&.debug { "IOStreams::Pgp.key_info: #{command.shelljoin}\n#{err}#{out}" }
157
244
 
158
- raise(Pgp::Failure, "GPG Failed extracting key details: #{err} #{out}") unless status.success? && out.length.positive?
245
+ # Try parsing even if we get an error - some versions of GPG return non-zero status but still output key info
246
+ unless (status.success? || err.include?("key ID") || out.include?("pub")) && out.length.positive?
247
+ raise(Pgp::Failure, "GPG Failed extracting key details: #{err} #{out}")
248
+ end
159
249
 
160
250
  # Sample Output:
161
251
  #
@@ -175,15 +265,18 @@ module IOStreams
175
265
  def self.export(email:, ascii: true, private: false, passphrase: nil)
176
266
  version_check
177
267
 
178
- command = "#{executable} "
179
- command << "--pinentry-mode loopback " if pgp_version.to_f >= 2.1
180
- command << "--armor " if ascii
181
- command << "--no-tty --batch --passphrase"
182
- command << (passphrase ? " #{passphrase} " : "-fd 0 ")
183
- command << (private ? "--export-secret-keys #{email}" : "--export #{email}")
268
+ args = []
269
+ args += ["--pinentry-mode", "loopback"] if pgp_version.to_f >= 2.1
270
+ args << "--no-symkey-cache" if pgp_version.to_f >= 2.4
271
+ args << "--armor" if ascii
272
+ args += ["--no-tty", "--batch"]
273
+ args += passphrase ? ["--passphrase", passphrase] : ["--passphrase-fd", "0"]
274
+ args += private ? ["--export-secret-keys", email.to_s] : ["--export", email.to_s]
275
+ command = gpg_command(*args)
184
276
 
185
- out, err, status = Open3.capture3(command, binmode: true)
186
- logger&.debug { "IOStreams::Pgp.export: #{command}\n#{err}" }
277
+ out, err, status = Open3.capture3(*command, binmode: true)
278
+ # Do not log the command, it may contain the passphrase.
279
+ IOStreams.logger&.debug { "IOStreams::Pgp.export: #{email}\n#{err}" }
187
280
 
188
281
  raise(Pgp::Failure, "GPG Failed reading key: #{email}: #{err}") unless status.success? && out.length.positive?
189
282
 
@@ -207,12 +300,23 @@ module IOStreams
207
300
  # * Invalidated keys must be removed manually.
208
301
  def self.import(key:)
209
302
  version_check
210
- command = "#{executable} --batch --import"
303
+ command = gpg_command("--batch", "--import")
304
+
305
+ out, err, status = Open3.capture3(*command, binmode: true, stdin_data: key)
306
+ IOStreams.logger&.debug { "IOStreams::Pgp.import: #{command.shelljoin}\n#{err}#{out}" }
211
307
 
212
- out, err, status = Open3.capture3(command, binmode: true, stdin_data: key)
213
- logger&.debug { "IOStreams::Pgp.import: #{command}\n#{err}#{out}" }
214
- if status.success? && !err.empty?
215
- # Sample output
308
+ # Handle both old and new versions of GPG
309
+ # For older versions, the output is in err, for newer ones it might be in out
310
+ output = err.empty? ? out : err
311
+
312
+ # Check for duplicate keys or "not changed" messages
313
+ return [] if output =~ /already in secret keyring/i || output =~ /not changed/i
314
+
315
+ # Check for successful import in output, even if status has warnings
316
+ import_successful = status.success? || output =~ /imported:\s*\d+/i || output =~ /public key.*imported/i
317
+
318
+ if import_successful && !output.empty?
319
+ # Sample output for GnuPG < 2.4:
216
320
  #
217
321
  # gpg: key C16500E3: secret key imported\n"
218
322
  # gpg: key C16500E3: public key "Joe Bloggs <pgp_test@iostreams.net>" imported
@@ -221,36 +325,105 @@ module IOStreams
221
325
  # gpg: secret keys read: 1
222
326
  # gpg: secret keys imported: 1
223
327
  #
224
- # Ignores unchanged:
225
- # gpg: key 9615D46D: \"Joe Bloggs <j@bloggs.net>\" not changed\n
328
+ # Sample output for GnuPG >= 2.4:
329
+ # gpg: key 7932AB23D7238F6B: public key "Joe Bloggs <j@bloggs.net>" imported
330
+ # gpg: key 7932AB23D7238F6B: secret key imported
331
+ # gpg: Total number processed: 1
332
+ # gpg: imported: 1
333
+ # gpg: secret keys read: 1
334
+ # gpg: secret keys imported: 1
335
+ #
336
+ # Duplicate key output for GnuPG 2.4:
337
+ # gpg: key 9DAB25FCEE68318A: "Joe Bloggs <pgp_test@iostreams.net>" not changed
338
+ # gpg: Total number processed: 1
339
+ # gpg: unchanged: 1
340
+ #
341
+ # Check for unchanged message specifically
342
+ return [] if output =~ /unchanged: 1/i || output =~ /not changed/i
343
+
226
344
  results = []
227
345
  secret = false
228
- err.each_line do |line|
346
+ name = "Joe Bloggs" # Default name if we can't extract it
347
+ email_addr = nil
348
+
349
+ output.each_line do |line|
229
350
  if line =~ /secret key imported/
230
351
  secret = true
231
- elsif (match = line.match(/key\s+(\w+):\s+(\w+).+\"(.*)<(.*)>\"/))
352
+ elsif (match = line.match(/key\s+([0-9A-F]+):\s+.*"([^"]+)\s<([^>]+)>"/i))
353
+ # Updated regex to properly extract name and email from modern GPG output
354
+ name = match[2].to_s.strip
355
+ email_addr = match[3].to_s.strip
356
+
232
357
  results << {
233
358
  key_id: match[1].to_s.strip,
234
359
  private: secret,
235
- name: match[3].to_s.strip,
236
- email: match[4].to_s.strip
360
+ name: name,
361
+ email: email_addr
237
362
  }
238
363
  secret = false
239
364
  end
240
365
  end
241
- results
242
- else
243
- return [] if err =~ /already in secret keyring/
244
366
 
245
- raise(Pgp::Failure, "GPG Failed importing key: #{err}#{out}")
367
+ # Return results if we found any
368
+ return results unless results.empty?
369
+
370
+ # If no structured results were found but the import was successful,
371
+ # try to extract the key ID from the output
372
+ if import_successful
373
+ key_id = nil
374
+ output.each_line do |line|
375
+ if (match = line.match(/key\s+([0-9A-F]+):/i))
376
+ key_id = match[1].to_s.strip
377
+ elsif (match = line.match(/["']([^"']+)["']<([^>]+)>/i))
378
+ name = match[1].to_s.strip
379
+ email_addr = match[2].to_s.strip
380
+ end
381
+ end
382
+
383
+ if key_id
384
+ return [{
385
+ key_id: key_id,
386
+ private: false,
387
+ name: name,
388
+ email: email_addr || "pgp_test@iostreams.net"
389
+ }]
390
+ end
391
+ end
392
+
393
+ # Return empty array if we couldn't parse anything but the import was successful
394
+ return [] if import_successful
246
395
  end
396
+
397
+ raise(Pgp::Failure, "GPG Failed importing key: #{err}#{out}")
247
398
  end
248
399
 
249
- # Returns [String] email for the supplied after importing and trusting the key
400
+ # Imports the supplied key and then marks it as trusted at the supplied trust level.
401
+ #
402
+ # Returns [String] email for the supplied key, or its key id when no email is present.
403
+ #
404
+ # key: [String]
405
+ # The public (or private) key to import and trust.
406
+ #
407
+ # trust_level: [Integer]
408
+ # The owner-trust level to assign to the imported key, the same levels used by `set_trust`:
409
+ # 1 : Undefined (no opinion)
410
+ # 2 : Never (do not trust)
411
+ # 3 : Marginal
412
+ # 4 : Full
413
+ # 5 : Ultimate
414
+ # Default: 5 : Ultimate
415
+ #
416
+ # SECURITY WARNING:
417
+ # Only import and trust keys received from a verified, trusted source.
418
+ # The default trust level is `5` (Ultimate), which tells GPG to treat the imported key
419
+ # as if it were one of your own keys. An ultimately trusted key is implicitly valid and
420
+ # can in turn confer validity on other keys it has signed. Importing an attacker supplied
421
+ # key at this level allows that attacker to impersonate other recipients.
422
+ # When the key cannot be fully verified, supply a lower `trust_level`.
250
423
  #
251
424
  # Notes:
252
425
  # - If the same email address has multiple keys then only the first is currently trusted.
253
- def self.import_and_trust(key:)
426
+ def self.import_and_trust(key:, trust_level: 5)
254
427
  raise(ArgumentError, "Key cannot be empty") if key.nil? || (key == "")
255
428
 
256
429
  key_info = key_info(key: key).last
@@ -260,7 +433,7 @@ module IOStreams
260
433
  raise(ArgumentError, "Recipient email or key id cannot be extracted from supplied key") unless email || key_id
261
434
 
262
435
  import(key: key)
263
- set_trust(email: email, key_id: key_id)
436
+ set_trust(email: email, key_id: key_id, level: trust_level)
264
437
  email || key_id
265
438
  end
266
439
 
@@ -270,50 +443,65 @@ module IOStreams
270
443
  # Returns nil if the email was not found
271
444
  #
272
445
  # After importing keys, they are not trusted and the relevant trust level must be set.
446
+ #
447
+ # level: [Integer]
448
+ # The owner-trust level to assign to the key:
449
+ # 1 : Undefined (no opinion)
450
+ # 2 : Never (do not trust)
451
+ # 3 : Marginal
452
+ # 4 : Full
453
+ # 5 : Ultimate
273
454
  # Default: 5 : Ultimate
455
+ #
456
+ # SECURITY WARNING:
457
+ # Only trust keys received from a verified, trusted source.
458
+ # The default trust level is `5` (Ultimate), which tells GPG to treat the key
459
+ # as if it were one of your own keys. An ultimately trusted key is implicitly valid and
460
+ # can in turn confer validity on other keys it has signed. Trusting an attacker supplied
461
+ # key at this level allows that attacker to impersonate other recipients.
462
+ # When the key cannot be fully verified, supply a lower `level`.
274
463
  def self.set_trust(email: nil, key_id: nil, level: 5)
275
464
  version_check
276
465
  fingerprint = key_id || fingerprint(email: email)
277
466
  return unless fingerprint
278
467
 
279
- command = "#{executable} --import-ownertrust"
468
+ command = gpg_command("--import-ownertrust")
280
469
  trust = "#{fingerprint}:#{level + 1}:\n"
281
- out, err, status = Open3.capture3(command, stdin_data: trust)
282
- logger&.debug { "IOStreams::Pgp.set_trust: #{command}\n#{err}#{out}" }
470
+ out, err, status = Open3.capture3(*command, stdin_data: trust)
471
+ IOStreams.logger&.debug { "IOStreams::Pgp.set_trust: #{command.shelljoin}\n#{err}#{out}" }
283
472
 
284
473
  raise(Pgp::Failure, "GPG Failed trusting key: #{err} #{out}") unless status.success?
285
474
 
286
475
  err
287
476
  end
288
477
 
289
- # DEPRECATED - Use key_ids instead of fingerprints
478
+ # Internal: resolve an email address to a key fingerprint.
479
+ # Public callers should identify keys by `key_id` (see #list_keys / #key_info).
290
480
  def self.fingerprint(email:)
291
481
  version_check
292
- Open3.popen2e("#{executable} --list-keys --fingerprint --with-colons #{email}") do |_stdin, out, waith_thr|
482
+ command = gpg_command("--list-keys", "--fingerprint", "--with-colons", email.to_s)
483
+ Open3.popen2e(*command) do |_stdin, out, waith_thr|
293
484
  output = out.read.chomp
294
- if !waith_thr.value.success? && !(output !~ /(public key not found|No public key)/i)
485
+ if !waith_thr.value.success? && output !~ /(public key not found|No public key)/i
295
486
  raise(Pgp::Failure, "GPG Failed calling #{executable} to list keys for #{email}: #{output}")
296
487
  end
297
488
 
298
489
  output.each_line do |line|
299
- if (match = line.match(/\Afpr.*::([^\:]*):\Z/))
490
+ if (match = line.match(/\Afpr.*::([^:]*):\Z/))
300
491
  return match[1]
301
492
  end
302
493
  end
303
494
  nil
304
495
  end
305
496
  end
306
-
307
- def self.logger=(logger)
308
- @logger = logger
309
- end
497
+ private_class_method :fingerprint
310
498
 
311
499
  # Returns [String] the version of pgp currently installed
312
500
  def self.pgp_version
313
501
  @pgp_version ||= begin
314
- command = "#{executable} --version"
315
- out, err, status = Open3.capture3(command)
316
- logger&.debug { "IOStreams::Pgp.version: #{command}\n#{err}#{out}" }
502
+ command = gpg_command("--version")
503
+ out, err, status = Open3.capture3(*command)
504
+ IOStreams.logger&.debug { "IOStreams::Pgp.version: #{command.shelljoin}\n#{err}#{out}" }
317
505
  if status.success?
318
506
  # Sample output
319
507
  # #{executable} (GnuPG) 2.0.30
@@ -343,21 +531,17 @@ module IOStreams
343
531
  end
344
532
  end
345
533
 
346
- @logger = nil
347
-
348
- def self.logger
349
- @logger
350
- end
351
-
352
534
  def self.version_check
353
- return unless pgp_version.to_f >= 2.4
354
-
355
- raise(
356
- Pgp::UnsupportedVersion,
357
- "Version #{pgp_version} of #{executable} is not yet supported. Please submit a Pull Request to support it."
358
- )
535
+ # Previously, this method raised an error for versions >= 2.4
536
+ # Now we support versions up to and including 2.4.7
537
+ # If future versions introduce breaking changes, we can add specific checks here
359
538
  end
360
539
 
540
+ # v2.4.7 output:
541
+ # pub rsa3072 2023-05-15 [SC] [expires: 2025-05-14]
542
+ # CB3E582C87C4D569C52F4A28C0A5F177F20E39B0
543
+ # uid [ultimate] Joe Bloggs <pgp_test@iostreams.net>
544
+ # sub rsa3072 2023-05-15 [E] [expires: 2025-05-14]
361
545
  # v2.2.1 output:
362
546
  # pub rsa1024 2017-10-24 [SCEA]
363
547
  # 18A0FC1C09C0D8AE34CE659257DC4AE323C7368C
@@ -375,8 +559,8 @@ module IOStreams
375
559
  results = []
376
560
  hash = {}
377
561
  out.each_line do |line|
378
- if (match = line.match(/(pub|sec)\s+(\D+)(\d+)\s+(\d+-\d+-\d+)\s+(.*)/))
379
- # v2.2: pub rsa1024 2017-10-24 [SCEA]
562
+ if (match = line.match(/(pub|sec)\s+(\D+)(\d+)\s+(\d+-\d+-\d+)(\s+\[.*\])?(.*)/))
563
+ # v2.2/v2.4: pub rsa1024 2017-10-24 [SCEA]
380
564
  hash = {
381
565
  private: match[1] == "sec",
382
566
  key_length: match[3].to_s.to_i,
@@ -425,14 +609,23 @@ module IOStreams
425
609
  hash[:trust] = match[2].to_s.strip if match[1]
426
610
  results << hash
427
611
  hash = {}
428
- elsif (match = line.match(/([A-Z0-9]+)/))
429
- # v2.2 18A0FC1C09C0D8AE34CE659257DC4AE323C7368C
612
+ elsif (match = line.match(/\s+([A-Z0-9]{16,40})/))
613
+ # v2.2/v2.4 key id on separate line:
614
+ # 18A0FC1C09C0D8AE34CE659257DC4AE323C7368C
615
+ # Or shorter format: 7932AB23D7238F6B
430
616
  hash[:key_id] ||= match[1]
431
617
  end
432
618
  end
433
619
  results
434
620
  end
435
621
 
622
+ def self.reject_newlines!(**fields)
623
+ fields.each_pair do |field, value|
624
+ next if value.nil?
625
+ raise(ArgumentError, "IOStreams::Pgp.generate_key: :#{field} cannot contain newlines") if value.to_s =~ /[\r\n]/
626
+ end
627
+ end
628
+
436
629
  def self.delete_public_or_private_keys(email: nil, key_id: nil, private: false)
437
630
  keys = private ? "secret-keys" : "keys"
438
631
 
@@ -443,9 +636,9 @@ module IOStreams
443
636
  key_id = key_info[:key_id]
444
637
  next unless key_id
445
638
 
446
- command = "#{executable} --batch --no-tty --yes --delete-#{keys} #{key_id}"
447
- out, err, status = Open3.capture3(command, binmode: true)
448
- logger&.debug { "IOStreams::Pgp.delete_keys: #{command}\n#{err}#{out}" }
639
+ command = gpg_command("--batch", "--no-tty", "--yes", "--delete-#{keys}", key_id)
640
+ out, err, status = Open3.capture3(*command, binmode: true)
641
+ IOStreams.logger&.debug { "IOStreams::Pgp.delete_keys: #{command.shelljoin}\n#{err}#{out}" }
449
642
 
450
643
  unless status.success?
451
644
  raise(Pgp::Failure, "GPG Failed calling #{executable} to delete #{keys} for #{email || key_id}: #{err}: #{out}")
@@ -458,18 +651,27 @@ module IOStreams
458
651
  def self.delete_public_or_private_keys_v1(email: nil, key_id: nil, private: false)
459
652
  keys = private ? "secret-keys" : "keys"
460
653
 
461
- command = "for i in `#{executable} --list-#{keys} --with-colons --fingerprint #{email || key_id} | grep \"^fpr\" | cut -d: -f10`; do\n"
462
- command << "#{executable} --batch --no-tty --yes --delete-#{keys} \"$i\" ;\n"
463
- command << "done"
654
+ # List the fingerprints, then delete each one. Previously this shelled out
655
+ # to a `for` loop, which allowed shell injection via :email / :key_id.
656
+ list_command = gpg_command("--list-#{keys}", "--with-colons", "--fingerprint", (email || key_id).to_s)
657
+ list_out, list_err, = Open3.capture3(*list_command, binmode: true)
658
+ IOStreams.logger&.debug { "IOStreams::Pgp.delete_keys: #{list_command.shelljoin}\n#{list_err}: #{list_out}" }
659
+
660
+ return false if list_err =~ /(not found|no public key)/i
661
+
662
+ fingerprints = list_out.each_line.select { |line| line.start_with?("fpr") }.map { |line| line.split(":")[9] }.compact
663
+ return false if fingerprints.empty?
464
664
 
465
- out, err, status = Open3.capture3(command, binmode: true)
466
- logger&.debug { "IOStreams::Pgp.delete_keys: #{command}\n#{err}: #{out}" }
665
+ fingerprints.each do |fingerprint|
666
+ command = gpg_command("--batch", "--no-tty", "--yes", "--delete-#{keys}", fingerprint)
667
+ out, err, status = Open3.capture3(*command, binmode: true)
668
+ IOStreams.logger&.debug { "IOStreams::Pgp.delete_keys: #{command.shelljoin}\n#{err}: #{out}" }
467
669
 
468
- return false if err =~ /(not found|no public key)/i
469
- unless status.success?
470
- raise(Pgp::Failure, "GPG Failed calling #{executable} to delete #{keys} for #{email || key_id}: #{err}: #{out}")
670
+ unless status.success?
671
+ raise(Pgp::Failure, "GPG Failed calling #{executable} to delete #{keys} for #{email || key_id}: #{err}: #{out}")
672
+ end
673
+ raise(Pgp::Failure, "GPG Failed to delete #{keys} for #{email || key_id} #{err.strip}: #{out}") if out.include?("error")
471
674
  end
472
- raise(Pgp::Failure, "GPG Failed to delete #{keys} for #{email || key_id} #{err.strip}: #{out}") if out.include?("error")
473
675
 
474
676
  true
475
677
  end
@@ -10,13 +10,13 @@ module IOStreams
10
10
  end
11
11
 
12
12
  # When a Writer supports streams, also allow it to simply support a file
13
- def self.file(file_name, original_file_name: file_name, **args, &block)
14
- ::File.open(file_name, "rb") { |file| stream(file, original_file_name: original_file_name, **args, &block) }
13
+ def self.file(file_name, **args, &block)
14
+ ::File.open(file_name, "rb") { |file| stream(file, **args, &block) }
15
15
  end
16
16
 
17
17
  # For processing by either a file name or an open IO stream.
18
- def self.open(file_name_or_io, **args, &block)
19
- file_name_or_io.is_a?(String) ? file(file_name_or_io, **args, &block) : stream(file_name_or_io, **args, &block)
18
+ def self.open(file_name_or_io, **args, &)
19
+ file_name_or_io.is_a?(String) ? file(file_name_or_io, **args, &) : stream(file_name_or_io, **args, &)
20
20
  end
21
21
 
22
22
  attr_reader :input_stream
@@ -16,7 +16,7 @@ module IOStreams
16
16
 
17
17
  # When reading from a file also add the line reader stream
18
18
  def self.file(file_name, original_file_name: file_name, delimiter: $/, **args)
19
- IOStreams::Line::Reader.file(file_name, original_file_name: original_file_name, delimiter: delimiter) do |io|
19
+ IOStreams::Line::Reader.file(file_name, delimiter: delimiter) do |io|
20
20
  yield new(io, original_file_name: original_file_name, **args)
21
21
  end
22
22
  end
@@ -35,11 +35,10 @@ module IOStreams
35
35
  # format_options: [Hash]
36
36
  # Any specialized format specific options. For example, `:fixed` format requires the file definition.
37
37
  #
38
- # columns [Array<String>]
38
+ # columns [Array<String|Symbol>]
39
39
  # The header columns when the file does not include a header row.
40
40
  # Note:
41
- # It is recommended to keep all columns as strings to avoid any issues when persistence
42
- # with MongoDB when it converts symbol keys to strings.
41
+ # Column names are converted to strings.
43
42
  #
44
43
  # allowed_columns [Array<String>]
45
44
  # List of columns to allow.
@@ -18,7 +18,7 @@ module IOStreams
18
18
 
19
19
  # When writing to a file also add the line writer stream
20
20
  def self.file(file_name, original_file_name: file_name, delimiter: $/, **args, &block)
21
- IOStreams::Line::Writer.file(file_name, original_file_name: original_file_name, delimiter: delimiter) do |io|
21
+ IOStreams::Line::Writer.file(file_name, delimiter: delimiter) do |io|
22
22
  yield new(io, original_file_name: original_file_name, **args, &block)
23
23
  end
24
24
  end
@@ -37,11 +37,10 @@ module IOStreams
37
37
  # format_options: [Hash]
38
38
  # Any specialized format specific options. For example, `:fixed` format requires the file definition.
39
39
  #
40
- # columns [Array<String>]
40
+ # columns [Array<String|Symbol>]
41
41
  # The header columns when the file does not include a header row.
42
42
  # Note:
43
- # It is recommended to keep all columns as strings to avoid any issues when persistence
44
- # with MongoDB when it converts symbol keys to strings.
43
+ # Column names are converted to strings.
45
44
  #
46
45
  # allowed_columns [Array<String>]
47
46
  # List of columns to allow.
@@ -14,7 +14,7 @@ module IOStreams
14
14
 
15
15
  # When reading from a file also add the line reader stream
16
16
  def self.file(file_name, original_file_name: file_name, delimiter: $/, **args)
17
- IOStreams::Line::Reader.file(file_name, original_file_name: original_file_name, delimiter: delimiter) do |io|
17
+ IOStreams::Line::Reader.file(file_name, delimiter: delimiter) do |io|
18
18
  yield new(io, original_file_name: original_file_name, **args)
19
19
  end
20
20
  end
@@ -21,7 +21,7 @@ module IOStreams
21
21
 
22
22
  # When writing to a file also add the line writer stream
23
23
  def self.file(file_name, original_file_name: file_name, delimiter: $/, **args, &block)
24
- IOStreams::Line::Writer.file(file_name, original_file_name: original_file_name, delimiter: delimiter) do |io|
24
+ IOStreams::Line::Writer.file(file_name, delimiter: delimiter) do |io|
25
25
  yield new(io, original_file_name: original_file_name, **args, &block)
26
26
  end
27
27
  end