diff --git a/BitwardenShared/Core/Auth/Models/Domain/DeviceAuthKeyRecord.swift b/BitwardenShared/Core/Auth/Models/Domain/DeviceAuthKeyRecord.swift index 3f8c367f28..195c6c6406 100644 --- a/BitwardenShared/Core/Auth/Models/Domain/DeviceAuthKeyRecord.swift +++ b/BitwardenShared/Core/Auth/Models/Domain/DeviceAuthKeyRecord.swift @@ -25,7 +25,7 @@ public struct DeviceAuthKeyRecord: Codable, Equatable, Sendable { public let discoverable: EncString /// The HMAC secret, if the credential supports the hmac-secret extension. - public let hmacSecret: EncString? + public let hmacSecret: EncString /// The algorithm used for the key (e.g., "ES256" for ECDSA with SHA-256). public let keyAlgorithm: EncString @@ -43,16 +43,16 @@ public struct DeviceAuthKeyRecord: Codable, Equatable, Sendable { public let rpId: EncString /// The human-readable name of the relying party. - public let rpName: EncString? + public let rpName: EncString /// The user's human-readable display name. - public let userDisplayName: EncString? + public let userDisplayName: EncString /// The user identifier for the relying party. - public let userId: EncString? + public let userId: EncString /// The user's username or login name. - public let userName: EncString? + public let userName: EncString /// Creates a new device auth key record. /// @@ -80,16 +80,16 @@ public struct DeviceAuthKeyRecord: Codable, Equatable, Sendable { creationDate: Date, credentialId: EncString, discoverable: EncString, - hmacSecret: EncString?, + hmacSecret: EncString, keyAlgorithm: EncString, keyCurve: EncString, keyType: EncString, keyValue: EncString, rpId: EncString, - rpName: EncString?, - userDisplayName: EncString?, - userId: EncString?, - userName: EncString?, + rpName: EncString, + userDisplayName: EncString, + userId: EncString, + userName: EncString, ) { self.cipherId = cipherId self.cipherName = cipherName @@ -108,4 +108,122 @@ public struct DeviceAuthKeyRecord: Codable, Equatable, Sendable { self.userId = userId self.userName = userName } + + func toCipherView() -> CipherView { + CipherView( + id: cipherId, + organizationId: nil, + folderId: nil, + collectionIds: [], + key: nil, + name: cipherName, + notes: nil, + type: .login, + login: BitwardenSdk.LoginView( + username: nil, + password: nil, + passwordRevisionDate: nil, + uris: nil, + totp: nil, + autofillOnPageLoad: true, + fido2Credentials: [ + Fido2Credential( + credentialId: credentialId, + keyType: keyType, + keyAlgorithm: keyAlgorithm, + keyCurve: keyCurve, + keyValue: keyValue, + rpId: rpId, + userHandle: userId, + userName: userName, + counter: counter, + rpName: rpName, + userDisplayName: userDisplayName, + discoverable: discoverable, + // TODO(PM-26177): SDK will add this field + // hmacSecret: hmacSecret, + creationDate: creationDate + ), + ] + ), + identity: nil, + card: nil, + secureNote: nil, + sshKey: nil, + favorite: false, + reprompt: .none, + organizationUseTotp: false, + edit: false, + permissions: nil, + viewPassword: false, + localData: nil, + attachments: nil, + attachmentDecryptionFailures: nil, + fields: nil, + passwordHistory: nil, + creationDate: creationDate, + deletedDate: nil, + revisionDate: creationDate, + archivedDate: nil + ) + } + + func toCipher() -> Cipher { + Cipher( + id: cipherId, + organizationId: nil, + folderId: nil, + collectionIds: [], + key: nil, + name: cipherName, + notes: nil, + type: .login, + login: BitwardenSdk.Login( + username: nil, + password: nil, + passwordRevisionDate: nil, + uris: nil, + totp: nil, + autofillOnPageLoad: true, + fido2Credentials: [ + Fido2Credential( + credentialId: credentialId, + keyType: keyType, + keyAlgorithm: keyAlgorithm, + keyCurve: keyCurve, + keyValue: keyValue, + rpId: rpId, + userHandle: userId, + userName: userName, + counter: counter, + rpName: rpName, + userDisplayName: userDisplayName, + discoverable: discoverable, + // TODO(PM-26177): SDK will add this field + // hmacSecret: hmacSecret, + creationDate: creationDate + ), + ] + ), + identity: nil, + card: nil, + secureNote: nil, + sshKey: nil, + favorite: false, + reprompt: .none, + organizationUseTotp: false, + edit: false, + permissions: nil, + viewPassword: false, + localData: nil, + attachments: nil, + fields: nil, + passwordHistory: nil, + creationDate: creationDate, + deletedDate: nil, + revisionDate: creationDate, + archivedDate: nil, + data: nil, + ) + } } diff --git a/BitwardenShared/Core/Auth/Services/ClientFido2Service.swift b/BitwardenShared/Core/Auth/Services/ClientFido2Service.swift index 81b97d39f6..48cc2510ce 100644 --- a/BitwardenShared/Core/Auth/Services/ClientFido2Service.swift +++ b/BitwardenShared/Core/Auth/Services/ClientFido2Service.swift @@ -1,4 +1,5 @@ import BitwardenSdk +import CryptoKit import Foundation /// A protocol for a service that handles Fido2 tasks. This is similar to @@ -20,6 +21,17 @@ protocol ClientFido2Service: AnyObject { /// - Returns: An array of decrypted Fido2 credentials of type `Fido2CredentialAutofillView`. func decryptFido2AutofillCredentials(cipherView: CipherView) throws -> [Fido2CredentialAutofillView] + /// - Parameters: + /// - userInterface: `Fido2UserInterface` with necessary platform side logic related to UI. + /// - credentialStore: `Fido2CredentialStore` with necessary platform side logic related to credential storage. + /// - deviceKey: `SymmetricKey` used to encrypt data on the device. + /// - Returns: Returns the `ClientFido2Authenticator` to perform Fido2 authenticator tasks. + func deviceAuthenticator( + userInterface: Fido2UserInterface, + credentialStore: Fido2CredentialStore, + deviceKey: SymmetricKey, + ) throws -> ClientFido2AuthenticatorProtocol + /// Returns the `ClientFido2Authenticator` to perform Fido2 authenticator tasks. /// - Parameters: /// - userInterface: `Fido2UserInterface` with necessary platform side logic related to UI. @@ -45,6 +57,17 @@ extension ClientFido2: ClientFido2Service { try decryptFido2AutofillCredentials(cipherView: cipherView) } + func deviceAuthenticator( + userInterface: Fido2UserInterface, + credentialStore: Fido2CredentialStore, + deviceKey: SymmetricKey, + ) throws -> ClientFido2AuthenticatorProtocol { + let encryptionKey = deviceKey.withUnsafeBytes { bytes in + Data(Array(bytes)) + } + throw DeviceAuthKeyError.notImplemented + } + func vaultAuthenticator( userInterface: Fido2UserInterface, credentialStore: Fido2CredentialStore, diff --git a/BitwardenShared/Core/Auth/Services/DeviceAuthKeyAuthenticator.swift b/BitwardenShared/Core/Auth/Services/DeviceAuthKeyAuthenticator.swift new file mode 100644 index 0000000000..adae2badb0 --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/DeviceAuthKeyAuthenticator.swift @@ -0,0 +1,286 @@ +// MARK: - DeviceAuthKeyAuthenticator + +import os.log +import BitwardenSdk +import BitwardenKit +import CryptoKit +import Foundation + +// TODO(PM-26177): This is a temporary implementation for the device authenticator that will eventually move to the SDK. +class DeviceAuthKeyAuthenticator { + /// 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)!)) + + private let deviceAuthKeychainRepository: DeviceAuthKeychainRepository + private let userId: String + + init(deviceAuthKeychainRepository: DeviceAuthKeychainRepository, userId: String) { + self.deviceAuthKeychainRepository = deviceAuthKeychainRepository + self.userId = userId + } + + func makeCredential(request: MakeCredentialRequest) async throws -> (MakeCredentialResult, Data) { + // 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) + // https://www.w3.org/TR/webauthn-3/#sctn-attested-credential-data + let attestedCredentialData = aaguid + UInt16(credId.count).bytes + credId + cosePubKey + + let extInput: WebAuthnAuthenticationExtensionsClientInputs? = if let ext = request.extensions { + try? JSONDecoder.defaultDecoder.decode( + WebAuthnAuthenticationExtensionsClientInputs.self, + from: Data( + ext.utf8 + ) + ) } + else { + nil + } + + // 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. + guard let prfInputB64 = extInput?.prf?.eval?.first, + let prfInput = try? Data(base64urlEncoded: prfInputB64) else { + throw DeviceAuthKeyError.missingPrfInput + } + 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) + let prfSeedB64 = prfSeed.withUnsafeBytes { bytes in + Data(Array(bytes)).base64EncodedString() + } + let record = DeviceAuthKeyRecord( + cipherId: UUID().uuidString, + cipherName: "Device Auth Key", + counter: "0", + creationDate: Date(), + credentialId: result.credentialId.base64EncodedString(), + discoverable: "true", + hmacSecret: prfSeedB64, + keyAlgorithm: "-7", + keyCurve: "P-256", + keyType: "public-key", + keyValue: privKey.rawRepresentation.base64EncodedString(), + rpId: request.rp.id, + rpName: request.rp.name ?? request.rp.id, + userDisplayName: request.user.displayName, + userId: request.user.id.base64EncodedString(), + userName: request.user.name, + ) + let metadata = DeviceAuthKeyMetadata( + cipherId: record.cipherId, + credentialId: result.credentialId, + rpId: record.rpId, + userHandle: request.user.id, + userName: request.user.name, + ) + try await deviceAuthKeychainRepository.setDeviceAuthKey(record: record, metadata: metadata, userId: userId) + return (result, prfResult) + } + + /// Use device auth key to assert a credential, outputting PRF output. + func getAssertion(request: GetAssertionRequest) async throws -> (GetAssertionResult, Data?)? { + guard let record = try await deviceAuthKeychainRepository.getDeviceAuthKey(userId: userId) else { + throw DeviceAuthKeyError.missingOrInvalidKey + } + + // extensions + // prf + let prfInput = if let extJson = request.extensions, + let extJsonData = extJson.data(using: .utf8), + let extInputs = try? JSONDecoder.defaultDecoder.decode(WebAuthnAuthenticationExtensionsClientInputs.self, from: extJsonData), + let prfEval = extInputs.prf?.eval, + let prfInput = try Data(base64urlEncoded: prfEval.first) + { + prfInput + } else { + defaultLoginWithPrfSalt + } + + guard let prfSeedData = Data(base64Encoded: record.hmacSecret) else { + throw DeviceAuthKeyError.missingOrInvalidKey + } + let prfSeed = SymmetricKey(data: prfSeedData) + + + // TODO: this is unused, but appears in GetAssertionResult signature. + let fido2View = Fido2CredentialView( + credentialId: record.credentialId, + 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.credentialId, + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + rpId: record.rpId, + userHandle: nil, + userName: nil, + counter: "0", + rpName: nil, + userDisplayName: nil, + creationDate: record.creationDate, + ) + guard let credId = Data(base64Encoded: record.credentialId), + let userHandle = Data(base64Encoded: record.userId), + let privKeyB64 = Data(base64Encoded: record.keyValue) else { + throw DeviceAuthKeyError.missingOrInvalidKey + } + let privKey = try P256.Signing.PrivateKey(rawRepresentation: privKeyB64) + 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) + } + + + + // MARK: PRIVATE + 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.base64urlEncodedString())\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/DeviceAuthKeyService.swift b/BitwardenShared/Core/Auth/Services/DeviceAuthKeyService.swift index 5a62e4b705..a8d5d51122 100644 --- a/BitwardenShared/Core/Auth/Services/DeviceAuthKeyService.swift +++ b/BitwardenShared/Core/Auth/Services/DeviceAuthKeyService.swift @@ -1,6 +1,9 @@ +import os.log +import CryptoKit import BitwardenKit import BitwardenSdk import Foundation +import UIKit // MARK: - DeviceAuthKeyService @@ -35,7 +38,7 @@ protocol DeviceAuthKeyService { // sourcery: AutoMockable masterPasswordHash: String, overwrite: Bool, userId: String?, - ) async throws -> DeviceAuthKeyRecord + ) async throws /// Deletes the device auth key. /// @@ -88,7 +91,7 @@ extension DeviceAuthKeyService { func createDeviceAuthKey( masterPasswordHash: String, overwrite: Bool, - ) async throws -> DeviceAuthKeyRecord { + ) async throws { try await createDeviceAuthKey( masterPasswordHash: masterPasswordHash, overwrite: overwrite, @@ -118,9 +121,21 @@ struct DefaultDeviceAuthKeyService: DeviceAuthKeyService { /// The provider for the active account state. private let activeAccountStateProvider: ActiveAccountStateProvider + private let authAPIService: AuthAPIService + + private let clientService: ClientService + /// Repository for managing device auth keys in the keychain. private let deviceAuthKeychainRepository: DeviceAuthKeychainRepository + private let environmentService: EnvironmentService + + /// Repository for managing keys in the keychain. + private let keychainRepository: KeychainRepository + + /// Default PRF salt input to use if none is received from WebAuthn client. + private static let defaultLoginWithPrfSalt = Data(SHA256.hash(data: "passwordless-login".data(using: .utf8)!)) + // MARK: Initializers /// Creates a new instance of `DefaultDeviceAuthKeyService`. @@ -131,10 +146,18 @@ struct DefaultDeviceAuthKeyService: DeviceAuthKeyService { /// init( activeAccountStateProvider: ActiveAccountStateProvider, + authAPIService: AuthAPIService, + clientService: ClientService, deviceAuthKeychainRepository: DeviceAuthKeychainRepository, + environmentService: EnvironmentService, + keychainRepository: KeychainRepository, ) { self.activeAccountStateProvider = activeAccountStateProvider + self.authAPIService = authAPIService + self.clientService = clientService self.deviceAuthKeychainRepository = deviceAuthKeychainRepository + self.environmentService = environmentService + self.keychainRepository = keychainRepository } // MARK: Functions @@ -144,17 +167,108 @@ struct DefaultDeviceAuthKeyService: DeviceAuthKeyService { recordIdentifier: String, userId: String?, ) async throws -> GetAssertionResult? { - // TODO: PM-26177 to finish building out this stub - throw DeviceAuthKeyError.notImplemented + let userId = try await activeAccountStateProvider.userIdOrActive(userId) + guard let metadata = try? await getDeviceAuthKeyMetadata(userId: userId) else { + return nil + } + + guard request.rpId == environmentService.webVaultURL.domain else { + throw DeviceAuthKeyError.originMismatch + } + + guard metadata.cipherId == recordIdentifier else { + return nil + } + + guard try await deviceAuthKeychainRepository.getDeviceAuthKey(userId: userId) != nil else { + return nil + } + + guard let deviceKeyB64 = try await keychainRepository.getDeviceKey(userId: userId), + let deviceKeyData = Data(base64Encoded: deviceKeyB64) else { + throw DeviceAuthKeyError.missingOrInvalidKey + } + let deviceKey = SymmetricKey(data: deviceKeyData) + + let fido2Client = try await clientService.platform().fido2() + return try await fido2Client.deviceAuthenticator( + userInterface: DeviceAuthKeyUserInterface(), + credentialStore: DeviceAuthKeyCredentialStore( + clientService: clientService, + deviceAuthKeychainRepository: deviceAuthKeychainRepository, + deviceKey: deviceKey, + userId: userId, + ), + deviceKey: deviceKey, + ).getAssertion( + request: request, + ) } func createDeviceAuthKey( masterPasswordHash: String, overwrite: Bool, userId: String?, - ) async throws -> DeviceAuthKeyRecord { - // TODO: PM-26177 to finish building out this stub - throw DeviceAuthKeyError.notImplemented + ) async throws { + let userId = try await activeAccountStateProvider.userIdOrActive(userId) + let record = try? await deviceAuthKeychainRepository.getDeviceAuthKey(userId: userId) + guard record == nil || overwrite else { + return + } + let deviceKey = try await ensureDeviceKeyIsSet(userId: userId) + + // Create passkey from server options + let response = try await authAPIService.getWebAuthnCredentialCreationOptions( + SecretVerificationRequestModel(type: .masterPasswordHash(masterPasswordHash)) + ) + let options = response.options + guard options.rp.id == environmentService.webVaultURL.domain else { + throw DeviceAuthKeyError.originMismatch + } + let token = response.token + let (createdCredential, clientDataJson, prfResult) = try await createPasskey( + options: options, + userId: userId, + deviceKey: deviceKey + ) + + // Create unlock keyset from PRF value + // TODO(PM-26177): Extensions will be returned in an SDK update + // let prfResult = createdCredential.extensions.prf!.results!.first + let prfKeyResponse = try await { + let key = prfResult.withUnsafeBytes { bytes in + Data(Array(bytes)) + } + return try await clientService.crypto().makePrfUserKeySet(prf: key.base64EncodedString()) + }() + + // Register the credential keyset with the server. + // TODO: This only returns generic names like `iPhone` on real devices. + // 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 \(UIDevice.current.name)" + guard let clientDataJsonData = clientDataJson.data(using: .utf8) else { + throw DeviceAuthKeyError.serialization(reason: "Failed to serialize clientDataJson to data") + } + + let request = WebAuthnLoginSaveCredentialRequestModel( + deviceResponse: WebAuthnPublicKeyCredentialWithAttestationResponse( + id: createdCredential.credentialId.base64urlEncodedString(), + rawId: createdCredential.credentialId.base64urlEncodedString(), + response: WebAuthnAuthenticatorAttestationResponse( + attestationObject: createdCredential.attestationObject.base64urlEncodedString(), + clientDataJSON: clientDataJsonData.base64urlEncodedString() + ), + type: "public-key" + ), + encryptedPrivateKey: prfKeyResponse.encryptedDecapsulationKey, + encryptedPublicKey: prfKeyResponse.encryptedEncapsulationKey, + encryptedUserKey: prfKeyResponse.encapsulatedDownstreamKey, + name: clientName, + supportsPrf: true, + token: token + ) + try await authAPIService.saveWebAuthnCredential(request) } func deleteDeviceAuthKey( @@ -183,15 +297,313 @@ struct DefaultDeviceAuthKeyService: DeviceAuthKeyService { let resolvedUserId = try await activeAccountStateProvider.userIdOrActive(userId) return try await deviceAuthKeychainRepository.getDeviceAuthKey(userId: resolvedUserId) } + + private func createPasskey( + options: WebAuthnPublicKeyCredentialCreationOptions, + userId: String, + deviceKey: SymmetricKey + ) async throws -> (MakeCredentialResult, String, SymmetricKey) { + let excludeCredentials: [PublicKeyCredentialDescriptor]? = if options.excludeCredentials != nil { + // TODO: return early if exclude credentials matches + try options.excludeCredentials!.map { params in + try PublicKeyCredentialDescriptor( + ty: params.type, + id: Foundation.Data(base64urlEncoded: params.id)!, + transports: nil + ) + } + } else { nil } + + let credParams = options.pubKeyCredParams.map { params in + PublicKeyCredentialParameters(ty: params.type, alg: Int64(params.alg)) + } + + let origin = deriveWebOrigin() + // Manually serialize to JSON to make sure it's ordered and formatted according to the spec. + let clientDataJson = #"{"type":"webauthn.create","challenge":"\#(options.challenge)","origin":"\#(origin)"}"# + let clientDataHash = Data(SHA256.hash(data: clientDataJson.data(using: .utf8)!)) + /* + let credentialStore = DeviceAuthKeyCredentialStore( + clientService: clientService, + deviceAuthKeychainRepository: deviceAuthKeychainRepository, + deviceKey: deviceKey, + userId: userId + ) + let userInterface = DeviceAuthKeyUserInterface() + let authenticator = try await clientService + .platform() + .fido2() + .deviceAuthenticator(userInterface: userInterface, credentialStore: credentialStore, deviceKey: deviceKey) + */ + + let credRequest = try 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: #"{"prf":{"eval":{"first":"\#(DefaultDeviceAuthKeyService.defaultLoginWithPrfSalt)"}}}"#, + ) + let authenticator = DeviceAuthKeyAuthenticator(deviceAuthKeychainRepository: deviceAuthKeychainRepository, userId: userId) + let (createdCredential, prfResult) = try await authenticator.makeCredential(request: credRequest) + return (createdCredential, clientDataJson, SymmetricKey(data: prfResult)) + } + + 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 ensureDeviceKeyIsSet(userId: String) async throws -> SymmetricKey { + + if let deviceKeyB64 = try await keychainRepository.getDeviceKey(userId: userId) { + guard let deviceKeyData = Data(base64Encoded: deviceKeyB64) else { + throw DeviceAuthKeyError.missingOrInvalidKey + } + return SymmetricKey(data: deviceKeyData) + } else { + // Set if not found + let deviceKey = SymmetricKey(size: SymmetricKeySize(bitCount: 512)) + let key = deviceKey.withUnsafeBytes { bytes in + Data(Array(bytes)).base64EncodedString() + } + try await keychainRepository.setDeviceKey(key, userId: userId) + return deviceKey + } + } } // MARK: - DeviceAuthKeyError /// Errors that can occur when working with device auth keys. enum DeviceAuthKeyError: Error { + /// An invalid cipher was returned from the SDK. + case invalidCipher + /// The device auth key is missing or invalid. case missingOrInvalidKey + /// PRF extension input was not present in the request + case missingPrfInput + /// The requested functionality has not yet been implemented. case notImplemented + + /// The WebAuthn RP ID for the request cred did not match the expected origin. + case originMismatch + + /// Failed to serialize some data + case serialization(reason: String) +} + +// MARK: DeviceAuthKeyCredentialStore + +final internal class DeviceAuthKeyCredentialStore: Fido2CredentialStore { + let clientService: ClientService + let deviceAuthKeychainRepository: DeviceAuthKeychainRepository + let deviceKey: SymmetricKey + let userId: String + + init(clientService: ClientService, deviceAuthKeychainRepository: DeviceAuthKeychainRepository, deviceKey: SymmetricKey, userId: String) { + self.clientService = clientService + self.deviceAuthKeychainRepository = deviceAuthKeychainRepository + self.deviceKey = deviceKey + self.userId = userId + } + + func findCredentials(ids: [Data]?, ripId: String, userHandle: Data?) async throws -> [BitwardenSdk.CipherView] { + guard let record = try? await deviceAuthKeychainRepository.getDeviceAuthKey(userId: userId) else { + return [] + } + // record contains encrypted values; we need to decrypt them + let encryptedCipher = record.toCipher() + let cipherView = try await clientService.vault().ciphers().decrypt(cipher: encryptedCipher) + + let fido2CredentialAutofillViews = try await clientService.platform() + .fido2() + // TODO(PM-26177): This requires a SDK update. This will fail to decrypt until that is implemented. + // .decryptFido2AutofillCredentials(cipherView: cipherView, encryptionKey: deviceKey) + .decryptFido2AutofillCredentials(cipherView: cipherView) + + guard let fido2CredentialAutofillView = fido2CredentialAutofillViews[safeIndex: 0], + ripId == fido2CredentialAutofillView.rpId else { + return [] + } + + if let ids, + !ids.contains(fido2CredentialAutofillView.credentialId) { + return [] + } + + if let userHandle, + fido2CredentialAutofillView.userHandle != userHandle { + return [] + } + + return [cipherView] + } + + func allCredentials() async throws -> [BitwardenSdk.CipherListView] { + var results: [BitwardenSdk.CipherListView] = [] + guard let record = try? await deviceAuthKeychainRepository.getDeviceAuthKey(userId: userId) else { + return results + } + // record contains encrypted values; we need to decrypt them + let encryptedCipherView = record.toCipherView() + let decrypted = try await clientService.vault().ciphers() + .decryptFido2Credentials(cipherView: encryptedCipherView)[0] + // TODO(PM-26177): This requires a SDK update. This will fail to decrypt until that is implemented. + // .decryptFido2Credentials(cipherView: encryptedCipherView, encryptionKey: deviceKey)[0] + + let fido2View = Fido2CredentialListView( + credentialId: decrypted.credentialId, + rpId: decrypted.rpId, + userHandle: decrypted.userHandle, + userName: decrypted.userName, + userDisplayName: decrypted.userDisplayName, + counter: decrypted.counter + ) + let loginView = BitwardenSdk.LoginListView( + fido2Credentials: [fido2View], + hasFido2: true, + username: decrypted.userDisplayName, + totp: nil, + uris: nil + ) + + let cipherView = CipherListView( + id: record.cipherId, + organizationId: nil, + folderId: nil, + collectionIds: [], + key: nil, // setting the key to null means that it will be encrypted by the user key directly. + name: record.cipherName, + subtitle: "Vault passkey created by Bitwarden app", + type: CipherListViewType.login(loginView), + favorite: false, + reprompt: BitwardenSdk.CipherRepromptType.none, + organizationUseTotp: false, + edit: false, + permissions: nil, + viewPassword: false, + attachments: 0, + hasOldAttachments: false, + creationDate: record.creationDate, + deletedDate: nil, + revisionDate: record.creationDate, + archivedDate: nil, + copyableFields: [], + localData: nil + ) + results.append(cipherView) + return results + } + + func saveCredential(cred: BitwardenSdk.EncryptionContext) async throws { + guard let fido2cred = cred.cipher.login?.fido2Credentials?[safeIndex: 0], + let userHandle = fido2cred.userHandle, + let userName = fido2cred.userName, + let userDisplayName = fido2cred.userDisplayName else { + throw DeviceAuthKeyError.invalidCipher + } + let record = DeviceAuthKeyRecord( + cipherId: UUID().uuidString, + cipherName: cred.cipher.name, + counter: fido2cred.counter, + creationDate: cred.cipher.creationDate, + credentialId: fido2cred.credentialId, + discoverable: fido2cred.discoverable, + // TODO(PM-26177): This requires a SDK update. This device auth key will fail to register until this is done. + // hmacSecret: fido2cred.hmacSecret, + hmacSecret: "", + keyAlgorithm: fido2cred.keyAlgorithm, + keyCurve: fido2cred.keyCurve, + keyType: fido2cred.keyType, + keyValue: fido2cred.keyValue, + rpId: fido2cred.rpId, + rpName: fido2cred.rpName ?? fido2cred.rpId, + userDisplayName: userDisplayName, + userId: userHandle, + userName: userName, + ) + + // The record contains encrypted data, we need to decrypt it before storing metadata + let fido2CredentialAutofillViews = try await clientService.platform() + .fido2() + // TODO(PM-26177): This requires a SDK update. This device auth key will fail to decrypt for now. + // .decryptFido2AutofillCredentials(cipherView: record.toCipherView(), encryptionKey: deviceKey) + .decryptFido2AutofillCredentials(cipherView: record.toCipherView()) + + let fido2CredentialAutofillView = fido2CredentialAutofillViews[safeIndex: 0]! + let metadata = DeviceAuthKeyMetadata( + cipherId: fido2CredentialAutofillView.cipherId, + credentialId: fido2CredentialAutofillView.credentialId, + rpId: fido2CredentialAutofillView.rpId, + userHandle: fido2CredentialAutofillView.userHandle, + userName: fido2CredentialAutofillView.safeUsernameForUi, + ) + + try await deviceAuthKeychainRepository + .setDeviceAuthKey( + record: record, + metadata: metadata, + userId: cred.encryptedFor + ) + } +} + + +// MARK: DeviceAuthKeyUserInterface + +final class DeviceAuthKeyUserInterface: Fido2UserInterface { + func checkUser( + options: BitwardenSdk.CheckUserOptions, + hint: BitwardenSdk.UiHint + ) async throws -> BitwardenSdk.CheckUserResult { + // If we have gotten this far, we have decrypted the credential using Keychain verification methods, so we + // assume the user is present and verified. + BitwardenSdk.CheckUserResult(userPresent: true, userVerified: true) + } + + func pickCredentialForAuthentication( + availableCredentials: [BitwardenSdk.CipherView] + ) async throws -> BitwardenSdk.CipherViewWrapper { + guard availableCredentials.count == 1 else { + throw Fido2Error.invalidOperationError + } + return BitwardenSdk.CipherViewWrapper(cipher: availableCredentials[0]) + } + + func checkUserAndPickCredentialForCreation( + options: BitwardenSdk.CheckUserOptions, + newCredential: BitwardenSdk.Fido2CredentialNewView + ) async throws -> BitwardenSdk.CheckUserAndPickCredentialForCreationResult { + BitwardenSdk + .CheckUserAndPickCredentialForCreationResult( + cipher: CipherViewWrapper( + cipher: CipherView( + fido2CredentialNewView: newCredential, + timeProvider: CurrentTime() + ) + ), + checkUserResult: CheckUserResult( + userPresent: true, + userVerified: true + ) + ) + } + + func isVerificationEnabled() -> Bool { + true + } } +