Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(mobile): add U2F support for Android devices #2311

Merged
merged 2 commits into from Apr 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Binary file not shown.
2 changes: 1 addition & 1 deletion packages/api/package.json
Expand Up @@ -37,7 +37,7 @@
},
"dependencies": {
"@standardnotes/common": "^1.46.6",
"@standardnotes/domain-core": "^1.11.3",
"@standardnotes/domain-core": "^1.12.0",
"@standardnotes/encryption": "workspace:*",
"@standardnotes/models": "workspace:*",
"@standardnotes/responses": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion packages/features/package.json
Expand Up @@ -26,7 +26,7 @@
},
"dependencies": {
"@standardnotes/common": "^1.46.6",
"@standardnotes/domain-core": "^1.11.3",
"@standardnotes/domain-core": "^1.12.0",
"@standardnotes/security": "^1.7.6",
"reflect-metadata": "^0.1.13"
},
Expand Down
1 change: 1 addition & 0 deletions packages/mobile/android/app/build.gradle
Expand Up @@ -178,6 +178,7 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.1.0-rc01'
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0")
implementation 'de.undercouch:gradle-download-task:5.0.2'
implementation 'com.google.android.gms:play-services-fido:20.0.1'

debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}")

Expand Down
@@ -0,0 +1,203 @@
package com.standardnotes;

import android.app.Activity;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.IntentSender;
import android.util.Base64;
import android.util.Log;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ActivityEventListener;
import com.facebook.react.bridge.BaseActivityEventListener;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.WritableMap;
import java.util.Map;
import java.util.HashMap;
import java.util.ArrayList;

import org.json.JSONObject;
import org.json.JSONArray;
import org.json.JSONException;

import com.google.android.gms.fido.fido2.Fido2ApiClient;
import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse;
import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse;
import com.google.android.gms.fido.fido2.api.common.AuthenticationExtensionsClientOutputs;
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredential;
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialType;
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialDescriptor;
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialRequestOptions;
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialRequestOptions;
import com.google.android.gms.fido.Fido;
import com.google.android.gms.tasks.OnFailureListener;
import com.google.android.gms.tasks.OnSuccessListener;
import com.google.android.gms.tasks.Task;

public class Fido2ApiModule extends ReactContextBaseJavaModule {
private final Fido2ApiClient fido2ApiClient;
private final ReactApplicationContext reactContext;
private static final int SIGN_REQUEST_CODE = 111;

private static final String LOGS_TAG = "Fido2ApiModule";
private static final String RP_ID = "app.standardnotes.com";

private Promise signInPromise;

private final ActivityEventListener activityEventListener = new BaseActivityEventListener() {
@Override
public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent intent) {
super.onActivityResult(activity, requestCode, resultCode, intent);

if (requestCode == SIGN_REQUEST_CODE) {
if (signInPromise != null) {
if (resultCode == Activity.RESULT_CANCELED) {
Log.e(LOGS_TAG, "FIDO sign in cancelled");

signInPromise.reject("FIDO sign in cancelled");
} else if (resultCode == Activity.RESULT_OK) {
if (intent.hasExtra(Fido.FIDO2_KEY_ERROR_EXTRA)) {
AuthenticatorErrorResponse authenticatorErrorResponse =
AuthenticatorErrorResponse.deserializeFromBytes(intent.getByteArrayExtra(Fido.FIDO2_KEY_ERROR_EXTRA));
Log.e(LOGS_TAG, "Fido Error: " + authenticatorErrorResponse.getErrorMessage());

signInPromise.reject(authenticatorErrorResponse.getErrorMessage());
} else if (intent.hasExtra(Fido.FIDO2_KEY_CREDENTIAL_EXTRA)) {
PublicKeyCredential publicKeyCredential =
PublicKeyCredential.deserializeFromBytes(
intent.getByteArrayExtra(Fido.FIDO2_KEY_CREDENTIAL_EXTRA));
AuthenticatorAssertionResponse signedData =
(AuthenticatorAssertionResponse) publicKeyCredential.getResponse();

WritableMap signInResult = Arguments.createMap();
signInResult.putString("id", Base64.encodeToString(signedData.getKeyHandle(), Base64.URL_SAFE));
signInResult.putString("rawId", Base64.encodeToString(signedData.getKeyHandle(), Base64.URL_SAFE));

byte[] extensionOutputsBytes = null;
AuthenticationExtensionsClientOutputs extensionOutputs = publicKeyCredential.getClientExtensionResults();
if (extensionOutputs != null) {
extensionOutputsBytes = extensionOutputs.serializeToBytes();
if (extensionOutputsBytes != null) {
signInResult.putString("clientExtensionResults", Base64.encodeToString(extensionOutputsBytes, Base64.URL_SAFE));
}
}

WritableMap response = Arguments.createMap();
response.putString("clientDataJSON", Base64.encodeToString(signedData.getClientDataJSON(), Base64.URL_SAFE));
response.putString("authenticatorData", Base64.encodeToString(signedData.getAuthenticatorData(), Base64.URL_SAFE));
response.putString("signature", Base64.encodeToString(signedData.getSignature(), Base64.URL_SAFE));
byte[] userHandle = signedData.getUserHandle();
if (userHandle != null) {
response.putString("userHandle", Base64.encodeToString(userHandle, Base64.URL_SAFE));
}
signInResult.putMap("response", response);

signInPromise.resolve(signInResult);
}
}
}
signInPromise = null;
}
}
};

