205 lines
12 KiB
Ruby
205 lines
12 KiB
Ruby
# Runs ONE phase of the encryption key rotation lifecycle against
|
|
# examples/blog, over a real HTTP request cycle. Always invoked as a brand
|
|
# new `ruby` process by blog_rotation_demo.rb (never required directly) -
|
|
# Prism::Orm::Encryption derives and caches each model's encryption key
|
|
# once per class per process (see encryption_key_for), so genuinely retiring
|
|
# an old secret and adopting a new one needs a fresh process with fresh ENV,
|
|
# not just a different in-process config call.
|
|
$LOAD_PATH.unshift(File.expand_path('lib', __dir__))
|
|
require 'prism'
|
|
require 'net/http'
|
|
require_relative 'examples/blog/app'
|
|
|
|
V1_SECRET = 'blog-demo-encryption-secret-v1'.freeze
|
|
V2_SECRET = 'blog-demo-encryption-secret-v2'.freeze
|
|
EMAIL = 'rotation@example.com'.freeze
|
|
EMAIL_AFTER_UPDATE = 'rotation-updated@example.com'.freeze
|
|
PASSWORD = 'correct horse battery staple v2'.freeze
|
|
EMAIL_SALT = 'prism-orm-encryption/users/email'.freeze
|
|
|
|
# A second row provisioned alongside the first but NEVER re-saved during
|
|
# the transition window (no login, no account update in 'rotate_and_login')
|
|
# - the realistic case of a row an operator forgot about, or a user who
|
|
# simply never signs back in before the cutover deadline. Same password
|
|
# for both so post_rotation_final's failing login attempt is unambiguously
|
|
# about the ciphertext, not a typo'd credential.
|
|
STRANDED_EMAIL = 'stranded@example.com'.freeze
|
|
|
|
def cookie_from(response)
|
|
lines = response.get_fields('set-cookie') || []
|
|
lines.map { |line| line.split(';').first }.join('; ')
|
|
end
|
|
|
|
def csrf_token_from(html)
|
|
html[/name="csrf-token" content="([^"]+)"/, 1]
|
|
end
|
|
|
|
# Independent of anything User/Encryption has cached in this process -
|
|
# derives a key straight from a raw secret and tries to decrypt with it,
|
|
# so a phase can prove exactly WHICH secret a given row's ciphertext is
|
|
# really tied to, not just that "some configured key" happens to work.
|
|
def decrypts_under?(secret, ciphertext)
|
|
key = Prism::MessageEncryptor.derive_key(secret, salt: EMAIL_SALT)
|
|
Prism::Orm::Encryption.decrypt(key, ciphertext)
|
|
rescue Prism::Orm::Encryption::Error
|
|
nil
|
|
end
|
|
|
|
phase = ARGV[0]
|
|
|
|
server = Prism::Server.new(Blog, port: 0).start
|
|
port = server.port
|
|
|
|
case phase
|
|
when 'signup'
|
|
Net::HTTP.start('127.0.0.1', port) do |http|
|
|
home = http.get('/')
|
|
cookie = cookie_from(home)
|
|
token = csrf_token_from(home.body)
|
|
|
|
body = URI.encode_www_form(
|
|
'user[email]' => EMAIL, 'user[password]' => PASSWORD,
|
|
'user[password_confirmation]' => PASSWORD, 'authenticity_token' => token
|
|
)
|
|
signup = http.post('/users', body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
|
raise "expected sign-up to redirect, got #{signup.code}" unless signup.code == '302'
|
|
|
|
user = User.find_by(email: EMAIL)
|
|
raise 'expected to find the freshly signed-up user by plaintext email under the v1 key' unless user
|
|
|
|
row = Prism::Orm.store.find(:users, user.id)
|
|
ciphertext = row['email']
|
|
raise 'expected users.email to be stored encrypted, not plaintext' if ciphertext == EMAIL
|
|
|
|
v1_plaintext = decrypts_under?(V1_SECRET, ciphertext)
|
|
raise "expected the row to decrypt under v1, got #{v1_plaintext.inspect}" unless v1_plaintext == EMAIL
|
|
raise 'expected the row NOT to decrypt under v2 yet (never encrypted with it)' if decrypts_under?(V2_SECRET, ciphertext)
|
|
|
|
puts 'signup: user provisioned, email ciphertext independently confirmed tied to the v1 secret only'
|
|
|
|
# Second row, provisioned the same way, that 'rotate_and_login' will
|
|
# deliberately leave untouched - the row this whole scenario is about.
|
|
stranded_body = URI.encode_www_form(
|
|
'user[email]' => STRANDED_EMAIL, 'user[password]' => PASSWORD,
|
|
'user[password_confirmation]' => PASSWORD, 'authenticity_token' => token
|
|
)
|
|
stranded_signup = http.post('/users', stranded_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
|
raise "expected the stranded user's sign-up to redirect, got #{stranded_signup.code}" unless stranded_signup.code == '302'
|
|
raise 'expected to find the stranded user right after signing up' unless User.find_by(email: STRANDED_EMAIL)
|
|
|
|
puts 'signup: second user provisioned under v1 too - rotate_and_login will deliberately never touch this one'
|
|
end
|
|
when 'rotate_and_login'
|
|
Net::HTTP.start('127.0.0.1', port) do |http|
|
|
home = http.get('/')
|
|
cookie = cookie_from(home)
|
|
token = csrf_token_from(home.body)
|
|
|
|
# The app is now running with v2 as the CURRENT key and v1 only as a
|
|
# previous_keys fallback - this row has never been touched since
|
|
# 'signup' wrote it under v1, so finding it at all here is the real
|
|
# proof that Encryption.decrypt_with_rotation's fallback (and
|
|
# encrypt_for_query's multi-candidate IN-list) both work over a real
|
|
# request cycle, not just against a fixture in a unit test.
|
|
login_body = URI.encode_www_form('user[email]' => EMAIL, 'user[password]' => PASSWORD, 'authenticity_token' => token)
|
|
signed_in = http.post('/session', login_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
|
raise "expected sign-in on a still-v1-encrypted row to succeed via previous_keys, got #{signed_in.code}" unless signed_in.code == '302'
|
|
cookie = cookie_from(signed_in)
|
|
|
|
edit_page = http.get('/account/edit', 'Cookie' => cookie)
|
|
raise "expected the account edit page to render, got #{edit_page.code}" unless edit_page.code == '200'
|
|
edit_token = csrf_token_from(edit_page.body)
|
|
|
|
# Changing the email is what actually forces a fresh Encryption.encrypt
|
|
# call (the setter's no-op guard only skips re-encrypting when the
|
|
# plaintext is unchanged) - the natural way a real user's row gets
|
|
# migrated onto the new key, exactly as Prism::Orm::Encryption's own
|
|
# comments describe ("any row that gets explicitly re-saved").
|
|
update_body = URI.encode_www_form('user[email]' => EMAIL_AFTER_UPDATE, '_method' => 'PATCH', 'authenticity_token' => edit_token)
|
|
updated = http.post('/account', update_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
|
raise "expected the account update to redirect, got #{updated.code}" unless updated.code == '302'
|
|
|
|
user = User.find_by(email: EMAIL_AFTER_UPDATE)
|
|
raise 'expected to find the user by their new plaintext email right after updating it' unless user
|
|
|
|
row = Prism::Orm.store.find(:users, user.id)
|
|
ciphertext = row['email']
|
|
v2_plaintext = decrypts_under?(V2_SECRET, ciphertext)
|
|
raise "expected the re-saved row to now decrypt under v2, got #{v2_plaintext.inspect}" unless v2_plaintext == EMAIL_AFTER_UPDATE
|
|
raise 'expected the re-saved row NOT to decrypt under the retired v1 secret anymore' if decrypts_under?(V1_SECRET, ciphertext)
|
|
|
|
puts 'rotate_and_login: signed in via the v1 fallback, updated the email, row now migrated onto v2 only'
|
|
|
|
# Deliberately NOT touched here: the stranded user's row stays exactly
|
|
# as 'signup' left it, still encrypted under v1 only, still findable
|
|
# right now only because this phase still carries v1 in previous_keys.
|
|
raise 'expected the stranded user to still be reachable during the transition window' unless User.find_by(email: STRANDED_EMAIL)
|
|
puts 'rotate_and_login: stranded user deliberately left untouched (still v1-only on disk)'
|
|
end
|
|
when 'post_rotation_final'
|
|
Net::HTTP.start('127.0.0.1', port) do |http|
|
|
home = http.get('/')
|
|
cookie = cookie_from(home)
|
|
token = csrf_token_from(home.body)
|
|
|
|
# v1 has been fully retired (PRISM_BLOG_ENCRYPTION_PREVIOUS_SECRETS is
|
|
# unset for this phase) - the migrated row from 'rotate_and_login' must
|
|
# still authenticate on v2 alone, proving the cutover is safe to
|
|
# complete once every row has actually been re-saved.
|
|
login_body = URI.encode_www_form('user[email]' => EMAIL_AFTER_UPDATE, 'user[password]' => PASSWORD, 'authenticity_token' => token)
|
|
signed_in = http.post('/session', login_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
|
raise "expected sign-in on the fully-migrated row to succeed with v1 fully retired, got #{signed_in.code}" unless signed_in.code == '302'
|
|
|
|
puts 'post_rotation_final: migrated user still signs in with v1 completely retired (no previous_keys configured)'
|
|
|
|
# The risk this whole scenario documents: cutting v1 over BEFORE every
|
|
# row has actually been re-saved locks out whoever was never migrated.
|
|
# find_by can't even locate the stranded row anymore - encrypt_for_query
|
|
# only has v2 to build candidate ciphertext with, so it builds the WRONG
|
|
# ciphertext for a still-v1 row and the deterministic lookup just misses
|
|
# (an ordinary index miss, not a raised error) - which surfaces to the
|
|
# user as an ordinary "invalid credentials" rejection, not a crash.
|
|
stranded_login_body = URI.encode_www_form('user[email]' => STRANDED_EMAIL, 'user[password]' => PASSWORD, 'authenticity_token' => token)
|
|
stranded_login = http.post('/session', stranded_login_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
|
raise "expected the stranded (never-migrated) user's login to be rejected as invalid credentials, got #{stranded_login.code}" unless stranded_login.code == '422'
|
|
raise 'expected the stranded login rejection page to actually explain why' unless stranded_login.body.include?('Invalid email or password')
|
|
|
|
# Independent proof of exactly WHY it failed: the row on disk never
|
|
# moved off v1 (nobody re-saved it during the transition window), and
|
|
# v2 alone genuinely cannot read it - scanned directly off the store
|
|
# (find_by can't be used here, that's precisely the query path that
|
|
# just failed above for the same reason).
|
|
stranded_row = Prism::Orm.store.all(:users).find { |row| decrypts_under?(V1_SECRET, row['email']) == STRANDED_EMAIL }
|
|
raise 'expected the stranded row to still be findable by scanning raw ciphertext under v1' unless stranded_row
|
|
raise 'expected the stranded row to NOT decrypt under v2 (this is the whole point: it was never re-saved)' if decrypts_under?(V2_SECRET, stranded_row['email'])
|
|
|
|
puts 'post_rotation_final: stranded (never-migrated) user correctly locked out after full cutover - ' \
|
|
'confirmed the row is genuinely still v1-only on disk, not a false negative'
|
|
|
|
# The login rejection above is a SILENT miss (encrypt_for_query builds
|
|
# the wrong ciphertext candidate and the lookup just finds nothing, an
|
|
# ordinary "invalid credentials" 422). Reading the same row's `.email`
|
|
# directly - by primary key, not through the encrypted-attribute query
|
|
# path - is a genuinely different failure shape: `find` never touches
|
|
# ciphertext (it looks up by plain `id`), so the row loads fine, and it
|
|
# is only the `encrypts :email` reader itself that has no key left to
|
|
# try (current v2 fails, and previous_keys is empty with v1 fully
|
|
# retired) - `decrypt_with_rotation` re-raises the original
|
|
# Encryption::Error instead of returning nil or garbage. Documents the
|
|
# contrast on purpose: a query silently sees nothing, a direct read
|
|
# loudly blows up.
|
|
stranded_user = User.find(stranded_row['id'])
|
|
begin
|
|
stranded_user.email
|
|
raise 'expected reading .email directly on a fully-retired-key row to raise Encryption::Error, it did not raise at all'
|
|
rescue Prism::Orm::Encryption::Error
|
|
puts 'post_rotation_final: direct .email read on the stranded row raised Encryption::Error as expected ' \
|
|
'(no key left that can decrypt it), unlike the silent find_by miss above'
|
|
end
|
|
end
|
|
else
|
|
raise "unknown phase #{phase.inspect} (expected signup, rotate_and_login, or post_rotation_final)"
|
|
end
|
|
|
|
server.stop
|