summaryrefslogtreecommitdiff
path: root/src/server/api/2fa.ts
blob: 77f0f8cd047450c4016e12c66b43240e21863e6a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
import * as crypto from 'crypto';
import config from '@/config';
import * as jsrsasign from 'jsrsasign';

const ECC_PRELUDE = Buffer.from([0x04]);
const NULL_BYTE = Buffer.from([0]);
const PEM_PRELUDE = Buffer.from(
	'3059301306072a8648ce3d020106082a8648ce3d030107034200',
	'hex'
);

// Android Safetynet attestations are signed with this cert:
const GSR2 = `-----BEGIN CERTIFICATE-----
MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G
A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp
Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1
MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG
A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI
hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL
v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8
eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq
tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd
C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa
zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB
mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH
V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n
bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG
3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs
J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO
291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS
ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd
AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7
TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg==
-----END CERTIFICATE-----\n`;

function base64URLDecode(source: string) {
	return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64');
}

function getCertSubject(certificate: string) {
	const subjectCert = new jsrsasign.X509();
	subjectCert.readCertPEM(certificate);

	const subjectString = subjectCert.getSubjectString();
	const subjectFields = subjectString.slice(1).split('/');

	const fields = {} as Record<string, string>;
	for (const field of subjectFields) {
		const eqIndex = field.indexOf('=');
		fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1);
	}

	return fields;
}

function verifyCertificateChain(certificates: string[]) {
	let valid = true;

	for (let i = 0; i < certificates.length; i++) {
		const Cert = certificates[i];
		const certificate = new jsrsasign.X509();
		certificate.readCertPEM(Cert);

		const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1];

		const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex!, 0, [0]);
		const algorithm = certificate.getSignatureAlgorithmField();
		const signatureHex = certificate.getSignatureValueHex();

		// Verify against CA
		const Signature = new jsrsasign.KJUR.crypto.Signature({alg: algorithm});
		Signature.init(CACert);
		Signature.updateHex(certStruct);
		valid = valid && !!Signature.verify(signatureHex); // true if CA signed the certificate
	}

	return valid;
}

function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') {
	if (pemBuffer.length === 65 && pemBuffer[0] === 0x04) {
		pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91);
		type = 'PUBLIC KEY';
	}
	const cert = pemBuffer.toString('base64');

	const keyParts = [];
	const max = Math.ceil(cert.length / 64);
	let start = 0;
	for (let i = 0; i < max; i++) {
		keyParts.push(cert.substring(start, start + 64));
		start += 64;
	}

	return (
		`-----BEGIN ${type}-----\n` +
		keyParts.join('\n') +
		`\n-----END ${type}-----\n`
	);
}

export function hash(data: Buffer) {
	return crypto
		.createHash('sha256')
		.update(data)
		.digest();
}

export function verifyLogin({
	publicKey,
	authenticatorData,
	clientDataJSON,
	clientData,
	signature,
	challenge
}: {
	publicKey: Buffer,
	authenticatorData: Buffer,
	clientDataJSON: Buffer,
	clientData: any,
	signature: Buffer,
	challenge: string
}) {
	if (clientData.type != 'webauthn.get') {
		throw new Error('type is not webauthn.get');
	}

	if (hash(clientData.challenge).toString('hex') != challenge) {
		throw new Error('challenge mismatch');
	}
	if (clientData.origin != config.scheme + '://' + config.host) {
		throw new Error('origin mismatch');
	}

	const verificationData = Buffer.concat(
		[authenticatorData, hash(clientDataJSON)],
		32 + authenticatorData.length
	);

	return crypto
		.createVerify('SHA256')
		.update(verificationData)
		.verify(PEMString(publicKey), signature);
}

