Module: OpenLoam::Encryption

Defined in:
lib/open_loam/encryption.rb,
lib/open_loam/encryption/cipher.rb,
lib/open_loam/encryption/key_provider.rb

Overview

Field-level encryption at rest, keyed per tenant.

The facade the rest of OpenLoam calls: encrypt/decrypt seal and open a value with the tenant's derived AES-256-GCM key, and blind_index computes the per-tenant HMAC used to find an encrypted field by exact value. Key derivation is delegated to a pluggable key_provider (HKDF by default, a KMS in production), so this module holds the scheme, not the key material.

Defined Under Namespace

Modules: Cipher Classes: DecryptionError, Error, HkdfKeyProvider, KeyProvider, MissingMasterKeyError

Constant Summary collapse

MASTER_KEY_MIN_BYTES =

HKDF extracts entropy from whatever it is given, but a short master key is a short master key — refuse anything below 256 bits of material.

32

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.key_provider ⇒ Object



36
37
38
# File 'lib/open_loam/encryption.rb', line 36

def key_provider
  @key_provider ||= HkdfKeyProvider.new
end

.previous_master_key ⇒ Object

The key being rotated AWAY from. Set it alongside the new master and decryption falls back to it, which is what makes rotation possible at all: open_loam:encryption:rotate has to READ every row under the old key before it can rewrite it under the new one. Unset it once the rotation has run everywhere.



51
52
53
54
55
# File 'lib/open_loam/encryption.rb', line 51

def previous_master_key
  key = defined?(@previous_master_key) ? @previous_master_key : nil
  key ||= ENV["OPEN_LOAM_PREVIOUS_MASTER_KEY"]
  key.presence
end

Class Method Details

.aad(scope, table, column) ⇒ Object

The Additional Authenticated Data that BINDS a ciphertext to where it lives — the key scope (tenant/owner) + table + column. Reconstructed identically on read and write, so a blob moved to a different column, table, or tenant fails the auth tag. NOT the record id (see OpenLoam::Encryptable): the id is unknown at INSERT time, and binding it would force an ugly post-insert double-write; record-swap within one tenant+table+column stays a documented residual.



118
119
120
# File 'lib/open_loam/encryption.rb', line 118

def aad(scope, table, column)
  "loam-aad:v2:#{scope}:#{table}:#{column}"
end

.blind_index(value, tenant_id, table: nil, column: nil) ⇒ Object

A deterministic, per-tenant keyed hash for exact-match lookup of an encrypted field. It leaks equality WITHIN a tenant (same value → same hash) — the accepted trade-off for searchability — but the per-tenant HMAC key means the same value hashes differently across tenants, so equality never leaks between them. Only searchable fields get one.



84
85
86
# File 'lib/open_loam/encryption.rb', line 84

def blind_index(value, tenant_id, table: nil, column: nil)
  blind_index_scoped(value, tenant_scope(tenant_id), table: table, column: column)
end

.blind_index_scoped(value, scope, table: nil, column: nil) ⇒ Object

The key is bound to (scope, table, column) for the same reason the ciphertext AAD is. A key scoped only to the tenant makes one value hash identically in every searchable column in that tenant: a dump correlates rows across tables, and anyone who can write one such field gets an equality oracle against columns they cannot read.

table/column default to nil so an unbound caller still works — the per-tenant key, which is what OpenLoam::PendingActions wants for an idempotency digest that is not a column at all.



131
132
133
134
135
136
# File 'lib/open_loam/encryption.rb', line 131

def blind_index_scoped(value, scope, table: nil, column: nil)
  return nil if value.nil?

  purpose = table && column ? "blind_index/#{table}/#{column}" : :blind_index
  OpenSSL::HMAC.hexdigest("SHA256", data_key(scope, purpose), value.to_s)
end

.decrypt(payload, tenant_id) ⇒ Object



75
76
77
# File 'lib/open_loam/encryption.rb', line 75

def decrypt(payload, tenant_id)
  decrypt_scoped(payload, tenant_scope(tenant_id))
end

.decrypt_scoped(payload, scope, aad: nil) ⇒ Object



96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/open_loam/encryption.rb', line 96

def decrypt_scoped(payload, scope, aad: nil)
  return nil if payload.nil?

  Cipher.open(payload, data_key(scope, :encryption), aad: aad)
rescue DecryptionError
  # GCM's auth tag makes "wrong key" a loud, unambiguous failure, so
  # falling back is safe: a blob that opens under the previous key really
  # was sealed with it. Writes always use the CURRENT key, so a row is
  # rotated the moment anything saves it.
  previous = previous_data_key(scope, :encryption)
  raise if previous.nil?

  Cipher.open(payload, previous, aad: aad)
end

.encrypt(plaintext, tenant_id) ⇒ Object

Tenant-scoped operations — the default for entity fields via OpenLoam::Encryptable. nil stays nil (an unset field is not "the empty string encrypted"); any other value is stringified and sealed.



71
72
73
# File 'lib/open_loam/encryption.rb', line 71

def encrypt(plaintext, tenant_id)
  encrypt_scoped(plaintext, tenant_scope(tenant_id))
end

.encrypt_scoped(plaintext, scope, aad: nil) ⇒ Object

Explicit-scope variants, for data owned by something OTHER than a tenant — an MFA secret, say, keyed "user/42" so it decrypts in whatever tenant the user is currently in, or at login when no tenant is chosen yet.



91
92
93
94
# File 'lib/open_loam/encryption.rb', line 91

def encrypt_scoped(plaintext, scope, aad: nil)
  return nil if plaintext.nil?
  Cipher.seal(plaintext.to_s, data_key(scope, :encryption), aad: aad)
end

.master_key ⇒ Object



57
58
59
60
61
62
63
64
65
66
# File 'lib/open_loam/encryption.rb', line 57

def master_key
  key = @master_key || ENV["OPEN_LOAM_MASTER_KEY"]
  raise MissingMasterKeyError if key.nil? || key.empty?
  if key.bytesize < MASTER_KEY_MIN_BYTES
    raise MissingMasterKeyError,
          "OPEN_LOAM_MASTER_KEY is too short (#{key.bytesize} bytes); use at least " \
          "#{MASTER_KEY_MIN_BYTES}, e.g. `SecureRandom.hex(32)`."
  end
  key
end

.master_key=(value) ⇒ Object



40
41
42
# File 'lib/open_loam/encryption.rb', line 40

def master_key=(value)
  @master_key = value
end