Fido2ApiModule(ReactApplicationContext context) {
super(context);

fido2ApiClient = Fido.getFido2ApiClient(context);
context.addActivityEventListener(activityEventListener);

this.reactContext = context;
}

@Override
public String getName() {
return "Fido2ApiModule";
}

@ReactMethod
public void promptForU2FAuthentication(String authenticationOptionsJSONString, Promise promise) throws JSONException {
signInPromise = promise;

JSONObject authenticationOptions = new JSONObject(authenticationOptionsJSONString);

ArrayList<PublicKeyCredentialDescriptor> allowedKeys = new ArrayList<PublicKeyCredentialDescriptor>();

JSONArray allowedCredentials = authenticationOptions.getJSONArray("allowCredentials");
for (int i = 0, size = allowedCredentials.length(); i < size; i++) {
JSONObject allowedCredential = allowedCredentials.getJSONObject(i);
allowedKeys.add(
new PublicKeyCredentialDescriptor(
PublicKeyCredentialType.PUBLIC_KEY.toString(),
this.convertBase64URLStringToBytes(allowedCredential.getString("id")),
null
)
);
}

String challenge = authenticationOptions.getString("challenge");
Double timeout = authenticationOptions.getDouble("timeout") / 1000d;

PublicKeyCredentialRequestOptions.Builder optionsBuilder = new PublicKeyCredentialRequestOptions
.Builder()
.setRpId(RP_ID)
.setAllowList(allowedKeys)
.setChallenge(this.convertBase64URLStringToBytes(challenge))
.setTimeoutSeconds(timeout);

PublicKeyCredentialRequestOptions options = optionsBuilder.build();

Task result = this.fido2ApiClient.getSignPendingIntent(options);

final Activity activity = this.reactContext.getCurrentActivity();

result.addOnSuccessListener(
new OnSuccessListener<PendingIntent>() {
@Override
public void onSuccess(PendingIntent fido2PendingIntent) {
if (fido2PendingIntent == null) {
Log.e(LOGS_TAG, "No pending FIDO intent returned");
return;
}

try {
activity.startIntentSenderForResult(
fido2PendingIntent.getIntentSender(),
SIGN_REQUEST_CODE,
null,
0,
0,
0
);
} catch (IntentSender.SendIntentException exception) {
Log.e(LOGS_TAG, "Error starting FIDO intent: " + exception.getMessage());
}
}
}
);

result.addOnFailureListener(
new OnFailureListener() {
@Override
public void onFailure(Exception e) {
Log.e(LOGS_TAG, "Error getting FIDO intent: " + e.getMessage());
signInPromise.reject(e.getMessage());
}
}
);
}

private byte[] convertBase64URLStringToBytes(String base64URLString) {
String base64String = base64URLString.replace('-', '+').replace('_', '/');
int padding = (4 - (base64String.length() % 4)) % 4;
for (int i = 0; i < padding; i++) {
base64String += '=';
}

return Base64.decode(base64String, Base64.DEFAULT);
}
}
@@ -0,0 +1,26 @@
package com.standardnotes;

import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class Fido2ApiPackage implements ReactPackage {
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}

@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();

modules.add(new Fido2ApiModule(reactContext));

return modules;
}
}
Expand Up @@ -37,6 +37,8 @@ protected List<ReactPackage> getPackages() {
@SuppressWarnings("UnnecessaryLocalVariable")
List<ReactPackage> packages = new PackageList(this).getPackages();

packages.add(new Fido2ApiPackage());

return packages;
}

Expand Down
5 changes: 4 additions & 1 deletion packages/mobile/android/gradle.properties
Expand Up @@ -43,4 +43,7 @@ newArchEnabled=false
hermesEnabled=true

# Set AsyncStorage limit
AsyncStorage_db_size_in_MB=50
AsyncStorage_db_size_in_MB=50

