block_cipher_kit 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +37 -0
  3. data/.gitignore +57 -0
  4. data/.standard.yml +1 -0
  5. data/Gemfile +3 -0
  6. data/README.md +136 -0
  7. data/Rakefile +18 -0
  8. data/block_cipher_kit.gemspec +37 -0
  9. data/lib/block_cipher_kit/aes_256_cbc_scheme.rb +56 -0
  10. data/lib/block_cipher_kit/aes_256_cfb_civ_scheme.rb +35 -0
  11. data/lib/block_cipher_kit/aes_256_cfb_scheme.rb +37 -0
  12. data/lib/block_cipher_kit/aes_256_ctr_scheme.rb +87 -0
  13. data/lib/block_cipher_kit/aes_256_gcm_scheme.rb +108 -0
  14. data/lib/block_cipher_kit/base_scheme.rb +43 -0
  15. data/lib/block_cipher_kit/block_writable.rb +20 -0
  16. data/lib/block_cipher_kit/cipher_io.rb +16 -0
  17. data/lib/block_cipher_kit/io_lens.rb +46 -0
  18. data/lib/block_cipher_kit/key_material.rb +16 -0
  19. data/lib/block_cipher_kit/passthru_scheme.rb +26 -0
  20. data/lib/block_cipher_kit/version.rb +3 -0
  21. data/lib/block_cipher_kit.rb +32 -0
  22. data/test/cipher_io_test.rb +18 -0
  23. data/test/io_lens_test.rb +30 -0
  24. data/test/key_material_test.rb +12 -0
  25. data/test/known_ciphertexts/AES256CBCScheme.ciphertext.bin +0 -0
  26. data/test/known_ciphertexts/AES256CFBCIVScheme.ciphertext.bin +0 -0
  27. data/test/known_ciphertexts/AES256CFBScheme.ciphertext.bin +0 -0
  28. data/test/known_ciphertexts/AES256CTRScheme.ciphertext.bin +0 -0
  29. data/test/known_ciphertexts/AES256GCMScheme.ciphertext.bin +0 -0
  30. data/test/known_ciphertexts/PassthruScheme.ciphertext.bin +0 -0
  31. data/test/known_ciphertexts/known_plain.bin +0 -0
  32. data/test/schemes_test.rb +221 -0
  33. data/test/test_helper.rb +6 -0
  34. data/test/test_known_ciphertext.rb +88 -0
  35. metadata +162 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5ffa6157fd34839dbc9c8af5cd121b647974d3c869ce61fb50f5d3027f8fb3c2
