diff --git a/libs/auth/README.md b/libs/auth/README.md index 07dcb8107..b32f2b97b 100644 --- a/libs/auth/README.md +++ b/libs/auth/README.md @@ -52,7 +52,7 @@ development. # Install script dependencies make install-script-dependencies -# With the relevant env vars set, a card reader connected, and a Java Card in the card reader, run: +# With the relevant env vars set, a card reader connected, and a Java Card in the card reader ./scripts/configure-java-card ``` @@ -81,7 +81,7 @@ production or development card. Programming a production card requires additional production-machine-specific env vars. ``` -# With the relevant env vars set, a card reader connected, and a Java Card in the card reader, run: +# With the relevant env vars set, a card reader connected, and a Java Card in the card reader ./scripts/program-system-administrator-java-card ``` @@ -95,6 +95,16 @@ relevant env vars for local development and then calls the base script: The initial Java Card configuration script needs to be run before this script can be run. This script will remind you if you haven't done so. +### Java Card Detail Reading Script + +This script reads Java Card details, namely environment, jurisdiction, user +role, and election hash. + +``` +# With a card reader connected and a Java Card in the card reader +./scripts/read-java-card-details +``` + ### Production Machine Cert Signing Request Creation Script This script creates a production machine cert signing request, using the @@ -103,7 +113,7 @@ create a machine cert. Because the script requires a TPM, it can only be run on real hardware. ``` -# With relevant env vars set +# With the relevant env vars set ./scripts/create-production-machine-cert-signing-request ``` diff --git a/libs/auth/scripts/create_production_machine_cert_signing_request.ts b/libs/auth/scripts/create_production_machine_cert_signing_request.ts index b2a211547..4da7ee9c6 100644 --- a/libs/auth/scripts/create_production_machine_cert_signing_request.ts +++ b/libs/auth/scripts/create_production_machine_cert_signing_request.ts @@ -1,18 +1,11 @@ import { Buffer } from 'buffer'; -import { assert, extractErrorMessage } from '@votingworks/basics'; +import { extractErrorMessage } from '@votingworks/basics'; -import { constructMachineCertSubject, MachineType } from '../src/certs'; +import { constructMachineCertSubject } from '../src/certs'; import { getRequiredEnvVar } from '../src/env_vars'; import { createCertSigningRequest } from '../src/openssl'; -const machineType = getRequiredEnvVar('VX_MACHINE_TYPE') as MachineType; -assert( - machineType === 'admin' || - machineType === 'central-scan' || - machineType === 'mark' || - machineType === 'scan', - 'VX_MACHINE_TYPE should be one of admin, central-scan, mark, or scan' -); +const machineType = getRequiredEnvVar('VX_MACHINE_TYPE'); const jurisdiction = machineType === 'admin' ? getRequiredEnvVar('VX_MACHINE_JURISDICTION') diff --git a/libs/auth/scripts/read-java-card-details b/libs/auth/scripts/read-java-card-details new file mode 100755 index 000000000..9faba7b54 --- /dev/null +++ b/libs/auth/scripts/read-java-card-details @@ -0,0 +1,4 @@ +#!/usr/bin/env node + +require('esbuild-runner/register'); +require('./read_java_card_details').main(); diff --git a/libs/auth/scripts/read_java_card_details.ts b/libs/auth/scripts/read_java_card_details.ts new file mode 100644 index 000000000..adb836a11 --- /dev/null +++ b/libs/auth/scripts/read_java_card_details.ts @@ -0,0 +1,85 @@ +import { extractErrorMessage } from '@votingworks/basics'; + +import { + DEV_VX_CERT_AUTHORITY_CERT_PATH, + PROD_VX_CERT_AUTHORITY_CERT_PATH, +} from '../src'; +import { CardDetails } from '../src/card'; +import { JavaCard } from '../src/java_card'; +import { waitForReadyCardStatus } from './utils'; +import { verifyFirstCertWasSignedBySecondCert } from '../src/openssl'; + +const ENVS = ['development', 'production'] as const; + +type Env = typeof ENVS[number]; + +interface ExtendedCardDetails { + cardDetails?: CardDetails; + env: Env; +} + +const VX_CERT_AUTHORITY_CERT_PATHS: Record = { + development: DEV_VX_CERT_AUTHORITY_CERT_PATH, + production: PROD_VX_CERT_AUTHORITY_CERT_PATH, +}; + +async function readJavaCardDetails(): Promise { + for (const env of ENVS) { + const vxCertAuthorityCertPath = VX_CERT_AUTHORITY_CERT_PATHS[env]; + const card = new JavaCard({ vxCertAuthorityCertPath }); + const { cardDetails } = await waitForReadyCardStatus(card); + if (cardDetails) { + // Card has been run through initial Java Card configuration script and programmed for a user + return { cardDetails, env }; + } + + try { + const cardVxCert = await card.retrieveCertByIdentifier('cardVxCert'); + await verifyFirstCertWasSignedBySecondCert( + cardVxCert, + vxCertAuthorityCertPath + ); + // Card has been run through initial Java Card configuration script but not programmed for a + // user + return { env }; + } catch {} /* eslint-disable-line no-empty */ + + // Disconnect the card so that it can be reconnected to, through a new JavaCard instance + await card.disconnect(); + } + + // Card has not been run through initial Java Card configuration script + return undefined; +} + +function formatCardDetails(extendedCardDetails?: ExtendedCardDetails): string { + const { cardDetails, env } = extendedCardDetails ?? {}; + const { jurisdiction, role } = cardDetails?.user ?? {}; + const electionHash = + cardDetails?.user.role !== 'system_administrator' + ? cardDetails?.user.electionHash + : undefined; + return ` +Env: ${env ?? '-'} +Jurisdiction: ${jurisdiction ?? '-'} +User role: ${role ?? '-'} +Election hash: ${electionHash ?? '-'} +`; +} + +/** + * A script for reading Java Card details, namely environment, jurisdiction, user role, and + * election hash + */ +export async function main(): Promise { + let formattedCardDetails: string; + try { + const cardDetails = await readJavaCardDetails(); + formattedCardDetails = formatCardDetails(cardDetails); + } catch (error) { + console.error(`❌ ${extractErrorMessage(error)}`); + process.exit(1); + } + console.log(formattedCardDetails); + process.exit(0); +} diff --git a/libs/auth/scripts/utils.ts b/libs/auth/scripts/utils.ts index 99b13088b..2a235c46b 100644 --- a/libs/auth/scripts/utils.ts +++ b/libs/auth/scripts/utils.ts @@ -1,6 +1,6 @@ import { sleep } from '@votingworks/basics'; -import { Card } from '../src/card'; +import { Card, CardStatusReady } from '../src/card'; /** * Waits for a card to have a ready status @@ -8,7 +8,7 @@ import { Card } from '../src/card'; export async function waitForReadyCardStatus( card: Card, waitTimeSeconds = 3 -): Promise { +): Promise { let cardStatus = await card.getCardStatus(); let remainingWaitTimeSeconds = waitTimeSeconds; while (cardStatus.status !== 'ready' && remainingWaitTimeSeconds > 0) { @@ -19,4 +19,5 @@ export async function waitForReadyCardStatus( if (cardStatus.status !== 'ready') { throw new Error(`Card status not "ready" after ${waitTimeSeconds} seconds`); } + return cardStatus; } diff --git a/libs/auth/src/card.ts b/libs/auth/src/card.ts index 4a5fb09bd..f6aab026b 100644 --- a/libs/auth/src/card.ts +++ b/libs/auth/src/card.ts @@ -61,12 +61,18 @@ export function arePollWorkerCardDetails( return cardDetails.user.role === 'poll_worker'; } -interface CardStatusReady { +/** + * A sub-type of CardStatus + */ +export interface CardStatusReady { status: 'ready'; cardDetails?: CardDetails; } -interface CardStatusNotReady { +/** + * A sub-type of CardStatus + */ +export interface CardStatusNotReady { status: 'card_error' | 'no_card' | 'unknown_error'; } diff --git a/libs/auth/src/card_reader.test.ts b/libs/auth/src/card_reader.test.ts index f648c70e4..bf241d93a 100644 --- a/libs/auth/src/card_reader.test.ts +++ b/libs/auth/src/card_reader.test.ts @@ -106,6 +106,8 @@ const mockConnectProtocol = 0; const mockConnectSuccess: Connect = (_options, cb) => cb(undefined, mockConnectProtocol); const mockConnectError: Connect = (_options, cb) => cb(new Error('Whoa!')); +const mockDisconnectSuccess: Disconnect = (cb) => cb(undefined); +const mockDisconnectError: Disconnect = (cb) => cb(new Error('Whoa')); function newMockTransmitSuccess(response: Buffer): Transmit { return (_data, _responseLength, _protocol, cb) => cb(undefined, response); } @@ -172,6 +174,28 @@ test('CardReader status changes', () => { expect(onReaderStatusChange).toHaveBeenNthCalledWith(6, 'no_card_reader'); }); +test('CardReader card disconnect - success', async () => { + const cardReader = newCardReader('ready'); + mockOf(mockPcscLiteReader.disconnect).mockImplementationOnce( + mockDisconnectSuccess + ); + + await cardReader.disconnectCard(); + + expect(mockPcscLiteReader.disconnect).toHaveBeenCalledTimes(1); +}); + +test('CardReader card disconnect - error', async () => { + const cardReader = newCardReader('ready'); + mockOf(mockPcscLiteReader.disconnect).mockImplementationOnce( + mockDisconnectError + ); + + await expect(cardReader.disconnectCard()).rejects.toThrow(); + + expect(mockPcscLiteReader.disconnect).toHaveBeenCalledTimes(1); +}); + test('CardReader command transmission - reader not ready', async () => { const cardReader = newCardReader(); diff --git a/libs/auth/src/card_reader.ts b/libs/auth/src/card_reader.ts index 1f44fb8b3..bbd654760 100644 --- a/libs/auth/src/card_reader.ts +++ b/libs/auth/src/card_reader.ts @@ -20,6 +20,7 @@ export type PcscLite = ReturnType; interface ReaderReady { status: 'ready'; + disconnect: () => Promise; transmit: (data: Buffer) => Promise; } @@ -75,11 +76,15 @@ export class CardReader { this.updateReader({ status: 'card_error' }); return; } + const disconnectPromisified = promisify(reader.disconnect).bind( + reader + ); const transmitPromisified = promisify(reader.transmit).bind( reader ); this.updateReader({ status: 'ready', + disconnect: disconnectPromisified, transmit: (data: Buffer) => transmitPromisified(data, MAX_APDU_LENGTH, protocol), }); @@ -97,6 +102,15 @@ export class CardReader { }); } + /** + * Disconnects the currently connected card, if any + */ + async disconnectCard(): Promise { + if (this.reader.status === 'ready') { + await this.reader.disconnect(); + } + } + /** * Transmits command APDUs to a smart card. On success, returns response data. On error, throws. * Specifically throws a ResponseApduError when a response APDU with an error status word is diff --git a/libs/auth/src/config.ts b/libs/auth/src/config.ts index e010e0788..c083be736 100644 --- a/libs/auth/src/config.ts +++ b/libs/auth/src/config.ts @@ -4,6 +4,23 @@ import { isIntegrationTest, isVxDev } from '@votingworks/utils'; import { getRequiredEnvVar } from './env_vars'; import { FileKey, TpmKey } from './keys'; +/** + * The path to the dev root cert + */ +export const DEV_VX_CERT_AUTHORITY_CERT_PATH = path.join( + __dirname, + '../certs/dev/vx-cert-authority-cert.pem' +); + +/** + * The path to the prod root cert. We can commit this cert to the codebase because it's 1) + * universal and 2) public. + */ +export const PROD_VX_CERT_AUTHORITY_CERT_PATH = path.join( + __dirname, + '../certs/prod/vx-cert-authority-cert.pem' +); + function shouldUseProdCerts(): boolean { return ( process.env.NODE_ENV === 'production' && !isVxDev() && !isIntegrationTest() @@ -12,9 +29,8 @@ function shouldUseProdCerts(): boolean { function getVxCertAuthorityCertPath(): string { return shouldUseProdCerts() - ? // We can commit this prod cert to the codebase because it's 1) universal and 2) public - path.join(__dirname, '../certs/prod/vx-cert-authority-cert.pem') - : path.join(__dirname, '../certs/dev/vx-cert-authority-cert.pem'); + ? PROD_VX_CERT_AUTHORITY_CERT_PATH + : DEV_VX_CERT_AUTHORITY_CERT_PATH; } function getMachineCertPathAndPrivateKey(): { diff --git a/libs/auth/src/env.d.ts b/libs/auth/src/env.d.ts index 4d81f3e93..3414ff8e4 100644 --- a/libs/auth/src/env.d.ts +++ b/libs/auth/src/env.d.ts @@ -2,7 +2,6 @@ declare namespace NodeJS { export interface ProcessEnv { readonly NODE_ENV: 'development' | 'production' | 'test'; readonly VX_CONFIG_ROOT?: string; - readonly VX_MACHINE_JURISDICTION?: string; readonly VX_MACHINE_TYPE?: 'admin' | 'central-scan' | 'mark' | 'scan'; } } diff --git a/libs/auth/src/java_card.test.ts b/libs/auth/src/java_card.test.ts index 7daeaa27c..6020ce16a 100644 --- a/libs/auth/src/java_card.test.ts +++ b/libs/auth/src/java_card.test.ts @@ -1041,6 +1041,33 @@ test('Attempting to write too much data', async () => { ); }); +// +// Methods for scripts +// + +test('disconnect', async () => { + const javaCard = new JavaCard(config); + + mockCardReader.disconnectCard.expectCallWith().resolves(); + + await javaCard.disconnect(); +}); + +test('retrieveCertByIdentifier', async () => { + const javaCard = new JavaCard(config); + + mockCardAppletSelectionRequest(); + mockCardCertRetrievalRequest( + CARD_VX_CERT.OBJECT_ID, + getTestFilePath({ + fileType: 'card-vx-cert.der', + cardType: 'system-administrator', + }) + ); + + await javaCard.retrieveCertByIdentifier('cardVxCert'); +}); + test('createAndStoreCardVxCert', async () => { const javaCard = new JavaCard(config); diff --git a/libs/auth/src/java_card.ts b/libs/auth/src/java_card.ts index 8eedb447b..346d5948c 100644 --- a/libs/auth/src/java_card.ts +++ b/libs/auth/src/java_card.ts @@ -702,9 +702,39 @@ export class JavaCard implements Card { ); } + // + // Methods for scripts + // + + /** + * Disconnects the card so that it can be reconnected to, through a new JavaCard instance + */ + async disconnect(): Promise { + await this.cardReader.disconnectCard(); + } + + /** + * Retrieves the specified cert from the card. Used by the card detail reading script. + */ + async retrieveCertByIdentifier( + certIdentifier: + | 'cardVxCert' + | 'cardVxAdminCert' + | 'vxAdminCertAuthorityCert' + ): Promise { + await this.selectApplet(); + + const certConfigs = { + cardVxCert: CARD_VX_CERT, + cardVxAdminCert: CARD_VX_ADMIN_CERT, + vxAdminCertAuthorityCert: VX_ADMIN_CERT_AUTHORITY_CERT, + } as const; + return await this.retrieveCert(certConfigs[certIdentifier].OBJECT_ID); + } + /** - * Creates and stores the card's VotingWorks-issued cert. Only to be used by the initial card - * configuration script. + * Creates and stores the card's VotingWorks-issued cert. Used by the initial card configuration + * script. */ async createAndStoreCardVxCert( vxPrivateKey: FileKey | TpmKey diff --git a/libs/auth/test/utils.ts b/libs/auth/test/utils.ts index e47a4b2d7..a6b0038c3 100644 --- a/libs/auth/test/utils.ts +++ b/libs/auth/test/utils.ts @@ -29,10 +29,15 @@ export class MockCardReader implements Pick { constructor(input: ConstructorParameters[0]) { this.onReaderStatusChange = input.onReaderStatusChange; } + setReaderStatus(readerStatus: ReaderStatus): void { this.onReaderStatusChange(readerStatus); } + // eslint-disable-next-line vx/gts-no-public-class-fields + disconnectCard: MockFunction = + mockFunction('disconnectCard'); + // eslint-disable-next-line vx/gts-no-public-class-fields transmit: MockFunction = mockFunction('transmit');