diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-09-08 14:05:03 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-09-08 14:05:03 +0900 |
| commit | ff9a65e8faa46a101d3ed3dc8915dd1f269ef556 (patch) | |
| tree | a6b1ae734e61da58b4205cd08a505ce392b317a9 /packages/backend/src/server/api/endpoints | |
| parent | Update CHANGELOG.md (diff) | |
| download | sharkey-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/server/api/endpoints')
6 files changed, 112 insertions, 148 deletions
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', }, |