-
Notifications
You must be signed in to change notification settings - Fork 21
/
signature.cr
154 lines (149 loc) · 5.97 KB
/
signature.cr
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
require "./key_pair"
module Ktistec
# HTTP Signature (https://tools.ietf.org/html/draft-cavage-http-signatures-10)
# support.
#
module Signature
extend self
class Error < Exception
end
def sign(key_pair, url, body = nil, content_type = nil, content_length = nil, accept = nil, method = :post, time = Time.utc, algorithm = "rsa-sha256")
key = key_pair.private_key.not_nil!
url = URI.parse(url).normalize
date = Time::Format::HTTP_DATE.format(time)
headers_string = "(request-target)"
signature_string = "(request-target): #{method} #{url.path}"
signature_params = %Q<keyId="#{key_pair.iri}">
case algorithm
when "hs2019"
headers_string += " (created) (expires) host"
signature_string += "\n(created): #{time.to_unix}\n(expires): #{time.to_unix + 300}\nhost: #{url.authority}"
signature_params += %Q<,algorithm="hs2019",created=#{time.to_unix},expires=#{time.to_unix + 300}>
when "rsa-sha256"
headers_string += " date host"
signature_string += "\ndate: #{date}\nhost: #{url.authority}"
signature_params += %Q<,algorithm="rsa-sha256">
end
headers = HTTP::Headers{"Date" => date}
if body
digest = "SHA-256=" + Base64.strict_encode(OpenSSL::Digest.new("SHA256").update(body).final)
headers_string += " digest"
signature_string += "\ndigest: #{digest}"
headers["Digest"] = digest
end
if accept
headers_string += " accept"
signature_string += "\naccept: #{accept}"
headers["Accept"] = accept
end
if content_type
headers_string += " content-type"
signature_string += "\ncontent-type: #{content_type}"
headers["Content-Type"] = content_type
end
if content_length
headers_string += " content-length"
signature_string += "\ncontent-length: #{content_length}"
headers["Content-Length"] = content_length.to_s
end
signature = Base64.strict_encode(key.sign(OpenSSL::Digest.new("SHA256"), signature_string))
headers["Signature"] = signature_params + %Q<,signature="#{signature}",headers="#{headers_string}">
headers
end
def verify(key_pair, url, headers, body = nil, method = :post)
unless (signature = headers["Signature"]?)
raise Error.new("missing signature")
end
parameters = signature.split(",", remove_empty: true).reduce({} of String => String) do |a, h|
k, v = h.split("=", 2)
a[k] = v.delete('"')
a
end
unless (parameters.keys.sort & ["signature", "headers"]).size == 2
raise Error.new("malformed signature")
end
url = URI.parse(url).normalize
split_headers_string = parameters["headers"].split
# it's bad to infer the algorithm from an untrusted input, but
# we need to know how to process the headers (for verification,
# the algorithm is always assumed to be rsa-sha256).
algorithm = parameters["algorithm"]? || begin
"date".in?(split_headers_string) ? "rsa-sha256" : "hs2019"
end
unless "(request-target)".in?(split_headers_string)
raise Error.new("(request-target) header must be signed")
end
unless algorithm == "rsa-sha256" || "(created)".in?(split_headers_string)
raise Error.new("(created) header must be signed")
end
unless algorithm == "hs2019" || "date".in?(split_headers_string)
raise Error.new("date header must be signed")
end
unless "host".in?(split_headers_string)
raise Error.new("host header must be signed")
end
unless method != :post || "digest".in?(split_headers_string)
raise Error.new("body digest must be signed")
end
signature_string =
split_headers_string.map do |header|
case header
when "(request-target)"
"#{header}: #{method} #{url.path}"
when "(created)"
"#{header}: #{parameters["created"]}"
when "(expires)"
"#{header}: #{parameters["expires"]}"
when "date"
"#{header}: #{headers["Date"]}"
when "host"
"#{header}: #{url.authority}"
when "accept"
"#{header}: #{headers["Accept"]}"
when "content-type"
"#{header}: #{headers["Content-Type"]}"
when "content-length"
"#{header}: #{headers["Content-Length"]}"
when "digest"
"#{header}: #{headers["Digest"]}"
end
end.compact.join("\n")
key = key_pair.public_key
unless key.try(&.verify(OpenSSL::Digest.new("SHA256"), Base64.decode(parameters["signature"]), signature_string))
raise Error.new("invalid signature: signed by keyId=#{parameters["keyId"]}")
end
# five minutes of leeway for clock skew
if "(created)".in?(split_headers_string)
created = Time.unix(Int64.new(parameters["created"]))
unless created < 5.minutes.from_now
raise Error.new("received before creation date")
end
end
if "(expires)".in?(split_headers_string)
expires = Time.unix(Int64.new(parameters["expires"]))
unless expires > 5.minutes.ago
raise Error.new("received after expiration date")
end
end
if "date".in?(split_headers_string)
date = Time::Format::HTTP_DATE.parse(headers["Date"])
unless date > 5.minutes.ago && date < 5.minutes.from_now
raise Error.new("date out of range")
end
end
if "digest".in?(split_headers_string)
digest = "SHA-256=" + Base64.strict_encode(OpenSSL::Digest.new("SHA256").update(body.not_nil!).final)
unless headers["Digest"]? == digest
raise Error.new("body doesn't match")
end
end
true
end
def verify?(key_pair, url, headers, *args, **opts)
verify(key_pair, url, headers, *args, **opts)
rescue ex : Error | OpenSSL::Error
Log.info { "verification failed: #{ex.message}" }
false
end
end
end