summaryrefslogtreecommitdiff
path: root/packages/backend/src/core/WebAuthnService.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/backend/src/core/WebAuthnService.ts')
-rw-r--r--packages/backend/src/core/WebAuthnService.ts252
1 files changed, 252 insertions, 0 deletions
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;
+ }
+}