Compare commits

...
28 changed files with 438 additions and 77 deletions
+1 -1
View File
@@ -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:*",
+1 -1
View File
@@ -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"
},
+1
View File
@@ -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}")
@@ -0,0 +1,182 @@
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.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 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.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 (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, "FIDO2_KEY_ERROR_EXTRA Security Key: " + authenticatorErrorResponse.getErrorMessage());
signInPromise.reject("FIDO2_KEY_ERROR_EXTRA", authenticatorErrorResponse.getErrorMessage());
signInPromise = null;
return;
}
Log.i(LOGS_TAG, "FIDO response OK");
signInPromise.resolve("OK");
} else {
Log.e(LOGS_TAG, "FIDO sign in failed");
signInPromise.reject("FIDO_SIGN_IN_FAILED", "FIDO sign in failed");
}
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 {
Log.i(LOGS_TAG, "Prompt for U2F sign in with options: " + authenticationOptionsJSONString);
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);
Log.i(LOGS_TAG, "Adding credential id: " + allowedCredential.getString("id"));
allowedKeys.add(
new PublicKeyCredentialDescriptor(
PublicKeyCredentialType.PUBLIC_KEY.toString(),
this.convertBase64URLStringToBytes(allowedCredential.getString("id")),
null
)
);
}
String challenge = authenticationOptions.getString("challenge");
Double timeout = authenticationOptions.getDouble("timeout");
Log.i(LOGS_TAG, "Challenge: " + challenge);
Log.i(LOGS_TAG, "Timeout: " + timeout);
Log.i(LOGS_TAG, "RP ID: " + RP_ID);
Log.i(LOGS_TAG, "Allowed keys size: " + allowedKeys.size());
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("unknown", 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 += '=';
}
byte[] decoded = Base64.decode(base64String, Base64.DEFAULT);
return decoded;
}
}
@@ -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;
}
}
@@ -37,6 +37,8 @@ public class MainApplication extends Application implements ReactApplication {
@SuppressWarnings("UnnecessaryLocalVariable")
List<ReactPackage> packages = new PackageList(this).getPackages();
packages.add(new Fido2ApiPackage());
return packages;
}
+4 -1
View File
@@ -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
+13
View File
@@ -22,6 +22,7 @@ import {
AppStateStatus,
ColorSchemeName,
Linking,
NativeModules,
PermissionsAndroid,
Platform,
StatusBar,
@@ -71,6 +72,18 @@ export class MobileDevice implements MobileDeviceInterface {
private colorSchemeService?: ColorSchemeObserverService,
) {}
async promptForU2FAuthentication(authenticationOptionsJSONString: string): Promise<void> {
const { Fido2ApiModule } = NativeModules
try {
const response = await Fido2ApiModule.promptForU2FAuthentication(authenticationOptionsJSONString)
this.consoleLog('Fido2ApiModule.promptForU2FAuthentication response', response)
} catch (error) {
this.consoleLog('Fido2ApiModule.promptForU2FAuthentication error', (error as Error).message)
}
}
purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise<AppleIAPReceipt | undefined> {
return PurchaseManager.getInstance().purchase(plan)
}
+1 -1
View File
@@ -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:^",
@@ -18,5 +18,6 @@ export interface WebApplicationInterface extends ApplicationInterface {
handleAndroidBackButtonPressed(): void
addAndroidBackHandlerEventListener(listener: () => boolean): (() => void) | undefined
setAndroidBackHandlerFallbackListener(listener: () => boolean): void
handleAndroidU2FAuthenticatorResponse(response: string): void
generateUUID(): string
}
@@ -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 {
@@ -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>
promptForU2FAuthentication(authenticationOptionsJSONString: string): Promise<void>
}
@@ -8,4 +8,5 @@ export enum WebAppEvent {
WindowDidBlur = 'WindowDidBlur',
MobileKeyboardDidChangeFrame = 'MobileKeyboardDidChangeFrame',
MobileKeyboardWillChangeFrame = 'MobileKeyboardWillChangeFrame',
U2FAuthenticatorResponseObtained = 'U2FAuthenticatorResponseObtained',
}
+9 -1
View File
@@ -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 '..'
/** How often to automatically sync, in milliseconds */
const DEFAULT_AUTO_SYNC_INTERVAL = 30_000
@@ -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
@@ -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
}
@@ -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,
)
@@ -9,4 +9,5 @@ export enum ReactNativeToWebEvent {
KeyboardFrameDidChange = 'KeyboardFrameDidChange',
KeyboardWillShow = 'KeyboardWillShow',
KeyboardWillHide = 'KeyboardWillHide',
U2FAuthenticatorResponseReceived = 'U2FAuthenticatorResponseReceived',
}
@@ -0,0 +1,42 @@
import { AuthenticatorClientInterface } from '@standardnotes/services'
import { GetAuthenticatorAuthenticationOptions } from './GetAuthenticatorAuthenticationOptions'
describe('GetAuthenticatorAuthenticationOptions', () => {
let authenticatorClient: AuthenticatorClientInterface
const createUseCase = () => new GetAuthenticatorAuthenticationOptions(authenticatorClient)
beforeEach(() => {
authenticatorClient = {} as jest.Mocked<AuthenticatorClientInterface>
authenticatorClient.generateAuthenticationOptions = jest.fn().mockResolvedValue({ foo: 'bar' })
})
it('should return an error if username is not provided', async () => {
const result = await createUseCase().execute({
username: '',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Could not generate authenticator authentication options: Username cannot be empty')
})
it('should return an error if authenticator client fails to generate authentication options', async () => {
authenticatorClient.generateAuthenticationOptions = jest.fn().mockResolvedValue(null)
const result = await createUseCase().execute({
username: '[email protected]',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Could not generate authenticator authentication options')
})
it('should return ok if authenticator client succeeds to generate authenticator response', async () => {
const result = await createUseCase().execute({
username: '[email protected]',
})
expect(result.isFailed()).toBe(false)
})
})
@@ -0,0 +1,23 @@
import { AuthenticatorClientInterface } from '@standardnotes/services'
import { Result, UseCaseInterface, Username } from '@standardnotes/domain-core'
import { GetAuthenticatorAuthenticationOptionsDTO } from './GetAuthenticatorAuthenticationOptionsDTO'
export class GetAuthenticatorAuthenticationOptions implements UseCaseInterface<Record<string, unknown>> {
constructor(private authenticatorClient: AuthenticatorClientInterface) {}
async execute(dto: GetAuthenticatorAuthenticationOptionsDTO): Promise<Result<Record<string, unknown>>> {
const usernameOrError = Username.create(dto.username)
if (usernameOrError.isFailed()) {
return Result.fail(`Could not generate authenticator authentication options: ${usernameOrError.getError()}`)
}
const username = usernameOrError.getValue()
const authenticationOptions = await this.authenticatorClient.generateAuthenticationOptions(username)
if (authenticationOptions === null) {
return Result.fail('Could not generate authenticator authentication options')
}
return Result.ok(authenticationOptions)
}
}
@@ -0,0 +1,3 @@
export interface GetAuthenticatorAuthenticationOptionsDTO {
username: string
}
@@ -1,40 +1,31 @@
import { AuthenticatorClientInterface } from '@standardnotes/services'
import { GetAuthenticatorAuthenticationResponse } from './GetAuthenticatorAuthenticationResponse'
import { GetAuthenticatorAuthenticationOptions } from '../GetAuthenticatorAuthenticationOptions/GetAuthenticatorAuthenticationOptions'
import { Result } from '@standardnotes/domain-core'
describe('GetAuthenticatorAuthenticationResponse', () => {
let authenticatorClient: AuthenticatorClientInterface
let getAuthenticatorAuthenticationOptions: GetAuthenticatorAuthenticationOptions
let authenticatorVerificationPromptFunction: (
authenticationOptions: Record<string, unknown>,
) => Promise<Record<string, unknown>>
const createUseCase = () => new GetAuthenticatorAuthenticationResponse(authenticatorClient, authenticatorVerificationPromptFunction)
const createUseCase = () => new GetAuthenticatorAuthenticationResponse(getAuthenticatorAuthenticationOptions, authenticatorVerificationPromptFunction)
beforeEach(() => {
authenticatorClient = {} as jest.Mocked<AuthenticatorClientInterface>
authenticatorClient.generateAuthenticationOptions = jest.fn().mockResolvedValue({ foo: 'bar' })
getAuthenticatorAuthenticationOptions = {} as jest.Mocked<GetAuthenticatorAuthenticationOptions>
getAuthenticatorAuthenticationOptions.execute = jest.fn().mockResolvedValue({ foo: 'bar' })
authenticatorVerificationPromptFunction = jest.fn()
})
it('should return an error if username is not provided', async () => {
const result = await createUseCase().execute({
username: '',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Could not generate authenticator authentication options: Username cannot be empty')
})
it('should return an error if authenticator client fails to generate authentication options', async () => {
authenticatorClient.generateAuthenticationOptions = jest.fn().mockResolvedValue(null)
it('should return an error if it fails to generate authentication options', async () => {
getAuthenticatorAuthenticationOptions.execute = jest.fn().mockReturnValue(Result.fail('error'))
const result = await createUseCase().execute({
username: '[email protected]',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Could not generate authenticator authentication options')
expect(result.getError()).toBe('error')
})
it('should return an error if authenticator verification prompt function fails', async () => {
@@ -57,7 +48,7 @@ describe('GetAuthenticatorAuthenticationResponse', () => {
})
it('should return error if authenticatorVerificationPromptFunction is not provided', async () => {
const result = await new GetAuthenticatorAuthenticationResponse(authenticatorClient).execute({
const result = await new GetAuthenticatorAuthenticationResponse(getAuthenticatorAuthenticationOptions).execute({
username: '[email protected]',
})
@@ -1,10 +1,10 @@
import { AuthenticatorClientInterface } from '@standardnotes/services'
import { Result, UseCaseInterface, Username } from '@standardnotes/domain-core'
import { Result, UseCaseInterface } from '@standardnotes/domain-core'
import { GetAuthenticatorAuthenticationResponseDTO } from './GetAuthenticatorAuthenticationResponseDTO'
import { GetAuthenticatorAuthenticationOptions } from '../GetAuthenticatorAuthenticationOptions/GetAuthenticatorAuthenticationOptions'
export class GetAuthenticatorAuthenticationResponse implements UseCaseInterface<Record<string, unknown>> {
constructor(
private authenticatorClient: AuthenticatorClientInterface,
private getAuthenticatorAuthenticationOptions: GetAuthenticatorAuthenticationOptions,
private authenticatorVerificationPromptFunction?: (
authenticationOptions: Record<string, unknown>,
) => Promise<Record<string, unknown>>,
@@ -17,16 +17,13 @@ export class GetAuthenticatorAuthenticationResponse implements UseCaseInterface<
)
}
const usernameOrError = Username.create(dto.username)
if (usernameOrError.isFailed()) {
return Result.fail(`Could not generate authenticator authentication options: ${usernameOrError.getError()}`)
}
const username = usernameOrError.getValue()
const authenticationOptions = await this.authenticatorClient.generateAuthenticationOptions(username)
if (authenticationOptions === null) {
return Result.fail('Could not generate authenticator authentication options')
const authenticationOptionsOrError = await this.getAuthenticatorAuthenticationOptions.execute({
username: dto.username,
})
if (authenticationOptionsOrError.isFailed()) {
return Result.fail(authenticationOptionsOrError.getError())
}
const authenticationOptions = authenticationOptionsOrError.getValue()
let authenticatorResponse
try {
@@ -7,6 +7,7 @@ import { GetAuthenticatorAuthenticationResponse } from './GetAuthenticatorAuthen
import { ListRevisions } from './ListRevisions/ListRevisions'
import { GetRevision } from './GetRevision/GetRevision'
import { DeleteRevision } from './DeleteRevision/DeleteRevision'
import { GetAuthenticatorAuthenticationOptions } from './GetAuthenticatorAuthenticationOptions/GetAuthenticatorAuthenticationOptions'
export interface UseCaseContainerInterface {
get signInWithRecoveryCodes(): SignInWithRecoveryCodes
@@ -15,6 +16,7 @@ export interface UseCaseContainerInterface {
get listAuthenticators(): ListAuthenticators
get deleteAuthenticator(): DeleteAuthenticator
get getAuthenticatorAuthenticationResponse(): GetAuthenticatorAuthenticationResponse
get getAuthenticatorAuthenticationOptions(): GetAuthenticatorAuthenticationOptions
get listRevisions(): ListRevisions
get getRevision(): GetRevision
get deleteRevision(): DeleteRevision
+2
View File
@@ -6,6 +6,8 @@ export * from './UseCase/DeleteAuthenticator/DeleteAuthenticator'
export * from './UseCase/DeleteAuthenticator/DeleteAuthenticatorDTO'
export * from './UseCase/DeleteRevision/DeleteRevision'
export * from './UseCase/DeleteRevision/DeleteRevisionDTO'
export * from './UseCase/GetAuthenticatorAuthenticationOptions/GetAuthenticatorAuthenticationOptions'
export * from './UseCase/GetAuthenticatorAuthenticationOptions/GetAuthenticatorAuthenticationOptionsDTO'
export * from './UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponse'
export * from './UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponseDTO'
export * from './UseCase/GetRecoveryCodes/GetRecoveryCodes'
+1 -1
View File
@@ -37,7 +37,7 @@
"@babel/preset-env": "*",
"@standardnotes/api": "workspace:*",
"@standardnotes/common": "^1.46.6",
"@standardnotes/domain-core": "^1.11.3",
"@standardnotes/domain-core": "^1.12.0",
"@standardnotes/domain-events": "^2.108.1",
"@standardnotes/encryption": "workspace:*",
"@standardnotes/features": "workspace:*",
@@ -2,7 +2,7 @@
* @jest-environment jsdom
*/
import { WebApplicationInterface } from '@standardnotes/snjs/dist/@types'
import { WebApplicationInterface } from '@standardnotes/snjs'
import { jsonTestData, htmlTestData } from './testData'
import { GoogleKeepConverter } from './GoogleKeepConverter'
@@ -346,6 +346,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter
this.notifyWebEvent(WebAppEvent.MobileKeyboardDidChangeFrame, frame)
}
handleAndroidU2FAuthenticatorResponse(response: string): void {
this.notifyWebEvent(WebAppEvent.U2FAuthenticatorResponseObtained, response)
}
private async lockApplicationAfterMobileEventIfApplicable(): Promise<void> {
const isLocked = await this.isLocked()
if (isLocked) {
@@ -1,8 +1,13 @@
import { Username } from '@standardnotes/snjs'
import { ChallengePrompt, WebAppEvent } from '@standardnotes/services'
import { RefObject, useEffect, useState } from 'react'
import { WebApplication } from '@/Application/Application'
import { ChallengePrompt } from '@standardnotes/services'
import { RefObject, useState } from 'react'
import { isAndroid } from '@/Utils'
import Button from '../Button/Button'
import Icon from '../Icon/Icon'
import { InputValue } from './InputValue'
import U2FPromptIframeContainer from './U2FPromptIframeContainer'
@@ -18,7 +23,43 @@ const U2FPrompt = ({ application, onValueChange, prompt, buttonRef, contextData
const [authenticatorResponse, setAuthenticatorResponse] = useState<Record<string, unknown> | null>(null)
const [error, setError] = useState('')
if (!application.isFullU2FClient) {
useEffect(() => {
const disposer = application.addWebEventObserver((event, authenticatorResponse) => {
if (event === WebAppEvent.U2FAuthenticatorResponseObtained) {
onValueChange(authenticatorResponse as string, prompt)
}
})
return disposer
}, [application, onValueChange, prompt])
useEffect(() => {
async function promptForU2F() {
const usernameOrError = Username.create((contextData as { username: string }).username)
if (usernameOrError.isFailed()) {
setError(usernameOrError.getError())
return
}
const username = usernameOrError.getValue()
const authenticatorOptionsOrError = await application.getAuthenticatorAuthenticationOptions.execute({
username: username.value,
})
if (authenticatorOptionsOrError.isFailed()) {
setError(authenticatorOptionsOrError.getError())
return
}
const authenticatorOptions = authenticatorOptionsOrError.getValue()
await application.mobileDevice().promptForU2FAuthentication(JSON.stringify(authenticatorOptions))
}
void promptForU2F()
}, [application, contextData, setError])
const isOnAndroidDevice = isAndroid()
if (!application.isFullU2FClient && !isOnAndroidDevice) {
return (
<U2FPromptIframeContainer
contextData={contextData}
@@ -33,41 +74,43 @@ const U2FPrompt = ({ application, onValueChange, prompt, buttonRef, contextData
return (
<div className="min-w-76">
{error && <div className="text-red-500">{error}</div>}
<Button
primary
fullWidth
colorStyle={authenticatorResponse ? 'success' : 'info'}
onClick={async () => {
if (!contextData || contextData.username === undefined) {
setError('No username provided')
return
}
{!isOnAndroidDevice && (
<Button
primary
fullWidth
colorStyle={authenticatorResponse ? 'success' : 'info'}
onClick={async () => {
if (!contextData || contextData.username === undefined) {
setError('No username provided')
return
}
const authenticatorResponseOrError = await application.getAuthenticatorAuthenticationResponse.execute({
username: contextData.username as string,
})
const authenticatorResponseOrError = await application.getAuthenticatorAuthenticationResponse.execute({
username: contextData.username as string,
})
if (authenticatorResponseOrError.isFailed()) {
setError(authenticatorResponseOrError.getError())
return
}
if (authenticatorResponseOrError.isFailed()) {
setError(authenticatorResponseOrError.getError())
return
}
const authenticatorResponse = authenticatorResponseOrError.getValue()
const authenticatorResponse = authenticatorResponseOrError.getValue()
setAuthenticatorResponse(authenticatorResponse)
onValueChange(authenticatorResponse, prompt)
}}
ref={buttonRef}
>
{authenticatorResponse ? (
<span className="flex items-center justify-center gap-3">
<Icon type="check-circle" />
Obtained Device Response
</span>
) : (
'Authenticate Device'
)}
</Button>
setAuthenticatorResponse(authenticatorResponse)
onValueChange(authenticatorResponse, prompt)
}}
ref={buttonRef}
>
{authenticatorResponse ? (
<span className="flex items-center justify-center gap-3">
<Icon type="check-circle" />
Obtained Device Response
</span>
) : (
'Authenticate Device'
)}
</Button>
)}
</div>
)
}
@@ -82,7 +82,12 @@ export class MobileWebReceiver {
messageData as { height: number; contentHeight: number },
)
break
case ReactNativeToWebEvent.U2FAuthenticatorResponseReceived: {
const { authenticatorResponse } = messageData as { authenticatorResponse: string }
this.application.handleAndroidU2FAuthenticatorResponse(authenticatorResponse)
break
}
default:
break
}
+13 -4
View File
@@ -4698,7 +4698,7 @@ __metadata:
resolution: "@standardnotes/api@workspace:packages/api"
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:*"
@@ -4909,6 +4909,15 @@ __metadata:
languageName: node
linkType: hard
"@standardnotes/domain-core@npm:^1.12.0":
version: 1.12.0
resolution: "@standardnotes/domain-core@npm:1.12.0"
dependencies:
uuid: ^9.0.0
checksum: 852f15e481546fac621d503ea1cbdf9b2cb3e343d0c4cbe2fea7acc0f751a122d64479b55fa18e032e51d277a56621b9401d6f2d01756eb877eeb3572108e2a6
languageName: node
linkType: hard
"@standardnotes/domain-events@npm:^2.108.1":
version: 2.108.1
resolution: "@standardnotes/domain-events@npm:2.108.1"
@@ -4986,7 +4995,7 @@ __metadata:
resolution: "@standardnotes/features@workspace:packages/features"
dependencies:
"@standardnotes/common": ^1.46.6
"@standardnotes/domain-core": ^1.11.3
"@standardnotes/domain-core": ^1.12.0
"@standardnotes/security": ^1.7.6
"@types/jest": ^29.2.3
"@typescript-eslint/eslint-plugin": "*"
@@ -5297,7 +5306,7 @@ __metadata:
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:^"
@@ -5395,7 +5404,7 @@ __metadata:
"@babel/preset-env": "*"
"@standardnotes/api": "workspace:*"
"@standardnotes/common": ^1.46.6
"@standardnotes/domain-core": ^1.11.3
"@standardnotes/domain-core": ^1.12.0
"@standardnotes/domain-events": ^2.108.1
"@standardnotes/encryption": "workspace:*"
"@standardnotes/features": "workspace:*"