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.
- checksums.yaml +4 -4
- data/README.md +20 -2
- data/Rakefile +7 -0
- data/lib/io_streams/builder.rb +10 -10
- data/lib/io_streams/bzip2/writer.rb +1 -1
- data/lib/io_streams/encode/reader.rb +2 -2
- data/lib/io_streams/encode/writer.rb +5 -5
- data/lib/io_streams/gzip/reader.rb +1 -1
- data/lib/io_streams/gzip/writer.rb +1 -1
- data/lib/io_streams/io_streams.rb +47 -21
- data/lib/io_streams/line/reader.rb +2 -2
- data/lib/io_streams/line/writer.rb +1 -1
- data/lib/io_streams/path.rb +2 -2
- data/lib/io_streams/paths/file.rb +25 -11
- data/lib/io_streams/paths/http.rb +80 -7
- data/lib/io_streams/paths/matcher.rb +3 -3
- data/lib/io_streams/paths/s3.rb +22 -3
- data/lib/io_streams/paths/sftp.rb +9 -10
- data/lib/io_streams/pgp/reader.rb +25 -7
- data/lib/io_streams/pgp/writer.rb +95 -29
- data/lib/io_streams/pgp.rb +289 -87
- data/lib/io_streams/reader.rb +4 -4
- data/lib/io_streams/record/reader.rb +3 -4
- data/lib/io_streams/record/writer.rb +3 -4
- data/lib/io_streams/row/reader.rb +1 -1
- data/lib/io_streams/row/writer.rb +1 -1
- data/lib/io_streams/stream.rb +36 -30
- data/lib/io_streams/symmetric_encryption/reader.rb +2 -2
- data/lib/io_streams/symmetric_encryption/writer.rb +4 -4
- data/lib/io_streams/tabular/header.rb +18 -6
- data/lib/io_streams/tabular/parser/array.rb +0 -10
- data/lib/io_streams/tabular/parser/csv.rb +6 -38
- data/lib/io_streams/tabular/parser/fixed.rb +5 -5
- data/lib/io_streams/tabular/parser/psv.rb +0 -12
- data/lib/io_streams/tabular.rb +5 -10
- data/lib/io_streams/utils.rb +6 -8
- data/lib/io_streams/version.rb +1 -1
- data/lib/io_streams/writer.rb +6 -6
- data/lib/io_streams/xlsx/reader.rb +1 -1
- data/lib/io_streams/zip/writer.rb +22 -10
- data/lib/iostreams.rb +0 -1
- metadata +28 -113
- data/lib/io_streams/deprecated.rb +0 -216
- data/lib/io_streams/tabular/utility/csv_row.rb +0 -105
- data/test/builder_test.rb +0 -311
- data/test/bzip2_reader_test.rb +0 -27
- data/test/bzip2_writer_test.rb +0 -56
- data/test/deprecated_test.rb +0 -121
- data/test/encode_reader_test.rb +0 -51
- data/test/encode_writer_test.rb +0 -90
- data/test/files/embedded_lines_test.csv +0 -7
- data/test/files/multiple_files.zip +0 -0
- data/test/files/spreadsheet.xlsx +0 -0
- data/test/files/test.csv +0 -4
- data/test/files/test.json +0 -3
- data/test/files/test.psv +0 -4
- data/test/files/text file.txt +0 -3
- data/test/files/text.txt +0 -3
- data/test/files/text.txt.bz2 +0 -0
- data/test/files/text.txt.gz +0 -0
- data/test/files/text.txt.gz.zip +0 -0
- data/test/files/text.zip +0 -0
- data/test/files/text.zip.gz +0 -0
- data/test/files/unclosed_quote_large_test.csv +0 -1658
- data/test/files/unclosed_quote_test.csv +0 -4
- data/test/files/unclosed_quote_test2.csv +0 -3
- data/test/files/utf16_test.csv +0 -0
- data/test/gzip_reader_test.rb +0 -27
- data/test/gzip_writer_test.rb +0 -52
- data/test/io_streams_test.rb +0 -132
- data/test/line_reader_test.rb +0 -325
- data/test/line_writer_test.rb +0 -59
- data/test/minimal_file_reader.rb +0 -25
- data/test/path_test.rb +0 -55
- data/test/paths/file_test.rb +0 -202
- data/test/paths/http_test.rb +0 -34
- data/test/paths/matcher_test.rb +0 -120
- data/test/paths/s3_test.rb +0 -220
- data/test/paths/sftp_test.rb +0 -106
- data/test/pgp_reader_test.rb +0 -46
- data/test/pgp_test.rb +0 -254
- data/test/pgp_writer_test.rb +0 -130
- data/test/record_reader_test.rb +0 -60
- data/test/record_writer_test.rb +0 -82
- data/test/row_reader_test.rb +0 -35
- data/test/row_writer_test.rb +0 -56
- data/test/stream_test.rb +0 -574
- data/test/tabular_test.rb +0 -338
- data/test/test_helper.rb +0 -40
- data/test/utils_test.rb +0 -20
- data/test/xlsx_reader_test.rb +0 -37
- data/test/zip_reader_test.rb +0 -53
- data/test/zip_writer_test.rb +0 -48
data/lib/io_streams/pgp.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
return unless match
|
|
149
|
+
raise(Pgp::Failure, "GPG Failed to generate key: #{err}#{out}") unless status.success?
|
|
79
150
|
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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 = "
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
#
|
|
225
|
-
# gpg: key
|
|
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
|
-
|
|
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+(
|
|
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:
|
|
236
|
-
email:
|
|
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
|
-
|
|
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
|
-
#
|
|
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 = "
|
|
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
|
-
#
|
|
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
|
-
|
|
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? &&
|
|
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.*::([
|
|
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 = "
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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(
|
|
429
|
-
# v2.2
|
|
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 = "
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
data/lib/io_streams/reader.rb
CHANGED
|
@@ -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,
|
|
14
|
-
::File.open(file_name, "rb") { |file| stream(file,
|
|
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, &
|
|
19
|
-
file_name_or_io.is_a?(String) ? file(file_name_or_io, **args, &
|
|
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,
|
|
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
|
-
#
|
|
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,
|
|
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
|
-
#
|
|
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,
|
|
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,
|
|
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
|