atproto_auth 0.0.1
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/.rubocop.yml +16 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +179 -0
- data/Rakefile +16 -0
- data/examples/confidential_client/Gemfile +12 -0
- data/examples/confidential_client/Gemfile.lock +84 -0
- data/examples/confidential_client/README.md +110 -0
- data/examples/confidential_client/app.rb +136 -0
- data/examples/confidential_client/config/client-metadata.json +25 -0
- data/examples/confidential_client/config.ru +4 -0
- data/examples/confidential_client/public/client-metadata.json +24 -0
- data/examples/confidential_client/public/styles.css +70 -0
- data/examples/confidential_client/scripts/generate_keys.rb +15 -0
- data/examples/confidential_client/views/authorized.erb +29 -0
- data/examples/confidential_client/views/index.erb +44 -0
- data/examples/confidential_client/views/layout.erb +11 -0
- data/lib/atproto_auth/client.rb +410 -0
- data/lib/atproto_auth/client_metadata.rb +264 -0
- data/lib/atproto_auth/configuration.rb +17 -0
- data/lib/atproto_auth/dpop/client.rb +122 -0
- data/lib/atproto_auth/dpop/key_manager.rb +235 -0
- data/lib/atproto_auth/dpop/nonce_manager.rb +138 -0
- data/lib/atproto_auth/dpop/proof_generator.rb +112 -0
- data/lib/atproto_auth/errors.rb +47 -0
- data/lib/atproto_auth/http_client.rb +227 -0
- data/lib/atproto_auth/identity/document.rb +104 -0
- data/lib/atproto_auth/identity/resolver.rb +221 -0
- data/lib/atproto_auth/identity.rb +24 -0
- data/lib/atproto_auth/par/client.rb +203 -0
- data/lib/atproto_auth/par/client_assertion.rb +50 -0
- data/lib/atproto_auth/par/request.rb +140 -0
- data/lib/atproto_auth/par/response.rb +23 -0
- data/lib/atproto_auth/par.rb +40 -0
- data/lib/atproto_auth/pkce.rb +105 -0
- data/lib/atproto_auth/server_metadata/authorization_server.rb +175 -0
- data/lib/atproto_auth/server_metadata/origin_url.rb +51 -0
- data/lib/atproto_auth/server_metadata/resource_server.rb +71 -0
- data/lib/atproto_auth/server_metadata.rb +24 -0
- data/lib/atproto_auth/state/session.rb +117 -0
- data/lib/atproto_auth/state/session_manager.rb +75 -0
- data/lib/atproto_auth/state/token_set.rb +68 -0
- data/lib/atproto_auth/state.rb +54 -0
- data/lib/atproto_auth/version.rb +5 -0
- data/lib/atproto_auth.rb +56 -0
- data/sig/atproto_auth/client_metadata.rbs +95 -0
- data/sig/atproto_auth/dpop/client.rbs +38 -0
- data/sig/atproto_auth/dpop/key_manager.rbs +33 -0
- data/sig/atproto_auth/dpop/nonce_manager.rbs +48 -0
- data/sig/atproto_auth/dpop/proof_generator.rbs +42 -0
- data/sig/atproto_auth/http_client.rbs +58 -0
- data/sig/atproto_auth/identity/document.rbs +31 -0
- data/sig/atproto_auth/identity/resolver.rbs +41 -0
- data/sig/atproto_auth/par/client.rbs +31 -0
- data/sig/atproto_auth/par/request.rbs +73 -0
- data/sig/atproto_auth/par/response.rbs +17 -0
- data/sig/atproto_auth/pkce.rbs +24 -0
- data/sig/atproto_auth/server_metadata/authorization_server.rbs +69 -0
- data/sig/atproto_auth/server_metadata/origin_url.rbs +21 -0
- data/sig/atproto_auth/server_metadata/resource_server.rbs +27 -0
- data/sig/atproto_auth/state/session.rbs +50 -0
- data/sig/atproto_auth/state/session_manager.rbs +26 -0
- data/sig/atproto_auth/state/token_set.rbs +40 -0
- data/sig/atproto_auth/version.rbs +3 -0
- data/sig/atproto_auth.rbs +39 -0
- metadata +142 -0
@@ -0,0 +1,264 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "uri"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
module AtprotoAuth
|
7
|
+
module ApplicationType
|
8
|
+
ALL = [
|
9
|
+
WEB = "web",
|
10
|
+
NATIVE = "native"
|
11
|
+
].freeze
|
12
|
+
end
|
13
|
+
|
14
|
+
# Handles validation and management of AT Protocol OAuth client metadata according to
|
15
|
+
# the specification. This includes required fields like client_id and redirect URIs,
|
16
|
+
# optional metadata like client name and logo, and authentication configuration for
|
17
|
+
# confidential clients. Validates that all fields conform to the protocol's requirements,
|
18
|
+
# including:
|
19
|
+
# - Application type (web/native) validation and redirect URI rules
|
20
|
+
# - Required scopes and grant types
|
21
|
+
# - JWKS configuration for confidential clients
|
22
|
+
# - DPoP binding requirements
|
23
|
+
# - URI scheme and format validation
|
24
|
+
class ClientMetadata
|
25
|
+
# Required fields
|
26
|
+
attr_reader :application_type, :client_id, :grant_types, :response_types, :redirect_uris, :scope
|
27
|
+
# Optional fields
|
28
|
+
attr_reader :client_name, :client_uri, :logo_uri, :tos_uri, :policy_uri
|
29
|
+
# Authentication and key-related fields
|
30
|
+
attr_reader :token_endpoint_auth_method, :jwks, :jwks_uri
|
31
|
+
|
32
|
+
# Initializes a new ClientMetadata instance from metadata hash.
|
33
|
+
# @param metadata [Hash] Client metadata.
|
34
|
+
# @raise [InvalidClientMetadata] if metadata is invalid.
|
35
|
+
def initialize(metadata)
|
36
|
+
validate_and_set_metadata!(metadata)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Fetches client metadata from a URL and creates a new instance.
|
40
|
+
# @param url [String] URL to fetch metadata from.
|
41
|
+
# @return [ClientMetadata] new instance with fetched metadata.
|
42
|
+
# @raise [InvalidClientMetadata] if metadata is invalid or cannot be fetched.
|
43
|
+
def self.from_url(url)
|
44
|
+
validate_url!(url)
|
45
|
+
response = fetch_metadata(url)
|
46
|
+
metadata = parse_metadata(response[:body])
|
47
|
+
validate_client_id!(metadata["client_id"], url)
|
48
|
+
new(metadata)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Determines if the client is confidential (has authentication keys).
|
52
|
+
# @return [Boolean] true if client is confidential.
|
53
|
+
def confidential?
|
54
|
+
token_endpoint_auth_method == "private_key_jwt"
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def validate_and_set_metadata!(metadata) # rubocop:disable Metrics/AbcSize
|
60
|
+
# Required fields
|
61
|
+
@application_type = validate_application_type(metadata["application_type"])
|
62
|
+
@client_id = validate_client_id!(metadata["client_id"])
|
63
|
+
@grant_types = validate_grant_types!(metadata["grant_types"])
|
64
|
+
@response_types = validate_response_types!(metadata["response_types"])
|
65
|
+
@redirect_uris = validate_redirect_uris!(metadata["redirect_uris"])
|
66
|
+
@scope = validate_scope!(metadata["scope"])
|
67
|
+
|
68
|
+
validate_dpop!(metadata)
|
69
|
+
|
70
|
+
# Optional fields
|
71
|
+
@client_name = metadata["client_name"]
|
72
|
+
@client_uri = validate_client_uri(metadata["client_uri"])
|
73
|
+
@logo_uri = validate_https_uri(metadata["logo_uri"])
|
74
|
+
@tos_uri = validate_https_uri(metadata["tos_uri"])
|
75
|
+
@policy_uri = validate_https_uri(metadata["policy_uri"])
|
76
|
+
|
77
|
+
# Authentication methods
|
78
|
+
validate_auth_methods!(metadata)
|
79
|
+
end
|
80
|
+
|
81
|
+
def validate_client_id!(client_id)
|
82
|
+
raise InvalidClientMetadata, "client_id is required" unless client_id
|
83
|
+
|
84
|
+
uri = URI(client_id)
|
85
|
+
unless uri.scheme == "https" || (uri.scheme == "http" && uri.host == "localhost")
|
86
|
+
raise InvalidClientMetadata, "client_id must be HTTPS or localhost HTTP URL"
|
87
|
+
end
|
88
|
+
|
89
|
+
client_id
|
90
|
+
end
|
91
|
+
|
92
|
+
def validate_grant_types!(grant_types)
|
93
|
+
raise InvalidClientMetadata, "grant_types is required" unless grant_types
|
94
|
+
|
95
|
+
valid_types = %w[authorization_code refresh_token]
|
96
|
+
unless grant_types.include?("authorization_code") && (grant_types - valid_types).empty?
|
97
|
+
raise InvalidClientMetadata, "grant_types must include authorization_code and optionally refresh_token"
|
98
|
+
end
|
99
|
+
|
100
|
+
grant_types
|
101
|
+
end
|
102
|
+
|
103
|
+
def validate_response_types!(response_types)
|
104
|
+
raise InvalidClientMetadata, "response_types is required" unless response_types
|
105
|
+
raise InvalidClientMetadata, "response_types must include 'code'" unless response_types.include?("code")
|
106
|
+
|
107
|
+
response_types
|
108
|
+
end
|
109
|
+
|
110
|
+
def validate_redirect_uris!(uris)
|
111
|
+
raise InvalidClientMetadata, "redirect_uris is required" if uris.nil? || uris.none?
|
112
|
+
|
113
|
+
uris.each { |uri| validate_redirect_uri!(URI(uri)) }
|
114
|
+
uris
|
115
|
+
end
|
116
|
+
|
117
|
+
def validate_redirect_uri!(uri)
|
118
|
+
case application_type
|
119
|
+
when ApplicationType::WEB
|
120
|
+
if uri.host != "127.0.0.1" && uri.scheme != "https"
|
121
|
+
raise InvalidClientMetadata, "web clients must use HTTPS redirect URIs #{uri}"
|
122
|
+
end
|
123
|
+
|
124
|
+
validate_redirect_uri_origin!(uri)
|
125
|
+
when ApplicationType::NATIVE
|
126
|
+
validate_native_redirect_uri!(uri)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def validate_redirect_uri_origin!(uri)
|
131
|
+
client_origin = URI(@client_id).host
|
132
|
+
valid = client_origin == "localhost" ? true : uri.host == client_origin
|
133
|
+
raise InvalidClientMetadata, "redirect URI must match client_id origin" unless valid
|
134
|
+
end
|
135
|
+
|
136
|
+
def validate_native_redirect_uri!(uri)
|
137
|
+
if uri.scheme == "http"
|
138
|
+
unless ["127.0.0.1", "[::1]"].include?(uri.host)
|
139
|
+
raise InvalidClientMetadata, "HTTP redirect URIs for native clients must use loopback IP"
|
140
|
+
end
|
141
|
+
else
|
142
|
+
validate_custom_scheme!(uri)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def validate_custom_scheme!(uri)
|
147
|
+
reversed_host = URI(@client_id).host.split(".").reverse
|
148
|
+
scheme_parts = uri.scheme.split(".")
|
149
|
+
unless scheme_parts == reversed_host
|
150
|
+
raise InvalidClientMetadata, "custom scheme must match reversed client_id domain"
|
151
|
+
end
|
152
|
+
raise InvalidClientMetadata, "custom scheme URI must have single path component" unless uri.path == "/"
|
153
|
+
end
|
154
|
+
|
155
|
+
def validate_scope!(scope)
|
156
|
+
raise InvalidClientMetadata, "scope is required" unless scope
|
157
|
+
|
158
|
+
scope_values = scope.split
|
159
|
+
raise InvalidClientMetadata, "atproto scope is required" unless scope_values.include?("atproto")
|
160
|
+
|
161
|
+
# validate_offline_access_scope!(scope_values)
|
162
|
+
scope
|
163
|
+
end
|
164
|
+
|
165
|
+
def validate_offline_access_scope!(scope_values)
|
166
|
+
has_refresh = @grant_types&.include?("refresh_token")
|
167
|
+
has_offline = scope_values.include?("offline_access")
|
168
|
+
return unless has_refresh != has_offline
|
169
|
+
|
170
|
+
raise InvalidClientMetadata, "offline_access scope must match refresh_token grant type"
|
171
|
+
end
|
172
|
+
|
173
|
+
def validate_application_type(type)
|
174
|
+
type ||= ApplicationType::WEB # Default to web
|
175
|
+
unless ApplicationType::ALL.include?(type)
|
176
|
+
raise InvalidClientMetadata,
|
177
|
+
"application_type must be 'web' or 'native'"
|
178
|
+
end
|
179
|
+
|
180
|
+
type
|
181
|
+
end
|
182
|
+
|
183
|
+
def validate_client_uri(uri)
|
184
|
+
return unless uri
|
185
|
+
raise InvalidClientMetadata, "client_uri must match client_id origin" unless URI(uri).host == URI(@client_id).host
|
186
|
+
|
187
|
+
uri
|
188
|
+
end
|
189
|
+
|
190
|
+
def validate_https_uri(uri)
|
191
|
+
return unless uri
|
192
|
+
raise InvalidClientMetadata, "URI must use HTTPS" unless URI(uri).scheme == "https"
|
193
|
+
|
194
|
+
uri
|
195
|
+
end
|
196
|
+
|
197
|
+
def validate_jwks!(jwks)
|
198
|
+
raise InvalidClientMetadata, "jwks must have keys array" unless jwks["keys"].is_a?(Array)
|
199
|
+
|
200
|
+
jwks["keys"].each_with_index do |key, index|
|
201
|
+
has_key_use_sig = key["use"] == "sig"
|
202
|
+
has_key_ops_sign = key["key_ops"]&.include?("sign")
|
203
|
+
if !has_key_use_sig && !has_key_ops_sign
|
204
|
+
raise InvalidClientMetadata, "jwks.keys.#{index} must have use='sig' or key_ops including 'sign'"
|
205
|
+
end
|
206
|
+
|
207
|
+
raise InvalidClientMetadata, "jwks.keys.#{index} must have kid" unless key["kid"]
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
def validate_auth_methods!(metadata) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
212
|
+
@token_endpoint_auth_method = metadata["token_endpoint_auth_method"]
|
213
|
+
return unless @token_endpoint_auth_method == "private_key_jwt"
|
214
|
+
|
215
|
+
# Validate auth signing algorithm
|
216
|
+
@token_endpoint_auth_signing_alg = metadata["token_endpoint_auth_signing_alg"]
|
217
|
+
unless @token_endpoint_auth_signing_alg == "ES256"
|
218
|
+
raise InvalidClientMetadata, "token_endpoint_auth_signing_alg must be ES256"
|
219
|
+
end
|
220
|
+
|
221
|
+
@jwks = metadata["jwks"]
|
222
|
+
@jwks_uri = metadata["jwks_uri"]
|
223
|
+
raise InvalidClientMetadata, "cannot use both jwks and jwks_uri" if @jwks && @jwks_uri
|
224
|
+
raise InvalidClientMetadata, "confidential clients must provide jwks or jwks_uri" unless @jwks || @jwks_uri
|
225
|
+
|
226
|
+
validate_jwks!(@jwks) if @jwks
|
227
|
+
validate_https_uri(@jwks_uri) if @jwks_uri
|
228
|
+
end
|
229
|
+
|
230
|
+
def validate_dpop!(metadata)
|
231
|
+
return if metadata["dpop_bound_access_tokens"] == true
|
232
|
+
|
233
|
+
raise InvalidClientMetadata, "dpop_bound_access_tokens must be true"
|
234
|
+
end
|
235
|
+
|
236
|
+
class << self
|
237
|
+
private
|
238
|
+
|
239
|
+
def validate_url!(url)
|
240
|
+
uri = URI(url)
|
241
|
+
return if uri.scheme == "https" || uri.host == "localhost"
|
242
|
+
|
243
|
+
raise InvalidClientMetadata, "client_id must use HTTPS except for localhost"
|
244
|
+
end
|
245
|
+
|
246
|
+
def fetch_metadata(url)
|
247
|
+
AtprotoAuth.configuration.http_client&.get(url) ||
|
248
|
+
raise(InvalidClientMetadata, "HTTP client not configured")
|
249
|
+
end
|
250
|
+
|
251
|
+
def parse_metadata(body)
|
252
|
+
JSON.parse(body)
|
253
|
+
rescue JSON::ParserError => e
|
254
|
+
raise InvalidClientMetadata, "Invalid JSON in client metadata: #{e.message}"
|
255
|
+
end
|
256
|
+
|
257
|
+
def validate_client_id!(metadata_client_id, url)
|
258
|
+
return if metadata_client_id == url
|
259
|
+
|
260
|
+
raise InvalidClientMetadata, "client_id mismatch: #{metadata_client_id} != #{url}"
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "logger"
|
4
|
+
|
5
|
+
module AtprotoAuth
|
6
|
+
# Configuration class for global AtprotoAuth settings
|
7
|
+
class Configuration
|
8
|
+
attr_accessor :default_token_lifetime, :dpop_nonce_lifetime, :http_client, :logger
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@default_token_lifetime = 300 # 5 minutes in seconds
|
12
|
+
@dpop_nonce_lifetime = 300 # 5 minutes in seconds
|
13
|
+
@http_client = nil
|
14
|
+
@logger = Logger.new($stdout)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AtprotoAuth
|
4
|
+
module DPoP
|
5
|
+
# High-level client for managing DPoP operations. Integrates key management,
|
6
|
+
# proof generation, and nonce tracking to provide a complete DPoP client
|
7
|
+
# implementation according to RFC 9449.
|
8
|
+
#
|
9
|
+
# This client handles:
|
10
|
+
# - Key management for signing proofs
|
11
|
+
# - Proof generation for HTTP requests
|
12
|
+
# - Nonce tracking across servers
|
13
|
+
# - Header construction for requests
|
14
|
+
# - Response processing for nonce updates
|
15
|
+
class Client
|
16
|
+
# Error raised for DPoP client operations
|
17
|
+
class Error < AtprotoAuth::Error; end
|
18
|
+
|
19
|
+
# @return [KeyManager] DPoP key manager instance
|
20
|
+
attr_reader :key_manager
|
21
|
+
# @return [ProofGenerator] DPoP proof generator instance
|
22
|
+
attr_reader :proof_generator
|
23
|
+
# @return [NonceManager] DPoP nonce manager instance
|
24
|
+
attr_reader :nonce_manager
|
25
|
+
|
26
|
+
# Creates a new DPoP client
|
27
|
+
# @param key_manager [KeyManager, nil] Optional existing key manager
|
28
|
+
# @param nonce_ttl [Integer, nil] Optional TTL for nonces in seconds
|
29
|
+
def initialize(key_manager: nil, nonce_ttl: nil)
|
30
|
+
@key_manager = key_manager || KeyManager.new
|
31
|
+
@nonce_manager = NonceManager.new(ttl: nonce_ttl)
|
32
|
+
@proof_generator = ProofGenerator.new(@key_manager)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Generates a DPoP proof for an HTTP request
|
36
|
+
# @param http_method [String] HTTP method (e.g., "POST")
|
37
|
+
# @param http_uri [String] Full request URI
|
38
|
+
# @param access_token [String, nil] Optional access token to bind to proof
|
39
|
+
# @return [String] The DPoP proof JWT
|
40
|
+
# @raise [Error] if proof generation fails
|
41
|
+
def generate_proof(http_method:, http_uri:, access_token: nil, nonce: nil)
|
42
|
+
uri = URI(http_uri)
|
43
|
+
server_url = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port != uri.default_port}"
|
44
|
+
|
45
|
+
# Use provided nonce or get one from the manager
|
46
|
+
nonce ||= @nonce_manager.get(server_url)
|
47
|
+
|
48
|
+
@proof_generator.generate(
|
49
|
+
http_method: http_method,
|
50
|
+
http_uri: http_uri,
|
51
|
+
nonce: nonce,
|
52
|
+
access_token: access_token
|
53
|
+
)
|
54
|
+
rescue StandardError => e
|
55
|
+
raise Error, "Failed to generate proof: #{e.message}"
|
56
|
+
end
|
57
|
+
|
58
|
+
# Updates stored nonce from server response
|
59
|
+
# @param response_headers [Hash] Response headers
|
60
|
+
# @param server_url [String] Server's base URL
|
61
|
+
# @return [void]
|
62
|
+
# @raise [Error] if nonce update fails
|
63
|
+
def process_response(response_headers, server_url)
|
64
|
+
return unless response_headers
|
65
|
+
|
66
|
+
# Look for DPoP-Nonce header (case insensitive)
|
67
|
+
nonce = response_headers.find { |k, _| k.downcase == "dpop-nonce" }&.last
|
68
|
+
return unless nonce
|
69
|
+
|
70
|
+
# Store new nonce for future requests
|
71
|
+
@nonce_manager.update(nonce: nonce, server_url: server_url)
|
72
|
+
rescue StandardError => e
|
73
|
+
raise Error, "Failed to process response: #{e.message}"
|
74
|
+
end
|
75
|
+
|
76
|
+
# Constructs DPoP header value for a request
|
77
|
+
# @param proof [String] The DPoP proof JWT
|
78
|
+
# @return [Hash] Headers to add to request
|
79
|
+
def request_headers(proof)
|
80
|
+
{
|
81
|
+
"DPoP" => proof
|
82
|
+
}
|
83
|
+
end
|
84
|
+
|
85
|
+
# Gets the current public key in JWK format
|
86
|
+
# @return [Hash] JWK representation of public key
|
87
|
+
def public_key
|
88
|
+
@key_manager.public_jwk
|
89
|
+
end
|
90
|
+
|
91
|
+
# Exports the current keypair as JWK
|
92
|
+
# @param include_private [Boolean] Whether to include private key
|
93
|
+
# @return [Hash] JWK representation of keypair
|
94
|
+
def export_key(include_private: false)
|
95
|
+
@key_manager.to_jwk(include_private: include_private)
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def extract_nonce(headers)
|
101
|
+
# Headers can be hash with string or symbol keys, or http headers object
|
102
|
+
headers = headers.to_h if headers.respond_to?(:to_h)
|
103
|
+
|
104
|
+
# Try different common header key formats
|
105
|
+
nonce = headers["DPoP-Nonce"] ||
|
106
|
+
headers["dpop-nonce"] ||
|
107
|
+
headers[:dpop_nonce]
|
108
|
+
|
109
|
+
nonce&.strip
|
110
|
+
end
|
111
|
+
|
112
|
+
def origin_for_uri(uri)
|
113
|
+
port = uri.port
|
114
|
+
port = nil if (uri.scheme == "https" && port == 443) || (uri.scheme == "http" && port == 80)
|
115
|
+
|
116
|
+
origin = "#{uri.scheme}://#{uri.host}"
|
117
|
+
origin = "#{origin}:#{port}" if port
|
118
|
+
origin
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,235 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AtprotoAuth
|
4
|
+
module DPoP
|
5
|
+
# Manages ES256 keypair generation and storage for DPoP proofs.
|
6
|
+
# Provides functionality to generate new keys and store them securely.
|
7
|
+
# Uses JOSE for cryptographic operations and key format handling.
|
8
|
+
class KeyManager
|
9
|
+
# Error raised when key operations fail
|
10
|
+
class KeyError < AtprotoAuth::Error; end
|
11
|
+
|
12
|
+
# Default curve for ES256 key generation
|
13
|
+
CURVE = "P-256"
|
14
|
+
# Default algorithm for key usage
|
15
|
+
ALGORITHM = "ES256"
|
16
|
+
|
17
|
+
# @return [JOSE::JWK] The current DPoP keypair
|
18
|
+
attr_reader :keypair
|
19
|
+
|
20
|
+
# Creates a new KeyManager instance with an optional existing keypair
|
21
|
+
# @param keypair [JOSE::JWK, nil] Optional existing keypair to use
|
22
|
+
# @raise [KeyError] if the provided keypair is invalid
|
23
|
+
def initialize(keypair = nil)
|
24
|
+
@keypair = keypair || generate_keypair
|
25
|
+
validate_keypair!
|
26
|
+
end
|
27
|
+
|
28
|
+
# Generates a new ES256 keypair for DPoP usage
|
29
|
+
# @return [JOSE::JWK] The newly generated keypair
|
30
|
+
# @raise [KeyError] if key generation fails
|
31
|
+
def generate_keypair
|
32
|
+
# Generate base keypair
|
33
|
+
base_key = JOSE::JWK.generate_key([:ec, CURVE])
|
34
|
+
base_map = base_key.to_map
|
35
|
+
|
36
|
+
# Create new map with all required properties
|
37
|
+
key_map = {
|
38
|
+
"kty" => base_map["kty"],
|
39
|
+
"crv" => base_map["crv"],
|
40
|
+
"x" => base_map["x"],
|
41
|
+
"y" => base_map["y"],
|
42
|
+
"d" => base_map["d"],
|
43
|
+
"use" => "sig",
|
44
|
+
"kid" => generate_kid(base_map)
|
45
|
+
}
|
46
|
+
|
47
|
+
# Create new JWK with all properties
|
48
|
+
JOSE::JWK.from_map(key_map)
|
49
|
+
rescue StandardError => e
|
50
|
+
raise KeyError, "Failed to generate keypair: #{e.message}"
|
51
|
+
end
|
52
|
+
|
53
|
+
# Returns the public key in JWK format
|
54
|
+
# @return [Hash] JWK representation of the public key
|
55
|
+
def public_jwk
|
56
|
+
jwk = @keypair.to_public.to_map.to_h
|
57
|
+
# If somehow the properties aren't set, add them
|
58
|
+
jwk["use"] ||= "sig"
|
59
|
+
jwk["kid"] ||= generate_kid(jwk)
|
60
|
+
jwk
|
61
|
+
rescue StandardError => e
|
62
|
+
raise KeyError, "Failed to export public key: #{e.message}"
|
63
|
+
end
|
64
|
+
|
65
|
+
# Signs data using the private key
|
66
|
+
# @param data [String] Data to sign
|
67
|
+
# @return [String] The signature
|
68
|
+
# @raise [KeyError] if signing fails
|
69
|
+
def sign(data)
|
70
|
+
@keypair.sign(data).compact
|
71
|
+
rescue StandardError => e
|
72
|
+
raise KeyError, "Failed to sign data: #{e.message}"
|
73
|
+
end
|
74
|
+
|
75
|
+
def sign_segments(header, payload)
|
76
|
+
# Deep transform all keys to strings to avoid symbol comparison issues
|
77
|
+
header = deep_stringify_keys(header)
|
78
|
+
payload = deep_stringify_keys(payload)
|
79
|
+
|
80
|
+
# Configure JOSE to use ES256 for signing
|
81
|
+
signing_config = { "alg" => "ES256" }
|
82
|
+
|
83
|
+
# Merge our header with JOSE's required fields
|
84
|
+
full_header = header.merge(signing_config)
|
85
|
+
|
86
|
+
# Convert payload to JSON string before signing
|
87
|
+
payload_json = JSON.generate(payload)
|
88
|
+
|
89
|
+
# Create the JWS with our header and payload
|
90
|
+
jws = @keypair.sign(payload_json, full_header)
|
91
|
+
|
92
|
+
# Get the compact serialization
|
93
|
+
jws.compact
|
94
|
+
rescue StandardError => e
|
95
|
+
raise KeyError, "Failed to sign segments: #{e.message}"
|
96
|
+
end
|
97
|
+
|
98
|
+
def deep_stringify_keys(obj)
|
99
|
+
case obj
|
100
|
+
when Hash
|
101
|
+
obj.each_with_object({}) do |(k, v), hash|
|
102
|
+
hash[k.to_s] = deep_stringify_keys(v)
|
103
|
+
end
|
104
|
+
when Array
|
105
|
+
obj.map { |v| deep_stringify_keys(v) }
|
106
|
+
else
|
107
|
+
obj
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
# Verifies a signed JWS
|
112
|
+
# @param signed_jws [String] The complete signed JWS to verify
|
113
|
+
# @return [Boolean] True if signature is valid
|
114
|
+
# @raise [KeyError] if verification fails
|
115
|
+
def verify(signed_jws)
|
116
|
+
verified, _payload, = @keypair.verify(signed_jws)
|
117
|
+
verified
|
118
|
+
rescue StandardError => e
|
119
|
+
raise KeyError, "Failed to verify signature: #{e.message}"
|
120
|
+
end
|
121
|
+
|
122
|
+
# Exports the keypair in JWK format
|
123
|
+
# @param include_private [Boolean] Whether to include private key
|
124
|
+
# @return [Hash] JWK representation of the keypair
|
125
|
+
# @raise [KeyError] if export fails
|
126
|
+
def to_jwk(include_private: false)
|
127
|
+
key = include_private ? @keypair : @keypair.to_public
|
128
|
+
key.to_map
|
129
|
+
rescue StandardError => e
|
130
|
+
raise KeyError, "Failed to export key: #{e.message}"
|
131
|
+
end
|
132
|
+
|
133
|
+
# Creates a KeyManager instance from a JWK
|
134
|
+
# @param jwk [Hash] JWK representation of a keypair
|
135
|
+
# @return [KeyManager] New KeyManager instance
|
136
|
+
# @raise [KeyError] if import fails
|
137
|
+
def self.from_jwk(jwk)
|
138
|
+
keypair = JOSE::JWK.from_map(jwk)
|
139
|
+
new(keypair)
|
140
|
+
rescue StandardError => e
|
141
|
+
raise KeyError, "Failed to import key: #{e.message}"
|
142
|
+
end
|
143
|
+
|
144
|
+
private
|
145
|
+
|
146
|
+
def generate_kid(jwk)
|
147
|
+
# Generate a key ID based on the key's components
|
148
|
+
components = [
|
149
|
+
jwk["kty"],
|
150
|
+
jwk["crv"],
|
151
|
+
jwk["x"],
|
152
|
+
jwk["y"]
|
153
|
+
].join(":")
|
154
|
+
|
155
|
+
# Create a SHA-256 hash and take first 8 bytes
|
156
|
+
digest = OpenSSL::Digest::SHA256.digest(components)
|
157
|
+
Base64.urlsafe_encode64(digest[0..7], padding: false)
|
158
|
+
end
|
159
|
+
|
160
|
+
# Validates that the keypair meets DPoP requirements
|
161
|
+
# @raise [KeyError] if validation fails
|
162
|
+
def validate_keypair!
|
163
|
+
# Check that we have a valid EC key
|
164
|
+
raise KeyError, "Invalid key type: #{@keypair.kty}, must be :ec" unless @keypair.kty.is_a?(JOSE::JWK::KTY_EC)
|
165
|
+
|
166
|
+
# Verify the curve
|
167
|
+
curve = @keypair.to_map["crv"]
|
168
|
+
raise KeyError, "Invalid curve: #{curve}, must be #{CURVE}" unless curve == CURVE
|
169
|
+
|
170
|
+
# Verify we can perform basic operations
|
171
|
+
test_data = "test"
|
172
|
+
signed = sign(test_data)
|
173
|
+
raise KeyError, "Key validation failed: signature verification error" unless verify(signed)
|
174
|
+
rescue StandardError => e
|
175
|
+
raise KeyError, "Key validation failed: #{e.message}"
|
176
|
+
end
|
177
|
+
|
178
|
+
def sign_message(message)
|
179
|
+
# Create SHA-256 digest of message
|
180
|
+
digest = OpenSSL::Digest::SHA256.digest(message)
|
181
|
+
|
182
|
+
# Get EC key from JOSE JWK
|
183
|
+
ec_key = extract_ec_key
|
184
|
+
|
185
|
+
# Sign using ECDSA
|
186
|
+
signature = ec_key.sign(OpenSSL::Digest.new("SHA256"), digest)
|
187
|
+
|
188
|
+
# Convert to raw r|s format required for JWTs
|
189
|
+
asn1_to_raw(signature)
|
190
|
+
end
|
191
|
+
|
192
|
+
def extract_ec_key # rubocop:disable Metrics/AbcSize
|
193
|
+
# Extract the raw EC key from JOSE JWK
|
194
|
+
key_data = @keypair.to_map
|
195
|
+
raise KeyError, "Private key required for signing" unless key_data["d"] # Private key component
|
196
|
+
|
197
|
+
group = OpenSSL::PKey::EC::Group.new("prime256v1")
|
198
|
+
key = OpenSSL::PKey::EC.new(group)
|
199
|
+
|
200
|
+
# Convert base64url to hex string for BN
|
201
|
+
d = bin_to_hex(Base64.urlsafe_decode64(key_data["d"]))
|
202
|
+
x = bin_to_hex(Base64.urlsafe_decode64(key_data["x"]))
|
203
|
+
y = bin_to_hex(Base64.urlsafe_decode64(key_data["y"]))
|
204
|
+
|
205
|
+
# Create BNs from hex strings
|
206
|
+
key.private_key = OpenSSL::BN.new(d, 16)
|
207
|
+
|
208
|
+
# Set public key point
|
209
|
+
point = OpenSSL::PKey::EC::Point.new(group)
|
210
|
+
point.set_to_keypair(x, y)
|
211
|
+
key.public_key = point
|
212
|
+
|
213
|
+
key
|
214
|
+
end
|
215
|
+
|
216
|
+
def bin_to_hex(binary)
|
217
|
+
binary.unpack1("H*")
|
218
|
+
end
|
219
|
+
|
220
|
+
def asn1_to_raw(signature)
|
221
|
+
# Parse ASN.1 signature
|
222
|
+
asn1 = OpenSSL::ASN1.decode(signature)
|
223
|
+
r = asn1.value[0].value.to_s(2)
|
224
|
+
s = asn1.value[1].value.to_s(2)
|
225
|
+
|
226
|
+
# Pad r and s to 32 bytes each
|
227
|
+
r = r.rjust(32, "\x00")
|
228
|
+
s = s.rjust(32, "\x00")
|
229
|
+
|
230
|
+
# Concatenate r|s
|
231
|
+
r + s
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|