diff --git a/Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved index ec95552ced..aed54af7f3 100644 --- a/Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,4 @@ { - "originHash" : "93ec2eb77f36826178e4c8677e95e70ec909a1b40aa8e2cb1ccaa066a778be58", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -118,14 +117,6 @@ "version" : "2.4.0" } }, - { - "identity" : "sdk-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/bitwarden/sdk-swift", - "state" : { - "revision" : "1e78e85bd3d45d14cf76592b0f109ba54c119d4b" - } - }, { "identity" : "swift-custom-dump", "kind" : "remoteSourceControl", @@ -190,5 +181,5 @@ } } ], - "version" : 3 + "version" : 2 } diff --git a/BitwardenKit/Core/Platform/Utilities/Base64UrlEncoder.swift b/BitwardenKit/Core/Platform/Utilities/Base64UrlEncoder.swift new file mode 100644 index 0000000000..bf95c86352 --- /dev/null +++ b/BitwardenKit/Core/Platform/Utilities/Base64UrlEncoder.swift @@ -0,0 +1,33 @@ +import Foundation + +extension Data { + public func base64UrlEncodedString(trimPadding: Bool? = true) -> String { + let shouldTrim = if trimPadding != nil { trimPadding! } else { true } + let encoded = base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_") + if shouldTrim { + return encoded.trimmingCharacters(in: CharacterSet(["="])) + } else { + return encoded + } + } + + public init?(base64UrlEncoded str: String) { + self.init(base64Encoded: normalizeBase64Url(str)) + } +} + +private func normalizeBase64Url(_ str: String) -> String { + let hasPadding = str.last == "=" + let padding = if !hasPadding { + switch str.count % 4 { + case 2: "==" + case 3: "=" + default: "" + } + } else { "" } + return str + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + padding +} + diff --git a/BitwardenShared/Core/Auth/Models/Request/SecretVerificationRequestModel.swift b/BitwardenShared/Core/Auth/Models/Request/SecretVerificationRequestModel.swift new file mode 100644 index 0000000000..bf7961ef54 --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Request/SecretVerificationRequestModel.swift @@ -0,0 +1,31 @@ +import Foundation +import Networking + +struct SecretVerificationRequestModel: JSONRequestBody, Equatable { + static let encoder = JSONEncoder() + + // MARK: Properties + + let authRequestAccessCode: String? + let masterPasswordHash: String? + let otp: String? + + + init(passwordHash: String) { + authRequestAccessCode = nil + masterPasswordHash = passwordHash + otp = nil + } + + init(otp: String) { + masterPasswordHash = nil + self.otp = otp + authRequestAccessCode = nil + } + + init(accessCode: String) { + authRequestAccessCode = accessCode + masterPasswordHash = nil + otp = nil + } +} diff --git a/BitwardenShared/Core/Auth/Models/Request/WebAuthnLoginSaveCredentialRequestModel.swift b/BitwardenShared/Core/Auth/Models/Request/WebAuthnLoginSaveCredentialRequestModel.swift new file mode 100644 index 0000000000..c1a70f7540 --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Request/WebAuthnLoginSaveCredentialRequestModel.swift @@ -0,0 +1,48 @@ +import Foundation +import Networking + +// MARK: - SaveCredentialRequestModel + +/// The request body for an answer login request request. +/// +struct WebAuthnLoginSaveCredentialRequestModel: JSONRequestBody, Equatable { + static let encoder = JSONEncoder() + + // MARK: Properties + // The response received from the authenticator. + // This contains all information needed for future authentication flows. + let deviceResponse: WebAuthnLoginAttestationResponseRequest + + // Nickname chosen by the user to identify this credential + let name: String + + // Token required by the server to complete the creation. + // It contains encrypted information that the server needs to verify the credential. + let token: String + + // True if the credential was created with PRF support. + let supportsPrf: Bool + + // Used for vault encryption. See {@link RotateableKeySet.encryptedUserKey } + let encryptedUserKey: String? + + // Used for vault encryption. See {@link RotateableKeySet.encryptedPublicKey } + let encryptedPublicKey: String? + + // Used for vault encryption. See {@link RotateableKeySet.encryptedPrivateKey } + let encryptedPrivateKey: String? +} + +struct WebAuthnLoginAttestationResponseRequest: Encodable, Equatable { + let id: String + let rawId: String + let type: String + // let extensions: [String: Any] + let response: WebAuthnLoginAttestationResponseRequestInner +} + +struct WebAuthnLoginAttestationResponseRequestInner: Encodable, Equatable { + let attestationObject: String + let clientDataJson: String + +} diff --git a/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialAssertionOptionsResponse.swift b/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialAssertionOptionsResponse.swift new file mode 100644 index 0000000000..7cce01dec0 --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialAssertionOptionsResponse.swift @@ -0,0 +1,19 @@ +import Foundation +import Networking + +struct WebAuthnLoginCredentialAssertionOptionsResponse: JSONResponse, Equatable, Sendable { + /// Options to be provided to the webauthn authenticator. + let options: PublicKeyCredentialAssertionOptions; + + /// Contains an encrypted version of the {@link options}. + /// Used by the server to validate the attestation response of newly created credentials. + let token: String; +} + +struct PublicKeyCredentialAssertionOptions: Codable, Equatable, Hashable { + let allowCredentials: [BwPublicKeyCredentialDescriptor]? + let challenge: String + let extensions: AuthenticationExtensionsClientInputs? + let rpId: String + let timeout: Int? +} diff --git a/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialCreationOptionsResponse.swift b/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialCreationOptionsResponse.swift new file mode 100644 index 0000000000..0e0f89d26a --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialCreationOptionsResponse.swift @@ -0,0 +1,59 @@ +import Foundation +import Networking + +struct WebAuthnLoginCredentialCreationOptionsResponse: JSONResponse, Equatable, Sendable { + /// Options to be provided to the webauthn authenticator. + let options: PublicKeyCredentialCreationOptions; + + /// Contains an encrypted version of the {@link options}. + /// Used by the server to validate the attestation response of newly created credentials. + let token: String; +} + +struct PublicKeyCredentialCreationOptions: Codable, Equatable, Hashable { + // attestation?: AttestationConveyancePreference + // let authenticatorSelection: AuthenticatorSelectionCriteria? + let challenge: String + let excludeCredentials: [BwPublicKeyCredentialDescriptor]? + let extensions: AuthenticationExtensionsClientInputs? + let pubKeyCredParams: [BwPublicKeyCredentialParameters] + let rp: BwPublicKeyCredentialRpEntity + let timeout: Int? + let user: BwPublicKeyCredentialUserEntity +} + + +struct AuthenticationExtensionsClientInputs: Codable, Equatable, Hashable { + let prf: AuthenticationExtensionsPRFInputs? +} + +struct AuthenticationExtensionsPRFInputs: Codable, Equatable, Hashable { + let eval: AuthenticationExtensionsPRFValues? + let evalByCredential: [String: AuthenticationExtensionsPRFValues]? +} + +struct AuthenticationExtensionsPRFValues: Codable, Equatable, Hashable { + let first: String + let second: String? +} + +struct BwPublicKeyCredentialDescriptor: Codable, Equatable, Hashable { + let type: String + let id: String + // let transports: [String]? +} + +struct BwPublicKeyCredentialParameters: Codable, Equatable, Hashable { + let type: String + let alg: Int +} + +struct BwPublicKeyCredentialRpEntity: Codable, Equatable, Hashable { + let id: String + let name: String +} + +struct BwPublicKeyCredentialUserEntity: Codable, Equatable, Hashable { + let id: String + let name: String +} diff --git a/BitwardenShared/Core/Auth/Services/API/Auth/AuthAPIService.swift b/BitwardenShared/Core/Auth/Services/API/Auth/AuthAPIService.swift index 2c7e0085b4..1c6dbf137b 100644 --- a/BitwardenShared/Core/Auth/Services/API/Auth/AuthAPIService.swift +++ b/BitwardenShared/Core/Auth/Services/API/Auth/AuthAPIService.swift @@ -20,6 +20,16 @@ protocol AuthAPIService { /// - Returns: The pending login request. /// func checkPendingLoginRequest(withId id: String, accessCode: String) async throws -> LoginRequest + + /// Retrieves the parameters for creating a new WebAuthn credential. + /// - Parameters: + /// - request: The data needed to send the request. + func getCredentialCreationOptions(_ request: SecretVerificationRequestModel) async throws -> WebAuthnLoginCredentialCreationOptionsResponse + + /// Retrieves the parameters for authenticating with a WebAuthn credential. + /// - Parameters: + /// - request: The data needed to send the request. + func getCredentialAssertionOptions(_ request: SecretVerificationRequestModel) async throws -> WebAuthnLoginCredentialAssertionOptionsResponse /// Performs the identity token request and returns the response. /// @@ -83,6 +93,8 @@ protocol AuthAPIService { /// - model: The data needed to send the request. /// func updateTrustedDeviceKeys(deviceIdentifier: String, model: TrustedDeviceKeysRequestModel) async throws + + func saveCredential(_ model: WebAuthnLoginSaveCredentialRequestModel) async throws } extension APIService: AuthAPIService { @@ -93,6 +105,14 @@ extension APIService: AuthAPIService { func checkPendingLoginRequest(withId id: String, accessCode: String) async throws -> LoginRequest { try await apiUnauthenticatedService.send(CheckLoginRequestRequest(accessCode: accessCode, id: id)) } + + func getCredentialCreationOptions(_ request: SecretVerificationRequestModel) async throws -> WebAuthnLoginCredentialCreationOptionsResponse { + try await apiService.send(WebAuthnLoginGetCredentialCreationOptionsRequest(requestModel: request)) + } + + func getCredentialAssertionOptions(_ request: SecretVerificationRequestModel) async throws -> WebAuthnLoginCredentialAssertionOptionsResponse { + try await apiService.send(WebAuthnLoginGetCredentialAssertionOptionsRequest(requestModel: request)) + } func getIdentityToken(_ request: IdentityTokenRequestModel) async throws -> IdentityTokenResponseModel { try await identityService.send(IdentityTokenRequest(requestModel: request)) @@ -133,6 +153,10 @@ extension APIService: AuthAPIService { _ = try await apiUnauthenticatedService.send(ResendNewDeviceOtpRequest(model: model)) } + func saveCredential(_ model: WebAuthnLoginSaveCredentialRequestModel) async throws { + _ = try await apiService.send(WebAuthnLoginSaveCredentialRequest(requestModel: model)) + } + func updateTrustedDeviceKeys(deviceIdentifier: String, model: TrustedDeviceKeysRequestModel) async throws { _ = try await apiService.send(TrustedDeviceKeysRequest(deviceIdentifier: deviceIdentifier, requestModel: model)) } diff --git a/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialAssertionOptionsRequest.swift b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialAssertionOptionsRequest.swift new file mode 100644 index 0000000000..5ce9b5de3b --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialAssertionOptionsRequest.swift @@ -0,0 +1,13 @@ +import Networking + +struct WebAuthnLoginGetCredentialAssertionOptionsRequest : Request { + typealias Response = WebAuthnLoginCredentialAssertionOptionsResponse + + var body: SecretVerificationRequestModel? { requestModel } + + var path: String { "/webauthn/assertion-options" } + + var method: HTTPMethod { .post } + + let requestModel: SecretVerificationRequestModel +} diff --git a/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialCreationOptionsRequest.swift b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialCreationOptionsRequest.swift new file mode 100644 index 0000000000..d7665fddcf --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialCreationOptionsRequest.swift @@ -0,0 +1,13 @@ +import Networking + +struct WebAuthnLoginGetCredentialCreationOptionsRequest : Request { + typealias Response = WebAuthnLoginCredentialCreationOptionsResponse + + var body: SecretVerificationRequestModel? { requestModel } + + var path: String { "/webauthn/attestation-options" } + + var method: HTTPMethod { .post } + + let requestModel: SecretVerificationRequestModel +} diff --git a/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginSaveCredentialRequest.swift b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginSaveCredentialRequest.swift new file mode 100644 index 0000000000..395b39bf60 --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginSaveCredentialRequest.swift @@ -0,0 +1,13 @@ +import Networking + +struct WebAuthnLoginSaveCredentialRequest : Request { + typealias Response = EmptyResponse + + var body: WebAuthnLoginSaveCredentialRequestModel? { requestModel } + + var path: String { "/webauthn" } + + var method: HTTPMethod { .post } + + let requestModel: WebAuthnLoginSaveCredentialRequestModel +} diff --git a/BitwardenShared/Core/Auth/Services/AuthService.swift b/BitwardenShared/Core/Auth/Services/AuthService.swift index ac591b15e5..dd62f24158 100644 --- a/BitwardenShared/Core/Auth/Services/AuthService.swift +++ b/BitwardenShared/Core/Auth/Services/AuthService.swift @@ -53,6 +53,9 @@ enum AuthError: Error { /// There was a problem generating the request to resend the email with new device otp. case unableToResendNewDeviceOtp + + /// There was a problem generating the device passkey or PRF encryption key. + case unableToCreateDevicePasskey } // MARK: - LoginUnlockMethod @@ -89,6 +92,9 @@ protocol AuthService { /// Check the status of the pending login request for the unauthenticated user. /// func checkPendingLoginRequest(withId id: String) async throws -> LoginRequest + + /// Create device passkey with PRF encryption key. + func createDevicePasskey(masterPasswordHash: String) async throws /// Deny all the pending login requests. /// @@ -241,6 +247,18 @@ extension AuthService { } } +struct DevicePasskeyRecord: Decodable, Encodable { + let credId: String + let privKey: String + let prfSeed: String + let rpId: String + let rpName: String? + let userId: String? + let userName: String? + let userDisplayName: String? + let creationDate: DateTime +} + // MARK: - DefaultAuthService /// The default implementation of `AuthService`. @@ -272,6 +290,9 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng /// The store which makes credential identities available to the system for AutoFill suggestions. private let credentialIdentityStore: CredentialIdentityStore + /// The service used to create and assert the device passkey for logging into remote clients. + private let devicePasskeyService: DevicePasskeyService + /// The service used by the application to manage the environment settings. private let environmentService: EnvironmentService @@ -326,6 +347,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng /// - configService: The service to get server-specified configuration. /// - credentialIdentityStore: The store which makes credential identities available to the /// system for AutoFill suggestions. + /// - devicePasskeyService: The service used to create and assert the device passkey for logging into remote clients. /// - environmentService: The service used by the application to manage the environment settings. /// - errorReporter: The service used by the application to report non-fatal errors. /// - keychainRepository: The repository used to manages keychain items. @@ -341,6 +363,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng clientService: ClientService, configService: ConfigService, credentialIdentityStore: CredentialIdentityStore = ASCredentialIdentityStore.shared, + devicePasskeyService: DevicePasskeyService, environmentService: EnvironmentService, errorReporter: ErrorReporter, keychainRepository: KeychainRepository, @@ -355,6 +378,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng self.clientService = clientService self.configService = configService self.credentialIdentityStore = credentialIdentityStore + self.devicePasskeyService = devicePasskeyService self.environmentService = environmentService self.errorReporter = errorReporter self.keychainRepository = keychainRepository @@ -589,7 +613,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng email: username, masterPassword: masterPassword ) - + // Save the master password hash. try await saveMasterPasswordHash(password: masterPassword) @@ -680,6 +704,11 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng return false } + + /// Create device passkey with PRF encryption key. + func createDevicePasskey(masterPasswordHash: String) async throws { + try await devicePasskeyService.createDevicePasskey(masterPasswordHash: masterPasswordHash, overwrite: true) + } func loginWithSingleSignOn(code: String, email: String) async throws -> LoginUnlockMethod { // Get the identity token to log in to Bitwarden. diff --git a/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift b/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift new file mode 100644 index 0000000000..4738b76238 --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift @@ -0,0 +1,386 @@ +import CryptoKit +import Foundation +import ObjectiveC +import os.log +import UIKit + +import BitwardenSdk +import BitwardenKit + +protocol DevicePasskeyService { + /// Create device passkey with PRF encryption key. + /// + /// Before calling, vault must be unlocked to wrap user encryption key. + /// - Parameters: + /// - masterPasswordHash: Master password hash suitable for server authentication + func createDevicePasskey(masterPasswordHash: String, overwrite: Bool) async throws + + /// Use device passkey to assert a credential, outputting PRF output. + func useDevicePasskey(for request: GetAssertionRequest) async throws -> (GetAssertionResult, Data?)? +} + +struct DefaultDevicePasskeyService : DevicePasskeyService { + static private let decoder = JSONDecoder() + static private let encoder = JSONEncoder() + + private let authAPIService: AuthAPIService + private let clientService: ClientService + private let environmentService: EnvironmentService + private let keychainRepository: KeychainRepository + private let stateService: StateService + + /// This is the AAGUID for the Bitwarden Passkey provider (d548826e-79b4-db40-a3d8-11116f7e8349) + /// It is used for the Relaying Parties to identify the authenticator during registration + private let aaguid = Data([ + 0xd5, 0x48, 0x82, 0x6e, 0x79, 0xb4, 0xdb, 0x40, 0xa3, 0xd8, 0x11, 0x11, 0x6f, 0x7e, 0x83, 0x49, + ]); + + /// Default PRF salt input to use if none is received from WebAuthn client. + private let defaultLoginWithPrfSalt = Data(SHA256.hash(data: "passwordless-login".data(using: .utf8)!)) + + init( + authAPIService: AuthAPIService, + clientService: ClientService, + environmentService: EnvironmentService, + keychainRepository: KeychainRepository, + stateService: StateService + ) { + self.authAPIService = authAPIService + self.clientService = clientService + self.environmentService = environmentService + self.keychainRepository = keychainRepository + self.stateService = stateService + } + + /// Create device passkey with PRF encryption key. + /// + /// Before calling, vault must be unlocked to wrap user encryption key. + func createDevicePasskey(masterPasswordHash: String, overwrite: Bool) async throws { + if !overwrite { + let existing = try? await keychainRepository.getDevicePasskey(userId: stateService.getActiveAccountId()) + if existing != nil { + return + } + } + let response = try await authAPIService.getCredentialCreationOptions(SecretVerificationRequestModel(passwordHash: masterPasswordHash)) + let options = response.options + let token = response.token + + let (prfInput, _) = try getPrfInput(extensionsInput: response.options.extensions) + + let excludeCredentials: [PublicKeyCredentialDescriptor]? = if options.excludeCredentials != nil { + // TODO: return early if exclude credentials matches + options.excludeCredentials!.map { + return PublicKeyCredentialDescriptor(ty: $0.type, id: Data(base64UrlEncoded: $0.id)!, transports: nil) + } + } + else { nil } + let credParams = options.pubKeyCredParams.map { + PublicKeyCredentialParameters(ty: $0.type, alg: Int64($0.alg)) + } + + let origin = deriveWebOrigin() + let clientDataJson = #"{"type":"webauthn.create","challenge":"\#(options.challenge)","origin":"\#(origin)"}"# + let clientDataHash = Data(SHA256.hash(data: clientDataJson.data(using: .utf8)!)) + + let credRequest = MakeCredentialRequest( + clientDataHash: clientDataHash, + rp: PublicKeyCredentialRpEntity(id: options.rp.id, name: options.rp.name), + user: PublicKeyCredentialUserEntity( + id: Data(base64UrlEncoded: options.user.id)!, + displayName: options.user.name, + name: options.user.name + ), + pubKeyCredParams: credParams, + excludeList: excludeCredentials, + options: Options( + rk: true, + uv: .required + ), + extensions: nil, + ) + + let makeResult = try makeWebAuthnCredential(request: credRequest, prfInput: prfInput) + let createdCredential = makeResult.credential + let prfKeyResponse = try await clientService.crypto().derivePrfKey(prf: makeResult.prfResult.base64EncodedString()) + + // Store the passkey with its PRF seed + let credRecord = DevicePasskeyRecord( + credId: createdCredential.credentialId.base64EncodedString(), + privKey: makeResult.privKey.rawRepresentation.base64EncodedString(), + prfSeed: makeResult.prfSeed.withUnsafeBytes{ + Data(Array($0)).base64EncodedString() + }, + rpId: credRequest.rp.id, + rpName: credRequest.rp.name, + userId: credRequest.user.id.base64EncodedString(), + userName: credRequest.user.name, + userDisplayName: credRequest.user.displayName, + creationDate: CurrentTime().presentTime, + ) + let encoder = JSONEncoder() + let recordJson = try String(data: encoder.encode(credRecord), encoding: .utf8)! + try await keychainRepository.setDevicePasskey(recordJson, userId: stateService.getActiveAccountId()) + + // Register the credential keyset with the server. + // TODO: This only returns generic names like `iPhone`. + // If there is a more specific name available (e.g., user-chosen), + // that would be helpful to disambiguate in the menu. + let clientName = await "Bitwarden App on \(UIKit.UIDevice.current.name)" + let request = WebAuthnLoginSaveCredentialRequestModel( + deviceResponse: WebAuthnLoginAttestationResponseRequest( + id: createdCredential.credentialId.base64UrlEncodedString(trimPadding: false), + rawId: createdCredential.credentialId.base64UrlEncodedString(trimPadding: false), + type: "public-key", + response: WebAuthnLoginAttestationResponseRequestInner( + attestationObject: createdCredential.attestationObject.base64UrlEncodedString(trimPadding: false), + clientDataJson: clientDataJson.data(using: .utf8)!.base64UrlEncodedString(trimPadding: false) + ), + ), + name: clientName, + token: token, + supportsPrf: true, + encryptedUserKey: prfKeyResponse.encapsulatedUserKey, + encryptedPublicKey: prfKeyResponse.encryptedEncapsulationKey, + encryptedPrivateKey: prfKeyResponse.wrappedDecapsulationKey + ) + try await authAPIService.saveCredential(request) + } + + /// Emulates a FIDO2 authenticator. + private func makeWebAuthnCredential(request: MakeCredentialRequest, prfInput: Data) throws -> DevicePasskeyResult { + // attested credential data + let credId = try getSecureRandomBytes(count: 32) + let privKey = P256.Signing.PrivateKey(compactRepresentable: false) + let publicKeyBytes = privKey.publicKey.rawRepresentation + let pointX = publicKeyBytes[1..<33] + let pointY = publicKeyBytes[33...] + var cosePubKey = Data() + cosePubKey.append(contentsOf: [ + 0xA5, // Map, length 5 + 0x01, 0x02, // 1 (kty): 2 (EC2) + 0x03, 0x26, // 3 (alg): -7 (ES256) + 0x20, 0x01, // -1 (crv): 1 (P256) + ]) + cosePubKey.append(contentsOf: [ + 0x21, 0x58, 0x20// -2 (x): bytes, len 32 + ]) + cosePubKey.append(contentsOf: pointX) + cosePubKey.append(contentsOf: [ + 0x22, 0x58, 0x20// -3 (x): bytes, len 32 + ]) + cosePubKey.append(contentsOf: pointY) + let attestedCredentialData = aaguid + UInt16(credId.count).bytes + credId + cosePubKey + + // PRF + // We're processing this as a WebAuthn extension, not a CTAP2 extension, + // so we're not writing this to the extension data in the authenticator data. + let prfSeed = SymmetricKey(size: SymmetricKeySize(bitCount: 256)) + let prfResult = generatePrf(using: prfInput, from: prfSeed) + + // authenticatorData + let authData = buildAuthenticatorData(rpId: request.rp.id, attestedCredentialData: attestedCredentialData) + + // signature + let response = try createAttestationObject( + withKey: privKey, + authenticatorData: authData, + clientDataHash: request.clientDataHash) + let result = MakeCredentialResult( + authenticatorData: authData, + attestationObject: response.attestationObject, + credentialId: credId) + return DevicePasskeyResult(credential: result, privKey: privKey, prfSeed: prfSeed, prfResult: prfResult) + } + + /// Use device passkey to assert a credential, outputting PRF output. + func useDevicePasskey(for request: GetAssertionRequest) async throws -> (GetAssertionResult, Data?)? { + let webVaultRpId = deriveRpId() + guard webVaultRpId == request.rpId else { return nil } + guard let json = try? await keychainRepository.getDevicePasskey(userId: stateService.getActiveAccountId()) else { + Logger.application.warning("Matched Bitwarden Web Vault rpID, but no device passkey found.") + return nil + } + + let record: DevicePasskeyRecord = try DefaultDevicePasskeyService.decoder.decode(DevicePasskeyRecord.self, from: json.data(using: .utf8)!) + + // extensions + // prf + let extInputs = if let extJson = request.extensions { + try DefaultDevicePasskeyService.decoder.decode(AuthenticationExtensionsClientInputs.self, from: extJson.data(using: .utf8)!) + } else { nil as AuthenticationExtensionsClientInputs? } + let (prfInput, _) = try getPrfInput(extensionsInput: extInputs) + let prfSeed = SymmetricKey(data: Data(base64Encoded: record.prfSeed)!) + + // TODO: this is unused, but appears in GetAssertionResult signature. + let fido2View = Fido2CredentialView( + credentialId: record.credId, + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + keyValue: EncString(), + rpId: record.rpId, + userHandle: nil, + userName: nil, + counter: "0", + rpName: nil, + userDisplayName: nil, + discoverable: "true", + creationDate: record.creationDate, + ) + let fido2NewView = Fido2CredentialNewView( + credentialId: record.credId, + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + rpId: record.rpId, + userHandle: nil, + userName: nil, + counter: "0", + rpName: nil, + userDisplayName: nil, + creationDate: record.creationDate, + ) + let credId = Data(base64Encoded: record.credId)! + let userHandle = Data(base64Encoded: record.userId!)! + let privKey = try P256.Signing.PrivateKey(rawRepresentation: Data(base64Encoded: record.privKey)!) + let assertion = try assertWebAuthnCredential( + withKey: privKey, + rpId: request.rpId, + clientDataHash: request.clientDataHash, + prfSeed: prfSeed, + prfInput: prfInput) + let result = GetAssertionResult( + credentialId: credId, + authenticatorData: assertion.authenticatorData, + signature: assertion.signature, + userHandle: userHandle, + selectedCredential: SelectedCredential(cipher: CipherView(fido2CredentialNewView: fido2NewView, timeProvider: CurrentTime()), credential: fido2View), + ) + return (result, assertion.prfResult) + } + + private func deriveRpId() -> String { + // TODO: Should we be using the web vault as the origin, and is this the best way to get it? + environmentService.webVaultURL.domain! + } + + private func deriveWebOrigin() -> String { + // TODO: Should we be using the web vault as the origin, and is this the best way to get it? + let url = environmentService.webVaultURL + return "\(url.scheme ?? "http")://\(url.hostWithPort!)" + } + + private func getPrfInput(extensionsInput extInputs: AuthenticationExtensionsClientInputs?) throws -> (salt1: Data, salt2: Data?) { + return (defaultLoginWithPrfSalt, nil) + /* + if let prfInputs = extInputs?.prf?.eval { + let input1 = Data(base64UrlEncoded: prfInputs.first)! + let input2: Data? = if let second = prfInputs.second { + Data(base64UrlEncoded: second) + } else { nil } + return (input1, input2) + } + else { + return (defaultLoginWithPrfSalt, nil) + } + */ + } + + struct DevicePasskeyResult { + let credential: MakeCredentialResult + let privKey: P256.Signing.PrivateKey + let prfSeed: SymmetricKey + let prfResult: Data + } +} + +private func assertWebAuthnCredential( + withKey privKey: P256.Signing.PrivateKey, + rpId: String, + clientDataHash: Data, + prfSeed: SymmetricKey, + prfInput: Data +) throws -> (authenticatorData: Data, signature: Data, prfResult: Data) { + // authenticatorData + let authData = buildAuthenticatorData(rpId: rpId, attestedCredentialData: nil) + + // signature + let response = try createAttestationObject( + withKey: privKey, + authenticatorData: authData, + clientDataHash: clientDataHash) + + let prfResult = generatePrf(using: prfInput, from: prfSeed) + return (authData, response.signature, prfResult) +} + +private func buildAuthenticatorData(rpId: String, attestedCredentialData: Data?) -> Data { + let rpIdHash = Data(SHA256.hash(data: rpId.data(using: .utf8)!)) + let signCount = UInt32(0) + if let credential = attestedCredentialData { + // Attesting/creating credential + let flags = 0b01000101 // AT, UV, UP + return rpIdHash + UInt8(flags).bytes + signCount.bytes + credential + } + else { + // Asserting credential + let flags = 0b0001_1101 // UV, UP; BE and BS also set because macOS requires it on assertions :( + return rpIdHash + UInt8(flags).bytes + signCount.bytes + } +} + +private func createAttestationObject( + withKey privKey: P256.Signing.PrivateKey, + authenticatorData authData: Data, + clientDataHash: Data +) throws -> (attestationObject: Data, signature: Data) { + // signature + let payload = authData + clientDataHash + // let privKey = try P256.Signing.PrivateKey(rawRepresentation: Data(base64Encoded: record.privKey)!) + let sig = try privKey.signature(for: payload).derRepresentation + + // attestation object + var attObj = Data() + attObj.append(contentsOf: [ + 0xA3, // map, length 3 + 0x63, 0x66, 0x6d, 0x74, // string, len 3 "fmt" + 0x66, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x64, // string, len 6, "packed" + 0x67, 0x61, 0x74, 0x74, 0x53, 0x74, 0x6d, 0x74, // string, len 7, "attStmt" + 0xA2, // map, length 2 + 0x63, 0x61, 0x6c, 0x67, // string, len 3, "alg" + 0x26, // -7 (P256) + 0x63, 0x73, 0x69, 0x67, // string, len 3, "sig" + 0x58, // bytes, length specified in following byte + ]) + attObj.append(contentsOf: UInt8(sig.count).bytes) + attObj.append(contentsOf: sig) + attObj.append(contentsOf:[ + 0x68, 0x61, 0x75, 0x74, 0x68, 0x44, 0x61, 0x74, 0x61, // string, len 8, "authData" + 0x58, // bytes, length specified in following byte. + ]) + attObj.append(contentsOf: UInt8(authData.count).bytes) + attObj.append(contentsOf: authData) + return (attObj, sig) +} + +private func generatePrf(using prfInput: Data, from seed: SymmetricKey) -> Data { + let saltPrefix = "WebAuthn PRF\0".data(using: .utf8)! + let salt1 = saltPrefix + prfInput + let logger = Logger() + seed.withUnsafeBytes{ + let seedBytes = Data(Array($0)) + logger.debug("PRF Input: \(salt1.base64EncodedString())\nPRF Seed: \(seedBytes.base64UrlEncodedString())") + } + // CTAP2 uses HMAC to expand salt into a PRF, so we're doing the same. + return Data(HMAC.authenticationCode(for: salt1, using: seed)) +} + +private func getSecureRandomBytes(count: Int) throws -> Data { + var bytes = [UInt8](repeating: 0, count: count) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + return Data(bytes) +} diff --git a/BitwardenShared/Core/Auth/Services/KeychainRepository.swift b/BitwardenShared/Core/Auth/Services/KeychainRepository.swift index c468da9c1e..1f4e8b4d94 100644 --- a/BitwardenShared/Core/Auth/Services/KeychainRepository.swift +++ b/BitwardenShared/Core/Auth/Services/KeychainRepository.swift @@ -27,6 +27,8 @@ enum KeychainItem: Equatable { /// The keychain item for a user's refresh token. case refreshToken(userId: String) + case devicePasskey(userId: String) + /// The `SecAccessControlCreateFlags` level for this keychain item. /// If `nil`, no extra protection is applied. /// @@ -39,7 +41,8 @@ enum KeychainItem: Equatable { .pendingAdminLoginRequest, .refreshToken: nil - case .biometrics: + case .biometrics, + .devicePasskey: .biometryCurrentSet } } @@ -56,6 +59,8 @@ enum KeychainItem: Equatable { .authenticatorVaultKey, .refreshToken: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + // TODO: These keys are not restored on backups, maybe this is a bad idea + case .devicePasskey: kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly } } @@ -77,6 +82,8 @@ enum KeychainItem: Equatable { "pendingAdminLoginRequest_\(userId)" case let .refreshToken(userId): "refreshToken_\(userId)" + case let .devicePasskey(userId: id): + "devicePasskey_\(id)" } } } @@ -114,9 +121,14 @@ protocol KeychainRepository: AnyObject { /// Attempts to delete the pending admin login request from the keychain. /// - /// - Parameter userId: The user ID associated with the stored device key. + /// - Parameter userId: The user ID associated with the pending admin login request. /// func deletePendingAdminLoginRequest(userId: String) async throws + + /// Attempts to delete the device passkey from the keychain. + /// + /// - Parameter userId: The user ID associated with the stored device passkey. + func deleteDevicePasskey(userId: String) async throws /// Gets the stored access token for a user from the keychain. /// @@ -152,7 +164,14 @@ protocol KeychainRepository: AnyObject { /// - Returns: The pending admin login request. /// func getPendingAdminLoginRequest(userId: String) async throws -> String? - + + /// Gets the stored device passkey for a user from the keychain. + /// + /// - Parameter userId: The user ID associated with the stored device passkey. + /// - Returns: The device key. + /// + func getDevicePasskey(userId: String) async throws -> String? + /// Gets a user auth key value. /// /// - Parameter item: The storage key of the user auth key. @@ -199,6 +218,14 @@ protocol KeychainRepository: AnyObject { /// - userId: The user ID associated with the pending admin login request. /// func setPendingAdminLoginRequest(_ value: String, userId: String) async throws + + /// Sets the device passkey for a user ID. + /// + /// - Parameters: + /// - value: The passkey to store + /// - userId: The user ID associated with the device passkey. + /// + func setDevicePasskey(_ value: String, userId: String) async throws /// Sets a user auth key/value pair. /// @@ -386,6 +413,7 @@ extension DefaultKeychainRepository { // Exclude `pendingAdminLoginRequest` since if a TDE user is logged out before the request // is approved, the next login for the user will succeed with the pending request. .refreshToken(userId: userId), + // Exclude `devicePasskey` since it is used to log back into an account. ] for keychainItem in keychainItems { try await keychainService.delete(query: keychainQueryValues(for: keychainItem)) @@ -409,6 +437,12 @@ extension DefaultKeychainRepository { query: keychainQueryValues(for: .pendingAdminLoginRequest(userId: userId)) ) } + + func deleteDevicePasskey(userId: String) async throws { + try await keychainService.delete( + query: keychainQueryValues(for: .devicePasskey(userId: userId)) + ) + } func getAccessToken(userId: String) async throws -> String { try await getValue(for: .accessToken(userId: userId)) @@ -430,6 +464,10 @@ extension DefaultKeychainRepository { try await getValue(for: .pendingAdminLoginRequest(userId: userId)) } + func getDevicePasskey(userId: String) async throws -> String? { + try await getValue(for: .devicePasskey(userId: userId)) + } + func getUserAuthKeyValue(for item: KeychainItem) async throws -> String { try await getValue(for: item) } @@ -453,6 +491,10 @@ extension DefaultKeychainRepository { func setPendingAdminLoginRequest(_ value: String, userId: String) async throws { try await setValue(value, for: .pendingAdminLoginRequest(userId: userId)) } + + func setDevicePasskey(_ value: String, userId: String) async throws { + try await setValue(value, for: .devicePasskey(userId: userId)) + } func setUserAuthKey(for item: KeychainItem, value: String) async throws { try await setValue(value, for: item) diff --git a/BitwardenShared/Core/Autofill/Extensions/BitwardenSdk+Autofill.swift b/BitwardenShared/Core/Autofill/Extensions/BitwardenSdk+Autofill.swift index 656eb46374..1272c3c025 100644 --- a/BitwardenShared/Core/Autofill/Extensions/BitwardenSdk+Autofill.swift +++ b/BitwardenShared/Core/Autofill/Extensions/BitwardenSdk+Autofill.swift @@ -47,11 +47,50 @@ extension GetAssertionRequest { rk: false, uv: BitwardenSdk.Uv(preference: passkeyRequest.userVerificationPreference) ), - extensions: nil + extensions: createExtensionJson(passkeyRequest: passkeyRequest), ) } } +@available(iOSApplicationExtension 17.0, *) +private func createExtensionJson(passkeyRequest: ASPasskeyCredentialRequest) -> String? { + guard #available(iOSApplicationExtension 18.0, *) else { + return nil + } + let prf: (Data, Data?)? = switch passkeyRequest.extensionInput { + case let .assertion(ext): + if let input = ext.prf?.inputValues { + (input.saltInput1, input.saltInput2) + } + else { + nil + } + case let .registration(ext): + if let input = ext.prf?.inputValues { + (input.saltInput1, input.saltInput2) + } + else { + nil + } + default: + nil + } + guard let prf else { return nil } + + let encoder = JSONEncoder() + let salt2 = if let salt2 = prf.1 { + "\"" + salt2.base64UrlEncodedString(trimPadding: true) + "\"" + } + else { "null" } + let eval = #""" + { + "first": "\#(prf.0.base64UrlEncodedString(trimPadding: true))", + "second": \#(salt2) + } + """# + return #"{"prf":{"eval":\#(eval)}}"# +} + // MARK: - MakeCredentialRequest extension BitwardenSdk.MakeCredentialRequest: @retroactive CustomDebugStringConvertible { diff --git a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift index 90dc74953e..4fde22aea8 100644 --- a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift +++ b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift @@ -1,6 +1,7 @@ import AuthenticationServices import BitwardenKit import BitwardenSdk +import CryptoKit import OSLog // swiftlint:disable file_length @@ -92,6 +93,9 @@ class DefaultAutofillCredentialService { /// The factory to create credential identities. private let credentialIdentityFactory: CredentialIdentityFactory + /// The service used to create and assert the device passkey for logging into remote clients. + private let devicePasskeyService: DevicePasskeyService + /// The service used by the application to report non-fatal errors. private let errorReporter: ErrorReporter @@ -108,6 +112,8 @@ class DefaultAutofillCredentialService { /// The service used to manage the credentials available for AutoFill suggestions. private let identityStore: CredentialIdentityStore + private let keychainRepository: KeychainRepository + /// The last user ID that had their identities synced. private var lastSyncedUserId: String? @@ -122,7 +128,7 @@ class DefaultAutofillCredentialService { /// The service used by the application to manage account state. private let stateService: StateService - + /// A reference to the task used to sync the user's ciphers to the identity store. This allows /// the task to be cancelled and recreated when the user changes. private var syncTask: Task? @@ -138,12 +144,14 @@ class DefaultAutofillCredentialService { /// - cipherService: The service used to manage syncing and updates to the user's ciphers. /// - clientService: The service that handles common client functionality such as encryption and decryption. /// - credentialIdentityFactory: The factory to create credential identities. + /// - devicePasskeyService: The service used to create and assert the device passkey for logging into remote clients. /// - errorReporter: The service used by the application to report non-fatal errors. /// - eventService: The service to manage events. /// - fido2UserInterfaceHelper: A helper to be used on Fido2 flows that requires user interaction /// and extends the capabilities of the `Fido2UserInterface` from the SDK. /// - fido2CredentialStore: A store to be used on Fido2 flows to get/save credentials. /// - identityStore: The service used to manage the credentials available for AutoFill suggestions. + /// - keychainRepository: The service used to manage the credentials available for AutoFill suggestions. /// - pasteboardService: The service used to manage copy/pasting from the device's clipboard. /// - stateService: The service used by the application to manage account state. /// - timeProvider: Provides the present time. @@ -154,11 +162,13 @@ class DefaultAutofillCredentialService { cipherService: CipherService, clientService: ClientService, credentialIdentityFactory: CredentialIdentityFactory, + devicePasskeyService: DevicePasskeyService, errorReporter: ErrorReporter, eventService: EventService, fido2CredentialStore: Fido2CredentialStore, fido2UserInterfaceHelper: Fido2UserInterfaceHelper, identityStore: CredentialIdentityStore = ASCredentialIdentityStore.shared, + keychainRepository: KeychainRepository, pasteboardService: PasteboardService, stateService: StateService, timeProvider: TimeProvider, @@ -168,11 +178,13 @@ class DefaultAutofillCredentialService { self.cipherService = cipherService self.clientService = clientService self.credentialIdentityFactory = credentialIdentityFactory + self.devicePasskeyService = devicePasskeyService self.errorReporter = errorReporter self.eventService = eventService self.fido2CredentialStore = fido2CredentialStore self.fido2UserInterfaceHelper = fido2UserInterfaceHelper self.identityStore = identityStore + self.keychainRepository = keychainRepository self.pasteboardService = pasteboardService self.stateService = stateService self.timeProvider = timeProvider @@ -260,6 +272,18 @@ class DefaultAutofillCredentialService { .credentialsForAutofill() .compactMap { $0.toFido2CredentialIdentity() } identities.append(contentsOf: fido2Identities) + // Add device passkey + if let json = try? await keychainRepository.getDevicePasskey(userId: userId) { + let decoder = JSONDecoder() + let record = try decoder.decode(DevicePasskeyRecord.self, from: json.data(using: .utf8)!) + identities.append(ASPasskeyCredentialIdentity( + relyingPartyIdentifier: record.rpId, + userName: record.userName!, + credentialID: Data(base64Encoded: record.credId)!, + userHandle: Data(base64Encoded: record.userId!)!, + recordIdentifier: "DEVICE_PASSKEY" + )) + } try await identityStore.replaceCredentialIdentities(identities) Logger.application.info("AutofillCredentialService: replaced \(identities.count) credential identities") @@ -366,7 +390,7 @@ extension DefaultAutofillCredentialService: AutofillCredentialService { let request = GetAssertionRequest( passkeyRequest: passkeyRequest, credentialIdentity: credentialIdentity ) - + return try await provideFido2Credential( with: request, fido2UserInterfaceHelperDelegate: fido2UserInterfaceHelperDelegate, @@ -469,12 +493,18 @@ extension DefaultAutofillCredentialService: AutofillCredentialService { #endif do { - let assertionResult = try await clientService.platform().fido2() - .authenticator( - userInterface: fido2UserInterfaceHelper, - credentialStore: fido2CredentialStore - ) - .getAssertion(request: request) + let devicePasskeyResult = try await devicePasskeyService.useDevicePasskey(for: request) + let (assertionResult, prfResult): (GetAssertionResult, Data?) = if let devicePasskeyResult { + devicePasskeyResult + } else { + (try await clientService.platform().fido2() + .authenticator( + userInterface: fido2UserInterfaceHelper, + credentialStore: fido2CredentialStore + ) + .getAssertion(request: request) + , nil as Data?) + } #if DEBUG Fido2DebuggingReportBuilder.builder.withGetAssertionResult(.success(assertionResult)) @@ -485,15 +515,37 @@ extension DefaultAutofillCredentialService: AutofillCredentialService { } catch { errorReporter.log(error: error) } - - return ASPasskeyAssertionCredential( - userHandle: assertionResult.userHandle, - relyingParty: rpId, - signature: assertionResult.signature, - clientDataHash: clientDataHash, - authenticatorData: assertionResult.authenticatorData, - credentialID: assertionResult.credentialId - ) + + + if #available(iOSApplicationExtension 18.0, *) { + let extOutput = if let prfResult { + ASPasskeyAssertionCredentialExtensionOutput( + largeBlob: nil, + prf: ASAuthorizationPublicKeyCredentialPRFAssertionOutput(first: SymmetricKey(data: prfResult), second: nil)) + } + else { + nil as ASPasskeyAssertionCredentialExtensionOutput? + } + return ASPasskeyAssertionCredential( + userHandle: assertionResult.userHandle, + relyingParty: rpId, + signature: assertionResult.signature, + clientDataHash: clientDataHash, + authenticatorData: assertionResult.authenticatorData, + credentialID: assertionResult.credentialId, + extensionOutput: extOutput, + ) + } + else { + return ASPasskeyAssertionCredential( + userHandle: assertionResult.userHandle, + relyingParty: rpId, + signature: assertionResult.signature, + clientDataHash: clientDataHash, + authenticatorData: assertionResult.authenticatorData, + credentialID: assertionResult.credentialId, + ) + } } catch { #if DEBUG Fido2DebuggingReportBuilder.builder.withGetAssertionResult(.failure(error)) diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index 485466ce2e..8563e19894 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -71,6 +71,11 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le /// The service to get server-specified configuration let configService: ConfigService + + /// The service used to create and use the device passkey for decrypting a vault for a remote client. + /// + /// This service should not be used for creating or asserting passkeys from the user's vault. + let devicePasskeyService: DevicePasskeyService /// The service used by the application to manage the environment settings. let environmentService: EnvironmentService @@ -217,6 +222,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le /// - changeKdfService: The service used to change the user's KDF settings. /// - clientService: The service used by the application to handle encryption and decryption tasks. /// - configService: The service to get server-specified configuration. + /// - devicePasskeyService: The service used to create and assert the device passkey. /// - environmentService: The service used by the application to manage the environment settings. /// - errorReportBuilder: A helper for building an error report containing the details of an /// error that occurred. @@ -278,6 +284,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le changeKdfService: ChangeKdfService, clientService: ClientService, configService: ConfigService, + devicePasskeyService: DevicePasskeyService, environmentService: EnvironmentService, errorReportBuilder: ErrorReportBuilder, errorReporter: ErrorReporter, @@ -335,6 +342,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le self.changeKdfService = changeKdfService self.clientService = clientService self.configService = configService + self.devicePasskeyService = devicePasskeyService self.environmentService = environmentService self.errorReportBuilder = errorReportBuilder self.errorReporter = errorReporter @@ -624,12 +632,21 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le stateService: stateService ) + let devicePasskeyService = DefaultDevicePasskeyService( + authAPIService: apiService, + clientService: clientService, + environmentService: environmentService, + keychainRepository: keychainRepository, + stateService: stateService, + ) + let authService = DefaultAuthService( accountAPIService: apiService, appIdService: appIdService, authAPIService: apiService, clientService: clientService, configService: configService, + devicePasskeyService: devicePasskeyService, environmentService: environmentService, errorReporter: errorReporter, keychainRepository: keychainRepository, @@ -793,6 +810,8 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le cipherService: cipherService, clientService: clientService, errorReporter: errorReporter, + keychainRepository: keychainRepository, + stateService: stateService, syncService: syncService ) ) @@ -801,6 +820,8 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le cipherService: cipherService, clientService: clientService, errorReporter: errorReporter, + keychainRepository: keychainRepository, + stateService: stateService, syncService: syncService ) #endif @@ -810,10 +831,12 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le cipherService: cipherService, clientService: clientService, credentialIdentityFactory: credentialIdentityFactory, + devicePasskeyService: devicePasskeyService, errorReporter: errorReporter, eventService: eventService, fido2CredentialStore: fido2CredentialStore, fido2UserInterfaceHelper: fido2UserInterfaceHelper, + keychainRepository: keychainRepository, pasteboardService: pasteboardService, stateService: stateService, timeProvider: timeProvider, @@ -920,6 +943,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le changeKdfService: changeKdfService, clientService: clientService, configService: configService, + devicePasskeyService: devicePasskeyService, environmentService: environmentService, errorReportBuilder: errorReportBuilder, errorReporter: errorReporter, diff --git a/BitwardenShared/Core/Platform/Services/Services.swift b/BitwardenShared/Core/Platform/Services/Services.swift index f1faff3148..1ab5e21d35 100644 --- a/BitwardenShared/Core/Platform/Services/Services.swift +++ b/BitwardenShared/Core/Platform/Services/Services.swift @@ -22,6 +22,7 @@ typealias Services = HasAPIService & HasClientService & HasConfigService & HasDeviceAPIService + & HasDevicePasskeyService & HasEnvironmentService & HasErrorReportBuilder & HasErrorReporter @@ -173,6 +174,13 @@ protocol HasDeviceAPIService { var deviceAPIService: DeviceAPIService { get } } +/// Protocol for an object that provides a `DevicePasskeyService`. +/// +protocol HasDevicePasskeyService { + /// The service used by the application to make device-related API requests. + var devicePasskeyService: DevicePasskeyService { get } +} + /// Protocol for an object that provides an `EnvironmentService`. /// protocol HasEnvironmentService { diff --git a/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift b/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift index abdf2a6e32..001a3f2cdb 100644 --- a/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift +++ b/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift @@ -15,6 +15,10 @@ class Fido2CredentialStoreService: Fido2CredentialStore { /// The service used by the application to report non-fatal errors. private let errorReporter: ErrorReporter + + private let keychainRepository: KeychainRepository + + private let stateService: StateService /// The service used to handle syncing vault data with the API private let syncService: SyncService @@ -31,11 +35,15 @@ class Fido2CredentialStoreService: Fido2CredentialStore { cipherService: CipherService, clientService: ClientService, errorReporter: ErrorReporter, + keychainRepository: KeychainRepository, + stateService: StateService, syncService: SyncService ) { self.cipherService = cipherService self.clientService = clientService self.errorReporter = errorReporter + self.keychainRepository = keychainRepository + self.stateService = stateService self.syncService = syncService } diff --git a/BitwardenShared/UI/Auth/AuthCoordinator.swift b/BitwardenShared/UI/Auth/AuthCoordinator.swift index 9d6e9e7434..be18ec46dc 100644 --- a/BitwardenShared/UI/Auth/AuthCoordinator.swift +++ b/BitwardenShared/UI/Auth/AuthCoordinator.swift @@ -56,6 +56,7 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt & HasClientService & HasConfigService & HasDeviceAPIService + & HasDevicePasskeyService & HasEnvironmentService & HasErrorAlertServices.ErrorAlertServices & HasErrorReporter diff --git a/BitwardenShared/UI/Auth/CompleteRegistration/CompleteRegistrationProcessor.swift b/BitwardenShared/UI/Auth/CompleteRegistration/CompleteRegistrationProcessor.swift index 55d4f44897..64c2698919 100644 --- a/BitwardenShared/UI/Auth/CompleteRegistration/CompleteRegistrationProcessor.swift +++ b/BitwardenShared/UI/Auth/CompleteRegistration/CompleteRegistrationProcessor.swift @@ -234,6 +234,7 @@ class CompleteRegistrationProcessor: StateProcessor< ) try await services.authRepository.unlockVaultWithPassword(password: state.passwordText) + try await services.authService.createDevicePasskey(masterPasswordHash: services.authService.hashPassword(password: state.passwordText, purpose: .serverAuthorization)) await coordinator.handleEvent(.didCompleteAuth) coordinator.navigate(to: .dismiss) diff --git a/BitwardenShared/UI/Auth/Login/LoginProcessor.swift b/BitwardenShared/UI/Auth/Login/LoginProcessor.swift index b99fce7887..d1c0c447f8 100644 --- a/BitwardenShared/UI/Auth/Login/LoginProcessor.swift +++ b/BitwardenShared/UI/Auth/Login/LoginProcessor.swift @@ -15,6 +15,7 @@ class LoginProcessor: StateProcessor { & HasAuthService & HasConfigService & HasDeviceAPIService + & HasDevicePasskeyService & HasErrorReporter & HasPolicyService @@ -105,6 +106,8 @@ class LoginProcessor: StateProcessor { // Unlock the vault. try await services.authRepository.unlockVaultWithPassword(password: state.masterPassword) + let masterPasswordHash = try await services.authService.hashPassword(password: state.masterPassword, purpose: .serverAuthorization) + try await services.devicePasskeyService.createDevicePasskey(masterPasswordHash: masterPasswordHash, overwrite: false) // Complete the login flow. coordinator.hideLoadingOverlay() await coordinator.handleEvent(.didCompleteAuth)