Kinko

CI Version

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:

  1. 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.
  2. 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_index for 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.decrypt on 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.

  1. Fork it (https://codeberg.org/fluck/kinko/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'feat: new feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

License

The gem is available as open source under the terms of the MIT License.