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 +7 -0
- data/CHANGELOG.md +34 -0
- data/LICENSE +21 -0
- data/README.md +340 -0
- data/lib/kinko/adapters/active_record.rb +56 -0
- data/lib/kinko/adapters/rom.rb +37 -0
- data/lib/kinko/adapters/sequel.rb +4 -0
- data/lib/kinko/blind_index.rb +78 -0
- data/lib/kinko/cipher.rb +91 -0
- data/lib/kinko/configuration.rb +32 -0
- data/lib/kinko/error.rb +25 -0
- data/lib/kinko/tasks.rb +11 -0
- data/lib/kinko/version.rb +5 -0
- data/lib/kinko.rb +30 -0
- data/lib/sequel/plugins/kinko.rb +59 -0
- metadata +91 -0
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
|
+
[](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,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
|
data/lib/kinko/cipher.rb
ADDED
|
@@ -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
|
data/lib/kinko/error.rb
ADDED
|
@@ -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
|
data/lib/kinko/tasks.rb
ADDED
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: []
|