iostreams 2.0.0 → 2.1.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -22
  3. data/Rakefile +45 -0
  4. data/docs/CLAUDE.md +9 -0
  5. data/docs/config.md +157 -0
  6. data/docs/copy_files.md +75 -0
  7. data/docs/extensions.md +111 -0
  8. data/docs/formats.md +188 -0
  9. data/docs/index.md +388 -0
  10. data/docs/path.md +652 -0
  11. data/docs/pgp.md +436 -0
  12. data/docs/streams.md +337 -0
  13. data/docs/tutorial.md +483 -0
  14. data/docs/upgrading.md +217 -0
  15. data/lib/io_streams/builder.rb +62 -2
  16. data/lib/io_streams/bzip2/reader.rb +25 -2
  17. data/lib/io_streams/bzip2/writer.rb +26 -2
  18. data/lib/io_streams/encode/reader.rb +4 -0
  19. data/lib/io_streams/encode/writer.rb +4 -0
  20. data/lib/io_streams/errors.rb +4 -0
  21. data/lib/io_streams/gzip/reader.rb +4 -0
  22. data/lib/io_streams/gzip/writer.rb +11 -2
  23. data/lib/io_streams/io_streams.rb +111 -1
  24. data/lib/io_streams/line/reader.rb +7 -2
  25. data/lib/io_streams/path.rb +115 -6
  26. data/lib/io_streams/paths/file.rb +47 -1
  27. data/lib/io_streams/paths/http.rb +45 -4
  28. data/lib/io_streams/paths/s3.rb +66 -15
  29. data/lib/io_streams/paths/sftp/net_ssh.rb +104 -0
  30. data/lib/io_streams/paths/sftp.rb +97 -57
  31. data/lib/io_streams/pgp/reader.rb +42 -2
  32. data/lib/io_streams/pgp/writer.rb +26 -6
  33. data/lib/io_streams/pgp.rb +78 -21
  34. data/lib/io_streams/reader.rb +10 -1
  35. data/lib/io_streams/record/reader.rb +72 -2
  36. data/lib/io_streams/stream.rb +12 -7
  37. data/lib/io_streams/symmetric_encryption/reader.rb +4 -0
  38. data/lib/io_streams/symmetric_encryption/writer.rb +4 -0
  39. data/lib/io_streams/tabular/header.rb +31 -4
  40. data/lib/io_streams/tabular/parser/base.rb +10 -0
  41. data/lib/io_streams/tabular/parser/csv.rb +5 -0
  42. data/lib/io_streams/tabular/parser/fixed.rb +3 -1
  43. data/lib/io_streams/tabular/parser/psv.rb +6 -2
  44. data/lib/io_streams/utils.rb +31 -0
  45. data/lib/io_streams/version.rb +1 -1
  46. data/lib/io_streams/writer.rb +10 -1
  47. data/lib/io_streams/xlsx/reader.rb +5 -1
  48. data/lib/io_streams/zip/reader.rb +4 -0
  49. data/lib/io_streams/zip/writer.rb +4 -0
  50. metadata +24 -7
