block_cipher_kit 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +37 -0
- data/.gitignore +57 -0
- data/.standard.yml +1 -0
- data/Gemfile +3 -0
- data/README.md +136 -0
- data/Rakefile +18 -0
- data/block_cipher_kit.gemspec +37 -0
- data/lib/block_cipher_kit/aes_256_cbc_scheme.rb +56 -0
- data/lib/block_cipher_kit/aes_256_cfb_civ_scheme.rb +35 -0
- data/lib/block_cipher_kit/aes_256_cfb_scheme.rb +37 -0
- data/lib/block_cipher_kit/aes_256_ctr_scheme.rb +87 -0
- data/lib/block_cipher_kit/aes_256_gcm_scheme.rb +108 -0
- data/lib/block_cipher_kit/base_scheme.rb +43 -0
- data/lib/block_cipher_kit/block_writable.rb +20 -0
- data/lib/block_cipher_kit/cipher_io.rb +16 -0
- data/lib/block_cipher_kit/io_lens.rb +46 -0
- data/lib/block_cipher_kit/key_material.rb +16 -0
- data/lib/block_cipher_kit/passthru_scheme.rb +26 -0
- data/lib/block_cipher_kit/version.rb +3 -0
- data/lib/block_cipher_kit.rb +32 -0
- data/test/cipher_io_test.rb +18 -0
- data/test/io_lens_test.rb +30 -0
- data/test/key_material_test.rb +12 -0
- data/test/known_ciphertexts/AES256CBCScheme.ciphertext.bin +0 -0
- data/test/known_ciphertexts/AES256CFBCIVScheme.ciphertext.bin +0 -0
- data/test/known_ciphertexts/AES256CFBScheme.ciphertext.bin +0 -0
- data/test/known_ciphertexts/AES256CTRScheme.ciphertext.bin +0 -0
- data/test/known_ciphertexts/AES256GCMScheme.ciphertext.bin +0 -0
- data/test/known_ciphertexts/PassthruScheme.ciphertext.bin +0 -0
- data/test/known_ciphertexts/known_plain.bin +0 -0
- data/test/schemes_test.rb +221 -0
- data/test/test_helper.rb +6 -0
- data/test/test_known_ciphertext.rb +88 -0
- 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
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
|