Browse Source

feat(auth): add verifying authenticator registration response

Karol Sójko 2 years ago
parent
commit
f5683cfd94

+ 9 - 0
packages/auth/src/Bootstrap/Container.ts

@@ -214,6 +214,7 @@ import { MySQLAuthenticatorRepository } from '../Infra/MySQL/MySQLAuthenticatorR
 import { AuthenticatorChallengeRepositoryInterface } from '../Domain/Authenticator/AuthenticatorChallengeRepositoryInterface'
 import { AuthenticatorChallengeRepositoryInterface } from '../Domain/Authenticator/AuthenticatorChallengeRepositoryInterface'
 import { MySQLAuthenticatorChallengeRepository } from '../Infra/MySQL/MySQLAuthenticatorChallengeRepository'
 import { MySQLAuthenticatorChallengeRepository } from '../Infra/MySQL/MySQLAuthenticatorChallengeRepository'
 import { GenerateAuthenticatorRegistrationOptions } from '../Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions'
 import { GenerateAuthenticatorRegistrationOptions } from '../Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions'
+import { VerifyAuthenticatorRegistrationResponse } from '../Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse'
 
 
 // eslint-disable-next-line @typescript-eslint/no-var-requires
 // eslint-disable-next-line @typescript-eslint/no-var-requires
 const newrelicFormatter = require('@newrelic/winston-enricher')
 const newrelicFormatter = require('@newrelic/winston-enricher')
@@ -560,6 +561,14 @@ export class ContainerConfigLoader {
           container.get(TYPES.AuthenticatorChallengeRepository),
           container.get(TYPES.AuthenticatorChallengeRepository),
         ),
         ),
       )
       )
+    container
+      .bind<VerifyAuthenticatorRegistrationResponse>(TYPES.VerifyAuthenticatorRegistrationResponse)
+      .toConstantValue(
+        new VerifyAuthenticatorRegistrationResponse(
+          container.get(TYPES.AuthenticatorRepository),
+          container.get(TYPES.AuthenticatorChallengeRepository),
+        ),
+      )
 
 
     container
     container
       .bind<CleanupSessionTraces>(TYPES.CleanupSessionTraces)
       .bind<CleanupSessionTraces>(TYPES.CleanupSessionTraces)

+ 1 - 0
packages/auth/src/Bootstrap/Types.ts

@@ -134,6 +134,7 @@ const TYPES = {
   CleanupSessionTraces: Symbol.for('CleanupSessionTraces'),
   CleanupSessionTraces: Symbol.for('CleanupSessionTraces'),
   PersistStatistics: Symbol.for('PersistStatistics'),
   PersistStatistics: Symbol.for('PersistStatistics'),
   GenerateAuthenticatorRegistrationOptions: Symbol.for('GenerateAuthenticatorRegistrationOptions'),
   GenerateAuthenticatorRegistrationOptions: Symbol.for('GenerateAuthenticatorRegistrationOptions'),
+  VerifyAuthenticatorRegistrationResponse: Symbol.for('VerifyAuthenticatorRegistrationResponse'),
   // Handlers
   // Handlers
   UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'),
   UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'),
   AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'),
   AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'),

+ 3 - 0
packages/auth/src/Domain/Authenticator/AuthenticatorChallengeRepositoryInterface.ts

@@ -1,5 +1,8 @@
+import { Uuid } from '@standardnotes/domain-core'
+
 import { AuthenticatorChallenge } from './AuthenticatorChallenge'
 import { AuthenticatorChallenge } from './AuthenticatorChallenge'
 
 
 export interface AuthenticatorChallengeRepositoryInterface {
 export interface AuthenticatorChallengeRepositoryInterface {
+  findByUserUuidAndChallenge(userUuid: Uuid, challenge: Buffer): Promise<AuthenticatorChallenge | null>
   save(authenticatorChallenge: AuthenticatorChallenge): Promise<void>
   save(authenticatorChallenge: AuthenticatorChallenge): Promise<void>
 }
 }

+ 1 - 0
packages/auth/src/Domain/Authenticator/AuthenticatorRepositoryInterface.ts

@@ -3,4 +3,5 @@ import { Authenticator } from './Authenticator'
 
 
 export interface AuthenticatorRepositoryInterface {
 export interface AuthenticatorRepositoryInterface {
   findByUserUuid(userUuid: Uuid): Promise<Authenticator[]>
   findByUserUuid(userUuid: Uuid): Promise<Authenticator[]>
+  save(authenticator: Authenticator): Promise<void>
 }
 }

+ 4 - 0
packages/auth/src/Domain/Authenticator/RelyingParty.ts

@@ -0,0 +1,4 @@
+export enum RelyingParty {
+  RP_NAME = 'Standard Notes',
+  RP_ID = 'standardnotes.com',
+}

+ 3 - 5
packages/auth/src/Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions.ts

@@ -5,11 +5,9 @@ import { GenerateAuthenticatorRegistrationOptionsDTO } from './GenerateAuthentic
 import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
 import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
 import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface'
 import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface'
 import { AuthenticatorChallenge } from '../../Authenticator/AuthenticatorChallenge'
 import { AuthenticatorChallenge } from '../../Authenticator/AuthenticatorChallenge'
+import { RelyingParty } from '../../Authenticator/RelyingParty'
 
 
 export class GenerateAuthenticatorRegistrationOptions implements UseCaseInterface<Record<string, unknown>> {
 export class GenerateAuthenticatorRegistrationOptions implements UseCaseInterface<Record<string, unknown>> {
-  private readonly RP_NAME = 'Standard Notes'
-  private readonly RP_ID = 'standardnotes.com'
-
   constructor(
   constructor(
     private authenticatorRepository: AuthenticatorRepositoryInterface,
     private authenticatorRepository: AuthenticatorRepositoryInterface,
     private authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface,
     private authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface,
@@ -30,8 +28,8 @@ export class GenerateAuthenticatorRegistrationOptions implements UseCaseInterfac
 
 
     const authenticators = await this.authenticatorRepository.findByUserUuid(userUuid)
     const authenticators = await this.authenticatorRepository.findByUserUuid(userUuid)
     const options = generateRegistrationOptions({
     const options = generateRegistrationOptions({
-      rpID: this.RP_ID,
-      rpName: this.RP_NAME,
+      rpID: RelyingParty.RP_ID,
+      rpName: RelyingParty.RP_NAME,
       userID: userUuid.value,
       userID: userUuid.value,
       userName: username.value,
       userName: username.value,
       attestationType: 'none',
       attestationType: 'none',

+ 209 - 0
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<AuthenticatorRepositoryInterface>
+    authenticatorRepository.save = jest.fn()
+
+    authenticatorChallengeRepository = {} as jest.Mocked<AuthenticatorChallengeRepositoryInterface>
+    authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({
+      props: {
+        challenge: Buffer.from('challenge'),
+      },
+    } as jest.Mocked<AuthenticatorChallenge>)
+  })
+
+  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<AuthenticatorChallenge>)
+
+    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<VerifiedRegistrationResponse>)
+    })
+
+    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<AuthenticatorChallenge>)
+
+    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<VerifiedRegistrationResponse>)
+    })
+
+    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<AuthenticatorChallenge>)
+
+    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<VerifiedRegistrationResponse>)
+    })
+
+    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()
+  })
+})

