Skip to content

Commit

Permalink
Add script to read Java Card details (#3775)
Browse files Browse the repository at this point in the history
* Remove no longer needed type cast and assertion

* Have waitForReadyCardStatus util return ready cardStatus

* Export Vx CA cert paths

* Add methods to JavaCard class for upcoming read-java-card-details script

* Add read-java-card-details script

* Add tests to maintain 100% code coverage

* Update libs/auth README

* Clean up names for clarity

* Promisify reader.disconnect and properly await its completion
  • Loading branch information
arsalansufi authored Aug 2, 2023
1 parent d1c7557 commit 6fe593a
Show file tree
Hide file tree
Showing 13 changed files with 237 additions and 23 deletions.
16 changes: 13 additions & 3 deletions libs/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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
```

Expand All @@ -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
Expand All @@ -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
```

Expand Down
Original file line number Diff line number Diff line change
@@ -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')
Expand Down
4 changes: 4 additions & 0 deletions libs/auth/scripts/read-java-card-details
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env node

require('esbuild-runner/register');
require('./read_java_card_details').main();
85 changes: 85 additions & 0 deletions libs/auth/scripts/read_java_card_details.ts
Original file line number Diff line number Diff line change
@@ -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<Env, string> = {
development: DEV_VX_CERT_AUTHORITY_CERT_PATH,
production: PROD_VX_CERT_AUTHORITY_CERT_PATH,
};

async function readJavaCardDetails(): Promise<ExtendedCardDetails | undefined> {
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<void> {
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);
}
5 changes: 3 additions & 2 deletions libs/auth/scripts/utils.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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
*/
export async function waitForReadyCardStatus(
card: Card,
waitTimeSeconds = 3
): Promise<void> {
): Promise<CardStatusReady> {
let cardStatus = await card.getCardStatus();
let remainingWaitTimeSeconds = waitTimeSeconds;
while (cardStatus.status !== 'ready' && remainingWaitTimeSeconds > 0) {
Expand All @@ -19,4 +19,5 @@ export async function waitForReadyCardStatus(
if (cardStatus.status !== 'ready') {
throw new Error(`Card status not "ready" after ${waitTimeSeconds} seconds`);
}
return cardStatus;
}
10 changes: 8 additions & 2 deletions libs/auth/src/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
24 changes: 24 additions & 0 deletions libs/auth/src/card_reader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();

Expand Down
14 changes: 14 additions & 0 deletions libs/auth/src/card_reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type PcscLite = ReturnType<typeof newPcscLite>;

interface ReaderReady {
status: 'ready';
disconnect: () => Promise<void>;
transmit: (data: Buffer) => Promise<Buffer>;
}

Expand Down Expand Up @@ -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),
});
Expand All @@ -97,6 +102,15 @@ export class CardReader {
});
}

/**
* Disconnects the currently connected card, if any
*/
async disconnectCard(): Promise<void> {
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
Expand Down
22 changes: 19 additions & 3 deletions libs/auth/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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(): {
Expand Down
1 change: 0 additions & 1 deletion libs/auth/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
}
27 changes: 27 additions & 0 deletions libs/auth/src/java_card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
34 changes: 32 additions & 2 deletions libs/auth/src/java_card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<Buffer> {
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
Expand Down
Loading

0 comments on commit 6fe593a

Please sign in to comment.