Skip to content

Commit 9e0d3e8

Browse files
committed
Improve selected client certificate validation
Previously, we didn't check whether the pfx file actually contained either a cert or a private key, where in either case it would be unusable. We also missed some potential invalid format issues. Now we catch each of these, and display different errors for specific failure cases.
1 parent bbca1ea commit 9e0d3e8

File tree

2 files changed

+72
-36
lines changed

2 files changed

+72
-36
lines changed

src/components/settings/connection-settings-card.tsx

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { WarningIcon, Icon } from '../../icons';
88
import { trackEvent } from '../../metrics';
99

1010
import { uploadFile } from '../../util/ui';
11-
import { asError } from '../../util/error';
11+
import { UnreachableCheck, asError, unreachableCheck } from '../../util/error';
1212

1313
import { UpstreamProxyType, RulesStore } from '../../model/rules/rules-store';
1414
import { ParsedCertificate, ValidationResult } from '../../model/crypto';
@@ -316,6 +316,9 @@ class ClientCertificateConfig extends React.Component<{ rulesStore: RulesStore }
316316
@observable
317317
clientCertState: undefined | 'encrypted' | 'processing' | 'error' | 'decrypted';
318318

319+
@observable
320+
clientCertError: string | undefined;
321+
319322
@action.bound
320323
onClientCertSelected(event: React.ChangeEvent<HTMLInputElement>) {
321324
const input = event.target;
@@ -326,6 +329,7 @@ class ClientCertificateConfig extends React.Component<{ rulesStore: RulesStore }
326329
fileReader.readAsArrayBuffer(file);
327330

328331
this.clientCertState = 'processing';
332+
this.clientCertError = undefined;
329333

330334
const thisConfig = this; // fileReader events set 'this'
331335
fileReader.addEventListener('load', flow(function * () {
@@ -338,29 +342,18 @@ class ClientCertificateConfig extends React.Component<{ rulesStore: RulesStore }
338342

339343
result = yield validatePKCS(thisConfig.clientCertData.pfx, undefined);
340344

341-
if (result === 'valid') {
342-
thisConfig.clientCertState = 'decrypted';
343-
thisConfig.clientCertData.passphrase = undefined;
344-
return;
345-
}
346-
347-
if (result === 'invalid-format') {
348-
thisConfig.clientCertState = 'error';
349-
return;
350-
}
345+
if (result === 'invalid-passphrase') {
346+
// If it fails, try again with an empty key, since that is sometimes used for 'no passphrase'
347+
result = yield validatePKCS(thisConfig.clientCertData.pfx, '');
351348

352-
// If it fails, try again with an empty key, since that is sometimes used for 'no passphrase'
353-
result = yield validatePKCS(thisConfig.clientCertData.pfx, '');
349+
if (result === 'valid') {
350+
thisConfig.clientCertData.passphrase = '';
351+
}
354352

355-
if (result === 'valid') {
356-
thisConfig.clientCertState = 'decrypted';
357-
thisConfig.clientCertData.passphrase = '';
358-
return;
353+
thisConfig.handleClientCertValidationResult(result);
354+
} else {
355+
thisConfig.handleClientCertValidationResult(result);
359356
}
360-
361-
// If that still hasn't worked, it's encrypted. Mark is as such, and wait for the user
362-
// to either cancel, or enter the correct passphrase.
363-
thisConfig.clientCertState = 'encrypted';
364357
}));
365358

366359
fileReader.addEventListener('error', () => {
@@ -371,15 +364,32 @@ class ClientCertificateConfig extends React.Component<{ rulesStore: RulesStore }
371364
readonly decryptClientCertData = flow(function * (this: ClientCertificateConfig) {
372365
const { pfx, passphrase } = this.clientCertData!;
373366

374-
let result: ValidationResult;
375-
376367
this.clientCertState = 'processing';
377-
result = yield validatePKCS(pfx, passphrase);
378-
this.clientCertState = result === 'valid'
379-
? 'decrypted'
380-
: 'encrypted';
368+
this.clientCertError = undefined;
369+
370+
const result = yield validatePKCS(pfx, passphrase);
371+
this.handleClientCertValidationResult(result);
381372
});
382373

374+
handleClientCertValidationResult(result: ValidationResult) {
375+
this.clientCertError = undefined;
376+
377+
if (result === 'valid') {
378+
this.clientCertState = 'decrypted';
379+
} else if (result === 'invalid-passphrase') {
380+
this.clientCertState = 'encrypted';
381+
} else if (result === 'invalid-format') {
382+
this.clientCertState = 'error';
383+
this.clientCertError = 'Parsing failed';
384+
} else if (result === 'missing-key') {
385+
this.clientCertState = 'error';
386+
this.clientCertError = 'No private key found';
387+
} else if (result === 'missing-cert') {
388+
this.clientCertState = 'error';
389+
this.clientCertError = 'No certificate found';
390+
} else unreachableCheck(result);
391+
}
392+
383393
@action.bound
384394
dropClientCertData() {
385395
this.clientCertData = undefined;
@@ -456,12 +466,14 @@ class ClientCertificateConfig extends React.Component<{ rulesStore: RulesStore }
456466
<Icon icon={['fas', 'undo']} title='Deselect this certificate' />
457467
</SettingsButton>
458468
</DecryptionInput>
459-
: <DecryptionInput>
460-
<p><WarningIcon /> Invalid certificate</p>
461-
<SettingsButton onClick={this.dropClientCertData}>
462-
<Icon icon={['fas', 'undo']} title='Deselect this certificate' />
463-
</SettingsButton>
464-
</DecryptionInput>
469+
: this.clientCertState === 'error'
470+
? <DecryptionInput>
471+
<p><WarningIcon /> {this.clientCertError || 'Invalid certificate'}</p>
472+
<SettingsButton onClick={this.dropClientCertData}>
473+
<Icon icon={['fas', 'undo']} title='Deselect this certificate' />
474+
</SettingsButton>
475+
</DecryptionInput>
476+
: unreachableCheck(this.clientCertState)
465477
}
466478
<SettingsButton
467479
disabled={

src/model/crypto.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,17 @@ require('node-forge/lib/asn1');
55
require('node-forge/lib/pki');
66
require('node-forge/lib/pkcs12');
77
import * as forge from 'node-forge/lib/forge';
8+
import { isErrorLike } from '../util/error';
89

9-
export type ValidationResult = 'valid' | 'invalid-format' | 'invalid-passphrase';
10+
export type ValidationResult =
11+
| 'valid'
12+
| 'invalid-format'
13+
| 'invalid-passphrase'
14+
| 'missing-cert'
15+
| 'missing-key';
16+
17+
const pkcs12ContainsBag = (pfx: forge.pkcs12.Pkcs12Pfx, bagType: string) =>
18+
pfx.getBags({ bagType })[bagType]?.length;
1019

1120
export function validatePKCS12(
1221
data: ArrayBuffer,
@@ -23,11 +32,26 @@ export function validatePKCS12(
2332
try {
2433
// Decrypt the PKCS12 with the passphrase - we assume if this works, it's good.
2534
// Could be false (different key on cert within, who knows what else), but typically not.
26-
forge.pkcs12.pkcs12FromAsn1(asn1Data, passphrase);
35+
const pfx = forge.pkcs12.pkcs12FromAsn1(asn1Data, passphrase);
36+
37+
if (!pkcs12ContainsBag(pfx, forge.pki.oids.pkcs8ShroudedKeyBag)) {
38+
return 'missing-key';
39+
// There is also oids.keyBag, but AFAICT that's not what we need here generally.
40+
}
41+
42+
if (!pkcs12ContainsBag(pfx, forge.pki.oids.certBag)) {
43+
return 'missing-cert';
44+
}
45+
2746
return 'valid';
2847
} catch (e) {
2948
console.log(e);
30-
return 'invalid-passphrase';
49+
if (isErrorLike(e) && e.message?.includes('Invalid password')) {
50+
return 'invalid-passphrase';
51+
} else {
52+
// E.g. "ASN.1 object is not an PKCS#12 PFX."
53+
return 'invalid-format';
54+
}
3155
}
3256
}
3357

0 commit comments

Comments
 (0)