4
+ data.tar.gz: 8c14d6643ef3635d8e5ce8a66cbb16b30748b5e205989aacb3169842405c59f2
5
+ SHA512:
6
+ metadata.gz: b1c94c2b391c0bfe8f51ee94fd79563dbac77de43db7617eee679cca9adff6821a4e840152a091fe41bfdde1257875e7ed9d1d20bb77bcb5c19e888b1127478f
7
+ data.tar.gz: 281cfb7fc70c84629fb5a15226fc560053ba183dd0b3ad75d8b819eded71b61f534541a5aa486e794d6d73a1d9cddc2affaea3147406ae86f23673418c7d4e6c
@@ -0,0 +1,37 @@
1
+ name: CI
2
+
3
+ on:
4
+ - push
5
+
6
+ env:
7
+ BUNDLE_PATH: vendor/bundle
8
+
9
+ jobs:
10
+ test_and_lint_minimum_ruby:
11
+ name: Tests/Lint (2.7)
12
+ runs-on: ubuntu-22.04
13
+ steps:
14
+ - name: Checkout
15
+ uses: actions/checkout@v4
16
+ - name: Setup Ruby
17
+ uses: ruby/setup-ruby@v1
18
+ with:
19
+ ruby-version: 2.7
20
+ bundler-cache: true
21
+ - name: "Tests"
22
+ run: bundle exec rake test --backtrace
23
+ - name: "Lint"
24
+ run: bundle exec rake standard
25
+ test_recent_ruby:
26
+ name: Tests (3.4)
27
+ runs-on: ubuntu-22.04
28
+ steps:
29
+ - name: Checkout
30
+ uses: actions/checkout@v4
31
+ - name: Setup Ruby
32
+ uses: ruby/setup-ruby@v1
33
+ with:
34
+ ruby-version: 3.4
35
+ bundler-cache: true
36
+ - name: "Tests"
37
+ run: bundle exec rake test --backtrace
data/.gitignore ADDED
@@ -0,0 +1,57 @@
1
+ # rcov generated
2
+ coverage
3
+ coverage.data
4
+
5
+ # rdoc generated
6
+ rdoc
7
+
8
+ # yard generated
9
+ doc
10
+ .yardoc
11
+
12
+ # Rubocop
13
+ rubocop.html
14
+
15
+ # bundler
16
+ .bundle
17
+ Gemfile.lock
18
+
19
+ # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore:
20
+ #
21
+ # * Create a file at ~/.gitignore
22
+ # * Include files you want ignored
23
+ # * Run: git config --global core.excludesfile ~/.gitignore
24
+ #
25
+ # After doing this, these files will be ignored in all your git projects,
26
+ # saving you from having to 'pollute' every project you touch with them
27
+ #
28
+ # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line)
29
+ #
30
+ # For MacOS:
31
+ #
32
+ .DS_Store
33
+
34
+ tmp
35
+ .ruby-version
36
+
37
+ # For TextMate
38
+ #*.tmproj
39
+ #tmtags
40
+
41
+ # For emacs:
42
+ #*~
43
+ #\#*
44
+ #.\#*
45
+
46
+ # For vim:
47
+ #*.swp
48
+
49
+ # For redcar:
50
+ #.redcar
51
+
52
+ # For rubinius:
53
+ #*.rbc
54
+
55
+ # Rubocop report
56
+ rubocop.html
57
+
data/.standard.yml ADDED
@@ -0,0 +1 @@
1
+ ruby_version: 2.7
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
data/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # block_cipher_kit
2
+
3
+ Is a small shim on top of a few block ciphers. It is useful for encrypting and decrypting data stored in files, or accessible via IOs. The main addition from using "bare" ciphers is the addition of random access reads where it can be realised.
4
+
5
+ The following constructions are currently implemented:
6
+
7
+ * AES-256-CBC (limited random read access, requires reading to end of source)
8
+ * AES-256-CFB (limited random read access, requires reading to start offset)
9
+ * AES-256-CFB-CIV - CIV for "concatenated IV", The IV is provided together with the encryption key (assumes unique key per message (limited random read access, requires reading to start offset) -
10
+ * AES-256-CTR (with random read access)
11
+ * AES-256-GCM (with random read access via CTR, random read access does not validate)
12
+
13
+ Most likely ChaCha20 cam be added fairly easily.
14
+
15
+ The gem provides a number of **schemes** which are known, mostly correct ways to use a particular block cipher. You can use those schemes to do block encryption and decryption.
16
+
17
+ ## What is a "scheme"?
18
+
19
+ A scheme is a crypto **construction** - a particular way to use a particular block cipher. In this gem, the schemes are guaranteed not to change between releases. Once a scheme is part of the gem, you will be able to use that scheme to read data you have encrypted using that scheme. Most of the **schemes** provided by the gem are constructed from standard AES block ciphers, used in a standard, transparent manner.
20
+
21
+ The following rules hold true for any given Scheme:
22
+
23
+ * Ciphertext output for known plaintext, randomness source and encryption key of every scheme will come out exactly the same (a scheme encrypts deterministically).
24
+ * Plaintext output for known ciphertext and encryption key of every scheme will come out exactly the same (a scheme decrypts deterministically).
25
+ * The scheme's output will stay exactly the same throughout the versioning of the gem, provided the underlying cipher (OpenSSL or other) is available on the host system.
26
+
27
+ Schemes are versioned. We give a guarantee they are not going to change once this gem is at version `1.0.0` or higher, every scheme becomes "frozen" once it is published with the gem.
28
+
29
+ ## Interop
30
+
31
+ Data written by the schemes is compatible with the "bare" uses of the ciphers, layouts are as follows:
32
+
33
+ * AES-256-CBC - Layout is `[ IV - 16 bytes) ][ Ciphertext in 16 byte blocks, no padding ]`
34
+ * AES-256-CFB - Layout is `[ IV - 16 bytes) ][ Ciphertext in 16 byte blocks ]`
35
+ * AES-256-CTR - Layout is `[ nonce - 4 bytes][ IV - 8 bytes ][ Ciphertext in 16 byte blocks ]`
36
+ * AES-256-GCM - Layout is `[ nonce - 4 bytes][ IV - 8 bytes ][ Ciphertext in 16 byte blocks ][ Validation tag - 16 bytes ]`
37
+ * AES-256-CFB-CIV - Layout is `[ Ciphertext in 16 byte blocks ]`. The `encryption_key` must be `[ IV - 16 bytes][ key - 32 bytes]` (IV is not stored with ciphertext)
38
+
39
+ You should thus be able to build either a decryptor that outputs in a format compatible with a given Scheme, or an encryptor that produces data valid for that Scheme,
40
+ in any language that provides standard AES cipher constructions.
41
+
42
+ ## Which scheme to use?
43
+
44
+ Please do some research as the topic is vast. GCM is quite good, I found CBC to be good for files as well. Be aware that both GCM and CTR have a limit of about 64GB of ciphertext before the block counter rolls over.
45
+
46
+ ## Basic streaming use
47
+
48
+ Imagine you want to encrypt some data in a streaming manner with AES-256-CTR, and data is stored in files:
49
+
50
+ ```ruby
51
+ File.open(plain_file_path, "rb", "rb") do |from|
52
+ File.open(plain_file_path + ".enc", "wb") do |into|
53
+ scheme = BlockCipherKit::AES256CTRScheme.new(encryption_key)
54
+ scheme.streaming_encrypt(from_plaintext_io: from, into_ciphertext_io: into)
55
+ end
56
+ end
57
+ ```
58
+
59
+ To decrypt the same file
60
+
61
+ ```ruby
62
+ File.open(encrypted_file_path, "rb") do |from|
63
+ File.open(encrypted_file_path + ".plain", "wb") do |into|
64
+ scheme = BlockCipherKit::AES256CTRScheme.new(encryption_key)
65
+ scheme.streaming_decrypt(from_ciphertext_io: from, into_plaintext_io: into)
66
+ end
67
+ end
68
+ ```
69
+
70
+ Note that in both of these cases:
71
+
72
+ * Only `read` will be called on the source IO (`from_ciphertext_io` and `from_plaintext_io`). They do not need to support `pos`, `seek` or `rewind`.
73
+ * Only `write` will be called on the destination IO (`to_ciphertext_io` and `to_plaintext_io`). They do not need to support `pos`, `seek` or `rewind`.
74
+
75
+ ## Streaming encryption / decryption "head to tail" with blocks
76
+
77
+ To use streaming encryption, writing the plaintext the data as you go:
78
+
79
+ ```
80
+ File.open(plain_file_path + ".enc", "wb") do |into|
81
+ scheme = BlockCipherKit::AES256CTRScheme.new(encryption_key)
82
+ scheme.streaming_encrypt(into_ciphertext_io: into) do |sink|
83
+ sink.write("This is some very secret data")
84
+ sink.write("Very secret indeed")
85
+ end
86
+ end
87
+ ```
88
+
89
+ The `sink` will be an object that responds to `write` (it can also be used with `IO.copy_stream`).
90
+
91
+ To use streaming decryption, reading the plaintext data as you go:
92
+
93
+ ```ruby
94
+ File.open(encrypted_file_path, "rb") do |from|
95
+ scheme = BlockCipherKit::AES256CTRScheme.new(encryption_key)
96
+ scheme.streaming_encrypt(from_ciphertext_io: from) do |decrypted_chunk_of_plaintext|
97
+ $stdout.puts "Decrypted: #{decrypted_chunk_of_plaintext.inspect}"
98
+ end
99
+ end
100
+ ```
101
+
102
+ ## Random access reads
103
+
104
+ For random access, you can either recover a String in binary encoding:
105
+
106
+ ```ruby
107
+ File.open(encrypted_file_path, "rb") do |from|
108
+ scheme = BlockCipherKit::AES256CTRScheme.new(encryption_key)
109
+ scheme.decrypt_range(from_ciphertext_io: from, range: 15..16) #=> "ab"
110
+ end
111
+ ```
112
+
113
+ or pass an IO to receive the decrypted data:
114
+
115
+ ```ruby
116
+ File.open(encrypted_file_path, "rb") do |from|
117
+ scheme = BlockCipherKit::AES256CTRScheme.new(encryption_key)
118
+ scheme.streaming_decrypt_range(from_ciphertext_io: from, range: 15..16, into_plaintext_io: $stdout) #=> "ab" gets printed to STDOUT
119
+ end
120
+ ```
121
+
122
+ or a block (will be called for every meaningful chunk of decrypted data, repeatedly):
123
+
124
+ ```ruby
125
+ File.open(encrypted_file_path, "rb") do |from|
126
+ scheme = BlockCipherKit::AES256CTRScheme.new(encryption_key)
127
+ scheme.streaming_decrypt_range(from_ciphertext_io: from, range: 15..) do |decrypted_chunk_of_plaintext|
128
+ $stderr.puts "Decrypted #{decrypted_chunk_of_plaintext.inspect}"
129
+ end
130
+ end
131
+ ```
132
+
133
+ For both `streaming_decrypt_range` and `decrypt_range`:
134
+
135
+ * The source IO (`from_ciphertext_io`) **must** support `pos`, `size`, `seek` and `read`
136
+ * The destination IO must only support `write`
data/Rakefile ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+ require "standard/rake"
6
+
7
+ Rake::TestTask.new(:test) do |t|
8
+ t.libs << "test"
9
+ t.libs << "lib"
10
+ t.test_files = FileList["test/**/*_test.rb"]
11
+ end
12
+
13
+ task :format do
14
+ `bundle exec standardrb --fix`
15
+ `bundle exec magic_frozen_string_literal .`
16
+ end
17
+
18
+ task default: [:test, :standard]
@@ -0,0 +1,37 @@
1
+ lib = File.expand_path("../lib", __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "block_cipher_kit/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "block_cipher_kit"
7
+ spec.version = BlockCipherKit::VERSION
8
+ spec.authors = ["Julik Tarkhanov", "Sebastian van Hesteren"]
9
+ spec.email = ["me@julik.nl"]
10
+ spec.license = "MIT"
11
+ spec.summary = "A thin toolkit for working with block cipher encryption."
12
+ spec.description = "A thin toolkit for working with block cipher encryption."
13
+ spec.homepage = "https://github.com/julik/block_cipher_kit"
14
+
15
+ spec.required_ruby_version = ">= 2.7.0"
16
+
17
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
+ spec.files = `git ls-files -z`.split("\x0")
19
+ spec.bindir = "exe"
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ["lib"]
22
+
23
+ # Do not depend on openssl explicitly, we have a warning in the code for this
24
+ # spec.add_dependency "openssl"
25
+
26
+ spec.add_development_dependency "minitest"
27
+ spec.add_development_dependency "rake"
28
+ spec.add_development_dependency "magic_frozen_string_literal"
29
+ spec.add_development_dependency "standard", "1.28.5" # Needed for 2.6
30
+ spec.add_development_dependency "yard"
31
+ # redcarpet is needed for the yard gem to enable Github Flavored Markdown
32
+ spec.add_development_dependency "redcarpet"
33
+
34
+ # Sord and sorbet-runtime are somewhat Ruby version dependent so wait with this
35
+ # until we have everything YARD-documented
36
+ # spec.add_development_dependency "sord"
37
+ end
@@ -0,0 +1,56 @@
1
+ require "securerandom"
2
+
3
+ class BlockCipherKit::AES256CBCScheme < BlockCipherKit::BaseScheme
4
+ IV_LENGTH = 16
5
+
6
+ def initialize(encryption_key, iv_generator: SecureRandom)
7
+ raise ArgumentError, "#{required_encryption_key_length} bytes of key material needed, at the minimum" unless encryption_key.bytesize >= required_encryption_key_length
8
+ @iv_generator = iv_generator
9
+ @key = BlockCipherKit::KeyMaterial.new(encryption_key.byteslice(0, 32))
10
+ end
11
+
12
+ def required_encryption_key_length
13
+ 32
14
+ end
15
+
16
+ def streaming_decrypt(from_ciphertext_io:, into_plaintext_io: nil, &blk)
17
+ cipher = OpenSSL::Cipher.new("aes-256-cbc")
18
+ cipher.decrypt
19
+ cipher.iv = from_ciphertext_io.read(IV_LENGTH)
20
+ cipher.key = @key
21
+ read_copy_stream_via_cipher(source_io: from_ciphertext_io, cipher: cipher, destination_io: into_plaintext_io, &blk)
22
+ end
23
+
24
+ def streaming_encrypt(into_ciphertext_io:, from_plaintext_io: nil, &blk)
25
+ random_iv = @iv_generator.bytes(IV_LENGTH)
26
+ cipher = OpenSSL::Cipher.new("aes-256-cbc")
27
+ cipher.encrypt
28
+ cipher.iv = random_iv
29
+ cipher.key = @key
30
+ into_ciphertext_io.write(random_iv)
31
+ write_copy_stream_via_cipher(source_io: from_plaintext_io, cipher: cipher, destination_io: into_ciphertext_io, &blk)
32
+ end
33
+
34
+ def streaming_decrypt_range(from_ciphertext_io:, range:, into_plaintext_io: nil, &blk)
35
+ block_size = 16
36
+ n_bytes_to_decrypt = range.end - range.begin + 1
37
+ n_blocks_to_skip, offset_into_first_block = range.begin.divmod(block_size)
38
+
39
+ cipher = OpenSSL::Cipher.new("aes-256-cbc")
40
+ cipher.decrypt
41
+ cipher.key = @key
42
+
43
+ # We need to read the IV either from the start of the IO (the initial IV)
44
+ # or from the block preceding the first block we need to decrypt
45
+ from_ciphertext_io.seek(from_ciphertext_io.pos + (n_blocks_to_skip * block_size))
46
+ cipher.iv = from_ciphertext_io.read(IV_LENGTH)
47
+
48
+ writable = BlockCipherKit::BlockWritable.new(into_plaintext_io, &blk)
49
+ lens_range = offset_into_first_block...(offset_into_first_block + n_bytes_to_decrypt)
50
+ lens = BlockCipherKit::IOLens.new(writable, lens_range)
51
+
52
+ # TODO: it seems that if we read only the blocks we touch, we need to call cipher.final to get all the output - the cipher buffers,
53
+ # but if we call .final without having read the entire ciphertext the cipher will barf. This needs to be fixed as it is certainly possible with CBC.
54
+ read_copy_stream_via_cipher(source_io: from_ciphertext_io, destination_io: lens, cipher: cipher, finalize_cipher: true, read_limit: from_ciphertext_io.size - from_ciphertext_io.pos)
55
+ end
56
+ end
@@ -0,0 +1,35 @@
1
+ require "tempfile"
2
+
3
+ class BlockCipherKit::AES256CFBCIVScheme < BlockCipherKit::BaseScheme
4
+ def initialize(encryption_key, **)
5
+ raise ArgumentError, "#{required_encryption_key_length} bytes of key material needed, at the minimum" unless encryption_key.bytesize >= required_encryption_key_length
6
+ @iv = BlockCipherKit::KeyMaterial.new(encryption_key.byteslice(0, 16))
7
+ @key = BlockCipherKit::KeyMaterial.new(encryption_key.byteslice(16, 32))
8
+ end
9
+
10
+ def required_encryption_key_length
11
+ 48
12
+ end
13
+
14
+ def streaming_decrypt(from_ciphertext_io:, into_plaintext_io: nil, &blk)
15
+ cipher = OpenSSL::Cipher.new("aes-256-cfb")
16
+ cipher.decrypt
17
+ cipher.iv = @iv
18
+ cipher.key = @key
19
+ read_copy_stream_via_cipher(source_io: from_ciphertext_io, cipher: cipher, destination_io: into_plaintext_io, &blk)
20
+ end
21
+
22
+ def streaming_encrypt(into_ciphertext_io:, from_plaintext_io: nil, &blk)
23
+ cipher = OpenSSL::Cipher.new("aes-256-cfb")
24
+ cipher.encrypt
25
+ cipher.iv = @iv
26
+ cipher.key = @key
27
+ write_copy_stream_via_cipher(source_io: from_plaintext_io, cipher: cipher, destination_io: into_ciphertext_io, &blk)
28
+ end
29
+
30
+ def streaming_decrypt_range(from_ciphertext_io:, range:, into_plaintext_io: nil, &blk)
31
+ writable = BlockCipherKit::BlockWritable.new(into_plaintext_io, &blk)
32
+ lens = BlockCipherKit::IOLens.new(writable, range)
33
+ streaming_decrypt(from_ciphertext_io: from_ciphertext_io, into_plaintext_io: lens)
34
+ end
35
+ end
@@ -0,0 +1,37 @@
1
+ class BlockCipherKit::AES256CFBScheme < BlockCipherKit::BaseScheme
2
+ IV_LENGTH = 16
3
+
4
+ def initialize(encryption_key, iv_generator: SecureRandom)
5
+ raise ArgumentError, "#{required_encryption_key_length} bytes of key material needed, at the minimum" unless encryption_key.bytesize >= required_encryption_key_length
6
+ @iv_generator = iv_generator
7
+ @key = BlockCipherKit::KeyMaterial.new(encryption_key.byteslice(0, 32))
8
+ end
9
+
10
+ def required_encryption_key_length
11
+ 32
12
+ end
13
+
14
+ def streaming_decrypt(from_ciphertext_io:, into_plaintext_io: nil, &blk)
15
+ cipher = OpenSSL::Cipher.new("aes-256-cfb")
16
+ cipher.decrypt
17
+ cipher.iv = from_ciphertext_io.read(IV_LENGTH)
18
+ cipher.key = @key
19
+ read_copy_stream_via_cipher(source_io: from_ciphertext_io, cipher: cipher, destination_io: into_plaintext_io, &blk)
20
+ end
21
+
22
+ def streaming_encrypt(into_ciphertext_io:, from_plaintext_io: nil, &blk)
23
+ iv = @iv_generator.bytes(16)
24
+ cipher = OpenSSL::Cipher.new("aes-256-cfb")
25
+ cipher.encrypt
26
+ cipher.iv = iv
27
+ cipher.key = @key
28
+ into_ciphertext_io.write(iv)
29
+ write_copy_stream_via_cipher(source_io: from_plaintext_io, cipher: cipher, destination_io: into_ciphertext_io, &blk)
30
+ end
31
+
32
+ def streaming_decrypt_range(from_ciphertext_io:, range:, into_plaintext_io: nil, &blk)
33
+ writable = BlockCipherKit::BlockWritable.new(into_plaintext_io, &blk)
34
+ lens = BlockCipherKit::IOLens.new(writable, range)
35
+ streaming_decrypt(from_ciphertext_io: from_ciphertext_io, into_plaintext_io: lens)
36
+ end
37
+ end
@@ -0,0 +1,87 @@
1
+ class BlockCipherKit::AES256CTRScheme < BlockCipherKit::BaseScheme
2
+ NONCE_LENGTH_BYTES = 4
3
+ IV_LENGTH_BYTES = 8
4
+
5
+ def initialize(encryption_key, iv_generator: SecureRandom)
6
+ raise ArgumentError, "#{required_encryption_key_length} bytes of key material needed, at the minimum" unless encryption_key.bytesize >= required_encryption_key_length
7
+ @iv_generator = iv_generator
8
+ @key = BlockCipherKit::KeyMaterial.new(encryption_key.byteslice(0, 32))
9
+ end
10
+
11
+ def required_encryption_key_length
12
+ 32
13
+ end
14
+
15
+ def streaming_encrypt(into_ciphertext_io:, from_plaintext_io: nil, &blk)
16
+ nonce_and_iv = @iv_generator.bytes(NONCE_LENGTH_BYTES + IV_LENGTH_BYTES)
17
+ into_ciphertext_io.write(nonce_and_iv)
18
+
19
+ cipher = OpenSSL::Cipher.new("aes-256-ctr")
20
+ cipher.encrypt
21
+ cipher.iv = ctr_iv(nonce_and_iv, _for_block_n = 0)
22
+ cipher.key = @key
23
+ write_copy_stream_via_cipher(source_io: from_plaintext_io, cipher: cipher, destination_io: into_ciphertext_io, &blk)
24
+ end
25
+
26
+ def streaming_decrypt(from_ciphertext_io:, into_plaintext_io: nil, &blk)
27
+ nonce_and_iv = from_ciphertext_io.read(NONCE_LENGTH_BYTES + IV_LENGTH_BYTES)
28
+
29
+ cipher = OpenSSL::Cipher.new("aes-256-ctr")
30
+ cipher.decrypt
31
+ cipher.iv = ctr_iv(nonce_and_iv, _for_block_n = 0)
32
+ cipher.key = @key
33
+ read_copy_stream_via_cipher(source_io: from_ciphertext_io, cipher: cipher, destination_io: into_plaintext_io, &blk)
34
+ end
35
+
36
+ def streaming_decrypt_range(from_ciphertext_io:, range:, into_plaintext_io: nil, &blk)
37
+ block_size = 16
38
+ n_bytes_to_read = range.end - range.begin + 1
39
+ n_blocks_to_skip, offset_into_first_block = range.begin.divmod(block_size)
40
+
41
+ nonce_and_iv = from_ciphertext_io.read(NONCE_LENGTH_BYTES + IV_LENGTH_BYTES)
42
+ ciphertext_starts_at = from_ciphertext_io.pos
43
+
44
+ cipher = OpenSSL::Cipher.new("aes-256-ctr")
45
+ cipher.decrypt
46
+ cipher.key = @key
47
+ cipher.iv = ctr_iv(nonce_and_iv, n_blocks_to_skip) # Set the counter for the first block we will be reading
48
+
49
+ lens_range = offset_into_first_block...(offset_into_first_block + n_bytes_to_read)
50
+ writable = BlockCipherKit::BlockWritable.new(into_plaintext_io, &blk)
51
+ lens = BlockCipherKit::IOLens.new(writable, lens_range)
52
+
53
+ # With CTR we do not need to read until the end of ciphertext as the cipher does not validate
54
+ from_ciphertext_io.seek(ciphertext_starts_at + (n_blocks_to_skip * block_size))
55
+ n_blocks_to_read = (n_bytes_to_read.to_f / block_size).ceil + 1
56
+ read_copy_stream_via_cipher(source_io: from_ciphertext_io, destination_io: lens, cipher: cipher, read_limit: n_blocks_to_read * block_size)
57
+ end
58
+
59
+ def ctr_iv(nonce_and_iv, for_block_n)
60
+ # The IV is the counter block
61
+ # see spec https://datatracker.ietf.org/doc/html/rfc3686#section-4
62
+ # It consists of:
63
+ # * a nonce (which should be the same across all blocks) - 4 bytes,
64
+ # * a chunk of the initial IV bytes - this is used as the actual IV - 8 bytes
65
+ # * and the counter, encoded as a big endian uint - 4 bytes
66
+ #
67
+ # So, while the OpenSSL Cipher reports iv_len to be 16 bytes, it is lying -
68
+ # even if it uses the IV to split it into a nonce + iv part, 4 bytes will be...zeroed?
69
+ # ignored? something else?
70
+ # Either way: for the nonce we can consume a part of our initial IV, for the block IV
71
+ # we can consume the rest of the initial IV, and the last 4 bytes will be the counter.
72
+ # The rest of the state will be maintained by the Cipher, luckily.
73
+ # nonce = iv_initial.byteslice(0, 4)
74
+ # iv_part = iv_initial.byteslice(3, 8)
75
+ #
76
+ # Also... the counter resets once we got more than 0xFFFFFFFF blocks?
77
+ # It seems in its infinite wisdom the library we are using (whichever) will do
78
+ # whatever the system integer overflow does?..
79
+ # https://stackoverflow.com/questions/66790768/aes256-ctr-mode-behavior-on-counter-overflow-rollover
80
+ # https://crypto.stackexchange.com/a/71210
81
+ # https://crypto.stackexchange.com/a/71196
82
+ # But for the counter to overflow we would need our input to be more than 68719476720 bytes.
83
+ # That is just short of 64 gigabytes (!). Maybe we need a backstop for that. Or maybe we don't.
84
+ raise ArgumentError unless nonce_and_iv.bytesize == 12
85
+ nonce_and_iv.b + [for_block_n + 1].pack("N")
86
+ end
87
+ end
@@ -0,0 +1,108 @@
1
+ class BlockCipherKit::AES256GCMScheme < BlockCipherKit::BaseScheme
2
+ IV_LENGTH = 12
3
+
4
+ def initialize(encryption_key, iv_generator: SecureRandom, auth_data: "")
5
+ raise ArgumentError, "#{required_encryption_key_length} bytes of key material needed, at the minimum" unless encryption_key.bytesize >= required_encryption_key_length
6
+ @iv_generator = iv_generator
7
+ @auth_data = BlockCipherKit::KeyMaterial.new(auth_data.b)
8
+ @key = BlockCipherKit::KeyMaterial.new(encryption_key.byteslice(0, 32))
9
+ end
10
+
11
+ def required_encryption_key_length
12
+ 32
13
+ end
14
+
15
+ def streaming_encrypt(into_ciphertext_io:, from_plaintext_io: nil, &blk)
16
+ iv = @iv_generator.bytes(IV_LENGTH)
17
+ into_ciphertext_io.write(iv)
18
+
19
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
20
+ cipher.encrypt
21
+ cipher.iv = iv
22
+ cipher.key = @key
23
+ cipher.auth_data = @auth_data
24
+
25
+ write_copy_stream_via_cipher(source_io: from_plaintext_io, cipher: cipher, destination_io: into_ciphertext_io, &blk)
26
+
27
+ tag = cipher.auth_tag
28
+ into_ciphertext_io.write(tag)
29
+ end
30
+
31
+ def streaming_decrypt(from_ciphertext_io:, into_plaintext_io: nil, &blk)
32
+ # Read the IV
33
+ iv = from_ciphertext_io.read(IV_LENGTH)
34
+ start_at = from_ciphertext_io.pos
35
+
36
+ # Read the auth tag, which we store after the ciphertext. This is streaming
37
+ # decrypt, but we still assume random access is available for from_ciphertext_io.
38
+ # We can access the ciphertext without tag validation but then it would be the same
39
+ # "downgrade" to CTR as in decrypt_range.
40
+ tag_len = 16
41
+ from_ciphertext_io.seek(from_ciphertext_io.size - tag_len)
42
+ auth_tag_from_io_tail = from_ciphertext_io.read(tag_len)
43
+
44
+ # From the docs:
45
+ # When decrypting, the authenticated data must be set after key, iv and especially
46
+ # after the authentication tag has been set. I.e. set it only after calling #decrypt,
47
+ # key=, #iv= and #auth_tag= first.
48
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
49
+ cipher.decrypt
50
+ cipher.iv = iv
51
+ cipher.key = @key
52
+ cipher.auth_tag = auth_tag_from_io_tail
53
+ cipher.auth_data = @auth_data
54
+
55
+ from_ciphertext_io.seek(start_at)
56
+
57
+ # We need to be careful not to read our auth tag along with the blocks,
58
+ # because we appended it to the ciphertext ourselves - if the cipher considers
59
+ # it part of ciphertext the validation will fail
60
+ n_bytes_to_read_excluding_auth_tag = from_ciphertext_io.size - from_ciphertext_io.pos - tag_len
61
+
62
+ # read_copy_stream_via_cipher will also call .final performing the validation
63
+ read_copy_stream_via_cipher(source_io: from_ciphertext_io, cipher: cipher, read_limit: n_bytes_to_read_excluding_auth_tag, destination_io: into_plaintext_io, &blk)
64
+ end
65
+
66
+ def decrypt_range(from_ciphertext_io:, range:)
67
+ # GCM uses 16 byte blocks, but it writes the block
68
+ # and the tag of 16 bytes. So actual block boundaries
69
+ # are at 2x AES block size of 16 bytes. This is also
70
+ # why the counter in the IV gets wound by 2 every time
71
+ # we move from block to block.
72
+ block_and_tag_size = 16 + 16
73
+
74
+ n_blocks_to_skip, offset_into_first_block = range.begin.divmod(block_and_tag_size)
75
+ n_bytes_to_read = range.end - range.begin + 1
76
+ n_blocks_to_read = ((offset_into_first_block + n_bytes_to_read) / block_and_tag_size.to_f).ceil
77
+
78
+ initial_iv_from_input = from_ciphertext_io.read(12)
79
+ ciphertext_starts_at = from_ciphertext_io.pos
80
+
81
+ # This is not a typo: we use GCM for encrypting the entire file and for decrypting the entire file, but to
82
+ # have access to random blocks we need to downgrade to CTR, since we can't validate the tag anyway
83
+ # This is a widely known trick, see
84
+ # https://stackoverflow.com/questions/49228671/aes-gcm-decryption-bypassing-authentication-in-java/49244840#49244840
85
+ # What we are doing here is not very secure
86
+ # because we lose the authencation of the cipher (this does not verify the tag). But we can't actually
87
+ # verify the tag without having decrypted the entire message.
88
+ cipher = OpenSSL::Cipher.new("aes-256-ctr")
89
+ cipher.decrypt
90
+ cipher.iv = ctr_iv(initial_iv_from_input, n_blocks_to_skip) # Set the IV for the first block we will be reading
91
+ cipher.key = @key
92
+
93
+ buf = StringIO.new.binmode
94
+ from_ciphertext_io.seek(ciphertext_starts_at + (n_blocks_to_skip * block_and_tag_size))
95
+ read_copy_stream_via_cipher(source_io: from_ciphertext_io, cipher: cipher, read_limit: n_blocks_to_read * block_and_tag_size, destination_io: buf)
96
+ buf.seek(offset_into_first_block) # Discard the bytes beyound the offset
97
+ buf.read(n_bytes_to_read) # return just the amount of bytes requested
98
+ end
99
+
100
+ def ctr_iv(initial_iv_from_input, for_block_n)
101
+ raise ArgumentError unless initial_iv_from_input.bytesize == 12
102
+ # The counter gets incremented twice per block with GCM and the
103
+ # initial counter value is 2 (as if there was a block before), see
104
+ # https://stackoverflow.com/a/49244840
105
+ ctr = (2 + (for_block_n * 2))
106
+ initial_iv_from_input.b + [ctr].pack("N")
107
+ end
108
+ end
@@ -0,0 +1,43 @@
1
+ class BlockCipherKit::BaseScheme
2
+ def streaming_decrypt(from_ciphertext_io:, into_plaintext_io: nil, &blk)
3
+ raise "Unimplemented"
4
+ end
5
+
6
+ def streaming_encrypt(into_ciphertext_io:, from_plaintext_io: nil, &blk)
7
+ raise "Unimplemented"
8
+ end
9
+
10
+ def streaming_decrypt_range(from_ciphertext_io:, range:, into_plaintext_io: nil, &blk)
11
+ raise "Unimplemented"
12
+ end
13
+
14
+ def decrypt_range(from_ciphertext_io:, range:)
15
+ buf = StringIO.new.binmode
16
+ streaming_decrypt_range(from_ciphertext_io: from_ciphertext_io, range: range, into_plaintext_io: buf)
17
+ buf.string
18
+ end
19
+
20
+ def read_copy_stream_via_cipher(source_io:, cipher:, read_limit: nil, destination_io: nil, finalize_cipher: true, &block_accepting_byte_chunks)
21
+ writable = BlockCipherKit::BlockWritable.new(destination_io, &block_accepting_byte_chunks)
22
+ cipher_io = BlockCipherKit::CipherIO.new(writable, cipher)
23
+ IO.copy_stream(source_io, cipher_io, read_limit)
24
+ # Some cases require us to skip authentication which gets performed in cipher.final
25
+ # - like decrypting a few blocks of CBC without decrypting the last block. This skips cipher
26
+ # authentication but is required for random access.
27
+ writable.write(cipher.final) if finalize_cipher
28
+ end
29
+
30
+ def write_copy_stream_via_cipher(cipher:, destination_io:, source_io: nil, read_limit: nil, &block_accepting_writable_io)
31
+ w = BlockCipherKit::CipherIO.new(destination_io, cipher)
32
+ if !source_io && block_accepting_writable_io && !read_limit
33
+ block_accepting_writable_io.call(w)
34
+ elsif !source_io && block_accepting_writable_io && read_limit
35
+ raise "write_copy_stream_via_cipher cannot enforce read_limit when writing via a block"
36
+ elsif source_io && !block_accepting_writable_io
37
+ IO.copy_stream(source_io, w, read_limit)
38
+ else
39
+ raise ArgumentError, "Either source_io: or a block accepting a writable IO must be provided"
40
+ end
41
+ destination_io.write(cipher.final)
42
+ end
43
+ end