feat(security): add Prism, MessageEncryptor and MessageVerifier
This commit is contained in:
1 parent
948376736e
commit
590cd639aa
2 files changed
+163
No files matched your search
@@ -0,0 +1,62 @@
|
|||||||
|
module Prism
|
||||||
|
# Authenticated encryption (AES-256-GCM) for values that must not be
|
||||||
|
# readable by the client at all, such as encrypted cookies. Use
|
||||||
|
# MessageEncryptor.derive_key to turn an arbitrary application secret
|
||||||
|
# into a valid 32-byte key.
|
||||||
|
class MessageEncryptor
|
||||||
|
class InvalidMessage < StandardError; end
|
||||||
|
|
||||||
|
CIPHER_NAME = 'aes-256-gcm'.freeze
|
||||||
|
KEY_LENGTH = 32
|
||||||
|
|
||||||
|
def self.derive_key(secret, salt: 'prism-message-encryptor', length: KEY_LENGTH)
|
||||||
|
OpenSSL::PKCS5.pbkdf2_hmac(secret.to_s, salt.to_s, 2 ** 16, length, OpenSSL::Digest.new('SHA256'))
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(key, serializer: JSON)
|
||||||
|
raise ArgumentError, "key must be #{KEY_LENGTH} bytes, got #{key.bytesize}" unless key.bytesize == KEY_LENGTH
|
||||||
|
@key = key
|
||||||
|
@serializer = serializer
|
||||||
|
end
|
||||||
|
|
||||||
|
def encrypt_and_sign(value)
|
||||||
|
cipher = new_cipher(:encrypt)
|
||||||
|
iv = cipher.random_iv
|
||||||
|
cipher.auth_data = ''
|
||||||
|
plaintext = @serializer.dump(value)
|
||||||
|
encrypted = cipher.update(plaintext) + cipher.final
|
||||||
|
[encode(encrypted), encode(iv), encode(cipher.auth_tag)].join('--')
|
||||||
|
end
|
||||||
|
|
||||||
|
def decrypt_and_verify(message)
|
||||||
|
encrypted, iv, auth_tag = message.to_s.split('--', 3)
|
||||||
|
raise InvalidMessage, 'invalid message' if [encrypted, iv, auth_tag].any?(&:nil?)
|
||||||
|
|
||||||
|
cipher = new_cipher(:decrypt)
|
||||||
|
cipher.iv = decode(iv)
|
||||||
|
cipher.auth_tag = decode(auth_tag)
|
||||||
|
cipher.auth_data = ''
|
||||||
|
decrypted = cipher.update(decode(encrypted)) + cipher.final
|
||||||
|
@serializer.load(decrypted)
|
||||||
|
rescue OpenSSL::Cipher::CipherError, ArgumentError, TypeError
|
||||||
|
raise InvalidMessage, 'invalid message'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def new_cipher(mode)
|
||||||
|
cipher = OpenSSL::Cipher.new(CIPHER_NAME)
|
||||||
|
cipher.public_send(mode)
|
||||||
|
cipher.key = @key
|
||||||
|
cipher
|
||||||
|
end
|
||||||
|
|
||||||
|
def encode(bytes)
|
||||||
|
[bytes].pack('m0')
|
||||||
|
end
|
||||||
|
|
||||||
|
def decode(text)
|
||||||
|
text.unpack1('m0')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
module Prism
|
||||||
|
# Signs arbitrary serializable values with an HMAC so tampering can be
|
||||||
|
# detected on the way back in (query strings, cookies, hidden form
|
||||||
|
# fields). This does not hide the payload - see MessageEncryptor for that.
|
||||||
|
class MessageVerifier
|
||||||
|
class InvalidSignature < StandardError; end
|
||||||
|
|
||||||
|
def initialize(secret, digest: 'SHA256', serializer: JSON)
|
||||||
|
raise ArgumentError, 'secret must be present' if secret.nil? || secret.to_s.empty?
|
||||||
|
@secret = secret.to_s
|
||||||
|
@digest = digest
|
||||||
|
@serializer = serializer
|
||||||
|
end
|
||||||
|
|
||||||
|
# `expires_in`/`expires_at` and `purpose` mirror Rails' MessageVerifier:
|
||||||
|
# a link generated with `purpose: 'cover_download'` fails #verify for
|
||||||
|
# any other purpose (or none), and a link past its expiry fails #verify
|
||||||
|
# the same way a tampered signature would - both close over the exact
|
||||||
|
# same InvalidSignature the caller already has to handle, so a
|
||||||
|
# controller cannot accidentally forget to check them separately.
|
||||||
|
# Omitting both keeps the plain envelope-free format earlier callers
|
||||||
|
# (CookieJar, Csrf) already rely on - this is additive, not a format
|
||||||
|
# bump, so it never breaks a signed value already in flight.
|
||||||
|
#
|
||||||
|
# `options` is a plain trailing Hash, not real keyword arguments, on
|
||||||
|
# purpose: every existing caller in this codebase (and the tests below)
|
||||||
|
# calls `generate('user_id' => 1)` - a bareword Hash AS the value being
|
||||||
|
# signed, no braces. Ruby's parser treats that call-site shape as
|
||||||
|
# keyword syntax regardless of the callee's actual parameter names, so
|
||||||
|
# declaring real `expires_in:`/`purpose:` keywords here would make
|
||||||
|
# every one of those calls raise "wrong number of arguments (given 0,
|
||||||
|
# expected 1)" instead of signing the hash - proven by hand before this
|
||||||
|
# method existed in this shape.
|
||||||
|
def generate(value, options = {})
|
||||||
|
exp = options[:expires_at] || (options[:expires_in] ? Time.now.utc + options[:expires_in] : nil)
|
||||||
|
payload = wrap(value, exp, options[:purpose])
|
||||||
|
data = encode(@serializer.dump(payload))
|
||||||
|
"#{data}--#{digest_for(data)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify(signed, purpose: nil)
|
||||||
|
raise InvalidSignature, 'invalid message' unless valid_message?(signed)
|
||||||
|
data, = signed.split('--', 2)
|
||||||
|
unwrap(@serializer.load(decode(data)), purpose)
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
raise InvalidSignature, 'invalid message'
|
||||||
|
end
|
||||||
|
|
||||||
|
def verified(signed, purpose: nil)
|
||||||
|
verify(signed, purpose: purpose)
|
||||||
|
rescue InvalidSignature
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_message?(signed)
|
||||||
|
return false if signed.nil? || !signed.include?('--')
|
||||||
|
data, signature = signed.split('--', 2)
|
||||||
|
return false if data.nil? || data.empty? || signature.nil? || signature.empty?
|
||||||
|
secure_compare(signature, digest_for(data))
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
ENVELOPE_KEY = '_prism_envelope'
|
||||||
|
|
||||||
|
def wrap(value, exp, purpose)
|
||||||
|
return value if exp.nil? && purpose.nil?
|
||||||
|
{ ENVELOPE_KEY => true, 'value' => value, 'exp' => exp&.utc&.iso8601, 'pur' => purpose&.to_s }
|
||||||
|
end
|
||||||
|
|
||||||
|
def unwrap(payload, purpose)
|
||||||
|
unless payload.is_a?(Hash) && payload[ENVELOPE_KEY]
|
||||||
|
raise InvalidSignature, 'message requires a purpose' if purpose
|
||||||
|
return payload
|
||||||
|
end
|
||||||
|
raise InvalidSignature, 'message expired' if payload['exp'] && Time.parse(payload['exp']) <= Time.now.utc
|
||||||
|
raise InvalidSignature, 'purpose mismatch' unless payload['pur'] == purpose&.to_s
|
||||||
|
payload['value']
|
||||||
|
end
|
||||||
|
|
||||||
|
def digest_for(data)
|
||||||
|
OpenSSL::HMAC.hexdigest(@digest, @secret, data)
|
||||||
|
end
|
||||||
|
|
||||||
|
def encode(text)
|
||||||
|
[text].pack('m0')
|
||||||
|
end
|
||||||
|
|
||||||
|
def decode(text)
|
||||||
|
text.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
|
||||||
Reference in new issue
Block a user