feat: wire up cookie_from and csrf_token_from
This commit is contained in:
1 parent
2cbcdf771c
commit
42683a6504
1 file changed
+204
@@ -0,0 +1,204 @@
|
||||
# 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
|
||||
Reference in new issue
Block a user