feat: passkey support (#11804)

https://github.com/MisskeyIO/misskey/pull/149
This commit is contained in:
syuilo
2023-09-08 14:05:03 +09:00
committed by GitHub
parent bc52d7a4fb
commit ff9a65e8fa
30 changed files with 800 additions and 1028 deletions

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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,
};
},
},
};
}
}

View File

@@ -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;
}
}

View File

@@ -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,
],

View File

@@ -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();
}
}

View File

@@ -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'),

View File

@@ -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,

View File

@@ -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;
}
}
}

View File

@@ -24,25 +24,48 @@ export class MiUserSecurityKey {
@JoinColumn()
public user: MiUser | null;
@Index()
@Column('varchar', {
comment:
'Variable-length public key used to verify attestations (hex-encoded).',
})
public publicKey: string;
@Column('timestamp with time zone', {
comment:
'The date of the last time the UserSecurityKey was successfully validated.',
})
public lastUsed: Date;
@Column('varchar', {
comment: 'User-defined name for this key',
length: 30,
})
public name: string;
@Index()
@Column('varchar', {
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: 'Timestamp of the last time the UserSecurityKey was used.',
default: () => 'now()',
})
public lastUsed: Date;
@Column('varchar', {
comment: 'The type of Backup Eligibility in authenticator data',
length: 32, nullable: true,
})
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;

View File

@@ -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>;

View File

@@ -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,

View File

@@ -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'),
});
const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential);
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,
});
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
}
}

View File

@@ -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');
throw new ApiError(meta.errors.twoFactorNotEnabled);
}
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');
}
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,
};
});

View File

@@ -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,
);
});
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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, {

View File

@@ -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',
},