touchque 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +105 -0
- data/LICENSE +21 -0
- data/README.md +383 -0
- data/lib/touchque/agent.rb +211 -0
- data/lib/touchque/agent_card.rb +159 -0
- data/lib/touchque/approvals.rb +214 -0
- data/lib/touchque/client.rb +81 -0
- data/lib/touchque/code_reply.rb +53 -0
- data/lib/touchque/cross_app_access.rb +204 -0
- data/lib/touchque/crypto.rb +215 -0
- data/lib/touchque/errors.rb +246 -0
- data/lib/touchque/flow.rb +362 -0
- data/lib/touchque/hosted_login.rb +401 -0
- data/lib/touchque/http.rb +127 -0
- data/lib/touchque/login_session.rb +86 -0
- data/lib/touchque/org_agent.rb +398 -0
- data/lib/touchque/rack/relay.rb +75 -0
- data/lib/touchque/rails/guard.rb +132 -0
- data/lib/touchque/rails/hosted_login.rb +143 -0
- data/lib/touchque/rails/sessions_controller.rb +12 -0
- data/lib/touchque/railtie.rb +34 -0
- data/lib/touchque/relay.rb +533 -0
- data/lib/touchque/resources/actions.rb +212 -0
- data/lib/touchque/resources/agents.rb +251 -0
- data/lib/touchque/resources/device_risk.rb +130 -0
- data/lib/touchque/resources/identity.rb +331 -0
- data/lib/touchque/resources/login.rb +325 -0
- data/lib/touchque/resources/mobile.rb +115 -0
- data/lib/touchque/resources/people.rb +237 -0
- data/lib/touchque/resources/qr_pairings.rb +82 -0
- data/lib/touchque/resources/safe_guard.rb +87 -0
- data/lib/touchque/resources/webhook.rb +177 -0
- data/lib/touchque/safe_approval.rb +283 -0
- data/lib/touchque/security_events.rb +162 -0
- data/lib/touchque/util.rb +260 -0
- data/lib/touchque/version.rb +5 -0
- data/lib/touchque.rb +124 -0
- metadata +83 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Touchque
|
|
6
|
+
# A code the person types back on the phone (tq.login.request(..., reply: {...})): a sign-in
|
|
7
|
+
# code another service sent them (a text message, WhatsApp, e-mail). Each request gets a
|
|
8
|
+
# one-time P-256 key pair; its public key goes with the request, the phone encrypts what was
|
|
9
|
+
# typed to it, and only you can open it: Touchque stores and hands back nothing but the
|
|
10
|
+
# ciphertext (tq.login.status(request_id)["reply"]["ct"]).
|
|
11
|
+
#
|
|
12
|
+
# ECDH P-256, HKDF-SHA256 (salt: the request id, info "touchque-code-reply-v1"), AES-256-GCM
|
|
13
|
+
# (additional data "touchque-code-reply:<request id>"), as
|
|
14
|
+
# "v1.<the phone's one-time key, x9.63>.<nonce|ciphertext|tag>", base64url.
|
|
15
|
+
module CodeReply
|
|
16
|
+
INFO = "touchque-code-reply-v1"
|
|
17
|
+
CT_RE = /\Av1\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\z/
|
|
18
|
+
CODE_RE = /\A[A-Za-z0-9 -]{4,20}\z/
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# A one-time key pair for one request: send "publicKey" (the uncompressed P-256 point,
|
|
23
|
+
# base64url) as reply[:key], keep "privateKey" (PKCS#8 DER, base64url) server-side.
|
|
24
|
+
def new_key
|
|
25
|
+
key = OpenSSL::PKey::EC.generate("prime256v1")
|
|
26
|
+
{ "publicKey" => Util.b64url(key.public_key.to_bn(:uncompressed).to_s(2)), "privateKey" => Util.b64url(key.private_to_der) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The code the person typed, or nil when ct does not open with this key for this request.
|
|
30
|
+
def open(private_key, request_id, ct)
|
|
31
|
+
m = CT_RE.match(ct.to_s)
|
|
32
|
+
return nil unless m
|
|
33
|
+
|
|
34
|
+
epk = Util.b64url_decode(m[1])
|
|
35
|
+
box = Util.b64url_decode(m[2])
|
|
36
|
+
return nil unless epk.bytesize == 65 && epk.getbyte(0) == 4 && box.bytesize >= 12 + 16 + 1
|
|
37
|
+
|
|
38
|
+
priv = OpenSSL::PKey.read(Util.b64url_decode(private_key))
|
|
39
|
+
peer = OpenSSL::PKey::EC::Point.new(priv.group, OpenSSL::BN.new(epk, 2))
|
|
40
|
+
shared = priv.dh_compute_key(peer)
|
|
41
|
+
key = OpenSSL::KDF.hkdf(shared, salt: request_id.to_s, info: INFO, length: 32, hash: "SHA256")
|
|
42
|
+
d = OpenSSL::Cipher.new("aes-256-gcm").decrypt
|
|
43
|
+
d.key = key
|
|
44
|
+
d.iv = box.byteslice(0, 12)
|
|
45
|
+
d.auth_tag = box.byteslice(box.bytesize - 16, 16)
|
|
46
|
+
d.auth_data = "touchque-code-reply:#{request_id}"
|
|
47
|
+
code = (d.update(box.byteslice(12, box.bytesize - 28)) + d.final).force_encoding("UTF-8").strip
|
|
48
|
+
CODE_RE.match?(code) ? code : nil
|
|
49
|
+
rescue OpenSSL::OpenSSLError, ArgumentError, EncodingError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Touchque
|
|
4
|
+
# A refused ID-JAG or exchange (Cross App Access). +error+ is the OAuth error:
|
|
5
|
+
# invalid_grant for a bad ID-JAG, else what the server answered.
|
|
6
|
+
class IdJagError < Error
|
|
7
|
+
attr_reader :error, :status
|
|
8
|
+
|
|
9
|
+
def initialize(error, message, status = nil)
|
|
10
|
+
@error = error
|
|
11
|
+
@status = status
|
|
12
|
+
super(message)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Cross App Access: Identity Assertion JWT Authorization Grants (ID-JAG,
|
|
17
|
+
# draft-ietf-oauth-identity-assertion-authz-grant, an open standard). An
|
|
18
|
+
# organization's identity provider (Touchque SSO) decides which of its apps may reach
|
|
19
|
+
# which other service for its members; the app trades a member's ID token for an
|
|
20
|
+
# ID-JAG there, then the ID-JAG for an access token at the other service's
|
|
21
|
+
# authorization server, with no consent screen per member. Your own authorization
|
|
22
|
+
# server takes them with IdJagVerifier.
|
|
23
|
+
#
|
|
24
|
+
# jag = Touchque::CrossAppAccess.request_id_jag(issuer: "https://sso.touchque.com", client_id: id, client_secret: secret,
|
|
25
|
+
# id_token: id_token, audience: "https://auth.files.example")
|
|
26
|
+
# tokens = Touchque::CrossAppAccess.redeem_id_jag(token_endpoint: "https://auth.files.example/token", client_id: "acme",
|
|
27
|
+
# client_secret: files_secret, assertion: jag["assertion"])
|
|
28
|
+
module CrossAppAccess
|
|
29
|
+
ID_JAG_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id-jag"
|
|
30
|
+
ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"
|
|
31
|
+
TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
|
|
32
|
+
JWT_BEARER_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# https, or http on this machine (tests).
|
|
37
|
+
def safe_url?(url)
|
|
38
|
+
u = URI.parse(url.to_s)
|
|
39
|
+
u.scheme == "https" || (u.scheme == "http" && %w[localhost 127.0.0.1 ::1 [::1]].include?(u.host))
|
|
40
|
+
rescue URI::InvalidURIError
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def get_json(url)
|
|
45
|
+
status, text, = Transport.call("GET", url, timeout: 5)
|
|
46
|
+
raise "#{url} answered #{status}" if status >= 400
|
|
47
|
+
|
|
48
|
+
Util.parse_json(text) || {}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The identity provider's OpenID configuration, checked against its issuer.
|
|
52
|
+
def discover(issuer, fetch = method(:get_json))
|
|
53
|
+
meta = fetch.call("#{issuer.chomp("/")}/.well-known/openid-configuration")
|
|
54
|
+
raise ConfigError, "the identity provider's metadata does not match its issuer" unless meta["issuer"] == issuer
|
|
55
|
+
|
|
56
|
+
meta
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def post_form(url, fields, basic = nil)
|
|
60
|
+
raise ConfigError, "#{url} must be an https URL" unless safe_url?(url)
|
|
61
|
+
|
|
62
|
+
headers = { "Content-Type" => "application/x-www-form-urlencoded" }
|
|
63
|
+
if basic
|
|
64
|
+
raw = "#{URI.encode_www_form_component(basic[0]).gsub("+", "%20")}:#{URI.encode_www_form_component(basic[1]).gsub("+", "%20")}"
|
|
65
|
+
headers["Authorization"] = "Basic #{[raw].pack("m0")}"
|
|
66
|
+
end
|
|
67
|
+
status, text, = Transport.call("POST", url, headers: headers, body: Transport.form(fields), timeout: 10)
|
|
68
|
+
data = Util.parse_json(text)
|
|
69
|
+
data = {} unless data.is_a?(Hash)
|
|
70
|
+
raise IdJagError.new((data["error"] || "server_error").to_s, (data["error_description"] || "#{url} answered #{status}").to_s, status) if status >= 400
|
|
71
|
+
|
|
72
|
+
data
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def scope_text(scope)
|
|
76
|
+
scope.is_a?(Array) ? scope.join(" ") : scope
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Trades a member's ID token for an ID-JAG at the identity provider (token exchange,
|
|
80
|
+
# client_secret_basic): { "assertion", "expires_in", "scope" }.
|
|
81
|
+
def request_id_jag(client_id:, client_secret:, id_token:, audience:, issuer: nil, token_endpoint: nil, resource: nil, scope: nil)
|
|
82
|
+
unless token_endpoint
|
|
83
|
+
raise ConfigError, "request_id_jag needs issuer or token_endpoint" if issuer.to_s.empty?
|
|
84
|
+
|
|
85
|
+
token_endpoint = discover(issuer)["token_endpoint"]
|
|
86
|
+
raise ConfigError, "the identity provider names no token_endpoint" unless token_endpoint.is_a?(String)
|
|
87
|
+
end
|
|
88
|
+
data = post_form(token_endpoint, {
|
|
89
|
+
"grant_type" => TOKEN_EXCHANGE_GRANT, "requested_token_type" => ID_JAG_TOKEN_TYPE,
|
|
90
|
+
"subject_token" => id_token, "subject_token_type" => ID_TOKEN_TYPE,
|
|
91
|
+
"audience" => audience, "resource" => resource, "scope" => scope_text(scope)
|
|
92
|
+
}, [client_id, client_secret])
|
|
93
|
+
unless data["issued_token_type"] == ID_JAG_TOKEN_TYPE && data["access_token"].is_a?(String)
|
|
94
|
+
raise IdJagError.new("invalid_response", "the identity provider did not answer with an ID-JAG")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
{ "assertion" => data["access_token"], "expires_in" => data["expires_in"], "scope" => data["scope"] }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Trades an ID-JAG for an access token at the other service's authorization server (jwt-bearer).
|
|
101
|
+
def redeem_id_jag(token_endpoint:, client_id:, assertion:, client_secret: nil, resource: nil, scope: nil)
|
|
102
|
+
fields = { "grant_type" => JWT_BEARER_GRANT, "assertion" => assertion, "resource" => resource, "scope" => scope_text(scope) }
|
|
103
|
+
fields["client_id"] = client_id if client_secret.to_s.empty?
|
|
104
|
+
post_form(token_endpoint, fields, client_secret.to_s.empty? ? nil : [client_id, client_secret])
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Your authorization server, taking ID-JAGs from an organization's identity provider:
|
|
109
|
+
# checks the one an authenticated client presents (jwt-bearer grant) and uses it once.
|
|
110
|
+
#
|
|
111
|
+
# verifier = Touchque::IdJagVerifier.new(issuer: "https://sso.touchque.com", audience: "https://auth.files.example")
|
|
112
|
+
# claims = verifier.verify(params[:assertion], client_id: authenticated_client_id)
|
|
113
|
+
#
|
|
114
|
+
# +first_use+ (a callable jti, exp → true when new) lets several processes share a
|
|
115
|
+
# store such as Redis SET NX; memory in this process by default.
|
|
116
|
+
class IdJagVerifier
|
|
117
|
+
def initialize(issuer:, audience:, jwks_uri: nil, clock_skew: 60, first_use: nil, fetch: nil)
|
|
118
|
+
if issuer.to_s.empty? || audience.to_s.empty?
|
|
119
|
+
raise ConfigError, "IdJagVerifier needs issuer (the identity provider) and audience (your authorization server's issuer)"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
@issuer = issuer
|
|
123
|
+
@audience = audience
|
|
124
|
+
@jwks_uri = jwks_uri
|
|
125
|
+
@skew = clock_skew
|
|
126
|
+
@first_use = first_use || method(:remember_once)
|
|
127
|
+
@fetch = fetch || CrossAppAccess.method(:get_json)
|
|
128
|
+
@keys = {}
|
|
129
|
+
@keys_at = 0
|
|
130
|
+
@tried_at = 0
|
|
131
|
+
@seen = {}
|
|
132
|
+
@lock = Mutex.new
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The ID-JAG, checked and used once: its claims, or IdJagError (error invalid_grant).
|
|
136
|
+
def verify(assertion, client_id:)
|
|
137
|
+
bad = ->(message) { IdJagError.new("invalid_grant", message) }
|
|
138
|
+
parts = JWS.split(assertion)
|
|
139
|
+
raise bad.call("assertion is not a JWT") unless parts
|
|
140
|
+
|
|
141
|
+
head, c, input, sig = parts
|
|
142
|
+
raise bad.call("not an ID-JAG (typ oauth-id-jag+jwt)") unless head["typ"] == "oauth-id-jag+jwt"
|
|
143
|
+
raise bad.call("unsupported signature") unless %w[RS256 ES256 EdDSA].include?(head["alg"]) && head["kid"].is_a?(String)
|
|
144
|
+
|
|
145
|
+
key = begin
|
|
146
|
+
key_for(head["kid"])
|
|
147
|
+
rescue StandardError => e
|
|
148
|
+
raise IdJagError.new("temporarily_unavailable", "the identity provider's keys could not be read: #{e.message}")
|
|
149
|
+
end
|
|
150
|
+
raise bad.call("signature does not verify") unless key && JWS.verify(head["alg"], key, input, sig)
|
|
151
|
+
|
|
152
|
+
now = Util.now
|
|
153
|
+
auds = c["aud"].is_a?(Array) ? c["aud"] : [c["aud"]]
|
|
154
|
+
raise bad.call("another issuer") unless c["iss"] == @issuer
|
|
155
|
+
raise bad.call("not for this authorization server") unless auds.length == 1 && auds[0] == @audience
|
|
156
|
+
raise bad.call("issued to another client") unless c["client_id"].is_a?(String) && c["client_id"] == client_id
|
|
157
|
+
raise bad.call("expired") unless c["exp"].is_a?(Numeric) && c["exp"] + @skew >= now
|
|
158
|
+
raise bad.call("issued in the future") unless c["iat"].is_a?(Numeric) && c["iat"] - @skew <= now
|
|
159
|
+
raise bad.call("sub and jti are required") unless c["sub"].is_a?(String) && c["jti"].is_a?(String)
|
|
160
|
+
raise bad.call("already used") unless @first_use.call(c["jti"], c["exp"].to_i)
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
"iss" => c["iss"], "sub" => c["sub"], "aud" => auds[0], "client_id" => c["client_id"], "jti" => c["jti"],
|
|
164
|
+
"issued_at" => c["iat"].to_i, "expires_at" => c["exp"].to_i,
|
|
165
|
+
"scopes" => c["scope"].is_a?(String) && !c["scope"].empty? ? c["scope"].split(" ") : [],
|
|
166
|
+
"resource" => c["resource"], "tenant" => c["tenant"], "email" => c["email"], "auth_time" => c["auth_time"], "amr" => c["amr"],
|
|
167
|
+
"claims" => c
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def remember_once(jti, exp)
|
|
174
|
+
@lock.synchronize do
|
|
175
|
+
now = Util.now
|
|
176
|
+
@seen.delete_if { |_, until_at| until_at < now }
|
|
177
|
+
return false if @seen.key?(jti)
|
|
178
|
+
|
|
179
|
+
@seen[jti] = exp + @skew
|
|
180
|
+
true
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def load_keys
|
|
185
|
+
unless @jwks_uri
|
|
186
|
+
@jwks_uri = CrossAppAccess.discover(@issuer, @fetch)["jwks_uri"]
|
|
187
|
+
raise "the identity provider names no jwks_uri" unless @jwks_uri.is_a?(String)
|
|
188
|
+
end
|
|
189
|
+
@keys = (@fetch.call(@jwks_uri)["keys"] || []).each_with_object({}) { |k, h| h[k["kid"]] = k if k["kid"] }
|
|
190
|
+
@keys_at = Util.now
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def key_for(kid)
|
|
194
|
+
@lock.synchronize do
|
|
195
|
+
load_keys if @keys_at.zero? || Util.now - @keys_at > 600
|
|
196
|
+
if !@keys.key?(kid) && Util.now - @tried_at > 60
|
|
197
|
+
@tried_at = Util.now
|
|
198
|
+
load_keys
|
|
199
|
+
end
|
|
200
|
+
@keys[kid]
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Touchque
|
|
6
|
+
# Key helpers for AI agents (Touchque::Agent): new key pairs and RFC 7638 thumbprints.
|
|
7
|
+
module AgentKeys
|
|
8
|
+
# DER prefixes of the key formats OpenSSL reads (the key bytes follow them).
|
|
9
|
+
ED25519_PKCS8 = ["302e020100300506032b657004220420"].pack("H*")
|
|
10
|
+
ED25519_SPKI = ["302a300506032b6570032100"].pack("H*")
|
|
11
|
+
P256_SPKI = ["3059301306072a8648ce3d020106082a8648ce3d030107034200"].pack("H*")
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# A new key pair: ES256 (P-256, default) or EdDSA (Ed25519), kid = the RFC 7638
|
|
16
|
+
# thumbprint. Register "publicJwk"; keep "privateJwk" where the agent runs.
|
|
17
|
+
def generate(alg = "ES256")
|
|
18
|
+
case alg
|
|
19
|
+
when "EdDSA"
|
|
20
|
+
key = OpenSSL::PKey.generate_key("ED25519")
|
|
21
|
+
public_jwk = { "kty" => "OKP", "crv" => "Ed25519", "x" => Util.b64url(key.public_to_der[-32, 32]) }
|
|
22
|
+
d = key.private_to_der[-32, 32]
|
|
23
|
+
when "ES256"
|
|
24
|
+
key = OpenSSL::PKey::EC.generate("prime256v1")
|
|
25
|
+
point = key.public_key.to_octet_string(:uncompressed)
|
|
26
|
+
public_jwk = { "kty" => "EC", "crv" => "P-256", "x" => Util.b64url(point[1, 32]), "y" => Util.b64url(point[33, 32]) }
|
|
27
|
+
d = pad32(key.private_key.to_s(2))
|
|
28
|
+
else
|
|
29
|
+
raise ConfigError, "alg must be ES256 or EdDSA"
|
|
30
|
+
end
|
|
31
|
+
kid = thumbprint(public_jwk)
|
|
32
|
+
{
|
|
33
|
+
"privateJwk" => public_jwk.merge("d" => Util.b64url(d), "kid" => kid, "alg" => alg),
|
|
34
|
+
"publicJwk" => public_jwk.merge("kid" => kid, "alg" => alg)
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# RFC 7638 thumbprint of a P-256 or Ed25519 public key (the default kid).
|
|
39
|
+
def thumbprint(jwk)
|
|
40
|
+
jwk = Util.stringify(jwk)
|
|
41
|
+
canonical = if jwk["kty"] == "EC"
|
|
42
|
+
"{\"crv\":#{Util.js_string(jwk["crv"])},\"kty\":\"EC\",\"x\":#{Util.js_string(jwk["x"])},\"y\":#{Util.js_string(jwk["y"])}}"
|
|
43
|
+
else
|
|
44
|
+
"{\"crv\":#{Util.js_string(jwk["crv"])},\"kty\":#{Util.js_string(jwk["kty"])},\"x\":#{Util.js_string(jwk["x"])}}"
|
|
45
|
+
end
|
|
46
|
+
Util.b64url(Util.sha256(canonical))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# base64url SHA-256 of an access token, as a DPoP proof carries it ("ath").
|
|
50
|
+
def access_token_hash(token)
|
|
51
|
+
Util.b64url(Util.sha256(token))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def pad32(bytes)
|
|
55
|
+
bytes = bytes.b
|
|
56
|
+
bytes.bytesize >= 32 ? bytes : ("\0" * (32 - bytes.bytesize)).b + bytes
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# An EC P-256 public key from its JWK coordinates.
|
|
60
|
+
def ec_public_key(x, y)
|
|
61
|
+
OpenSSL::PKey.read(P256_SPKI + "\x04".b + pad32(Util.b64url_decode(x)) + pad32(Util.b64url_decode(y)))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# An EC P-256 private key (SEC1 DER) from its JWK parts.
|
|
65
|
+
def ec_private_key(d, x, y)
|
|
66
|
+
der = ["30770201010420"].pack("H*") + pad32(Util.b64url_decode(d)) +
|
|
67
|
+
["a00a06082a8648ce3d030107a144034200"].pack("H*") + "\x04".b +
|
|
68
|
+
pad32(Util.b64url_decode(x)) + pad32(Util.b64url_decode(y))
|
|
69
|
+
OpenSSL::PKey.read(der)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def ed25519_private_key(seed)
|
|
73
|
+
OpenSSL::PKey.read(ED25519_PKCS8 + seed.b)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def ed25519_public_key(raw)
|
|
77
|
+
OpenSSL::PKey.read(ED25519_SPKI + raw.b)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# An RSA public key from its JWK modulus and exponent.
|
|
81
|
+
def rsa_public_key(n, e)
|
|
82
|
+
int = ->(b64) { OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(Util.b64url_decode(b64), 2)) }
|
|
83
|
+
rsa = OpenSSL::ASN1::Sequence.new([int.call(n), int.call(e)]).to_der
|
|
84
|
+
spki = OpenSSL::ASN1::Sequence.new([
|
|
85
|
+
OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::ObjectId.new("rsaEncryption"), OpenSSL::ASN1::Null.new(nil)]),
|
|
86
|
+
OpenSSL::ASN1::BitString.new(rsa)
|
|
87
|
+
]).to_der
|
|
88
|
+
OpenSSL::PKey.read(spki)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# JOSE's raw r||s as an ECDSA DER signature.
|
|
92
|
+
def raw_to_der(raw)
|
|
93
|
+
r = OpenSSL::BN.new(raw[0, 32], 2)
|
|
94
|
+
s = OpenSSL::BN.new(raw[32, 32], 2)
|
|
95
|
+
OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Integer.new(r), OpenSSL::ASN1::Integer.new(s)]).to_der
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# An ECDSA DER signature as JOSE's raw r||s (64 bytes).
|
|
99
|
+
def der_to_raw(der)
|
|
100
|
+
seq = OpenSSL::ASN1.decode(der)
|
|
101
|
+
seq.value.map { |i| pad32(i.value.to_s(2)) }.join.b
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# An ES256 or EdDSA private key that signs compact JWS (agent assertions, DPoP proofs).
|
|
106
|
+
class SigningKey
|
|
107
|
+
attr_reader :alg, :public_jwk
|
|
108
|
+
|
|
109
|
+
# A JWK (Hash or its JSON text) or an EC P-256 PEM.
|
|
110
|
+
def self.load(key, who)
|
|
111
|
+
jwk = if key.is_a?(Hash)
|
|
112
|
+
Util.stringify(key)
|
|
113
|
+
elsif key.is_a?(String) && key.lstrip.start_with?("{")
|
|
114
|
+
Util.parse_json(key)
|
|
115
|
+
end
|
|
116
|
+
return from_jwk(jwk) if jwk.is_a?(Hash)
|
|
117
|
+
return from_pem(key, who) if key.is_a?(String)
|
|
118
|
+
|
|
119
|
+
raise ConfigError, "#{who}: the private key must be a JWK (Hash or JSON) or an EC PEM"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.new_es256
|
|
123
|
+
key = OpenSSL::PKey::EC.generate("prime256v1")
|
|
124
|
+
point = key.public_key.to_octet_string(:uncompressed)
|
|
125
|
+
new("ES256", key, { "kty" => "EC", "crv" => "P-256", "x" => Util.b64url(point[1, 32]), "y" => Util.b64url(point[33, 32]) })
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.from_jwk(jwk)
|
|
129
|
+
raise ConfigError, "The agent key must be the PRIVATE key (it has no d)" if jwk["d"].to_s.empty?
|
|
130
|
+
|
|
131
|
+
d = Util.b64url_decode(jwk["d"])
|
|
132
|
+
if jwk["kty"] == "OKP" && jwk["crv"] == "Ed25519" && d.bytesize == 32
|
|
133
|
+
key = AgentKeys.ed25519_private_key(d)
|
|
134
|
+
return new("EdDSA", key, { "kty" => "OKP", "crv" => "Ed25519", "x" => Util.b64url(key.public_to_der[-32, 32]) })
|
|
135
|
+
end
|
|
136
|
+
if jwk["kty"] == "EC" && jwk["crv"] == "P-256" && jwk["x"] && jwk["y"]
|
|
137
|
+
key = AgentKeys.ec_private_key(jwk["d"], jwk["x"], jwk["y"])
|
|
138
|
+
return new("ES256", key, { "kty" => "EC", "crv" => "P-256", "x" => jwk["x"], "y" => jwk["y"] })
|
|
139
|
+
end
|
|
140
|
+
raise ConfigError, "The agent key must be EC P-256 or Ed25519"
|
|
141
|
+
rescue OpenSSL::PKey::PKeyError, ArgumentError
|
|
142
|
+
raise ConfigError, "The agent key could not be read"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.from_pem(pem, who)
|
|
146
|
+
key = OpenSSL::PKey.read(pem)
|
|
147
|
+
raise ConfigError, "An EC agent key must be on P-256" unless key.is_a?(OpenSSL::PKey::EC) && key.group.curve_name == "prime256v1"
|
|
148
|
+
|
|
149
|
+
point = key.public_key.to_octet_string(:uncompressed)
|
|
150
|
+
new("ES256", key, { "kty" => "EC", "crv" => "P-256", "x" => Util.b64url(point[1, 32]), "y" => Util.b64url(point[33, 32]) })
|
|
151
|
+
rescue OpenSSL::PKey::PKeyError, ArgumentError
|
|
152
|
+
raise ConfigError, "#{who}: the private key must be a JWK (Hash or JSON) or an EC P-256 PEM"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def initialize(alg, key, public_jwk)
|
|
156
|
+
@alg = alg
|
|
157
|
+
@key = key
|
|
158
|
+
@public_jwk = public_jwk
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def sign(input)
|
|
162
|
+
return @key.sign(nil, input) if @alg == "EdDSA"
|
|
163
|
+
|
|
164
|
+
AgentKeys.der_to_raw(@key.sign(OpenSSL::Digest.new("SHA256"), input))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# A compact JWS; "alg" is added to the header.
|
|
168
|
+
def jws(header, payload)
|
|
169
|
+
input = "#{Util.b64url(Util.json(header.merge("alg" => @alg)))}.#{Util.b64url(Util.json(payload))}"
|
|
170
|
+
"#{input}.#{Util.b64url(sign(input))}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# JWS signature checks for the algorithms Touchque uses.
|
|
175
|
+
module JWS
|
|
176
|
+
module_function
|
|
177
|
+
|
|
178
|
+
# [header, claims, signing input, signature] of a compact JWS, or nil.
|
|
179
|
+
def split(token)
|
|
180
|
+
parts = token.to_s.split(".", -1)
|
|
181
|
+
return nil unless parts.length == 3
|
|
182
|
+
|
|
183
|
+
header = Util.parse_json(Util.b64url_decode(parts[0]).force_encoding("UTF-8"))
|
|
184
|
+
claims = Util.parse_json(Util.b64url_decode(parts[1]).force_encoding("UTF-8"))
|
|
185
|
+
return nil unless header.is_a?(Hash) && claims.is_a?(Hash)
|
|
186
|
+
|
|
187
|
+
[header, claims, "#{parts[0]}.#{parts[1]}", Util.b64url_decode(parts[2])]
|
|
188
|
+
rescue ArgumentError
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def verify(alg, jwk, input, signature)
|
|
193
|
+
jwk = Util.stringify(jwk)
|
|
194
|
+
case alg
|
|
195
|
+
when "ES256"
|
|
196
|
+
return false unless jwk["kty"] == "EC" && jwk["crv"] == "P-256" && signature.bytesize == 64
|
|
197
|
+
|
|
198
|
+
AgentKeys.ec_public_key(jwk["x"], jwk["y"]).verify(OpenSSL::Digest.new("SHA256"), AgentKeys.raw_to_der(signature), input)
|
|
199
|
+
when "RS256"
|
|
200
|
+
return false unless jwk["kty"] == "RSA"
|
|
201
|
+
|
|
202
|
+
AgentKeys.rsa_public_key(jwk["n"], jwk["e"]).verify(OpenSSL::Digest.new("SHA256"), signature, input)
|
|
203
|
+
when "EdDSA"
|
|
204
|
+
return false unless jwk["kty"] == "OKP" && jwk["crv"] == "Ed25519" && signature.bytesize == 64
|
|
205
|
+
|
|
206
|
+
raw = Util.b64url_decode(jwk["x"])
|
|
207
|
+
raw.bytesize == 32 && AgentKeys.ed25519_public_key(raw).verify(nil, signature, input)
|
|
208
|
+
else
|
|
209
|
+
false
|
|
210
|
+
end
|
|
211
|
+
rescue OpenSSL::PKey::PKeyError, OpenSSL::ASN1::ASN1Error, ArgumentError
|
|
212
|
+
false
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|