# The URL of the server
host=https://app.standardnotes.com
21 changes: 21 additions & 0 deletions packages/mobile/src/Lib/MobileDevice.ts
Expand Up @@ -22,6 +22,7 @@ import {
AppStateStatus,
ColorSchemeName,
Linking,
NativeModules,
PermissionsAndroid,
Platform,
StatusBar,
Expand Down Expand Up @@ -71,6 +72,26 @@ export class MobileDevice implements MobileDeviceInterface {
private colorSchemeService?: ColorSchemeObserverService,
) {}

async authenticateWithU2F(authenticationOptionsJSONString: string): Promise<Record<string, unknown> | null> {
const { Fido2ApiModule } = NativeModules

if (!Fido2ApiModule) {
this.consoleLog('Fido2ApiModule is not available')

return null
}

try {
const response = await Fido2ApiModule.promptForU2FAuthentication(authenticationOptionsJSONString)

return response
} catch (error) {
this.consoleLog(`Fido2ApiModule.authenticateWithU2F error: ${(error as Error).message}`)

return null
}
}

purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise<AppleIAPReceipt | undefined> {
return PurchaseManager.getInstance().purchase(plan)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/services/package.json
Expand Up @@ -18,7 +18,7 @@
"dependencies": {
"@standardnotes/api": "workspace:^",
"@standardnotes/common": "^1.46.4",
"@standardnotes/domain-core": "^1.11.3",
"@standardnotes/domain-core": "^1.12.0",
"@standardnotes/encryption": "workspace:^",
"@standardnotes/files": "workspace:^",
"@standardnotes/models": "workspace:^",
Expand Down
4 changes: 3 additions & 1 deletion packages/services/src/Domain/Device/MobileDeviceInterface.ts
@@ -1,6 +1,7 @@
import { Environment, Platform, RawKeychainValue } from '@standardnotes/models'

import { AppleIAPProductId } from './../Subscription/AppleIAPProductId'
import { DeviceInterface } from './DeviceInterface'
import { Environment, Platform, RawKeychainValue } from '@standardnotes/models'
import { AppleIAPReceipt } from '../Subscription/AppleIAPReceipt'

export interface MobileDeviceInterface extends DeviceInterface {
Expand All @@ -25,4 +26,5 @@ export interface MobileDeviceInterface extends DeviceInterface {
getAppState(): Promise<'active' | 'background' | 'inactive' | 'unknown' | 'extension'>
getColorScheme(): Promise<'light' | 'dark' | null | undefined>
purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise<AppleIAPReceipt | undefined>
authenticateWithU2F(authenticationOptionsJSONString: string): Promise<Record<string, unknown> | null>
}
10 changes: 9 additions & 1 deletion packages/snjs/lib/Application/Application.ts
Expand Up @@ -102,6 +102,7 @@ import { ListRevisions } from '@Lib/Domain/UseCase/ListRevisions/ListRevisions'
import { GetRevision } from '@Lib/Domain/UseCase/GetRevision/GetRevision'
import { DeleteRevision } from '@Lib/Domain/UseCase/DeleteRevision/DeleteRevision'
import { GetAuthenticatorAuthenticationResponse } from '@Lib/Domain/UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponse'
import { GetAuthenticatorAuthenticationOptions } from '@Lib/Domain/UseCase/GetAuthenticatorAuthenticationOptions/GetAuthenticatorAuthenticationOptions'

/** How often to automatically sync, in milliseconds */
const DEFAULT_AUTO_SYNC_INTERVAL = 30_000
Expand Down Expand Up @@ -182,6 +183,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
private declare _addAuthenticator: AddAuthenticator
private declare _listAuthenticators: ListAuthenticators
private declare _deleteAuthenticator: DeleteAuthenticator
private declare _getAuthenticatorAuthenticationOptions: GetAuthenticatorAuthenticationOptions
private declare _getAuthenticatorAuthenticationResponse: GetAuthenticatorAuthenticationResponse
private declare _listRevisions: ListRevisions
private declare _getRevision: GetRevision
Expand Down Expand Up @@ -284,6 +286,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
return this._deleteAuthenticator
}

get getAuthenticatorAuthenticationOptions(): GetAuthenticatorAuthenticationOptions {
return this._getAuthenticatorAuthenticationOptions
}

get getAuthenticatorAuthenticationResponse(): GetAuthenticatorAuthenticationResponse {
return this._getAuthenticatorAuthenticationResponse
}
Expand Down Expand Up @@ -1819,8 +1825,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli

this._deleteAuthenticator = new DeleteAuthenticator(this.authenticatorManager)

this._getAuthenticatorAuthenticationOptions = new GetAuthenticatorAuthenticationOptions(this.authenticatorManager)

this._getAuthenticatorAuthenticationResponse = new GetAuthenticatorAuthenticationResponse(
this.authenticatorManager,
this._getAuthenticatorAuthenticationOptions,
this.options.u2fAuthenticatorVerificationPromptFunction,
)

Expand Down