Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,18 @@ To decrypt data outside the model, use:
User.decrypt_email_ciphertext(user.email_ciphertext)
```

#### Protected Encryption Contexts

Lockbox respects Active Record's protected encryption context. Inside the block, encrypted attributes return their ciphertext and can't be modified.

```ruby
ActiveRecord::Encryption.protecting_encrypted_data do
User.first.email # ciphertext
end
```

This provides no-decryption-by-default support for [console1984](https://github.com/basecamp/console1984).

## Action Text

**Note:** Action Text uses direct uploads for files, which cannot be encrypted with application-level encryption like Lockbox. This only encrypts the database field.
Expand Down
4 changes: 4 additions & 0 deletions lib/lockbox.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ def self.master_key
@master_key ||= ENV["LOCKBOX_MASTER_KEY"]
end

def self.protecting_encrypted_data?
defined?(ActiveRecord::Encryption) && ActiveRecord::Encryption.context.frozen_encryption?
end

def self.migrate(relation, batch_size: 1000, restart: false)
Migrator.new(relation, batch_size: batch_size).migrate(restart: restart)
end
Expand Down
2 changes: 2 additions & 0 deletions lib/lockbox/encryptor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ def encrypt(message, **options)
end

def decrypt(ciphertext, **options)
return ciphertext if Lockbox.protecting_encrypted_data?

ciphertext = ciphertext.unpack1("m") if @encode
ciphertext = check_string(ciphertext)

Expand Down
25 changes: 25 additions & 0 deletions lib/lockbox/model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,21 @@ def inspect
end

if activerecord
unless private_method_defined?(:lockbox_cant_modify_encrypted_attributes_when_protected)
validate :lockbox_cant_modify_encrypted_attributes_when_protected, if: -> { Lockbox.protecting_encrypted_data? }

def lockbox_cant_modify_encrypted_attributes_when_protected
self.class.lockbox_attributes.each_value do |lockbox_attribute|
attribute = lockbox_attribute[:attribute]
encrypted_attribute = lockbox_attribute[:encrypted_attribute]
if will_save_change_to_attribute?(attribute) || will_save_change_to_attribute?(encrypted_attribute)
errors.add(attribute.to_sym, "can't be modified because it is encrypted")
end
end
end
private :lockbox_cant_modify_encrypted_attributes_when_protected
end

# TODO wrap in module?
def attributes
# load attributes
Expand All @@ -149,6 +164,8 @@ def attributes
if !attributes.include?(lockbox_attribute[:encrypted_attribute].to_s)
attributes.delete(k.to_s)
attributes.delete(lockbox_attribute[:attribute])
elsif Lockbox.protecting_encrypted_data?
attributes[lockbox_attribute[:attribute]] = attributes[lockbox_attribute[:encrypted_attribute].to_s]
end
end
attributes
Expand Down Expand Up @@ -395,6 +412,8 @@ def reload
end

define_method("#{name}_was") do
return send("#{encrypted_attribute}_was") if Lockbox.protecting_encrypted_data?

send(name) # writes attribute when not already set
super()
end
Expand All @@ -406,6 +425,8 @@ def reload
end

define_method("#{name}_in_database") do
return send("#{encrypted_attribute}_in_database") if Lockbox.protecting_encrypted_data?

send(name) # writes attribute when not already set
super()
end
Expand Down Expand Up @@ -499,6 +520,8 @@ def reload
private :"lockbox_direct_#{name}="

define_method(name) do
return send(encrypted_attribute) if Lockbox.protecting_encrypted_data?

message = super()

# possibly keep track of decrypted attributes directly in the future
Expand Down Expand Up @@ -603,6 +626,8 @@ def reload
end

define_singleton_method decrypt_method_name do |ciphertext, **opts|
return ciphertext if Lockbox.protecting_encrypted_data?

message =
if ciphertext.nil? || (ciphertext == "" && !options[:padding])
ciphertext
Expand Down
14 changes: 14 additions & 0 deletions test/lockbox_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ def test_works
assert_equal Encoding::BINARY, lockbox.decrypt(ciphertext).encoding
end

def test_protecting_encrypted_data
skip unless defined?(ActiveRecord::Encryption)

lockbox = Lockbox.new(key: random_key, encode: true)
message = "it works!"
ciphertext = lockbox.encrypt(message)

ActiveRecord::Encryption.protecting_encrypted_data do
assert_equal ciphertext, lockbox.decrypt(ciphertext)
end

assert_equal message, lockbox.decrypt(ciphertext)
end

def test_same_message_different_ciphertext
lockbox = Lockbox.new(key: random_key)
message = "it works!"
Expand Down
75 changes: 75 additions & 0 deletions test/model_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,81 @@ def test_symmetric
assert_equal email, user.email
end

def test_protecting_encrypted_data
skip if mongoid?

email = "test@example.org"
user = User.create!(email: email)
user = User.last
ciphertext = user.email_ciphertext

# ensure cached plaintext isn't returned in the protected context
assert_equal email, user.email

ActiveRecord::Encryption.protecting_encrypted_data do
assert Lockbox.protecting_encrypted_data?
assert_equal ciphertext, user.email
assert_equal ciphertext, user.attributes["email"]
assert_equal ciphertext, user.email_was
assert_equal ciphertext, user.email_in_database
assert_equal ciphertext, User.decrypt_email_ciphertext(ciphertext)
assert_equal [ciphertext], User.where(id: user.id).pluck(:email)
end

assert !Lockbox.protecting_encrypted_data?
assert_equal email, user.email
end

def test_protecting_typed_encrypted_data
skip if mongoid?

user = User.create!(born_on2: Date.new(2020, 1, 2))
user = User.last

ActiveRecord::Encryption.protecting_encrypted_data do
assert_equal user.born_on2_ciphertext, user.born_on2
end
end

def test_cant_modify_encrypted_data_when_protected
skip if mongoid?

email = "test@example.org"
user = User.create!(email: email, name: "Test")

ActiveRecord::Encryption.protecting_encrypted_data do
error = assert_raises ActiveRecord::RecordInvalid do
user.update!(email: "new@example.org")
end
assert_includes error.record.errors[:email], "can't be modified because it is encrypted"

user.reload
user.update!(name: "New")
end

assert_equal email, user.reload.email
assert_equal "New", user.name
end

def test_protected_validation_isnt_duplicated_in_subclasses
skip if mongoid?

parent_class = Class.new(ActiveRecord::Base) do
self.table_name = "users"
has_encrypted :email
end
child_class = Class.new(parent_class) do
has_encrypted :city
end
user = child_class.create!(email: "test@example.org", city: "San Francisco")

ActiveRecord::Encryption.protecting_encrypted_data do
user.email = "new@example.org"
assert !user.valid?
assert_equal ["can't be modified because it is encrypted"], user.errors[:email]
end
end

def test_decrypt_after_destroy
email = "test@example.org"
User.create!(email: email)
Expand Down