export const procedures = {
	none: {
		verify({publicKey}: {publicKey: Map<number, Buffer>}) {
			const negTwo = publicKey.get(-2);

			if (!negTwo || negTwo.length != 32) {
				throw new Error('invalid or no -2 key given');
			}
			const negThree = publicKey.get(-3);
			if (!negThree || negThree.length != 32) {
				throw new Error('invalid or no -3 key given');
			}

			const publicKeyU2F = Buffer.concat(
				[ECC_PRELUDE, negTwo, negThree],
				1 + 32 + 32
			);

			return {
				publicKey: publicKeyU2F,
				valid: true
			};
		}
	},
	'android-key': {
		verify({
			attStmt,
			authenticatorData,
			clientDataHash,
			publicKey,
			rpIdHash,
			credentialId
		}: {
			attStmt: any,
			authenticatorData: Buffer,
			clientDataHash: Buffer,
			publicKey: Map<number, any>;
			rpIdHash: Buffer,
			credentialId: Buffer,
		}) {
			if (attStmt.alg != -7) {
				throw new Error('alg mismatch');
			}

			const verificationData = Buffer.concat([
				authenticatorData,
				clientDataHash
			]);

			const attCert: Buffer = attStmt.x5c[0];

			const negTwo = publicKey.get(-2);

			if (!negTwo || negTwo.length != 32) {
				throw new Error('invalid or no -2 key given');
			}
			const negThree = publicKey.get(-3);
			if (!negThree || negThree.length != 32) {
				throw new Error('invalid or no -3 key given');
			}

			const publicKeyData = Buffer.concat(
				[ECC_PRELUDE, negTwo, negThree],
				1 + 32 + 32
			);

			if (!attCert.equals(publicKeyData)) {
				throw new Error('public key mismatch');
			}

			const isValid = crypto
				.createVerify('SHA256')
				.update(verificationData)
				.verify(PEMString(attCert), attStmt.sig);

			// TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON)

			return {
				valid: isValid,
				publicKey: publicKeyData
			};
		}
	},
	// what a stupid attestation
	'android-safetynet': {
		verify({
			attStmt,
			authenticatorData,
			clientDataHash,
			publicKey,
			rpIdHash,
			credentialId
		}: {
			attStmt: any,
			authenticatorData: Buffer,
			clientDataHash: Buffer,
			publicKey: Map<number, any>;
			rpIdHash: Buffer,
			credentialId: Buffer,
		}) {
			const verificationData = hash(
				Buffer.concat([authenticatorData, clientDataHash])
			);

			const jwsParts = attStmt.response.toString('utf-8').split('.');

			const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8'));
			const response = JSON.parse(
				base64URLDecode(jwsParts[1]).toString('utf-8')
			);
			const signature = jwsParts[2];

			if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) {
				throw new Error('invalid nonce');
			}

			const certificateChain = header.x5c
				.map((key: any) => PEMString(key))
				.concat([GSR2]);

			if (getCertSubject(certificateChain[0]).CN != 'attest.android.com') {
				throw new Error('invalid common name');
			}

			if (!verifyCertificateChain(certificateChain)) {
				throw new Error('Invalid certificate chain!');
			}

			const signatureBase = Buffer.from(
				jwsParts[0] + '.' + jwsParts[1],
				'utf-8'
			);

			const valid = crypto
				.createVerify('sha256')
				.update(signatureBase)
				.verify(certificateChain[0], base64URLDecode(signature));

			const negTwo = publicKey.get(-2);

			if (!negTwo || negTwo.length != 32) {
				throw new Error('invalid or no -2 key given');
			}
			const negThree = publicKey.get(-3);
			if (!negThree || negThree.length != 32) {
				throw new Error('invalid or no -3 key given');
			}

			const publicKeyData = Buffer.concat(
				[ECC_PRELUDE, negTwo, negThree],
				1 + 32 + 32
			);
			return {
				valid,
				publicKey: publicKeyData
			};
		}
	},
	packed: {
		verify({
			attStmt,
			authenticatorData,
			clientDataHash,
			publicKey,
			rpIdHash,
			credentialId
		}: {
			attStmt: any,
			authenticatorData: Buffer,
			clientDataHash: Buffer,
			publicKey: Map<number, any>;
			rpIdHash: Buffer,
			credentialId: Buffer,
		}) {
			const verificationData = Buffer.concat([
				authenticatorData,
				clientDataHash
			]);

			if (attStmt.x5c) {
				const attCert = attStmt.x5c[0];

				const validSignature = crypto
					.createVerify('SHA256')
					.update(verificationData)
					.verify(PEMString(attCert), attStmt.sig);

				const negTwo = publicKey.get(-2);

				if (!negTwo || negTwo.length != 32) {
					throw new Error('invalid or no -2 key given');
				}
				const negThree = publicKey.get(-3);
				if (!negThree || negThree.length != 32) {
					throw new Error('invalid or no -3 key given');
				}

				const publicKeyData = Buffer.concat(
					[ECC_PRELUDE, negTwo, negThree],
					1 + 32 + 32
				);

				return {
					valid: validSignature,
					publicKey: publicKeyData
				};
			} else if (attStmt.ecdaaKeyId) {
				// https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation
				throw new Error('ECDAA-Verify is not supported');
			} else {
				if (attStmt.alg != -7) throw new Error('alg mismatch');

				throw new Error('self attestation is not supported');
			}
		}
	},

	'fido-u2f': {
		verify({
			attStmt,
			authenticatorData,
			clientDataHash,
			publicKey,
			rpIdHash,
			credentialId
		}: {
			attStmt: any,
			authenticatorData: Buffer,
			clientDataHash: Buffer,
			publicKey: Map<number, any>,
			rpIdHash: Buffer,
			credentialId: Buffer
		}) {
			const x5c: Buffer[] = attStmt.x5c;
			if (x5c.length != 1) {
				throw new Error('x5c length does not match expectation');
			}

			const attCert = x5c[0];

			// TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve

			const negTwo: Buffer = publicKey.get(-2);

			if (!negTwo || negTwo.length != 32) {
				throw new Error('invalid or no -2 key given');
			}
			const negThree: Buffer = publicKey.get(-3);
			if (!negThree || negThree.length != 32) {
				throw new Error('invalid or no -3 key given');
			}

			const publicKeyU2F = Buffer.concat(
				[ECC_PRELUDE, negTwo, negThree],
				1 + 32 + 32
			);

			const verificationData = Buffer.concat([
				NULL_BYTE,
				rpIdHash,
				clientDataHash,
				credentialId,
				publicKeyU2F
			]);

			const validSignature = crypto
				.createVerify('SHA256')
				.update(verificationData)
				.verify(PEMString(attCert), attStmt.sig);

			return {
				valid: validSignature,
				publicKey: publicKeyU2F
			};
		}
	}
};