160 lines
6.3 KiB
Ruby
160 lines
6.3 KiB
Ruby
require 'minitest/autorun'
|
|
require 'prism'
|
|
require 'tmpdir'
|
|
|
|
# Key rotation for Prism::Orm::Encryption: retiring an application secret
|
|
# (a suspected leak, a scheduled policy) should not make every already-
|
|
# encrypted row unreadable the moment the new secret goes live. These tests
|
|
# encrypt a row under an OLD secret, then simulate the moment of rotation
|
|
# (primary_key flips to a new secret, the old one moves to previous_keys),
|
|
# and check that reads/queries against the old row keep working while
|
|
# writes immediately start using the new key.
|
|
class OrmEncryptionRotationTest < Minitest::Test
|
|
OLD_SECRET = 'rotation-old-secret-do-not-use-in-prod'.freeze
|
|
NEW_SECRET = 'rotation-new-secret-do-not-use-in-prod'.freeze
|
|
|
|
def setup
|
|
@dir = Dir.mktmpdir('prism-encryption-rotation-store')
|
|
@store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore'))
|
|
@store.create_table(:patients, %w[id name ssn notes])
|
|
end
|
|
|
|
def teardown
|
|
FileUtils.remove_entry(@dir)
|
|
Prism::Orm::Encryption.primary_key = nil
|
|
Prism::Orm::Encryption.previous_keys = []
|
|
Object.send(:remove_const, :RotatingPatient) if Object.const_defined?(:RotatingPatient)
|
|
end
|
|
|
|
def define_patient_class
|
|
klass = Class.new(Prism::Orm::Model) do
|
|
table_name :patients
|
|
encrypts :ssn, deterministic: true
|
|
encrypts :notes
|
|
end
|
|
klass.store = @store
|
|
Object.const_set(:RotatingPatient, klass)
|
|
klass
|
|
end
|
|
|
|
def test_a_row_encrypted_under_the_old_key_is_unreadable_without_rotation_configured
|
|
Prism::Orm::Encryption.primary_key = OLD_SECRET
|
|
old_class = define_patient_class
|
|
patient = old_class.create!(name: 'Alice', ssn: '123-45-6789', notes: 'pre-rotation note')
|
|
|
|
Object.send(:remove_const, :RotatingPatient)
|
|
Prism::Orm::Encryption.primary_key = NEW_SECRET
|
|
new_class = define_patient_class
|
|
|
|
error = assert_raises(Prism::Orm::Encryption::Error) { new_class.find(patient.id).notes }
|
|
assert_match(/wrong key/, error.message)
|
|
end
|
|
|
|
def test_a_row_encrypted_under_the_old_key_stays_readable_once_rotation_is_configured
|
|
Prism::Orm::Encryption.primary_key = OLD_SECRET
|
|
old_class = define_patient_class
|
|
patient = old_class.create!(name: 'Alice', ssn: '123-45-6789', notes: 'pre-rotation note')
|
|
|
|
Object.send(:remove_const, :RotatingPatient)
|
|
Prism::Orm::Encryption.primary_key = NEW_SECRET
|
|
Prism::Orm::Encryption.previous_keys = [OLD_SECRET]
|
|
new_class = define_patient_class
|
|
|
|
reloaded = new_class.find(patient.id)
|
|
assert_equal '123-45-6789', reloaded.ssn
|
|
assert_equal 'pre-rotation note', reloaded.notes
|
|
end
|
|
|
|
def test_a_row_re_saved_after_rotation_is_re_encrypted_under_the_new_key
|
|
Prism::Orm::Encryption.primary_key = OLD_SECRET
|
|
old_class = define_patient_class
|
|
patient = old_class.create!(name: 'Alice', ssn: '123-45-6789', notes: 'pre-rotation note')
|
|
|
|
Object.send(:remove_const, :RotatingPatient)
|
|
Prism::Orm::Encryption.primary_key = NEW_SECRET
|
|
Prism::Orm::Encryption.previous_keys = [OLD_SECRET]
|
|
new_class = define_patient_class
|
|
|
|
record = new_class.find(patient.id)
|
|
record.notes = 'still the same note, just re-saved'
|
|
record.save!
|
|
|
|
# Once re-saved, the OLD key alone (rotation not configured on THIS
|
|
# class) must no longer be able to decrypt it - proving the write path
|
|
# switched to the new key rather than continuing to use whichever key
|
|
# happened to work on read.
|
|
Object.send(:remove_const, :RotatingPatient)
|
|
Prism::Orm::Encryption.primary_key = OLD_SECRET
|
|
Prism::Orm::Encryption.previous_keys = []
|
|
old_key_only_class = define_patient_class
|
|
error = assert_raises(Prism::Orm::Encryption::Error) { old_key_only_class.find(patient.id).notes }
|
|
assert_match(/wrong key/, error.message)
|
|
end
|
|
|
|
def test_where_on_a_deterministic_column_finds_rows_encrypted_under_either_key
|
|
Prism::Orm::Encryption.primary_key = OLD_SECRET
|
|
old_class = define_patient_class
|
|
stale = old_class.create!(name: 'Stale', ssn: '555-55-5555', notes: 'n')
|
|
|
|
Object.send(:remove_const, :RotatingPatient)
|
|
Prism::Orm::Encryption.primary_key = NEW_SECRET
|
|
Prism::Orm::Encryption.previous_keys = [OLD_SECRET]
|
|
new_class = define_patient_class
|
|
fresh = new_class.create!(name: 'Fresh', ssn: '555-55-5555', notes: 'n')
|
|
|
|
found = new_class.where(ssn: '555-55-5555').to_a.map(&:id).sort
|
|
assert_equal [stale.id, fresh.id].sort, found
|
|
end
|
|
|
|
def test_find_by_on_a_deterministic_column_matches_a_row_still_encrypted_under_the_old_key
|
|
Prism::Orm::Encryption.primary_key = OLD_SECRET
|
|
old_class = define_patient_class
|
|
stale = old_class.create!(name: 'Stale', ssn: '999-99-9999', notes: 'n')
|
|
|
|
Object.send(:remove_const, :RotatingPatient)
|
|
Prism::Orm::Encryption.primary_key = NEW_SECRET
|
|
Prism::Orm::Encryption.previous_keys = [OLD_SECRET]
|
|
new_class = define_patient_class
|
|
|
|
found = new_class.find_by(ssn: '999-99-9999')
|
|
assert_equal stale.id, found.id
|
|
end
|
|
|
|
def test_per_attribute_previous_keys_overrides_the_global_rotation_list
|
|
Prism::Orm::Encryption.primary_key = NEW_SECRET
|
|
Prism::Orm::Encryption.previous_keys = ['a-secret-that-was-never-used']
|
|
|
|
scoped_old_class = Class.new(Prism::Orm::Model) do
|
|
table_name :patients
|
|
encrypts :notes, key: OLD_SECRET
|
|
end
|
|
scoped_old_class.store = @store
|
|
patient = scoped_old_class.create!(name: 'Alice', notes: 'scoped secret note')
|
|
|
|
scoped_new_class = Class.new(Prism::Orm::Model) do
|
|
table_name :patients
|
|
encrypts :notes, key: NEW_SECRET, previous_keys: [OLD_SECRET]
|
|
end
|
|
scoped_new_class.store = @store
|
|
|
|
assert_equal 'scoped secret note', scoped_new_class.find(patient.id).notes
|
|
end
|
|
|
|
def test_a_tampered_ciphertext_still_raises_even_with_rotation_configured
|
|
Prism::Orm::Encryption.primary_key = OLD_SECRET
|
|
old_class = define_patient_class
|
|
patient = old_class.create!(name: 'Alice', ssn: '123-45-6789', notes: 'note')
|
|
row = @store.find(:patients, patient.id)
|
|
tampered = row.dup
|
|
tampered['notes'] = row['notes'].sub(/^./) { |c| c == 'A' ? 'B' : 'A' }
|
|
@store.update(:patients, patient.id, tampered)
|
|
|
|
Object.send(:remove_const, :RotatingPatient)
|
|
Prism::Orm::Encryption.primary_key = NEW_SECRET
|
|
Prism::Orm::Encryption.previous_keys = [OLD_SECRET]
|
|
new_class = define_patient_class
|
|
|
|
assert_raises(Prism::Orm::Encryption::Error) { new_class.find(patient.id).notes }
|
|
end
|
|
end
|