Implement Webauthn 🎉 (#5088)
* Implement Webauthn 🎉 * Share hexifyAB * Move hr inside template and add AttestationChallenges janitor daemon * Apply suggestions from code review Co-Authored-By: Acid Chicken (硫酸鶏) <root@acid-chicken.com> * Add newline at the end of file * Fix stray newline in promise chain * Ignore var in try{}catch(){} block Co-Authored-By: Acid Chicken (硫酸鶏) <root@acid-chicken.com> * Add missing comma * Add missing semicolon * Support more attestation formats * add support for more key types and linter pass * Refactor * Refactor * credentialId --> id * Fix * Improve readability * Add indexes * fixes for credentialId->id * Avoid changing store state * Fix syntax error and code style * Remove unused import * Refactor of getkey API * Create 1561706992953-webauthn.ts * Update ja-JP.yml * Add type annotations * Fix code style * Specify depedency version * Fix code style * Fix janitor daemon and login requesting 2FA regardless of status
This commit is contained in:
		
							
								
								
									
										422
									
								
								src/server/api/2fa.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										422
									
								
								src/server/api/2fa.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,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.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 => 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 | ||||
| 			}; | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
							
								
								
									
										67
									
								
								src/server/api/endpoints/i/2fa/getkeys.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/server/api/endpoints/i/2fa/getkeys.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| import $ from 'cafy'; | ||||
