UNPKG

ox

Version:

Ethereum Standard Library

1,051 lines (967 loc) 27.9 kB
import { afterAll, beforeAll, describe, expect, test, vi } from 'vp/test' import * as Base64 from '../../core/Base64.js' import * as Bytes from '../../core/Bytes.js' import * as Hash from '../../core/Hash.js' import * as Hex from '../../core/Hex.js' import * as P256 from '../../core/P256.js' import * as Signature from '../../core/Signature.js' import { Authentication, Authenticator, Registration, type Types, } from '../index.js' beforeAll(() => { vi.stubGlobal('window', { location: { hostname: 'https://example.com', }, document: { title: 'My Website', }, }) }) afterAll(() => { vi.restoreAllMocks() }) describe('create', () => { test('default', async () => { let options: CredentialCreationOptions | undefined const credential = await Registration.create({ createFn(options_) { options = options_ return Promise.resolve({ id: 'm1-bMPuAqpWhCxHZQZTT6e-lSPntQbh3opIoGe7g4Qs', response: { getPublicKey() { return [ 48, 89, 48, 19, 6, 7, 42, 134, 72, 206, 61, 2, 1, 6, 8, 42, 134, 72, 206, 61, 3, 1, 7, 3, 66, 0, 4, 171, 137, 20, 0, 20, 15, 196, 248, 233, 65, 206, 15, 249, 14, 65, 157, 233, 71, 10, 202, 202, 97, 59, 189, 113, 122, 71, 117, 67, 80, 49, 167, 216, 132, 49, 142, 145, 159, 211, 179, 229, 166, 49, 216, 102, 216, 163, 128, 180, 64, 99, 231, 15, 12, 56, 30, 225, 110, 6, 82, 247, 249, 117, 84, ] }, }, } as any) }, name: 'Foo', }) expect(credential).toMatchInlineSnapshot(` { "attestationObject": undefined, "clientDataJSON": undefined, "id": "m1-bMPuAqpWhCxHZQZTT6e-lSPntQbh3opIoGe7g4Qs", "publicKey": { "prefix": 4, "x": "0xab891400140fc4f8e941ce0ff90e419de9470acaca613bbd717a4775435031a7", "y": "0xd884318e919fd3b3e5a631d866d8a380b44063e70f0c381ee16e0652f7f97554", }, "raw": { "id": "m1-bMPuAqpWhCxHZQZTT6e-lSPntQbh3opIoGe7g4Qs", "response": { "getPublicKey": [Function], }, }, } `) expect(options).toMatchInlineSnapshot(` { "publicKey": { "attestation": "none", "authenticatorSelection": { "requireResidentKey": false, "residentKey": "preferred", "userVerification": "required", }, "challenge": Uint8Array [ 105, 171, 180, 181, 160, 222, 75, 198, 42, 42, 32, 31, 141, 37, 186, 233, ], "pubKeyCredParams": [ { "alg": -7, "type": "public-key", }, ], "rp": { "id": "https://example.com", "name": "My Website", }, "user": { "displayName": "Foo", "id": Uint8Array [ 182, 8, 199, 66, 131, 243, 52, 225, 240, 71, 219, 191, 29, 170, 36, 7, 212, 29, 70, 137, 172, 166, 124, 66, 39, 150, 249, 54, 172, 206, 22, 183, ], "name": "Foo", }, }, } `) }) test('error: null credential', async () => { await expect(() => Registration.create({ createFn() { return Promise.resolve(null) }, name: 'Foo', }), ).rejects.toMatchInlineSnapshot(` [Registration.CreateFailedError: Failed to create credential. Details: Failed to create credential.] `) }) test('error: thrown', async () => { await expect(() => Registration.create({ createFn() { return Promise.reject(new Error('foo')) }, name: 'Foo', }), ).rejects.toMatchInlineSnapshot(` [Registration.CreateFailedError: Failed to create credential. Details: foo] `) }) test('behavior: firefox workaround', async () => { const credential = await Registration.create({ createFn(_options) { return Promise.resolve({ id: 'DxRcX5C6BRQ-q-CO7XEFwrnmKlk', response: { attestationObject: new Uint8Array([ 163, 99, 102, 109, 116, 100, 110, 111, 110, 101, 103, 97, 116, 116, 83, 116, 109, 116, 160, 104, 97, 117, 116, 104, 68, 97, 116, 97, 88, 152, 73, 150, 13, 229, 136, 14, 140, 104, 116, 52, 23, 15, 100, 118, 96, 91, 143, 228, 174, 185, 162, 134, 50, 199, 153, 92, 243, 186, 131, 29, 151, 99, 93, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 20, 15, 20, 92, 95, 144, 186, 5, 20, 62, 171, 224, 142, 237, 113, 5, 194, 185, 230, 42, 89, 165, 1, 2, 3, 38, 32, 1, 33, 88, 32, 98, 163, 23, 104, 212, 79, 94, 255, 34, 47, 141, 112, 196, 203, 97, 171, 210, 132, 11, 39, 214, 23, 167, 254, 141, 17, 183, 45, 213, 232, 111, 193, 34, 88, 32, 102, 17, 186, 227, 241, 226, 205, 56, 228, 5, 21, 55, 118, 167, 220, 182, 153, 91, 130, 84, 161, 65, 110, 173, 16, 42, 9, 108, 176, 216, 6, 24, ]), getPublicKey() { throw new Error('Permission denied to access object') }, }, } as any) }, name: 'Example', }) const publicKey = credential.publicKey expect(publicKey).toMatchInlineSnapshot(` { "prefix": 4, "x": "0x62a31768d44f5eff222f8d70c4cb61abd2840b27d617a7fe8d11b72dd5e86fc1", "y": "0x6611bae3f1e2cd38e405153776a7dcb6995b8254a1416ead102a096cb0d80618", } `) expect( Authentication.verify({ metadata: { authenticatorData: '0x49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97631d00000000', clientDataJSON: '{"type":"webauthn.get","challenge":"9jEFijuhEWrM4SOW-tChJbUEHEP44VcjcJ-Bqo1fTM8","origin":"http://localhost:5173"}', challengeIndex: 23, typeIndex: 1, userVerificationRequired: true, }, challenge: '0xf631058a3ba1116acce12396fad0a125b5041c43f8e15723709f81aa8d5f4ccf', publicKey, signature: { r: '0xfb93e78ee89f6110313194b0e6ea55c8a33d59ec89a1eb009a179dcbebcd33f1', s: '0x0beb1ae5eaa0c2823343507fe836b35b755b6fea5973fbe58941eb7184e714ce', }, }), ).toBeTruthy() }) }) describe('getOptions', () => { test('default', () => { expect( Registration.getOptions({ name: 'Foo', }), ).toMatchInlineSnapshot(` { "publicKey": { "attestation": "none", "authenticatorSelection": { "requireResidentKey": false, "residentKey": "preferred", "userVerification": "required", }, "challenge": Uint8Array [ 105, 171, 180, 181, 160, 222, 75, 198, 42, 42, 32, 31, 141, 37, 186, 233, ], "pubKeyCredParams": [ { "alg": -7, "type": "public-key", }, ], "rp": { "id": "https://example.com", "name": "My Website", }, "user": { "displayName": "Foo", "id": Uint8Array [ 182, 8, 199, 66, 131, 243, 52, 225, 240, 71, 219, 191, 29, 170, 36, 7, 212, 29, 70, 137, 172, 166, 124, 66, 39, 150, 249, 54, 172, 206, 22, 183, ], "name": "Foo", }, }, } `) }) test('args: excludeCredentialIds', () => { expect( Registration.getOptions({ excludeCredentialIds: ['pzpQZRhXUkboj-b_srH0X42XJS7Ai2ZXd6-9lnFULig'], name: 'Foo', }), ).toMatchInlineSnapshot(` { "publicKey": { "attestation": "none", "authenticatorSelection": { "requireResidentKey": false, "residentKey": "preferred", "userVerification": "required", }, "challenge": Uint8Array [ 105, 171, 180, 181, 160, 222, 75, 198, 42, 42, 32, 31, 141, 37, 186, 233, ], "excludeCredentials": [ { "id": Uint8Array [ 167, 58, 80, 101, 24, 87, 82, 70, 232, 143, 230, 255, 178, 177, 244, 95, 141, 151, 37, 46, 192, 139, 102, 87, 119, 175, 189, 150, 113, 84, 46, 40, ], "type": "public-key", }, ], "pubKeyCredParams": [ { "alg": -7, "type": "public-key", }, ], "rp": { "id": "https://example.com", "name": "My Website", }, "user": { "displayName": "Foo", "id": Uint8Array [ 182, 8, 199, 66, 131, 243, 52, 225, 240, 71, 219, 191, 29, 170, 36, 7, 212, 29, 70, 137, 172, 166, 124, 66, 39, 150, 249, 54, 172, 206, 22, 183, ], "name": "Foo", }, }, } `) }) test('args: user', () => { expect( Registration.getOptions({ user: { name: 'Foo', }, }), ).toMatchInlineSnapshot(` { "publicKey": { "attestation": "none", "authenticatorSelection": { "requireResidentKey": false, "residentKey": "preferred", "userVerification": "required", }, "challenge": Uint8Array [ 105, 171, 180, 181, 160, 222, 75, 198, 42, 42, 32, 31, 141, 37, 186, 233, ], "pubKeyCredParams": [ { "alg": -7, "type": "public-key", }, ], "rp": { "id": "https://example.com", "name": "My Website", }, "user": { "displayName": "Foo", "id": Uint8Array [ 182, 8, 199, 66, 131, 243, 52, 225, 240, 71, 219, 191, 29, 170, 36, 7, 212, 29, 70, 137, 172, 166, 124, 66, 39, 150, 249, 54, 172, 206, 22, 183, ], "name": "Foo", }, }, } `) }) }) describe('serializeOptions', () => { test('default', () => { const options = Registration.getOptions({ name: 'Foo' }) const serialized = Registration.serializeOptions(options) expect(typeof serialized.publicKey!.challenge).toBe('string') expect(typeof serialized.publicKey!.user.id).toBe('string') expect(serialized).toMatchInlineSnapshot(` { "publicKey": { "attestation": "none", "authenticatorSelection": { "requireResidentKey": false, "residentKey": "preferred", "userVerification": "required", }, "challenge": "0x69abb4b5a0de4bc62a2a201f8d25bae9", "pubKeyCredParams": [ { "alg": -7, "type": "public-key", }, ], "rp": { "id": "https://example.com", "name": "My Website", }, "user": { "displayName": "Foo", "id": "tgjHQoPzNOHwR9u_HaokB9QdRomspnxCJ5b5NqzOFrc", "name": "Foo", }, }, } `) expect(() => JSON.stringify(serialized)).not.toThrow() }) test('with excludeCredentials', () => { const options = Registration.getOptions({ name: 'Foo', excludeCredentialIds: ['pzpQZRhXUkboj-b_srH0X42XJS7Ai2ZXd6-9lnFULig'], }) const serialized = Registration.serializeOptions(options) expect(typeof serialized.publicKey!.excludeCredentials![0]!.id).toBe( 'string', ) expect(() => JSON.stringify(serialized)).not.toThrow() }) test('with extensions (prf)', () => { const options: Types.CredentialCreationOptions = { publicKey: { challenge: new Uint8Array([1, 2, 3]), pubKeyCredParams: [{ type: 'public-key', alg: -7 }], rp: { id: 'test', name: 'Test' }, user: { id: new Uint8Array([4, 5, 6]), name: 'Test', displayName: 'Test', }, extensions: { prf: { eval: { first: new Uint8Array([7, 8, 9]), }, }, }, }, } const serialized = Registration.serializeOptions(options) expect(typeof serialized.publicKey!.extensions!.prf!.eval.first).toBe( 'string', ) expect(() => JSON.stringify(serialized)).not.toThrow() }) test('empty publicKey', () => { const serialized = Registration.serializeOptions({}) expect(serialized).toEqual({}) }) }) describe('deserializeOptions', () => { test('default', () => { const options = Registration.getOptions({ name: 'Foo' }) const serialized = Registration.serializeOptions(options) const deserialized = Registration.deserializeOptions(serialized) expect(deserialized.publicKey!.challenge).toBeInstanceOf(Uint8Array) expect(deserialized.publicKey!.user.id).toBeInstanceOf(Uint8Array) expect(deserialized.publicKey!.challenge).toEqual( options.publicKey!.challenge, ) expect(deserialized.publicKey!.user.id).toEqual(options.publicKey!.user.id) expect(deserialized.publicKey!.user.name).toBe(options.publicKey!.user.name) expect(deserialized.publicKey!.rp).toEqual(options.publicKey!.rp) }) test('with excludeCredentials', () => { const options = Registration.getOptions({ name: 'Foo', excludeCredentialIds: ['pzpQZRhXUkboj-b_srH0X42XJS7Ai2ZXd6-9lnFULig'], }) const serialized = Registration.serializeOptions(options) const deserialized = Registration.deserializeOptions(serialized) expect(deserialized.publicKey!.excludeCredentials![0]!.id).toBeInstanceOf( Uint8Array, ) expect(deserialized.publicKey!.excludeCredentials![0]!.id).toEqual( options.publicKey!.excludeCredentials![0]!.id, ) }) test('with extensions (prf)', () => { const options: Types.CredentialCreationOptions = { publicKey: { challenge: new Uint8Array([1, 2, 3]), pubKeyCredParams: [{ type: 'public-key', alg: -7 }], rp: { id: 'test', name: 'Test' }, user: { id: new Uint8Array([4, 5, 6]), name: 'Test', displayName: 'Test', }, extensions: { prf: { eval: { first: new Uint8Array([7, 8, 9]), }, }, }, }, } const serialized = Registration.serializeOptions(options) const deserialized = Registration.deserializeOptions(serialized) expect(deserialized.publicKey!.extensions!.prf!.eval.first).toBeInstanceOf( Uint8Array, ) expect(deserialized.publicKey!.extensions!.prf!.eval.first).toEqual( new Uint8Array([7, 8, 9]), ) }) test('empty publicKey', () => { const deserialized = Registration.deserializeOptions({}) expect(deserialized).toEqual({}) }) test('JSON round-trip', () => { const options = Registration.getOptions({ name: 'Foo', excludeCredentialIds: ['pzpQZRhXUkboj-b_srH0X42XJS7Ai2ZXd6-9lnFULig'], }) const serialized = Registration.serializeOptions(options) const json = JSON.stringify(serialized) const parsed = JSON.parse(json) const deserialized = Registration.deserializeOptions(parsed) expect(deserialized.publicKey!.challenge).toEqual( options.publicKey!.challenge, ) expect(deserialized.publicKey!.user.id).toEqual(options.publicKey!.user.id) }) }) describe('verify', () => { async function mockCreateCredential(options?: { challenge?: `0x${string}` origin?: string rpId?: string flag?: number fmt?: 'none' | 'packed' }) { const challenge = options?.challenge ?? '0xdeadbeef' const origin = options?.origin ?? 'https://example.com' const rpId = options?.rpId ?? 'example.com' const flag = options?.flag ?? 0x45 // UP + UV + AT const fmt = options?.fmt ?? 'packed' const { privateKey, publicKey } = P256.createKeyPair() const credentialId = new Uint8Array(20).fill(42) const authData = Authenticator.getAuthenticatorData({ rpId, flag, credential: { id: credentialId, publicKey }, }) const clientDataJSON = Bytes.fromString( JSON.stringify({ type: 'webauthn.create', challenge: Base64.fromHex(challenge, { url: true, pad: false }), origin, crossOrigin: false, }), ) const attStmt: Record<string, unknown> = {} if (fmt === 'packed') { const clientDataHash = Hash.sha256(clientDataJSON, { as: 'Bytes' }) const verificationData = Bytes.concat( Hex.toBytes(authData), clientDataHash, ) const sig = P256.sign({ payload: verificationData, privateKey, hash: true, }) attStmt.alg = -7 attStmt.sig = Signature.toDerBytes(sig) } const attestationObject = Hex.toBytes( Authenticator.getAttestationObject({ authData, fmt, attStmt }), ) const credential = await Registration.create({ createFn: () => Promise.resolve({ id: Base64.fromBytes(credentialId, { url: true, pad: false }), type: 'public-key', authenticatorAttachment: 'platform', rawId: credentialId.buffer, response: { clientDataJSON: clientDataJSON.buffer, attestationObject: attestationObject.buffer, getPublicKey() { throw new Error('Permission denied to access object') }, }, getClientExtensionResults: () => ({}), } as any), name: 'Example', challenge, rp: { id: rpId, name: 'Test' }, }) return { challenge, credential, origin, rpId } } test('default', async () => { const { challenge, credential, origin, rpId } = await mockCreateCredential() const result = Registration.verify({ credential, challenge, origin, rpId, }) expect(result.credential).toEqual(credential) expect(result.counter).toBe(0) expect(result.userVerified).toBe(true) }) test('options: challenge as Uint8Array', async () => { const { credential, challenge, origin, rpId } = await mockCreateCredential() const result = Registration.verify({ credential, challenge: Hex.toBytes(challenge), origin, rpId, }) expect(result.credential.id).toBe(credential.id) }) test('options: challenge as function', async () => { const { credential, challenge, origin, rpId } = await mockCreateCredential() const result = Registration.verify({ credential, challenge: (c) => c === Base64.fromBytes(Hex.toBytes(challenge), { url: true, pad: false }), origin, rpId, }) expect(result.credential.id).toBe(credential.id) }) test('options: origin as array', async () => { const { credential, challenge, rpId } = await mockCreateCredential() const result = Registration.verify({ credential, challenge, origin: ['https://other.com', 'https://example.com'], rpId, }) expect(result.credential.id).toBe(credential.id) }) test('options: userVerification = discouraged', async () => { const { credential, challenge, origin, rpId } = await mockCreateCredential({ flag: 0x41, }) // UP + AT, no UV const result = Registration.verify({ credential, challenge, origin, rpId, userVerification: 'discouraged', }) expect(result.credential.id).toBe(credential.id) expect(result.userVerified).toBeUndefined() }) test('error: wrong type', async () => { const { credential, challenge, origin, rpId } = await mockCreateCredential() const tamperedClientDataJSON = JSON.stringify({ type: 'webauthn.get', challenge: Base64.fromHex(challenge, { url: true, pad: false }), origin, }) credential.clientDataJSON = new TextEncoder().encode(tamperedClientDataJSON) .buffer as ArrayBuffer expect(() => Registration.verify({ credential, challenge, origin, rpId, }), ).toThrow('Expected clientData.type "webauthn.create"') }) test('error: challenge mismatch', async () => { const { credential, origin, rpId } = await mockCreateCredential() expect(() => Registration.verify({ credential, challenge: '0xbadbadbadbad', origin, rpId, }), ).toThrow('Challenge mismatch') }) test('error: origin mismatch', async () => { const { credential, challenge, rpId } = await mockCreateCredential() expect(() => Registration.verify({ credential, challenge, origin: 'https://evil.com', rpId, }), ).toThrow('Origin mismatch') }) test('error: rpId mismatch', async () => { const { credential, challenge, origin } = await mockCreateCredential() expect(() => Registration.verify({ credential, challenge, origin, rpId: 'evil.com', }), ).toThrow('rpId hash mismatch') }) test('error: user verification required but not set', async () => { const { credential, challenge, origin, rpId } = await mockCreateCredential({ flag: 0x41, }) // UP + AT, no UV expect(() => Registration.verify({ credential, challenge, origin, rpId, }), ).toThrow('User verification flag not set') }) test('options: attestation = none', async () => { const { credential, challenge, origin, rpId } = await mockCreateCredential({ fmt: 'none', }) const result = Registration.verify({ credential, challenge, origin, rpId, attestation: 'none', }) expect(result.credential.id).toBe(credential.id) }) test('error: BS flag set without BE flag', async () => { const { credential, challenge, origin, rpId } = await mockCreateCredential({ flag: 0x55, }) // UP + UV + AT + BS (without BE) expect(() => Registration.verify({ credential, challenge, origin, rpId, attestation: 'none', }), ).toThrow('Backup state (BS) flag is set but backup eligibility (BE)') }) test('error: credential ID mismatch', async () => { const { credential, challenge, origin, rpId } = await mockCreateCredential() // Tamper with the credential ID credential.id = 'tampered-id-that-does-not-match' expect(() => Registration.verify({ credential, challenge, origin, rpId, }), ).toThrow('Credential ID mismatch') }) test('default attestation is "none" (matches getOptions default)', async () => { const { credential, challenge, origin, rpId } = await mockCreateCredential({ fmt: 'none', }) // No `attestation` option; runtime default must be 'none' so that // unattested ('fmt: "none"') credentials are accepted by default. const result = Registration.verify({ credential, challenge, origin, rpId, }) expect(result.credential.id).toBe(credential.id) }) test('error: fmt none rejected when attestation is required', async () => { const { credential, challenge, origin, rpId } = await mockCreateCredential({ fmt: 'none', }) expect(() => Registration.verify({ attestation: 'required', credential, challenge, origin, rpId, }), ).toThrow( 'Attestation format is "none" but attestation verification is required', ) }) test('serializeResponse → deserializeResponse round-trip', async () => { const { credential, challenge, origin, rpId } = await mockCreateCredential() const response = Registration.verify({ credential, challenge, origin, rpId, }) const serialized = Registration.serializeResponse(response) expect(typeof serialized.credential.attestationObject).toBe('string') expect(typeof serialized.credential.clientDataJSON).toBe('string') expect(typeof serialized.credential.publicKey).toBe('string') expect(serialized.credential.id).toBe(credential.id) expect(serialized.counter).toBe(0) expect(serialized.userVerified).toBe(true) const json = JSON.stringify(serialized) const deserialized = Registration.deserializeResponse(JSON.parse(json)) expect(deserialized.credential.id).toBe(credential.id) expect(deserialized.credential.publicKey.x).toBe(credential.publicKey.x) expect(deserialized.credential.publicKey.y).toBe(credential.publicKey.y) expect(deserialized.counter).toBe(response.counter) expect(deserialized.userVerified).toBe(response.userVerified) }) })