feat(dev): add u2f ui for managing devices and signing in (#2182)

* feat: add u2f ui for managing devices and signing in

* refactor: change unnecessary useState to derived constant

* fix: modal refactor

* fix(web): hide u2f under feature trunk

* fix(web): jest setup

---------

Co-authored-by: Aman Harwara <amanharwara@protonmail.com>
This commit is contained in:
Karol Sójko
2023-02-03 07:54:56 +01:00
committed by GitHub
parent b4f14c668d
commit 9414774e89
48 changed files with 552 additions and 190 deletions

View File

@@ -4,5 +4,4 @@ export enum AuthenticatorApiOperations {
GenerateRegistrationOptions,
GenerateAuthenticationOptions,
VerifyRegistrationResponse,
VerifyAuthenticationResponse,
}

View File

@@ -9,7 +9,6 @@ import {
GenerateAuthenticatorRegistrationOptionsResponse,
VerifyAuthenticatorRegistrationResponseResponse,
GenerateAuthenticatorAuthenticationOptionsResponse,
VerifyAuthenticatorAuthenticationResponseResponse,
} from '../../Response'
import { AuthenticatorServerInterface } from '../../Server/Authenticator/AuthenticatorServerInterface'
@@ -79,7 +78,7 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface
async verifyRegistrationResponse(
userUuid: string,
name: string,
registrationCredential: Record<string, unknown>,
attestationResponse: Record<string, unknown>,
): Promise<VerifyAuthenticatorRegistrationResponseResponse> {
if (this.operationsInProgress.get(AuthenticatorApiOperations.VerifyRegistrationResponse)) {
throw new ApiCallError(ErrorMessage.GenericInProgress)
@@ -91,7 +90,7 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface
const response = await this.authenticatorServer.verifyRegistrationResponse({
userUuid,
name,
registrationCredential,
attestationResponse,
})
return response
@@ -102,7 +101,7 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface
}
}
async generateAuthenticationOptions(): Promise<GenerateAuthenticatorAuthenticationOptionsResponse> {
async generateAuthenticationOptions(username: string): Promise<GenerateAuthenticatorAuthenticationOptionsResponse> {
if (this.operationsInProgress.get(AuthenticatorApiOperations.GenerateAuthenticationOptions)) {
throw new ApiCallError(ErrorMessage.GenericInProgress)
}
@@ -110,7 +109,9 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface
this.operationsInProgress.set(AuthenticatorApiOperations.GenerateAuthenticationOptions, true)
try {
const response = await this.authenticatorServer.generateAuthenticationOptions()
const response = await this.authenticatorServer.generateAuthenticationOptions({
username,
})
return response
} catch (error) {
@@ -119,28 +120,4 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface
this.operationsInProgress.set(AuthenticatorApiOperations.GenerateAuthenticationOptions, false)
}
}
async verifyAuthenticationResponse(
userUuid: string,
authenticationCredential: Record<string, unknown>,
): Promise<VerifyAuthenticatorAuthenticationResponseResponse> {
if (this.operationsInProgress.get(AuthenticatorApiOperations.VerifyAuthenticationResponse)) {
throw new ApiCallError(ErrorMessage.GenericInProgress)
}
this.operationsInProgress.set(AuthenticatorApiOperations.VerifyAuthenticationResponse, true)
try {
const response = await this.authenticatorServer.verifyAuthenticationResponse({
authenticationCredential,
userUuid,
})
return response
} catch (error) {
throw new ApiCallError(ErrorMessage.GenericFail)
} finally {
this.operationsInProgress.set(AuthenticatorApiOperations.VerifyAuthenticationResponse, false)
}
}
}

View File

@@ -4,7 +4,6 @@ import {
GenerateAuthenticatorRegistrationOptionsResponse,
VerifyAuthenticatorRegistrationResponseResponse,
GenerateAuthenticatorAuthenticationOptionsResponse,
VerifyAuthenticatorAuthenticationResponseResponse,
} from '../../Response'
export interface AuthenticatorApiServiceInterface {
@@ -14,11 +13,7 @@ export interface AuthenticatorApiServiceInterface {
verifyRegistrationResponse(
userUuid: string,
name: string,
registrationCredential: Record<string, unknown>,
attestationResponse: Record<string, unknown>,
): Promise<VerifyAuthenticatorRegistrationResponseResponse>
generateAuthenticationOptions(): Promise<GenerateAuthenticatorAuthenticationOptionsResponse>
verifyAuthenticationResponse(
userUuid: string,
authenticationCredential: Record<string, unknown>,
): Promise<VerifyAuthenticatorAuthenticationResponseResponse>
generateAuthenticationOptions(username: string): Promise<GenerateAuthenticatorAuthenticationOptionsResponse>
}

View File

@@ -0,0 +1,3 @@
export interface GenerateAuthenticatorAuthenticationOptionsRequestParams {
username: string
}

View File

@@ -1,5 +0,0 @@
export interface VerifyAuthenticatorAuthenticationResponseRequestParams {
userUuid: string
authenticationCredential: Record<string, unknown>
[additionalParam: string]: unknown
}

View File

@@ -1,6 +1,6 @@
export interface VerifyAuthenticatorRegistrationResponseRequestParams {
userUuid: string
name: string
registrationCredential: Record<string, unknown>
attestationResponse: Record<string, unknown>
[additionalParam: string]: unknown
}

View File

@@ -1,7 +1,7 @@
export * from './ApiEndpointParam'
export * from './Authenticator/DeleteAuthenticatorRequestParams'
export * from './Authenticator/GenerateAuthenticatorAuthenticationOptionsRequestParams'
export * from './Authenticator/ListAuthenticatorsRequestParams'
export * from './Authenticator/VerifyAuthenticatorAuthenticationResponseRequestParams'
export * from './Authenticator/VerifyAuthenticatorRegistrationResponseRequestParams'
export * from './Recovery/RecoveryKeyParamsRequestParams'
export * from './Recovery/SignInWithRecoveryCodesRequestParams'

View File

@@ -1,10 +0,0 @@
import { Either } from '@standardnotes/common'
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
import { HttpResponse } from '../../Http/HttpResponse'
import { VerifyAuthenticatorAuthenticationResponseResponseBody } from './VerifyAuthenticatorAuthenticationResponseResponseBody'
export interface VerifyAuthenticatorAuthenticationResponseResponse extends HttpResponse {
data: Either<VerifyAuthenticatorAuthenticationResponseResponseBody, HttpErrorResponseBody>
}

View File

@@ -1,3 +0,0 @@
export interface VerifyAuthenticatorAuthenticationResponseResponseBody {
success: boolean
}

View File

@@ -8,8 +8,6 @@ export * from './Authenticator/GenerateAuthenticatorRegistrationOptionsResponse'
export * from './Authenticator/GenerateAuthenticatorRegistrationOptionsResponseBody'
export * from './Authenticator/ListAuthenticatorsResponse'
export * from './Authenticator/ListAuthenticatorsResponseBody'
export * from './Authenticator/VerifyAuthenticatorAuthenticationResponseResponse'
export * from './Authenticator/VerifyAuthenticatorAuthenticationResponseResponseBody'
export * from './Authenticator/VerifyAuthenticatorRegistrationResponseResponse'
export * from './Authenticator/VerifyAuthenticatorRegistrationResponseResponseBody'
export * from './Recovery/GenerateRecoveryCodesResponse'

View File

@@ -1,9 +1,9 @@
import { HttpServiceInterface } from '../../Http/HttpServiceInterface'
import {
ListAuthenticatorsRequestParams,
GenerateAuthenticatorAuthenticationOptionsRequestParams,
DeleteAuthenticatorRequestParams,
VerifyAuthenticatorRegistrationResponseRequestParams,
VerifyAuthenticatorAuthenticationResponseRequestParams,
} from '../../Request'
import {
ListAuthenticatorsResponse,
@@ -11,7 +11,6 @@ import {
GenerateAuthenticatorRegistrationOptionsResponse,
VerifyAuthenticatorRegistrationResponseResponse,
GenerateAuthenticatorAuthenticationOptionsResponse,
VerifyAuthenticatorAuthenticationResponseResponse,
} from '../../Response'
import { AuthenticatorServerInterface } from './AuthenticatorServerInterface'
import { Paths } from './Paths'
@@ -45,17 +44,11 @@ export class AuthenticatorServer implements AuthenticatorServerInterface {
return response as VerifyAuthenticatorRegistrationResponseResponse
}
async generateAuthenticationOptions(): Promise<GenerateAuthenticatorAuthenticationOptionsResponse> {
const response = await this.httpService.get(Paths.v1.generateAuthenticationOptions)
async generateAuthenticationOptions(
params: GenerateAuthenticatorAuthenticationOptionsRequestParams,
): Promise<GenerateAuthenticatorAuthenticationOptionsResponse> {
const response = await this.httpService.post(Paths.v1.generateAuthenticationOptions, params)
return response as GenerateAuthenticatorAuthenticationOptionsResponse
}
async verifyAuthenticationResponse(
params: VerifyAuthenticatorAuthenticationResponseRequestParams,
): Promise<VerifyAuthenticatorAuthenticationResponseResponse> {
const response = await this.httpService.post(Paths.v1.verifyAuthenticationResponse, params)
return response as VerifyAuthenticatorAuthenticationResponseResponse
}
}

View File

@@ -2,7 +2,7 @@ import {
ListAuthenticatorsRequestParams,
DeleteAuthenticatorRequestParams,
VerifyAuthenticatorRegistrationResponseRequestParams,
VerifyAuthenticatorAuthenticationResponseRequestParams,
GenerateAuthenticatorAuthenticationOptionsRequestParams,
} from '../../Request'
import {
ListAuthenticatorsResponse,
@@ -10,7 +10,6 @@ import {
GenerateAuthenticatorRegistrationOptionsResponse,
VerifyAuthenticatorRegistrationResponseResponse,
GenerateAuthenticatorAuthenticationOptionsResponse,
VerifyAuthenticatorAuthenticationResponseResponse,
} from '../../Response'
export interface AuthenticatorServerInterface {
@@ -20,8 +19,7 @@ export interface AuthenticatorServerInterface {
verifyRegistrationResponse(
params: VerifyAuthenticatorRegistrationResponseRequestParams,
): Promise<VerifyAuthenticatorRegistrationResponseResponse>
generateAuthenticationOptions(): Promise<GenerateAuthenticatorAuthenticationOptionsResponse>
verifyAuthenticationResponse(
params: VerifyAuthenticatorAuthenticationResponseRequestParams,
): Promise<VerifyAuthenticatorAuthenticationResponseResponse>
generateAuthenticationOptions(
params: GenerateAuthenticatorAuthenticationOptionsRequestParams,
): Promise<GenerateAuthenticatorAuthenticationOptionsResponse>
}

View File

@@ -4,7 +4,6 @@ const AuthenticatorPaths = {
generateRegistrationOptions: '/v1/authenticators/generate-registration-options',
verifyRegistrationResponse: '/v1/authenticators/verify-registration',
generateAuthenticationOptions: '/v1/authenticators/generate-authentication-options',
verifyAuthenticationResponse: '/v1/authenticators/verify-authentication',
}
export const Paths = {

View File

@@ -1,4 +1,4 @@
import { Uuid } from '@standardnotes/domain-core'
import { Username, Uuid } from '@standardnotes/domain-core'
export interface AuthenticatorClientInterface {
list(): Promise<Array<{ id: string; name: string }>>
@@ -9,6 +9,5 @@ export interface AuthenticatorClientInterface {
name: string,
registrationCredential: Record<string, unknown>,
): Promise<boolean>
generateAuthenticationOptions(): Promise<Record<string, unknown> | null>
verifyAuthenticationResponse(userUuid: Uuid, authenticationCredential: Record<string, unknown>): Promise<boolean>
generateAuthenticationOptions(username: Username): Promise<Record<string, unknown> | null>
}

View File

@@ -1,7 +1,7 @@
/* istanbul ignore file */
import { AuthenticatorApiServiceInterface } from '@standardnotes/api'
import { Uuid } from '@standardnotes/domain-core'
import { Username, Uuid } from '@standardnotes/domain-core'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { AbstractService } from '../Service/AbstractService'
@@ -79,9 +79,9 @@ export class AuthenticatorManager extends AbstractService implements Authenticat
}
}
async generateAuthenticationOptions(): Promise<Record<string, unknown> | null> {
async generateAuthenticationOptions(username: Username): Promise<Record<string, unknown> | null> {
try {
const result = await this.authenticatorApiService.generateAuthenticationOptions()
const result = await this.authenticatorApiService.generateAuthenticationOptions(username.value)
if (result.data.error) {
return null
@@ -92,24 +92,4 @@ export class AuthenticatorManager extends AbstractService implements Authenticat
return null
}
}
async verifyAuthenticationResponse(
userUuid: Uuid,
authenticationCredential: Record<string, unknown>,
): Promise<boolean> {
try {
const result = await this.authenticatorApiService.verifyAuthenticationResponse(
userUuid.value,
authenticationCredential,
)
if (result.data.error) {
return false
}
return result.data.success
} catch (error) {
return false
}
}
}

View File

@@ -20,6 +20,7 @@ export class ChallengePrompt implements ChallengePromptInterface {
public readonly secureTextEntry = true,
public readonly keyboardType?: ChallengeKeyboardType,
public readonly initialValue?: ChallengeRawValue,
public readonly contextData?: Record<string, unknown>,
) {
switch (this.validation) {
case ChallengeValidation.AccountPassword:
@@ -37,6 +38,11 @@ export class ChallengePrompt implements ChallengePromptInterface {
this.placeholder = placeholder ?? ''
this.validates = true
break
case ChallengeValidation.Authenticator:
this.title = title ?? ChallengePromptTitle.U2F
this.placeholder = placeholder ?? ''
this.validates = true
break
case ChallengeValidation.ProtectionSessionDuration:
this.title = title ?? ChallengePromptTitle.RememberFor
this.placeholder = placeholder ?? ''

View File

@@ -6,4 +6,5 @@ export const ChallengePromptTitle = {
Biometrics: 'Biometrics',
RememberFor: 'Remember For',
Mfa: 'Two-factor Authentication Code',
U2F: 'Security Key',
}

View File

@@ -1,3 +1,3 @@
/* istanbul ignore file */
export type ChallengeRawValue = number | string | boolean
export type ChallengeRawValue = number | string | boolean | Record<string, unknown>

View File

@@ -6,4 +6,5 @@ export enum ChallengeValidation {
AccountPassword = 2,
Biometric = 3,
ProtectionSessionDuration = 4,
Authenticator = 5,
}

View File

@@ -121,6 +121,7 @@ export const SessionStrings = {
},
SessionRestored: 'Your session has been successfully restored.',
EnterMfa: 'Please enter your two-factor authentication code.',
InputU2FDevice: 'Please authenticate with your U2F device.',
MfaInputPlaceholder: 'Two-factor authentication code',
EmailInputPlaceholder: 'Email',
PasswordInputPlaceholder: 'Password',

View File

@@ -105,11 +105,11 @@ import { GetRecoveryCodes } from '@Lib/Domain/UseCase/GetRecoveryCodes/GetRecove
import { AddAuthenticator } from '@Lib/Domain/UseCase/AddAuthenticator/AddAuthenticator'
import { ListAuthenticators } from '@Lib/Domain/UseCase/ListAuthenticators/ListAuthenticators'
import { DeleteAuthenticator } from '@Lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator'
import { VerifyAuthenticator } from '@Lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator'
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 { RevisionMetadata } from '@Lib/Domain/Revision/RevisionMetadata'
import { GetAuthenticatorAuthenticationResponse } from '@Lib/Domain/UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponse'
/** How often to automatically sync, in milliseconds */
const DEFAULT_AUTO_SYNC_INTERVAL = 30_000
@@ -193,7 +193,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
private declare _addAuthenticator: AddAuthenticator
private declare _listAuthenticators: ListAuthenticators
private declare _deleteAuthenticator: DeleteAuthenticator
private declare _verifyAuthenticator: VerifyAuthenticator
private declare _getAuthenticatorAuthenticationResponse: GetAuthenticatorAuthenticationResponse
private declare _listRevisions: ListRevisions
private declare _getRevision: GetRevision
private declare _deleteRevision: DeleteRevision
@@ -299,8 +299,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
return this._deleteAuthenticator
}
get verifyAuthenticator(): UseCaseInterface<void> {
return this._verifyAuthenticator
get getAuthenticatorAuthenticationResponse(): UseCaseInterface<Record<string, unknown>> {
return this._getAuthenticatorAuthenticationResponse
}
get listRevisions(): UseCaseInterface<Array<RevisionMetadata>> {
@@ -1272,7 +1272,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
;(this._addAuthenticator as unknown) = undefined
;(this._listAuthenticators as unknown) = undefined
;(this._deleteAuthenticator as unknown) = undefined
;(this._verifyAuthenticator as unknown) = undefined
;(this._getAuthenticatorAuthenticationResponse as unknown) = undefined
;(this._listRevisions as unknown) = undefined
;(this._getRevision as unknown) = undefined
;(this._deleteRevision as unknown) = undefined
@@ -1849,7 +1849,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this._deleteAuthenticator = new DeleteAuthenticator(this.authenticatorManager)
this._verifyAuthenticator = new VerifyAuthenticator(
this._getAuthenticatorAuthenticationResponse = new GetAuthenticatorAuthenticationResponse(
this.authenticatorManager,
this.options.u2fAuthenticatorVerificationPromptFunction,
)

View File

@@ -1,27 +1,37 @@
import { AuthenticatorClientInterface } from '@standardnotes/services'
import { VerifyAuthenticator } from './VerifyAuthenticator'
import { GetAuthenticatorAuthenticationResponse } from './GetAuthenticatorAuthenticationResponse'
describe('VerifyAuthenticator', () => {
describe('GetAuthenticatorAuthenticationResponse', () => {
let authenticatorClient: AuthenticatorClientInterface
let authenticatorVerificationPromptFunction: (
authenticationOptions: Record<string, unknown>,
) => Promise<Record<string, unknown>>
const createUseCase = () => new VerifyAuthenticator(authenticatorClient, authenticatorVerificationPromptFunction)
const createUseCase = () => new GetAuthenticatorAuthenticationResponse(authenticatorClient, authenticatorVerificationPromptFunction)
beforeEach(() => {
authenticatorClient = {} as jest.Mocked<AuthenticatorClientInterface>
authenticatorClient.generateAuthenticationOptions = jest.fn().mockResolvedValue({ foo: 'bar' })
authenticatorClient.verifyAuthenticationResponse = jest.fn().mockResolvedValue(true)
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)
const result = await createUseCase().execute({ userUuid: '00000000-0000-0000-0000-000000000000' })
const result = await createUseCase().execute({
username: 'test@test.te',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Could not generate authenticator authentication options')
@@ -30,36 +40,25 @@ describe('VerifyAuthenticator', () => {
it('should return an error if authenticator verification prompt function fails', async () => {
authenticatorVerificationPromptFunction = jest.fn().mockRejectedValue(new Error('error'))
const result = await createUseCase().execute({ userUuid: '00000000-0000-0000-0000-000000000000' })
const result = await createUseCase().execute({
username: 'test@test.te',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Could not generate authenticator authentication options: error')
})
it('should return an error if authenticator client fails to verify authentication response', async () => {
authenticatorClient.verifyAuthenticationResponse = jest.fn().mockResolvedValue(false)
const result = await createUseCase().execute({ userUuid: '00000000-0000-0000-0000-000000000000' })
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: 'test@test.te',
})
it('should return ok if authenticator client succeeds to verify authentication response', async () => {
const result = await createUseCase().execute({ userUuid: '00000000-0000-0000-0000-000000000000' })
expect(result.isFailed()).toBe(false)
})
it('should return an error if user uuid is invalid', async () => {
const result = await createUseCase().execute({ userUuid: 'invalid' })
expect(result.isFailed()).toBe(true)
})
it('should return error if authenticatorVerificationPromptFunction is not provided', async () => {
const result = await new VerifyAuthenticator(authenticatorClient).execute({
userUuid: '00000000-0000-0000-0000-000000000000',
const result = await new GetAuthenticatorAuthenticationResponse(authenticatorClient).execute({
username: 'test@test.te',
})
expect(result.isFailed()).toBe(true)

View File

@@ -1,9 +1,8 @@
import { AuthenticatorClientInterface } from '@standardnotes/services'
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { Result, UseCaseInterface, Username } from '@standardnotes/domain-core'
import { GetAuthenticatorAuthenticationResponseDTO } from './GetAuthenticatorAuthenticationResponseDTO'
import { VerifyAuthenticatorDTO } from './VerifyAuthenticatorDTO'
export class VerifyAuthenticator implements UseCaseInterface<void> {
export class GetAuthenticatorAuthenticationResponse implements UseCaseInterface<Record<string, unknown>> {
constructor(
private authenticatorClient: AuthenticatorClientInterface,
private authenticatorVerificationPromptFunction?: (
@@ -11,20 +10,20 @@ export class VerifyAuthenticator implements UseCaseInterface<void> {
) => Promise<Record<string, unknown>>,
) {}
async execute(dto: VerifyAuthenticatorDTO): Promise<Result<void>> {
async execute(dto: GetAuthenticatorAuthenticationResponseDTO): Promise<Result<Record<string, unknown>>> {
if (!this.authenticatorVerificationPromptFunction) {
return Result.fail(
'Could not generate authenticator authentication options: No authenticator verification prompt function provided',
)
}
const userUuidOrError = Uuid.create(dto.userUuid)
if (userUuidOrError.isFailed()) {
return Result.fail(`Could not generate authenticator authentication options: ${userUuidOrError.getError()}`)
const usernameOrError = Username.create(dto.username)
if (usernameOrError.isFailed()) {
return Result.fail(`Could not generate authenticator authentication options: ${usernameOrError.getError()}`)
}
const userUuid = userUuidOrError.getValue()
const username = usernameOrError.getValue()
const authenticationOptions = await this.authenticatorClient.generateAuthenticationOptions()
const authenticationOptions = await this.authenticatorClient.generateAuthenticationOptions(username)
if (authenticationOptions === null) {
return Result.fail('Could not generate authenticator authentication options')
}
@@ -36,14 +35,6 @@ export class VerifyAuthenticator implements UseCaseInterface<void> {
return Result.fail(`Could not generate authenticator authentication options: ${(error as Error).message}`)
}
const verificationResponse = await this.authenticatorClient.verifyAuthenticationResponse(
userUuid,
authenticatorResponse,
)
if (!verificationResponse) {
return Result.fail('Could not generate authenticator authentication options')
}
return Result.ok()
return Result.ok(authenticatorResponse)
}
}

View File

@@ -0,0 +1,3 @@
export interface GetAuthenticatorAuthenticationResponseDTO {
username: string
}

View File

@@ -9,7 +9,7 @@ export interface UseCaseContainerInterface {
get addAuthenticator(): UseCaseInterface<void>
get listAuthenticators(): UseCaseInterface<Array<{ id: string; name: string }>>
get deleteAuthenticator(): UseCaseInterface<void>
get verifyAuthenticator(): UseCaseInterface<void>
get getAuthenticatorAuthenticationResponse(): UseCaseInterface<Record<string, unknown>>
get listRevisions(): UseCaseInterface<Array<RevisionMetadata>>
get getRevision(): UseCaseInterface<HistoryEntry>
get deleteRevision(): UseCaseInterface<void>

View File

@@ -1,3 +0,0 @@
export interface VerifyAuthenticatorDTO {
userUuid: string
}

View File

@@ -232,6 +232,7 @@ export class SNApiService
email: string
mfaKeyPath?: string
mfaCode?: string
authenticatorResponse?: Record<string, unknown>
}): Promise<Responses.KeyParamsResponse | Responses.HttpResponse> {
const codeVerifier = this.crypto.generateRandomKey(256)
this.inMemoryStore.setValue(StorageKey.CodeVerifier, codeVerifier)
@@ -247,6 +248,10 @@ export class SNApiService
params[dto.mfaKeyPath] = dto.mfaCode
}
if (dto.authenticatorResponse) {
params.authenticator_response = dto.authenticatorResponse
}
return this.request({
verb: HttpVerb.Post,
url: joinPaths(this.host, Paths.v2.keyParams),

View File

@@ -93,6 +93,8 @@ export class ChallengeService extends AbstractService implements ChallengeServic
return this.protocolService.validateAccountPassword(value.value as string)
case ChallengeValidation.Biometric:
return { valid: value.value === true }
case ChallengeValidation.Authenticator:
return { valid: 'id' in (value.value as Record<string, unknown>) }
case ChallengeValidation.ProtectionSessionDuration:
return { valid: isValidProtectionSessionLength(value.value) }
default:

View File

@@ -48,6 +48,7 @@ import { ChallengeService } from '../Challenge'
import {
ApiCallError,
ErrorMessage,
ErrorTag,
HttpErrorResponseBody,
HttpServiceInterface,
UserApiServiceInterface,
@@ -284,6 +285,35 @@ export class SNSessionManager
return (response as Responses.GetAvailableSubscriptionsResponse).data!
}
private async promptForU2FVerification(username: string): Promise<Record<string, unknown> | undefined> {
const challenge = new Challenge(
[
new ChallengePrompt(
ChallengeValidation.Authenticator,
ChallengePromptTitle.U2F,
undefined,
false,
undefined,
undefined,
{
username,
},
),
],
ChallengeReason.Custom,
true,
SessionStrings.InputU2FDevice,
)
const response = await this.challengeService.promptForChallengeResponse(challenge)
if (!response) {
return undefined
}
return response.values[0].value as Record<string, unknown>
}
private async promptForMfaValue(): Promise<string | undefined> {
const challenge = new Challenge(
[
@@ -344,31 +374,28 @@ export class SNSessionManager
return registerResponse.data
}
private async retrieveKeyParams(
email: string,
mfaKeyPath?: string,
mfaCode?: string,
): Promise<{
private async retrieveKeyParams(dto: {
email: string
mfaKeyPath?: string
mfaCode?: string
authenticatorResponse?: Record<string, unknown>
}): Promise<{
keyParams?: SNRootKeyParams
response: Responses.KeyParamsResponse | Responses.HttpResponse
mfaKeyPath?: string
mfaCode?: string
}> {
const response = await this.apiService.getAccountKeyParams({
email,
mfaKeyPath,
mfaCode,
})
const response = await this.apiService.getAccountKeyParams(dto)
if (response.error || isNullOrUndefined(response.data)) {
if (mfaCode) {
if (dto.mfaCode) {
await this.alertService.alert(SignInStrings.IncorrectMfa)
}
if (response.error?.payload?.mfa_key) {
/** Prompt for MFA code and try again */
const inputtedCode = await this.promptForMfaValue()
if (!inputtedCode) {
/** User dismissed window without input */
if ([ErrorTag.U2FRequired, ErrorTag.MfaRequired].includes(response.error?.tag as ErrorTag)) {
const isU2FRequired = response.error?.tag === ErrorTag.U2FRequired
const result = isU2FRequired ? await this.promptForU2FVerification(dto.email) : await this.promptForMfaValue()
if (!result) {
return {
response: this.apiService.createErrorResponse(
SignInStrings.SignInCanceledMissingMfa,
@@ -376,19 +403,25 @@ export class SNSessionManager
),
}
}
return this.retrieveKeyParams(email, response.error.payload.mfa_key, inputtedCode)
return this.retrieveKeyParams({
email: dto.email,
mfaKeyPath: isU2FRequired ? undefined : response.error?.payload?.mfa_key,
mfaCode: isU2FRequired ? undefined : (result as string),
authenticatorResponse: isU2FRequired ? (result as Record<string, unknown>) : undefined,
})
} else {
return { response }
}
}
/** Make sure to use client value for identifier/email */
const keyParams = KeyParamsFromApiResponse(response as Responses.KeyParamsResponse, email)
const keyParams = KeyParamsFromApiResponse(response as Responses.KeyParamsResponse, dto.email)
if (!keyParams || !keyParams.version) {
return {
response: this.apiService.createErrorResponse(API_MESSAGE_FALLBACK_LOGIN_FAIL),
}
}
return { keyParams, response, mfaKeyPath, mfaCode }
return { keyParams, response, mfaKeyPath: dto.mfaKeyPath, mfaCode: dto.mfaCode }
}
public async signIn(
@@ -425,7 +458,9 @@ export class SNSessionManager
ephemeral = false,
minAllowedVersion?: Common.ProtocolVersion,
): Promise<SessionManagerResponse> {
const paramsResult = await this.retrieveKeyParams(email)
const paramsResult = await this.retrieveKeyParams({
email,
})
if (paramsResult.response.error) {
return {
response: paramsResult.response,

View File

@@ -37,6 +37,7 @@
"@babel/preset-env": "*",
"@standardnotes/api": "workspace:*",
"@standardnotes/common": "^1.46.4",
"@standardnotes/domain-core": "^1.11.1",
"@standardnotes/domain-events": "^2.106.0",
"@standardnotes/encryption": "workspace:*",
"@standardnotes/features": "workspace:*",
@@ -84,8 +85,5 @@
"webpack": "*",
"webpack-cli": "*",
"webpack-merge": "^5.8.0"
},
"dependencies": {
"@standardnotes/domain-core": "^1.11.1"
}
}

View File

@@ -14,6 +14,7 @@ module.exports = {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'@standardnotes/toast': 'identity-obj-proxy',
'@standardnotes/styles': 'identity-obj-proxy',
'@simplewebauthn/browser': 'identity-obj-proxy',
},
globals: {
__WEB_VERSION__: '1.0.0',

View File

@@ -116,6 +116,7 @@
"app/**/*.{js,ts,jsx,tsx,css,md}": "prettier --write"
},
"dependencies": {
"@lexical/headless": "^0.7.6"
"@lexical/headless": "^0.7.6",
"@simplewebauthn/browser": "^7.0.0"
}
}

View File

@@ -25,6 +25,7 @@ import {
ApplicationOptionsDefaults,
} from '@standardnotes/snjs'
import { makeObservable, observable } from 'mobx'
import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
import { PanelResizedData } from '@/Types/PanelResizedData'
import { isAndroid, isDesktopApplication, isIOS } from '@/Utils'
import { DesktopManager } from './Device/DesktopManager'
@@ -83,6 +84,8 @@ export class WebApplication extends SNApplication implements WebApplicationInter
deviceInterface.environment === Environment.Mobile ? 250 : ApplicationOptionsDefaults.sleepBetweenBatches,
allowMultipleSelection: deviceInterface.environment !== Environment.Mobile,
allowNoteSelectionStatePersistence: deviceInterface.environment !== Environment.Mobile,
u2fAuthenticatorRegistrationPromptFunction: startRegistration,
u2fAuthenticatorVerificationPromptFunction: startAuthentication,
})
makeObservable(this, {

View File

@@ -180,13 +180,23 @@ const ChallengeModal: FunctionComponent<Props> = ({
}, [application, challenge, onDismiss])
const biometricPrompt = challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.Biometric)
const authenticatorPrompt = challenge.prompts.find(
(prompt) => prompt.validation === ChallengeValidation.Authenticator,
)
const hasOnlyBiometricPrompt = challenge.prompts.length === 1 && !!biometricPrompt
const wasBiometricInputSuccessful = biometricPrompt && !!values[biometricPrompt.id].value
const hasOnlyAuthenticatorPrompt = challenge.prompts.length === 1 && !!authenticatorPrompt
const wasBiometricInputSuccessful = !!biometricPrompt && !!values[biometricPrompt.id].value
const wasAuthenticatorInputSuccessful = !!authenticatorPrompt && !!values[authenticatorPrompt.id].value
const hasSecureTextPrompt = challenge.prompts.some((prompt) => prompt.secureTextEntry)
const shouldShowSubmitButton = !(hasOnlyBiometricPrompt || hasOnlyAuthenticatorPrompt)
useEffect(() => {
const shouldAutoSubmit = hasOnlyBiometricPrompt && wasBiometricInputSuccessful
const shouldAutoSubmit =
(hasOnlyBiometricPrompt && wasBiometricInputSuccessful) ||
(hasOnlyAuthenticatorPrompt && wasAuthenticatorInputSuccessful)
const shouldFocusSecureTextPrompt = hasSecureTextPrompt && wasBiometricInputSuccessful
if (shouldAutoSubmit) {
submit()
} else if (shouldFocusSecureTextPrompt) {
@@ -195,7 +205,14 @@ const ChallengeModal: FunctionComponent<Props> = ({
) as HTMLInputElement | null
secureTextEntry?.focus()
}
}, [wasBiometricInputSuccessful, hasOnlyBiometricPrompt, submit, hasSecureTextPrompt])
}, [
wasBiometricInputSuccessful,
hasOnlyBiometricPrompt,
submit,
hasSecureTextPrompt,
hasOnlyAuthenticatorPrompt,
wasAuthenticatorInputSuccessful,
])
useEffect(() => {
const removeListener = application.addAndroidBackHandlerEventListener(() => {
@@ -289,12 +306,15 @@ const ChallengeModal: FunctionComponent<Props> = ({
index={index}
onValueChange={onValueChange}
isInvalid={values[prompt.id].invalid}
contextData={prompt.contextData}
/>
))}
</form>
{shouldShowSubmitButton && (
<Button primary disabled={isProcessing} className="mt-1 mb-3.5 min-w-76" onClick={submit}>
{isProcessing ? 'Generating Keys...' : 'Submit'}
</Button>
)}
{shouldShowForgotPasscode && (
<Button
className="flex min-w-76 items-center justify-center"

View File

@@ -11,6 +11,7 @@ import { ChallengeModalValues } from './ChallengeModalValues'
import { WebApplication } from '@/Application/Application'
import { InputValue } from './InputValue'
import BiometricsPrompt from './BiometricsPrompt'
import U2FPrompt from './U2FPrompt'
type Props = {
application: WebApplication
@@ -19,6 +20,7 @@ type Props = {
index: number
onValueChange: (value: InputValue['value'], prompt: ChallengePrompt) => void
isInvalid: boolean
contextData?: Record<string, unknown>
}
const ChallengeModalPrompt: FunctionComponent<Props> = ({
@@ -28,9 +30,11 @@ const ChallengeModalPrompt: FunctionComponent<Props> = ({
index,
onValueChange,
isInvalid,
contextData,
}) => {
const inputRef = useRef<HTMLInputElement>(null)
const biometricsButtonRef = useRef<HTMLButtonElement>(null)
const authenticatorButtonRef = useRef<HTMLButtonElement>(null)
const activatePrompt = useCallback(async () => {
if (prompt.validation === ChallengeValidation.Biometric) {
@@ -137,6 +141,14 @@ const ChallengeModalPrompt: FunctionComponent<Props> = ({
prompt={prompt}
buttonRef={biometricsButtonRef}
/>
) : prompt.validation === ChallengeValidation.Authenticator ? (
<U2FPrompt
application={application}
onValueChange={onValueChange}
prompt={prompt}
buttonRef={authenticatorButtonRef}
contextData={contextData}
/>
) : prompt.secureTextEntry ? (
<DecoratedPasswordInput
ref={inputRef}

View File

@@ -2,6 +2,6 @@ import { ChallengePrompt } from '@standardnotes/snjs'
export type InputValue = {
prompt: ChallengePrompt
value: string | number | boolean
value: string | number | boolean | Record<string, unknown>
invalid: boolean
}

View File

@@ -0,0 +1,62 @@
import { WebApplication } from '@/Application/Application'
import { ChallengePrompt } from '@standardnotes/services'
import { RefObject, useState } from 'react'
import Button from '../Button/Button'
import Icon from '../Icon/Icon'
import { InputValue } from './InputValue'
type Props = {
application: WebApplication
onValueChange: (value: InputValue['value'], prompt: ChallengePrompt) => void
prompt: ChallengePrompt
buttonRef: RefObject<HTMLButtonElement>
contextData?: Record<string, unknown>
}
const U2FPrompt = ({ application, onValueChange, prompt, buttonRef, contextData }: Props) => {
const [authenticatorResponse, setAuthenticatorResponse] = useState<Record<string, unknown> | null>(null)
const [error, setError] = useState('')
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
}
const authenticatorResponseOrError = await application.getAuthenticatorAuthenticationResponse.execute({
username: contextData.username,
})
if (authenticatorResponseOrError.isFailed()) {
setError(authenticatorResponseOrError.getError())
return
}
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>
</div>
)
}
export default U2FPrompt

View File

@@ -11,6 +11,8 @@ import ErroredItems from './ErroredItems'
import PreferencesPane from '@/Components/Preferences/PreferencesComponents/PreferencesPane'
import BiometricsLock from '@/Components/Preferences/Panes/Security/BiometricsLock'
import MultitaskingPrivacy from '@/Components/Preferences/Panes/Security/MultitaskingPrivacy'
import U2FWrapper from './U2F/U2FWrapper'
import { featureTrunkEnabled, FeatureTrunkName } from '@/FeatureTrunk'
interface SecurityProps extends MfaProps {
viewControllerManager: ViewControllerManager
@@ -32,6 +34,9 @@ const Security: FunctionComponent<SecurityProps> = (props) => {
userProvider={props.userProvider}
application={props.application}
/>
{featureTrunkEnabled(FeatureTrunkName.U2F) && (
<U2FWrapper userProvider={props.userProvider} application={props.application} />
)}
{isNativeMobileWeb && <MultitaskingPrivacy application={props.application} />}
<PasscodeLock viewControllerManager={props.viewControllerManager} application={props.application} />
{isNativeMobileWeb && <BiometricsLock application={props.application} />}

View File

@@ -0,0 +1,94 @@
import { FunctionComponent, useCallback, useState } from 'react'
import { observer } from 'mobx-react-lite'
import { UseCaseInterface } from '@standardnotes/snjs'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import { UserProvider } from '@/Components/Preferences/Providers'
import Modal from '@/Components/Modal/Modal'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
type Props = {
userProvider: UserProvider
addAuthenticator: UseCaseInterface<void>
onDeviceAddingModalToggle: (show: boolean) => void
onDeviceAdded: () => Promise<void>
}
const U2FAddDeviceView: FunctionComponent<Props> = ({
userProvider,
addAuthenticator,
onDeviceAddingModalToggle,
onDeviceAdded,
}) => {
const [deviceName, setDeviceName] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const handleDeviceNameChange = useCallback((deviceName: string) => {
setDeviceName(deviceName)
}, [])
const handleAddDeviceClick = useCallback(async () => {
if (!deviceName) {
setErrorMessage('Device name is required')
return
}
const user = userProvider.getUser()
if (user === undefined) {
setErrorMessage('User not found')
return
}
const authenticatorAddedOrError = await addAuthenticator.execute({
userUuid: user.uuid,
authenticatorName: deviceName,
})
if (authenticatorAddedOrError.isFailed()) {
setErrorMessage(authenticatorAddedOrError.getError())
return
}
onDeviceAddingModalToggle(false)
await onDeviceAdded()
}, [deviceName, setErrorMessage, userProvider, addAuthenticator, onDeviceAddingModalToggle, onDeviceAdded])
const closeModal = () => {
onDeviceAddingModalToggle(false)
}
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
return (
<Modal
title="Add U2F Device"
close={closeModal}
actions={[
{
label: 'Cancel',
type: 'cancel',
onClick: closeModal,
mobileSlot: 'left',
hidden: !isMobileScreen,
},
{
label: (
<>
Add <span className="hidden md:inline">Device</span>
</>
),
type: 'primary',
onClick: handleAddDeviceClick,
mobileSlot: 'right',
},
]}
>
<div className="w-25 h-25 flex items-center justify-center bg-info">...Some Cool Device Picture Here...</div>
<div className="flex flex-grow flex-col gap-2">
<DecoratedInput className={{ container: 'w-92 ml-4' }} value={deviceName} onChange={handleDeviceNameChange} />
</div>
{errorMessage && <div className="text-error">{errorMessage}</div>}
</Modal>
)
}
export default observer(U2FAddDeviceView)

View File

@@ -0,0 +1,7 @@
import { WebApplication } from '@/Application/Application'
import { UserProvider } from '@/Components/Preferences/Providers'
export interface U2FProps {
userProvider: UserProvider
application: WebApplication
}

View File

@@ -0,0 +1,19 @@
import { FunctionComponent } from 'react'
import { observer } from 'mobx-react-lite'
import { Text } from '@/Components/Preferences/PreferencesComponents/Content'
import { UserProvider } from '@/Components/Preferences/Providers'
type Props = {
userProvider: UserProvider
}
const U2FDescription: FunctionComponent<Props> = ({ userProvider }) => {
if (userProvider.getUser() === undefined) {
return <Text>Sign in or register for an account to configure U2F.</Text>
}
return <Text>Authenticate with a U2F hardware device.</Text>
}
export default observer(U2FDescription)

View File

@@ -0,0 +1,57 @@
import { FunctionComponent, useCallback } from 'react'
import { observer } from 'mobx-react-lite'
import { Text } from '@/Components/Preferences/PreferencesComponents/Content'
import { WebApplication } from '@/Application/Application'
import Button from '@/Components/Button/Button'
type Props = {
application: WebApplication
devices: Array<{ id: string; name: string }>
onDeviceDeleted: () => Promise<void>
onError: (error: string) => void
}
const U2FDevicesList: FunctionComponent<Props> = ({ application, devices, onError, onDeviceDeleted }) => {
const handleDeleteButtonOnClick = useCallback(
async (authenticatorId: string) => {
const deleteAuthenticatorOrError = await application.deleteAuthenticator.execute({
authenticatorId,
})
if (deleteAuthenticatorOrError.isFailed()) {
onError(deleteAuthenticatorOrError.getError())
return
}
await onDeviceDeleted()
},
[application, onDeviceDeleted, onError],
)
return (
<div className="flex flex-row items-center">
{devices.length > 0 && (
<div className="flex flex-grow flex-col">
<div>
<Text>Devices:</Text>
</div>
{devices.map((device) => (
<div key="device-{device.id}">
<Text>{device.name}</Text>
<Button
key={device.id}
primary={true}
label="Delete"
onClick={async () => handleDeleteButtonOnClick(device.id)}
></Button>
</div>
))}
</div>
)}
</div>
)
}
export default observer(U2FDevicesList)

View File

@@ -0,0 +1,19 @@
import { FunctionComponent } from 'react'
import { observer } from 'mobx-react-lite'
import { Title } from '@/Components/Preferences/PreferencesComponents/Content'
import { UserProvider } from '@/Components/Preferences/Providers'
type Props = {
userProvider: UserProvider
}
const U2FTitle: FunctionComponent<Props> = ({ userProvider }) => {
if (userProvider.getUser() === undefined) {
return <Title>Universal 2nd Factor authentication not available</Title>
}
return <Title>Universal 2nd Factor authentication</Title>
}
export default observer(U2FTitle)

View File

@@ -0,0 +1,80 @@
import { FunctionComponent, useCallback, useEffect, useState } from 'react'
import { observer } from 'mobx-react-lite'
import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment'
import { WebApplication } from '@/Application/Application'
import { UserProvider } from '@/Components/Preferences/Providers'
import U2FTitle from './U2FTitle'
import U2FDescription from './U2FDescription'
import Button from '@/Components/Button/Button'
import U2FAddDeviceView from '../U2FAddDeviceView'
import U2FDevicesList from './U2FDevicesList'
type Props = {
application: WebApplication
userProvider: UserProvider
}
const U2FView: FunctionComponent<Props> = ({ application, userProvider }) => {
const [showDeviceAddingModal, setShowDeviceAddingModal] = useState(false)
const [devices, setDevices] = useState<Array<{ id: string; name: string }>>([])
const [error, setError] = useState('')
const handleAddDeviceClick = useCallback(() => {
setShowDeviceAddingModal(true)
}, [])
const loadAuthenticatorDevices = useCallback(async () => {
const authenticatorListOrError = await application.listAuthenticators.execute()
if (authenticatorListOrError.isFailed()) {
setError(authenticatorListOrError.getError())
return
}
setDevices(authenticatorListOrError.getValue())
}, [setError, setDevices, application])
useEffect(() => {
loadAuthenticatorDevices().catch(console.error)
}, [loadAuthenticatorDevices])
return (
<>
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-row items-center">
<div className="flex flex-grow flex-col">
<U2FTitle userProvider={userProvider} />
<U2FDescription userProvider={userProvider} />
</div>
<PreferencesSegment>
<Button label="Add Device" primary onClick={handleAddDeviceClick} />
</PreferencesSegment>
</div>
</PreferencesSegment>
<PreferencesSegment>
{error && <div className="text-red-500">{error}</div>}
<U2FDevicesList
application={application}
devices={devices}
onError={setError}
onDeviceDeleted={loadAuthenticatorDevices}
/>
</PreferencesSegment>
</PreferencesGroup>
{showDeviceAddingModal && (
<U2FAddDeviceView
onDeviceAddingModalToggle={setShowDeviceAddingModal}
onDeviceAdded={loadAuthenticatorDevices}
userProvider={userProvider}
addAuthenticator={application.addAuthenticator}
/>
)}
</>
)
}
export default observer(U2FView)

View File

@@ -0,0 +1,10 @@
import { FunctionComponent } from 'react'
import { U2FProps } from './U2FProps'
import U2FView from './U2FView/U2FView'
const U2FWrapper: FunctionComponent<U2FProps> = (props) => {
return <U2FView application={props.application} userProvider={props.userProvider} />
}
export default U2FWrapper

View File

@@ -3,11 +3,13 @@ import { isDev } from '@/Utils'
export enum FeatureTrunkName {
Super,
ImportTools,
U2F,
}
const FeatureTrunkStatus: Record<FeatureTrunkName, boolean> = {
[FeatureTrunkName.Super]: isDev && true,
[FeatureTrunkName.ImportTools]: isDev && true,
[FeatureTrunkName.U2F]: isDev && true,
}
export function featureTrunkEnabled(trunk: FeatureTrunkName): boolean {

View File

@@ -4335,6 +4335,13 @@ __metadata:
languageName: node
linkType: hard
"@simplewebauthn/browser@npm:^7.0.0":
version: 7.0.0
resolution: "@simplewebauthn/browser@npm:7.0.0"
checksum: eb8d7e2d923649c116275cc9bfbabfa27a180cd33dbf9d6a28c7aa9460ea79cd25204c9a7d76ed8cc24764da4a09b5939209aa30e9b295b9d54e497bb9b652a4
languageName: node
linkType: hard
"@sinclair/typebox@npm:^0.24.1":
version: 0.24.46
resolution: "@sinclair/typebox@npm:0.24.46"
@@ -5264,6 +5271,7 @@ __metadata:
"@reach/listbox": ^0.18.0
"@reach/tooltip": ^0.18.0
"@reach/visually-hidden": ^0.18.0
"@simplewebauthn/browser": ^7.0.0
"@standardnotes/authenticator": ^2.3.9
"@standardnotes/autobiography-theme": ^1.2.7
"@standardnotes/blocks-editor": "workspace:*"