Add support for latest HTTP Signatures spec draft (#14556)
* Add support for latest HTTP Signatures spec draft https://www.ietf.org/id/draft-ietf-httpbis-message-signatures-00.html - add support for the “hs2019” signature algorithm (assumed to be equivalent to RSA-SHA256, since we do not have a mechanism to specify the algorithm within the key metadata yet) - add support for (created) and (expires) pseudo-headers and related signature parameters, when using the hs2019 signature algorithm - adjust default “headers” parameter while being backwards-compatible with previous implementation - change the acceptable time window logic from 12 hours surrounding the “date” header to accepting signatures created up to 1 hour in the future and expiring up to 1 hour in the past (but only allowing expiration dates up to 12 hours after the creation date) This doesn't conform with the current draft, as it doesn't permit accounting for clock skew. This, however, should be addressed in a next version of the draft: https://github.com/httpwg/http-extensions/pull/1235 * Add additional signature requirements * Rewrite signature params parsing using Parslet * Make apparent which signature algorithm Mastodon on verification failure Mastodon uses RSASSA-PKCS1-v1_5, which is not recommended for new applications, and new implementers may thus unknowingly use RSASSA-PSS. * Add workaround for PeerTube's invalid signature header The previous parser allowed incorrect Signature headers, such as those produced by old versions of the `http-signature` node.js package, and seemingly used by PeerTube. This commit adds a workaround for that. * Fix `signature_key_id` raising an exception Previously, parsing failures would result in `signature_key_id` being nil, but the parser changes made that result in an exception. This commit changes the `signature_key_id` method to return `nil` in case of parsing failures. * Move extra HTTP signature helper methods to private methods * Relax (request-target) requirement to (request-target) || digest This lets requests from Plume work without lowering security significantly.main
parent
175cd4f8ef
commit
b241f20bd2
|
@ -7,6 +7,44 @@ module SignatureVerification
|
||||||
|
|
||||||
include DomainControlHelper
|
include DomainControlHelper
|
||||||
|
|
||||||
|
EXPIRATION_WINDOW_LIMIT = 12.hours
|
||||||
|
CLOCK_SKEW_MARGIN = 1.hour
|
||||||
|
|
||||||
|
class SignatureVerificationError < StandardError; end
|
||||||
|
|
||||||
|
class SignatureParamsParser < Parslet::Parser
|
||||||
|
rule(:token) { match("[0-9a-zA-Z!#$%&'*+.^_`|~-]").repeat(1).as(:token) }
|
||||||
|
rule(:quoted_string) { str('"') >> (qdtext | quoted_pair).repeat.as(:quoted_string) >> str('"') }
|
||||||
|
# qdtext and quoted_pair are not exactly according to spec but meh
|
||||||
|
rule(:qdtext) { match('[^\\\\"]') }
|
||||||
|
rule(:quoted_pair) { str('\\') >> any }
|
||||||
|
rule(:bws) { match('\s').repeat }
|
||||||
|
rule(:param) { (token.as(:key) >> bws >> str('=') >> bws >> (token | quoted_string).as(:value)).as(:param) }
|
||||||
|
rule(:comma) { bws >> str(',') >> bws }
|
||||||
|
# Old versions of node-http-signature add an incorrect "Signature " prefix to the header
|
||||||
|
rule(:buggy_prefix) { str('Signature ') }
|
||||||
|
rule(:params) { buggy_prefix.maybe >> (param >> (comma >> param).repeat).as(:params) }
|
||||||
|
root(:params)
|
||||||
|
end
|
||||||
|
|
||||||
|
class SignatureParamsTransformer < Parslet::Transform
|
||||||
|
rule(params: subtree(:p)) do
|
||||||
|
(p.is_a?(Array) ? p : [p]).each_with_object({}) { |(key, val), h| h[key] = val }
|
||||||
|
end
|
||||||
|
|
||||||
|
rule(param: { key: simple(:key), value: simple(:val) }) do
|
||||||
|
[key, val]
|
||||||
|
end
|
||||||
|
|
||||||
|
rule(quoted_string: simple(:string)) do
|
||||||
|
string.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
rule(token: simple(:string)) do
|
||||||
|
string.to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def require_signature!
|
def require_signature!
|
||||||
render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
|
render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
|
||||||
end
|
end
|
||||||
|
@ -24,72 +62,40 @@ module SignatureVerification
|
||||||
end
|
end
|
||||||
|
|
||||||
def signature_key_id
|
def signature_key_id
|
||||||
raw_signature = request.headers['Signature']
|
|
||||||
signature_params = {}
|
|
||||||
|
|
||||||
raw_signature.split(',').each do |part|
|
|
||||||
parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
|
|
||||||
next if parsed_parts.nil? || parsed_parts.size != 3
|
|
||||||
signature_params[parsed_parts[1]] = parsed_parts[2]
|
|
||||||
end
|
|
||||||
|
|
||||||
signature_params['keyId']
|
signature_params['keyId']
|
||||||
|
rescue SignatureVerificationError
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def signed_request_account
|
def signed_request_account
|
||||||
return @signed_request_account if defined?(@signed_request_account)
|
return @signed_request_account if defined?(@signed_request_account)
|
||||||
|
|
||||||
unless signed_request?
|
raise SignatureVerificationError, 'Request not signed' unless signed_request?
|
||||||
@signature_verification_failure_reason = 'Request not signed'
|
raise SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters?
|
||||||
@signed_request_account = nil
|
raise SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm)
|
||||||
return
|
raise SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
|
||||||
end
|
|
||||||
|
|
||||||
if request.headers['Date'].present? && !matches_time_window?
|
verify_signature_strength!
|
||||||
@signature_verification_failure_reason = 'Signed request date outside acceptable time window'
|
|
||||||
@signed_request_account = nil
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
raw_signature = request.headers['Signature']
|
|
||||||
signature_params = {}
|
|
||||||
|
|
||||||
raw_signature.split(',').each do |part|
|
|
||||||
parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
|
|
||||||
next if parsed_parts.nil? || parsed_parts.size != 3
|
|
||||||
signature_params[parsed_parts[1]] = parsed_parts[2]
|
|
||||||
end
|
|
||||||
|
|
||||||
if incompatible_signature?(signature_params)
|
|
||||||
@signature_verification_failure_reason = 'Incompatible request signature'
|
|
||||||
@signed_request_account = nil
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
account = account_from_key_id(signature_params['keyId'])
|
account = account_from_key_id(signature_params['keyId'])
|
||||||
|
|
||||||
if account.nil?
|
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if account.nil?
|
||||||
@signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
|
|
||||||
@signed_request_account = nil
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
signature = Base64.decode64(signature_params['signature'])
|
signature = Base64.decode64(signature_params['signature'])
|
||||||
compare_signed_string = build_signed_string(signature_params['headers'])
|
compare_signed_string = build_signed_string
|
||||||
|
|
||||||
return account unless verify_signature(account, signature, compare_signed_string).nil?
|
return account unless verify_signature(account, signature, compare_signed_string).nil?
|
||||||
|
|
||||||
account = stoplight_wrap_request { account.possibly_stale? ? account.refresh! : account_refresh_key(account) }
|
account = stoplight_wrap_request { account.possibly_stale? ? account.refresh! : account_refresh_key(account) }
|
||||||
|
|
||||||
if account.nil?
|
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if account.nil?
|
||||||
@signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
|
|
||||||
@signed_request_account = nil
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
return account unless verify_signature(account, signature, compare_signed_string).nil?
|
return account unless verify_signature(account, signature, compare_signed_string).nil?
|
||||||
|
|
||||||
@signature_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}"
|
@signature_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)"
|
||||||
|
@signed_request_account = nil
|
||||||
|
rescue SignatureVerificationError => e
|
||||||
|
@signature_verification_failure_reason = e.message
|
||||||
@signed_request_account = nil
|
@signed_request_account = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -99,6 +105,31 @@ module SignatureVerification
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def signature_params
|
||||||
|
@signature_params ||= begin
|
||||||
|
raw_signature = request.headers['Signature']
|
||||||
|
tree = SignatureParamsParser.new.parse(raw_signature)
|
||||||
|
SignatureParamsTransformer.new.apply(tree)
|
||||||
|
end
|
||||||
|
rescue Parslet::ParseFailed
|
||||||
|
raise SignatureVerificationError, 'Error parsing signature parameters'
|
||||||
|
end
|
||||||
|
|
||||||
|
def signature_algorithm
|
||||||
|
signature_params.fetch('algorithm', 'hs2019')
|
||||||
|
end
|
||||||
|
|
||||||
|
def signed_headers
|
||||||
|
signature_params.fetch('headers', signature_algorithm == 'hs2019' ? '(created)' : 'date').downcase.split(' ')
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify_signature_strength!
|
||||||
|
raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
|
||||||
|
raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(Request::REQUEST_TARGET) || signed_headers.include?('digest')
|
||||||
|
raise SignatureVerificationError, 'Mastodon requires the Host header to be signed' unless signed_headers.include?('host')
|
||||||
|
raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
|
||||||
|
end
|
||||||
|
|
||||||
def verify_signature(account, signature, compare_signed_string)
|
def verify_signature(account, signature, compare_signed_string)
|
||||||
if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
|
if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
|
||||||
@signed_request_account = account
|
@signed_request_account = account
|
||||||
|
@ -108,12 +139,20 @@ module SignatureVerification
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_signed_string(signed_headers)
|
def build_signed_string
|
||||||
signed_headers = 'date' if signed_headers.blank?
|
signed_headers.map do |signed_header|
|
||||||
|
|
||||||
signed_headers.downcase.split(' ').map do |signed_header|
|
|
||||||
if signed_header == Request::REQUEST_TARGET
|
if signed_header == Request::REQUEST_TARGET
|
||||||
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
||||||
|
elsif signed_header == '(created)'
|
||||||
|
raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
||||||
|
raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
|
||||||
|
|
||||||
|
"(created): #{signature_params['created']}"
|
||||||
|
elsif signed_header == '(expires)'
|
||||||
|
raise SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
||||||
|
raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
|
||||||
|
|
||||||
|
"(expires): #{signature_params['expires']}"
|
||||||
elsif signed_header == 'digest'
|
elsif signed_header == 'digest'
|
||||||
"digest: #{body_digest}"
|
"digest: #{body_digest}"
|
||||||
else
|
else
|
||||||
|
@ -123,13 +162,28 @@ module SignatureVerification
|
||||||
end
|
end
|
||||||
|
|
||||||
def matches_time_window?
|
def matches_time_window?
|
||||||
|
created_time = nil
|
||||||
|
expires_time = nil
|
||||||
|
|
||||||
begin
|
begin
|
||||||
time_sent = Time.httpdate(request.headers['Date'])
|
if signature_algorithm == 'hs2019' && signature_params['created'].present?
|
||||||
|
created_time = Time.at(signature_params['created'].to_i).utc
|
||||||
|
elsif request.headers['Date'].present?
|
||||||
|
created_time = Time.httpdate(request.headers['Date']).utc
|
||||||
|
end
|
||||||
|
|
||||||
|
expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
|
||||||
rescue ArgumentError
|
rescue ArgumentError
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
(Time.now.utc - time_sent).abs <= 12.hours
|
expires_time ||= created_time + 5.minutes unless created_time.nil?
|
||||||
|
expires_time = [expires_time, created_time + EXPIRATION_WINDOW_LIMIT].min unless created_time.nil?
|
||||||
|
|
||||||
|
return false if created_time.present? && created_time > Time.now.utc + CLOCK_SKEW_MARGIN
|
||||||
|
return false if expires_time.present? && Time.now.utc > expires_time + CLOCK_SKEW_MARGIN
|
||||||
|
|
||||||
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def body_digest
|
def body_digest
|
||||||
|
@ -140,9 +194,8 @@ module SignatureVerification
|
||||||
name.split(/-/).map(&:capitalize).join('-')
|
name.split(/-/).map(&:capitalize).join('-')
|
||||||
end
|
end
|
||||||
|
|
||||||
def incompatible_signature?(signature_params)
|
def missing_required_signature_parameters?
|
||||||
signature_params['keyId'].blank? ||
|
signature_params['keyId'].blank? || signature_params['signature'].blank?
|
||||||
signature_params['signature'].blank?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_from_key_id(key_id)
|
def account_from_key_id(key_id)
|
||||||
|
|
Loading…
Reference in New Issue