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
- .key_provider ⇒ Object
-
.previous_master_key ⇒ Object
The key being rotated AWAY from.
Class Method Summary collapse
-
.aad(scope, table, column) ⇒ Object
The Additional Authenticated Data that BINDS a ciphertext to where it lives — the key scope (tenant/owner) + table + column.
-
.blind_index(value, tenant_id, table: nil, column: nil) ⇒ Object
A deterministic, per-tenant keyed hash for exact-match lookup of an encrypted field.
-
.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.
- .decrypt(payload, tenant_id) ⇒ Object
- .decrypt_scoped(payload, scope, aad: nil) ⇒ Object
-
.encrypt(plaintext, tenant_id) ⇒ Object
Tenant-scoped operations — the default for entity fields via OpenLoam::Encryptable.
-
.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.
- .master_key ⇒ Object
- .master_key=(value) ⇒ Object
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 |