this repo has no description
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Security Hotfixes

+107 -28
+1 -1
Sources/CoreATProtocol/APEnvironment.swift
··· 18 18 public var tokenRefreshHandler: (@Sendable () async throws -> Bool)? 19 19 public var dpopPrivateKey: ES256PrivateKey? 20 20 public var dpopKeys: JWTKeyCollection? 21 - public var dpopNonce: String? 21 + public let dpopNonceStore = DPoPNonceStore() 22 22 public let routerDelegate = APRouterDelegate() 23 23 24 24 private init() {}
+23
Sources/CoreATProtocol/DPoPNonceStore.swift
··· 1 + // 2 + // DPoPNonceStore.swift 3 + // CoreATProtocol 4 + // 5 + 6 + /// Serialises reads and writes to the DPoP server-issued nonce. 7 + /// 8 + /// RFC 9449 allows a server to rotate the DPoP nonce on any response. Multiple 9 + /// in-flight requests can observe a nonce update concurrently, so a dedicated 10 + /// actor is used to keep the read/update pair ordered. 11 + public actor DPoPNonceStore { 12 + private var nonce: String? 13 + 14 + public init(nonce: String? = nil) { 15 + self.nonce = nonce 16 + } 17 + 18 + public func get() -> String? { nonce } 19 + 20 + public func update(_ nonce: String) { self.nonce = nonce } 21 + 22 + public func clear() { nonce = nil } 23 + }
+46 -18
Sources/CoreATProtocol/Networking.swift
··· 9 9 import Crypto 10 10 import JWTKit 11 11 import NetworkingKit 12 + import OAuthenticator 13 + import os 14 + 15 + private let networkingLog = Logger(subsystem: "com.sparrowtek.CoreATProtocol", category: "Networking") 12 16 13 17 extension JSONDecoder { 14 18 public static var atDecoder: JSONDecoder { ··· 54 58 if let dpopKey = await APEnvironment.current.dpopPrivateKey, 55 59 let keys = await APEnvironment.current.dpopKeys { 56 60 // DPoP-bound token: use "DPoP" scheme + DPoP proof header 57 - request.setValue("DPoP \(accessToken)", forHTTPHeaderField: "Authorization") 58 - 59 - if let proof = await generateDPoPProof(for: request, accessToken: accessToken, privateKey: dpopKey, keys: keys) { 61 + do { 62 + let proof = try await generateDPoPProof(for: request, accessToken: accessToken, privateKey: dpopKey, keys: keys) 63 + request.setValue("DPoP \(accessToken)", forHTTPHeaderField: "Authorization") 60 64 request.setValue(proof, forHTTPHeaderField: "DPoP") 65 + } catch { 66 + // DPoP signing failed. Do NOT attach the DPoP-bound access token 67 + // without a proof — that would send a malformed authenticated 68 + // request and waste a round trip. Clearing the header causes 69 + // the networking layer to fail with an unauthenticated error, 70 + // which surfaces a useful signal to callers. 71 + networkingLog.error("DPoP proof generation failed: \(error.localizedDescription, privacy: .public)") 72 + request.setValue(nil, forHTTPHeaderField: "Authorization") 73 + request.setValue(nil, forHTTPHeaderField: "DPoP") 61 74 } 62 75 } else { 63 76 // Standard Bearer token ··· 70 83 accessToken: String, 71 84 privateKey: ES256PrivateKey, 72 85 keys: JWTKeyCollection 73 - ) async -> String? { 86 + ) async throws -> String { 74 87 guard let method = request.httpMethod, 75 - let url = request.url else { return nil } 88 + let url = request.url else { 89 + throw AtError.message(ErrorMessage(error: "DPoPProofInvalidRequest", message: "Request is missing method or URL")) 90 + } 76 91 77 92 // Strip query and fragment per DPoP spec 78 93 var components = URLComponents(url: url, resolvingAgainstBaseURL: false) ··· 80 95 components?.fragment = nil 81 96 let htu = components?.url?.absoluteString ?? url.absoluteString 82 97 83 - let nonce = await APEnvironment.current.dpopNonce 98 + // Read the nonce at proof-generation time so a concurrent update 99 + // between intercept() and sign() is observed on the next retry. 100 + let nonce = await APEnvironment.current.dpopNonceStore.get() 84 101 85 102 // ath: base64url-encoded SHA-256 hash of the access token (RFC 9449 §4.2) 86 103 let hash = SHA256.hash(data: Data(accessToken.utf8)) ··· 120 137 ] 121 138 } 122 139 123 - return try? await keys.sign(payload, header: header) 140 + return try await keys.sign(payload, header: header) 124 141 } 125 142 126 143 public func didReceiveErrorResponse(_ response: HTTPURLResponse) async { 127 - let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce") 144 + let headerNonce = response.value(forHTTPHeaderField: "DPoP-Nonce") 128 145 ?? response.value(forHTTPHeaderField: "dpop-nonce") 129 - if let nonce { 130 - await storeDPoPNonce(nonce) 146 + if let headerNonce { 147 + await APEnvironment.current.dpopNonceStore.update(headerNonce) 148 + lastErrorHadNonceHeader = true 149 + } else { 150 + lastErrorHadNonceHeader = false 131 151 } 132 152 } 133 153 134 - @APActor 135 - private func storeDPoPNonce(_ nonce: String) { 136 - APEnvironment.current.dpopNonce = nonce 137 - } 154 + private var lastErrorHadNonceHeader: Bool = false 138 155 139 156 public func shouldRetry(error: Error, attempts: Int) async throws -> Bool { 140 157 guard attempts <= 2 else { return false } ··· 196 213 data = nil 197 214 } 198 215 199 - guard let data, 200 - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], 201 - let errorType = json["error"] as? String else { return false } 202 - return errorType == "use_dpop_nonce" 216 + if let data { 217 + do { 218 + let oauthError = try JSONDecoder().decode(OAuthErrorResponse.self, from: data) 219 + if oauthError.error == "use_dpop_nonce" { return true } 220 + // Body decoded but wasn't a nonce challenge. 221 + return false 222 + } catch { 223 + let body = String(data: data, encoding: .utf8) ?? "<non-utf8>" 224 + networkingLog.debug("Failed to decode OAuth error body for nonce check: \(error.localizedDescription, privacy: .public) — body: \(body, privacy: .public)") 225 + } 226 + } 227 + 228 + // Fallback: a DPoP-Nonce header on an error response is a strong 229 + // nonce-challenge signal even when the body is absent or malformed. 230 + return lastErrorHadNonceHeader 203 231 } 204 232 } 205 233
+37 -9
Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift
··· 1 1 import Foundation 2 2 import OAuthenticator 3 3 import JWTKit 4 + import os 5 + 6 + private let oauthLog = Logger(subsystem: "com.sparrowtek.CoreATProtocol", category: "OAuth") 4 7 5 8 // MARK: - Re-export OAuthenticator types for convenience 6 9 public typealias Login = OAuthenticator.Login ··· 78 81 case subjectMismatch(expected: String, actual: String) 79 82 case issuerMismatch(expected: String, actual: String) 80 83 case malformedAuthorizationCallback 84 + case malformedServerMetadata(field: String, value: String) 81 85 case tokenRequestFailed(String) 82 86 case invalidTokenResponse 83 87 ··· 99 103 "Issuer mismatch — expected \(expected), got \(actual)" 100 104 case .malformedAuthorizationCallback: 101 105 "Authorization callback URL is malformed" 106 + case .malformedServerMetadata(let field, let value): 107 + "Server metadata field \(field) is not a valid URL: \(value)" 102 108 case .tokenRequestFailed(let detail): 103 109 "Token request failed: \(detail)" 104 110 case .invalidTokenResponse: ··· 239 245 } 240 246 241 247 // Step 7: Create authenticator configuration 242 - let tokenHandling = buildTokenHandling( 248 + let tokenHandling = try buildTokenHandling( 243 249 accountHint: identifier, 244 250 server: serverConfig, 245 251 jwtGenerator: jwtGenerator, ··· 383 389 } 384 390 } 385 391 386 - let tokenHandling = buildTokenHandling( 392 + let tokenHandling = try buildTokenHandling( 387 393 accountHint: accountIdentifier, 388 394 server: serverConfig, 389 395 jwtGenerator: jwtGenerator, ··· 630 636 } 631 637 632 638 private func retrieveAuthProxyKeyID(for login: Login) async -> String? { 633 - try? await storage.retrieveAuthProxyKeyID?(login) 639 + guard let retrieve = storage.retrieveAuthProxyKeyID else { return nil } 640 + do { 641 + return try await retrieve(login) 642 + } catch { 643 + oauthLog.error("Failed to retrieve auth proxy key ID: \(error.localizedDescription, privacy: .public)") 644 + return nil 645 + } 634 646 } 635 647 636 648 private func persistAuthProxyKeyID(_ keyID: String, for login: Login) async { 637 - try? await storage.storeAuthProxyKeyID?(login, keyID) 649 + guard let store = storage.storeAuthProxyKeyID else { return } 650 + do { 651 + try await store(login, keyID) 652 + } catch { 653 + oauthLog.error("Failed to persist auth proxy key ID: \(error.localizedDescription, privacy: .public)") 654 + } 638 655 } 639 656 640 657 private func clearAuthProxyKeyID(for login: Login?) async { 641 - guard let login else { return } 642 - try? await storage.clearAuthProxyKeyID?(login) 658 + guard let login, let clear = storage.clearAuthProxyKeyID else { return } 659 + do { 660 + try await clear(login) 661 + } catch { 662 + oauthLog.error("Failed to clear auth proxy key ID: \(error.localizedDescription, privacy: .public)") 663 + } 643 664 } 644 665 645 666 private func buildTokenHandling( ··· 648 669 jwtGenerator: @escaping DPoPSigner.JWTGenerator, 649 670 expectedSubjectDID: String?, 650 671 expectedAuthorizationServer: String 651 - ) -> TokenHandling { 652 - TokenHandling( 672 + ) throws -> TokenHandling { 673 + guard let parURL = URL(string: server.pushedAuthorizationRequestEndpoint) else { 674 + throw ATProtoOAuthError.malformedServerMetadata( 675 + field: "pushed_authorization_request_endpoint", 676 + value: server.pushedAuthorizationRequestEndpoint 677 + ) 678 + } 679 + 680 + return TokenHandling( 653 681 parConfiguration: PARConfiguration( 654 - url: URL(string: server.pushedAuthorizationRequestEndpoint)!, 682 + url: parURL, 655 683 parameters: { if let accountHint { ["login_hint": accountHint] } else { [:] } }() 656 684 ), 657 685 authorizationURLProvider: authorizationURLProvider(server: server),