2023-09-08 14:05:03 +09:00
|
|
|
/*
|
2024-02-14 00:59:27 +09:00
|
|
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
2023-09-08 14:05:03 +09:00
|
|
|
* 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';
|
2023-09-15 14:28:29 +09:00
|
|
|
import type { UserSecurityKeysRepository } from '@/models/_.js';
|
2023-09-08 14:05:03 +09:00
|
|
|
import type { Config } from '@/config.js';
|
|
|
|
import { bindThis } from '@/decorators.js';
|
|
|
|
import { MetaService } from '@/core/MetaService.js';
|
2023-09-15 14:28:29 +09:00
|
|
|
import { MiUser } from '@/models/_.js';
|
2023-09-08 14:05:03 +09:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|