diff --git a/libs/common/src/webauthn/abstractions/fido2-authenticator.service.abstraction.ts b/libs/common/src/webauthn/abstractions/fido2-authenticator.service.abstraction.ts index f3619a9953..92bd80c689 100644 --- a/libs/common/src/webauthn/abstractions/fido2-authenticator.service.abstraction.ts +++ b/libs/common/src/webauthn/abstractions/fido2-authenticator.service.abstraction.ts @@ -98,9 +98,9 @@ export interface Fido2AuthenticatorGetAssertionParams { } export interface Fido2AuthenticatorGetAssertionResult { - selectedCredential?: { + selectedCredential: { id: string; - userHandle: Uint8Array; + userHandle?: Uint8Array; }; authenticatorData: Uint8Array; signature: Uint8Array; diff --git a/libs/common/src/webauthn/services/fido2-authenticator.service.spec.ts b/libs/common/src/webauthn/services/fido2-authenticator.service.spec.ts index 6652af6571..b30a955907 100644 --- a/libs/common/src/webauthn/services/fido2-authenticator.service.spec.ts +++ b/libs/common/src/webauthn/services/fido2-authenticator.service.spec.ts @@ -629,50 +629,85 @@ describe("FidoAuthenticatorService", () => { }); }); - describe("assertion of non-discoverable credential", () => { - let credentialIds: string[]; - let ciphers: CipherView[]; - let params: Fido2AuthenticatorGetAssertionParams; + for (const residentKey of [true, false]) { + describe(`assertion of ${ + residentKey ? "discoverable" : "non-discoverable" + } credential`, () => { + let credentialIds: string[]; + let selectedCredentialId: string; + let ciphers: CipherView[]; + let params: Fido2AuthenticatorGetAssertionParams; - beforeEach(async () => { - credentialIds = [Utils.newGuid(), Utils.newGuid()]; - ciphers = await Promise.all( - credentialIds.map((id) => - createCipherView( - { type: CipherType.Login }, - { nonDiscoverableId: id, rpId: RpId, counter: 9000 } - ) - ) - ); - params = await createParams({ - allowCredentialDescriptorList: credentialIds.map((credentialId) => ({ - id: Utils.guidToRawFormat(credentialId), - type: "public-key", - })), - rpId: RpId, + beforeEach(async () => { + credentialIds = [Utils.newGuid(), Utils.newGuid()]; + if (residentKey) { + ciphers = credentialIds.map((id) => + createCipherView({ type: CipherType.Fido2Key }, { rpId: RpId, counter: 9000 }) + ); + selectedCredentialId = ciphers[0].id; + params = await createParams({ + allowCredentialDescriptorList: undefined, + rpId: RpId, + }); + } else { + ciphers = credentialIds.map((id) => + createCipherView( + { type: CipherType.Login }, + { nonDiscoverableId: id, rpId: RpId, counter: 9000 } + ) + ); + selectedCredentialId = credentialIds[0]; + params = await createParams({ + allowCredentialDescriptorList: credentialIds.map((credentialId) => ({ + id: Utils.guidToRawFormat(credentialId), + type: "public-key", + })), + rpId: RpId, + }); + } + cipherService.getAllDecrypted.mockResolvedValue(ciphers); + userInterface.pickCredential.mockResolvedValue(ciphers[0].id); + }); + + /** Spec: Increment the credential associated signature counter */ + it("should increment counter", async () => { + const encrypted = Symbol(); + cipherService.encrypt.mockResolvedValue(encrypted as any); + + await authenticator.getAssertion(params); + + expect(cipherService.encrypt).toHaveBeenCalledWith( + expect.objectContaining({ + id: ciphers[0].id, + fido2Key: expect.objectContaining({ + counter: 9001, + }), + }) + ); + expect(cipherService.updateWithServer).toHaveBeenCalledWith(encrypted); + }); + + it("should return an assertion result", async () => { + const result = await authenticator.getAssertion(params); + + const encAuthData = result.authenticatorData; + const rpIdHash = encAuthData.slice(0, 32); + const flags = encAuthData.slice(32, 33); + const counter = encAuthData.slice(33, 37); + + expect(result.selectedCredential.id).toBe(selectedCredentialId); + expect(rpIdHash).toEqual( + new Uint8Array([ + 0x22, 0x6b, 0xb3, 0x92, 0x02, 0xff, 0xf9, 0x22, 0xdc, 0x74, 0x05, 0xcd, 0x28, 0xa8, + 0x34, 0x5a, 0xc4, 0xf2, 0x64, 0x51, 0xd7, 0x3d, 0x0b, 0x40, 0xef, 0xf3, 0x1d, 0xc1, + 0xd0, 0x5c, 0x3d, 0xc3, + ]) + ); + expect(flags).toEqual(new Uint8Array([0b00000001])); // UP = true + expect(counter).toEqual(new Uint8Array([0, 0, 0x23, 0x29])); // 9001 in hex }); - cipherService.getAllDecrypted.mockResolvedValue(ciphers); - userInterface.pickCredential.mockResolvedValue(ciphers[0].id); }); - - /** Spec: Increment the credential associated signature counter */ - it("should increment counter", async () => { - const encrypted = Symbol(); - cipherService.encrypt.mockResolvedValue(encrypted as any); - - await authenticator.getAssertion(params); - - expect(cipherService.encrypt).toHaveBeenCalledWith( - expect.objectContaining({ - id: ciphers[0].id, - fido2Key: expect.objectContaining({ - counter: 9001, - }), - }) - ); - expect(cipherService.updateWithServer).toHaveBeenCalledWith(encrypted); - }); - }); + } async function createParams( params: Partial = {} @@ -713,6 +748,7 @@ function createCipherView( cipher.fido2Key.nonDiscoverableId = fido2Key.nonDiscoverableId; cipher.fido2Key.rpId = fido2Key.rpId ?? RpId; cipher.fido2Key.counter = fido2Key.counter ?? 0; + cipher.fido2Key.userHandle = Fido2Utils.bufferToString(randomBytes(16)); return cipher; } @@ -729,6 +765,7 @@ async function createClientDataHash() { return await crypto.subtle.digest({ name: "SHA-256" }, clientData); } +/** This is a fake function that always returns the same byte sequence */ function randomBytes(length: number) { - return new Uint8Array(Array.from({ length }, () => Math.floor(Math.random() * 255))); + return new Uint8Array(Array.from({ length }, (_, k) => k % 255)); } diff --git a/libs/common/src/webauthn/services/fido2-authenticator.service.ts b/libs/common/src/webauthn/services/fido2-authenticator.service.ts index 637f503dff..793486ee64 100644 --- a/libs/common/src/webauthn/services/fido2-authenticator.service.ts +++ b/libs/common/src/webauthn/services/fido2-authenticator.service.ts @@ -146,35 +146,63 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Constraint); } - let credentialOptions: CipherView[]; + let cipherOptions: CipherView[]; // eslint-disable-next-line no-empty if (params.allowCredentialDescriptorList?.length > 0) { - credentialOptions = await this.findNonDiscoverableCredentials( + cipherOptions = await this.findNonDiscoverableCredentials( params.allowCredentialDescriptorList, params.rpId ); } else { - credentialOptions = await this.findDiscoverableCredentials(params.rpId); + cipherOptions = await this.findDiscoverableCredentials(params.rpId); } - if (credentialOptions.length === 0) { + if (cipherOptions.length === 0) { throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); } - const selectedCredentialId = await this.userInterface.pickCredential( - credentialOptions.map((cipher) => cipher.id) + const selectedCipherId = await this.userInterface.pickCredential( + cipherOptions.map((cipher) => cipher.id) ); - const selectedCredential = credentialOptions.find((c) => c.id === selectedCredentialId); + const selectedCipher = cipherOptions.find((c) => c.id === selectedCipherId); - if (selectedCredential === undefined) { + if (selectedCipher === undefined) { throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); } - ++selectedCredential.fido2Key.counter; - selectedCredential.localData.lastUsedDate = new Date().getTime(); - const encrypted = await this.cipherService.encrypt(selectedCredential); + const selectedCredentialId = + params.allowCredentialDescriptorList?.length > 0 + ? selectedCipher.fido2Key.nonDiscoverableId + : selectedCipher.id; + + ++selectedCipher.fido2Key.counter; + selectedCipher.localData.lastUsedDate = new Date().getTime(); + const encrypted = await this.cipherService.encrypt(selectedCipher); await this.cipherService.updateWithServer(encrypted); + + const authenticatorData = await generateAuthData({ + rpId: selectedCipher.fido2Key.rpId, + credentialId: selectedCredentialId, + counter: selectedCipher.fido2Key.counter, + userPresence: true, + userVerification: false, + }); + + // const signature = await generateSignature({ + // authData, + // clientData, + // privateKey: credential.keyValue, + // }); + + return { + authenticatorData, + selectedCredential: { + id: selectedCredentialId, + userHandle: Fido2Utils.stringToBuffer(selectedCipher.fido2Key.userHandle), + }, + signature: null, + }; } private async vaultContainsCredentials( @@ -305,18 +333,18 @@ async function generateAuthData(params: AuthDataParams) { counter & 0x000000ff ); - // attestedCredentialData - const attestedCredentialData: Array = []; - - attestedCredentialData.push(...AAGUID); - - // credentialIdLength (2 bytes) and credential Id - const rawId = Utils.guidToRawFormat(params.credentialId); - const credentialIdLength = [(rawId.length - (rawId.length & 0xff)) / 256, rawId.length & 0xff]; - attestedCredentialData.push(...credentialIdLength); - attestedCredentialData.push(...rawId); - if (params.keyPair) { + // attestedCredentialData + const attestedCredentialData: Array = []; + + attestedCredentialData.push(...AAGUID); + + // credentialIdLength (2 bytes) and credential Id + const rawId = Utils.guidToRawFormat(params.credentialId); + const credentialIdLength = [(rawId.length - (rawId.length & 0xff)) / 256, rawId.length & 0xff]; + attestedCredentialData.push(...credentialIdLength); + attestedCredentialData.push(...rawId); + const publicKeyJwk = await crypto.subtle.exportKey("jwk", params.keyPair.publicKey); // COSE format of the EC256 key const keyX = Utils.fromUrlB64ToArray(publicKeyJwk.x);