Kinko
Encrypted columns for Ruby with versioned key rotation and optional blind indexes for equality lookups. Framework-agnostic core and adapters for Sequel, ROM, and Active Record.
Kinko (金庫) is the Japanese word for "safe" or "vault", the small locked box you keep valuables in.
This is a Ruby companion to the Crystal avram_encrypted shard. It bundels two primitives into one gem:
- Cipher. AES-256-GCM envelope encryption keyed by a version prefix, so you can rotate keys without touching old rows. Any value that JSON-serializes survives a round-trip.
- Blind index. HMAC-SHA256 lookup digests, so encrypted columns remain queryable through a sidecar column, with optional case/whitespace/unicode normalization.
[!NOTE] The original repository is hosted at Codeberg. The GitHub repo is just a mirror.
How it compares
kinko
- Frameworks: any Ruby app and adapters for Sequel, ROM, Active Record
- AES-256-GCM + HMAC-SHA256 blind index
- Versioned envelope for key rotation
- JSON round-trip so integers, hashes, and custom objects survive
lockbox
- Frameworks: Active Record, Mongoid
- AES-256-GCM/XSalsa20
- Actively maintained, richer ecosystem
Active Record Encryption
- Frameworks: Active Record only (Rails 7+)
- AES-256-GCM, deterministic + non-deterministic modes
- Built into Rails, no extra gem
On Rails, the built-in encrypts or lockbox will usually be the better fit.
Kinko exists for stacks where a portable core with adapters across Sequel, ROM,
and Active Record matters more than deep framework integration.
When to encrypt vs. blind-index
- Encrypt when you need to read the value back later (email addresses on display, API tokens, JSON payloads). Reversible with the key, unreadable without.
- Blind-index when you only need to verify a value (recovery codes, one-time tokens, deduplication keys). One-way HMAC, high enough to survive a stolen DB dump as long as the key stays out of it.
- Both on the same column when you need reversible storage plus equality
lookups (encrypted email +
email_blind_indexfor the login-by-email query).
Installation
Add this to your Gemfile:
gem "kinko"
Then bundle install.
Quickstart
Configure your keys once at boot:
# config/initializers/kinko.rb
Kinko.configure do |c|
c.keys = {
"v1" => ENV.fetch("KINKO_KEY_V1")
}
c.key_version = "v1"
end
Generate a new key with Kinko.generate_key (returns a base64-encoded 32-byte
string).
[!NOTE] Keys can be anything you like. It could be
"v1","v2", etc. like in the example above, but also something more self-documenting like"202607".
Encrypt and decrypt directly
envelope = Kinko::Cipher.encrypt("I’m not a diva. I’m a queen!")
# => "v1:AAECAwQFBgcICQoLDA..."
Kinko::Cipher.decrypt(envelope)
# => "swordfish"
Non-string values round-trip through JSON:
Kinko::Cipher.encrypt(42) # ⇒ envelope
Kinko::Cipher.decrypt(envelope) # ⇒ 42
Kinko::Cipher.encrypt({name: "moi", age: 36}) # ⇒ envelope
Kinko::Cipher.decrypt(envelope) # ⇒ {"name" => "moi", "age" => 36}
For custom classes, pass a type: that responds to .from_json:
class ContactCard
attr_reader :name, :email
def initialize(name:, email:) = (@name, @email = name, email)
def to_json(*args) = {name: @name, email: @email}.to_json(*args)
def self.from_json(json) = new(**JSON.parse(json, symbolize_names: true))
end
envelope = Kinko::Cipher.encrypt(ContactCard.new(name: "Moi", email: "m@o.i"))
card = Kinko::Cipher.decrypt(envelope, type: ContactCard)
# => #<ContactCard @name="Moi", @email="m@o.i">
Blind-index a value for lookups
digest = Kinko::BlindIndex.compute("attitude")
# => "v1:9f4bcafdb1..."
Kinko::BlindIndex.match?("attitude", digest) # => true
Kinko::BlindIndex.match?("grace", digest) # => false
Normalize the input for forgiving lookups:
Kinko::BlindIndex.compute(" MP@moi.com ", normalize: :downcase_strip)
# ⇒ same digest as "mp@moi.com"
Built-in normalizers: :none (default), :downcase, :strip,
:downcase_strip, :unicode (NFC). Or pass any callable:
Kinko::BlindIndex.compute(phone, normalize: ->(v) { v.to_s.tr("^0-9", "") })
Store the digest in a plain column and query by it directly.
Framework integration
Sequel
require "kinko/adapters/sequel"
class User < Sequel::Model
plugin :kinko
kinko_encrypt :email_ciphertext,
index: :email_blind_index,
normalize: :downcase_strip
kinko_blind_index :recovery_code, normalize: :downcase
end
# Insert transparently:
User.create(email_ciphertext: "hi@moi.com", recovery_code: "abcdef2345")
# Lookup by the blind index:
User.first(email_blind_index: User.blind_index_for(:email_blind_index, "HI@moi.com"))
kinko_encrypt encrypts writes to the column and decrypts reads. When index:
is given, the same value is HMAC'd into the sidecar column for equality
lookups.
kinko_blind_index skips encryption entirely and stores only the digest,
which is what you want for values you only need to verify (recovery codes,
one-time tokens).
Both accept normalize: (any of the built-in strategies or a callable).
kinko_encrypt also accepts type: for custom class deserialization on read.
ROM (and Hanami)
require "kinko/adapters/rom"
class Users < ROM::Relation[:sql]
schema(:users, infer: true)
use :kinko
kinko do
encrypt :email, to: :email_ciphertext,
index: :email_blind_index,
normalize: :downcase_strip
blind_index :recovery_code, to: :recovery_blind_index,
normalize: :downcase
end
end
users.command(:create).call(email: "hi@moi.com", recovery_code: "abc123")
users.first[:email_ciphertext] # => "hi@moi.com"
digest = Users.blind_index_for(:email_blind_index, "HI@moi.com")
users.where(email_blind_index: digest).one
The plugin hooks command(:create/:update) and each. Bypassing those
(e.g. users.insert) writes plaintext.
For explicit changeset-based encryption without the plugin, use
Kinko::Adapters::ROM::Types.encrypted for reads and Kinko::Cipher /
Kinko::BlindIndex for writes.
Active Record
require "kinko/adapters/active_record"
class User < ApplicationRecord
include Kinko::Adapters::ActiveRecord
encrypts :secret_ciphertext
encrypts :email_ciphertext, index: :email_blind_index, normalize: :downcase_strip
blind_indexed :recovery_code, normalize: :downcase
end
user = User.create!(email_ciphertext: "hi@moi.com")
User.find_by(email_blind_index: User.blind_index_for(:email_blind_index, "HI@moi.com"))
encrypts writes ciphertext + optional blind index mirror. blind_indexed
stores only the digest. Both accept normalize:; encrypts also accepts
type: for custom classes.
Column types
Encrypted values are base64-encoded envelopes; a String / text column
holds them fine. If you prefer bytes, decode before storing, but base64 is
the default so migrations don't need to think about binary.
Blind indexes are "vN:" (or any other key name format you chose) plus 64 hex
characters (~68 characters). Any String column with an index on it works:
# Sequel migration
alter_table :users do
add_column :email_ciphertext, String
add_column :email_blind_index, String
add_index :email_blind_index, unique: true
end
# Rails migration
add_column :users, :email_ciphertext, :string
add_column :users, :email_blind_index, :string
add_index :users, :email_blind_index, unique: true
Key rotation
Add the new key alongside the old one, flip key_version, and existing rows
keep decrypting from their original key while new writes use the new one:
Kinko.configure do |c|
c.keys = {
"v1" => ENV.fetch("KINKO_KEY_V1"),
"v2" => ENV.fetch("KINKO_KEY_V2")
}
c.key_version = "v2"
end
Rewrap old ciphertexts in bulk when you have time:
User.where(Sequel.like(:email_ciphertext, "v1:%")).each do |user|
user.update(email_ciphertext: Kinko::Cipher.recrypt(user[:email_ciphertext]))
end
Blind indexes are deterministic per key, so their equivalent rotation is to recompute from the plaintext (which you have during writes). Old digests keep matching until the row is next written.
Threat model
Kinko protects against database-only compromise: an attacker with a copy of your rows can't read encrypted values or brute-force blind indexes without the encryption key.
Kinko does not protect against:
- Compromise of the application host, where the key is loaded into memory.
- Application-layer bugs (SQL injection, template injection) that let an
attacker call
Kinko::Cipher.decrypton your behalf.
Store your keys somewhere the database can't reach. Rotate them if you suspect either the DB or the app host was accessed.
For anything more elaborate (per-tenant keys, envelope encryption backed by a KMS, deterministic ciphers) you probably want lockbox or a purpose-built solution.
Contributing
We use conventional commits for our commit messages, so please adhere to that pattern.
- Fork it (https://codeberg.org/fluck/kinko/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'feat: new feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
License
The gem is available as open source under the terms of the MIT License.