# 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