/*
 * SPDX-FileCopyrightText: syuilo and other misskey contributors
 * SPDX-License-Identifier: AGPL-3.0-only
 */

import * as crypto from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import * as jsrsasign from 'jsrsasign';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';

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]);
		if (certStruct == null) throw new Error('certStruct is null');

		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`
	);
}

@Injectable()
export class TwoFactorAuthenticationService {
	constructor(
		@Inject(DI.config)
		private config: Config,
	) {
	}

	@bindThis
	public hash(data: Buffer) {
		return crypto
			.createHash('sha256')
			.update(data)
			.digest();
	}

	@bindThis
	public verifySignin({
		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 (this.hash(clientData.challenge).toString('hex') !== challenge) {
			throw new Error('challenge mismatch');
		}
		if (clientData.origin !== this.config.scheme + '://' + this.config.host) {
			throw new Error('origin mismatch');
		}

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

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

	@bindThis
	public getProcedures() {
		return {
			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 = this.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,
					};
				},
			},
		};
	}
}