# encoding UTF-8 require 'base64' require 'valcro' module Fernet # Internal: encapsulates a fernet token structure and validation class Token include Valcro class InvalidToken < StandardError; end # Internal: the default token version DEFAULT_VERSION = 0x80.freeze # Internal: max allowed clock skew for calculating TTL MAX_CLOCK_SKEW = 60.freeze # Internal: initializes a Token object # # token - the string representation of this token # opts - a has containing # secret: the secret, optionally base 64 encoded (required) # enforce_ttl: whether to enforce TTL upon validation. Defaults to value # set in Configuration.enforce_ttl # ttl: number of seconds token is valid, defaults to Configuration.ttl def initialize(token, opts = {}) @token = token @secret = Secret.new(opts.fetch(:secret)) @enforce_ttl = opts.fetch(:enforce_ttl) { Configuration.enforce_ttl } @ttl = opts[:ttl] || Configuration.ttl @now = opts[:now] end # Internal: returns the token as a string def to_s @token end # Internal: Validates this token and returns true if it's valid # # Returns a boolean set to true if it's valid, false otherwise def valid? validate super end # Internal: returns the decrypted message in this token # # Raises InvalidToken if it cannot be decrypted or is invalid # # Returns a string containing the original message in plain text def message if valid? begin Encryption.decrypt(key: @secret.encryption_key, ciphertext: encrypted_message, iv: iv) rescue OpenSSL::Cipher::CipherError raise InvalidToken, "bad decrypt" end else raise InvalidToken, error_messages end end # Internal: generates a Fernet Token # # opts - a hash containing # secret: a string containing the secret, optionally base64 encoded # message: the message in plain text def self.generate(opts) unless opts[:secret] raise ArgumentError, 'Secret not provided' end secret = Secret.new(opts.fetch(:secret)) encrypted_message, iv = Encryption.encrypt(key: secret.encryption_key, message: opts[:message], iv: opts[:iv]) issued_timestamp = (opts[:now] || Time.now).to_i payload = [DEFAULT_VERSION].pack("C") + BitPacking.pack_int64_bigendian(issued_timestamp) + iv + encrypted_message mac = OpenSSL::HMAC.digest('sha256', secret.signing_key, payload) new(Base64.urlsafe_encode64(payload + mac), secret: opts.fetch(:secret)) end private def decoded_token @decoded_token ||= Base64.urlsafe_decode64(@token) end def version decoded_token.chr.unpack("C").first end def received_signature decoded_token[(decoded_token.length - 32), 32] end def issued_timestamp BitPacking.unpack_int64_bigendian(decoded_token[1, 8]) end def iv decoded_token[9, 16] end def encrypted_message decoded_token[25..(decoded_token.length - 33)] end validate do if valid_base64? if unknown_token_version? errors.add :version, "is unknown" elsif enforce_ttl? && !issued_recent_enough? errors.add :issued_timestamp, "is too far in the past: token expired" else unless signatures_match? errors.add :signature, "does not match" end if unacceptable_clock_slew? errors.add :issued_timestamp, "is too far in the future" end unless ciphertext_multiple_of_block_size? errors.add :ciphertext, "is not a multiple of block size" end end else errors.add(:token, "invalid base64") end end def regenerated_mac Encryption.hmac_digest(@secret.signing_key, signing_blob) end def signing_blob [version].pack("C") + BitPacking.pack_int64_bigendian(issued_timestamp) + iv + encrypted_message end def valid_base64? decoded_token true rescue ArgumentError false end def signatures_match? regenerated_bytes = regenerated_mac.bytes.to_a received_bytes = received_signature.bytes.to_a received_bytes.inject(0) do |accum, byte| accum |= byte ^ regenerated_bytes.shift end.zero? end def issued_recent_enough? good_till = issued_timestamp + @ttl good_till >= now.to_i end def unacceptable_clock_slew? issued_timestamp >= (now.to_i + MAX_CLOCK_SKEW) end def ciphertext_multiple_of_block_size? (encrypted_message.size % Encryption::AES_BLOCK_SIZE).zero? end def unknown_token_version? DEFAULT_VERSION != version end def enforce_ttl? @enforce_ttl end def now @now || Time.now end end end