From 6fbd1c723bdadd1ea7e50bd4920851d3ecc1cb88 Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:25:11 +0300 Subject: [PATCH] fix(ios): preserve passkey bytes and own request completion safely --- ios/Passkey.m | 5 ++ ios/Passkey.swift | 19 +++++-- ios/PasskeyDelegate.swift | 60 +++++++++++++++------- ios/PasskeyShared.swift | 105 ++++++++++++++++++-------------------- src/PasskeyRequest.ts | 5 +- 5 files changed, 116 insertions(+), 78 deletions(-) diff --git a/ios/Passkey.m b/ios/Passkey.m index 650eb91..e186a48 100644 --- a/ios/Passkey.m +++ b/ios/Passkey.m @@ -32,4 +32,9 @@ + (BOOL)requiresMainQueueSetup return NO; } +- (dispatch_queue_t)methodQueue +{ + return dispatch_get_main_queue(); +} + @end diff --git a/ios/Passkey.swift b/ios/Passkey.swift index 16a3aa8..768d107 100644 --- a/ios/Passkey.swift +++ b/ios/Passkey.swift @@ -25,6 +25,10 @@ class Passkey: NSObject, RNPasskeyResultHandler { */ @objc(create:withForcePlatformKey:withForceSecurityKey:withResolver:withRejecter:) func create(_ request: String, forcePlatformKey: Bool, forceSecurityKey: Bool, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void { + guard passkeyHandler == nil else { + reject("RequestFailed", "Another passkey request is already in progress", nil); + return; + } do { passkeyHandler = RNPasskeyHandler(resolve, reject); // Create never uses immediate mediation; reset so a stale flag from a prior @@ -72,6 +76,10 @@ class Passkey: NSObject, RNPasskeyResultHandler { */ @objc(get:withForcePlatformKey:withForceSecurityKey:withPreferImmediatelyAvailable:withResolver:withRejecter:) func get(_ request: String, forcePlatformKey: Bool, forceSecurityKey: Bool, preferImmediatelyAvailable: Bool, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void { + guard passkeyHandler == nil else { + reject("RequestFailed", "Another passkey request is already in progress", nil); + return; + } do { passkeyHandler = RNPasskeyHandler(resolve, reject); self.preferImmediatelyAvailable = preferImmediatelyAvailable; @@ -186,6 +194,8 @@ class Passkey: NSObject, RNPasskeyResultHandler { print("passkeyHandler was nil"); return } + passkeyHandler = nil; + passkeyDelegate = nil; do { switch data { @@ -211,7 +221,7 @@ class Passkey: NSObject, RNPasskeyResultHandler { */ private func configureCreateSecurityKeyRequest(challenge: Data, userId: Data, request: RNPasskeyCredentialCreationOptions) -> ASAuthorizationSecurityKeyPublicKeyCredentialRegistrationRequest { - let securityKeyProvider = ASAuthorizationSecurityKeyPublicKeyCredentialProvider(relyingPartyIdentifier: request.rp.id!); + let securityKeyProvider = ASAuthorizationSecurityKeyPublicKeyCredentialProvider(relyingPartyIdentifier: request.rp.id); let authRequest = securityKeyProvider.createCredentialRegistrationRequest(challenge: challenge, displayName: request.user.displayName, @@ -245,7 +255,7 @@ class Passkey: NSObject, RNPasskeyResultHandler { */ private func configureCreatePlatformRequest(challenge: Data, userId: Data, request: RNPasskeyCredentialCreationOptions) throws -> ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest { - let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: request.rp.id!); + let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: request.rp.id); let authRequest = platformProvider.createCredentialRegistrationRequest(challenge: challenge, name: request.user.name, @@ -315,7 +325,7 @@ class Passkey: NSObject, RNPasskeyResultHandler { if prf.evalByCredential != nil { // If evalByCredential is present and allowCredentials is empty we throw an "Unsupported" error as specified in the WebAuthn standard - if let allowCredentials = request.allowCredentials, allowCredentials.isEmpty { + if request.allowCredentials?.isEmpty != false { throw NSError(domain: "PRF Issue", code: 1) } @@ -437,8 +447,9 @@ class Passkey: NSObject, RNPasskeyResultHandler { print("passkeyHandler was nil"); return } + passkeyHandler = nil; + passkeyDelegate = nil; handler.reject(error.type.rawValue, error.message, nil); } } - diff --git a/ios/PasskeyDelegate.swift b/ios/PasskeyDelegate.swift index 8144dbd..7fa39a3 100644 --- a/ios/PasskeyDelegate.swift +++ b/ios/PasskeyDelegate.swift @@ -3,7 +3,7 @@ import AuthenticationServices import CryptoKit @available(iOS 15.0, *) -protocol RNPasskeyResultHandler { +protocol RNPasskeyResultHandler: AnyObject { func onSuccess(_ data: PublicKeyCredentialJSON) func onError(_ error: Error) } @@ -11,7 +11,7 @@ protocol RNPasskeyResultHandler { @objc(PasskeyDelegate) @available(iOS 15.0, *) class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { - private let _completionHandler: RNPasskeyResultHandler + private weak var _completionHandler: RNPasskeyResultHandler? /** Whether the system asked us for a window to present the credential sheet in. @@ -30,6 +30,18 @@ class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, ASAuthorizat _completionHandler = completionHandler; } + private func finishWithError(_ error: Error) { + let handler = _completionHandler; + _completionHandler = nil; + handler?.onError(error); + } + + private func finishWithSuccess(_ data: PublicKeyCredentialJSON) { + let handler = _completionHandler; + _completionHandler = nil; + handler?.onSuccess(data); + } + // Perform the authorization request for a given ASAuthorizationController instance func performAuthForController(controller: ASAuthorizationController, preferImmediatelyAvailable: Bool = false) { controller.delegate = self; @@ -62,7 +74,7 @@ class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, ASAuthorizat didCompleteWithError error: Error ) { // Authorization request returned an error - _completionHandler.onError(error); + finishWithError(error); } func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { @@ -80,13 +92,14 @@ class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, ASAuthorizat case let credential as ASAuthorizationSecurityKeyPublicKeyCredentialAssertion: self.handleSecurityKeyPublicKeyAssertionResponse(credential: credential); default: - _completionHandler.onError(ASAuthorizationError(ASAuthorizationError.invalidResponse)); + finishWithError(ASAuthorizationError(ASAuthorizationError.invalidResponse)); } } func handlePlatformPublicKeyRegistrationResponse(credential: ASAuthorizationPlatformPublicKeyCredentialRegistration) -> Void { - if credential.rawAttestationObject == nil { - _completionHandler.onError(ASAuthorizationError(ASAuthorizationError.invalidResponse)); + guard let attestationObject = credential.rawAttestationObject else { + finishWithError(ASAuthorizationError(ASAuthorizationError.invalidResponse)); + return; } // LargeBlob Extension @@ -117,7 +130,7 @@ class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, ASAuthorizat let response = AuthenticatorAttestationResponseJSON( clientDataJSON: credential.rawClientDataJSON.toBase64URLEncodedString(), - attestationObject: credential.rawAttestationObject!.toBase64URLEncodedString() + attestationObject: attestationObject.toBase64URLEncodedString() ); let createResponse = RNPasskeyCreateResponseJSON( @@ -127,12 +140,13 @@ class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, ASAuthorizat clientExtensionResults: clientExtensionResults ); - _completionHandler.onSuccess(.create(createResponse)); + finishWithSuccess(.create(createResponse)); } func handleSecurityKeyPublicKeyRegistrationResponse(credential: ASAuthorizationSecurityKeyPublicKeyCredentialRegistration) -> Void { - if credential.rawAttestationObject == nil { - _completionHandler.onError((ASAuthorizationError(ASAuthorizationError.Code.failed))); + guard let attestationObject = credential.rawAttestationObject else { + finishWithError((ASAuthorizationError(ASAuthorizationError.Code.failed))); + return; } var transports: [AuthenticatorTransport] = []; @@ -148,7 +162,7 @@ class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, ASAuthorizat let response = AuthenticatorAttestationResponseJSON( clientDataJSON: credential.rawClientDataJSON.toBase64URLEncodedString(), transports: transports, - attestationObject: credential.rawAttestationObject!.toBase64URLEncodedString() + attestationObject: attestationObject.toBase64URLEncodedString() ); let createResponse = RNPasskeyCreateResponseJSON( @@ -157,19 +171,23 @@ class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, ASAuthorizat response: response ); - _completionHandler.onSuccess(.create(createResponse)); + finishWithSuccess(.create(createResponse)); } func handlePlatformPublicKeyAssertionResponse(credential: ASAuthorizationPlatformPublicKeyCredentialAssertion) -> Void { + guard let signature = credential.signature else { + finishWithError(ASAuthorizationError(ASAuthorizationError.invalidResponse)); + return; + } var largeBlob: AuthenticationExtensionsLargeBlobOutputsJSON?; if #available(iOS 17.0, *), let result = credential.largeBlob?.result { largeBlob = AuthenticationExtensionsLargeBlobOutputsJSON() switch (result) { case .read(data: let blobData): if let blob = blobData { - // get uIntArray, then transform to a dictionary RN can work with - largeBlob?.blob = Dictionary(uniqueKeysWithValues: blob.uIntArray.enumerated().map { (index, value) in - (String(index + 1), Int(value)) + // Preserve each byte and expose the same zero-based indices as Uint8Array. + largeBlob?.blob = Dictionary(uniqueKeysWithValues: blob.enumerated().map { (index, value) in + (String(index), Int(value)) }) } case .write(success: let successfullyWritten): @@ -197,7 +215,7 @@ class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, ASAuthorizat let response = AuthenticatorAssertionResponseJSON( authenticatorData: credential.rawAuthenticatorData.toBase64URLEncodedString(), clientDataJSON: credential.rawClientDataJSON.toBase64URLEncodedString(), - signature: credential.signature!.toBase64URLEncodedString(), + signature: signature.toBase64URLEncodedString(), userHandle: userHandle ); @@ -208,16 +226,20 @@ class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, ASAuthorizat clientExtensionResults: clientExtensionResults ); - _completionHandler.onSuccess(.get(getResponse)); + finishWithSuccess(.get(getResponse)); } func handleSecurityKeyPublicKeyAssertionResponse(credential: ASAuthorizationSecurityKeyPublicKeyCredentialAssertion) -> Void { + guard let signature = credential.signature else { + finishWithError(ASAuthorizationError(ASAuthorizationError.invalidResponse)); + return; + } let userHandle: String? = credential.userID?.toBase64URLEncodedString(); let response = AuthenticatorAssertionResponseJSON( authenticatorData: credential.rawAuthenticatorData.toBase64URLEncodedString(), clientDataJSON: credential.rawClientDataJSON.toBase64URLEncodedString(), - signature: credential.signature!.toBase64URLEncodedString(), + signature: signature.toBase64URLEncodedString(), userHandle: userHandle ); @@ -227,6 +249,6 @@ class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, ASAuthorizat response: response ); - _completionHandler.onSuccess(.get(getResponse)); + finishWithSuccess(.get(getResponse)); } } diff --git a/ios/PasskeyShared.swift b/ios/PasskeyShared.swift index c415675..77351ce 100644 --- a/ios/PasskeyShared.swift +++ b/ios/PasskeyShared.swift @@ -7,17 +7,29 @@ enum Either { case create(Create), get(Get) } -extension Array { - var data: Data { withUnsafeBytes { .init($0) } } -} +private struct PasskeyBinaryData: Decodable { + let data: Data -extension Data { - func toUIntArray() -> [UInt] { - var UIntArray = Array(repeating: 0, count: self.count/MemoryLayout.stride) - _ = UIntArray.withUnsafeMutableBytes { self.copyBytes(to: $0) } - return UIntArray + init(from decoder: any Decoder) throws { + let value = try decoder.singleValueContainer() + if let encoded = try? value.decode(String.self) { + guard let decoded = Data(base64URLEncoded: encoded) else { + throw DecodingError.dataCorruptedError(in: value, debugDescription: "Invalid base64url binary value") + } + data = decoded + } else if let bytes = try? value.decode([UInt8].self) { + data = Data(bytes) + } else { + let record = try value.decode([String: UInt8].self) + let indexedBytes = try record.map { key, byte -> (Int, UInt8) in + guard let index = Int(key), index >= 0 else { + throw DecodingError.dataCorruptedError(in: value, debugDescription: "Invalid binary byte index") + } + return (index, byte) + } + data = Data(indexedBytes.sorted { $0.0 < $1.0 }.map { $0.1 }) } - var uIntArray: [UInt] { toUIntArray() } + } } /** @@ -244,7 +256,7 @@ internal struct PublicKeyCredentialRpEntity: Decodable { var name: String - var id: String? + var id: String } /** @@ -268,23 +280,20 @@ internal struct PublicKeyCredentialDescriptor: Decodable { var id: Base64URLString - var transports: AuthenticatorTransport? + var transports: [AuthenticatorTransport] + + private let credentialID: Data var type: PublicKeyCredentialType = .publicKey func getPlatformDescriptor() -> ASAuthorizationPlatformPublicKeyCredentialDescriptor { - return ASAuthorizationPlatformPublicKeyCredentialDescriptor.init(credentialID: Data(base64URLEncoded: self.id)!) + return ASAuthorizationPlatformPublicKeyCredentialDescriptor.init(credentialID: credentialID) } func getCrossPlatformDescriptor() -> ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor { - var transports = ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.Transport.allSupported - - if self.transports?.appleise()?.isEmpty == false { - transports = self.transports!.appleise()!.compactMap { $0 } - } - - return ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.init(credentialID: Data(base64URLEncoded: self.id)!, - transports: transports) + let supportedTransports = transports.flatMap { $0.appleise() ?? [] } + return ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.init(credentialID: credentialID, + transports: supportedTransports.isEmpty ? ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.Transport.allSupported : supportedTransports) } enum CodingKeys: String, CodingKey { @@ -297,10 +306,14 @@ internal struct PublicKeyCredentialDescriptor: Decodable { init(from decoder: any Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) - id = try values.decodeIfPresent(String.self, forKey: .id)! + id = try values.decode(String.self, forKey: .id) + guard let decodedID = Data(base64URLEncoded: id), !decodedID.isEmpty else { + throw DecodingError.dataCorruptedError(forKey: .id, in: values, debugDescription: "Invalid base64url credential ID") + } + credentialID = decodedID let transportStrings = try values.decodeIfPresent([String].self, forKey: .transports) ?? [] - transports = transportStrings.compactMap { AuthenticatorTransport(rawValue: $0) }.first ?? .none + transports = transportStrings.compactMap { AuthenticatorTransport(rawValue: $0) } let typeValue = try values.decodeIfPresent(String.self, forKey: .type) if let typeString = typeValue { @@ -341,10 +354,7 @@ internal struct AuthenticationExtensionsLargeBlobInputs: Decodable { read = try values.decodeIfPresent(Bool.self, forKey: .read) - // RN converts UInt8Array to Dictionary, need to decode it - let writeDict = try values.decodeIfPresent([String : Int].self, forKey: .write) - // sort dict, convert to array and then data - write = writeDict?.sorted(by: { $0.key < $1.key }).map({ $0.value }).data + write = try values.decodeIfPresent(PasskeyBinaryData.self, forKey: .write)?.data } } @@ -360,21 +370,8 @@ internal struct AuthenticationExtensionsPRFValues: Encodable, Decodable { init(from decoder: any Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) - // Decode RN dictionary -> Data for `first` - if let firstDict = try values.decodeIfPresent([String: Int].self, forKey: .first) { - first = firstDict - .sorted { Int($0.key)! < Int($1.key)! } - .map { UInt8($0.value) } - .data - } - - // Decode RN dictionary -> Data for `second` - if let secondDict = try values.decodeIfPresent([String: Int].self, forKey: .second) { - second = secondDict - .sorted { Int($0.key)! < Int($1.key)! } - .map { UInt8($0.value) } - .data - } + first = try values.decodeIfPresent(PasskeyBinaryData.self, forKey: .first)?.data + second = try values.decodeIfPresent(PasskeyBinaryData.self, forKey: .second)?.data } init(first: SymmetricKey?, second: SymmetricKey?) { @@ -414,22 +411,23 @@ internal struct AuthenticationExtensionsPRFInputs: Decodable { init(from decoder: any Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) - // Decode RN dictionary -> Data for `first` - if let evalDict = try values.decodeIfPresent(AuthenticationExtensionsPRFValues.self, forKey: .eval) { - eval = AuthenticationExtensionsPRFValues(first: evalDict.first, second: evalDict.second) - } - - if let credentialsArray = try values.decodeIfPresent([[String: AuthenticationExtensionsPRFValues]].self, forKey: .evalByCredential) { + eval = try values.decodeIfPresent(AuthenticationExtensionsPRFValues.self, forKey: .eval) + + if values.contains(.evalByCredential), try !values.decodeNil(forKey: .evalByCredential) { + let credentialsArray: [[String: AuthenticationExtensionsPRFValues]] + if let record = try? values.decode([String: AuthenticationExtensionsPRFValues].self, forKey: .evalByCredential) { + credentialsArray = [record] + } else { + credentialsArray = try values.decode([[String: AuthenticationExtensionsPRFValues]].self, forKey: .evalByCredential) + } evalByCredential = [:] for credentialDict in credentialsArray { for (credentialID, prfValues) in credentialDict { - let credentialIDData = credentialID.data(using: .utf8) ?? Data() - let convertedValues = AuthenticationExtensionsPRFValues( - first: prfValues.first, - second: prfValues.second - ) - evalByCredential?[credentialIDData] = convertedValues + guard let credentialIDData = Data(base64URLEncoded: credentialID), !credentialIDData.isEmpty else { + throw DecodingError.dataCorruptedError(forKey: .evalByCredential, in: values, debugDescription: "Invalid base64url credential ID") + } + evalByCredential?[credentialIDData] = prfValues } } } @@ -444,7 +442,6 @@ internal struct AuthenticationExtensionsPRFInputs: Decodable { for (credentialID, value) in evalByCredential { guard let inputValues = value.toInputValues() else { - print("Failed to convert PRF Input Values \(value)") continue } diff --git a/src/PasskeyRequest.ts b/src/PasskeyRequest.ts index 6c5610f..79ae602 100644 --- a/src/PasskeyRequest.ts +++ b/src/PasskeyRequest.ts @@ -22,7 +22,10 @@ export function stringifyPasskeyRequest( platformOS: string ): string { if (platformOS !== 'android') { - return JSON.stringify(request); + return JSON.stringify({ + ...request, + extensions: normalizeExtensions(request.extensions), + }); } return JSON.stringify(normalizeAndroidRequest(request));