From f5683cfd9494db8e25010e9c4ef5fd4d8fcd6bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Wed, 28 Dec 2022 15:50:43 +0100 Subject: [PATCH] feat(auth): add verifying authenticator registration response --- packages/auth/src/Bootstrap/Container.ts | 9 + packages/auth/src/Bootstrap/Types.ts | 1 + ...thenticatorChallengeRepositoryInterface.ts | 3 + .../AuthenticatorRepositoryInterface.ts | 1 + .../src/Domain/Authenticator/RelyingParty.ts | 4 + ...enerateAuthenticatorRegistrationOptions.ts | 8 +- ...yAuthenticatorRegistrationResponse.spec.ts | 209 ++++++++++++++++++ ...VerifyAuthenticatorRegistrationResponse.ts | 61 +++++ ...ifyAuthenticatorRegistrationResponseDTO.ts | 5 + .../MySQLAuthenticatorChallengeRepository.ts | 18 +- .../MySQL/MySQLAuthenticatorRepository.ts | 6 + 11 files changed, 319 insertions(+), 6 deletions(-) create mode 100644 packages/auth/src/Domain/Authenticator/RelyingParty.ts create mode 100644 packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse.spec.ts create mode 100644 packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse.ts create mode 100644 packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponseDTO.ts diff --git a/packages/auth/src/Bootstrap/Container.ts b/packages/auth/src/Bootstrap/Container.ts index 627c9e618..993a647d4 100644 --- a/packages/auth/src/Bootstrap/Container.ts +++ b/packages/auth/src/Bootstrap/Container.ts @@ -214,6 +214,7 @@ import { MySQLAuthenticatorRepository } from '../Infra/MySQL/MySQLAuthenticatorR import { AuthenticatorChallengeRepositoryInterface } from '../Domain/Authenticator/AuthenticatorChallengeRepositoryInterface' import { MySQLAuthenticatorChallengeRepository } from '../Infra/MySQL/MySQLAuthenticatorChallengeRepository' import { GenerateAuthenticatorRegistrationOptions } from '../Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions' +import { VerifyAuthenticatorRegistrationResponse } from '../Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse' // eslint-disable-next-line @typescript-eslint/no-var-requires const newrelicFormatter = require('@newrelic/winston-enricher') @@ -560,6 +561,14 @@ export class ContainerConfigLoader { container.get(TYPES.AuthenticatorChallengeRepository), ), ) + container + .bind(TYPES.VerifyAuthenticatorRegistrationResponse) + .toConstantValue( + new VerifyAuthenticatorRegistrationResponse( + container.get(TYPES.AuthenticatorRepository), + container.get(TYPES.AuthenticatorChallengeRepository), + ), + ) container .bind(TYPES.CleanupSessionTraces) diff --git a/packages/auth/src/Bootstrap/Types.ts b/packages/auth/src/Bootstrap/Types.ts index 8770edcf4..954c309d9 100644 --- a/packages/auth/src/Bootstrap/Types.ts +++ b/packages/auth/src/Bootstrap/Types.ts @@ -134,6 +134,7 @@ const TYPES = { CleanupSessionTraces: Symbol.for('CleanupSessionTraces'), PersistStatistics: Symbol.for('PersistStatistics'), GenerateAuthenticatorRegistrationOptions: Symbol.for('GenerateAuthenticatorRegistrationOptions'), + VerifyAuthenticatorRegistrationResponse: Symbol.for('VerifyAuthenticatorRegistrationResponse'), // Handlers UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'), AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'), diff --git a/packages/auth/src/Domain/Authenticator/AuthenticatorChallengeRepositoryInterface.ts b/packages/auth/src/Domain/Authenticator/AuthenticatorChallengeRepositoryInterface.ts index b30032e30..24c8f7c4c 100644 --- a/packages/auth/src/Domain/Authenticator/AuthenticatorChallengeRepositoryInterface.ts +++ b/packages/auth/src/Domain/Authenticator/AuthenticatorChallengeRepositoryInterface.ts @@ -1,5 +1,8 @@ +import { Uuid } from '@standardnotes/domain-core' + import { AuthenticatorChallenge } from './AuthenticatorChallenge' export interface AuthenticatorChallengeRepositoryInterface { + findByUserUuidAndChallenge(userUuid: Uuid, challenge: Buffer): Promise save(authenticatorChallenge: AuthenticatorChallenge): Promise } diff --git a/packages/auth/src/Domain/Authenticator/AuthenticatorRepositoryInterface.ts b/packages/auth/src/Domain/Authenticator/AuthenticatorRepositoryInterface.ts index 60b2a74c2..dfe779ecc 100644 --- a/packages/auth/src/Domain/Authenticator/AuthenticatorRepositoryInterface.ts +++ b/packages/auth/src/Domain/Authenticator/AuthenticatorRepositoryInterface.ts @@ -3,4 +3,5 @@ import { Authenticator } from './Authenticator' export interface AuthenticatorRepositoryInterface { findByUserUuid(userUuid: Uuid): Promise + save(authenticator: Authenticator): Promise } diff --git a/packages/auth/src/Domain/Authenticator/RelyingParty.ts b/packages/auth/src/Domain/Authenticator/RelyingParty.ts new file mode 100644 index 000000000..b6c41536c --- /dev/null +++ b/packages/auth/src/Domain/Authenticator/RelyingParty.ts @@ -0,0 +1,4 @@ +export enum RelyingParty { + RP_NAME = 'Standard Notes', + RP_ID = 'standardnotes.com', +} diff --git a/packages/auth/src/Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions.ts b/packages/auth/src/Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions.ts index f68b51583..98aa5275f 100644 --- a/packages/auth/src/Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions.ts +++ b/packages/auth/src/Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions.ts @@ -5,11 +5,9 @@ import { GenerateAuthenticatorRegistrationOptionsDTO } from './GenerateAuthentic import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface' import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface' import { AuthenticatorChallenge } from '../../Authenticator/AuthenticatorChallenge' +import { RelyingParty } from '../../Authenticator/RelyingParty' export class GenerateAuthenticatorRegistrationOptions implements UseCaseInterface> { - private readonly RP_NAME = 'Standard Notes' - private readonly RP_ID = 'standardnotes.com' - constructor( private authenticatorRepository: AuthenticatorRepositoryInterface, private authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface, @@ -30,8 +28,8 @@ export class GenerateAuthenticatorRegistrationOptions implements UseCaseInterfac const authenticators = await this.authenticatorRepository.findByUserUuid(userUuid) const options = generateRegistrationOptions({ - rpID: this.RP_ID, - rpName: this.RP_NAME, + rpID: RelyingParty.RP_ID, + rpName: RelyingParty.RP_NAME, userID: userUuid.value, userName: username.value, attestationType: 'none', diff --git a/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse.spec.ts b/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse.spec.ts new file mode 100644 index 000000000..0e223f370 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse.spec.ts @@ -0,0 +1,209 @@ +import * as simeplWebAuthnServer from '@simplewebauthn/server' +import { VerifiedRegistrationResponse } from '@simplewebauthn/server' +import { Result } from '@standardnotes/domain-core' +import { Authenticator } from '../../Authenticator/Authenticator' + +import { AuthenticatorChallenge } from '../../Authenticator/AuthenticatorChallenge' +import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface' +import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface' +import { VerifyAuthenticatorRegistrationResponse } from './VerifyAuthenticatorRegistrationResponse' + +describe('VerifyAuthenticatorRegistrationResponse', () => { + let authenticatorRepository: AuthenticatorRepositoryInterface + let authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface + + const createUseCase = () => + new VerifyAuthenticatorRegistrationResponse(authenticatorRepository, authenticatorChallengeRepository) + + beforeEach(() => { + authenticatorRepository = {} as jest.Mocked + authenticatorRepository.save = jest.fn() + + authenticatorChallengeRepository = {} as jest.Mocked + authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({ + props: { + challenge: Buffer.from('challenge'), + }, + } as jest.Mocked) + }) + + it('should return error if user uuid is invalid', async () => { + const useCase = createUseCase() + + const result = await useCase.execute({ + userUuid: 'invalid', + challenge: Buffer.from('challenge'), + registrationCredential: { + id: Buffer.from('id'), + rawId: Buffer.from('rawId'), + response: { + attestationObject: Buffer.from('attestationObject'), + clientDataJSON: Buffer.from('clientDataJSON'), + }, + type: 'type', + }, + }) + + expect(result.isFailed()).toBeTruthy() + expect(result.getError()).toEqual( + 'Could not verify authenticator registration response: Given value is not a valid uuid: invalid', + ) + }) + + it('should return error if challenge is not found', async () => { + authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue(null) + + const useCase = createUseCase() + + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + challenge: Buffer.from('challenge'), + registrationCredential: { + id: Buffer.from('id'), + rawId: Buffer.from('rawId'), + response: { + attestationObject: Buffer.from('attestationObject'), + clientDataJSON: Buffer.from('clientDataJSON'), + }, + type: 'type', + }, + }) + + expect(result.isFailed()).toBeTruthy() + expect(result.getError()).toEqual('Could not verify authenticator registration response: challenge not found') + }) + + it('should return error if verification could not verify', async () => { + authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({ + props: { + challenge: Buffer.from('challenge'), + }, + } as jest.Mocked) + + const useCase = createUseCase() + + const mock = jest.spyOn(simeplWebAuthnServer, 'verifyRegistrationResponse') + mock.mockImplementation(() => { + return Promise.resolve({ + verified: false, + registrationInfo: { + counter: 1, + credentialBackedUp: true, + credentialDeviceType: 'singleDevice', + credentialID: Buffer.from('test'), + credentialPublicKey: Buffer.from('test'), + }, + } as jest.Mocked) + }) + + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + challenge: Buffer.from('invalid'), + registrationCredential: { + id: Buffer.from('id'), + rawId: Buffer.from('rawId'), + response: { + attestationObject: Buffer.from('attestationObject'), + clientDataJSON: Buffer.from('clientDataJSON'), + }, + type: 'type', + }, + }) + + expect(result.isFailed()).toBeTruthy() + expect(result.getError()).toEqual('Could not verify authenticator registration response: verification failed') + + mock.mockRestore() + }) + + it('should return error if authenticator could not be created', async () => { + authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({ + props: { + challenge: Buffer.from('challenge'), + }, + } as jest.Mocked) + + const useCase = createUseCase() + + const mock = jest.spyOn(simeplWebAuthnServer, 'verifyRegistrationResponse') + mock.mockImplementation(() => { + return Promise.resolve({ + verified: true, + registrationInfo: { + counter: 1, + credentialBackedUp: true, + credentialDeviceType: 'singleDevice', + credentialID: Buffer.from('test'), + credentialPublicKey: Buffer.from('test'), + }, + } as jest.Mocked) + }) + + const mockAuthenticator = jest.spyOn(Authenticator, 'create') + mockAuthenticator.mockImplementation(() => { + return Result.fail('Oops') + }) + + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + challenge: Buffer.from('invalid'), + registrationCredential: { + id: Buffer.from('id'), + rawId: Buffer.from('rawId'), + response: { + attestationObject: Buffer.from('attestationObject'), + clientDataJSON: Buffer.from('clientDataJSON'), + }, + type: 'type', + }, + }) + + expect(result.isFailed()).toBeTruthy() + expect(result.getError()).toEqual('Could not verify authenticator registration response: Oops') + + mock.mockRestore() + mockAuthenticator.mockRestore() + }) + + it('should verify authenticator registration response', async () => { + authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({ + props: { + challenge: Buffer.from('challenge'), + }, + } as jest.Mocked) + + const useCase = createUseCase() + + const mock = jest.spyOn(simeplWebAuthnServer, 'verifyRegistrationResponse') + mock.mockImplementation(() => { + return Promise.resolve({ + verified: true, + registrationInfo: { + counter: 1, + credentialBackedUp: true, + credentialDeviceType: 'singleDevice', + credentialID: Buffer.from('test'), + credentialPublicKey: Buffer.from('test'), + }, + } as jest.Mocked) + }) + + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + challenge: Buffer.from('invalid'), + registrationCredential: { + id: Buffer.from('id'), + rawId: Buffer.from('rawId'), + response: { + attestationObject: Buffer.from('attestationObject'), + clientDataJSON: Buffer.from('clientDataJSON'), + }, + type: 'type', + }, + }) + + expect(result.isFailed()).toBeFalsy() + + mock.mockRestore() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse.ts b/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse.ts new file mode 100644 index 000000000..689727ddd --- /dev/null +++ b/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse.ts @@ -0,0 +1,61 @@ +import { Dates, Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core' +import { verifyRegistrationResponse } from '@simplewebauthn/server' + +import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface' +import { RelyingParty } from '../../Authenticator/RelyingParty' +import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface' +import { Authenticator } from '../../Authenticator/Authenticator' +import { VerifyAuthenticatorRegistrationResponseDTO } from './VerifyAuthenticatorRegistrationResponseDTO' + +export class VerifyAuthenticatorRegistrationResponse implements UseCaseInterface { + constructor( + private authenticatorRepository: AuthenticatorRepositoryInterface, + private authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface, + ) {} + + async execute(dto: VerifyAuthenticatorRegistrationResponseDTO): Promise> { + const userUuidOrError = Uuid.create(dto.userUuid) + if (userUuidOrError.isFailed()) { + return Result.fail(`Could not verify authenticator registration response: ${userUuidOrError.getError()}`) + } + const userUuid = userUuidOrError.getValue() + + const authenticatorChallenge = await this.authenticatorChallengeRepository.findByUserUuidAndChallenge( + userUuid, + dto.challenge, + ) + if (!authenticatorChallenge) { + return Result.fail('Could not verify authenticator registration response: challenge not found') + } + + const verification = await verifyRegistrationResponse({ + credential: dto.registrationCredential, + expectedChallenge: authenticatorChallenge.props.challenge.toString(), + expectedOrigin: `https://${RelyingParty.RP_ID}`, + expectedRPID: RelyingParty.RP_ID, + }) + + if (!verification.verified) { + return Result.fail('Could not verify authenticator registration response: verification failed') + } + + const authenticatorOrError = Authenticator.create({ + userUuid, + counter: verification.registrationInfo?.counter as number, + credentialBackedUp: verification.registrationInfo?.credentialBackedUp as boolean, + credentialDeviceType: verification.registrationInfo?.credentialDeviceType, + credentialId: verification.registrationInfo?.credentialID as Buffer, + credentialPublicKey: verification.registrationInfo?.credentialPublicKey as Buffer, + dates: Dates.create(new Date(), new Date()).getValue(), + }) + + if (authenticatorOrError.isFailed()) { + return Result.fail(`Could not verify authenticator registration response: ${authenticatorOrError.getError()}`) + } + const authenticator = authenticatorOrError.getValue() + + await this.authenticatorRepository.save(authenticator) + + return Result.ok(true) + } +} diff --git a/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponseDTO.ts b/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponseDTO.ts new file mode 100644 index 000000000..8de5fb90d --- /dev/null +++ b/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponseDTO.ts @@ -0,0 +1,5 @@ +export interface VerifyAuthenticatorRegistrationResponseDTO { + userUuid: string + challenge: Buffer + registrationCredential: Record +} diff --git a/packages/auth/src/Infra/MySQL/MySQLAuthenticatorChallengeRepository.ts b/packages/auth/src/Infra/MySQL/MySQLAuthenticatorChallengeRepository.ts index d944fdd3a..9ee50aa8f 100644 --- a/packages/auth/src/Infra/MySQL/MySQLAuthenticatorChallengeRepository.ts +++ b/packages/auth/src/Infra/MySQL/MySQLAuthenticatorChallengeRepository.ts @@ -1,4 +1,4 @@ -import { MapperInterface } from '@standardnotes/domain-core' +import { MapperInterface, Uuid } from '@standardnotes/domain-core' import { Repository } from 'typeorm' import { AuthenticatorChallenge } from '../../Domain/Authenticator/AuthenticatorChallenge' @@ -12,6 +12,22 @@ export class MySQLAuthenticatorChallengeRepository implements AuthenticatorChall private mapper: MapperInterface, ) {} + async findByUserUuidAndChallenge(userUuid: Uuid, challenge: Buffer): Promise { + const typeOrm = await this.ormRepository + .createQueryBuilder('challenge') + .where('challenge.user_uuid = :userUuid and challenge.challenge = :challenge', { + userUuid: userUuid.value, + challenge, + }) + .getOne() + + if (typeOrm === null) { + return null + } + + return this.mapper.toDomain(typeOrm) + } + async save(authenticatorChallenge: AuthenticatorChallenge): Promise { const persistence = this.mapper.toProjection(authenticatorChallenge) diff --git a/packages/auth/src/Infra/MySQL/MySQLAuthenticatorRepository.ts b/packages/auth/src/Infra/MySQL/MySQLAuthenticatorRepository.ts index 216a60bf5..1084278c3 100644 --- a/packages/auth/src/Infra/MySQL/MySQLAuthenticatorRepository.ts +++ b/packages/auth/src/Infra/MySQL/MySQLAuthenticatorRepository.ts @@ -11,6 +11,12 @@ export class MySQLAuthenticatorRepository implements AuthenticatorRepositoryInte private mapper: MapperInterface, ) {} + async save(authenticator: Authenticator): Promise { + const persistence = this.mapper.toProjection(authenticator) + + await this.ormRepository.save(persistence) + } + async findByUserUuid(userUuid: Uuid): Promise { const typeOrm = await this.ormRepository .createQueryBuilder('authenticator')