kinko 0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 816a9f7595eccbf358a03ec748a0ee6ba723bc81f7292ae589bd3b1954605285
4
+ data.tar.gz: ce1314b805ed12a494f84c412e638db9448423fffcab89699aaadc876c2c1080
5
+ SHA512:
6
+ metadata.gz: c988acbeae16dea88a9c3a9151a5da080ea38f7e596d74d901a290231aca38e5d8c4b06dae22009e59022d7f9de3916a26f9e4a8a1894c2d5ef9acc0306ef4c1
7
+ data.tar.gz: 191ff65897bacae44081edd2a42c7abe15409ea45912fb0e2021a7343e5608be65a6f7f835607bc38a000751a0bc5c766011845f92354f026b157df70a02d0a6
data/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## v0.1.0
9
+
10
+ ### Added
11
+
12
+ - Initial scaffolding inspired by the
13
+ [Crystal avram_encrypted shard](https://codeberg.org/fluck/avram_encrypted).
14
+ - `Kinko::Configuration` with `keys` and `key_version`, accessed via
15
+ `Kinko.configure`. `Kinko.generate_key` returns a base64-encoded 32-byte key.
16
+ - `Kinko::Cipher` for AES-256-GCM encryption with a `"version:payload"`
17
+ envelope, JSON round-trip for non-string values (with optional `type:` hook for
18
+ custom classes via `.from_json`), key-version dispatch, and a `recrypt` helper
19
+ for bulk rotation.
20
+ - `Kinko::BlindIndex` for HMAC-SHA256 lookup digests over encrypted values,
21
+ versioned symmetrically with the cipher. Built-in `:none`, `:downcase`,
22
+ `:strip`, `:downcase_strip`, and `:unicode` normalizers, plus any callable.
23
+ - Sequel adapter (`kinko/adapters/sequel`): plugin adding `kinko_encrypt`,
24
+ `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
+ - Active Record adapter (`kinko/adapters/active_record`): concern adding
30
+ `encrypts`, `blind_indexed`, and `blind_index_for(column, value)` to models.
31
+ - `Kinko::Error`, `Kinko::ConfigurationError`, `Kinko::InvalidEnvelope`, and
32
+ `Kinko::UnknownKeyVersion` exception types.
33
+ - README covering quickstart, framework integration for Sequel, ROM, and Active
34
+ Record, key rotation, and threat-model notes.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wout <hi@wout.codes>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,340 @@
1
+ # Kinko
2
+
3
+ [![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
+ Encrypted columns for Ruby with versioned key rotation and optional blind
6
+ indexes for equality lookups. Framework-agnostic core and adapters for Sequel,
7
+ ROM, and Active Record.
8
+
9
+ > _Kinko_ (金庫) is the Japanese word for "safe" or "vault", the small locked
10
+ > box you keep valuables in.
11
+
12
+ This is a Ruby companion to the [Crystal avram_encrypted
13
+ shard](https://codeberg.org/fluck/avram_encrypted). It bundels two primitives
14
+ into one gem:
15
+
16
+ 1. **Cipher**. AES-256-GCM envelope encryption keyed by a version prefix, so
17
+ you can rotate keys without touching old rows. Any value that
18
+ JSON-serializes survives a round-trip.
19
+ 2. **Blind index**. HMAC-SHA256 lookup digests, so encrypted columns remain
20
+ queryable through a sidecar column, with optional case/whitespace/unicode
21
+ normalization.
22
+
23
+ > [!NOTE]
24
+ > The original repository is hosted at
25
+ > [Codeberg](https://codeberg.org/fluck/kinko). The [GitHub
26
+ > repo](https://github.com/flucksite/kinko) is just a mirror.
27
+
28
+ ## How it compares
29
+
30
+ ### kinko
31
+
32
+ - Frameworks: any Ruby app and adapters for Sequel, ROM, Active Record
33
+ - AES-256-GCM + HMAC-SHA256 blind index
34
+ - Versioned envelope for key rotation
35
+ - JSON round-trip so integers, hashes, and custom objects survive
36
+
37
+ ### [lockbox](https://github.com/ankane/lockbox)
38
+
39
+ - Frameworks: Active Record, Mongoid
40
+ - AES-GCM + XChaCha20-Poly1305
41
+ - Actively maintained, richer ecosystem
42
+
43
+ ### [Active Record Encryption](https://guides.rubyonrails.org/active_record_encryption.html)
44
+
45
+ - Frameworks: Active Record only (Rails 7+)
46
+ - AES-256-GCM, deterministic + non-deterministic modes
47
+ - Built into Rails, no extra gem
48
+
49
+ On Rails, the built-in `encrypts` or `lockbox` will usually be the better fit.
50
+ Kinko exists for stacks where a portable core with adapters across Sequel, ROM,
51
+ and Active Record matters more than deep framework integration.
52
+
53
+ ## When to encrypt vs. blind-index
54
+
55
+ - **Encrypt** when you need to read the value back later (email addresses on
56
+ display, API tokens, JSON payloads). Reversible with the key, unreadable
57
+ without.
58
+ - **Blind-index** when you only need to _verify_ a value (recovery codes,
59
+ one-time tokens, deduplication keys). One-way HMAC, high enough to survive a
60
+ stolen DB dump as long as the key stays out of it.
61
+ - **Both** on the same column when you need reversible storage _plus_ equality
62
+ lookups (encrypted email + `email_blind_index` for the login-by-email query).
63
+
64
+ ## Installation
65
+
66
+ Add this to your Gemfile:
67
+
68
+ ```ruby
69
+ gem "kinko"
70
+ ```
71
+
72
+ Then `bundle install`.
73
+
74
+ ## Quickstart
75
+
76
+ Configure your keys once at boot:
77
+
78
+ ```ruby
79
+ # config/initializers/kinko.rb
80
+ Kinko.configure do |c|
81
+ c.keys = {
82
+ "v1" => ENV.fetch("KINKO_KEY_V1")
83
+ }
84
+ c.key_version = "v1"
85
+ end
86
+ ```
87
+
88
+ Generate a new key with `Kinko.generate_key` (returns a base64-encoded 32-byte
89
+ string).
90
+
91
+ > [!NOTE]
92
+ > 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.
95
+
96
+ ### Encrypt and decrypt directly
97
+
98
+ ```ruby
99
+ envelope = Kinko::Cipher.encrypt("I’m not a diva. I’m a queen!")
100
+ # => "v1:AAECAwQFBgcICQoLDA..."
101
+
102
+ Kinko::Cipher.decrypt(envelope)
103
+ # => "swordfish"
104
+ ```
105
+
106
+ Non-string values round-trip through JSON:
107
+
108
+ ```ruby
109
+ Kinko::Cipher.encrypt(42) # ⇒ envelope
110
+ Kinko::Cipher.decrypt(envelope) # ⇒ 42
111
+
112
+ Kinko::Cipher.encrypt({name: "moi", age: 36}) # ⇒ envelope
113
+ Kinko::Cipher.decrypt(envelope) # ⇒ {"name" => "moi", "age" => 36}
114
+ ```
115
+
116
+ For custom classes, pass a `type:` that responds to `.from_json`:
117
+
118
+ ```ruby
119
+ class ContactCard
120
+ attr_reader :name, :email
121
+
122
+ def initialize(name:, email:) = (@name, @email = name, email)
123
+
124
+ def to_json(*args) = {name: @name, email: @email}.to_json(*args)
125
+
126
+ def self.from_json(json) = new(**JSON.parse(json, symbolize_names: true))
127
+ end
128
+
129
+ envelope = Kinko::Cipher.encrypt(ContactCard.new(name: "Moi", email: "m@o.i"))
130
+ card = Kinko::Cipher.decrypt(envelope, type: ContactCard)
131
+ # => #<ContactCard @name="Moi", @email="m@o.i">
132
+ ```
133
+
134
+ ### Blind-index a value for lookups
135
+
136
+ ```ruby
137
+ digest = Kinko::BlindIndex.compute("attitude")
138
+ # => "v1:9f4bcafdb1..."
139
+
140
+ Kinko::BlindIndex.match?("attitude", digest) # => true
141
+ Kinko::BlindIndex.match?("grace", digest) # => false
142
+ ```
143
+
144
+ Normalize the input for forgiving lookups:
145
+
146
+ ```ruby
147
+ Kinko::BlindIndex.compute(" MP@moi.com ", normalize: :downcase_strip)
148
+ # ⇒ same digest as "mp@moi.com"
149
+ ```
150
+
151
+ Built-in normalizers: `:none` (default), `:downcase`, `:strip`,
152
+ `:downcase_strip`, `:unicode` (NFC). Or pass any callable:
153
+
154
+ ```ruby
155
+ Kinko::BlindIndex.compute(phone, normalize: ->(v) { v.to_s.tr("^0-9", "") })
156
+ ```
157
+
158
+ Store the digest in a plain column and query by it directly.
159
+
160
+ ## Framework integration
161
+
162
+ ### Sequel
163
+
164
+ ```ruby
165
+ require "kinko/adapters/sequel"
166
+
167
+ class User < Sequel::Model
168
+ plugin :kinko
169
+
170
+ kinko_encrypt :email_ciphertext,
171
+ index: :email_blind_index,
172
+ normalize: :downcase_strip
173
+ kinko_blind_index :recovery_code, normalize: :downcase
174
+ end
175
+
176
+ # Insert transparently:
177
+ User.create(email_ciphertext: "hi@moi.com", recovery_code: "abcdef2345")
178
+
179
+ # Lookup by the blind index:
180
+ User.first(email_blind_index: User.blind_index_for(:email_blind_index, "HI@moi.com"))
181
+ ```
182
+
183
+ `kinko_encrypt` encrypts writes to the column and decrypts reads. When `index:`
184
+ is given, the same value is HMAC'd into the sidecar column for equality
185
+ lookups.
186
+
187
+ `kinko_blind_index` skips encryption entirely and stores only the digest,
188
+ which is what you want for values you only need to verify (recovery codes,
189
+ one-time tokens).
190
+
191
+ Both accept `normalize:` (any of the built-in strategies or a callable).
192
+ `kinko_encrypt` also accepts `type:` for custom class deserialization on read.
193
+
194
+ ### ROM
195
+
196
+ Reads via a dry-types constructor:
197
+
198
+ ```ruby
199
+ require "kinko/adapters/rom"
200
+
201
+ 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
+ ```
208
+
209
+ Writes go through a changeset, since ROM's write path is intentionally
210
+ decoupled from the schema:
211
+
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
+ )
219
+ end
220
+ end
221
+ ```
222
+
223
+ Or directly from a repository:
224
+
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
232
+ ```
233
+
234
+ ### Active Record
235
+
236
+ ```ruby
237
+ require "kinko/adapters/active_record"
238
+
239
+ class User < ApplicationRecord
240
+ include Kinko::Adapters::ActiveRecord
241
+
242
+ encrypts :secret_ciphertext
243
+ encrypts :email_ciphertext, index: :email_blind_index, normalize: :downcase_strip
244
+ blind_indexed :recovery_code, normalize: :downcase
245
+ end
246
+
247
+ user = User.create!(email_ciphertext: "hi@moi.com")
248
+ User.find_by(email_blind_index: User.blind_index_for(:email_blind_index, "HI@moi.com"))
249
+ ```
250
+
251
+ `encrypts` writes ciphertext + optional blind index mirror. `blind_indexed`
252
+ stores only the digest. Both accept `normalize:`; `encrypts` also accepts
253
+ `type:` for custom classes.
254
+
255
+ ## Column types
256
+
257
+ Encrypted values are base64-encoded envelopes; a `String` / `text` column
258
+ holds them fine. If you prefer bytes, decode before storing, but base64 is
259
+ the default so migrations don't need to think about binary.
260
+
261
+ Blind indexes are `"vN:"` (or any other key name format you chose) plus 64 hex
262
+ characters (~68 characters). Any `String` column with an index on it works:
263
+
264
+ ```ruby
265
+ # Sequel migration
266
+ alter_table :users do
267
+ add_column :email_ciphertext, String
268
+ add_column :email_blind_index, String
269
+ add_index :email_blind_index, unique: true
270
+ end
271
+ ```
272
+
273
+ ```ruby
274
+ # Rails migration
275
+ add_column :users, :email_ciphertext, :string
276
+ add_column :users, :email_blind_index, :string
277
+ add_index :users, :email_blind_index, unique: true
278
+ ```
279
+
280
+ ## Key rotation
281
+
282
+ Add the new key alongside the old one, flip `key_version`, and existing rows
283
+ keep decrypting from their original key while new writes use the new one:
284
+
285
+ ```ruby
286
+ Kinko.configure do |c|
287
+ c.keys = {
288
+ "v1" => ENV.fetch("KINKO_KEY_V1"),
289
+ "v2" => ENV.fetch("KINKO_KEY_V2")
290
+ }
291
+ c.key_version = "v2"
292
+ end
293
+ ```
294
+
295
+ Rewrap old ciphertexts in bulk when you have time:
296
+
297
+ ```ruby
298
+ User.where(Sequel.like(:email_ciphertext, "v1:%")).each do |user|
299
+ user.update(email_ciphertext: Kinko::Cipher.recrypt(user[:email_ciphertext]))
300
+ end
301
+ ```
302
+
303
+ Blind indexes are deterministic per key, so their equivalent rotation is to
304
+ recompute from the plaintext (which you have during writes). Old digests
305
+ keep matching until the row is next written.
306
+
307
+ ## Threat model
308
+
309
+ Kinko protects against **database-only compromise**: an attacker with a copy of
310
+ your rows can't read encrypted values or brute-force blind indexes without the
311
+ encryption key.
312
+
313
+ Kinko does **not** protect against:
314
+
315
+ - Compromise of the application host, where the key is loaded into memory.
316
+ - Application-layer bugs (SQL injection, template injection) that let an
317
+ attacker call `Kinko::Cipher.decrypt` on your behalf.
318
+
319
+ Store your keys somewhere the database can't reach. Rotate them if you suspect
320
+ either the DB or the app host was accessed.
321
+
322
+ For anything more elaborate (per-tenant keys, envelope encryption backed by a
323
+ KMS, deterministic ciphers) you probably want
324
+ [lockbox](https://github.com/ankane/lockbox) or a purpose-built solution.
325
+
326
+ ## Contributing
327
+
328
+ We use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/)
329
+ for our commit messages, so please adhere to that pattern.
330
+
331
+ 1. Fork it (<https://codeberg.org/fluck/kinko/fork>)
332
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
333
+ 3. Commit your changes (`git commit -am 'feat: new feature'`)
334
+ 4. Push to the branch (`git push origin my-new-feature`)
335
+ 5. Create a new Pull Request
336
+
337
+ ## License
338
+
339
+ The gem is available as open source under the terms of the
340
+ [MIT License](LICENSE).
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "kinko"
5
+
6
+ module Kinko
7
+ module Adapters
8
+ module ActiveRecord
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ class_attribute :kinko_index_normalizers, instance_writer: false, default: {}
13
+ end
14
+
15
+ class_methods do
16
+ def encrypts(column, type: nil, index: nil, normalize: :none)
17
+ self.kinko_index_normalizers = kinko_index_normalizers.merge(index => normalize) if index
18
+
19
+ define_method(column) do
20
+ raw = self[column]
21
+ raw.nil? ? nil : Kinko::Cipher.decrypt(raw, type: type)
22
+ end
23
+
24
+ define_method("#{column}=") do |value|
25
+ if value.nil?
26
+ self[column] = nil
27
+ self[index] = nil if index
28
+ else
29
+ self[column] = Kinko::Cipher.encrypt(value)
30
+ self[index] = Kinko::BlindIndex.compute(value, normalize:) if index
31
+ end
32
+ end
33
+ end
34
+
35
+ def blind_indexed(column, normalize: :none)
36
+ self.kinko_index_normalizers = kinko_index_normalizers.merge(column => normalize)
37
+
38
+ define_method("#{column}=") do |value|
39
+ if value.nil?
40
+ self[column] = nil
41
+ else
42
+ self[column] = Kinko::BlindIndex.compute(value, normalize:)
43
+ end
44
+ end
45
+ end
46
+
47
+ def blind_index_for(column, value)
48
+ normalize = kinko_index_normalizers.fetch(column) do
49
+ raise ArgumentError, "no blind index registered for #{column.inspect}"
50
+ end
51
+ Kinko::BlindIndex.compute(value, normalize:)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/types"
4
+ require "kinko"
5
+
6
+ module Kinko
7
+ module Adapters
8
+ module ROM
9
+ module Types
10
+ module_function
11
+
12
+ def encrypted(type: nil, inner: nil)
13
+ 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
17
+ end
18
+
19
+ def blind_index(inner: Dry::Types["string"].optional) = inner
20
+ end
21
+
22
+ module_function
23
+
24
+ def encrypt(value, **opts)
25
+ Kinko::Cipher.encrypt(value, **opts)
26
+ end
27
+
28
+ def decrypt(envelope, **opts)
29
+ Kinko::Cipher.decrypt(envelope, **opts)
30
+ end
31
+
32
+ def blind_index(value, **opts)
33
+ Kinko::BlindIndex.compute(value, **opts)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel"
4
+ require "sequel/plugins/kinko"
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "openssl"
5
+
6
+ module Kinko
7
+ # HMAC-SHA256 lookup digest for encrypted columns.
8
+ module BlindIndex
9
+ extend self
10
+
11
+ DIGEST = "SHA256"
12
+ SEPARATOR = ":"
13
+
14
+ NORMALIZERS = {
15
+ none: ->(v) { v.to_s },
16
+ downcase: ->(v) { v.to_s.downcase },
17
+ strip: ->(v) { v.to_s.strip },
18
+ downcase_strip: ->(v) { v.to_s.downcase.strip },
19
+ unicode: ->(v) { v.to_s.unicode_normalize(:nfc) }
20
+ }.freeze
21
+
22
+ def compute(value, version: Kinko.config.key_version, normalize: :none)
23
+ Kinko.config.require!
24
+ key = decode_key(Kinko.config.key_for(version))
25
+ hex = OpenSSL::HMAC.hexdigest(DIGEST, key, normalize_with(normalize, value))
26
+ "#{version}#{SEPARATOR}#{hex}"
27
+ end
28
+
29
+ def match?(value, stored, normalize: :none)
30
+ version, digest = split(stored)
31
+ return false unless key = Kinko.config.keys[version]
32
+
33
+ candidate = OpenSSL::HMAC.hexdigest(
34
+ DIGEST,
35
+ decode_key(key),
36
+ normalize_with(normalize, value)
37
+ )
38
+ OpenSSL.fixed_length_secure_compare(digest, candidate)
39
+ rescue InvalidEnvelope, ArgumentError
40
+ false
41
+ end
42
+
43
+ def version_of(stored) = split(stored).first
44
+
45
+ private
46
+
47
+ def split(stored)
48
+ version, digest = stored.to_s.split(SEPARATOR, 2)
49
+ raise InvalidEnvelope, stored unless version && digest && !digest.empty?
50
+
51
+ [version, digest]
52
+ end
53
+
54
+ def normalize_with(strategy, value)
55
+ if strategy.respond_to?(:call)
56
+ strategy
57
+ else
58
+ NORMALIZERS.fetch(strategy) do
59
+ raise ArgumentError,
60
+ "unknown normalize strategy #{strategy.inspect}; " \
61
+ "one of #{NORMALIZERS.keys.inspect} or a callable"
62
+ end
63
+ end.call(value)
64
+ end
65
+
66
+ def decode_key(key)
67
+ bytes = key.to_s
68
+ return bytes if bytes.bytesize == Configuration::KEY_BYTES
69
+
70
+ Base64.strict_decode64(bytes).tap do |decoded|
71
+ next if decoded.bytesize == Configuration::KEY_BYTES
72
+
73
+ raise ConfigurationError,
74
+ "keys must be #{Configuration::KEY_BYTES} bytes (raw or base64)"
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+
7
+ module Kinko
8
+ module Cipher
9
+ extend self
10
+
11
+ ALGORITHM = "aes-256-gcm"
12
+ NONCE_BYTES = 12
13
+ TAG_BYTES = 16
14
+ SEPARATOR = ":"
15
+
16
+ def encrypt(value, version: Kinko.config.key_version)
17
+ Kinko.config.require!
18
+ key = decode_key(Kinko.config.key_for(version))
19
+ cipher = new_cipher(:encrypt, key)
20
+ nonce = cipher.random_iv
21
+ ciphertext = cipher.update(serialize(value)) + cipher.final
22
+ envelope(version, nonce + ciphertext + cipher.auth_tag)
23
+ end
24
+
25
+ def decrypt(envelope, type: nil)
26
+ version, payload = split(envelope)
27
+ key = decode_key(Kinko.config.key_for(version))
28
+ raw = Base64.strict_decode64(payload)
29
+ nonce, ciphertext, tag = split_payload(raw)
30
+ cipher = new_cipher(:decrypt, key)
31
+ cipher.iv = nonce
32
+ cipher.auth_tag = tag
33
+ deserialize(cipher.update(ciphertext) + cipher.final, type)
34
+ end
35
+
36
+ def recrypt(envelope, version: Kinko.config.key_version)
37
+ current, = split(envelope)
38
+ return envelope if current == version.to_s
39
+
40
+ encrypt(decrypt(envelope), version:)
41
+ end
42
+
43
+ def version_of(envelope) = split(envelope).first
44
+
45
+ private
46
+
47
+ def new_cipher(mode, key)
48
+ OpenSSL::Cipher.new(ALGORITHM).public_send(mode).tap { _1.key = key }
49
+ end
50
+
51
+ def envelope(version, bytes)
52
+ "#{version}#{SEPARATOR}#{Base64.strict_encode64(bytes)}"
53
+ end
54
+
55
+ def split(envelope)
56
+ version, payload = envelope.to_s.split(SEPARATOR, 2)
57
+ raise InvalidEnvelope, envelope unless version && payload && !payload.empty?
58
+
59
+ [version, payload]
60
+ end
61
+
62
+ def split_payload(raw)
63
+ raise InvalidEnvelope, raw if raw.bytesize <= NONCE_BYTES + TAG_BYTES
64
+
65
+ nonce = raw.byteslice(0, NONCE_BYTES)
66
+ tag = raw.byteslice(-TAG_BYTES, TAG_BYTES)
67
+ ciphertext = raw.byteslice(NONCE_BYTES, raw.bytesize - NONCE_BYTES - TAG_BYTES)
68
+ [nonce, ciphertext, tag]
69
+ end
70
+
71
+ def serialize(value) = JSON.generate(value)
72
+
73
+ def deserialize(json, type)
74
+ return type.from_json(json) if type.respond_to?(:from_json)
75
+
76
+ JSON.parse(json)
77
+ end
78
+
79
+ def decode_key(key)
80
+ bytes = key.to_s
81
+ return bytes if bytes.bytesize == Configuration::KEY_BYTES
82
+
83
+ Base64.strict_decode64(bytes).tap do |decoded|
84
+ next if decoded.bytesize == Configuration::KEY_BYTES
85
+
86
+ raise ConfigurationError,
87
+ "keys must be #{Configuration::KEY_BYTES} bytes (raw or base64)"
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kinko
4
+ class Configuration
5
+ KEY_BYTES = 32
6
+
7
+ attr_accessor :keys, :key_version
8
+
9
+ def initialize
10
+ @keys = {}
11
+ @key_version = nil
12
+ end
13
+
14
+ def current_key
15
+ require!
16
+ keys.fetch(key_version)
17
+ end
18
+
19
+ def key_for(version)
20
+ keys[version] || raise(UnknownKeyVersion, version)
21
+ end
22
+
23
+ def require!
24
+ raise ConfigurationError, "at least one key must be configured" if keys.empty?
25
+ raise ConfigurationError, "key_version must be set" if key_version.nil?
26
+ return if keys.key?(key_version)
27
+
28
+ raise ConfigurationError,
29
+ "key_version #{key_version.inspect} is not present in keys"
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kinko
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class InvalidEnvelope < Error
9
+ def initialize(value)
10
+ super(
11
+ "Envelope #{value.inspect} does not match the expected " \
12
+ "\"version:payload\" format."
13
+ )
14
+ end
15
+ end
16
+
17
+ class UnknownKeyVersion < Error
18
+ def initialize(version)
19
+ super(
20
+ "No key configured for version #{version.inspect}. Add it to " \
21
+ "Kinko.configure so old values remain decryptable."
22
+ )
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake"
4
+ require "kinko"
5
+
6
+ namespace :kinko do
7
+ desc "Print a fresh base64-encoded 32-byte key"
8
+ task :generate_key do
9
+ puts Kinko.generate_key
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kinko
4
+ VERSION = "0.1.0"
5
+ end
data/lib/kinko.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "kinko/version"
5
+ require_relative "kinko/error"
6
+ require_relative "kinko/configuration"
7
+ require_relative "kinko/cipher"
8
+ require_relative "kinko/blind_index"
9
+
10
+ module Kinko
11
+ class << self
12
+ def config
13
+ @config ||= Configuration.new
14
+ end
15
+
16
+ def configure
17
+ yield config
18
+ config.require!
19
+ config
20
+ end
21
+
22
+ def reset_config!
23
+ @config = Configuration.new
24
+ end
25
+
26
+ def generate_key
27
+ Base64.strict_encode64(SecureRandom.random_bytes(Configuration::KEY_BYTES))
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kinko"
4
+
5
+ module Sequel
6
+ module Plugins
7
+ module Kinko
8
+ def self.apply(model, *)
9
+ model.instance_variable_set(:@kinko_encrypted_columns, [])
10
+ model.instance_variable_set(:@kinko_indexed_columns, [])
11
+ model.instance_variable_set(:@kinko_index_normalizers, {})
12
+ end
13
+
14
+ module ClassMethods
15
+ attr_reader :kinko_encrypted_columns, :kinko_indexed_columns, :kinko_index_normalizers
16
+
17
+ def kinko_encrypt(column, type: nil, index: nil, normalize: :none)
18
+ @kinko_encrypted_columns << column
19
+ @kinko_index_normalizers[index] = normalize if index
20
+
21
+ define_method(column) do
22
+ raw = self[column]
23
+ raw.nil? ? nil : ::Kinko::Cipher.decrypt(raw, type: type)
24
+ end
25
+
26
+ define_method("#{column}=") do |value|
27
+ if value.nil?
28
+ self[column] = nil
29
+ self[index] = nil if index
30
+ else
31
+ self[column] = ::Kinko::Cipher.encrypt(value)
32
+ self[index] = ::Kinko::BlindIndex.compute(value, normalize:) if index
33
+ end
34
+ end
35
+ end
36
+
37
+ def kinko_blind_index(column, normalize: :none)
38
+ @kinko_indexed_columns << column
39
+ @kinko_index_normalizers[column] = normalize
40
+
41
+ define_method("#{column}=") do |value|
42
+ if value.nil?
43
+ self[column] = nil
44
+ else
45
+ self[column] = ::Kinko::BlindIndex.compute(value, normalize:)
46
+ end
47
+ end
48
+ end
49
+
50
+ def blind_index_for(column, value)
51
+ normalize = @kinko_index_normalizers.fetch(column) do
52
+ raise ArgumentError, "no blind index registered for #{column.inspect}"
53
+ end
54
+ ::Kinko::BlindIndex.compute(value, normalize:)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kinko
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Wout
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: openssl
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '3.0'
40
+ description: |
41
+ Encrypted column values with versioned key rotation and optional blind
42
+ indexes for equality lookups. Framework-agnostic core (AES-256-GCM
43
+ envelope + HMAC-SHA256 blind index) with opt-in adapters for Sequel, ROM,
44
+ and Active Record. Ruby companion to the Crystal avram_encrypted shard.
45
+ email:
46
+ - hi@wout.codes
47
+ executables: []
48
+ extensions: []
49
+ extra_rdoc_files: []
50
+ files:
51
+ - CHANGELOG.md
52
+ - LICENSE
53
+ - README.md
54
+ - lib/kinko.rb
55
+ - lib/kinko/adapters/active_record.rb
56
+ - lib/kinko/adapters/rom.rb
57
+ - lib/kinko/adapters/sequel.rb
58
+ - lib/kinko/blind_index.rb
59
+ - lib/kinko/cipher.rb
60
+ - lib/kinko/configuration.rb
61
+ - lib/kinko/error.rb
62
+ - lib/kinko/tasks.rb
63
+ - lib/kinko/version.rb
64
+ - lib/sequel/plugins/kinko.rb
65
+ homepage: https://codeberg.org/fluck/kinko
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ source_code_uri: https://codeberg.org/fluck/kinko
70
+ bug_tracker_uri: https://codeberg.org/fluck/kinko/issues
71
+ changelog_uri: https://codeberg.org/fluck/kinko/src/branch/main/CHANGELOG.md
72
+ rubygems_mfa_required: 'true'
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '3.2'
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 4.0.15
88
+ specification_version: 4
89
+ summary: Storage-agnostic encrypted attributes for Ruby, with thin Sequel, ROM, and
90
+ Active Record adapters.
91
+ test_files: []