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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 816a9f7595eccbf358a03ec748a0ee6ba723bc81f7292ae589bd3b1954605285
4
- data.tar.gz: ce1314b805ed12a494f84c412e638db9448423fffcab89699aaadc876c2c1080
3
+ metadata.gz: 5d6413d3fb6b1ba4f284e00e196f4e16f8fda53530319167c38fd19b9013e61b
4
+ data.tar.gz: '0439b767ef534a75c26e7d447c329109f0e04d0a608b8a28ac3778268194855b'
5
5
  SHA512:
6
- metadata.gz: c988acbeae16dea88a9c3a9151a5da080ea38f7e596d74d901a290231aca38e5d8c4b06dae22009e59022d7f9de3916a26f9e4a8a1894c2d5ef9acc0306ef4c1
7
- data.tar.gz: 191ff65897bacae44081edd2a42c7abe15409ea45912fb0e2021a7343e5608be65a6f7f835607bc38a000751a0bc5c766011845f92354f026b157df70a02d0a6
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
+ [![CI](https://codeberg.org/fluck/kinko/actions/workflows/ci.yml/badge.svg)](https://codeberg.org/fluck/kinko/actions?workflow=ci.yml)
3
4
  [![Version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fcodeberg.org%2Fapi%2Fv1%2Frepos%2Ffluck%2Fkinko%2Ftags&query=%24%5B0%5D.name&label=version)](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 + XChaCha20-Poly1305
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"` for
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) do
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
- Writes go through a changeset, since ROM's write path is intentionally
210
- decoupled from the schema:
202
+ use :kinko
211
203
 
212
- ```ruby
213
- class UserChangeset < ROM::Changeset::Create
214
- map do |tuple|
215
- tuple.merge(
216
- secret_ciphertext: Kinko::Cipher.encrypt(tuple[:secret]),
217
- email_blind_index: Kinko::BlindIndex.compute(tuple[:email], normalize: :downcase_strip)
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
- Or directly from a repository:
213
+ users.command(:create).call(email: "hi@moi.com", recovery_code: "abc123")
214
+ users.first[:email_ciphertext] # => "hi@moi.com"
224
215
 
225
- ```ruby
226
- class UserRepo < ROM::Repository[:users]
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
@@ -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 do |value|
15
- value.nil? ? nil : Kinko::Cipher.decrypt(value, type:)
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).public_send(mode).tap { _1.key = key }
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)
@@ -4,13 +4,21 @@ module Kinko
4
4
  class Configuration
5
5
  KEY_BYTES = 32
6
6
 
7
- attr_accessor :keys, :key_version
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kinko
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.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