fernet/fernet-rb

View on GitHub
lib/fernet/token.rb

Summary

Maintainability
A
2 hrs
Test Coverage
# encoding UTF-8
require 'base64'
require 'valcro'
require_relative 'errors'

module Fernet
  # Internal: encapsulates a fernet token structure and validation
  class Token
    include Valcro

    class InvalidToken < Fernet::Error; 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

      version = opts[:version] || DEFAULT_VERSION
      payload = [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.nil?
    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