data/docs/pgp.md ADDED
@@ -0,0 +1,436 @@
1
+ ---
2
+ layout: default
3
+ title: PGP Encryption
4
+ heading: PGP Encrypted Files and Streams
5
+ description: >-
6
+ Reading and writing PGP encrypted files by shelling out to GnuPG, including
7
+ installing gpg, importing and trusting keys, and the failures to expect.
8
+ ---
9
+
10
+ IOStreams encrypts and decrypts PGP data by shelling out to the [GnuPG](https://gnupg.org) command
11
+ line program, so `gpg` must already be installed and on the `PATH` wherever PGP files are read or
12
+ written (see [Installation](#installation) below). As with every other stream, all you do is add a
13
+ `.pgp` extension to the file name and IOStreams handles the rest.
14
+
15
+ GnuPG is required because there is no standard, maintained pure-Ruby PGP library. Calling the `gpg`
16
+ executable directly is the deliberate approach: it is the reference PGP implementation, is widely
17
+ installed, and is kept current with the OpenPGP standard. It is also well suited to the large files
18
+ IOStreams targets, since `gpg` streams the data rather than holding it in memory.
19
+
20
+ IOStreams has been tested against GnuPG v1.4, v2.0.30, v2.2.1, and v2.4.7.
21
+ Because GnuPG is a command line program, IOStreams parses its output to extract information, so each
22
+ time GnuPG changes its output the regular expression parsers may need updating for that version.
23
+
24
+ ## Installation
25
+
26
+ Install [GnuPG](https://gnupg.org)
27
+
28
+ Mac OSX via homebrew
29
+
30
+ brew install gnupg
31
+
32
+ Redhat / CentOS / Fedora Linux
33
+
34
+ dnf install gnupg2
35
+
36
+ Ubuntu / Debian Linux
37
+
38
+ apt-get install gnupg
39
+
40
+ Confirm GnuPG is installed:
41
+
42
+ gpg --version
43
+
44
+ ### Tutorial
45
+
46
+ After installing GnuPG above, install iostreams:
47
+
48
+ Install IOStreams gem:
49
+ ~~~
50
+ gem install iostreams --no-doc
51
+ ~~~
52
+
53
+ Open a ruby interactive console:
54
+
55
+ ~~~
56
+ irb
57
+ ~~~
58
+
59
+ Load iostreams:
60
+
61
+ ~~~ruby
62
+ require "iostreams"
63
+ ~~~
64
+
65
+ Generate a private and public key that we can use as the sample sender of the encrypted files:
66
+ ~~~ruby
67
+ IOStreams::Pgp.generate_key(name: "Sender", email: "sender@example.org", passphrase: "sender_passphrase")
68
+ ~~~
69
+
70
+ Generate a private and public key that we can use as the sample receiver of the encrypted files:
71
+ ~~~ruby
72
+ IOStreams::Pgp.generate_key(name: "Receiver", email: "receiver@example.org", passphrase: "receiver_passphrase")
73
+ ~~~
74
+
75
+ By default the above keys are RSA 4096 bit encryption keys.
76
+
77
+ Reference a file path to hold the PGP encrypted data by adding `.pgp` as a file name extension:
78
+ ~~~ruby
79
+ path = IOStreams.path("sample/example.csv.pgp")
80
+ # => #<IOStreams::Paths::File:sample/example.csv.pgp pipeline={:pgp=>{}}>
81
+ ~~~
82
+
83
+ Add the email address for the recipient:
84
+ ~~~ruby
85
+ path.option(:pgp, recipient: "receiver@example.org")
86
+ # => #<IOStreams::Paths::File:example.csv.pgp @options={:pgp=>{:recipient=>"receiver@example.org"}} pipeline={:pgp=>{:recipient=>"receiver@example.org"}}>
87
+ ~~~
88
+
89
+ Write data to the PGP file:
90
+ ~~~ruby
91
+ path.writer do |io|
92
+ io << "name,login\n"
93
+ io << "Jack Jones,jjones\n"
94
+ io << "Jill Smith,jsmith\n"
95
+ end
96
+ ~~~
97
+
98
+ Lets try to read the file without supplying a passphrase:
99
+ ~~~ruby
100
+ IOStreams.path("sample/example.csv.pgp").read
101
+ # IOStreams::Pgp::Failure
102
+ # gpg: decryption failed: No secret key
103
+ ~~~
104
+
105
+ In order to decrypt the file it needs the passphrase for the receivers private key above:
106
+ ~~~ruby
107
+ path = IOStreams.path("sample/example.csv.pgp")
108
+ path.option(:pgp, passphrase: "sender_passphrase")
109
+ path.read
110
+ # IOStreams::Pgp::Failure
111
+ # "Receiver <receiver@example.org>"
112
+ # gpg: public key decryption failed: Bad passphrase
113
+ # gpg: decryption failed: No secret key
114
+ ~~~
115
+
116
+ It failed again because we tried to use the senders passphrase. Since only the receiver can decrypt this file we
117
+ need to use its passphrase and therefore private key:
118
+ ~~~ruby
119
+ path = IOStreams.path("sample/example.csv.pgp")
120
+ path.option(:pgp, passphrase: "receiver_passphrase")
121
+ path.read
122
+ # => "name,login\nJack Jones,jjones\nJill Smith,jsmith\n"
123
+ ~~~
124
+
125
+ ##### Sign the file
126
+
127
+ To prevent a man-in-the-middle attack we can sign the file so that the recipient knows the file came from the sender:
128
+ ~~~ruby
129
+ path = IOStreams.path("sample/example.csv.pgp")
130
+ path.option(:pgp, recipient: "receiver@example.org", signer: "sender@example.org", signer_passphrase: "sender_passphrase")
131
+ path.writer do |io|
132
+ io << "name,login\n"
133
+ io << "Jack Jones,jjones\n"
134
+ io << "Jill Smith,jsmith\n"
135
+ end
136
+ ~~~
137
+
138
+ Try reading the pgp encrypted file that is now also signed by the sender:
139
+ ~~~ruby
140
+ path = IOStreams.path("sample/example.csv.pgp")
141
+ path.option(:pgp, passphrase: "receiver_passphrase")
142
+ path.read
143
+ # => "name,login\nJack Jones,jjones\nJill Smith,jsmith\n"
144
+ ~~~
145
+
146
+ This time when we read the file the signature is automatically verified. However, this only works if the receiver
147
+ has already imported the senders public key.
148
+
149
+ ##### Sign without encrypting
150
+
151
+ Sometimes the contents do not need to be kept secret, but the recipient still needs to verify that the file came from
152
+ the sender and was not tampered with. Set `encrypt: false` to sign the file without encrypting it. In this mode a
153
+ `signer` is required, and `recipient` / `import_and_trust_key` are ignored:
154
+ ~~~ruby
155
+ path = IOStreams.path("sample/example.csv.pgp")
156
+ path.option(:pgp, encrypt: false, signer: "sender@example.org", signer_passphrase: "sender_passphrase")
157
+ path.writer do |io|
158
+ io << "name,login\n"
159
+ io << "Jack Jones,jjones\n"
160
+ io << "Jill Smith,jsmith\n"
161
+ end
162
+ ~~~
163
+
164
+ Because a signed-only file is not encrypted, no passphrase is needed to read it. The signature is still verified
165
+ automatically when the senders public key has been imported:
166
+ ~~~ruby
167
+ IOStreams.path("sample/example.csv.pgp").read
168
+ # => "name,login\nJack Jones,jjones\nJill Smith,jsmith\n"
169
+ ~~~
170
+
171
+ ##### Auto-sign all files
172
+
173
+ To automatically sign every PGP output file, set the global config options to sign with the sender credentials.
174
+ ~~~ruby
175
+ IOStreams::Pgp::Writer.default_signer = "sender@example.org"
176
+ IOStreams::Pgp::Writer.default_signer_passphrase = "sender_passphrase"
177
+ ~~~
178
+
179
+ Now the file will be signed automatically without needing to supply the `signer` and `signer_passphrase` on every write.
180
+ ~~~ruby
181
+ path = IOStreams.path("sample/example.csv.pgp")
182
+ path.option(:pgp, recipient: "receiver@example.org")
183
+ path.writer do |io|
184
+ io << "name,login\n"
185
+ io << "Jack Jones,jjones\n"
186
+ io << "Jill Smith,jsmith\n"
187
+ end
188
+ ~~~
189
+
190
+ ### Decrypt output files
191
+
192
+ In the examples above the sender cannot decrypt the file because it was generated for the recipient only.
193
+
194
+ ~~~ruby
195
+ path = IOStreams.path("sample/example.csv.pgp")
196
+ path.option(:pgp, passphrase: "sender_passphrase")
197
+ path.read
198
+ # IOStreams::Pgp::Failure
199
+ # "Receiver <receiver@example.org>"
200
+ # gpg: public key decryption failed: Bad passphrase
201
+ # gpg: decryption failed: No secret key
202
+ ~~~
203
+
204
+ Since the sender cannot decrypt the file how do we know what was actually sent to the recipient?
205
+
206
+ There are 2 options:
207
+ * Add the sender to the recipient list every time a file is created.
208
+ * Or, use the global configuration option to add the sender automatically to all PGP output files.
209
+
210
+ Add the sender to the recipient list every time a file is created.
211
+ ~~~ruby
212
+ path = IOStreams.path("sample/example.csv.pgp")
213
+ path.option(:pgp, recipient: ["receiver@example.org", "sender@example.org"])
214
+ path.writer do |io|
215
+ io << "name,login\n"
216
+ io << "Jack Jones,jjones\n"
217
+ io << "Jill Smith,jsmith\n"
218
+ end
219
+ ~~~
220
+
221
+ Now the sender can also read the file:
222
+ ~~~ruby
223
+ path = IOStreams.path("sample/example.csv.pgp")
224
+ path.option(:pgp, passphrase: "sender_passphrase")
225
+ path.read
226
+ # => "name,login\nJack Jones,jjones\nJill Smith,jsmith\n"
227
+ ~~~
228
+
229
+ Set the global configuration option to add the sender automatically to all PGP output files.
230
+ ~~~ruby
231
+ IOStreams::Pgp::Writer.audit_recipient = "sender@example.org"
232
+ ~~~
233
+
234
+ The sender is now automatically added to the recipient list every time a file is created.
235
+ ~~~ruby
236
+ path = IOStreams.path("sample/example.csv.pgp")
237
+ path.option(:pgp, recipient: "receiver@example.org")
238
+ path.writer do |io|
239
+ io << "name,login\n"
240
+ io << "Jack Jones,jjones\n"
241
+ io << "Jill Smith,jsmith\n"
242
+ end
243
+ ~~~
244
+
245
+ Now the sender can also read the file:
246
+ ~~~ruby
247
+ path = IOStreams.path("sample/example.csv.pgp")
248
+ path.option(:pgp, passphrase: "sender_passphrase")
249
+ path.read
250
+ # => "name,login\nJack Jones,jjones\nJill Smith,jsmith\n"
251
+ ~~~
252
+
253
+ By now you must be getting tired of typing in the senders passphrase, so lets set the config option to remove that requirement:
254
+
255
+ ~~~ruby
256
+ IOStreams::Pgp::Reader.default_passphrase = "sender_passphrase"
257
+ ~~~
258
+
259
+ Try reading the above file again:
260
+ ~~~ruby
261
+ IOStreams.path("sample/example.csv.pgp").read
262
+ # => "name,login\nJack Jones,jjones\nJill Smith,jsmith\n"
263
+ ~~~
264
+
265
+ ## Cleanup
266
+
267
+ To remove the test PGP keys created above:
268
+ ~~~ruby
269
+ IOStreams::Pgp.delete_keys(email: 'sender@example.org', private: true)
270
+ IOStreams::Pgp.delete_keys(email: 'receiver@example.org', private: true)
271
+ ~~~
272
+
273
+ ### import_and_trust_key
274
+
275
+ The standard approach with PGP is to import public keys into the keystore on every server. Copying the public keys to
276
+ every server can be tedious, especially when running inside docker containers.
277
+
278
+ An ideal way to manage PGP public keys is in a database, or in Secret Config that stores the keys in the
279
+ AWS SSM Parameter store.
280
+
281
+ Fetch the recipients public key from a data store. For example Secret Config
282
+ ~~~ruby
283
+ public_pgp_key = SecretConfig.fetch("suppliers/acxiom/pgp/public_key")
284
+ ~~~
285
+
286
+ When creating the pgp encrypted file let IOStreams import and trust the public key every time so that it does
287
+ not have to be managed on every production server. The option `import_and_trust_key` takes the public key as a string:
288
+ ~~~ruby
289
+ path = IOStreams.join("test/sample.pgp", root: :downloads)
290
+ path.option(:pgp, import_and_trust_key: public_pgp_key)
291
+ path.write("Hello World")
292
+ ​~~~
293
+
294
+ Now try to read the file:
295
+ ~~~ruby
296
+ IOStreams.join("test/sample.pgp", root: :downloads).read
297
+ ~~~
298
+
299
+ #### Trust level
300
+
301
+ The key is imported and then marked as trusted so that GPG will encrypt to it without prompting.
302
+ The trust level can be controlled with the `import_and_trust_level` option:
303
+
304
+ ~~~ruby
305
+ path = IOStreams.join("test/sample.pgp", root: :downloads)
306
+ path.option(:pgp, import_and_trust_key: public_pgp_key, import_and_trust_level: 4)
307
+ path.write("Hello World")
308
+ ~~~
309
+
310
+ Or by calling `IOStreams::Pgp.import_and_trust` directly with the `trust_level` argument:
311
+
312
+ ~~~ruby
313
+ IOStreams::Pgp.import_and_trust(key: public_pgp_key, trust_level: 4)
314
+ ~~~
315
+
316
+ The available levels are the same as those used by `IOStreams::Pgp.set_trust`:
317
+
318
+ | Level | Meaning |
319
+ |:------|:-------------------------|
320
+ | 1 | Undefined (no opinion) |
321
+ | 2 | Never (do not trust) |
322
+ | 3 | Marginal |
323
+ | 4 | Full |
324
+ | 5 | Ultimate (default) |
325
+
326
+ > **Security warning**
327
+ >
328
+ > Only import and trust keys that were received from a verified, trusted source.
329
+ >
330
+ > The default trust level is `5` (Ultimate), which tells GPG to treat the imported key as
331
+ > if it were one of your own keys: it becomes implicitly valid and can in turn confer
332
+ > validity on other keys that it has signed. Importing an attacker supplied key at this
333
+ > level allows that attacker to impersonate other recipients. When a key cannot be fully
334
+ > verified, supply a lower `trust_level`.
335
+
336
+ #### Compression:
337
+
338
+ The compression used by pgp can be specified by suppling the `:compress` option.
339
+
340
+ The valid values for this option: `:none`, `:zip`, `:zlib`, or `:bzip2`.
341
+
342
+ The default compression is `:zip` for which has the highest compatibility.
343
+
344
+ Most PGP tools now support `:zlib` and is the recommended compression to use when possible.
345
+
346
+ ~~~ruby
347
+ path = IOStreams.path("sample/example.csv.pgp")
348
+ path.option(:pgp, recipient: "receiver@example.org", compress: :zlib)
349
+ path.write("Hello World")
350
+ ~~~
351
+
352
+ The compression level can be adjusted with the `:compress_level` option, where `1` is the
353
+ fastest and `9` compresses the most:
354
+
355
+ ~~~ruby
356
+ path = IOStreams.path("sample/example.csv.pgp")
357
+ path.option(:pgp, recipient: "receiver@example.org", compress: :zlib, compress_level: 9)
358
+ path.write("Hello World")
359
+ ~~~
360
+
361
+ Default: `6`
362
+
363
+ Compression Performance
364
+ * Running tests on an Early 2015 Macbook Pro Dual Core with Ruby v2.3.1
365
+ ~~~
366
+ Input text file: test.log 3.6GB
367
+ :none: size: 3.6GB write: 52s read: 45s
368
+ :zip: size: 411MB write: 75s read: 31s
369
+ :zlib: size: 241MB write: 66s read: 23s ( 756KB Memory )
370
+ :bzip2: size: 129MB write: 430s read: 130s ( 5MB Memory )
371
+ ~~~
372
+
373
+ ### Verifying a file before processing it
374
+
375
+ gpg can only check a file's integrity (MDC) and its signature once it has read the whole file.
376
+ By default IOStreams passes the decrypted contents to your block as gpg decrypts them, so that
377
+ files of any size can be processed without storing the decrypted contents. When a check fails,
378
+ `IOStreams::Pgp::Failure` is raised after the block has already processed the contents.
379
+
380
+ For example, with a signed file whose contents were changed after it was signed, every record is
381
+ processed before the `BAD signature` failure is raised:
382
+
383
+ ~~~ruby
384
+ IOStreams.path("sample/example.csv.pgp").each(:hash) do |row|
385
+ # Receives every row, including the changed ones.
386
+ end
387
+ # IOStreams::Pgp::Failure: ... gpg: BAD signature from "Sender <sender@example.org>"
388
+ ~~~
389
+
390
+ Either do not commit any side effects until the block returns without raising, for example
391
+ by processing the file within a database transaction, or supply the `verify_first` option:
392
+
393
+ ~~~ruby
394
+ path = IOStreams.path("sample/example.csv.pgp")
395
+ path.option(:pgp, passphrase: "receiver_passphrase", verify_first: true)
396
+ path.each(:hash) do |row|
397
+ # Only called once gpg has checked the whole file.
398
+ end
399
+ ~~~
400
+
401
+ With `verify_first`, the whole file is first decrypted into a temporary file that only the current
402
+ user can read. The block is only called once gpg has successfully checked the file, and the
403
+ temporary file is deleted afterwards. This requires local disk space for the decrypted contents,
404
+ and an extra pass over the data.
405
+
406
+ ### Reading legacy files without MDC integrity protection
407
+
408
+ Modern GnuPG refuses to decrypt files that lack MDC (Modification Detection Code) integrity
409
+ protection, failing with `gpg: decryption forced to fail!`. Some legacy or enterprise systems
410
+ still produce such files. To read them, supply the `ignore_mdc_error` option:
411
+
412
+ ~~~ruby
413
+ path = IOStreams.path("sample/example.csv.pgp")
414
+ path.option(:pgp, passphrase: "receiver_passphrase", ignore_mdc_error: true)
415
+ path.read
416
+ ~~~
417
+
418
+ > **Security warning**
419
+ >
420
+ > Only enable `ignore_mdc_error` for files from a trusted source: without MDC the decrypted
421
+ > contents are not protected against tampering.
422
+
423
+ Note: IOStreams never writes files without MDC, this option only applies when reading.
424
+
425
+ ### PGP FAQ:
426
+
427
+ If you get not trusted errors
428
+
429
+ `gpg --edit-key sender@example.org`
430
+
431
+ Select highest level: 5
432
+
433
+ ### PGP Limitations
434
+
435
+ * Designed for processing larger files since a process is spawned for each file processed.
436
+ * For lots of small, in memory files, use the [gpgme](https://github.com/ueno/ruby-gpgme) library. For example to attach pgp files to emails.