kinko 0.1.0 → 0.2.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/CHANGELOG.md +18 -4
- data/README.md +23 -30
- data/lib/kinko/adapters/rom/plugin.rb +123 -0
- data/lib/kinko/adapters/rom.rb +3 -3
- data/lib/kinko/cipher.rb +3 -1
- data/lib/kinko/configuration.rb +9 -1
- data/lib/kinko/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5d6413d3fb6b1ba4f284e00e196f4e16f8fda53530319167c38fd19b9013e61b
|
|
4
|
+
data.tar.gz: '0439b767ef534a75c26e7d447c329109f0e04d0a608b8a28ac3778268194855b'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 770a82c50f7ec3e53b8e87220513579e045f5bb59d62d27011177900673efe34c3fe347ff75c9234dd7dea3b75dae7c14ff07fd633cf69d92e87f830443811d5
|
|
7
|
+
data.tar.gz: 6b27841c3c7a906fbf513db3b6304f8a979ac598612f106e9e2662d40355b51160c0d8ec12760e372205d2c2207da25b9a82d2de5a91ef0b797033bd8fb98fbe
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## v0.2.0
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- ROM adapter (`kinko/adapters/rom`): relation plugin (`use :kinko` with a
|
|
13
|
+
`kinko { encrypt ...; blind_index ... }` block) that encrypts on
|
|
14
|
+
`command(:create/:update)` and decrypts on read, plus
|
|
15
|
+
`blind_index_for(column, value)` for lookups. Also
|
|
16
|
+
`Kinko::Adapters::ROM::Types.encrypted` and `.blind_index` schema types and
|
|
17
|
+
module-level `encrypt` / `decrypt` / `blind_index` helpers for changeset-based
|
|
18
|
+
flows without the plugin.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `Kinko::Adapters::ROM::Types.encrypted` now attaches decryption via
|
|
23
|
+
`meta(read:)` instead of a bidirectional constructor, so ROM's write path no
|
|
24
|
+
longer runs `Kinko::Cipher.decrypt` on incoming values.
|
|
25
|
+
|
|
8
26
|
## v0.1.0
|
|
9
27
|
|
|
10
28
|
### Added
|
|
@@ -22,10 +40,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
22
40
|
`:strip`, `:downcase_strip`, and `:unicode` normalizers, plus any callable.
|
|
23
41
|
- Sequel adapter (`kinko/adapters/sequel`): plugin adding `kinko_encrypt`,
|
|
24
42
|
`kinko_blind_index`, and `blind_index_for(column, value)` to models.
|
|
25
|
-
- ROM adapter (`kinko/adapters/rom`): `Kinko::Adapters::ROM::Types.encrypted`
|
|
26
|
-
and `.blind_index` schema types for read-side decryption, plus module-level
|
|
27
|
-
`encrypt` / `decrypt` / `blind_index` helpers for use in changesets and
|
|
28
|
-
repositories.
|
|
29
43
|
- Active Record adapter (`kinko/adapters/active_record`): concern adding
|
|
30
44
|
`encrypts`, `blind_indexed`, and `blind_index_for(column, value)` to models.
|
|
31
45
|
- `Kinko::Error`, `Kinko::ConfigurationError`, `Kinko::InvalidEnvelope`, and
|
data/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# Kinko
|
|
2
2
|
|
|
3
|
+
[](https://codeberg.org/fluck/kinko/actions?workflow=ci.yml)
|
|
3
4
|
[](https://codeberg.org/fluck/kinko/tags)
|
|
4
5
|
|
|
5
6
|
Encrypted columns for Ruby with versioned key rotation and optional blind
|
|
@@ -37,7 +38,7 @@ into one gem:
|
|
|
37
38
|
### [lockbox](https://github.com/ankane/lockbox)
|
|
38
39
|
|
|
39
40
|
- Frameworks: Active Record, Mongoid
|
|
40
|
-
- AES-GCM
|
|
41
|
+
- AES-256-GCM/XSalsa20
|
|
41
42
|
- Actively maintained, richer ecosystem
|
|
42
43
|
|
|
43
44
|
### [Active Record Encryption](https://guides.rubyonrails.org/active_record_encryption.html)
|
|
@@ -90,8 +91,7 @@ string).
|
|
|
90
91
|
|
|
91
92
|
> [!NOTE]
|
|
92
93
|
> Keys can be anything you like. It could be `"v1"`, `"v2"`, etc. like in the
|
|
93
|
-
> example above, but also something more self-documenting like `"202607"
|
|
94
|
-
> example.
|
|
94
|
+
> example above, but also something more self-documenting like `"202607"`.
|
|
95
95
|
|
|
96
96
|
### Encrypt and decrypt directly
|
|
97
97
|
|
|
@@ -191,46 +191,39 @@ one-time tokens).
|
|
|
191
191
|
Both accept `normalize:` (any of the built-in strategies or a callable).
|
|
192
192
|
`kinko_encrypt` also accepts `type:` for custom class deserialization on read.
|
|
193
193
|
|
|
194
|
-
### ROM
|
|
195
|
-
|
|
196
|
-
Reads via a dry-types constructor:
|
|
194
|
+
### ROM (and Hanami)
|
|
197
195
|
|
|
198
196
|
```ruby
|
|
199
197
|
require "kinko/adapters/rom"
|
|
200
198
|
|
|
201
199
|
class Users < ROM::Relation[:sql]
|
|
202
|
-
schema(:users, infer: true)
|
|
203
|
-
attribute :secret_ciphertext, Kinko::Adapters::ROM::Types.encrypted
|
|
204
|
-
attribute :email_blind_index, Kinko::Adapters::ROM::Types.blind_index
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
```
|
|
200
|
+
schema(:users, infer: true)
|
|
208
201
|
|
|
209
|
-
|
|
210
|
-
decoupled from the schema:
|
|
202
|
+
use :kinko
|
|
211
203
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
)
|
|
204
|
+
kinko do
|
|
205
|
+
encrypt :email, to: :email_ciphertext,
|
|
206
|
+
index: :email_blind_index,
|
|
207
|
+
normalize: :downcase_strip
|
|
208
|
+
blind_index :recovery_code, to: :recovery_blind_index,
|
|
209
|
+
normalize: :downcase
|
|
219
210
|
end
|
|
220
211
|
end
|
|
221
|
-
```
|
|
222
212
|
|
|
223
|
-
|
|
213
|
+
users.command(:create).call(email: "hi@moi.com", recovery_code: "abc123")
|
|
214
|
+
users.first[:email_ciphertext] # => "hi@moi.com"
|
|
224
215
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def by_email(email)
|
|
228
|
-
digest = Kinko::BlindIndex.compute(email, normalize: :downcase_strip)
|
|
229
|
-
users.where(email_blind_index: digest).one
|
|
230
|
-
end
|
|
231
|
-
end
|
|
216
|
+
digest = Users.blind_index_for(:email_blind_index, "HI@moi.com")
|
|
217
|
+
users.where(email_blind_index: digest).one
|
|
232
218
|
```
|
|
233
219
|
|
|
220
|
+
The plugin hooks `command(:create/:update)` and `each`. Bypassing those
|
|
221
|
+
(e.g. `users.insert`) writes plaintext.
|
|
222
|
+
|
|
223
|
+
For explicit changeset-based encryption without the plugin, use
|
|
224
|
+
`Kinko::Adapters::ROM::Types.encrypted` for reads and `Kinko::Cipher` /
|
|
225
|
+
`Kinko::BlindIndex` for writes.
|
|
226
|
+
|
|
234
227
|
### Active Record
|
|
235
228
|
|
|
236
229
|
```ruby
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rom"
|
|
4
|
+
require "kinko"
|
|
5
|
+
|
|
6
|
+
module Kinko
|
|
7
|
+
module Adapters
|
|
8
|
+
module ROM
|
|
9
|
+
module Plugin
|
|
10
|
+
class Config
|
|
11
|
+
Encrypted = Struct.new(:source, :to, :index, :normalize, :type, keyword_init: true)
|
|
12
|
+
Indexed = Struct.new(:source, :to, :normalize, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
attr_reader :encrypted, :indexed, :normalizers
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@encrypted = []
|
|
18
|
+
@indexed = []
|
|
19
|
+
@normalizers = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def encrypt(source, to:, index: nil, normalize: :none, type: nil)
|
|
23
|
+
@encrypted << Encrypted.new(source:, to:, index:, normalize:, type:)
|
|
24
|
+
@normalizers[index] = normalize if index
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def blind_index(source, to:, normalize: :none)
|
|
28
|
+
@indexed << Indexed.new(source:, to:, normalize:)
|
|
29
|
+
@normalizers[to] = normalize
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def encrypt_input(tuple)
|
|
33
|
+
result = tuple.to_h.dup
|
|
34
|
+
@encrypted.each { apply_encrypt(_1, result) if result.key?(_1.source) }
|
|
35
|
+
@indexed.each { apply_index(_1, result) if result.key?(_1.source) }
|
|
36
|
+
result
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def decrypt_output(tuple)
|
|
40
|
+
result = tuple.to_h.dup
|
|
41
|
+
@encrypted.each { apply_decrypt(_1, result) if result.key?(_1.to) }
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def apply_encrypt(entry, result)
|
|
48
|
+
if value = result.delete(entry.source)
|
|
49
|
+
result[entry.to] = Kinko::Cipher.encrypt(value)
|
|
50
|
+
return unless entry.index
|
|
51
|
+
|
|
52
|
+
result[entry.index] = Kinko::BlindIndex.compute(value, normalize: entry.normalize)
|
|
53
|
+
else
|
|
54
|
+
result[entry.to] = nil
|
|
55
|
+
result[entry.index] = nil if entry.index
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def apply_index(entry, result)
|
|
60
|
+
if value = result.delete(entry.source)
|
|
61
|
+
result[entry.to] = Kinko::BlindIndex.compute(value, normalize: entry.normalize)
|
|
62
|
+
else
|
|
63
|
+
result[entry.to] = nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def apply_decrypt(entry, result)
|
|
68
|
+
if envelope = result[entry.to]
|
|
69
|
+
result[entry.to] = Kinko::Cipher.decrypt(envelope, type: entry.type)
|
|
70
|
+
else
|
|
71
|
+
result[entry.to] = nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
module ClassMethods
|
|
77
|
+
def kinko(&block)
|
|
78
|
+
@kinko_config ||= Config.new
|
|
79
|
+
@kinko_config.instance_eval(&block) if block
|
|
80
|
+
@kinko_config
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
attr_reader :kinko_config
|
|
84
|
+
|
|
85
|
+
def blind_index_for(column, value)
|
|
86
|
+
registry = kinko_config&.normalizers || {}
|
|
87
|
+
normalize = registry.fetch(column) do
|
|
88
|
+
raise ArgumentError, "no blind index registered for #{column.inspect}"
|
|
89
|
+
end
|
|
90
|
+
Kinko::BlindIndex.compute(value, normalize:)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
module InstanceMethods
|
|
95
|
+
def command(type, **)
|
|
96
|
+
cmd = super
|
|
97
|
+
config = self.class.kinko_config
|
|
98
|
+
return cmd unless config && %i[create update].include?(type)
|
|
99
|
+
|
|
100
|
+
base_input = cmd.input
|
|
101
|
+
cmd.with(input: ->(tuple) { base_input[config.encrypt_input(tuple)] })
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def each(&block)
|
|
105
|
+
config = self.class.kinko_config
|
|
106
|
+
return super unless config && block_given?
|
|
107
|
+
|
|
108
|
+
super { block.call(config.decrypt_output(_1)) }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.apply(relation, **)
|
|
113
|
+
relation.extend(ClassMethods)
|
|
114
|
+
relation.include(InstanceMethods)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
ROM.plugins do
|
|
122
|
+
register :kinko, Kinko::Adapters::ROM::Plugin, type: :relation
|
|
123
|
+
end
|
data/lib/kinko/adapters/rom.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "dry/types"
|
|
4
4
|
require "kinko"
|
|
5
|
+
require "kinko/adapters/rom/plugin"
|
|
5
6
|
|
|
6
7
|
module Kinko
|
|
7
8
|
module Adapters
|
|
@@ -11,9 +12,8 @@ module Kinko
|
|
|
11
12
|
|
|
12
13
|
def encrypted(type: nil, inner: nil)
|
|
13
14
|
inner ||= type ? Dry::Types["any"].optional : Dry::Types["string"].optional
|
|
14
|
-
inner.constructor
|
|
15
|
-
|
|
16
|
-
end
|
|
15
|
+
read = inner.constructor { _1.nil? ? nil : Kinko::Cipher.decrypt(_1, type:) }
|
|
16
|
+
inner.meta(read: read)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def blind_index(inner: Dry::Types["string"].optional) = inner
|
data/lib/kinko/cipher.rb
CHANGED
|
@@ -45,7 +45,9 @@ module Kinko
|
|
|
45
45
|
private
|
|
46
46
|
|
|
47
47
|
def new_cipher(mode, key)
|
|
48
|
-
OpenSSL::Cipher.new(ALGORITHM)
|
|
48
|
+
cipher = OpenSSL::Cipher.new(ALGORITHM)
|
|
49
|
+
mode == :encrypt ? cipher.encrypt : cipher.decrypt
|
|
50
|
+
cipher.tap { _1.key = key }
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
def envelope(version, bytes)
|
data/lib/kinko/configuration.rb
CHANGED
|
@@ -4,13 +4,21 @@ module Kinko
|
|
|
4
4
|
class Configuration
|
|
5
5
|
KEY_BYTES = 32
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
attr_reader :keys, :key_version
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
10
10
|
@keys = {}
|
|
11
11
|
@key_version = nil
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
def keys=(mapping)
|
|
15
|
+
@keys = mapping.to_h { |version, key| [version.to_s, key] }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def key_version=(version)
|
|
19
|
+
@key_version = version&.to_s
|
|
20
|
+
end
|
|
21
|
+
|
|
14
22
|
def current_key
|
|
15
23
|
require!
|
|
16
24
|
keys.fetch(key_version)
|
data/lib/kinko/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kinko
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Wout
|
|
@@ -54,6 +54,7 @@ files:
|
|
|
54
54
|
- lib/kinko.rb
|
|
55
55
|
- lib/kinko/adapters/active_record.rb
|
|
56
56
|
- lib/kinko/adapters/rom.rb
|
|
57
|
+
- lib/kinko/adapters/rom/plugin.rb
|
|
57
58
|
- lib/kinko/adapters/sequel.rb
|
|
58
59
|
- lib/kinko/blind_index.rb
|
|
59
60
|
- lib/kinko/cipher.rb
|