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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +105 -0
  3. data/LICENSE +21 -0
  4. data/README.md +383 -0
  5. data/lib/touchque/agent.rb +211 -0
  6. data/lib/touchque/agent_card.rb +159 -0
  7. data/lib/touchque/approvals.rb +214 -0
  8. data/lib/touchque/client.rb +81 -0
  9. data/lib/touchque/code_reply.rb +53 -0
  10. data/lib/touchque/cross_app_access.rb +204 -0
  11. data/lib/touchque/crypto.rb +215 -0
  12. data/lib/touchque/errors.rb +246 -0
  13. data/lib/touchque/flow.rb +362 -0
  14. data/lib/touchque/hosted_login.rb +401 -0
  15. data/lib/touchque/http.rb +127 -0
  16. data/lib/touchque/login_session.rb +86 -0
  17. data/lib/touchque/org_agent.rb +398 -0
  18. data/lib/touchque/rack/relay.rb +75 -0
  19. data/lib/touchque/rails/guard.rb +132 -0
  20. data/lib/touchque/rails/hosted_login.rb +143 -0
  21. data/lib/touchque/rails/sessions_controller.rb +12 -0
  22. data/lib/touchque/railtie.rb +34 -0
  23. data/lib/touchque/relay.rb +533 -0
  24. data/lib/touchque/resources/actions.rb +212 -0
  25. data/lib/touchque/resources/agents.rb +251 -0
  26. data/lib/touchque/resources/device_risk.rb +130 -0
  27. data/lib/touchque/resources/identity.rb +331 -0
  28. data/lib/touchque/resources/login.rb +325 -0
  29. data/lib/touchque/resources/mobile.rb +115 -0
  30. data/lib/touchque/resources/people.rb +237 -0
  31. data/lib/touchque/resources/qr_pairings.rb +82 -0
  32. data/lib/touchque/resources/safe_guard.rb +87 -0
  33. data/lib/touchque/resources/webhook.rb +177 -0
  34. data/lib/touchque/safe_approval.rb +283 -0
  35. data/lib/touchque/security_events.rb +162 -0
  36. data/lib/touchque/util.rb +260 -0
  37. data/lib/touchque/version.rb +5 -0
  38. data/lib/touchque.rb +124 -0
  39. 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