From cd9f2b0b56f772497f1f745274203a423cf4e50d Mon Sep 17 00:00:00 2001 From: arsalansufi Date: Tue, 7 Mar 2023 10:21:53 -0500 Subject: [PATCH] Add libs/auth tests, part 1 (#3075) * Add files to code coverage ignore list * Add test-utils dependency * Make minor tweaks to source files in prep for tests E.g. export more, adjust logic to make achieving 100% code coverage easier, etc. * Add tests for apdu.ts * Add tests for card_reader.ts * Add tests for certs.ts * Add tests for piv.ts * Bump code coverage thresholds * Update pnpm-lock.yaml * Misc cleanup --- libs/auth/jest.config.js | 15 +- libs/auth/package.json | 1 + libs/auth/src/apdu.test.ts | 235 ++++++++++++++++++++ libs/auth/src/apdu.ts | 14 +- libs/auth/src/card_reader.test.ts | 355 ++++++++++++++++++++++++++++++ libs/auth/src/card_reader.ts | 9 +- libs/auth/src/certs.test.ts | 233 ++++++++++++++++++++ libs/auth/src/certs.ts | 2 +- libs/auth/src/piv.test.ts | 82 +++++++ libs/auth/test/utils.ts | 10 + libs/auth/tsconfig.build.json | 1 + libs/auth/tsconfig.json | 1 + pnpm-lock.yaml | 2 + 13 files changed, 949 insertions(+), 11 deletions(-) create mode 100644 libs/auth/src/apdu.test.ts create mode 100644 libs/auth/src/card_reader.test.ts create mode 100644 libs/auth/src/certs.test.ts create mode 100644 libs/auth/src/piv.test.ts diff --git a/libs/auth/jest.config.js b/libs/auth/jest.config.js index 08e0a4c9f..87b57d994 100644 --- a/libs/auth/jest.config.js +++ b/libs/auth/jest.config.js @@ -5,12 +5,19 @@ const shared = require('../../jest.config.shared'); */ module.exports = { ...shared, + coveragePathIgnorePatterns: [ + 'src/index.ts', + 'src/legacy/.*.ts', + 'src/memory_card.ts', + 'src/test_utils.ts', + 'test/utils.ts,', + ], coverageThreshold: { global: { - statements: 0, - branches: 0, - functions: 0, - lines: 0, + statements: 44, + branches: 31, + functions: 48, + lines: 44, }, }, }; diff --git a/libs/auth/package.json b/libs/auth/package.json index b9c70631f..4eece1a7f 100644 --- a/libs/auth/package.json +++ b/libs/auth/package.json @@ -48,6 +48,7 @@ "@types/uuid": "^9.0.1", "@typescript-eslint/eslint-plugin": "^5.37.0", "@typescript-eslint/parser": "^5.37.0", + "@votingworks/test-utils": "workspace:*", "esbuild-runner": "^2.2.1", "eslint": "^8.23.1", "eslint-config-prettier": "^8.5.0", diff --git a/libs/auth/src/apdu.test.ts b/libs/auth/src/apdu.test.ts new file mode 100644 index 000000000..2fdac66c7 --- /dev/null +++ b/libs/auth/src/apdu.test.ts @@ -0,0 +1,235 @@ +import { Buffer } from 'buffer'; +import { Byte } from '@votingworks/types'; + +import { numericArray } from '../test/utils'; +import { + CardCommand, + CommandApdu, + constructTlv, + parseTlv, + ResponseApduError, +} from './apdu'; + +test.each<{ + cla?: { chained?: boolean; secure?: boolean }; + expectedFirstByte: Byte; +}>([ + { cla: undefined, expectedFirstByte: 0x00 }, + { cla: {}, expectedFirstByte: 0x00 }, + { cla: { chained: true }, expectedFirstByte: 0x10 }, + { cla: { secure: true }, expectedFirstByte: 0x0c }, + { cla: { chained: true, secure: true }, expectedFirstByte: 0x1c }, +])('CommandApdu CLA handling, $cla', ({ cla, expectedFirstByte }) => { + const apdu = new CommandApdu({ + cla, + ins: 0x01, + p1: 0x02, + p2: 0x03, + }); + expect(apdu.asBuffer()).toEqual( + Buffer.from([expectedFirstByte, 0x01, 0x02, 0x03, 0x00]) + ); +}); + +test('CommandApdu with data', () => { + const apdu = new CommandApdu({ + ins: 0x01, + p1: 0x02, + p2: 0x03, + data: Buffer.from([0x04, 0x05]), + }); + expect(apdu.asBuffer()).toEqual( + Buffer.from([0x00, 0x01, 0x02, 0x03, 0x02, 0x04, 0x05]) + ); +}); + +test('CommandApdu data length validation', () => { + expect( + () => + new CommandApdu({ + ins: 0x01, + p1: 0x02, + p2: 0x03, + data: Buffer.from(numericArray({ length: 256 })), + }) + ).toThrow('APDU data exceeds max command APDU data length'); +}); + +test('CommandApdu as hex string', () => { + const apdu = new CommandApdu({ + ins: 0xa1, + p1: 0xb2, + p2: 0xc3, + data: Buffer.from([0xd4, 0xe5]), + }); + expect(apdu.asHexString()).toEqual('00a1b2c302d4e5'); + expect(apdu.asHexString(':')).toEqual('00:a1:b2:c3:02:d4:e5'); +}); + +test('CardCommand with no data', () => { + const command = new CardCommand({ + ins: 0x01, + p1: 0x02, + p2: 0x03, + }); + expect(command.asCommandApdus().map((apdu) => apdu.asBuffer())).toEqual([ + Buffer.from([0x00, 0x01, 0x02, 0x03, 0x00]), + ]); +}); + +test('CardCommand with data requiring a single APDU', () => { + const command = new CardCommand({ + ins: 0x01, + p1: 0x02, + p2: 0x03, + data: Buffer.from(numericArray({ length: 255 })), + }); + expect(command.asCommandApdus().map((apdu) => apdu.asBuffer())).toEqual([ + Buffer.from([ + 0x00, + 0x01, + 0x02, + 0x03, + 0xff, + ...numericArray({ length: 255 }), + ]), + ]); +}); + +test('CardCommand with data requiring multiple APDUs', () => { + const command = new CardCommand({ + ins: 0x01, + p1: 0x02, + p2: 0x03, + data: Buffer.from([ + ...numericArray({ length: 200, value: 1 }), + ...numericArray({ length: 200, value: 2 }), + ...numericArray({ length: 200, value: 3 }), + ]), + }); + expect(command.asCommandApdus().map((apdu) => apdu.asBuffer())).toEqual([ + Buffer.from([ + 0x10, + 0x01, + 0x02, + 0x03, + 0xff, + ...numericArray({ length: 200, value: 1 }), + ...numericArray({ length: 55, value: 2 }), + ]), + Buffer.from([ + 0x10, + 0x01, + 0x02, + 0x03, + 0xff, + ...numericArray({ length: 145, value: 2 }), + ...numericArray({ length: 110, value: 3 }), + ]), + Buffer.from([ + 0x00, + 0x01, + 0x02, + 0x03, + 0x5a, // 90 (600 - 255 - 255) in hex + ...numericArray({ length: 90, value: 3 }), + ]), + ]); +}); + +test('constructTlv with Byte tag', () => { + const tlv = constructTlv(0x01, Buffer.from([0x02, 0x03])); + expect(tlv).toEqual(Buffer.from([0x01, 0x02, 0x02, 0x03])); +}); + +test('constructTlv with Buffer tag', () => { + const tlv = constructTlv( + Buffer.from([0x01, 0x02]), + Buffer.from([0x03, 0x04]) + ); + expect(tlv).toEqual(Buffer.from([0x01, 0x02, 0x02, 0x03, 0x04])); +}); + +test.each<{ valueLength: number; expectedTlvLength: Byte[] }>([ + { valueLength: 51, expectedTlvLength: [0x33] }, + { valueLength: 127, expectedTlvLength: [0x7f] }, + { valueLength: 147, expectedTlvLength: [0x81, 0x93] }, + { valueLength: 255, expectedTlvLength: [0x81, 0xff] }, + { valueLength: 3017, expectedTlvLength: [0x82, 0x0b, 0xc9] }, + { valueLength: 65535, expectedTlvLength: [0x82, 0xff, 0xff] }, +])( + 'constructTlv value length handling ($valueLength)', + ({ valueLength, expectedTlvLength }) => { + const value = numericArray({ length: valueLength }); + const tlv = constructTlv(0x01, Buffer.from(value)); + expect(tlv).toEqual(Buffer.from([0x01, ...expectedTlvLength, ...value])); + } +); + +test('constructTlv value length validation', () => { + expect(() => + constructTlv(0x01, Buffer.from(numericArray({ length: 65536 }))) + ).toThrow('TLV value is too large'); +}); + +test.each<{ + tagAsByteOrBuffer: Byte | Buffer; + tlv: Buffer; + expectedOutput: [Buffer, Buffer, Buffer]; +}>([ + { + tagAsByteOrBuffer: 0x01, + tlv: Buffer.from([0x01, 0x7f, ...numericArray({ length: 127 })]), + expectedOutput: [ + Buffer.from([0x01]), + Buffer.from([0x7f]), + Buffer.from(numericArray({ length: 127 })), + ], + }, + { + tagAsByteOrBuffer: 0x01, + tlv: Buffer.from([0x01, 0x81, 0xff, ...numericArray({ length: 255 })]), + expectedOutput: [ + Buffer.from([0x01]), + Buffer.from([0x81, 0xff]), + Buffer.from(numericArray({ length: 255 })), + ], + }, + { + tagAsByteOrBuffer: 0x01, + tlv: Buffer.from([ + 0x01, + 0x82, + 0xff, + 0xff, + ...numericArray({ length: 65535 }), + ]), + expectedOutput: [ + Buffer.from([0x01]), + Buffer.from([0x82, 0xff, 0xff]), + Buffer.from(numericArray({ length: 65535 })), + ], + }, + { + tagAsByteOrBuffer: Buffer.from([0x01, 0x02]), + tlv: Buffer.from([0x01, 0x02, 0x01, 0x00]), + expectedOutput: [ + Buffer.from([0x01, 0x02]), + Buffer.from([0x01]), + Buffer.from([0x00]), + ], + }, +])('parseTlv', ({ tagAsByteOrBuffer, tlv, expectedOutput }) => { + expect(parseTlv(tagAsByteOrBuffer, tlv)).toEqual(expectedOutput); +}); + +test('ResponseApduError', () => { + const error = new ResponseApduError([0x6a, 0x82]); + expect(error.message).toEqual( + 'Received response APDU with non-success status: 6a 82' + ); + expect(error.statusWord()).toEqual([0x6a, 0x82]); + expect(error.hasStatusWord([0x6a, 0x82])).toEqual(true); + expect(error.hasStatusWord([0x6b, 0x82])).toEqual(false); + expect(error.hasStatusWord([0x6a, 0x83])).toEqual(false); +}); diff --git a/libs/auth/src/apdu.ts b/libs/auth/src/apdu.ts index 81dfab119..78d0fdd13 100644 --- a/libs/auth/src/apdu.ts +++ b/libs/auth/src/apdu.ts @@ -12,7 +12,13 @@ export const MAX_APDU_LENGTH = 260; * The max length of a command APDU's data. The `- 5` accounts for the CLA, INS, P1, P2, and Lc * (see CommandApdu below). */ -const MAX_COMMAND_APDU_DATA_LENGTH = MAX_APDU_LENGTH - 5; +export const MAX_COMMAND_APDU_DATA_LENGTH = MAX_APDU_LENGTH - 5; + +/** + * The max length of a response APDU's data. The `- 2` accounts for the status word (see + * STATUS_WORD below). + */ +export const MAX_RESPONSE_APDU_DATA_LENGTH = MAX_APDU_LENGTH - 2; /** * Because APDUs have a max length, commands involving larger amounts of data have to be sent as @@ -66,8 +72,10 @@ export const GET_RESPONSE = { } as const; function splitEvery2Characters(s: string): string[] { - assert(s.length % 2 === 0); - return s.match(/.{2}/g) || []; + assert(s.length > 0 && s.length % 2 === 0); + const sSplit = s.match(/.{2}/g); + assert(sSplit !== null); + return sSplit; } /** diff --git a/libs/auth/src/card_reader.test.ts b/libs/auth/src/card_reader.test.ts new file mode 100644 index 000000000..bbdd943f4 --- /dev/null +++ b/libs/auth/src/card_reader.test.ts @@ -0,0 +1,355 @@ +import { Buffer } from 'buffer'; +import EventEmitter from 'events'; +import pcscLite from 'pcsclite'; +import { mockOf } from '@votingworks/test-utils'; + +import { numericArray } from '../test/utils'; +import { + CardCommand, + GET_RESPONSE, + MAX_APDU_LENGTH, + MAX_COMMAND_APDU_DATA_LENGTH, + MAX_RESPONSE_APDU_DATA_LENGTH, + ResponseApduError, + STATUS_WORD, +} from './apdu'; +import { CardReader, PcscLite } from './card_reader'; + +type ConnectCallback = (error?: Error, protocol?: number) => void; +type Connect = (options: { share_mode: number }, cb: ConnectCallback) => void; +type DisconnectCallback = (error?: Error) => void; +type Disconnect = (db: DisconnectCallback) => void; +type TransmitCallback = (error?: Error, response?: Buffer) => void; +type Transmit = ( + data: Buffer, + responseLength: number, + protocol: number, + cb: TransmitCallback +) => void; + +// Because pcsclite doesn't export the reader type (and it can't easily be extracted from the types +// that are exported), create a type that covers the subset of the reader interface that we use +type PcscLiteReader = EventEmitter & { + connect: Connect; + disconnect: Disconnect; + SCARD_SHARE_EXCLUSIVE: number; + SCARD_STATE_PRESENT: number; + transmit: Transmit; +}; + +function newMockPcscLiteReader(): PcscLiteReader { + const additionalFields: Partial = { + connect: jest.fn(), + disconnect: jest.fn(), + SCARD_SHARE_EXCLUSIVE: 123, + SCARD_STATE_PRESENT: 1, // A number that's easy to reason about bitwise-& with + transmit: jest.fn(), + }; + return Object.assign(new EventEmitter(), additionalFields) as PcscLiteReader; +} + +jest.mock('pcsclite'); + +let mockPcscLite: PcscLite; +let mockPcscLiteReader: PcscLiteReader; +let onReaderStatusChange: jest.Mock; + +beforeEach(() => { + mockPcscLite = new EventEmitter() as PcscLite; + mockOf(pcscLite).mockImplementation(() => mockPcscLite); + mockPcscLiteReader = newMockPcscLiteReader(); + onReaderStatusChange = jest.fn(); +}); + +const simpleCommand = { + command: new CardCommand({ ins: 0x01, p1: 0x02, p2: 0x03 }), + buffer: Buffer.from([0x00, 0x01, 0x02, 0x03, 0x00]), +} as const; +const commandWithLotsOfData = { + command: new CardCommand({ + ins: 0x01, + p1: 0x02, + p2: 0x03, + data: Buffer.from( + numericArray({ length: MAX_COMMAND_APDU_DATA_LENGTH * 2 + 10 }) + ), + }), + buffers: [ + Buffer.from([ + 0x10, + 0x01, + 0x02, + 0x03, + MAX_COMMAND_APDU_DATA_LENGTH, + ...numericArray({ length: MAX_COMMAND_APDU_DATA_LENGTH }), + ]), + Buffer.from([ + 0x10, + 0x01, + 0x02, + 0x03, + MAX_COMMAND_APDU_DATA_LENGTH, + ...numericArray({ length: MAX_COMMAND_APDU_DATA_LENGTH }), + ]), + Buffer.from([ + 0x00, + 0x01, + 0x02, + 0x03, + 0x0a, // 10 in hex + ...numericArray({ length: 10 }), + ]), + ], +} as const; + +const mockConnectProtocol = 0; +const mockConnectSuccess: Connect = (_options, cb) => + cb(undefined, mockConnectProtocol); +const mockConnectError: Connect = (_options, cb) => cb(new Error('Whoa!')); +function newMockTransmitSuccess(response: Buffer): Transmit { + return (_data, _responseLength, _protocol, cb) => cb(undefined, response); +} +const mockTransmitError: Transmit = (_data, _responseLength, _protocol, cb) => + cb(new Error('Whoa!')); + +function newCardReader( + startingStatus: 'default' | 'ready' = 'default' +): CardReader { + const cardReader = new CardReader({ onReaderStatusChange }); + if (startingStatus === 'ready') { + mockPcscLite.emit('reader', mockPcscLiteReader); + mockOf(mockPcscLiteReader.connect).mockImplementationOnce( + mockConnectSuccess + ); + mockPcscLiteReader.emit('status', { state: 1 }); + expect(onReaderStatusChange).toHaveBeenNthCalledWith(1, 'ready'); + onReaderStatusChange.mockClear(); + } + return cardReader; +} + +test('CardReader status changes', () => { + newCardReader(); + + mockPcscLite.emit('error'); + expect(onReaderStatusChange).toHaveBeenNthCalledWith(1, 'unknown_error'); + + mockPcscLite.emit('reader', mockPcscLiteReader); + mockPcscLiteReader.emit('error'); + // Verify that onReaderStatusChange hasn't been called, since the status is still unknown_error + expect(onReaderStatusChange).toHaveBeenCalledTimes(1); + + mockOf(mockPcscLiteReader.connect).mockImplementationOnce(mockConnectError); + mockPcscLiteReader.emit('status', { state: 1 }); + expect(mockPcscLiteReader.connect).toHaveBeenCalledWith( + { share_mode: mockPcscLiteReader.SCARD_SHARE_EXCLUSIVE }, + expect.anything() + ); + expect(onReaderStatusChange).toHaveBeenNthCalledWith(2, 'card_error'); + + mockOf(mockPcscLiteReader.connect).mockImplementationOnce(mockConnectSuccess); + mockPcscLiteReader.emit('status', { state: 1 }); + expect(mockPcscLiteReader.connect).toHaveBeenCalledWith( + { share_mode: mockPcscLiteReader.SCARD_SHARE_EXCLUSIVE }, + expect.anything() + ); + expect(onReaderStatusChange).toHaveBeenNthCalledWith(3, 'ready'); + + mockPcscLiteReader.emit('status', { state: 0 }); + expect(onReaderStatusChange).toHaveBeenNthCalledWith(4, 'no_card'); + expect(mockPcscLiteReader.disconnect).toHaveBeenCalledTimes(1); + + mockPcscLiteReader.emit('error'); + expect(onReaderStatusChange).toHaveBeenNthCalledWith(5, 'unknown_error'); + + mockPcscLiteReader.emit('end'); + expect(onReaderStatusChange).toHaveBeenNthCalledWith(6, 'no_card_reader'); + + // Verify that onReaderStatusChange hasn't been called any additional times + expect(onReaderStatusChange).toHaveBeenCalledTimes(6); +}); + +test('CardReader command transmission, reader not ready', async () => { + const cardReader = newCardReader(); + + await expect(cardReader.transmit(simpleCommand.command)).rejects.toThrow( + 'Reader not ready' + ); +}); + +test('CardReader command transmission, success', async () => { + const cardReader = newCardReader('ready'); + mockOf(mockPcscLiteReader.transmit).mockImplementationOnce( + newMockTransmitSuccess( + Buffer.from([STATUS_WORD.SUCCESS.SW1, STATUS_WORD.SUCCESS.SW2]) + ) + ); + + expect(await cardReader.transmit(simpleCommand.command)).toEqual( + Buffer.from([]) + ); + + expect(mockPcscLiteReader.transmit).toHaveBeenNthCalledWith( + 1, + simpleCommand.buffer, + MAX_APDU_LENGTH, + mockConnectProtocol, + expect.anything() + ); +}); + +test('CardReader command transmission, response APDU with non-success status', async () => { + const cardReader = newCardReader('ready'); + mockOf(mockPcscLiteReader.transmit).mockImplementationOnce( + newMockTransmitSuccess( + Buffer.from([ + STATUS_WORD.FILE_NOT_FOUND.SW1, + STATUS_WORD.FILE_NOT_FOUND.SW2, + ]) + ) + ); + + await expect(cardReader.transmit(simpleCommand.command)).rejects.toThrow( + new ResponseApduError([ + STATUS_WORD.FILE_NOT_FOUND.SW1, + STATUS_WORD.FILE_NOT_FOUND.SW2, + ]) + ); + + expect(mockPcscLiteReader.transmit).toHaveBeenNthCalledWith( + 1, + simpleCommand.buffer, + MAX_APDU_LENGTH, + mockConnectProtocol, + expect.anything() + ); +}); + +test('CardReader command transmission, response APDU with no status', async () => { + const cardReader = newCardReader('ready'); + mockOf(mockPcscLiteReader.transmit).mockImplementationOnce( + newMockTransmitSuccess(Buffer.from([])) + ); + + await expect(cardReader.transmit(simpleCommand.command)).rejects.toThrow(); + + expect(mockPcscLiteReader.transmit).toHaveBeenNthCalledWith( + 1, + simpleCommand.buffer, + MAX_APDU_LENGTH, + mockConnectProtocol, + expect.anything() + ); +}); + +test('CardReader command transmission, chained command', async () => { + const cardReader = newCardReader('ready'); + mockOf(mockPcscLiteReader.transmit).mockImplementationOnce( + newMockTransmitSuccess( + Buffer.from([STATUS_WORD.SUCCESS.SW1, STATUS_WORD.SUCCESS.SW2]) + ) + ); + mockOf(mockPcscLiteReader.transmit).mockImplementationOnce( + newMockTransmitSuccess( + Buffer.from([STATUS_WORD.SUCCESS.SW1, STATUS_WORD.SUCCESS.SW2]) + ) + ); + mockOf(mockPcscLiteReader.transmit).mockImplementationOnce( + newMockTransmitSuccess( + Buffer.from([0x00, STATUS_WORD.SUCCESS.SW1, STATUS_WORD.SUCCESS.SW2]) + ) + ); + + expect(await cardReader.transmit(commandWithLotsOfData.command)).toEqual( + Buffer.from([0x00]) + ); + + expect(mockPcscLiteReader.transmit).toHaveBeenNthCalledWith( + 1, + commandWithLotsOfData.buffers[0], + MAX_APDU_LENGTH, + mockConnectProtocol, + expect.anything() + ); + expect(mockPcscLiteReader.transmit).toHaveBeenNthCalledWith( + 2, + commandWithLotsOfData.buffers[1], + MAX_APDU_LENGTH, + mockConnectProtocol, + expect.anything() + ); + expect(mockPcscLiteReader.transmit).toHaveBeenNthCalledWith( + 3, + commandWithLotsOfData.buffers[2], + MAX_APDU_LENGTH, + mockConnectProtocol, + expect.anything() + ); +}); + +test('CardReader command transmission, chained response', async () => { + const cardReader = newCardReader('ready'); + mockOf(mockPcscLiteReader.transmit).mockImplementationOnce( + newMockTransmitSuccess( + Buffer.from([ + ...numericArray({ length: MAX_RESPONSE_APDU_DATA_LENGTH, value: 1 }), + STATUS_WORD.SUCCESS_MORE_DATA_AVAILABLE.SW1, + 10, + ]) + ) + ); + mockOf(mockPcscLiteReader.transmit).mockImplementationOnce( + newMockTransmitSuccess( + Buffer.from([ + ...numericArray({ length: 10, value: 2 }), + STATUS_WORD.SUCCESS.SW1, + STATUS_WORD.SUCCESS.SW2, + ]) + ) + ); + + expect(await cardReader.transmit(simpleCommand.command)).toEqual( + Buffer.from([ + ...numericArray({ length: MAX_RESPONSE_APDU_DATA_LENGTH, value: 1 }), + ...numericArray({ length: 10, value: 2 }), + ]) + ); + + expect(mockPcscLiteReader.transmit).toHaveBeenNthCalledWith( + 1, + simpleCommand.buffer, + MAX_APDU_LENGTH, + mockConnectProtocol, + expect.anything() + ); + expect(mockPcscLiteReader.transmit).toHaveBeenNthCalledWith( + 2, + Buffer.from([ + 0x00, + GET_RESPONSE.INS, + GET_RESPONSE.P1, + GET_RESPONSE.P2, + 0x00, + ]), + MAX_APDU_LENGTH, + mockConnectProtocol, + expect.anything() + ); +}); + +test('CardReader command transmission, transmit failure', async () => { + const cardReader = newCardReader('ready'); + mockOf(mockPcscLiteReader.transmit).mockImplementationOnce(mockTransmitError); + + await expect(cardReader.transmit(simpleCommand.command)).rejects.toThrow( + 'Failed to transmit data to card' + ); + + expect(mockPcscLiteReader.transmit).toHaveBeenNthCalledWith( + 1, + simpleCommand.buffer, + MAX_APDU_LENGTH, + mockConnectProtocol, + expect.anything() + ); +}); diff --git a/libs/auth/src/card_reader.ts b/libs/auth/src/card_reader.ts index c7dcbf301..c9f433308 100644 --- a/libs/auth/src/card_reader.ts +++ b/libs/auth/src/card_reader.ts @@ -1,5 +1,5 @@ import { Buffer } from 'buffer'; -import pcscLite from 'pcsclite'; +import newPcscLite from 'pcsclite'; import { promisify } from 'util'; import { assert } from '@votingworks/basics'; import { isByte } from '@votingworks/types'; @@ -13,7 +13,10 @@ import { STATUS_WORD, } from './apdu'; -type PcscLite = ReturnType; +/** + * A PCSC Lite instance + */ +export type PcscLite = ReturnType; interface ReaderReady { status: 'ready'; @@ -38,7 +41,7 @@ export class CardReader { constructor(input: { onReaderStatusChange: OnReaderStatusChange }) { this.onReaderStatusChange = input.onReaderStatusChange; - this.pcscLite = pcscLite(); + this.pcscLite = newPcscLite(); this.reader = { status: 'no_card_reader' }; this.pcscLite.on('error', () => { diff --git a/libs/auth/src/certs.test.ts b/libs/auth/src/certs.test.ts new file mode 100644 index 000000000..91fe10f21 --- /dev/null +++ b/libs/auth/src/certs.test.ts @@ -0,0 +1,233 @@ +import { Buffer } from 'buffer'; +import { mockOf } from '@votingworks/test-utils'; +import { + ElectionManagerUser, + PollWorkerUser, + SystemAdministratorUser, + User, +} from '@votingworks/types'; + +import { + constructCardCertSubject, + constructCardCertSubjectWithoutJurisdictionAndCardType, + CustomCertFields, + parseCert, + parseUserDataFromCert, +} from './certs'; +import { openssl } from './openssl'; + +jest.mock('./openssl'); + +const cert = Buffer.from([]); +const electionHash = + '43939f8d6b94dd85827c1d151d0b75f4617e934979d53b6d5ce2abf4535a93d4'; +const jurisdiction = 'ST.Jurisdiction'; + +test.each<{ subject: string; expectedCustomCertFields: CustomCertFields }>([ + { + subject: + 'subject=C = US, ST = CA, O = VotingWorks, ' + + '1.3.6.1.4.1.59817.1 = card, ' + + `1.3.6.1.4.1.59817.2 = ${jurisdiction}, ` + + '1.3.6.1.4.1.59817.3 = em, ' + + `1.3.6.1.4.1.59817.4 = ${electionHash}`, + expectedCustomCertFields: { + component: 'card', + jurisdiction, + cardType: 'em', + electionHash, + }, + }, + { + subject: + 'subject=C = US, ST = CA, O = VotingWorks, ' + + '1.3.6.1.4.1.59817.1 = card, ' + + `1.3.6.1.4.1.59817.2 = ${jurisdiction}, ` + + '1.3.6.1.4.1.59817.3 = sa', + expectedCustomCertFields: { + component: 'card', + jurisdiction, + cardType: 'sa', + }, + }, + { + subject: + 'subject=C = US, ST = CA, O = VotingWorks, ' + + '1.3.6.1.4.1.59817.1 = admin, ' + + `1.3.6.1.4.1.59817.2 = ${jurisdiction}`, + expectedCustomCertFields: { + component: 'admin', + jurisdiction, + }, + }, +])('parseCert', async ({ subject, expectedCustomCertFields }) => { + mockOf(openssl).mockImplementationOnce(() => + Promise.resolve(Buffer.from(subject, 'utf-8')) + ); + expect(await parseCert(cert)).toEqual(expectedCustomCertFields); +}); + +test.each<{ description: string; subject: string }>([ + { + description: 'invalid component', + subject: + 'subject=C = US, ST = CA, O = VotingWorks, ' + + '1.3.6.1.4.1.59817.1 = invalid-component, ' + + `1.3.6.1.4.1.59817.2 = ${jurisdiction}`, + }, + { + description: 'invalid card type', + subject: + 'subject=C = US, ST = CA, O = VotingWorks, ' + + '1.3.6.1.4.1.59817.1 = card, ' + + `1.3.6.1.4.1.59817.2 = ${jurisdiction}, ` + + '1.3.6.1.4.1.59817.3 = invalid-card-type', + }, + { + description: 'missing component', + subject: + 'subject=C = US, ST = CA, O = VotingWorks, ' + + `1.3.6.1.4.1.59817.2 = ${jurisdiction}`, + }, + { + description: 'missing jurisdiction', + subject: + 'subject=C = US, ST = CA, O = VotingWorks, ' + + '1.3.6.1.4.1.59817.1 = admin', + }, +])('parseCert validation, $description', async ({ subject }) => { + mockOf(openssl).mockImplementationOnce(() => + Promise.resolve(Buffer.from(subject, 'utf-8')) + ); + await expect(parseCert(cert)).rejects.toThrow(); +}); + +test.each<{ subject: string; expectedUserData: User }>([ + { + subject: + 'subject=C = US, ST = CA, O = VotingWorks, ' + + '1.3.6.1.4.1.59817.1 = card, ' + + `1.3.6.1.4.1.59817.2 = ${jurisdiction}, ` + + '1.3.6.1.4.1.59817.3 = sa', + expectedUserData: { + role: 'system_administrator', + }, + }, + { + subject: + 'subject=C = US, ST = CA, O = VotingWorks, ' + + '1.3.6.1.4.1.59817.1 = card, ' + + `1.3.6.1.4.1.59817.2 = ${jurisdiction}, ` + + '1.3.6.1.4.1.59817.3 = em, ' + + `1.3.6.1.4.1.59817.4 = ${electionHash}`, + expectedUserData: { + role: 'election_manager', + electionHash, + }, + }, + { + subject: + 'subject=C = US, ST = CA, O = VotingWorks, ' + + '1.3.6.1.4.1.59817.1 = card, ' + + `1.3.6.1.4.1.59817.2 = ${jurisdiction}, ` + + '1.3.6.1.4.1.59817.3 = pw, ' + + `1.3.6.1.4.1.59817.4 = ${electionHash}`, + expectedUserData: { + role: 'poll_worker', + electionHash, + }, + }, +])('parseUserDataFromCert', async ({ subject, expectedUserData }) => { + mockOf(openssl).mockImplementationOnce(() => + Promise.resolve(Buffer.from(subject, 'utf-8')) + ); + expect(await parseUserDataFromCert(cert, jurisdiction)).toEqual( + expectedUserData + ); +}); + +test.each<{ description: string; subject: string }>([ + { + description: 'machine cert instead of card cert', + subject: + 'subject=C = US, ST = CA, O = VotingWorks, ' + + '1.3.6.1.4.1.59817.1 = admin, ' + + `1.3.6.1.4.1.59817.2 = ${jurisdiction}`, + }, + { + description: 'wrong jurisdiction', + subject: + 'subject=C = US, ST = CA, O = VotingWorks, ' + + '1.3.6.1.4.1.59817.1 = card, ' + + `1.3.6.1.4.1.59817.2 = ST.WrongJurisdiction, ` + + '1.3.6.1.4.1.59817.3 = sa', + }, + { + description: 'missing card type', + subject: + 'subject=C = US, ST = CA, O = VotingWorks, ' + + '1.3.6.1.4.1.59817.1 = card, ' + + `1.3.6.1.4.1.59817.2 = ${jurisdiction}`, + }, + { + description: 'missing election hash for election card', + subject: + 'subject=C = US, ST = CA, O = VotingWorks, ' + + '1.3.6.1.4.1.59817.1 = card, ' + + `1.3.6.1.4.1.59817.2 = ${jurisdiction}, ` + + '1.3.6.1.4.1.59817.3 = em', + }, +])('parseUserDataFromCert validation, $description', async ({ subject }) => { + mockOf(openssl).mockImplementationOnce(() => + Promise.resolve(Buffer.from(subject, 'utf-8')) + ); + await expect(parseUserDataFromCert(cert, jurisdiction)).rejects.toThrow(); +}); + +test.each<{ + user: SystemAdministratorUser | ElectionManagerUser | PollWorkerUser; + expectedSubject: string; +}>([ + { + user: { + role: 'system_administrator', + }, + expectedSubject: + '/C=US/ST=CA/O=VotingWorks' + + '/1.3.6.1.4.1.59817.1=card' + + `/1.3.6.1.4.1.59817.2=${jurisdiction}` + + '/1.3.6.1.4.1.59817.3=sa/', + }, + { + user: { + role: 'election_manager', + electionHash, + }, + expectedSubject: + '/C=US/ST=CA/O=VotingWorks' + + '/1.3.6.1.4.1.59817.1=card' + + `/1.3.6.1.4.1.59817.2=${jurisdiction}` + + '/1.3.6.1.4.1.59817.3=em' + + `/1.3.6.1.4.1.59817.4=${electionHash}/`, + }, + { + user: { + role: 'poll_worker', + electionHash, + }, + expectedSubject: + '/C=US/ST=CA/O=VotingWorks' + + '/1.3.6.1.4.1.59817.1=card' + + `/1.3.6.1.4.1.59817.2=${jurisdiction}` + + '/1.3.6.1.4.1.59817.3=pw' + + `/1.3.6.1.4.1.59817.4=${electionHash}/`, + }, +])('constructCardCertSubject', ({ user, expectedSubject }) => { + expect(constructCardCertSubject(user, jurisdiction)).toEqual(expectedSubject); +}); + +test('constructCardCertSubjectWithoutJurisdictionAndCardType', () => { + expect(constructCardCertSubjectWithoutJurisdictionAndCardType()).toEqual( + '/C=US/ST=CA/O=VotingWorks/1.3.6.1.4.1.59817.1=card/' + ); +}); diff --git a/libs/auth/src/certs.ts b/libs/auth/src/certs.ts index 69743491e..72c6bbdea 100644 --- a/libs/auth/src/certs.ts +++ b/libs/auth/src/certs.ts @@ -41,7 +41,7 @@ const STANDARD_CERT_FIELDS = [ /** * Parsed custom cert fields */ -interface CustomCertFields { +export interface CustomCertFields { component: 'card' | 'admin' | 'central-scan' | 'mark' | 'scan'; jurisdiction: string; cardType?: 'sa' | 'em' | 'pw'; diff --git a/libs/auth/src/piv.test.ts b/libs/auth/src/piv.test.ts new file mode 100644 index 000000000..a79a55f03 --- /dev/null +++ b/libs/auth/src/piv.test.ts @@ -0,0 +1,82 @@ +import { Buffer } from 'buffer'; +import { Byte } from '@votingworks/types'; + +import { + construct8BytePinBuffer, + isIncorrectPinStatusWord, + numRemainingAttemptsFromIncorrectPinStatusWord, + pivDataObjectId, +} from './piv'; + +test('pivDataObjectId', () => { + expect(pivDataObjectId(0x00)).toEqual(Buffer.from([0x5f, 0xc1, 0x00])); +}); + +test.each<{ pin: string; expectedBuffer: Buffer }>([ + { + pin: '123456', + expectedBuffer: Buffer.from([ + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0xff, 0xff, + ]), + }, + { + pin: '12345678', + expectedBuffer: Buffer.from([ + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + ]), + }, + { + pin: '0000', + expectedBuffer: Buffer.from([ + 0x30, 0x30, 0x30, 0x30, 0xff, 0xff, 0xff, 0xff, + ]), + }, + { + pin: '', + expectedBuffer: Buffer.from([ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + ]), + }, +])('construct8BytePinBuffer', ({ pin, expectedBuffer }) => { + expect(construct8BytePinBuffer(pin)).toEqual(expectedBuffer); +}); + +test('isIncorrectPinStatusWord', () => { + expect(isIncorrectPinStatusWord([0x63, 0xc0])).toEqual(true); + expect(isIncorrectPinStatusWord([0x63, 0xcf])).toEqual(true); + expect(isIncorrectPinStatusWord([0x73, 0xc0])).toEqual(false); + expect(isIncorrectPinStatusWord([0x64, 0xc0])).toEqual(false); + expect(isIncorrectPinStatusWord([0x63, 0xd0])).toEqual(false); +}); + +test.each<{ sw2: Byte; expectedNumRemainingAttempts: number }>([ + { sw2: 0xc0, expectedNumRemainingAttempts: 0 }, + { sw2: 0xc1, expectedNumRemainingAttempts: 1 }, + { sw2: 0xc2, expectedNumRemainingAttempts: 2 }, + { sw2: 0xc3, expectedNumRemainingAttempts: 3 }, + { sw2: 0xc4, expectedNumRemainingAttempts: 4 }, + { sw2: 0xc5, expectedNumRemainingAttempts: 5 }, + { sw2: 0xc6, expectedNumRemainingAttempts: 6 }, + { sw2: 0xc7, expectedNumRemainingAttempts: 7 }, + { sw2: 0xc8, expectedNumRemainingAttempts: 8 }, + { sw2: 0xc9, expectedNumRemainingAttempts: 9 }, + { sw2: 0xca, expectedNumRemainingAttempts: 10 }, + { sw2: 0xcb, expectedNumRemainingAttempts: 11 }, + { sw2: 0xcc, expectedNumRemainingAttempts: 12 }, + { sw2: 0xcd, expectedNumRemainingAttempts: 13 }, + { sw2: 0xce, expectedNumRemainingAttempts: 14 }, + { sw2: 0xcf, expectedNumRemainingAttempts: 15 }, +])( + 'numRemainingAttemptsFromIncorrectPinStatusWord', + ({ sw2, expectedNumRemainingAttempts }) => { + expect(numRemainingAttemptsFromIncorrectPinStatusWord([0x63, sw2])).toEqual( + expectedNumRemainingAttempts + ); + } +); + +test('numRemainingAttemptsFromIncorrectPinStatusWord validation', () => { + expect(() => + numRemainingAttemptsFromIncorrectPinStatusWord([0x90, 0x00]) + ).toThrow(); +}); diff --git a/libs/auth/test/utils.ts b/libs/auth/test/utils.ts index 0406652c0..9d68c5159 100644 --- a/libs/auth/test/utils.ts +++ b/libs/auth/test/utils.ts @@ -1,5 +1,15 @@ import { Card } from '../src/card'; +/** + * Generates a numeric array of the specified length, where all values are the specified value + */ +export function numericArray(input: { + length: number; + value?: number; +}): number[] { + return Array.from({ length: input.length }).fill(input.value ?? 0); +} + /** * Builds a mock card instance */ diff --git a/libs/auth/tsconfig.build.json b/libs/auth/tsconfig.build.json index 37a5c0e87..ea40a233a 100644 --- a/libs/auth/tsconfig.build.json +++ b/libs/auth/tsconfig.build.json @@ -13,6 +13,7 @@ { "path": "../basics/tsconfig.build.json" }, { "path": "../eslint-plugin-vx/tsconfig.build.json" }, { "path": "../logging/tsconfig.build.json" }, + { "path": "../test-utils/tsconfig.build.json" }, { "path": "../types/tsconfig.build.json" }, { "path": "../utils/tsconfig.build.json" } ] diff --git a/libs/auth/tsconfig.json b/libs/auth/tsconfig.json index fccd25bdd..b8e8a696c 100644 --- a/libs/auth/tsconfig.json +++ b/libs/auth/tsconfig.json @@ -14,6 +14,7 @@ { "path": "../basics/tsconfig.build.json" }, { "path": "../eslint-plugin-vx/tsconfig.build.json" }, { "path": "../logging/tsconfig.build.json" }, + { "path": "../test-utils/tsconfig.build.json" }, { "path": "../types/tsconfig.build.json" }, { "path": "../utils/tsconfig.build.json" } ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76a4feda3..1430e8967 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1869,6 +1869,7 @@ importers: '@typescript-eslint/parser': ^5.37.0 '@votingworks/basics': workspace:* '@votingworks/logging': workspace:* + '@votingworks/test-utils': workspace:* '@votingworks/types': workspace:* '@votingworks/utils': workspace:* base64-js: ^1.3.1 @@ -1912,6 +1913,7 @@ importers: '@types/uuid': 9.0.1 '@typescript-eslint/eslint-plugin': 5.37.0_srb6gpj27cpytxa4hvxonfqyzi '@typescript-eslint/parser': 5.37.0_fnqnj4wr4adlkzkgntzwybsmii + '@votingworks/test-utils': link:../test-utils esbuild-runner: 2.2.1_esbuild@0.17.6 eslint: 8.26.0 eslint-config-prettier: 8.5.0_eslint@8.26.0