diff --git a/lib/prism/security/cookie_jar.rb b/lib/prism/security/cookie_jar.rb new file mode 100644 index 0000000..b4bd91a --- /dev/null +++ b/lib/prism/security/cookie_jar.rb @@ -0,0 +1,84 @@ +module Prism + # Ties MessageVerifier/MessageEncryptor to a request/response pair so + # controllers can read and write tamper-evident or fully opaque cookies + # without touching OpenSSL directly. + class CookieJar + # `previous_secrets:` gives cookies.signed/cookies.encrypted the same + # rotation contract as Application#enable_attribute_encryption and + # Prism::Session: a cookie written under a retired secret still reads + # back, a fresh #[]= write always signs/encrypts under `secret`. + def initialize(request, response, secret:, previous_secrets: []) + @request = request + @response = response + @verifier = MessageVerifier.new(secret) + @encryptor = MessageEncryptor.new(MessageEncryptor.derive_key(secret)) + @previous_verifiers = previous_secrets.map { |previous| MessageVerifier.new(previous) } + @previous_encryptors = previous_secrets.map { |previous| MessageEncryptor.new(MessageEncryptor.derive_key(previous)) } + end + + def [](name) + @request.cookies[name.to_s] + end + + def []=(name, value_or_options) + value, options = self.class.split_value(value_or_options) + set(name, value, options) + end + + def set(name, value, options = {}) + @response.set_cookie(name, value.to_s, options) + end + + def signed + @signed ||= Accessor.new(self, @verifier, :generate, :verified, fallback_codecs: @previous_verifiers) + end + + def encrypted + @encrypted ||= Accessor.new(self, @encryptor, :encrypt_and_sign, :decrypt_and_verify_safe, fallback_codecs: @previous_encryptors) + end + + def self.split_value(value_or_options) + if value_or_options.is_a?(Hash) && value_or_options.key?(:value) + options = value_or_options.dup + [options.delete(:value), options] + else + [value_or_options, {}] + end + end + + class Accessor + def initialize(jar, codec, encode_method, decode_method, fallback_codecs: []) + @jar = jar + @codec = codec + @encode_method = encode_method + @decode_method = decode_method + @fallback_codecs = fallback_codecs + end + + def [](name) + raw = @jar[name] + return nil if raw.nil? + decoded = @codec.public_send(@decode_method, raw) + return decoded unless decoded.nil? + @fallback_codecs.each do |codec| + decoded = codec.public_send(@decode_method, raw) + return decoded unless decoded.nil? + end + nil + end + + def []=(name, value_or_options) + value, options = CookieJar.split_value(value_or_options) + @jar.set(name, @codec.public_send(@encode_method, value), options) + end + end + end + + class MessageEncryptor + def decrypt_and_verify_safe(message) + decrypt_and_verify(message) + rescue InvalidMessage + nil + end + end +end diff --git a/lib/prism/security/csrf.rb b/lib/prism/security/csrf.rb new file mode 100644 index 0000000..5ed3d44 --- /dev/null +++ b/lib/prism/security/csrf.rb @@ -0,0 +1,63 @@ +require 'securerandom' + +module Prism + # Per-request masked CSRF tokens (the same scheme Rails uses): the raw + # token lives in the session, but every rendered token is XORed with a + # fresh random mask so two tokens issued to the same page never look + # alike. That defeats BREACH-style compression oracles against a token + # that would otherwise be a constant string embedded next to attacker- + # influenced response bytes. + class Csrf + TOKEN_BYTES = 32 + SESSION_KEY = '_csrf_token' + + def initialize(session) + @session = session + end + + def raw_token + @session[SESSION_KEY] ||= encode(SecureRandom.random_bytes(TOKEN_BYTES)) + end + + def masked_token + mask = SecureRandom.random_bytes(TOKEN_BYTES) + masked = xor(decode(raw_token), mask) + encode(mask + masked) + end + + def valid_token?(token) + return false if token.nil? || token.empty? + decoded = decode(token) + return false unless decoded && decoded.bytesize == TOKEN_BYTES * 2 + mask = decoded.byteslice(0, TOKEN_BYTES) + masked = decoded.byteslice(TOKEN_BYTES, TOKEN_BYTES) + secure_compare(xor(masked, mask), decode(raw_token)) + rescue ArgumentError + false + end + + private + + def xor(left, right) + left.bytes.zip(right.bytes).map { |a, b| a ^ b }.pack('C*') + end + + def encode(bytes) + [bytes].pack('m0').tr('+/', '-_').delete('=') + end + + def decode(text) + restored = text.tr('-_', '+/') + padding = (4 - restored.bytesize % 4) % 4 + (restored + ('=' * padding)).unpack1('m0') + end + + def secure_compare(left, right) + return false unless left.bytesize == right.bytesize + left_bytes = left.unpack("C#{left.bytesize}") + difference = 0 + right.each_byte.with_index { |byte, index| difference |= byte ^ left_bytes[index] } + difference.zero? + end + end +end