summaryrefslogtreecommitdiff
path: root/packages/backend/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/backend/src')
-rw-r--r--packages/backend/src/boot/common.ts2
-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
-rw-r--r--packages/backend/src/daemons/DaemonModule.ts3
-rw-r--r--packages/backend/src/daemons/JanitorService.ts50
-rw-r--r--packages/backend/src/di-symbols.ts1
-rw-r--r--packages/backend/src/models/RepositoryModule.ts10
-rw-r--r--packages/backend/src/models/entities/AttestationChallenge.ts51
-rw-r--r--packages/backend/src/models/entities/UserSecurityKey.ts37
-rw-r--r--packages/backend/src/models/index.ts3
-rw-r--r--packages/backend/src/postgres.ts2
-rw-r--r--packages/backend/src/server/api/SigninApiService.ts115
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/key-done.ts137
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/register-key.ts82
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/register.ts13
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts13
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/unregister.ts13
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/update-key.ts2
19 files changed, 416 insertions, 828 deletions
diff --git a/packages/backend/src/boot/common.ts b/packages/backend/src/boot/common.ts
index ca2b729156..4783a2b2da 100644
--- a/packages/backend/src/boot/common.ts
+++ b/packages/backend/src/boot/common.ts
@@ -8,7 +8,6 @@ import { ChartManagementService } from '@/core/chart/ChartManagementService.js';
import { QueueProcessorService } from '@/queue/QueueProcessorService.js';
import { NestLogger } from '@/NestLogger.js';
import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js';
-import { JanitorService } from '@/daemons/JanitorService.js';
import { QueueStatsService } from '@/daemons/QueueStatsService.js';
import { ServerStatsService } from '@/daemons/ServerStatsService.js';
import { ServerService } from '@/server/ServerService.js';
@@ -25,7 +24,6 @@ export async function server() {
if (process.env.NODE_ENV !== 'test') {
app.get(ChartManagementService).start();
- app.get(JanitorService).start();
app.get(QueueStatsService).start();
app.get(ServerStatsService).start();
}
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;
+ }
+}
diff --git a/packages/backend/src/daemons/DaemonModule.ts b/packages/backend/src/daemons/DaemonModule.ts
index 7543a2ea3d..236985076c 100644
--- a/packages/backend/src/daemons/DaemonModule.ts
+++ b/packages/backend/src/daemons/DaemonModule.ts
@@ -6,7 +6,6 @@
import { Module } from '@nestjs/common';
import { CoreModule } from '@/core/CoreModule.js';
import { GlobalModule } from '@/GlobalModule.js';
-import { JanitorService } from './JanitorService.js';
import { QueueStatsService } from './QueueStatsService.js';
import { ServerStatsService } from './ServerStatsService.js';
@@ -16,12 +15,10 @@ import { ServerStatsService } from './ServerStatsService.js';
CoreModule,
],
providers: [
- JanitorService,
QueueStatsService,
ServerStatsService,
],
exports: [
- JanitorService,
QueueStatsService,
ServerStatsService,
],
diff --git a/packages/backend/src/daemons/JanitorService.ts b/packages/backend/src/daemons/JanitorService.ts
deleted file mode 100644
index 63c44e874f..0000000000
--- a/packages/backend/src/daemons/JanitorService.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and other misskey contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { Inject, Injectable } from '@nestjs/common';
-import { LessThan } from 'typeorm';
-import { DI } from '@/di-symbols.js';
-import type { AttestationChallengesRepository } from '@/models/index.js';
-import { bindThis } from '@/decorators.js';
-import type { OnApplicationShutdown } from '@nestjs/common';
-
-const interval = 30 * 60 * 1000;
-
-@Injectable()
-export class JanitorService implements OnApplicationShutdown {
- private intervalId: NodeJS.Timeout;
-
- constructor(
- @Inject(DI.attestationChallengesRepository)
- private attestationChallengesRepository: AttestationChallengesRepository,
- ) {
- }
-
- /**
- * Clean up database occasionally
- */
- @bindThis
- public start(): void {
- const tick = async () => {
- await this.attestationChallengesRepository.delete({
- createdAt: LessThan(new Date(new Date().getTime() - 5 * 60 * 1000)),
- });
- };
-
- tick();
-
- this.intervalId = setInterval(tick, interval);
- }
-
- @bindThis
- public dispose(): void {
- clearInterval(this.intervalId);
- }
-
- @bindThis
- public onApplicationShutdown(signal?: string | undefined): void {
- this.dispose();
- }
-}
diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts
index c911f60566..72ec98cebe 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -26,7 +26,6 @@ export const DI = {
userProfilesRepository: Symbol('userProfilesRepository'),
userKeypairsRepository: Symbol('userKeypairsRepository'),
userPendingsRepository: Symbol('userPendingsRepository'),
- attestationChallengesRepository: Symbol('attestationChallengesRepository'),
userSecurityKeysRepository: Symbol('userSecurityKeysRepository'),
userPublickeysRepository: Symbol('userPublickeysRepository'),
userListsRepository: Symbol('userListsRepository'),
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index b8372b1470..9b35996519 100644
--- a/packages/backend/src/models/RepositoryModule.ts
+++ b/packages/backend/src/models/RepositoryModule.ts
@@ -5,7 +5,7 @@
import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
-import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAttestationChallenge, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMutedNote, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListJoining, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './index.js';
+import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMutedNote, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListJoining, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './index.js';
import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common';
@@ -93,12 +93,6 @@ const $userPendingsRepository: Provider = {
inject: [DI.db],
};
-const $attestationChallengesRepository: Provider = {
- provide: DI.attestationChallengesRepository,
- useFactory: (db: DataSource) => db.getRepository(MiAttestationChallenge),
- inject: [DI.db],
-};
-
const $userSecurityKeysRepository: Provider = {
provide: DI.userSecurityKeysRepository,
useFactory: (db: DataSource) => db.getRepository(MiUserSecurityKey),
@@ -423,7 +417,6 @@ const $userMemosRepository: Provider = {
$userProfilesRepository,
$userKeypairsRepository,
$userPendingsRepository,
- $attestationChallengesRepository,
$userSecurityKeysRepository,
$userPublickeysRepository,
$userListsRepository,
@@ -491,7 +484,6 @@ const $userMemosRepository: Provider = {
$userProfilesRepository,
$userKeypairsRepository,
$userPendingsRepository,
- $attestationChallengesRepository,
$userSecurityKeysRepository,
$userPublickeysRepository,
$userListsRepository,
diff --git a/packages/backend/src/models/entities/AttestationChallenge.ts b/packages/backend/src/models/entities/AttestationChallenge.ts
deleted file mode 100644
index dace378eff..0000000000
--- a/packages/backend/src/models/entities/AttestationChallenge.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and other misskey contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm';
-import { id } from '../id.js';
-import { MiUser } from './User.js';
-
-@Entity('attestation_challenge')
-export class MiAttestationChallenge {
- @PrimaryColumn(id())
- public id: string;
-
- @Index()
- @PrimaryColumn(id())
- public userId: MiUser['id'];
-
- @ManyToOne(type => MiUser, {
- onDelete: 'CASCADE',
- })
- @JoinColumn()
- public user: MiUser | null;
-
- @Index()
- @Column('varchar', {
- length: 64,
- comment: 'Hex-encoded sha256 hash of the challenge.',
- })
- public challenge: string;
-
- @Column('timestamp with time zone', {
- comment: 'The date challenge was created for expiry purposes.',
- })
- public createdAt: Date;
-
- @Column('boolean', {
- comment:
- 'Indicates that the challenge is only for registration purposes if true to prevent the challenge for being used as authentication.',
- default: false,
- })
- public registrationChallenge: boolean;
-
- constructor(data: Partial<MiAttestationChallenge>) {
- if (data == null) return;
-
- for (const [k, v] of Object.entries(data)) {
- (this as any)[k] = v;
- }
- }
-}
diff --git a/packages/backend/src/models/entities/UserSecurityKey.ts b/packages/backend/src/models/entities/UserSecurityKey.ts
index ce1c270d46..96dd27d083 100644
--- a/packages/backend/src/models/entities/UserSecurityKey.ts
+++ b/packages/backend/src/models/entities/UserSecurityKey.ts
@@ -24,24 +24,47 @@ export class MiUserSecurityKey {
@JoinColumn()
public user: MiUser | null;
+ @Column('varchar', {
+ comment: 'User-defined name for this key',
+ length: 30,
+ })
+ public name: string;
+
@Index()
@Column('varchar', {
- comment:
- 'Variable-length public key used to verify attestations (hex-encoded).',
+ comment: 'The public key of the UserSecurityKey, hex-encoded.',
})
public publicKey: string;
+ @Column('bigint', {
+ comment: 'The number of times the UserSecurityKey was validated.',
+ default: 0,
+ })
+ public counter: number;
+
@Column('timestamp with time zone', {
- comment:
- 'The date of the last time the UserSecurityKey was successfully validated.',
+ comment: 'Timestamp of the last time the UserSecurityKey was used.',
+ default: () => 'now()',
})
public lastUsed: Date;
@Column('varchar', {
- comment: 'User-defined name for this key',
- length: 30,
+ comment: 'The type of Backup Eligibility in authenticator data',
+ length: 32, nullable: true,
})
- public name: string;
+ public credentialDeviceType: string | null;
+
+ @Column('boolean', {
+ comment: 'Whether or not the credential has been backed up',
+ nullable: true,
+ })
+ public credentialBackedUp: boolean | null;
+
+ @Column('varchar', {
+ comment: 'The type of the credential returned by the browser',
+ length: 32, array: true, nullable: true,
+ })
+ public transports: string[] | null;
constructor(data: Partial<MiUserSecurityKey>) {
if (data == null) return;
diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts
index d14234b792..e4f4dce7d6 100644
--- a/packages/backend/src/models/index.ts
+++ b/packages/backend/src/models/index.ts
@@ -10,7 +10,6 @@ import { MiAnnouncement } from '@/models/entities/Announcement.js';
import { MiAnnouncementRead } from '@/models/entities/AnnouncementRead.js';
import { MiAntenna } from '@/models/entities/Antenna.js';
import { MiApp } from '@/models/entities/App.js';
-import { MiAttestationChallenge } from '@/models/entities/AttestationChallenge.js';
import { MiAuthSession } from '@/models/entities/AuthSession.js';
import { MiBlocking } from '@/models/entities/Blocking.js';
import { MiChannelFollowing } from '@/models/entities/ChannelFollowing.js';
@@ -79,7 +78,6 @@ export {
MiAnnouncementRead,
MiAntenna,
MiApp,
- MiAttestationChallenge,
MiAuthSession,
MiBlocking,
MiChannelFollowing,
@@ -147,7 +145,6 @@ export type AnnouncementsRepository = Repository<MiAnnouncement>;
export type AnnouncementReadsRepository = Repository<MiAnnouncementRead>;
export type AntennasRepository = Repository<MiAntenna>;
export type AppsRepository = Repository<MiApp>;
-export type AttestationChallengesRepository = Repository<MiAttestationChallenge>;
export type AuthSessionsRepository = Repository<MiAuthSession>;
export type BlockingsRepository = Repository<MiBlocking>;
export type ChannelFollowingsRepository = Repository<MiChannelFollowing>;
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index 6c2f4b21f0..c5d9e41463 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -18,7 +18,6 @@ import { MiAnnouncement } from '@/models/entities/Announcement.js';
import { MiAnnouncementRead } from '@/models/entities/AnnouncementRead.js';
import { MiAntenna } from '@/models/entities/Antenna.js';
import { MiApp } from '@/models/entities/App.js';
-import { MiAttestationChallenge } from '@/models/entities/AttestationChallenge.js';
import { MiAuthSession } from '@/models/entities/AuthSession.js';
import { MiBlocking } from '@/models/entities/Blocking.js';
import { MiChannelFollowing } from '@/models/entities/ChannelFollowing.js';
@@ -143,7 +142,6 @@ export const entities = [
MiUserNotePining,
MiUserSecurityKey,
MiUsedUsername,
- MiAttestationChallenge,
MiFollowing,
MiFollowRequest,
MiMuting,
diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts
index 58a5cca4fc..ac8371d8d0 100644
--- a/packages/backend/src/server/api/SigninApiService.ts
+++ b/packages/backend/src/server/api/SigninApiService.ts
@@ -3,22 +3,26 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { randomBytes } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import * as OTPAuth from 'otpauth';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
-import type { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js';
+import type {
+ SigninsRepository,
+ UserProfilesRepository,
+ UsersRepository,
+} from '@/models/index.js';
import type { Config } from '@/config.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import type { MiLocalUser } from '@/models/entities/User.js';
import { IdService } from '@/core/IdService.js';
-import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js';
import { bindThis } from '@/decorators.js';
+import { WebAuthnService } from '@/core/WebAuthnService.js';
import { RateLimiterService } from './RateLimiterService.js';
import { SigninService } from './SigninService.js';
-import type { FastifyRequest, FastifyReply } from 'fastify';
+import type { AuthenticationResponseJSON } from '@simplewebauthn/typescript-types';
+import type { FastifyReply, FastifyRequest } from 'fastify';
@Injectable()
export class SigninApiService {
@@ -29,22 +33,16 @@ export class SigninApiService {
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
- @Inject(DI.userSecurityKeysRepository)
- private userSecurityKeysRepository: UserSecurityKeysRepository,
-
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
- @Inject(DI.attestationChallengesRepository)
- private attestationChallengesRepository: AttestationChallengesRepository,
-
@Inject(DI.signinsRepository)
private signinsRepository: SigninsRepository,
private idService: IdService,
private rateLimiterService: RateLimiterService,
private signinService: SigninService,
- private twoFactorAuthenticationService: TwoFactorAuthenticationService,
+ private webAuthnService: WebAuthnService,
) {
}
@@ -55,11 +53,7 @@ export class SigninApiService {
username: string;
password: string;
token?: string;
- signature?: string;
- authenticatorData?: string;
- clientDataJSON?: string;
- credentialId?: string;
- challengeId?: string;
+ credential?: AuthenticationResponseJSON;
};
}>,
reply: FastifyReply,
@@ -181,64 +175,16 @@ export class SigninApiService {
} else {
return this.signinService.signin(request, reply, user);
}
- } else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) {
+ } else if (body.credential) {
if (!same && !profile.usePasswordLessLogin) {
return await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
}
- const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex');
- const clientData = JSON.parse(clientDataJSON.toString('utf-8'));
- const challenge = await this.attestationChallengesRepository.findOneBy({
- userId: user.id,
- id: body.challengeId,
- registrationChallenge: false,
- challenge: this.twoFactorAuthenticationService.hash(clientData.challenge).toString('hex'),
- });
-
- if (!challenge) {
- return await fail(403, {
- id: '2715a88a-2125-4013-932f-aa6fe72792da',
- });
- }
-
- await this.attestationChallengesRepository.delete({
- userId: user.id,
- id: body.challengeId,
- });
-
- if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) {
- return await fail(403, {
- id: '2715a88a-2125-4013-932f-aa6fe72792da',
- });
- }
-
- const securityKey = await this.userSecurityKeysRepository.findOneBy({
- id: Buffer.from(
- body.credentialId
- .replace(/-/g, '+')
- .replace(/_/g, '/'),
- 'base64',
- ).toString('hex'),
- });
-
- if (!securityKey) {
- return await fail(403, {
- id: '66269679-aeaf-4474-862b-eb761197e046',
- });
- }
-
- const isValid = this.twoFactorAuthenticationService.verifySignin({
- publicKey: Buffer.from(securityKey.publicKey, 'hex'),
- authenticatorData: Buffer.from(body.authenticatorData, 'hex'),
- clientDataJSON,
- clientData,
- signature: Buffer.from(body.signature, 'hex'),
- challenge: challenge.challenge,
- });
+ const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential);
- if (isValid) {
+ if (authorized) {
return this.signinService.signin(request, reply, user);
} else {
return await fail(403, {
@@ -252,42 +198,11 @@ export class SigninApiService {
});
}
- const keys = await this.userSecurityKeysRepository.findBy({
- userId: user.id,
- });
-
- if (keys.length === 0) {
- return await fail(403, {
- id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4',
- });
- }
-
- // 32 byte challenge
- const challenge = randomBytes(32).toString('base64')
- .replace(/=/g, '')
- .replace(/\+/g, '-')
- .replace(/\//g, '_');
-
- const challengeId = this.idService.genId();
-
- await this.attestationChallengesRepository.insert({
- userId: user.id,
- id: challengeId,
- challenge: this.twoFactorAuthenticationService.hash(Buffer.from(challenge, 'utf-8')).toString('hex'),
- createdAt: new Date(),
- registrationChallenge: false,
- });
+ const authRequest = await this.webAuthnService.initiateAuthentication(user.id);
reply.code(200);
- return {
- challenge,
- challengeId,
- securityKeys: keys.map(key => ({
- id: key.id,
- })),
- };
+ return authRequest;
}
// never get here
}
}
-
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
index cff51245c7..87a15da0c2 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
@@ -3,155 +3,86 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { promisify } from 'node:util';
import bcrypt from 'bcryptjs';
-import cbor from 'cbor';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
-import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js';
-import type { AttestationChallengesRepository, UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
-
-const cborDecodeFirst = promisify(cbor.decodeFirst) as any;
+import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
+import { WebAuthnService } from '@/core/WebAuthnService.js';
+import { ApiError } from '@/server/api/error.js';
export const meta = {
requireCredential: true,
secure: true,
+
+ errors: {
+ incorrectPassword: {
+ message: 'Incorrect password.',
+ code: 'INCORRECT_PASSWORD',
+ id: '0d7ec6d2-e652-443e-a7bf-9ee9a0cd77b0',
+ },
+
+ twoFactorNotEnabled: {
+ message: '2fa not enabled.',
+ code: 'TWO_FACTOR_NOT_ENABLED',
+ id: '798d6847-b1ed-4f9c-b1f9-163c42655995',
+ },
+ },
} as const;
export const paramDef = {
type: 'object',
properties: {
- clientDataJSON: { type: 'string' },
- attestationObject: { type: 'string' },
password: { type: 'string' },
- challengeId: { type: 'string' },
name: { type: 'string', minLength: 1, maxLength: 30 },
+ credential: { type: 'object' },
},
- required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'],
+ required: ['password', 'name', 'credential'],
} as const;
+// eslint-disable-next-line import/no-default-export
@Injectable()
-export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
- @Inject(DI.config)
- private config: Config,
-
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.userSecurityKeysRepository)
private userSecurityKeysRepository: UserSecurityKeysRepository,
- @Inject(DI.attestationChallengesRepository)
- private attestationChallengesRepository: AttestationChallengesRepository,
-
+ private webAuthnService: WebAuthnService,
private userEntityService: UserEntityService,
private globalEventService: GlobalEventService,
- private twoFactorAuthenticationService: TwoFactorAuthenticationService,
) {
super(meta, paramDef, async (ps, me) => {
- const rpIdHashReal = this.twoFactorAuthenticationService.hash(Buffer.from(this.config.hostname, 'utf-8'));
-
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
// Compare password
- const same = await bcrypt.compare(ps.password, profile.password!);
+ const same = await bcrypt.compare(ps.password, profile.password ?? '');
if (!same) {
- throw new Error('incorrect password');
+ throw new ApiError(meta.errors.incorrectPassword);
}
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 !== this.config.scheme + '://' + this.config.host) {
- throw new Error('origin mismatch');
- }
-
- const clientDataJSONHash = this.twoFactorAuthenticationService.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];
-
- // eslint: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');
- }
-
- const procedures = this.twoFactorAuthenticationService.getProcedures();
-
- if (!(procedures as any)[attestation.fmt]) {
- throw new Error(`unsupported fmt: ${attestation.fmt}. Supported ones: ${Object.keys(procedures)}`);
- }
-
- const verificationData = (procedures as any)[attestation.fmt].verify({
- attStmt: attestation.attStmt,
- authenticatorData: authData,
- clientDataHash: clientDataJSONHash,
- credentialId,
- publicKey,
- rpIdHash,
- });
- if (!verificationData.valid) throw new Error('signature invalid');
-
- const attestationChallenge = await this.attestationChallengesRepository.findOneBy({
- userId: me.id,
- id: ps.challengeId,
- registrationChallenge: true,
- challenge: this.twoFactorAuthenticationService.hash(clientData.challenge).toString('hex'),
- });
-
- if (!attestationChallenge) {
- throw new Error('non-existent challenge');
- }
-
- await this.attestationChallengesRepository.delete({
- userId: me.id,
- id: ps.challengeId,
- });
-
- // Expired challenge (> 5min old)
- if (
- new Date().getTime() - attestationChallenge.createdAt.getTime() >=
- 5 * 60 * 1000
- ) {
- throw new Error('expired challenge');
+ throw new ApiError(meta.errors.twoFactorNotEnabled);
}
- const credentialIdString = credentialId.toString('hex');
+ const keyInfo = await this.webAuthnService.verifyRegistration(me.id, ps.credential);
+ const credentialId = Buffer.from(keyInfo.credentialID).toString('base64url');
await this.userSecurityKeysRepository.insert({
+ id: credentialId,
userId: me.id,
- id: credentialIdString,
- lastUsed: new Date(),
name: ps.name,
- publicKey: verificationData.publicKey.toString('hex'),
+ publicKey: Buffer.from(keyInfo.credentialPublicKey).toString('base64url'),
+ counter: keyInfo.counter,
+ credentialDeviceType: keyInfo.credentialDeviceType,
+ credentialBackedUp: keyInfo.credentialBackedUp,
+ transports: keyInfo.transports,
});
// Publish meUpdated event
@@ -161,7 +92,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}));
return {
- id: credentialIdString,
+ id: credentialId,
name: ps.name,
};
});
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts
index 65ae66d01e..cae4f5ab52 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts
@@ -3,22 +3,38 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { promisify } from 'node:util';
-import * as crypto from 'node:crypto';
import bcrypt from 'bcryptjs';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { UserProfilesRepository, AttestationChallengesRepository } from '@/models/index.js';
-import { IdService } from '@/core/IdService.js';
-import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js';
+import type { UserProfilesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
-
-const randomBytes = promisify(crypto.randomBytes);
+import { WebAuthnService } from '@/core/WebAuthnService.js';
+import { ApiError } from '@/server/api/error.js';
export const meta = {
requireCredential: true,
secure: true,
+
+ errors: {
+ userNotFound: {
+ message: 'User not found.',
+ code: 'USER_NOT_FOUND',
+ id: '652f899f-66d4-490e-993e-6606c8ec04c3',
+ },
+
+ incorrectPassword: {
+ message: 'Incorrect password.',
+ code: 'INCORRECT_PASSWORD',
+ id: '38769596-efe2-4faf-9bec-abbb3f2cd9ba',
+ },
+
+ twoFactorNotEnabled: {
+ message: '2fa not enabled.',
+ code: 'TWO_FACTOR_NOT_ENABLED',
+ id: 'bf32b864-449b-47b8-974e-f9a5468546f1',
+ },
+ },
} as const;
export const paramDef = {
@@ -29,53 +45,43 @@ export const paramDef = {
required: ['password'],
} as const;
+// eslint-disable-next-line import/no-default-export
@Injectable()
-export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
- @Inject(DI.attestationChallengesRepository)
- private attestationChallengesRepository: AttestationChallengesRepository,
-
- private idService: IdService,
- private twoFactorAuthenticationService: TwoFactorAuthenticationService,
+ private webAuthnService: WebAuthnService,
) {
super(meta, paramDef, async (ps, me) => {
- const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
+ const profile = await this.userProfilesRepository.findOne({
+ where: {
+ userId: me.id,
+ },
+ relations: ['user'],
+ });
+
+ if (profile == null) {
+ throw new ApiError(meta.errors.userNotFound);
+ }
// Compare password
- const same = await bcrypt.compare(ps.password, profile.password!);
+ const same = await bcrypt.compare(ps.password, profile.password ?? '');
if (!same) {
- throw new Error('incorrect password');
+ throw new ApiError(meta.errors.incorrectPassword);
}
if (!profile.twoFactorEnabled) {
- throw new Error('2fa not enabled');
+ throw new ApiError(meta.errors.twoFactorNotEnabled);
}
- // 32 byte challenge
- const entropy = await randomBytes(32);
- const challenge = entropy.toString('base64')
- .replace(/=/g, '')
- .replace(/\+/g, '-')
- .replace(/\//g, '_');
-
- const challengeId = this.idService.genId();
-
- await this.attestationChallengesRepository.insert({
- userId: me.id,
- id: challengeId,
- challenge: this.twoFactorAuthenticationService.hash(Buffer.from(challenge, 'utf-8')).toString('hex'),
- createdAt: new Date(),
- registrationChallenge: true,
- });
-
- return {
- challengeId,
- challenge,
- };
+ return await this.webAuthnService.initiateRegistration(
+ me.id,
+ profile.user?.username ?? me.id,
+ profile.user?.name ?? undefined,
+ );
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts
index 5ab1635e48..c60343d25d 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts
@@ -11,11 +11,20 @@ import type { UserProfilesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
+import { ApiError } from '@/server/api/error.js';
export const meta = {
requireCredential: true,
secure: true,
+
+ errors: {
+ incorrectPassword: {
+ message: 'Incorrect password.',
+ code: 'INCORRECT_PASSWORD',
+ id: '78d6c839-20c9-4c66-b90a-fc0542168b48',
+ },
+ },
} as const;
export const paramDef = {
@@ -39,10 +48,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
// Compare password
- const same = await bcrypt.compare(ps.password, profile.password!);
+ const same = await bcrypt.compare(ps.password, profile.password ?? '');
if (!same) {
- throw new Error('incorrect password');
+ throw new ApiError(meta.errors.incorrectPassword);
}
// Generate user's secret key
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts
index 9cd0898d48..90d23d11f6 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts
@@ -10,11 +10,20 @@ import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/model
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
export const meta = {
requireCredential: true,
secure: true,
+
+ errors: {
+ incorrectPassword: {
+ message: 'Incorrect password.',
+ code: 'INCORRECT_PASSWORD',
+ id: '141c598d-a825-44c8-9173-cfb9d92be493',
+ },
+ },
} as const;
export const paramDef = {
@@ -42,10 +51,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
// Compare password
- const same = await bcrypt.compare(ps.password, profile.password!);
+ const same = await bcrypt.compare(ps.password, profile.password ?? '');
if (!same) {
- throw new Error('incorrect password');
+ throw new ApiError(meta.errors.incorrectPassword);
}
// Make sure we only delete the user's own creds
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts
index e017e2ef53..33910f7738 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts
@@ -10,11 +10,20 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { UserProfilesRepository } from '@/models/index.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
export const meta = {
requireCredential: true,
secure: true,
+
+ errors: {
+ incorrectPassword: {
+ message: 'Incorrect password.',
+ code: 'INCORRECT_PASSWORD',
+ id: '7add0395-9901-4098-82f9-4f67af65f775',
+ },
+ },
} as const;
export const paramDef = {
@@ -38,10 +47,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
// Compare password
- const same = await bcrypt.compare(ps.password, profile.password!);
+ const same = await bcrypt.compare(ps.password, profile.password ?? '');
if (!same) {
- throw new Error('incorrect password');
+ throw new ApiError(meta.errors.incorrectPassword);
}
await this.userProfilesRepository.update(me.id, {
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts
index cc837ca9f0..90640fd57a 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts
@@ -25,7 +25,7 @@ export const meta = {
},
accessDenied: {
- message: 'You do not have edit privilege of the channel.',
+ message: 'You do not have edit privilege of this key.',
code: 'ACCESS_DENIED',
id: '1fb7cb09-d46a-4fff-b8df-057708cce513',
},