1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 13:53:34 +00:00

Improve MacOS Syncing

This changes the behaviour to react to logoff, but not to account locks. It also adds better error handling on the native side.
This commit is contained in:
Anders Åberg
2025-04-14 15:36:14 +02:00
parent 8d598c33ed
commit 6426dc51e6
3 changed files with 86 additions and 38 deletions

View File

@@ -14,7 +14,9 @@ void runSync(void* context, NSDictionary *params) {
// Map credentials to ASPasswordCredential objects // Map credentials to ASPasswordCredential objects
NSMutableArray *mappedCredentials = [NSMutableArray arrayWithCapacity:credentials.count]; NSMutableArray *mappedCredentials = [NSMutableArray arrayWithCapacity:credentials.count];
for (NSDictionary *credential in credentials) { for (NSDictionary *credential in credentials) {
@try {
NSString *type = credential[@"type"]; NSString *type = credential[@"type"];
if ([type isEqualToString:@"password"]) { if ([type isEqualToString:@"password"]) {
@@ -22,33 +24,50 @@ void runSync(void* context, NSDictionary *params) {
NSString *uri = credential[@"uri"]; NSString *uri = credential[@"uri"];
NSString *username = credential[@"username"]; NSString *username = credential[@"username"];
ASCredentialServiceIdentifier *serviceId = [[ASCredentialServiceIdentifier alloc] // Skip credentials with null username
initWithIdentifier:uri type:ASCredentialServiceIdentifierTypeURL]; if ([username isKindOfClass:[NSNull class]] || username.length == 0) {
ASPasswordCredentialIdentity *credential = [[ASPasswordCredentialIdentity alloc] NSLog(@"Skipping credential, username is empty: %@", credential);
initWithServiceIdentifier:serviceId user:username recordIdentifier:cipherId]; continue;
[mappedCredentials addObject:credential];
} }
if (@available(macos 14, *)) { ASCredentialServiceIdentifier *serviceId = [[ASCredentialServiceIdentifier alloc]
initWithIdentifier:uri type:ASCredentialServiceIdentifierTypeURL];
ASPasswordCredentialIdentity *passwordIdentity = [[ASPasswordCredentialIdentity alloc]
initWithServiceIdentifier:serviceId user:username recordIdentifier:cipherId];
[mappedCredentials addObject:passwordIdentity];
}
else if (@available(macos 14, *)) {
if ([type isEqualToString:@"fido2"]) { if ([type isEqualToString:@"fido2"]) {
NSString *cipherId = credential[@"cipherId"]; NSString *cipherId = credential[@"cipherId"];
NSString *rpId = credential[@"rpId"]; NSString *rpId = credential[@"rpId"];
NSString *userName = credential[@"userName"]; NSString *userName = credential[@"userName"];
// Skip credentials with null userName
if ([userName isKindOfClass:[NSNull class]] || userName.length == 0) {
NSLog(@"Skipping credential, username is empty: %@", credential);
continue;
}
NSData *credentialId = decodeBase64URL(credential[@"credentialId"]); NSData *credentialId = decodeBase64URL(credential[@"credentialId"]);
NSData *userHandle = decodeBase64URL(credential[@"userHandle"]); NSData *userHandle = decodeBase64URL(credential[@"userHandle"]);
Class passkeyCredentialIdentityClass = NSClassFromString(@"ASPasskeyCredentialIdentity"); Class passkeyCredentialIdentityClass = NSClassFromString(@"ASPasskeyCredentialIdentity");
id credential = [[passkeyCredentialIdentityClass alloc] id passkeyIdentity = [[passkeyCredentialIdentityClass alloc]
initWithRelyingPartyIdentifier:rpId initWithRelyingPartyIdentifier:rpId
userName:userName userName:userName
credentialID:credentialId credentialID:credentialId
userHandle:userHandle userHandle:userHandle
recordIdentifier:cipherId]; recordIdentifier:cipherId];
[mappedCredentials addObject:credential]; [mappedCredentials addObject:passkeyIdentity];
} }
} }
} @catch (NSException *exception) {
// Silently skip any credential that causes an exception
NSLog(@"ERROR: Exception processing credential: %@ - %@", exception.name, exception.reason);
continue;
}
} }
[ASCredentialIdentityStore.sharedStore replaceCredentialIdentityEntries:mappedCredentials [ASCredentialIdentityStore.sharedStore replaceCredentialIdentityEntries:mappedCredentials

View File

@@ -332,6 +332,7 @@ const safeProviders: SafeProvider[] = [
ConfigService, ConfigService,
Fido2AuthenticatorServiceAbstraction, Fido2AuthenticatorServiceAbstraction,
AccountService, AccountService,
AuthService,
], ],
}), }),
safeProvider({ safeProvider({

View File

@@ -2,6 +2,8 @@ import { Injectable, OnDestroy } from "@angular/core";
import { autofill } from "desktop_native/napi"; import { autofill } from "desktop_native/napi";
import { import {
Subject, Subject,
combineLatest,
debounceTime,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
firstValueFrom, firstValueFrom,
@@ -12,6 +14,8 @@ import {
} from "rxjs"; } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service"; import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
@@ -52,6 +56,7 @@ export class DesktopAutofillService implements OnDestroy {
private configService: ConfigService, private configService: ConfigService,
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<NativeWindowObject>, private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<NativeWindowObject>,
private accountService: AccountService, private accountService: AccountService,
private authService: AuthService,
) {} ) {}
async init() { async init() {
@@ -59,21 +64,39 @@ export class DesktopAutofillService implements OnDestroy {
.getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync) .getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync)
.pipe( .pipe(
distinctUntilChanged(), distinctUntilChanged(),
switchMap((enabled) => { //filter((enabled) => enabled === true), // Only proceed if feature is enabled
return this.accountService.activeAccount$.pipe( switchMap(() => {
return combineLatest([
this.accountService.activeAccount$.pipe(
map((account) => account?.id), map((account) => account?.id),
filter((userId): userId is UserId => userId != null), filter((userId): userId is UserId => userId != null),
switchMap((userId) => this.cipherService.cipherViews$(userId)), ),
this.authService.activeAccountStatus$,
]).pipe(
// Only proceed when the vault is unlocked
filter(([, status]) => status === AuthenticationStatus.Unlocked),
// Then get cipher views
switchMap(([userId]) => this.cipherService.cipherViews$(userId)),
); );
}), }),
// TODO: This will unset all the autofill credentials on the OS debounceTime(100), // just a precaution to not spam the sync if there are multiple changes (we typically observe a null change)
// when the account locks. We should instead explicilty clear the credentials // No filter for empty arrays here - we want to sync even if there are 0 items
// when the user logs out. Maybe by subscribing to the encrypted ciphers observable instead. filter((cipherViewMap) => cipherViewMap !== null),
mergeMap((cipherViewMap) => this.sync(Object.values(cipherViewMap ?? []))), mergeMap((cipherViewMap) => this.sync(Object.values(cipherViewMap ?? []))),
takeUntil(this.destroy$), takeUntil(this.destroy$),
) )
.subscribe(); .subscribe();
// Listen for sign out to clear credentials?
this.authService.activeAccountStatus$
.pipe(
filter((status) => status === AuthenticationStatus.LoggedOut),
mergeMap(() => this.sync([])), // sync an empty array
takeUntil(this.destroy$),
)
.subscribe();
this.listenIpc(); this.listenIpc();
} }
@@ -131,6 +154,11 @@ export class DesktopAutofillService implements OnDestroy {
})); }));
} }
this.logService.warning("Syncing autofill credentials", {
fido2Credentials,
passwordCredentials,
});
const syncResult = await ipc.autofill.runCommand<NativeAutofillSyncCommand>({ const syncResult = await ipc.autofill.runCommand<NativeAutofillSyncCommand>({
namespace: "autofill", namespace: "autofill",
command: "sync", command: "sync",