1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 05:43:41 +00:00

[PM-9035] desktop build logic to provide credentials to os on sync (#10181)

* feat: scaffold desktop_objc

* feat: rename fido2 to autofill

* feat: scaffold electron autofill

* feat: auto call hello world on init

* feat: scaffold call to basic objc function

* feat: simple log that checks if autofill is enabled

* feat: adding some availability guards

* feat: scaffold services and allow calls from inspector

* feat: create custom type for returning strings across rust/objc boundary

* chore: clean up comments

* feat: enable ARC

* feat: add util function `c_string_to_nsstring`

* chore: refactor and rename to `run_command`

* feat: add try-catch around command execution

* feat: properly implement command calling

Add static typing. Add proper error handling.

* feat: add autoreleasepool to avoid memory leaks

* chore: change objc names to camelCase

* fix: error returning

* feat: extract some helper functions into utils class

* feat: scaffold status command

* feat: implement status command

* feat: implement password credential mapping

* wip: implement sync command

This crashes because we are not properly handling the fact that `saveCredentialIdentities` uses callbacks, resulting in a race condition where we try to access a variable (result) that has already gotten dealloc'd.

* feat: first version of callback

* feat: make run_command async

* feat: functioning callback returns

* chore: refactor to make objc code easier to read and use

* feat: refactor everything to use new callback return method

* feat: re-implement status command with callback

* fix: warning about CommandContext not being FFI-safe

* feat: implement sync command using callbacks

* feat: implement manual password credential sync

* feat: add auto syncing

* docs: add todo

* feat: add support for passkeys

* chore: move desktop autofill service to init service

* feat: auto-add all .m files to builder

* fix: native build on unix and windows

* fix: unused compiler warnings

* fix: napi type exports

* feat: add corresponding dist command

* feat: comment signing profile until we fix signing

* fix: build breaking on non-macOS platforms

* chore: cargo lock update

* chore: revert accidental version change

* feat: put sync behind feature flag

* chore: put files in autofill folder

* fix: obj-c code not recompiling on changes

* feat: add `namespace` to commands

* fix: linting complaining about flag

* feat: add autofill as owner of their objc code

* chore: make autofill owner of run_command in core crate

* fix: re-add napi annotation

* fix: remove dev bypass
This commit is contained in:
Andreas Coroiu
2024-12-06 16:31:30 +01:00
committed by GitHub
parent f95cc7b82c
commit f16bfa4cd2
41 changed files with 1099 additions and 112 deletions

View File

@@ -0,0 +1,21 @@
[package]
edition = "2021"
license = "GPL-3.0"
name = "desktop_objc"
version = "0.0.0"
publish = false
[features]
default = []
[dependencies]
anyhow = "=1.0.93"
thiserror = "=1.0.69"
tokio = "1.39.1"
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "=0.9.4"
[build-dependencies]
cc = "1.0.104"
glob = "0.3.1"

View File

@@ -0,0 +1,22 @@
use glob::glob;
#[cfg(target_os = "macos")]
fn main() {
let mut builder = cc::Build::new();
// Auto compile all .m files in the src/native directory
for entry in glob("src/native/**/*.m").expect("Failed to read glob pattern") {
let path = entry.expect("Failed to read glob entry");
builder.file(path.clone());
println!("cargo::rerun-if-changed={}", path.display());
}
builder
.flag("-fobjc-arc") // Enable Auto Reference Counting (ARC)
.compile("autofill");
}
#[cfg(not(target_os = "macos"))]
fn main() {
// Crate is only supported on macOS
}

View File

@@ -0,0 +1,124 @@
#![cfg(target_os = "macos")]
use std::{
ffi::{c_char, CStr, CString},
os::raw::c_void,
};
use anyhow::{Context, Result};
#[repr(C)]
pub struct ObjCString {
value: *const c_char,
size: usize,
}
#[repr(C)]
pub struct CommandContext {
tx: Option<tokio::sync::oneshot::Sender<String>>,
}
impl CommandContext {
pub fn new() -> (Self, tokio::sync::oneshot::Receiver<String>) {
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
(CommandContext { tx: Some(tx) }, rx)
}
pub fn send(&mut self, value: String) -> Result<()> {
let tx = self.tx.take().context(
"Failed to take Sender from CommandContext. Has this context already returned once?",
)?;
tx.send(value).map_err(|_| {
anyhow::anyhow!("Failed to send ObjCString from CommandContext to Rust code")
})?;
Ok(())
}
pub fn as_ptr(&mut self) -> *mut c_void {
self as *mut Self as *mut c_void
}
}
impl TryFrom<ObjCString> for String {
type Error = anyhow::Error;
fn try_from(value: ObjCString) -> Result<Self> {
let c_str = unsafe { CStr::from_ptr(value.value) };
let str = c_str
.to_str()
.context("Failed to convert ObjC output string to &str for use in Rust")?;
Ok(str.to_owned())
}
}
impl Drop for ObjCString {
fn drop(&mut self) {
unsafe {
objc::freeObjCString(self);
}
}
}
mod objc {
use std::os::raw::c_void;
use super::*;
extern "C" {
pub fn runCommand(context: *mut c_void, value: *const c_char);
pub fn freeObjCString(value: &ObjCString);
}
/// This function is called from the ObjC code to return the output of the command
#[no_mangle]
pub extern "C" fn commandReturn(context: &mut CommandContext, value: ObjCString) -> bool {
let value: String = match value.try_into() {
Ok(value) => value,
Err(e) => {
println!(
"Error: Failed to convert ObjCString to Rust string during commandReturn: {}",
e
);
return false;
}
};
match context.send(value) {
Ok(_) => 0,
Err(e) => {
println!(
"Error: Failed to return ObjCString from ObjC code to Rust code: {}",
e
);
return false;
}
};
return true;
}
}
pub async fn run_command(input: String) -> Result<String> {
// Convert input to type that can be passed to ObjC code
let c_input = CString::new(input)
.context("Failed to convert Rust input string to a CString for use in call to ObjC code")?;
let (mut context, rx) = CommandContext::new();
// Call ObjC code
unsafe { objc::runCommand(context.as_ptr(), c_input.as_ptr()) };
// Convert output from ObjC code to Rust string
let objc_output = rx.await?.try_into()?;
// Convert output from ObjC code to Rust string
// let objc_output = output.try_into()?;
Ok(objc_output)
}

View File

@@ -0,0 +1,2 @@
CompileFlags:
Add: [-fobjc-arc]

View File

@@ -0,0 +1,8 @@
#ifndef STATUS_H
#define STATUS_H
#import <Foundation/Foundation.h>
void status(void *context, NSDictionary *params);
#endif

View File

@@ -0,0 +1,57 @@
#import <Foundation/Foundation.h>
#import <AuthenticationServices/ASCredentialIdentityStore.h>
#import <AuthenticationServices/ASCredentialIdentityStoreState.h>
#import "../../interop.h"
#import "status.h"
void storeState(void (^callback)(ASCredentialIdentityStoreState*)) {
if (@available(macos 11, *)) {
ASCredentialIdentityStore *store = [ASCredentialIdentityStore sharedStore];
[store getCredentialIdentityStoreStateWithCompletion:^(ASCredentialIdentityStoreState * _Nonnull state) {
callback(state);
}];
} else {
callback(nil);
}
}
BOOL fido2Supported() {
if (@available(macos 14, *)) {
return YES;
} else {
return NO;
}
}
BOOL passwordSupported() {
if (@available(macos 11, *)) {
return YES;
} else {
return NO;
}
}
void status(void* context, __attribute__((unused)) NSDictionary *params) {
storeState(^(ASCredentialIdentityStoreState *state) {
BOOL enabled = NO;
BOOL supportsIncremental = NO;
if (state != nil) {
enabled = state.isEnabled;
supportsIncremental = state.supportsIncrementalUpdates;
}
_return(context,
_success(@{
@"support": @{
@"fido2": @(fido2Supported()),
@"password": @(passwordSupported()),
@"incrementalUpdates": @(supportsIncremental),
},
@"state": @{
@"enabled": @(enabled),
}
})
);
});
}

View File

@@ -0,0 +1,8 @@
#ifndef SYNC_H
#define SYNC_H
#import <Foundation/Foundation.h>
void runSync(void *context, NSDictionary *params);
#endif

View File

@@ -0,0 +1,59 @@
#import <Foundation/Foundation.h>
#import <AuthenticationServices/ASCredentialIdentityStore.h>
#import <AuthenticationServices/ASCredentialIdentityStoreState.h>
#import <AuthenticationServices/ASCredentialServiceIdentifier.h>
#import <AuthenticationServices/ASPasswordCredentialIdentity.h>
#import <AuthenticationServices/ASPasskeyCredentialIdentity.h>
#import "../../utils.h"
#import "../../interop.h"
#import "sync.h"
// 'run' is added to the name because it clashes with internal macOS function
void runSync(void* context, NSDictionary *params) {
NSArray *credentials = params[@"credentials"];
// Map credentials to ASPasswordCredential objects
NSMutableArray *mappedCredentials = [NSMutableArray arrayWithCapacity:credentials.count];
for (NSDictionary *credential in credentials) {
NSString *type = credential[@"type"];
if ([type isEqualToString:@"password"]) {
NSString *cipherId = credential[@"cipherId"];
NSString *uri = credential[@"uri"];
NSString *username = credential[@"username"];
ASCredentialServiceIdentifier *serviceId = [[ASCredentialServiceIdentifier alloc]
initWithIdentifier:uri type:ASCredentialServiceIdentifierTypeURL];
ASPasswordCredentialIdentity *credential = [[ASPasswordCredentialIdentity alloc]
initWithServiceIdentifier:serviceId user:username recordIdentifier:cipherId];
[mappedCredentials addObject:credential];
}
if ([type isEqualToString:@"fido2"]) {
NSString *cipherId = credential[@"cipherId"];
NSString *rpId = credential[@"rpId"];
NSString *userName = credential[@"userName"];
NSData *credentialId = decodeBase64URL(credential[@"credentialId"]);
NSData *userHandle = decodeBase64URL(credential[@"userHandle"]);
ASPasskeyCredentialIdentity *credential = [[ASPasskeyCredentialIdentity alloc]
initWithRelyingPartyIdentifier:rpId
userName:userName
credentialID:credentialId
userHandle:userHandle
recordIdentifier:cipherId];
[mappedCredentials addObject:credential];
}
}
[ASCredentialIdentityStore.sharedStore replaceCredentialIdentityEntries:mappedCredentials
completion:^(__attribute__((unused)) BOOL success, NSError * _Nullable error) {
if (error) {
return _return(context, _error_er(error));
}
_return(context, _success(@{@"added": @([mappedCredentials count])}));
}];
}

View File

@@ -0,0 +1,8 @@
#ifndef RUN_AUTOFILL_COMMAND_H
#define RUN_AUTOFILL_COMMAND_H
#import <Foundation/Foundation.h>
void runAutofillCommand(void* context, NSDictionary *input);
#endif

View File

@@ -0,0 +1,20 @@
#import <Foundation/Foundation.h>
#import "commands/sync.h"
#import "commands/status.h"
#import "../interop.h"
#import "../utils.h"
#import "run_autofill_command.h"
void runAutofillCommand(void* context, NSDictionary *input) {
NSString *command = input[@"command"];
NSDictionary *params = input[@"params"];
if ([command isEqual:@"status"]) {
return status(context, params);
} else if ([command isEqual:@"sync"]) {
return runSync(context, params);
}
_return(context, _error([NSString stringWithFormat:@"Unknown command: %@", command]));
}

View File

@@ -0,0 +1,47 @@
#ifndef INTEROP_H
#define INTEROP_H
#import <Foundation/Foundation.h>
// Tips for developing Objective-C code:
// - Use the `NSLog` function to log messages to the system log
// - Example:
// NSLog(@"An example log: %@", someVariable);
// - Use the `@try` and `@catch` directives to catch exceptions
#if !__has_feature(objc_arc)
// Auto Reference Counting makes memory management easier for Objective-C objects
// Regular C objects still need to be managed manually
#error ARC must be enabled!
#endif
/// [Shared with Rust]
/// Simple struct to hold a C-string and its length
/// This is used to return strings created in Objective-C to Rust
/// so that Rust can free the memory when it's done with the string
struct ObjCString
{
char *value;
size_t size;
};
/// [Defined in Rust]
/// External function callable from Objective-C to return a string to Rust
extern bool commandReturn(void *context, struct ObjCString output);
/// [Callable from Rust]
/// Frees the memory allocated for an ObjCString
void freeObjCString(struct ObjCString *value);
// --- Helper functions to convert between Objective-C and Rust types ---
NSString *_success(NSDictionary *value);
NSString *_error(NSString *error);
NSString *_error_er(NSError *error);
NSString *_error_ex(NSException *error);
void _return(void *context, NSString *output);
struct ObjCString nsStringToObjCString(NSString *string);
NSString *cStringToNSString(char *string);
#endif

View File

@@ -0,0 +1,71 @@
#import "interop.h"
#import "utils.h"
/// [Callable from Rust]
/// Frees the memory allocated for an ObjCString
void freeObjCString(struct ObjCString *value) {
free(value->value);
}
// --- Helper functions to convert between Objective-C and Rust types ---
NSString *_success(NSDictionary *value) {
NSDictionary *wrapper = @{@"type": @"success", @"value": value};
NSError *jsonError = nil;
NSString *toReturn = serializeJson(wrapper, jsonError);
if (jsonError) {
// Manually format message since there seems to be an issue with the JSON serialization
return [NSString stringWithFormat:@"{\"type\": \"error\", \"error\": \"Error occurred while serializing error: %@\"}", jsonError];
}
return toReturn;
}
NSString *_error(NSString *error) {
NSDictionary *errorDictionary = @{@"type": @"error", @"error": error};
NSError *jsonError = nil;
NSString *toReturn = serializeJson(errorDictionary, jsonError);
if (jsonError) {
// Manually format message since there seems to be an issue with the JSON serialization
return [NSString stringWithFormat:@"{\"type\": \"error\", \"error\": \"Error occurred while serializing error: %@\"}", jsonError];
}
return toReturn;
}
NSString *_error_er(NSError *error) {
return _error([error localizedDescription]);
}
NSString *_error_ex(NSException *error) {
return _error([NSString stringWithFormat:@"%@ (%@): %@", error.name, error.reason, [error callStackSymbols]]);
}
void _return(void* context, NSString *output) {
if (!commandReturn(context, nsStringToObjCString(output))) {
NSLog(@"Error: Failed to return command output");
// NOTE: This will most likely crash the application
@throw [NSException exceptionWithName:@"CommandReturnError" reason:@"Failed to return command output" userInfo:nil];
}
}
/// Converts an NSString to an ObjCString struct
struct ObjCString nsStringToObjCString(NSString* string) {
size_t size = [string lengthOfBytesUsingEncoding:NSUTF8StringEncoding] + 1;
char *value = malloc(size);
[string getCString:value maxLength:size encoding:NSUTF8StringEncoding];
struct ObjCString objCString;
objCString.value = value;
objCString.size = size;
return objCString;
}
/// Converts a C-string to an NSString
NSString* cStringToNSString(char* string) {
return [[NSString alloc] initWithUTF8String:string];
}

View File

@@ -0,0 +1,39 @@
#import <Foundation/Foundation.h>
#import "autofill/run_autofill_command.h"
#import "interop.h"
#import "utils.h"
void pickAndRunCommand(void* context, NSDictionary *input) {
NSString *namespace = input[@"namespace"];
if ([namespace isEqual:@"autofill"]) {
return runAutofillCommand(context, input);
}
_return(context, _error([NSString stringWithFormat:@"Unknown namespace: %@", namespace]));
}
/// [Callable from Rust]
/// Runs a command with the given input JSON
/// This function is called from Rust and is the entry point for running Objective-C code.
/// It takes a JSON string as input, deserializes it, runs the command, and serializes the output.
/// It also catches any exceptions that occur during the command execution.
void runCommand(void *context, char* inputJson) {
@autoreleasepool {
@try {
NSString *inputString = cStringToNSString(inputJson);
NSError *error = nil;
NSDictionary *input = parseJson(inputString, error);
if (error) {
NSLog(@"Error occured while deserializing input params: %@", error);
return _return(context, _error([NSString stringWithFormat:@"Error occured while deserializing input params: %@", error]));
}
pickAndRunCommand(context, input);
} @catch (NSException *e) {
NSLog(@"Error occurred while running Objective-C command: %@", e);
_return(context, _error([NSString stringWithFormat:@"Error occurred while running Objective-C command: %@", e]));
}
}
}

View File

@@ -0,0 +1,11 @@
#ifndef UTILS_H
#define UTILS_H
#import <Foundation/Foundation.h>
NSDictionary *parseJson(NSString *jsonString, NSError *error);
NSString *serializeJson(NSDictionary *dictionary, NSError *error);
NSData *decodeBase64URL(NSString *base64URLString);
#endif

View File

@@ -0,0 +1,28 @@
#import "utils.h"
NSDictionary *parseJson(NSString *jsonString, NSError *error) {
NSData *data = [jsonString dataUsingEncoding:NSUTF8StringEncoding];
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
if (error) {
return nil;
}
return json;
}
NSString *serializeJson(NSDictionary *dictionary, NSError *error) {
NSData *data = [NSJSONSerialization dataWithJSONObject:dictionary options:0 error:&error];
if (error) {
return nil;
}
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
}
NSData *decodeBase64URL(NSString *base64URLString) {
NSString *base64String = [base64URLString stringByReplacingOccurrencesOfString:@"-" withString:@"+"];
base64String = [base64String stringByReplacingOccurrencesOfString:@"_" withString:@"/"];
NSData *nsdataFromBase64String = [[NSData alloc]
initWithBase64EncodedString:base64String options:0];
return nsdataFromBase64String;
}