Add stub for asserting device auth key

This commit is contained in:
Isaiah Inuwa 2026-01-27 14:01:02 -06:00
commit d24350aee8
No known key found for this signature in database
6 changed files with 439 additions and 46 deletions

View File

@ -18,4 +18,119 @@ struct DeviceAuthKeyRecord: Decodable, Encodable {
let discoverable: String
let hmacSecret: String?
let creationDate: DateTime
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,
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
)
}
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,
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,
)
}
}

View File

@ -1,20 +1,11 @@
import BitwardenSdk
import CryptoKit
import Foundation
/// A protocol for a service that handles Fido2 tasks. This is similar to
/// `ClientFido2Protocol` but returns the protocols so they can be mocked for testing.
///
protocol ClientFido2Service: AnyObject {
/// Returns the `ClientFido2Authenticator` to perform Fido2 authenticator tasks.
/// - Parameters:
/// - userInterface: `Fido2UserInterface` with necessary platform side logic related to UI.
/// - credentialStore: `Fido2CredentialStore` with necessary platform side logic related to credential storage.
/// - Returns: Returns the `ClientFido2Authenticator` to perform Fido2 authenticator tasks
func authenticator(
userInterface: Fido2UserInterface,
credentialStore: Fido2CredentialStore,
) -> ClientFido2AuthenticatorProtocol
/// Returns the `ClientFido2Client` to perform Fido2 client tasks.
/// - Parameters:
/// - userInterface: `Fido2UserInterface` with necessary platform side logic related to UI.
@ -29,18 +20,32 @@ protocol ClientFido2Service: AnyObject {
/// - Parameter cipherView: `CipherView` containing the Fido2 credentials to decrypt.
/// - 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.
/// - credentialStore: `Fido2CredentialStore` with necessary platform side logic related to credential storage.
/// - Returns: Returns the `ClientFido2Authenticator` to perform Fido2 authenticator tasks
func vaultAuthenticator(
userInterface: Fido2UserInterface,
credentialStore: Fido2CredentialStore,
) -> ClientFido2AuthenticatorProtocol
}
// MARK: ClientFido2
extension ClientFido2: ClientFido2Service {
func authenticator(
userInterface: Fido2UserInterface,
credentialStore: Fido2CredentialStore,
) -> ClientFido2AuthenticatorProtocol {
authenticator(userInterface: userInterface, credentialStore: credentialStore) as ClientFido2Authenticator
}
func client(
userInterface: Fido2UserInterface,
credentialStore: Fido2CredentialStore,
@ -51,4 +56,22 @@ extension ClientFido2: ClientFido2Service {
func decryptFido2AutofillCredentials(cipher cipherView: CipherView) throws -> [Fido2CredentialAutofillView] {
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,
) -> ClientFido2AuthenticatorProtocol {
authenticator(userInterface: userInterface, credentialStore: credentialStore) as ClientFido2Authenticator
}
}

View File

@ -1,4 +1,5 @@
import CryptoKit
import BitwardenKit
import Foundation
import os.log
@ -47,13 +48,16 @@ protocol DeviceAuthKeyService {
struct DefaultDeviceAuthKeyService: DeviceAuthKeyService {
// MARK: Properties
private let clientService: ClientService
private let keychainRepository: KeychainRepository
// MARK: Initializers
init(
clientService: ClientService,
keychainRepository: KeychainRepository,
) {
self.clientService = clientService
self.keychainRepository = keychainRepository
}
@ -72,7 +76,40 @@ struct DefaultDeviceAuthKeyService: DeviceAuthKeyService {
recordIdentifier: String,
userId: String
) async throws -> GetAssertionResult? {
throw DeviceAuthKeyError.notImplemented
guard let metadata = try? await getDeviceAuthKeyMetadata(userId: userId) else {
return nil
}
guard metadata.cipherId == recordIdentifier else {
return nil
}
guard let record = try await getDeviceAuthKeyRecord(
keychainRepository: keychainRepository,
userId: userId
) 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()
let result = try await fido2Client.deviceAuthenticator(
userInterface: DeviceAuthKeyUserInterface(),
credentialStore: DeviceAuthKeyCredentialStore(
clientService: clientService,
keychainRepository: keychainRepository,
userId: userId,
),
deviceKey: deviceKey
).getAssertion(
request: request
)
return result
}
func getDeviceAuthKeyMetadata(userId: String) async throws -> DeviceAuthKeyMetadata? {
@ -91,33 +128,251 @@ struct DefaultDeviceAuthKeyService: DeviceAuthKeyService {
Logger.application.debug("Metadata: \(json) })")
return metadata
}
// MARK: Private
/// Retrieve the device auth key secrets, if the record exists.
///
/// Before calling, vault must be unlocked to wrap user encryption key.
/// - Parameters:
/// - userId: User ID for the account to fetch.
private func getDeviceAuthKeyRecord(userId: String) async throws -> DeviceAuthKeyRecord? {
guard let json = try? await keychainRepository.getDeviceAuthKey(userId: userId) else {
return nil
}
guard let jsonData = json.data(using: .utf8) else {
return nil
}
let record: DeviceAuthKeyRecord = try JSONDecoder.defaultDecoder.decode(
DeviceAuthKeyRecord.self,
from: jsonData
)
Logger.application.debug("Record: \(json) })")
return record
}
}
enum DeviceAuthKeyError: Error {
case notImplemented
case missingOrInvalidKey
}
// MARK: DeviceAuthKeyCredentialStore
final internal class DeviceAuthKeyCredentialStore: Fido2CredentialStore {
let clientService: ClientService
let keychainRepository: KeychainRepository
let userId: String
init(clientService: ClientService, keychainRepository: KeychainRepository, userId: String) {
self.clientService = clientService
self.keychainRepository = keychainRepository
self.userId = userId
}
func findCredentials(ids: [Data]?, ripId: String, userHandle: Data?) async throws -> [BitwardenSdk.CipherView] {
guard let record = try? await getDeviceAuthKeyRecord(
keychainRepository: keychainRepository,
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 deviceKey = try await getDeviceKey()
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] = []
if let record = try? await getDeviceAuthKeyRecord(keychainRepository: keychainRepository, userId: userId) {
// record contains encrypted values; we need to decrypt them
let encryptedCipherView = record.toCipherView()
let deviceKey = try await getDeviceKey()
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 {
if let fido2cred = cred.cipher.login?.fido2Credentials?[safeIndex: 0] {
let record = DeviceAuthKeyRecord(
cipherId: UUID().uuidString,
cipherName: cred.cipher.name,
credentialId: fido2cred.credentialId,
keyType: fido2cred.keyType,
keyAlgorithm: fido2cred.keyAlgorithm,
keyCurve: fido2cred.keyCurve,
keyValue: fido2cred.keyValue,
rpId: fido2cred.rpId,
rpName: fido2cred.rpName,
userId: fido2cred.userHandle,
userName: fido2cred.userName,
userDisplayName: fido2cred.userDisplayName,
counter: fido2cred.counter,
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: "",
creationDate: cred.cipher.creationDate
)
let recordJson = try String(data: JSONEncoder.defaultEncoder.encode(record), encoding: .utf8)!
// The record contains encrypted data, we need to decrypt it before storing metadata
let deviceKey = try await SymmetricKey(
data: Data(
base64Encoded: keychainRepository.getDeviceKey(
userId: userId
)!
)!
)
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(
credentialId: fido2CredentialAutofillView.credentialId.base64EncodedString(),
cipherId: fido2CredentialAutofillView.cipherId,
rpId: fido2CredentialAutofillView.rpId,
userName: fido2CredentialAutofillView.safeUsernameForUi,
userHandle: fido2CredentialAutofillView.userHandle.base64EncodedString(),
)
let metadataJson = try String(data: JSONEncoder.defaultEncoder.encode(metadata), encoding: .utf8)!
try await keychainRepository
.setDeviceAuthKey(
recordJson: recordJson,
metadataJson: metadataJson,
userId: cred.encryptedFor
)
}
}
private func getDeviceKey() async throws -> SymmetricKey {
guard let deviceKeyB64 = try await keychainRepository.getDeviceKey(userId: userId),
let deviceKeyData = Data(base64Encoded: deviceKeyB64) else {
throw DeviceAuthKeyError.missingOrInvalidKey
}
return SymmetricKey(data: deviceKeyData)
}
}
// 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
}
}
// MARK: Private
/// Retrieve the device auth key secrets, if the record exists.
///
/// - Parameters:
/// - keychainRepository: The repository for keychain items.
/// - userId: User ID for the account to fetch.
fileprivate func getDeviceAuthKeyRecord(keychainRepository: KeychainRepository, userId: String) async throws -> DeviceAuthKeyRecord? {
guard let json = try? await keychainRepository.getDeviceAuthKey(userId: userId) else {
return nil
}
guard let jsonData = json.data(using: .utf8) else {
return nil
}
let record: DeviceAuthKeyRecord = try JSONDecoder.defaultDecoder.decode(
DeviceAuthKeyRecord.self,
from: jsonData
)
Logger.application.debug("Record: \(json) })")
return record
}

View File

@ -10,7 +10,7 @@ class MockClientFido2Service: ClientFido2Service {
InvocationMockerWithThrowingResult<CipherView, [Fido2CredentialAutofillView]>()
.withResult([.fixture()])
func authenticator(
func vaultAuthenticator(
userInterface: any BitwardenSdk.Fido2UserInterface,
credentialStore: any BitwardenSdk.Fido2CredentialStore,
) -> BitwardenSdk.ClientFido2AuthenticatorProtocol {

View File

@ -333,7 +333,7 @@ class DefaultAutofillCredentialService {
}
let fido2Identities = try await clientService.platform().fido2()
.authenticator(
.vaultAuthenticator(
userInterface: fido2UserInterfaceHelper,
credentialStore: fido2CredentialStore,
)
@ -548,7 +548,7 @@ extension DefaultAutofillCredentialService: AutofillCredentialService {
identities.append(contentsOf: newIdentities)
let fido2Identities = try await clientService.platform().fido2()
.authenticator(
.vaultAuthenticator(
userInterface: fido2UserInterfaceHelper,
credentialStore: fido2CredentialStore,
)
@ -587,7 +587,7 @@ extension DefaultAutofillCredentialService: AutofillCredentialService {
do {
let assertionResult = try await clientService.platform().fido2()
.authenticator(
.vaultAuthenticator(
userInterface: fido2UserInterfaceHelper,
credentialStore: fido2CredentialStore,
)

View File

@ -674,7 +674,7 @@ extension VaultAutofillListProcessor {
userVerificationPreference: userVerificationPreference,
)
let createdCredential = try await services.clientService.platform().fido2()
.authenticator(
.vaultAuthenticator(
userInterface: services.fido2UserInterfaceHelper,
credentialStore: services.fido2CredentialStore,
)