cprussin/token

View on GitHub
lib/token.rb

Summary

Maintainability
A
25 mins
Test Coverage
require 'openssl'

# This class provides token creation and validation.  It can be used as a
# singleton or by creating instances.
class Token

    # The cipher to use with OpenSSL
    attr_accessor :cipher

    # The key to use with OpenSSL
    attr_accessor :key

    # The initialization vector to use with OpenSSL
    attr_accessor :iv

    # The format of the payload.  This string should be in the format expected by
    # Array.pack
    attr_accessor :format

    # All token errors raise an exception of this class.
    class Error < Exception; end

    # Creates a new token generator.
    #
    # @param options [Hash] options to modify the default behavior of the token
    #   generator
    # @option options [String] :cipher The cipher to use with OpenSSL.  Defaults
    #   to 'AES-256-CFB'
    # @option options [String] :key The key to use with OpenSSL.  Defaults to a
    #   random key.  Do not specify without also specifying cipher
    # @option options [String] :iv The initialization vector to use with OpenSSL.
    #   Defaults to a random vector.  Do not specify without also specifying
    #   cipher
    # @option options [String] :format The string describing the format of the
    #   payloads.  Should be in the format expected by Array.pack.  Defaults to
    #   'L'
    def initialize(options = {})

        # Check to see if there are custom cryptographic settings
        if options.has_key? :cipher
            @cipher = options[:cipher]
            cipher  = OpenSSL::Cipher.new(@cipher)
            @key    = options[:key] || cipher.random_key
            @iv     = options[:iv]  || cipher.random_iv

        # Otherwise, set the default cryptographic settings
        else
            @cipher = self.class.cipher
            @key    = self.class.key
            @iv     = self.class.iv
        end

        # Set the payload format
        @format = options[:format] || self.class.format
    end

    # Creates a token consisting of the given payload and expiration date.
    #
    # @param payload [Array] the payload to embed in the token -- this may be a
    #   scalar if the payload format only specifies a single field
    # @param expires [Time] the time after which the token should be considered
    #   expired
    # @return [String] the generated token
    def generate(payload, expires)

        # Pack the token
        token = [
            expires.to_i,
            payload
        ]
        token = token.flatten.pack("L#{@format}")

        # Prepend the token's hash
        token.prepend(Digest::SHA256.hexdigest(token))

        # Encrypt the token
        crypt = OpenSSL::Cipher.new(@cipher)
        crypt.encrypt
        crypt.key = @key
        crypt.iv  = @iv
        token     = crypt.update(token) + crypt.final
    end

    # Verifies the validity of the given token and, if successful, returns the
    # associated payload.
    #
    # @param token [String] the token to verify
    # @return [Array] the payload -- this will be a scalar depending on the
    #   setting for format
    # @raise [Token::Error] if the token fails to decrypt, is not signed
    #   properly, or is expired
    def verify(token)

        # Decrypt the token
        begin
            crypt = OpenSSL::Cipher.new(@cipher)
            crypt.decrypt
            crypt.key = @key
            crypt.iv  = @iv
            tok       = crypt.update(token) + crypt.final
        rescue
            raise Error, 'Session is invalid'
        end

        # Split the token
        tok = tok.unpack("A64L#{@format}")

        # Validate the token
        time_valid = Time.at(tok[1]) > Time.now
        hash       = Digest::SHA256.hexdigest(tok[1..-1].pack("L#{@format}"))
        hash_valid = hash == tok[0]
        raise Error, 'Session is invalid' unless time_valid && hash_valid

        # Return the payload
        tok.length == 3 ? tok.last : tok[2..-1]
    end

    class << self

        # The class default cipher.  Note that when setting the cipher, the key and
        # initialization vector are cleared.
        attr_accessor :cipher
        def cipher=(cipher)
            @cipher = cipher
            @key = @iv = @instance = nil
        end

        # The class default encryption key.  Randomly generated when necessary if
        # unset.
        attr_accessor :key
        def key
            @key ||= OpenSSL::Cipher.new(@cipher).random_key
        end
        def key=(key)
            @key      = key
            @instance = nil
        end

        # The class default encryption initialization vector.  Randomly generated
        # when necessary if unset.
        attr_accessor :iv
        def iv
            @iv ||= OpenSSL::Cipher.new(@cipher).random_iv
        end
        def iv=(iv)
            @iv       = iv
            @instance = nil
        end

        # The class default token payload format.
        attr_accessor :format
        def format=(format)
            @format   = format
            @instance = nil
        end

        # Generates a token using the class default cryptographic settings and
        # payload format.
        #
        # @see Token#generate
        def generate(payload, expires)
            instance.generate(payload, expires)
        end

        # Verifies the validity of a token using the class default cryptographic
        # settings and payload format.
        #
        # @see Token#verify
        def verify(token)
            instance.verify(token)
        end

        # Resets the cipher to 'AES-256-CFB', the payload format to 'L', and the
        # key and initialization vectors to random values.
        def reset
            @cipher = 'AES-256-CFB'
            cipher  = OpenSSL::Cipher.new(@cipher)
            @key    = cipher.random_key
            @iv     = cipher.random_iv
            @format = 'L'
        end

        private

        # Retrieve the default instance.
        def instance
            @instance ||= new
        end
    end

    # Set the default settings when loading the class
    self.reset
end