| 1 | import AuthenticationServices |
| 2 | import Foundation |
| 3 | |
| 4 | #if canImport(UIKit) |
| 5 | import UIKit |
| 6 | public typealias HomeAuthAnchor = UIWindow |
| 7 | #elseif canImport(AppKit) |
| 8 | import AppKit |
| 9 | public typealias HomeAuthAnchor = NSWindow |
| 10 | #endif |
| 11 | |
| 12 | // đŸ¤– The native "Sign in with Eleven" passkey ceremony (AuthenticationServices, |
| 13 | // iOS 18 / macOS 15 for the PRF extension). It runs a discoverable login (or a |
| 14 | // registration for a first backup), evaluates the SAME PRF input the web uses, |
| 15 | // reshapes Apple's raw ceremony fields into the WebAuthn JSON the go-webauthn |
| 16 | // server verifies, and returns the link token + credential id + PRF output. |
| 17 | // It never sends the PRF output anywhere — that stays here for HomeCrypto. |
| 18 | @available(iOS 18.0, macOS 15.0, *) |
| 19 | public final class HomeAuth: NSObject { |
| 20 | public struct Result { |
| 21 | public let token: String |
| 22 | public let proof: String? |
| 23 | public let credentialID: String // base64url |
| 24 | public let prf: Data |
| 25 | } |
| 26 | |
| 27 | private let client: HomeClient |
| 28 | private let rpID: String |
| 29 | private let anchor: HomeAuthAnchor |
| 30 | private var continuation: CheckedContinuation<ASAuthorization, Error>? |
| 31 | |
| 32 | public init(client: HomeClient = HomeClient(), |
| 33 | rpID: String = HomeBase.rpID, |
| 34 | anchor: HomeAuthAnchor) { |
| 35 | self.client = client |
| 36 | self.rpID = rpID |
| 37 | self.anchor = anchor |
| 38 | } |
| 39 | |
| 40 | // ---- sign in (discoverable assertion) ----------------------------------- |
| 41 | |
| 42 | public func signIn() async throws -> Result { |
| 43 | let begin = try await client.loginBegin() |
| 44 | guard let challenge = Data(base64urlEncoded: begin.options.publicKey.challenge) else { |
| 45 | throw HomeAuthError.badOptions |
| 46 | } |
| 47 | let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpID) |
| 48 | let request = provider.createCredentialAssertionRequest(challenge: challenge) |
| 49 | request.prf = ASAuthorizationPublicKeyCredentialPRFAssertionInput.inputValues(.init(saltInput1: HomeCrypto.prfEvalInput)) |
| 50 | |
| 51 | let auth = try await run(request) |
| 52 | guard let cred = auth.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion else { |
| 53 | throw HomeAuthError.wrongCredential |
| 54 | } |
| 55 | // Apple's Swift overlay hands the PRF output back as a SymmetricKey; its |
| 56 | // raw bytes are exactly what the web feeds HKDF, so byte-compat holds. |
| 57 | guard let prfKey = cred.prf?.first else { throw HomeAuthError.noPRF } |
| 58 | let prf = prfKey.withUnsafeBytes { Data($0) } |
| 59 | |
| 60 | let json = try Self.assertionJSON(cred) |
| 61 | let fin = try await client.finish(register: false, session: begin.session, credentialJSON: json) |
| 62 | return Result(token: fin.token, proof: fin.proof, |
| 63 | credentialID: fin.credential_id, prf: prf) |
| 64 | } |
| 65 | |
| 66 | // ---- register a first backup (attestation) ------------------------------ |
| 67 | |
| 68 | public func register() async throws -> Result { |
| 69 | let begin = try await client.registerBegin() |
| 70 | guard let challenge = Data(base64urlEncoded: begin.options.publicKey.challenge), |
| 71 | let userIDB64 = begin.options.publicKey.user?.id, |
| 72 | let userID = Data(base64urlEncoded: userIDB64) else { |
| 73 | throw HomeAuthError.badOptions |
| 74 | } |
| 75 | let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpID) |
| 76 | let request = provider.createCredentialRegistrationRequest( |
| 77 | challenge: challenge, name: "Eleven", userID: userID) |
| 78 | request.prf = ASAuthorizationPublicKeyCredentialPRFRegistrationInput.inputValues( |
| 79 | .init(saltInput1: HomeCrypto.prfEvalInput)) |
| 80 | |
| 81 | let auth = try await run(request) |
| 82 | guard let cred = auth.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration else { |
| 83 | throw HomeAuthError.wrongCredential |
| 84 | } |
| 85 | let json = try Self.registrationJSON(cred) |
| 86 | let fin = try await client.finish(register: true, session: begin.session, credentialJSON: json) |
| 87 | |
| 88 | // Most authenticators evaluate PRF at create time; if not, one assertion |
| 89 | // with the same input recovers it (mirrors backup-core.js runRegister). |
| 90 | var prf = cred.prf?.first.map { $0.withUnsafeBytes { Data($0) } } |
| 91 | if prf == nil { |
| 92 | prf = try await prfViaAssertion(credentialID: cred.credentialID) |
| 93 | } |
| 94 | guard let prf else { throw HomeAuthError.noPRF } |
| 95 | return Result(token: fin.token, proof: fin.proof, |
| 96 | credentialID: fin.credential_id, prf: prf) |
| 97 | } |
| 98 | |
| 99 | private func prfViaAssertion(credentialID: Data) async throws -> Data? { |
| 100 | let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpID) |
| 101 | var localChallenge = Data(count: 32) |
| 102 | localChallenge.withUnsafeMutableBytes { _ = SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!) } |
| 103 | let request = provider.createCredentialAssertionRequest(challenge: localChallenge) |
| 104 | request.allowedCredentials = [ |
| 105 | ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: credentialID)] |
| 106 | request.prf = ASAuthorizationPublicKeyCredentialPRFAssertionInput.inputValues(.init(saltInput1: HomeCrypto.prfEvalInput)) |
| 107 | let auth = try await run(request) |
| 108 | guard let key = (auth.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion)? |
| 109 | .prf?.first else { return nil } |
| 110 | return key.withUnsafeBytes { Data($0) } |
| 111 | } |
| 112 | |
| 113 | // ---- WebAuthn JSON assembly (go-webauthn wire) -------------------------- |
| 114 | |
| 115 | private static func registrationJSON(_ c: ASAuthorizationPlatformPublicKeyCredentialRegistration) throws -> Data { |
| 116 | let id = c.credentialID.base64urlEncodedString() |
| 117 | var response: [String: Any] = [ |
| 118 | "clientDataJSON": c.rawClientDataJSON.base64urlEncodedString(), |
| 119 | ] |
| 120 | if let att = c.rawAttestationObject { |
| 121 | response["attestationObject"] = att.base64urlEncodedString() |
| 122 | } |
| 123 | return try JSONSerialization.data(withJSONObject: [ |
| 124 | "id": id, "rawId": id, "type": "public-key", |
| 125 | "clientExtensionResults": [String: Any](), |
| 126 | "response": response, |
| 127 | ]) |
| 128 | } |
| 129 | |
| 130 | private static func assertionJSON(_ c: ASAuthorizationPlatformPublicKeyCredentialAssertion) throws -> Data { |
| 131 | let id = c.credentialID.base64urlEncodedString() |
| 132 | var response: [String: Any] = [ |
| 133 | "clientDataJSON": c.rawClientDataJSON.base64urlEncodedString(), |
| 134 | "authenticatorData": c.rawAuthenticatorData.base64urlEncodedString(), |
| 135 | "signature": c.signature.base64urlEncodedString(), |
| 136 | ] |
| 137 | if !c.userID.isEmpty { response["userHandle"] = c.userID.base64urlEncodedString() } |
| 138 | return try JSONSerialization.data(withJSONObject: [ |
| 139 | "id": id, "rawId": id, "type": "public-key", |
| 140 | "clientExtensionResults": [String: Any](), |
| 141 | "response": response, |
| 142 | ]) |
| 143 | } |
| 144 | |
| 145 | // ---- controller driving ------------------------------------------------- |
| 146 | |
| 147 | private func run(_ request: ASAuthorizationRequest) async throws -> ASAuthorization { |
| 148 | try await withCheckedThrowingContinuation { cont in |
| 149 | self.continuation = cont |
| 150 | let controller = ASAuthorizationController(authorizationRequests: [request]) |
| 151 | controller.delegate = self |
| 152 | controller.presentationContextProvider = self |
| 153 | controller.performRequests() |
| 154 | } |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | @available(iOS 18.0, macOS 15.0, *) |
| 159 | extension HomeAuth: ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { |
| 160 | public func authorizationController(controller: ASAuthorizationController, |
| 161 | didCompleteWithAuthorization authorization: ASAuthorization) { |
| 162 | continuation?.resume(returning: authorization) |
| 163 | continuation = nil |
| 164 | } |
| 165 | |
| 166 | public func authorizationController(controller: ASAuthorizationController, |
| 167 | didCompleteWithError error: Error) { |
| 168 | continuation?.resume(throwing: error) |
| 169 | continuation = nil |
| 170 | } |
| 171 | |
| 172 | public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { |
| 173 | anchor |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | public enum HomeAuthError: Error { case badOptions, wrongCredential, noPRF } |
| 178 | |