summaryrefslogtreecommitdiff
path: root/packages/backend/src/core
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2023-09-08 14:05:03 +0900
committerGitHub <noreply@github.com>2023-09-08 14:05:03 +0900
commitff9a65e8faa46a101d3ed3dc8915dd1f269ef556 (patch)
treea6b1ae734e61da58b4205cd08a505ce392b317a9 /packages/backend/src/core
parentUpdate CHANGELOG.md (diff)
downloadsharkey-ff9a65e8faa46a101d3ed3dc8915dd1f269ef556.tar.gz
sharkey-ff9a65e8faa46a101d3ed3dc8915dd1f269ef556.tar.bz2
sharkey-ff9a65e8faa46a101d3ed3dc8915dd1f269ef556.zip
feat: passkey support (#11804)
https://github.com/MisskeyIO/misskey/pull/149
Diffstat (limited to 'packages/backend/src/core')
-rw-r--r--packages/backend/src/core/CoreModule.ts12
-rw-r--r--packages/backend/src/core/TwoFactorAuthenticationService.ts446
-rw-r--r--packages/backend/src/core/WebAuthnService.ts252
3 files changed, 258 insertions, 452 deletions
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index 51d4f9cfa9..863f1a2fd5 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -43,7 +43,7 @@ import { RelayService } from './RelayService.js';
import { RoleService } from './RoleService.js';
import { S3Service } from './S3Service.js';
import { SignupService } from './SignupService.js';
-import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js';
+import { WebAuthnService } from './WebAuthnService.js';
import { UserBlockingService } from './UserBlockingService.js';
import { CacheService } from './CacheService.js';
import { UserFollowingService } from './UserFollowingService.js';
@@ -168,7 +168,7 @@ const $RelayService: Provider = { provide: 'RelayService', useExisting: RelaySer
const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService };
const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service };
const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService };
-const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService };
+const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService };
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService };
@@ -296,7 +296,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
RoleService,
S3Service,
SignupService,
- TwoFactorAuthenticationService,
+ WebAuthnService,
UserBlockingService,
CacheService,
UserFollowingService,
@@ -417,7 +417,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$RoleService,
$S3Service,
$SignupService,
- $TwoFactorAuthenticationService,
+ $WebAuthnService,
$UserBlockingService,
$CacheService,
$UserFollowingService,
@@ -539,7 +539,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
RoleService,
S3Service,
SignupService,
- TwoFactorAuthenticationService,
+ WebAuthnService,
UserBlockingService,
CacheService,
UserFollowingService,
@@ -659,7 +659,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$RoleService,
$S3Service,
$SignupService,
- $TwoFactorAuthenticationService,
+ $WebAuthnService,
$UserBlockingService,
$CacheService,
$UserFollowingService,
diff --git a/packages/backend/src/core/TwoFactorAuthenticationService.ts b/packages/backend/src/core/TwoFactorAuthenticationService.ts
deleted file mode 100644
index ecf7676f4b..0000000000
--- a/packages/backend/src/core/TwoFactorAuthenticationService.ts
+++ /dev/null
@@ -1,446 +0,0 @@
-/*
- * 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,
- };
- },
- },
- };
- }
-}
diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts
new file mode 100644
index 0000000000..1c344eabe1
--- /dev/null
+++ b/packages/backend/src/core/WebAuthnService.ts
@@ -0,0 +1,252 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import * as Redis from 'ioredis';
+import {
+ generateAuthenticationOptions,
+ generateRegistrationOptions, verifyAuthenticationResponse,
+ verifyRegistrationResponse,
+} from '@simplewebauthn/server';
+import { AttestationFormat, isoCBOR } from '@simplewebauthn/server/helpers';
+import { DI } from '@/di-symbols.js';
+import type { UserSecurityKeysRepository } from '@/models/index.js';
+import type { Config } from '@/config.js';
+import { bindThis } from '@/decorators.js';
+import { MetaService } from '@/core/MetaService.js';
+import { MiUser } from '@/models/index.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
+import type {
+ AuthenticationResponseJSON,
+ AuthenticatorTransportFuture,
+ CredentialDeviceType,
+ PublicKeyCredentialCreationOptionsJSON,
+ PublicKeyCredentialDescriptorFuture,
+ PublicKeyCredentialRequestOptionsJSON,
+ RegistrationResponseJSON,
+} from '@simplewebauthn/typescript-types';
+
+@Injectable()
+export class WebAuthnService {
+ constructor(
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
+ @Inject(DI.config)
+ private config: Config,
+
+ @Inject(DI.userSecurityKeysRepository)
+ private userSecurityKeysRepository: UserSecurityKeysRepository,
+
+ private metaService: MetaService,
+ ) {
+ }
+
+ @bindThis
+ public async getRelyingParty(): Promise<{ origin: string; rpId: string; rpName: string; rpIcon?: string; }> {
+ const instance = await this.metaService.fetch();
+ return {
+ origin: this.config.url,
+ rpId: this.config.host,
+ rpName: instance.name ?? this.config.host,
+ rpIcon: instance.iconUrl ?? undefined,
+ };
+ }
+
+ @bindThis
+ public async initiateRegistration(userId: MiUser['id'], userName: string, userDisplayName?: string): Promise<PublicKeyCredentialCreationOptionsJSON> {
+ const relyingParty = await this.getRelyingParty();
+ const keys = await this.userSecurityKeysRepository.findBy({
+ userId: userId,
+ });
+
+ const registrationOptions = await generateRegistrationOptions({
+ rpName: relyingParty.rpName,
+ rpID: relyingParty.rpId,
+ userID: userId,
+ userName: userName,
+ userDisplayName: userDisplayName,
+ attestationType: 'indirect',
+ excludeCredentials: keys.map(key => (<PublicKeyCredentialDescriptorFuture>{
+ id: Buffer.from(key.id, 'base64url'),
+ type: 'public-key',
+ transports: key.transports ?? undefined,
+ })),
+ authenticatorSelection: {
+ residentKey: 'required',
+ userVerification: 'preferred',
+ },
+ });
+
+ await this.redisClient.setex(`webauthn:challenge:${userId}`, 90, registrationOptions.challenge);
+
+ return registrationOptions;
+ }
+
+ @bindThis
+ public async verifyRegistration(userId: MiUser['id'], response: RegistrationResponseJSON): Promise<{
+ credentialID: Uint8Array;
+ credentialPublicKey: Uint8Array;
+ attestationObject: Uint8Array;
+ fmt: AttestationFormat;
+ counter: number;
+ userVerified: boolean;
+ credentialDeviceType: CredentialDeviceType;
+ credentialBackedUp: boolean;
+ transports?: AuthenticatorTransportFuture[];
+ }> {
+ const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
+
+ if (!challenge) {
+ throw new IdentifiableError('7dbfb66c-9216-4e2b-9c27-cef2ac8efb84', 'challenge not found');
+ }
+
+ await this.redisClient.del(`webauthn:challenge:${userId}`);
+
+ const relyingParty = await this.getRelyingParty();
+
+ let verification;
+ try {
+ verification = await verifyRegistrationResponse({
+ response: response,
+ expectedChallenge: challenge,
+ expectedOrigin: relyingParty.origin,
+ expectedRPID: relyingParty.rpId,
+ requireUserVerification: true,
+ });
+ } catch (error) {
+ console.error(error);
+ throw new IdentifiableError('5c1446f8-8ca7-4d31-9f39-656afe9c5d87', 'verification failed');
+ }
+
+ const { verified } = verification;
+
+ if (!verified || !verification.registrationInfo) {
+ throw new IdentifiableError('bb333667-3832-4a80-8bb5-c505be7d710d', 'verification failed');
+ }
+
+ const { registrationInfo } = verification;
+
+ return {
+ credentialID: registrationInfo.credentialID,
+ credentialPublicKey: registrationInfo.credentialPublicKey,
+ attestationObject: registrationInfo.attestationObject,
+ fmt: registrationInfo.fmt,
+ counter: registrationInfo.counter,
+ userVerified: registrationInfo.userVerified,
+ credentialDeviceType: registrationInfo.credentialDeviceType,
+ credentialBackedUp: registrationInfo.credentialBackedUp,
+ transports: response.response.transports,
+ };
+ }
+
+ @bindThis
+ public async initiateAuthentication(userId: MiUser['id']): Promise<PublicKeyCredentialRequestOptionsJSON> {
+ const keys = await this.userSecurityKeysRepository.findBy({
+ userId: userId,
+ });
+
+ if (keys.length === 0) {
+ throw new IdentifiableError('f27fd449-9af4-4841-9249-1f989b9fa4a4', 'no keys found');
+ }
+
+ const authenticationOptions = await generateAuthenticationOptions({
+ allowCredentials: keys.map(key => (<PublicKeyCredentialDescriptorFuture>{
+ id: Buffer.from(key.id, 'base64url'),
+ type: 'public-key',
+ transports: key.transports ?? undefined,
+ })),
+ userVerification: 'preferred',
+ });
+
+ await this.redisClient.setex(`webauthn:challenge:${userId}`, 90, authenticationOptions.challenge);
+
+ return authenticationOptions;
+ }
+
+ @bindThis
+ public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise<boolean> {
+ const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
+
+ if (!challenge) {
+ throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', 'challenge not found');
+ }
+
+ await this.redisClient.del(`webauthn:challenge:${userId}`);
+
+ const key = await this.userSecurityKeysRepository.findOneBy({
+ id: response.id,
+ userId: userId,
+ });
+
+ if (!key) {
+ throw new IdentifiableError('36b96a7d-b547-412d-aeed-2d611cdc8cdc', 'unknown key');
+ }
+
+ // マイグレーション
+ if (key.counter === 0 && key.publicKey.length === 87) {
+ const cert = new Uint8Array(Buffer.from(key.publicKey, 'base64url'));
+ if (cert[0] === 0x04) { // 前の実装ではいつも 0x04 で始まっていた
+ const halfLength = (cert.length - 1) / 2;
+
+ const cborMap = new Map<number, number | ArrayBufferLike>();
+ cborMap.set(1, 2); // kty, EC2
+ cborMap.set(3, -7); // alg, ES256
+ cborMap.set(-1, 1); // crv, P256
+ cborMap.set(-2, cert.slice(1, halfLength + 1)); // x
+ cborMap.set(-3, cert.slice(halfLength + 1)); // y
+
+ const cborPubKey = Buffer.from(isoCBOR.encode(cborMap)).toString('base64url');
+ await this.userSecurityKeysRepository.update({
+ id: response.id,
+ userId: userId,
+ }, {
+ publicKey: cborPubKey,
+ });
+ key.publicKey = cborPubKey;
+ }
+ }
+
+ const relyingParty = await this.getRelyingParty();
+
+ let verification;
+ try {
+ verification = await verifyAuthenticationResponse({
+ response: response,
+ expectedChallenge: challenge,
+ expectedOrigin: relyingParty.origin,
+ expectedRPID: relyingParty.rpId,
+ authenticator: {
+ credentialID: Buffer.from(key.id, 'base64url'),
+ credentialPublicKey: Buffer.from(key.publicKey, 'base64url'),
+ counter: key.counter,
+ transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined,
+ },
+ requireUserVerification: true,
+ });
+ } catch (error) {
+ console.error(error);
+ throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', 'verification failed');
+ }
+
+ const { verified, authenticationInfo } = verification;
+
+ if (!verified) {
+ return false;
+ }
+
+ await this.userSecurityKeysRepository.update({
+ id: response.id,
+ userId: userId,
+ }, {
+ lastUsed: new Date(),
+ counter: authenticationInfo.newCounter,
+ credentialDeviceType: authenticationInfo.credentialDeviceType,
+ credentialBackedUp: authenticationInfo.credentialBackedUp,
+ });
+
+ return verified;
+ }
+}