atproto_auth 0.0.1 → 0.1.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 +4 -4
- data/.rubocop.yml +17 -2
- data/CHANGELOG.md +23 -2
- data/README.md +91 -5
- data/examples/confidential_client/.gitignore +2 -0
- data/examples/confidential_client/Gemfile +1 -0
- data/examples/confidential_client/Gemfile.lock +10 -1
- data/examples/confidential_client/README.md +86 -9
- data/examples/confidential_client/app.rb +83 -12
- data/examples/confidential_client/{public/client-metadata.json → config/client-metadata.example.json} +5 -4
- data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
- data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
- data/examples/confidential_client/scripts/generate_keys.rb +0 -0
- data/examples/confidential_client/views/authorized.erb +1 -1
- data/lib/atproto_auth/client.rb +98 -38
- data/lib/atproto_auth/client_metadata.rb +2 -2
- data/lib/atproto_auth/configuration.rb +35 -1
- data/lib/atproto_auth/dpop/key_manager.rb +1 -1
- data/lib/atproto_auth/dpop/nonce_manager.rb +30 -47
- data/lib/atproto_auth/encryption.rb +156 -0
- data/lib/atproto_auth/http_client.rb +2 -2
- data/lib/atproto_auth/identity/document.rb +1 -1
- data/lib/atproto_auth/identity/resolver.rb +1 -1
- data/lib/atproto_auth/serialization/base.rb +189 -0
- data/lib/atproto_auth/serialization/dpop_key.rb +29 -0
- data/lib/atproto_auth/serialization/session.rb +77 -0
- data/lib/atproto_auth/serialization/stored_nonce.rb +37 -0
- data/lib/atproto_auth/serialization/token_set.rb +43 -0
- data/lib/atproto_auth/server_metadata/authorization_server.rb +20 -1
- data/lib/atproto_auth/state/session_manager.rb +67 -20
- data/lib/atproto_auth/storage/interface.rb +112 -0
- data/lib/atproto_auth/storage/key_builder.rb +39 -0
- data/lib/atproto_auth/storage/memory.rb +191 -0
- data/lib/atproto_auth/storage/redis.rb +119 -0
- data/lib/atproto_auth/token/refresh.rb +249 -0
- data/lib/atproto_auth/version.rb +1 -1
- data/lib/atproto_auth.rb +29 -1
- metadata +32 -5
- data/examples/confidential_client/config/client-metadata.json +0 -25
data/lib/atproto_auth/client.rb
CHANGED
@@ -1,7 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
# rubocop:disable Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/CyclomaticComplexity
|
4
|
-
|
5
3
|
module AtprotoAuth
|
6
4
|
# Main client class for AT Protocol OAuth implementation. Handles the complete
|
7
5
|
# OAuth flow including authorization, token management, and identity verification.
|
@@ -12,6 +10,16 @@ module AtprotoAuth
|
|
12
10
|
# Error raised when token operations fail
|
13
11
|
class TokenError < Error; end
|
14
12
|
|
13
|
+
# Error raised when session operations fail
|
14
|
+
class SessionError < Error
|
15
|
+
attr_reader :session_id
|
16
|
+
|
17
|
+
def initialize(message, session_id: nil)
|
18
|
+
@session_id = session_id
|
19
|
+
super(message)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
15
23
|
# @return [String] OAuth client ID
|
16
24
|
attr_reader :client_id
|
17
25
|
# @return [String] OAuth redirect URI
|
@@ -59,6 +67,9 @@ module AtprotoAuth
|
|
59
67
|
scope: scope
|
60
68
|
)
|
61
69
|
|
70
|
+
# Store session with storage backend
|
71
|
+
session_manager.update_session(session)
|
72
|
+
|
62
73
|
# Resolve identity and authorization server if handle provided
|
63
74
|
if handle
|
64
75
|
auth_info = resolve_from_handle(handle, session)
|
@@ -96,34 +107,39 @@ module AtprotoAuth
|
|
96
107
|
# Verify issuer matches session
|
97
108
|
raise CallbackError, "Issuer mismatch" unless session.auth_server && session.auth_server.issuer == iss
|
98
109
|
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
# Validate token response
|
106
|
-
validate_token_response!(token_response, session)
|
107
|
-
|
108
|
-
# Create token set and store in session
|
109
|
-
token_set = State::TokenSet.new(
|
110
|
-
access_token: token_response["access_token"],
|
111
|
-
token_type: token_response["token_type"],
|
112
|
-
expires_in: token_response["expires_in"],
|
113
|
-
refresh_token: token_response["refresh_token"],
|
114
|
-
scope: token_response["scope"],
|
115
|
-
sub: token_response["sub"]
|
116
|
-
)
|
117
|
-
session.tokens = token_set
|
110
|
+
AtprotoAuth.storage.with_lock(Storage::KeyBuilder.lock_key("session", session.session_id), ttl: 30) do
|
111
|
+
# Exchange code for tokens
|
112
|
+
token_response = exchange_code(
|
113
|
+
code: code,
|
114
|
+
session: session
|
115
|
+
)
|
118
116
|
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
117
|
+
# Validate token response
|
118
|
+
validate_token_response!(token_response, session)
|
119
|
+
|
120
|
+
# Create token set and store in session
|
121
|
+
token_set = State::TokenSet.new(
|
122
|
+
access_token: token_response["access_token"],
|
123
|
+
token_type: token_response["token_type"],
|
124
|
+
expires_in: token_response["expires_in"],
|
125
|
+
refresh_token: token_response["refresh_token"],
|
126
|
+
scope: token_response["scope"],
|
127
|
+
sub: token_response["sub"]
|
128
|
+
)
|
129
|
+
session.tokens = token_set
|
130
|
+
|
131
|
+
# Update stored session
|
132
|
+
session_manager.update_session(session)
|
133
|
+
|
134
|
+
{
|
135
|
+
access_token: token_set.access_token,
|
136
|
+
token_type: token_set.token_type,
|
137
|
+
expires_in: (token_set.expires_at - Time.now).to_i,
|
138
|
+
refresh_token: token_set.refresh_token,
|
139
|
+
scope: token_set.scope,
|
140
|
+
session_id: session.session_id
|
141
|
+
}
|
142
|
+
end
|
127
143
|
end
|
128
144
|
|
129
145
|
# Gets active tokens for a session
|
@@ -142,6 +158,38 @@ module AtprotoAuth
|
|
142
158
|
}
|
143
159
|
end
|
144
160
|
|
161
|
+
# Refreshes tokens for a session
|
162
|
+
# @param session_id [String] ID of session to refresh
|
163
|
+
def refresh_token(session_id)
|
164
|
+
session = session_manager.get_session(session_id)
|
165
|
+
raise TokenError, "Invalid session" unless session
|
166
|
+
raise TokenError, "Session not authorized" unless session.renewable?
|
167
|
+
|
168
|
+
AtprotoAuth.storage.with_lock(Storage::KeyBuilder.lock_key("session", session.session_id), ttl: 30) do
|
169
|
+
refresher = Token::Refresh.new(
|
170
|
+
session: session,
|
171
|
+
dpop_client: @dpop_client,
|
172
|
+
auth_server: session.auth_server,
|
173
|
+
client_metadata: client_metadata
|
174
|
+
)
|
175
|
+
|
176
|
+
new_tokens = refresher.perform!
|
177
|
+
session.tokens = new_tokens
|
178
|
+
|
179
|
+
# Update stored session
|
180
|
+
session_manager.update_session(session)
|
181
|
+
|
182
|
+
{
|
183
|
+
access_token: new_tokens.access_token,
|
184
|
+
token_type: new_tokens.token_type,
|
185
|
+
expires_in: (new_tokens.expires_at - Time.now).to_i,
|
186
|
+
refresh_token: new_tokens.refresh_token,
|
187
|
+
scope: new_tokens.scope,
|
188
|
+
session_id: session.session_id
|
189
|
+
}
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
145
193
|
# Checks if a session has valid tokens
|
146
194
|
# @param session_id [String] ID of session to check
|
147
195
|
# @return [Boolean] true if session exists and has valid tokens
|
@@ -174,6 +222,21 @@ module AtprotoAuth
|
|
174
222
|
}
|
175
223
|
end
|
176
224
|
|
225
|
+
# Removes a session and its stored data
|
226
|
+
# @param session_id [String] ID of session to remove
|
227
|
+
# @return [void]
|
228
|
+
def remove_session(session_id)
|
229
|
+
key = Storage::KeyBuilder.session_key(session_id)
|
230
|
+
AtprotoAuth.storage.delete(key)
|
231
|
+
session_manager.remove_session(session_id)
|
232
|
+
end
|
233
|
+
|
234
|
+
# Cleans up expired sessions from storage
|
235
|
+
# @return [void]
|
236
|
+
def cleanup_expired_sessions
|
237
|
+
session_manager.cleanup_expired
|
238
|
+
end
|
239
|
+
|
177
240
|
private
|
178
241
|
|
179
242
|
def load_client_metadata(metadata)
|
@@ -214,6 +277,9 @@ module AtprotoAuth
|
|
214
277
|
server = resolve_auth_server(resolution[:pds])
|
215
278
|
session.authorization_server = server
|
216
279
|
|
280
|
+
# Update stored session
|
281
|
+
session_manager.update_session(session)
|
282
|
+
|
217
283
|
{ server: server, pds: resolution[:pds] }
|
218
284
|
end
|
219
285
|
|
@@ -222,6 +288,9 @@ module AtprotoAuth
|
|
222
288
|
server = resolve_auth_server(pds_url)
|
223
289
|
session.authorization_server = server
|
224
290
|
|
291
|
+
# Update stored session
|
292
|
+
session_manager.update_session(session)
|
293
|
+
|
225
294
|
{ server: server, pds: pds_url }
|
226
295
|
end
|
227
296
|
|
@@ -296,8 +365,6 @@ module AtprotoAuth
|
|
296
365
|
|
297
366
|
# Handle DPoP nonce requirement
|
298
367
|
if requires_dpop_nonce?(response)
|
299
|
-
puts "*" * 88
|
300
|
-
puts "requires_dpop_nonce"
|
301
368
|
# Extract and store nonce from error response
|
302
369
|
extract_dpop_nonce(response)
|
303
370
|
dpop_client.process_response(response[:headers], session.auth_server.issuer)
|
@@ -322,11 +389,6 @@ module AtprotoAuth
|
|
322
389
|
http_uri: session.auth_server.token_endpoint
|
323
390
|
)
|
324
391
|
|
325
|
-
# Log the DPoP proof details
|
326
|
-
AtprotoAuth.configuration.logger.debug "Token Request DPoP Proof:"
|
327
|
-
AtprotoAuth.configuration.logger.debug "- Key: #{dpop_client.public_key}"
|
328
|
-
AtprotoAuth.configuration.logger.debug "- Proof: #{proof}"
|
329
|
-
|
330
392
|
body = {
|
331
393
|
grant_type: "authorization_code",
|
332
394
|
code: code,
|
@@ -406,5 +468,3 @@ module AtprotoAuth
|
|
406
468
|
end
|
407
469
|
end
|
408
470
|
end
|
409
|
-
|
410
|
-
# rubocop:enable Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/CyclomaticComplexity
|
@@ -56,7 +56,7 @@ module AtprotoAuth
|
|
56
56
|
|
57
57
|
private
|
58
58
|
|
59
|
-
def validate_and_set_metadata!(metadata)
|
59
|
+
def validate_and_set_metadata!(metadata)
|
60
60
|
# Required fields
|
61
61
|
@application_type = validate_application_type(metadata["application_type"])
|
62
62
|
@client_id = validate_client_id!(metadata["client_id"])
|
@@ -208,7 +208,7 @@ module AtprotoAuth
|
|
208
208
|
end
|
209
209
|
end
|
210
210
|
|
211
|
-
def validate_auth_methods!(metadata)
|
211
|
+
def validate_auth_methods!(metadata)
|
212
212
|
@token_endpoint_auth_method = metadata["token_endpoint_auth_method"]
|
213
213
|
return unless @token_endpoint_auth_method == "private_key_jwt"
|
214
214
|
|
@@ -3,15 +3,49 @@
|
|
3
3
|
require "logger"
|
4
4
|
|
5
5
|
module AtprotoAuth
|
6
|
+
class ConfigurationError < Error; end
|
7
|
+
|
6
8
|
# Configuration class for global AtprotoAuth settings
|
7
9
|
class Configuration
|
8
|
-
attr_accessor :default_token_lifetime,
|
10
|
+
attr_accessor :default_token_lifetime,
|
11
|
+
:dpop_nonce_lifetime,
|
12
|
+
:encryption,
|
13
|
+
:http_client,
|
14
|
+
:logger,
|
15
|
+
:storage
|
9
16
|
|
10
17
|
def initialize
|
11
18
|
@default_token_lifetime = 300 # 5 minutes in seconds
|
12
19
|
@dpop_nonce_lifetime = 300 # 5 minutes in seconds
|
20
|
+
@encryption = nil
|
13
21
|
@http_client = nil
|
14
22
|
@logger = Logger.new($stdout)
|
23
|
+
@storage = AtprotoAuth::Storage::Memory.new
|
24
|
+
end
|
25
|
+
|
26
|
+
# Validates the current configuration
|
27
|
+
# @raise [ConfigurationError] if configuration is invalid
|
28
|
+
def validate!
|
29
|
+
validate_storage!
|
30
|
+
validate_http_client!
|
31
|
+
true
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def validate_storage!
|
37
|
+
raise ConfigurationError, "Storage must be configured" if @storage.nil?
|
38
|
+
return if @storage.is_a?(AtprotoAuth::Storage::Interface)
|
39
|
+
|
40
|
+
raise ConfigurationError, "Storage must implement Storage::Interface"
|
41
|
+
end
|
42
|
+
|
43
|
+
def validate_http_client!
|
44
|
+
return if @http_client.nil? # Allow nil for testing
|
45
|
+
|
46
|
+
return if @http_client.respond_to?(:get) && @http_client.respond_to?(:post)
|
47
|
+
|
48
|
+
raise ConfigurationError, "HTTP client must implement get and post methods"
|
15
49
|
end
|
16
50
|
end
|
17
51
|
end
|
@@ -189,7 +189,7 @@ module AtprotoAuth
|
|
189
189
|
asn1_to_raw(signature)
|
190
190
|
end
|
191
191
|
|
192
|
-
def extract_ec_key
|
192
|
+
def extract_ec_key
|
193
193
|
# Extract the raw EC key from JOSE JWK
|
194
194
|
key_data = @keypair.to_map
|
195
195
|
raise KeyError, "Private key required for signing" unless key_data["d"] # Private key component
|
@@ -1,40 +1,31 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "monitor"
|
4
|
-
|
5
3
|
module AtprotoAuth
|
6
4
|
module DPoP
|
7
5
|
# Manages DPoP nonces provided by servers during the OAuth flow.
|
8
|
-
# Tracks separate nonces for
|
6
|
+
# Tracks separate nonces for each server using persistent storage.
|
9
7
|
# Thread-safe to handle concurrent requests.
|
10
8
|
class NonceManager
|
11
9
|
# Error for nonce-related issues
|
12
10
|
class NonceError < AtprotoAuth::Error; end
|
13
11
|
|
14
|
-
# Represents a stored nonce with its
|
12
|
+
# Represents a stored nonce with its server URL
|
15
13
|
class StoredNonce
|
16
|
-
attr_reader :value, :
|
14
|
+
attr_reader :value, :server_url, :timestamp
|
17
15
|
|
18
|
-
def initialize(value, server_url)
|
16
|
+
def initialize(value, server_url, timestamp: nil)
|
19
17
|
@value = value
|
20
18
|
@server_url = server_url
|
21
|
-
@timestamp = Time.now.to_i
|
22
|
-
end
|
23
|
-
|
24
|
-
def expired?(ttl = nil)
|
25
|
-
return false unless ttl
|
26
|
-
|
27
|
-
(Time.now.to_i - @timestamp) > ttl
|
19
|
+
@timestamp = timestamp || Time.now.to_i
|
28
20
|
end
|
29
21
|
end
|
30
22
|
|
31
|
-
#
|
23
|
+
# Default time in seconds a nonce is considered valid
|
32
24
|
DEFAULT_TTL = 300 # 5 minutes
|
33
25
|
|
34
26
|
def initialize(ttl: nil)
|
35
27
|
@ttl = ttl || DEFAULT_TTL
|
36
|
-
@
|
37
|
-
@monitor = Monitor.new
|
28
|
+
@serializer = Serialization::StoredNonce.new
|
38
29
|
end
|
39
30
|
|
40
31
|
# Updates the stored nonce for a server
|
@@ -45,9 +36,13 @@ module AtprotoAuth
|
|
45
36
|
validate_inputs!(nonce, server_url)
|
46
37
|
origin = normalize_server_url(server_url)
|
47
38
|
|
48
|
-
|
49
|
-
|
50
|
-
|
39
|
+
stored_nonce = StoredNonce.new(nonce, origin)
|
40
|
+
serialized = @serializer.serialize(stored_nonce)
|
41
|
+
|
42
|
+
key = Storage::KeyBuilder.nonce_key(origin)
|
43
|
+
return if AtprotoAuth.storage.set(key, serialized, ttl: @ttl)
|
44
|
+
|
45
|
+
raise NonceError, "Failed to store nonce"
|
51
46
|
end
|
52
47
|
|
53
48
|
# Gets the current nonce for a server
|
@@ -57,36 +52,26 @@ module AtprotoAuth
|
|
57
52
|
def get(server_url)
|
58
53
|
validate_server_url!(server_url)
|
59
54
|
origin = normalize_server_url(server_url)
|
55
|
+
key = Storage::KeyBuilder.nonce_key(origin)
|
60
56
|
|
61
|
-
|
62
|
-
|
63
|
-
return nil if stored.nil? || stored.expired?(@ttl)
|
57
|
+
stored = AtprotoAuth.storage.get(key)
|
58
|
+
return nil unless stored
|
64
59
|
|
65
|
-
|
60
|
+
begin
|
61
|
+
stored_nonce = @serializer.deserialize(stored)
|
62
|
+
stored_nonce.value
|
63
|
+
rescue Serialization::Error => e
|
64
|
+
raise NonceError, "Failed to deserialize nonce: #{e.message}"
|
66
65
|
end
|
67
66
|
end
|
68
67
|
|
69
|
-
# Clears
|
68
|
+
# Clears a nonce for a server
|
70
69
|
# @param server_url [String] The server's URL
|
71
70
|
def clear(server_url)
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
# Clears all stored nonces
|
78
|
-
def clear_all
|
79
|
-
@monitor.synchronize do
|
80
|
-
@nonces.clear
|
81
|
-
end
|
82
|
-
end
|
83
|
-
|
84
|
-
# Get all currently stored server URLs
|
85
|
-
# @return [Array<String>] Array of server URLs with stored nonces
|
86
|
-
def server_urls
|
87
|
-
@monitor.synchronize do
|
88
|
-
@nonces.keys
|
89
|
-
end
|
71
|
+
validate_server_url!(server_url)
|
72
|
+
origin = normalize_server_url(server_url)
|
73
|
+
key = Storage::KeyBuilder.nonce_key(origin)
|
74
|
+
AtprotoAuth.storage.delete(key)
|
90
75
|
end
|
91
76
|
|
92
77
|
# Check if a server has a valid nonce
|
@@ -94,11 +79,9 @@ module AtprotoAuth
|
|
94
79
|
# @return [Boolean] true if server has a valid nonce
|
95
80
|
def valid_nonce?(server_url)
|
96
81
|
validate_server_url!(server_url)
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
!stored.nil? && !stored.expired?(@ttl)
|
101
|
-
end
|
82
|
+
origin = normalize_server_url(server_url)
|
83
|
+
key = Storage::KeyBuilder.nonce_key(origin)
|
84
|
+
AtprotoAuth.storage.exists?(key)
|
102
85
|
end
|
103
86
|
|
104
87
|
private
|
@@ -0,0 +1,156 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/atproto_auth/encryption.rb
|
4
|
+
module AtprotoAuth
|
5
|
+
module Encryption
|
6
|
+
class Error < AtprotoAuth::Error; end
|
7
|
+
class ConfigurationError < Error; end
|
8
|
+
class EncryptionError < Error; end
|
9
|
+
class DecryptionError < Error; end
|
10
|
+
|
11
|
+
# HKDF implementation based on RFC 5869
|
12
|
+
module HKDF
|
13
|
+
def self.derive(secret, salt:, info:, length:)
|
14
|
+
# 1. extract
|
15
|
+
prk = OpenSSL::HMAC.digest(
|
16
|
+
OpenSSL::Digest.new("SHA256"),
|
17
|
+
salt.empty? ? "\x00" * 32 : salt,
|
18
|
+
secret.to_s
|
19
|
+
)
|
20
|
+
|
21
|
+
# 2. expand
|
22
|
+
n = (length.to_f / 32).ceil
|
23
|
+
t = [""]
|
24
|
+
output = ""
|
25
|
+
1.upto(n) do |i|
|
26
|
+
t[i] = OpenSSL::HMAC.digest(
|
27
|
+
OpenSSL::Digest.new("SHA256"),
|
28
|
+
prk,
|
29
|
+
t[i - 1] + info + [i].pack("C")
|
30
|
+
)
|
31
|
+
output += t[i]
|
32
|
+
end
|
33
|
+
output[0, length]
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Core encryption service - used internally by serializers
|
38
|
+
class Service
|
39
|
+
CIPHER = "aes-256-gcm"
|
40
|
+
VERSION = 1
|
41
|
+
|
42
|
+
def initialize
|
43
|
+
@key_provider = KeyProvider.new
|
44
|
+
end
|
45
|
+
|
46
|
+
def encrypt(data, context:)
|
47
|
+
validate_encryption_inputs!(data, context)
|
48
|
+
|
49
|
+
iv = SecureRandom.random_bytes(12)
|
50
|
+
|
51
|
+
cipher = OpenSSL::Cipher.new(CIPHER)
|
52
|
+
cipher.encrypt
|
53
|
+
cipher.key = @key_provider.key_for_context(context)
|
54
|
+
cipher.iv = iv
|
55
|
+
cipher.auth_data = context.to_s
|
56
|
+
|
57
|
+
encrypted = cipher.update(data.to_s) + cipher.final
|
58
|
+
auth_tag = cipher.auth_tag
|
59
|
+
|
60
|
+
{
|
61
|
+
version: VERSION,
|
62
|
+
iv: Base64.strict_encode64(iv),
|
63
|
+
data: Base64.strict_encode64(encrypted),
|
64
|
+
tag: Base64.strict_encode64(auth_tag)
|
65
|
+
}
|
66
|
+
rescue StandardError => e
|
67
|
+
raise EncryptionError, "Encryption failed: #{e.message}"
|
68
|
+
end
|
69
|
+
|
70
|
+
def decrypt(encrypted, context:)
|
71
|
+
validate_decryption_inputs!(encrypted, context)
|
72
|
+
validate_encrypted_data!(encrypted)
|
73
|
+
|
74
|
+
iv = Base64.strict_decode64(encrypted[:iv])
|
75
|
+
data = Base64.strict_decode64(encrypted[:data])
|
76
|
+
auth_tag = Base64.strict_decode64(encrypted[:tag])
|
77
|
+
|
78
|
+
cipher = OpenSSL::Cipher.new(CIPHER)
|
79
|
+
cipher.decrypt
|
80
|
+
cipher.key = @key_provider.key_for_context(context)
|
81
|
+
cipher.iv = iv
|
82
|
+
cipher.auth_tag = auth_tag
|
83
|
+
cipher.auth_data = context.to_s
|
84
|
+
|
85
|
+
cipher.update(data) + cipher.final
|
86
|
+
rescue ArgumentError => e
|
87
|
+
raise DecryptionError, "Invalid encrypted data format: #{e.message}"
|
88
|
+
rescue StandardError => e
|
89
|
+
raise DecryptionError, "Decryption failed: #{e.message}"
|
90
|
+
end
|
91
|
+
|
92
|
+
private
|
93
|
+
|
94
|
+
def validate_encryption_inputs!(data, context)
|
95
|
+
raise EncryptionError, "Data cannot be nil" if data.nil?
|
96
|
+
raise EncryptionError, "Context cannot be nil" if context.nil?
|
97
|
+
raise EncryptionError, "Context must be a string" unless context.is_a?(String)
|
98
|
+
raise EncryptionError, "Context cannot be empty" if context.empty?
|
99
|
+
end
|
100
|
+
|
101
|
+
def validate_decryption_inputs!(encrypted, context)
|
102
|
+
raise DecryptionError, "Encrypted data cannot be nil" if encrypted.nil?
|
103
|
+
raise DecryptionError, "Context cannot be nil" if context.nil?
|
104
|
+
raise DecryptionError, "Context must be a string" unless context.is_a?(String)
|
105
|
+
raise DecryptionError, "Context cannot be empty" if context.empty?
|
106
|
+
end
|
107
|
+
|
108
|
+
def validate_encrypted_data!(encrypted)
|
109
|
+
raise DecryptionError, "Invalid encrypted data format" unless encrypted.is_a?(Hash)
|
110
|
+
|
111
|
+
unless encrypted[:version] == VERSION
|
112
|
+
raise DecryptionError, "Unsupported encryption version: #{encrypted[:version]}"
|
113
|
+
end
|
114
|
+
|
115
|
+
%i[iv data tag].each do |field|
|
116
|
+
raise DecryptionError, "Missing required field: #{field}" unless encrypted[field].is_a?(String)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
# Handles key management and derivation
|
122
|
+
class KeyProvider
|
123
|
+
def initialize
|
124
|
+
@master_key = load_master_key
|
125
|
+
end
|
126
|
+
|
127
|
+
def key_for_context(context)
|
128
|
+
raise ConfigurationError, "Context is required" if context.nil?
|
129
|
+
|
130
|
+
HKDF.derive(
|
131
|
+
@master_key,
|
132
|
+
salt: salt_for_context(context),
|
133
|
+
info: "atproto-#{context}",
|
134
|
+
length: 32
|
135
|
+
)
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
|
140
|
+
def load_master_key
|
141
|
+
# Try environment variable first
|
142
|
+
key = ENV.fetch("ATPROTO_MASTER_KEY", nil)
|
143
|
+
return Base64.strict_decode64(key) if key
|
144
|
+
|
145
|
+
# Generate and store a random key if not configured
|
146
|
+
key = SecureRandom.random_bytes(32)
|
147
|
+
warn "WARNING: Using randomly generated encryption key - tokens will not persist across restarts"
|
148
|
+
key
|
149
|
+
end
|
150
|
+
|
151
|
+
def salt_for_context(context)
|
152
|
+
OpenSSL::Digest.digest("SHA256", "atproto-salt-#{context}")
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
@@ -136,7 +136,7 @@ module AtprotoAuth
|
|
136
136
|
raise HttpError.new("Request failed: #{e.message}", nil)
|
137
137
|
end
|
138
138
|
|
139
|
-
def make_post_request(uri, body, headers = {}, redirect_count = 0)
|
139
|
+
def make_post_request(uri, body, headers = {}, redirect_count = 0)
|
140
140
|
http = Net::HTTP.new(uri.host, uri.port)
|
141
141
|
configure_http_client!(http)
|
142
142
|
|
@@ -182,7 +182,7 @@ module AtprotoAuth
|
|
182
182
|
# - headers: Hash of headers from the original request
|
183
183
|
# - redirect_count: Number of redirects so far
|
184
184
|
# - body: Request body for POST requests
|
185
|
-
def handle_redirect(**kwargs)
|
185
|
+
def handle_redirect(**kwargs)
|
186
186
|
response = kwargs[:response]
|
187
187
|
redirect_count = kwargs[:redirect_count]
|
188
188
|
|
@@ -78,7 +78,7 @@ module AtprotoAuth
|
|
78
78
|
raise DocumentError, "Invalid DID format (must be did:plc:): #{did}"
|
79
79
|
end
|
80
80
|
|
81
|
-
def validate_services!(services)
|
81
|
+
def validate_services!(services)
|
82
82
|
return if services.nil?
|
83
83
|
raise DocumentError, "services must be an array" unless services.is_a?(Array)
|
84
84
|
|