[go: up one dir, main page]

Parse and validate DPoP Tokens

See the epic (Allow users to require demonstrated proof of po... (&14383)) for context, pre-work, and other related issues.

This issue is to provide logic that can:

  1. Validate that DPoP token (a signed JWT) is well formed according to the spec
    • E.g. each part of the token has the correct number of parts, the signing algorithm is supported, the timestamps are recent enough, that a valid key ID is present, etc etc
  2. Validate that a DPoP token was correctly signed with one of a user's valid and current signing keys according to the spec. Specifically:
    • the kid references a valid, current signing key for the provided user
    • the ath claim is a signature that corresponds to the private key referenced in kid

Not in this issue:

  1. Using this logic in the request flow of any API requests. See Sender constraining personal access tokens (#425130 - closed)
  2. Extracting a DPoP Token from the headers of an http request
  3. Documenting the feature. See Allow users to require demonstrated proof of po... (&14383).

Class boiler plate code / interface

This is a team effort between @nmalcolm and @ameyadarshan. Nick is responsible for providing a skeleton / boilerplate / framework for this work, and Ameya will implement it (with Nick's help and support as needed.

Code

# This class encapsulates the logic to 
module Auth
  class DpopAuthenticationService < BaseService

    def initialize(current_user, personal_access_token_plaintext, request)
      # Raise errors if these are missing
      # Raise an error unless DpopToken.enabled_for_user?(current_user)
      # Raise if the PAT does not belong to the user or is inactive
      # Extract the raw DPoP token from the request header, and check there's only one header
      @dpop_token = extract_dpop_from_request(request)
    end

    def execute!
      # Create a DpopToken
      # Call unsafe_well_formed?. It will raise if there's an error.
      # Call valid_for_user_and_pat?(user, pat). It will raise if there's an error.

      true
    end

    private
      attr_reader :dpop_token

      def extract_dpop_from_request(request)
        # get the header value
        # raise if multiple, otherwise return it
      end
  end
end
# Demonstrated Proof of Possession (DPoP) is a mechanism to tie a user's
# Personal Access Token (PAT) to one of their signing keys.
#
# A DPoP Token is a signed JSON Web Token, and this class implements
# the logic to ensure a provided DPoP Token is well-formed and
# cryptographically signed.
#
# In this class:
#   - well-formed is defined as being a syntatically correct JWT that is
#   using supported values (e.g. supported algorithms).
#   - valid is defined as being cryptographically signed with a user's
#   active signing key. A user may have many active signing key's; it
#   must match one.
#
# All checks MUST raise an error if they fail:
#  - JWT errors will use JWT error classes & message (don't catch & re-raise)
#  - DPoP specific errors will use a DPoP error class
class DpopToken
  SUPPORTED_JWS_ALGORITHMS = ['RS256']
  SUPPORTED_TYPES = ['dpop+jwt']
  SUPPORTED_KEY_TYPES = ['RSA']
  SUPPORTED_PROOF_KEY_ID_HASHING_ALGORITHMS = ['SHA256']
  KID_DELIMETER = ':'
  
  def self.dpop_enabled_for_user?(user)
    # Check the user is a human, not a bot, etc
    # Check the FF is enabled for the user
    # Check the user has enabled DPoP
    # Return true / false
  end

  def initialize(dpop_token)
    @dpop_token = dpop_token
    raise ArgumentError unless dpop_token.present
    @payload, @header = JWT.decode(
      dpop_token, 
      nil,   # we do not pass a key here as we are not checking the signature 
      false, # we are not verifying the signature or claims
    )
  end

  # We prepend this method with `unsafe` to signal to developers that
  # well-formed DOES NOT equal valid.
  def unsafe_well_formed?
    well_formed_header?
    well_formed_body?
  end

  def valid_for_user_and_pat?(user, plaintext)
    call valid_for_user?(user)
    valid_access_token_hash_for?(plaintext)
    # NOT FOR AMEYA: we don't expose those publicly to avoid developers
    # calling one  without calling the other. It makes it a bit harder
    # to test since we can only access this method in rspec
  end

  # The methods below are public but (currently) only used by DpopToken
  # itself. It's easier to test

  # A DPoP Token has a header containing metadata
  def well_formed_header?
    # All comparisons should be case-sensitive, using secure comparison
    # See https://www.rfc-editor.org/rfc/rfc7515#section-4.1.1

    # Check header[typ]

    # Check header[alg] is one of SUPPORTED_JWS_ALGORITHMS.
    # NOTE FOR AMEYA: Can we skip this if JWT.decode will validate it
    # in .valid_for_user?

    # Check the format of header[kid] (ALGORITHM DELIMITER b64(HASH))
    # Check kid_algorithm is supported

    # Check that the hash can be decoded from base64
  end

  def kid_fingerprint
    # Get a fingerprint from the header. We already checked the
    # algorithm in well_formed_header?
  end

  # A DPoP Token has a body containing additional metadata and the
  # signature
  def well_formed_body?
    # NOTE FOR AMEYA: This might not be required? JWT.decode will
    # validate iat, exp, and ath in .valid_for_user?
  end

  # NOTE TO AMEYA: It kinda feels like this maybe shouldn't exist
  # in this class?
  def signing_key_for_user!(user)
    # Get a signing key from the user based on the fingerprint. Raise if you can't.
    # Use the `kid_fingerprint` method to keep logic small and testable.

    # Validate the signing key uses a supported algorithm.
  end

  def kid_algorithm
    # Get the kid algorithm
  end

  # A user's public key is prefixed with its algorithm. This method
  # should return that algorithm, or raise if it can't.
  def algorithm_from_users_public_key(key)
  end

  private
    attr_reader :dpop_token, :header, :payload

    # Check that the DPoP is signed with a SSH key belonging to the user
    def valid_for_user?(user)
      raise unless self.dpop_enabled_for_user?(user)
      raise unless unsafe_well_formed?
  
      # Decode the JSON token again, this time with the key, 
      # the expected algorthm, verifying all the timestamps, etc
      # Overwrites the attrs, in case .decode returns a different result
      # when verify is true.
      @payload, @header = JWT.decode(
        dpop_token,
        signing_key_for_user!(user),
        true,
        {
          required_claims: %w[exp ath iat], 
          algorithm: SUPPORTED_JWS_ALGORITHMS, 
          verify_iat: true,
          # ...
        }
      )
      
    end
  
    # Check that the DPoP contains a hash of the PAT being used.
    # Users can have multiple PATs, so we still need to check that
    # they created this DPoP for this particular PAT.
    def valid_access_token_hash_for?(plaintext)
      expected_hash = Base64.urlsafe_encode64(
        Digest::SHA256.digest(plaintext), 
        padding: false
      )
      raise unless secure_compare(payload['ath'], expected_hash)
    end

end
Edited by Nick Malcolm