+ 61 - 0
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<boolean> {
+  constructor(
+    private authenticatorRepository: AuthenticatorRepositoryInterface,
+    private authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface,
+  ) {}
+
+  async execute(dto: VerifyAuthenticatorRegistrationResponseDTO): Promise<Result<boolean>> {
+    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)
+  }
+}

+ 5 - 0
packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponseDTO.ts

@@ -0,0 +1,5 @@
+export interface VerifyAuthenticatorRegistrationResponseDTO {
+  userUuid: string
+  challenge: Buffer
+  registrationCredential: Record<string, unknown>
+}

+ 17 - 1
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 { Repository } from 'typeorm'
 
 
 import { AuthenticatorChallenge } from '../../Domain/Authenticator/AuthenticatorChallenge'
 import { AuthenticatorChallenge } from '../../Domain/Authenticator/AuthenticatorChallenge'
@@ -12,6 +12,22 @@ export class MySQLAuthenticatorChallengeRepository implements AuthenticatorChall
     private mapper: MapperInterface<AuthenticatorChallenge, TypeORMAuthenticatorChallenge>,
     private mapper: MapperInterface<AuthenticatorChallenge, TypeORMAuthenticatorChallenge>,
   ) {}
   ) {}
 
 
+  async findByUserUuidAndChallenge(userUuid: Uuid, challenge: Buffer): Promise<AuthenticatorChallenge | null> {
+    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<void> {
   async save(authenticatorChallenge: AuthenticatorChallenge): Promise<void> {
     const persistence = this.mapper.toProjection(authenticatorChallenge)
     const persistence = this.mapper.toProjection(authenticatorChallenge)
 
 

+ 6 - 0
packages/auth/src/Infra/MySQL/MySQLAuthenticatorRepository.ts

@@ -11,6 +11,12 @@ export class MySQLAuthenticatorRepository implements AuthenticatorRepositoryInte
     private mapper: MapperInterface<Authenticator, TypeORMAuthenticator>,
     private mapper: MapperInterface<Authenticator, TypeORMAuthenticator>,
   ) {}
   ) {}
 
 
+  async save(authenticator: Authenticator): Promise<void> {
+    const persistence = this.mapper.toProjection(authenticator)
+
+    await this.ormRepository.save(persistence)
+  }
+
   async findByUserUuid(userUuid: Uuid): Promise<Authenticator[]> {
   async findByUserUuid(userUuid: Uuid): Promise<Authenticator[]> {
     const typeOrm = await this.ormRepository
     const typeOrm = await this.ormRepository
       .createQueryBuilder('authenticator')
       .createQueryBuilder('authenticator')