| import * as bcrypt from 'bcryptjs'; | ||||
| import * as crypto from 'crypto'; | ||||
| import define from '../../../define'; | ||||
| import { UserProfiles, UserSecurityKeys, AttestationChallenges } from '../../../../../models'; | ||||
| import { ensure } from '../../../../../prelude/ensure'; | ||||
| import { promisify } from 'util'; | ||||
| import { hash } from '../../../2fa'; | ||||
| import { genId } from '../../../../../misc/gen-id'; | ||||
|  | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
|  | ||||
| 	secure: true, | ||||
|  | ||||
| 	params: { | ||||
| 		password: { | ||||
| 			validator: $.str | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| const randomBytes = promisify(crypto.randomBytes); | ||||
|  | ||||
| export default define(meta, async (ps, user) => { | ||||
| 	const profile = await UserProfiles.findOne(user.id).then(ensure); | ||||
|  | ||||
| 	// Compare password | ||||
| 	const same = await bcrypt.compare(ps.password, profile.password!); | ||||
|  | ||||
| 	if (!same) { | ||||
| 		throw new Error('incorrect password'); | ||||
| 	} | ||||
|  | ||||
| 	const keys = await UserSecurityKeys.find({ | ||||
| 		userId: user.id | ||||
| 	}); | ||||
|  | ||||
| 	if (keys.length === 0) { | ||||
| 		throw new Error('no keys found'); | ||||
| 	} | ||||
|  | ||||
| 	// 32 byte challenge | ||||
| 	const entropy = await randomBytes(32); | ||||
| 	const challenge = entropy.toString('base64') | ||||
| 		.replace(/=/g, '') | ||||
| 		.replace(/\+/g, '-') | ||||
| 		.replace(/\//g, '_'); | ||||
|  | ||||
| 	const challengeId = genId(); | ||||
|  | ||||
| 	await AttestationChallenges.save({ | ||||
| 		userId: user.id, | ||||
| 		id: challengeId, | ||||
| 		challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'), | ||||
| 		createdAt: new Date(), | ||||
| 		registrationChallenge: false | ||||
| 	}); | ||||
|  | ||||
| 	return { | ||||
| 		challenge, | ||||
| 		challengeId, | ||||
| 		securityKeys: keys.map(key => ({ | ||||
| 			id: key.id | ||||
| 		})) | ||||
| 	}; | ||||
| }); | ||||
							
								
								
									
										151
									
								
								src/server/api/endpoints/i/2fa/key-done.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								src/server/api/endpoints/i/2fa/key-done.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,151 @@ | ||||
| import $ from 'cafy'; | ||||
| import * as bcrypt from 'bcryptjs'; | ||||
| import { promisify } from 'util'; | ||||
| import * as cbor from 'cbor'; | ||||
| import define from '../../../define'; | ||||
| import { | ||||
| 	UserProfiles, | ||||
| 	UserSecurityKeys, | ||||
| 	AttestationChallenges, | ||||
| 	Users | ||||
| } from '../../../../../models'; | ||||
| import { ensure } from '../../../../../prelude/ensure'; | ||||
| import config from '../../../../../config'; | ||||
| import { procedures, hash } from '../../../2fa'; | ||||
| import { publishMainStream } from '../../../../../services/stream'; | ||||
|  | ||||
| const cborDecodeFirst = promisify(cbor.decodeFirst); | ||||
|  | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
|  | ||||
| 	secure: true, | ||||
|  | ||||
| 	params: { | ||||
| 		clientDataJSON: { | ||||
| 			validator: $.str | ||||
| 		}, | ||||
| 		attestationObject: { | ||||
| 			validator: $.str | ||||
| 		}, | ||||
| 		password: { | ||||
| 			validator: $.str | ||||
| 		}, | ||||
| 		challengeId: { | ||||
| 			validator: $.str | ||||
| 		}, | ||||
| 		name: { | ||||
| 			validator: $.str | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| const rpIdHashReal = hash(Buffer.from(config.hostname, 'utf-8')); | ||||
|  | ||||
| export default define(meta, async (ps, user) => { | ||||
| 	const profile = await UserProfiles.findOne(user.id).then(ensure); | ||||
|  | ||||
| 	// Compare password | ||||
| 	const same = await bcrypt.compare(ps.password, profile.password!); | ||||
|  | ||||
| 	if (!same) { | ||||
| 		throw new Error('incorrect password'); | ||||
| 	} | ||||
|  | ||||
| 	if (!profile.twoFactorEnabled) { | ||||
| 		throw new Error('2fa not enabled'); | ||||
| 	} | ||||
|  | ||||
| 	const clientData = JSON.parse(ps.clientDataJSON); | ||||
|  | ||||
| 	if (clientData.type != 'webauthn.create') { | ||||
| 		throw new Error('not a creation attestation'); | ||||
| 	} | ||||
| 	if (clientData.origin != config.scheme + '://' + config.host) { | ||||
| 		throw new Error('origin mismatch'); | ||||
| 	} | ||||
|  | ||||
| 	const clientDataJSONHash = hash(Buffer.from(ps.clientDataJSON, 'utf-8')); | ||||
|  | ||||
| 	const attestation = await cborDecodeFirst(ps.attestationObject); | ||||
|  | ||||
| 	const rpIdHash = attestation.authData.slice(0, 32); | ||||
| 	if (!rpIdHashReal.equals(rpIdHash)) { | ||||
| 		throw new Error('rpIdHash mismatch'); | ||||
| 	} | ||||
|  | ||||
| 	const flags = attestation.authData[32]; | ||||
|  | ||||
| 	// tslint:disable-next-line:no-bitwise | ||||
| 	if (!(flags & 1)) { | ||||
| 		throw new Error('user not present'); | ||||
| 	} | ||||
|  | ||||
| 	const authData = Buffer.from(attestation.authData); | ||||
| 	const credentialIdLength = authData.readUInt16BE(53); | ||||
| 	const credentialId = authData.slice(55, 55 + credentialIdLength); | ||||
| 	const publicKeyData = authData.slice(55 + credentialIdLength); | ||||
| 	const publicKey: Map<number, any> = await cborDecodeFirst(publicKeyData); | ||||
| 	if (publicKey.get(3) != -7) { | ||||
| 		throw new Error('alg mismatch'); | ||||
| 	} | ||||
|  | ||||
| 	if (!procedures[attestation.fmt]) { | ||||
| 		throw new Error('unsupported fmt'); | ||||
| 	} | ||||
|  | ||||
| 	const verificationData = procedures[attestation.fmt].verify({ | ||||
| 		attStmt: attestation.attStmt, | ||||
| 		authenticatorData: authData, | ||||
| 		clientDataHash: clientDataJSONHash, | ||||
| 		credentialId, | ||||
| 		publicKey, | ||||
| 		rpIdHash | ||||
| 	}); | ||||
| 	if (!verificationData.valid) throw new Error('signature invalid'); | ||||
|  | ||||
| 	const attestationChallenge = await AttestationChallenges.findOne({ | ||||
| 		userId: user.id, | ||||
| 		id: ps.challengeId, | ||||
| 		registrationChallenge: true, | ||||
| 		challenge: hash(clientData.challenge).toString('hex') | ||||
| 	}); | ||||
|  | ||||
| 	if (!attestationChallenge) { | ||||
| 		throw new Error('non-existent challenge'); | ||||
| 	} | ||||
|  | ||||
| 	await AttestationChallenges.delete({ | ||||
| 		userId: user.id, | ||||
| 		id: ps.challengeId | ||||
| 	}); | ||||
|  | ||||
| 	// Expired challenge (> 5min old) | ||||
| 	if ( | ||||
| 		new Date().getTime() - attestationChallenge.createdAt.getTime() >= | ||||
| 		5 * 60 * 1000 | ||||
| 	) { | ||||
| 		throw new Error('expired challenge'); | ||||
| 	} | ||||
|  | ||||
| 	const credentialIdString = credentialId.toString('hex'); | ||||
|  | ||||
| 	await UserSecurityKeys.save({ | ||||
| 		userId: user.id, | ||||
| 		id: credentialIdString, | ||||
| 		lastUsed: new Date(), | ||||
| 		name: ps.name, | ||||
| 		publicKey: verificationData.publicKey.toString('hex') | ||||
| 	}); | ||||
|  | ||||
| 	// Publish meUpdated event | ||||
| 	publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, { | ||||
| 		detail: true, | ||||
| 		includeSecrets: true | ||||
| 	})); | ||||
|  | ||||
| 	return { | ||||
| 		id: credentialIdString, | ||||
| 		name: ps.name | ||||
| 	}; | ||||
| }); | ||||
							
								
								
									
										60
									
								
								src/server/api/endpoints/i/2fa/register-key.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/server/api/endpoints/i/2fa/register-key.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| import $ from 'cafy'; | ||||
| import * as bcrypt from 'bcryptjs'; | ||||
| import define from '../../../define'; | ||||
| import { UserProfiles, AttestationChallenges } from '../../../../../models'; | ||||
| import { ensure } from '../../../../../prelude/ensure'; | ||||
| import { promisify } from 'util'; | ||||
| import * as crypto from 'crypto'; | ||||
| import { genId } from '../../../../../misc/gen-id'; | ||||
| import { hash } from '../../../2fa'; | ||||
|  | ||||
| const randomBytes = promisify(crypto.randomBytes); | ||||
|  | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
|  | ||||
| 	secure: true, | ||||
|  | ||||
| 	params: { | ||||
| 		password: { | ||||
| 			validator: $.str | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| export default define(meta, async (ps, user) => { | ||||
| 	const profile = await UserProfiles.findOne(user.id).then(ensure); | ||||
|  | ||||
| 	// Compare password | ||||
| 	const same = await bcrypt.compare(ps.password, profile.password!); | ||||
|  | ||||
| 	if (!same) { | ||||
| 		throw new Error('incorrect password'); | ||||
| 	} | ||||
|  | ||||
| 	if (!profile.twoFactorEnabled) { | ||||
| 		throw new Error('2fa not enabled'); | ||||
| 	} | ||||
|  | ||||
| 	// 32 byte challenge | ||||
| 	const entropy = await randomBytes(32); | ||||
| 	const challenge = entropy.toString('base64') | ||||
| 		.replace(/=/g, '') | ||||
| 		.replace(/\+/g, '-') | ||||
| 		.replace(/\//g, '_'); | ||||
|  | ||||
| 	const challengeId = genId(); | ||||
|  | ||||
| 	await AttestationChallenges.save({ | ||||
| 		userId: user.id, | ||||
| 		id: challengeId, | ||||
| 		challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'), | ||||
| 		createdAt: new Date(), | ||||
| 		registrationChallenge: true | ||||
| 	}); | ||||
|  | ||||
| 	return { | ||||
| 		challengeId, | ||||
| 		challenge | ||||
| 	}; | ||||
| }); | ||||
							
								
								
									
										46
									
								
								src/server/api/endpoints/i/2fa/remove-key.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/server/api/endpoints/i/2fa/remove-key.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| import $ from 'cafy'; | ||||
| import * as bcrypt from 'bcryptjs'; | ||||
| import define from '../../../define'; | ||||
| import { UserProfiles, UserSecurityKeys, Users } from '../../../../../models'; | ||||
| import { ensure } from '../../../../../prelude/ensure'; | ||||
| import { publishMainStream } from '../../../../../services/stream'; | ||||
|  | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
|  | ||||
| 	secure: true, | ||||
|  | ||||
| 	params: { | ||||
| 		password: { | ||||
| 			validator: $.str | ||||
| 		}, | ||||
| 		credentialId: { | ||||
| 			validator: $.str | ||||
| 		}, | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| export default define(meta, async (ps, user) => { | ||||
| 	const profile = await UserProfiles.findOne(user.id).then(ensure); | ||||
|  | ||||
| 	// Compare password | ||||
| 	const same = await bcrypt.compare(ps.password, profile.password!); | ||||
|  | ||||
| 	if (!same) { | ||||
| 		throw new Error('incorrect password'); | ||||
| 	} | ||||
|  | ||||
| 	// Make sure we only delete the user's own creds | ||||
| 	await UserSecurityKeys.delete({ | ||||
| 		userId: user.id, | ||||
| 		id: ps.credentialId | ||||
| 	}); | ||||
|  | ||||
| 	// Publish meUpdated event | ||||
| 	publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, { | ||||
| 		detail: true, | ||||
| 		includeSecrets: true | ||||
| 	})); | ||||
|  | ||||
| 	return {}; | ||||
| }); | ||||
| @@ -4,10 +4,11 @@ import * as speakeasy from 'speakeasy'; | ||||
| import { publishMainStream } from '../../../services/stream'; | ||||
| import signin from '../common/signin'; | ||||
| import config from '../../../config'; | ||||
| import { Users, Signins, UserProfiles } from '../../../models'; | ||||
| import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges } from '../../../models'; | ||||
| import { ILocalUser } from '../../../models/entities/user'; | ||||
| import { genId } from '../../../misc/gen-id'; | ||||
| import { ensure } from '../../../prelude/ensure'; | ||||
| import { verifyLogin, hash } from '../2fa'; | ||||
|  | ||||
| export default async (ctx: Koa.BaseContext) => { | ||||
| 	ctx.set('Access-Control-Allow-Origin', config.url); | ||||
| @@ -51,40 +52,116 @@ export default async (ctx: Koa.BaseContext) => { | ||||
| 	// Compare password | ||||
| 	const same = await bcrypt.compare(password, profile.password!); | ||||
|  | ||||
| 	if (same) { | ||||
| 		if (profile.twoFactorEnabled) { | ||||
| 			const verified = (speakeasy as any).totp.verify({ | ||||
| 				secret: profile.twoFactorSecret, | ||||
| 				encoding: 'base32', | ||||
| 				token: token | ||||
| 			}); | ||||
|  | ||||
| 			if (verified) { | ||||
| 				signin(ctx, user); | ||||
| 			} else { | ||||
| 				ctx.throw(403, { | ||||
| 					error: 'invalid token' | ||||
| 				}); | ||||
| 			} | ||||
| 		} else { | ||||
| 			signin(ctx, user); | ||||
| 		} | ||||
| 	} else { | ||||
| 		ctx.throw(403, { | ||||
| 			error: 'incorrect password' | ||||
| 	async function fail(status?: number, failure?: {error: string}) { | ||||
| 		// Append signin history | ||||
| 		const record = await Signins.save({ | ||||
| 			id: genId(), | ||||
| 			createdAt: new Date(), | ||||
| 			userId: user.id, | ||||
| 			ip: ctx.ip, | ||||
| 			headers: ctx.headers, | ||||
| 			success: !!(status || failure) | ||||
| 		}); | ||||
|  | ||||
| 		// Publish signin event | ||||
| 		publishMainStream(user.id, 'signin', await Signins.pack(record)); | ||||
|  | ||||
| 		if (status && failure) { | ||||
| 			ctx.throw(status, failure); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Append signin history | ||||
| 	const record = await Signins.save({ | ||||
| 		id: genId(), | ||||
| 		createdAt: new Date(), | ||||
| 		userId: user.id, | ||||
| 		ip: ctx.ip, | ||||
| 		headers: ctx.headers, | ||||
| 		success: same | ||||
| 	}); | ||||
| 	if (!same) { | ||||
| 		await fail(403, { | ||||
| 			error: 'incorrect password' | ||||
| 		}); | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	// Publish signin event | ||||
| 	publishMainStream(user.id, 'signin', await Signins.pack(record)); | ||||
| 	if (!profile.twoFactorEnabled) { | ||||
| 		signin(ctx, user); | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	if (token) { | ||||
| 		const verified = (speakeasy as any).totp.verify({ | ||||
| 			secret: profile.twoFactorSecret, | ||||
| 			encoding: 'base32', | ||||
| 			token: token | ||||
| 		}); | ||||
|  | ||||
| 		if (verified) { | ||||
| 			signin(ctx, user); | ||||
| 			return; | ||||
| 		} else { | ||||
| 			await fail(403, { | ||||
| 				error: 'invalid token' | ||||
| 			}); | ||||
| 			return; | ||||
| 		} | ||||
| 	} else { | ||||
| 		const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex'); | ||||
| 		const clientData = JSON.parse(clientDataJSON.toString('utf-8')); | ||||
| 		const challenge = await AttestationChallenges.findOne({ | ||||
| 			userId: user.id, | ||||
| 			id: body.challengeId, | ||||
| 			registrationChallenge: false, | ||||
| 			challenge: hash(clientData.challenge).toString('hex') | ||||
| 		}); | ||||
|  | ||||
| 		if (!challenge) { | ||||
| 			await fail(403, { | ||||
| 				error: 'non-existent challenge' | ||||
| 			}); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		await AttestationChallenges.delete({ | ||||
| 			userId: user.id, | ||||
| 			id: body.challengeId | ||||
| 		}); | ||||
|  | ||||
| 		if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) { | ||||
| 			await fail(403, { | ||||
| 				error: 'non-existent challenge' | ||||
| 			}); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		const securityKey = await UserSecurityKeys.findOne({ | ||||
| 			id: Buffer.from( | ||||
| 				body.credentialId | ||||
| 					.replace(/\-/g, '+') | ||||
| 					.replace(/_/g, '/'), | ||||
| 					'base64' | ||||
| 			).toString('hex') | ||||
| 		}); | ||||
|  | ||||
| 		if (!securityKey) { | ||||
| 			await fail(403, { | ||||
| 				error: 'invalid credentialId' | ||||
| 			}); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		const isValid = verifyLogin({ | ||||
| 			publicKey: Buffer.from(securityKey.publicKey, 'hex'), | ||||
| 			authenticatorData: Buffer.from(body.authenticatorData, 'hex'), | ||||
| 			clientDataJSON, | ||||
| 			clientData, | ||||
| 			signature: Buffer.from(body.signature, 'hex'), | ||||
| 			challenge: challenge.challenge | ||||
| 		}); | ||||
|  | ||||
| 		if (isValid) { | ||||
| 			signin(ctx, user); | ||||
| 		} else { | ||||
| 			await fail(403, { | ||||
| 				error: 'invalid challenge data' | ||||
| 			}); | ||||
| 			return; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	await fail(); | ||||
| }; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Mary
					Mary