This commit is contained in:
Leendert de Borst
2025-10-16 11:24:04 +02:00
parent 90e846674e
commit 7c64e656ff
2 changed files with 92 additions and 73 deletions

View File

@@ -19,77 +19,29 @@ public class CredentialIdentityStore {
/// - credentials: The credentials to register
/// - passkeyOnly: If true, only register passkey identities (skip passwords). Default is false.
public func saveCredentialIdentities(_ credentials: [Credential], passkeyOnly: Bool = false) async throws {
// TODO: improve implementation to better separate password and passkey identities.
// As if a record has both a password and passkey, it will not only show up as a password identity.
var allIdentities: [ASCredentialIdentity] = []
// Create password identities (skip if passkeyOnly mode)
let passwordIdentities: [ASPasswordCredentialIdentity] = passkeyOnly ? [] : credentials.compactMap { credential in
guard let urlString = credential.service.url,
let url = URL(string: urlString),
let host = url.host else {
return nil
}
// Use the same logic as the UI for determining the identifier
let identifier = usernameOrEmail(credential: credential)
guard !identifier.isEmpty else {
return nil // Skip credentials with no identifier
}
guard let password = credential.password, !password.value.isEmpty else {
return nil // Skip credentials with no password (e.g. applies when this record is a passkey)
}
let effectiveDomain = Self.effectiveDomain(from: host)
return ASPasswordCredentialIdentity(
serviceIdentifier: ASCredentialServiceIdentifier(identifier: effectiveDomain, type: .domain),
user: identifier,
recordIdentifier: credential.id.uuidString
)
}
allIdentities.append(contentsOf: passwordIdentities)
// Create passkey identities
let passkeyIdentities: [ASPasskeyCredentialIdentity] = credentials.flatMap { credential -> [ASPasskeyCredentialIdentity] in
guard let passkeys = credential.passkeys else {
return []
}
return passkeys.filter { !($0.isDeleted) }
.compactMap { passkey in
// Get the userName for display in iOS AutoFill UI
// Passkeys don't store userName in the database, so we use the credential's username or email
let userName = passkey.userName ?? usernameOrEmail(credential: credential)
// Convert passkey.Id to bytes for credentialID
let credentialId = try? PasskeyHelper.guidToBytes(passkey.id.uuidString)
// For passkeys, we use the rpId from the passkey itself, not the service URL
// This is because passkeys are tied to the RP ID, which may differ from the service URL
return ASPasskeyCredentialIdentity(
relyingPartyIdentifier: passkey.rpId,
userName: userName,
credentialID: credentialId ?? Data(), // WebAuthn credential ID (16-byte GUID)
userHandle: passkey.userHandle ?? Data(),
recordIdentifier: passkey.id.uuidString
)
}
}
allIdentities.append(contentsOf: passkeyIdentities)
guard !allIdentities.isEmpty else {
return
}
let state = await storeState()
guard state.isEnabled else {
return
}
// Only save passwords into autofill store on iOS 26+, as it works buggy on older iOS versions.
if #available(iOS 26.0, *) {
if !passkeyOnly {
let passwordIdentities = createPasswordIdentities(from: credentials)
allIdentities.append(contentsOf: passwordIdentities)
}
}
// Create passkey identities
let passkeyIdentities = createPasskeyIdentities(from: credentials)
allIdentities.append(contentsOf: passkeyIdentities)
guard !allIdentities.isEmpty else {
return
}
do {
// First, remove all existing credential identities to ensure a clean replacement
try await store.removeAllCredentialIdentities()
@@ -116,7 +68,7 @@ public class CredentialIdentityStore {
)
// Use the same logic as the UI for determining the identifier
let identifier = usernameOrEmail(credential: credential)
let identifier = Helpers.usernameOrEmail(credential: credential)
guard !identifier.isEmpty else {
return nil // Skip credentials with no identifier
}
@@ -144,15 +96,62 @@ public class CredentialIdentityStore {
guard parts.count >= 2 else { return host }
return parts.suffix(2).joined(separator: ".")
}
}
/// Returns username or email depending on if they are not null
public func usernameOrEmail(credential: Credential) -> String {
if let username = credential.username, !username.isEmpty {
return username
/// Create password credential identities from credentials
private func createPasswordIdentities(from credentials: [Credential]) -> [ASPasswordCredentialIdentity] {
return credentials.compactMap { credential in
guard let urlString = credential.service.url,
let url = URL(string: urlString),
let host = url.host else {
return nil
}
// Use the same logic as the UI for determining the identifier
let identifier = Helpers.usernameOrEmail(credential: credential)
guard !identifier.isEmpty else {
return nil // Skip credentials with no identifier
}
guard let password = credential.password, !password.value.isEmpty else {
return nil // Skip credentials with no password (e.g. applies when this record is a passkey)
}
let effectiveDomain = Self.effectiveDomain(from: host)
return ASPasswordCredentialIdentity(
serviceIdentifier: ASCredentialServiceIdentifier(identifier: effectiveDomain, type: .domain),
user: identifier,
recordIdentifier: credential.id.uuidString
)
}
}
if let email = credential.alias?.email, !email.isEmpty {
return email
/// Create passkey credential identities from credentials
private func createPasskeyIdentities(from credentials: [Credential]) -> [ASPasskeyCredentialIdentity] {
return credentials.flatMap { credential -> [ASPasskeyCredentialIdentity] in
guard let passkeys = credential.passkeys else {
return []
}
return passkeys.filter { !($0.isDeleted) }
.compactMap { passkey in
// Get the userName for display in iOS AutoFill UI
// Passkeys don't store userName in the database, so we use the credential's username or email
let userName = passkey.userName ?? Helpers.usernameOrEmail(credential: credential)
// Convert passkey.Id to bytes for credentialID
let credentialId = try? PasskeyHelper.guidToBytes(passkey.id.uuidString)
// For passkeys, we use the rpId from the passkey itself, not the service URL
// This is because passkeys are tied to the RP ID, which may differ from the service URL
return ASPasskeyCredentialIdentity(
relyingPartyIdentifier: passkey.rpId,
userName: userName,
credentialID: credentialId ?? Data(), // WebAuthn credential ID (16-byte GUID)
userHandle: passkey.userHandle ?? Data(),
recordIdentifier: passkey.id.uuidString
)
}
}
}
return ""
}

View File

@@ -0,0 +1,20 @@
import Foundation
import VaultModels
/**
* Helpers
* -------------------------
* Generic utility class for general helper functions.
*/
public class Helpers {
/// Returns username or email depending on if they are not null
public static func usernameOrEmail(credential: Credential) -> String {
if let username = credential.username, !username.isEmpty {
return username
}
if let email = credential.alias?.email, !email.isEmpty {
return email
}
return ""
}
}