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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 800 additions and 1028 deletions

View File

@ -23,6 +23,7 @@
- チャンネルをセンシティブ指定できるようになりました - チャンネルをセンシティブ指定できるようになりました
- センシティブチャンネルのNoteのReNoteはデフォルトでHome TLに流れるようになりました - センシティブチャンネルのNoteのReNoteはデフォルトでHome TLに流れるようになりました
- 二要素認証のバックアップコードが生成されるようになりました ref. https://github.com/MisskeyIO/misskey/pull/121 - 二要素認証のバックアップコードが生成されるようになりました ref. https://github.com/MisskeyIO/misskey/pull/121
- 二要素認証でパスキーをサポートするようになりました
### Client ### Client
- プロフィールにその人が作ったPlayの一覧出せるように - プロフィールにその人が作ったPlayの一覧出せるように

2
locales/index.d.ts vendored
View File

@ -1108,6 +1108,7 @@ export interface Locale {
"currentAnnouncements": string; "currentAnnouncements": string;
"pastAnnouncements": string; "pastAnnouncements": string;
"youHaveUnreadAnnouncements": string; "youHaveUnreadAnnouncements": string;
"useSecurityKey": string;
"_announcement": { "_announcement": {
"forExistingUsers": string; "forExistingUsers": string;
"forExistingUsersDescription": string; "forExistingUsersDescription": string;
@ -1822,7 +1823,6 @@ export interface Locale {
"securityKeyNotSupported": string; "securityKeyNotSupported": string;
"registerTOTPBeforeKey": string; "registerTOTPBeforeKey": string;
"securityKeyInfo": string; "securityKeyInfo": string;
"chromePasskeyNotSupported": string;
"registerSecurityKey": string; "registerSecurityKey": string;
"securityKeyName": string; "securityKeyName": string;
"tapSecurityKey": string; "tapSecurityKey": string;

View File

@ -1105,6 +1105,7 @@ forYou: "あなたへ"
currentAnnouncements: "現在のお知らせ" currentAnnouncements: "現在のお知らせ"
pastAnnouncements: "過去のお知らせ" pastAnnouncements: "過去のお知らせ"
youHaveUnreadAnnouncements: "未読のお知らせがあります。" youHaveUnreadAnnouncements: "未読のお知らせがあります。"
useSecurityKey: "ブラウザまたはデバイスの指示に従って、セキュリティキーまたはパスキーを使用してください。"
_announcement: _announcement:
forExistingUsers: "既存ユーザーのみ" forExistingUsers: "既存ユーザーのみ"
@ -1740,7 +1741,6 @@ _2fa:
securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。" securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。"
registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。" registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。"
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。" securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。"
chromePasskeyNotSupported: "Chromeのパスキーは現在サポートしていません。"
registerSecurityKey: "セキュリティキー・パスキーを登録する" registerSecurityKey: "セキュリティキー・パスキーを登録する"
securityKeyName: "キーの名前を入力" securityKeyName: "キーの名前を入力"
tapSecurityKey: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください" tapSecurityKey: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください"

View File

@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class PasskeySupport1691959191872 {
name = 'PasskeySupport1691959191872'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_security_key" ADD "counter" bigint NOT NULL DEFAULT '0'`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."counter" IS 'The number of times the UserSecurityKey was validated.'`);
await queryRunner.query(`ALTER TABLE "user_security_key" ADD "credentialDeviceType" character varying(32)`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."credentialDeviceType" IS 'The type of Backup Eligibility in authenticator data'`);
await queryRunner.query(`ALTER TABLE "user_security_key" ADD "credentialBackedUp" boolean`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."credentialBackedUp" IS 'Whether or not the credential has been backed up'`);
await queryRunner.query(`ALTER TABLE "user_security_key" ADD "transports" character varying(32) array`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."transports" IS 'The type of the credential returned by the browser'`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."publicKey" IS 'The public key of the UserSecurityKey, hex-encoded.'`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."lastUsed" IS 'Timestamp of the last time the UserSecurityKey was used.'`);
await queryRunner.query(`ALTER TABLE "user_security_key" ALTER COLUMN "lastUsed" SET DEFAULT now()`);
await queryRunner.query(`UPDATE "user_security_key" SET "id" = REPLACE(REPLACE(REPLACE(REPLACE(ENCODE(DECODE("id", 'hex'), 'base64'), E'\\n', ''), '+', '-'), '/', '_'), '=', ''), "publicKey" = REPLACE(REPLACE(REPLACE(REPLACE(ENCODE(DECODE("publicKey", 'hex'), 'base64'), E'\\n', ''), '+', '-'), '/', '_'), '=', '')`);
await queryRunner.query(`ALTER TABLE "attestation_challenge" DROP CONSTRAINT "FK_f1a461a618fa1755692d0e0d592"`);
await queryRunner.query(`DROP INDEX "IDX_47efb914aed1f72dd39a306c7b"`);
await queryRunner.query(`DROP INDEX "IDX_f1a461a618fa1755692d0e0d59"`);
await queryRunner.query(`DROP TABLE "attestation_challenge"`);
}
async down(queryRunner) {
await queryRunner.query(`CREATE TABLE "attestation_challenge" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "challenge" character varying(64) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "registrationChallenge" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_d0ba6786e093f1bcb497572a6b5" PRIMARY KEY ("id", "userId"))`);
await queryRunner.query(`CREATE INDEX "IDX_f1a461a618fa1755692d0e0d59" ON "attestation_challenge" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_47efb914aed1f72dd39a306c7b" ON "attestation_challenge" ("challenge") `);
await queryRunner.query(`ALTER TABLE "attestation_challenge" ADD CONSTRAINT "FK_f1a461a618fa1755692d0e0d592" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`COMMENT ON COLUMN "attestation_challenge"."challenge" IS 'Hex-encoded sha256 hash of the challenge.'`);
await queryRunner.query(`COMMENT ON COLUMN "attestation_challenge"."createdAt" IS 'The date challenge was created for expiry purposes.'`);
await queryRunner.query(`COMMENT ON COLUMN "attestation_challenge"."registrationChallenge" IS 'Indicates that the challenge is only for registration purposes if true to prevent the challenge for being used as authentication.'`);
await queryRunner.query(`UPDATE "user_security_key" SET "id" = ENCODE(DECODE(REPLACE(REPLACE("id" || CASE WHEN LENGTH("id") % 4 = 2 THEN '==' WHEN LENGTH("id") % 4 = 3 THEN '=' ELSE '' END, '-', '+'), '_', '/'), 'base64'), 'hex'), "publicKey" = ENCODE(DECODE(REPLACE(REPLACE("publicKey" || CASE WHEN LENGTH("publicKey") % 4 = 2 THEN '==' WHEN LENGTH("publicKey") % 4 = 3 THEN '=' ELSE '' END, '-', '+'), '_', '/'), 'base64'), 'hex')`);
await queryRunner.query(`ALTER TABLE "user_security_key" ALTER COLUMN "lastUsed" DROP DEFAULT`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."lastUsed" IS 'The date of the last time the UserSecurityKey was successfully validated.'`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."publicKey" IS 'Variable-length public key used to verify attestations (hex-encoded).'`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."transports" IS 'The type of the credential returned by the browser'`);
await queryRunner.query(`ALTER TABLE "user_security_key" DROP COLUMN "transports"`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."credentialBackedUp" IS 'Whether or not the credential has been backed up'`);
await queryRunner.query(`ALTER TABLE "user_security_key" DROP COLUMN "credentialBackedUp"`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."credentialDeviceType" IS 'The type of Backup Eligibility in authenticator data'`);
await queryRunner.query(`ALTER TABLE "user_security_key" DROP COLUMN "credentialDeviceType"`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."counter" IS 'The number of times the UserSecurityKey was validated.'`);
await queryRunner.query(`ALTER TABLE "user_security_key" DROP COLUMN "counter"`);
}
}

View File

@ -75,6 +75,7 @@
"@nestjs/core": "10.2.4", "@nestjs/core": "10.2.4",
"@nestjs/testing": "10.2.4", "@nestjs/testing": "10.2.4",
"@peertube/http-signature": "1.7.0", "@peertube/http-signature": "1.7.0",
"@simplewebauthn/server": "8.1.1",
"@sinonjs/fake-timers": "11.1.0", "@sinonjs/fake-timers": "11.1.0",
"@swc/cli": "0.1.62", "@swc/cli": "0.1.62",
"@swc/core": "1.3.82", "@swc/core": "1.3.82",
@ -170,6 +171,7 @@
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "29.6.4", "@jest/globals": "29.6.4",
"@simplewebauthn/typescript-types": "8.0.0",
"@swc/jest": "0.2.29", "@swc/jest": "0.2.29",
"@types/accepts": "1.3.5", "@types/accepts": "1.3.5",
"@types/archiver": "5.3.2", "@types/archiver": "5.3.2",

View File

@ -8,7 +8,6 @@ import { ChartManagementService } from '@/core/chart/ChartManagementService.js';
import { QueueProcessorService } from '@/queue/QueueProcessorService.js'; import { QueueProcessorService } from '@/queue/QueueProcessorService.js';
import { NestLogger } from '@/NestLogger.js'; import { NestLogger } from '@/NestLogger.js';
import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js'; import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js';
import { JanitorService } from '@/daemons/JanitorService.js';
import { QueueStatsService } from '@/daemons/QueueStatsService.js'; import { QueueStatsService } from '@/daemons/QueueStatsService.js';
import { ServerStatsService } from '@/daemons/ServerStatsService.js'; import { ServerStatsService } from '@/daemons/ServerStatsService.js';
import { ServerService } from '@/server/ServerService.js'; import { ServerService } from '@/server/ServerService.js';
@ -25,7 +24,6 @@ export async function server() {
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
app.get(ChartManagementService).start(); app.get(ChartManagementService).start();
app.get(JanitorService).start();
app.get(QueueStatsService).start(); app.get(QueueStatsService).start();
app.get(ServerStatsService).start(); app.get(ServerStatsService).start();
} }

View File

@ -43,7 +43,7 @@ import { RelayService } from './RelayService.js';
import { RoleService } from './RoleService.js'; import { RoleService } from './RoleService.js';
import { S3Service } from './S3Service.js'; import { S3Service } from './S3Service.js';
import { SignupService } from './SignupService.js'; import { SignupService } from './SignupService.js';
import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js'; import { WebAuthnService } from './WebAuthnService.js';
import { UserBlockingService } from './UserBlockingService.js'; import { UserBlockingService } from './UserBlockingService.js';
import { CacheService } from './CacheService.js'; import { CacheService } from './CacheService.js';
import { UserFollowingService } from './UserFollowingService.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 $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService };
const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service }; const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service };
const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService }; 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 $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService }; const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService }; const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService };
@ -296,7 +296,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
RoleService, RoleService,
S3Service, S3Service,
SignupService, SignupService,
TwoFactorAuthenticationService, WebAuthnService,
UserBlockingService, UserBlockingService,
CacheService, CacheService,
UserFollowingService, UserFollowingService,
@ -417,7 +417,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$RoleService, $RoleService,
$S3Service, $S3Service,
$SignupService, $SignupService,
$TwoFactorAuthenticationService, $WebAuthnService,
$UserBlockingService, $UserBlockingService,
$CacheService, $CacheService,
$UserFollowingService, $UserFollowingService,
@ -539,7 +539,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
RoleService, RoleService,
S3Service, S3Service,
SignupService, SignupService,
TwoFactorAuthenticationService, WebAuthnService,
UserBlockingService, UserBlockingService,
CacheService, CacheService,
UserFollowingService, UserFollowingService,
@ -659,7 +659,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$RoleService, $RoleService,
$S3Service, $S3Service,
$SignupService, $SignupService,
$TwoFactorAuthenticationService, $WebAuthnService,
$UserBlockingService, $UserBlockingService,
$CacheService, $CacheService,
$UserFollowingService, $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 { Module } from '@nestjs/common';
import { CoreModule } from '@/core/CoreModule.js'; import { CoreModule } from '@/core/CoreModule.js';
import { GlobalModule } from '@/GlobalModule.js'; import { GlobalModule } from '@/GlobalModule.js';
import { JanitorService } from './JanitorService.js';
import { QueueStatsService } from './QueueStatsService.js'; import { QueueStatsService } from './QueueStatsService.js';
import { ServerStatsService } from './ServerStatsService.js'; import { ServerStatsService } from './ServerStatsService.js';
@ -16,12 +15,10 @@ import { ServerStatsService } from './ServerStatsService.js';
CoreModule, CoreModule,
], ],
providers: [ providers: [
JanitorService,
QueueStatsService, QueueStatsService,
ServerStatsService, ServerStatsService,
], ],
exports: [ exports: [
JanitorService,
QueueStatsService, QueueStatsService,
ServerStatsService, 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'), userProfilesRepository: Symbol('userProfilesRepository'),
userKeypairsRepository: Symbol('userKeypairsRepository'), userKeypairsRepository: Symbol('userKeypairsRepository'),
userPendingsRepository: Symbol('userPendingsRepository'), userPendingsRepository: Symbol('userPendingsRepository'),
attestationChallengesRepository: Symbol('attestationChallengesRepository'),
userSecurityKeysRepository: Symbol('userSecurityKeysRepository'), userSecurityKeysRepository: Symbol('userSecurityKeysRepository'),
userPublickeysRepository: Symbol('userPublickeysRepository'), userPublickeysRepository: Symbol('userPublickeysRepository'),
userListsRepository: Symbol('userListsRepository'), userListsRepository: Symbol('userListsRepository'),

View File

@ -5,7 +5,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; 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 { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common'; import type { Provider } from '@nestjs/common';
@ -93,12 +93,6 @@ const $userPendingsRepository: Provider = {
inject: [DI.db], inject: [DI.db],
}; };
const $attestationChallengesRepository: Provider = {
provide: DI.attestationChallengesRepository,
useFactory: (db: DataSource) => db.getRepository(MiAttestationChallenge),
inject: [DI.db],
};
const $userSecurityKeysRepository: Provider = { const $userSecurityKeysRepository: Provider = {
provide: DI.userSecurityKeysRepository, provide: DI.userSecurityKeysRepository,
useFactory: (db: DataSource) => db.getRepository(MiUserSecurityKey), useFactory: (db: DataSource) => db.getRepository(MiUserSecurityKey),
@ -423,7 +417,6 @@ const $userMemosRepository: Provider = {
$userProfilesRepository, $userProfilesRepository,
$userKeypairsRepository, $userKeypairsRepository,
$userPendingsRepository, $userPendingsRepository,
$attestationChallengesRepository,
$userSecurityKeysRepository, $userSecurityKeysRepository,
$userPublickeysRepository, $userPublickeysRepository,
$userListsRepository, $userListsRepository,
@ -491,7 +484,6 @@ const $userMemosRepository: Provider = {
$userProfilesRepository, $userProfilesRepository,
$userKeypairsRepository, $userKeypairsRepository,
$userPendingsRepository, $userPendingsRepository,
$attestationChallengesRepository,
$userSecurityKeysRepository, $userSecurityKeysRepository,
$userPublickeysRepository, $userPublickeysRepository,
$userListsRepository, $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() @JoinColumn()
public user: MiUser | null; 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', { @Column('varchar', {
comment: 'User-defined name for this key', comment: 'User-defined name for this key',
length: 30, length: 30,
}) })
public name: string; 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>) { constructor(data: Partial<MiUserSecurityKey>) {
if (data == null) return; 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 { MiAnnouncementRead } from '@/models/entities/AnnouncementRead.js';
import { MiAntenna } from '@/models/entities/Antenna.js'; import { MiAntenna } from '@/models/entities/Antenna.js';
import { MiApp } from '@/models/entities/App.js'; import { MiApp } from '@/models/entities/App.js';
import { MiAttestationChallenge } from '@/models/entities/AttestationChallenge.js';
import { MiAuthSession } from '@/models/entities/AuthSession.js'; import { MiAuthSession } from '@/models/entities/AuthSession.js';
import { MiBlocking } from '@/models/entities/Blocking.js'; import { MiBlocking } from '@/models/entities/Blocking.js';
import { MiChannelFollowing } from '@/models/entities/ChannelFollowing.js'; import { MiChannelFollowing } from '@/models/entities/ChannelFollowing.js';
@ -79,7 +78,6 @@ export {
MiAnnouncementRead, MiAnnouncementRead,
MiAntenna, MiAntenna,
MiApp, MiApp,
MiAttestationChallenge,
MiAuthSession, MiAuthSession,
MiBlocking, MiBlocking,
MiChannelFollowing, MiChannelFollowing,
@ -147,7 +145,6 @@ export type AnnouncementsRepository = Repository<MiAnnouncement>;
export type AnnouncementReadsRepository = Repository<MiAnnouncementRead>; export type AnnouncementReadsRepository = Repository<MiAnnouncementRead>;
export type AntennasRepository = Repository<MiAntenna>; export type AntennasRepository = Repository<MiAntenna>;
export type AppsRepository = Repository<MiApp>; export type AppsRepository = Repository<MiApp>;
export type AttestationChallengesRepository = Repository<MiAttestationChallenge>;
export type AuthSessionsRepository = Repository<MiAuthSession>; export type AuthSessionsRepository = Repository<MiAuthSession>;
export type BlockingsRepository = Repository<MiBlocking>; export type BlockingsRepository = Repository<MiBlocking>;
export type ChannelFollowingsRepository = Repository<MiChannelFollowing>; 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 { MiAnnouncementRead } from '@/models/entities/AnnouncementRead.js';
import { MiAntenna } from '@/models/entities/Antenna.js'; import { MiAntenna } from '@/models/entities/Antenna.js';
import { MiApp } from '@/models/entities/App.js'; import { MiApp } from '@/models/entities/App.js';
import { MiAttestationChallenge } from '@/models/entities/AttestationChallenge.js';
import { MiAuthSession } from '@/models/entities/AuthSession.js'; import { MiAuthSession } from '@/models/entities/AuthSession.js';
import { MiBlocking } from '@/models/entities/Blocking.js'; import { MiBlocking } from '@/models/entities/Blocking.js';
import { MiChannelFollowing } from '@/models/entities/ChannelFollowing.js'; import { MiChannelFollowing } from '@/models/entities/ChannelFollowing.js';
@ -143,7 +142,6 @@ export const entities = [
MiUserNotePining, MiUserNotePining,
MiUserSecurityKey, MiUserSecurityKey,
MiUsedUsername, MiUsedUsername,
MiAttestationChallenge,
MiFollowing, MiFollowing,
MiFollowRequest, MiFollowRequest,
MiMuting, MiMuting,

View File

@ -3,22 +3,26 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { randomBytes } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import * as OTPAuth from 'otpauth'; import * as OTPAuth from 'otpauth';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js'; 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 type { Config } from '@/config.js';
import { getIpHash } from '@/misc/get-ip-hash.js'; import { getIpHash } from '@/misc/get-ip-hash.js';
import type { MiLocalUser } from '@/models/entities/User.js'; import type { MiLocalUser } from '@/models/entities/User.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { WebAuthnService } from '@/core/WebAuthnService.js';
import { RateLimiterService } from './RateLimiterService.js'; import { RateLimiterService } from './RateLimiterService.js';
import { SigninService } from './SigninService.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() @Injectable()
export class SigninApiService { export class SigninApiService {
@ -29,22 +33,16 @@ export class SigninApiService {
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@Inject(DI.userSecurityKeysRepository)
private userSecurityKeysRepository: UserSecurityKeysRepository,
@Inject(DI.userProfilesRepository) @Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository, private userProfilesRepository: UserProfilesRepository,
@Inject(DI.attestationChallengesRepository)
private attestationChallengesRepository: AttestationChallengesRepository,
@Inject(DI.signinsRepository) @Inject(DI.signinsRepository)
private signinsRepository: SigninsRepository, private signinsRepository: SigninsRepository,
private idService: IdService, private idService: IdService,
private rateLimiterService: RateLimiterService, private rateLimiterService: RateLimiterService,
private signinService: SigninService, private signinService: SigninService,
private twoFactorAuthenticationService: TwoFactorAuthenticationService, private webAuthnService: WebAuthnService,
) { ) {
} }
@ -55,11 +53,7 @@ export class SigninApiService {
username: string; username: string;
password: string; password: string;
token?: string; token?: string;
signature?: string; credential?: AuthenticationResponseJSON;
authenticatorData?: string;
clientDataJSON?: string;
credentialId?: string;
challengeId?: string;
}; };
}>, }>,
reply: FastifyReply, reply: FastifyReply,
@ -181,64 +175,16 @@ export class SigninApiService {
} else { } else {
return this.signinService.signin(request, reply, user); 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) { if (!same && !profile.usePasswordLessLogin) {
return await fail(403, { return await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
}); });
} }
const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex'); const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential);
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) { if (authorized) {
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) {
return this.signinService.signin(request, reply, user); return this.signinService.signin(request, reply, user);
} else { } else {
return await fail(403, { return await fail(403, {
@ -252,42 +198,11 @@ export class SigninApiService {
}); });
} }
const keys = await this.userSecurityKeysRepository.findBy({ const authRequest = await this.webAuthnService.initiateAuthentication(user.id);
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,
});
reply.code(200); reply.code(200);
return { return authRequest;
challenge,
challengeId,
securityKeys: keys.map(key => ({
id: key.id,
})),
};
} }
// never get here // never get here
} }
} }

View File

@ -3,155 +3,86 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { promisify } from 'node:util';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import cbor from 'cbor';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js'; import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
import type { AttestationChallengesRepository, UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js'; import { WebAuthnService } from '@/core/WebAuthnService.js';
import { ApiError } from '@/server/api/error.js';
const cborDecodeFirst = promisify(cbor.decodeFirst) as any;
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,
secure: 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; } as const;
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
clientDataJSON: { type: 'string' },
attestationObject: { type: 'string' },
password: { type: 'string' }, password: { type: 'string' },
challengeId: { type: 'string' },
name: { type: 'string', minLength: 1, maxLength: 30 }, name: { type: 'string', minLength: 1, maxLength: 30 },
credential: { type: 'object' },
}, },
required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'], required: ['password', 'name', 'credential'],
} as const; } as const;
// eslint-disable-next-line import/no-default-export
@Injectable() @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( constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.userProfilesRepository) @Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository, private userProfilesRepository: UserProfilesRepository,
@Inject(DI.userSecurityKeysRepository) @Inject(DI.userSecurityKeysRepository)
private userSecurityKeysRepository: UserSecurityKeysRepository, private userSecurityKeysRepository: UserSecurityKeysRepository,
@Inject(DI.attestationChallengesRepository) private webAuthnService: WebAuthnService,
private attestationChallengesRepository: AttestationChallengesRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private twoFactorAuthenticationService: TwoFactorAuthenticationService,
) { ) {
super(meta, paramDef, async (ps, me) => { 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 }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
// Compare password // Compare password
const same = await bcrypt.compare(ps.password, profile.password!); const same = await bcrypt.compare(ps.password, profile.password ?? '');
if (!same) { if (!same) {
throw new Error('incorrect password'); throw new ApiError(meta.errors.incorrectPassword);
} }
if (!profile.twoFactorEnabled) { if (!profile.twoFactorEnabled) {
throw new Error('2fa not enabled'); throw new ApiError(meta.errors.twoFactorNotEnabled);
} }
const clientData = JSON.parse(ps.clientDataJSON); const keyInfo = await this.webAuthnService.verifyRegistration(me.id, ps.credential);
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 credentialId = Buffer.from(keyInfo.credentialID).toString('base64url');
await this.userSecurityKeysRepository.insert({ await this.userSecurityKeysRepository.insert({
id: credentialId,
userId: me.id, userId: me.id,
id: credentialIdString,
lastUsed: new Date(),
name: ps.name, 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 // Publish meUpdated event
@ -161,7 +92,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
})); }));
return { return {
id: credentialIdString, id: credentialId,
name: ps.name, name: ps.name,
}; };
}); });

View File

@ -3,22 +3,38 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { promisify } from 'node:util';
import * as crypto from 'node:crypto';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UserProfilesRepository, AttestationChallengesRepository } from '@/models/index.js'; import type { UserProfilesRepository } from '@/models/index.js';
import { IdService } from '@/core/IdService.js';
import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { WebAuthnService } from '@/core/WebAuthnService.js';
const randomBytes = promisify(crypto.randomBytes); import { ApiError } from '@/server/api/error.js';
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,
secure: 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; } as const;
export const paramDef = { export const paramDef = {
@ -29,53 +45,43 @@ export const paramDef = {
required: ['password'], required: ['password'],
} as const; } as const;
// eslint-disable-next-line import/no-default-export
@Injectable() @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( constructor(
@Inject(DI.userProfilesRepository) @Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository, private userProfilesRepository: UserProfilesRepository,
@Inject(DI.attestationChallengesRepository) private webAuthnService: WebAuthnService,
private attestationChallengesRepository: AttestationChallengesRepository,
private idService: IdService,
private twoFactorAuthenticationService: TwoFactorAuthenticationService,
) { ) {
super(meta, paramDef, async (ps, me) => { 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 // Compare password
const same = await bcrypt.compare(ps.password, profile.password!); const same = await bcrypt.compare(ps.password, profile.password ?? '');
if (!same) { if (!same) {
throw new Error('incorrect password'); throw new ApiError(meta.errors.incorrectPassword);
} }
if (!profile.twoFactorEnabled) { if (!profile.twoFactorEnabled) {
throw new Error('2fa not enabled'); throw new ApiError(meta.errors.twoFactorNotEnabled);
} }
// 32 byte challenge return await this.webAuthnService.initiateRegistration(
const entropy = await randomBytes(32); me.id,
const challenge = entropy.toString('base64') profile.user?.username ?? me.id,
.replace(/=/g, '') profile.user?.name ?? undefined,
.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,
};
}); });
} }
} }

View File

@ -11,11 +11,20 @@ import type { UserProfilesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { ApiError } from '@/server/api/error.js';
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,
secure: true, secure: true,
errors: {
incorrectPassword: {
message: 'Incorrect password.',
code: 'INCORRECT_PASSWORD',
id: '78d6c839-20c9-4c66-b90a-fc0542168b48',
},
},
} as const; } as const;
export const paramDef = { 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 }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
// Compare password // Compare password
const same = await bcrypt.compare(ps.password, profile.password!); const same = await bcrypt.compare(ps.password, profile.password ?? '');
if (!same) { if (!same) {
throw new Error('incorrect password'); throw new ApiError(meta.errors.incorrectPassword);
} }
// Generate user's secret key // 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 { UserEntityService } from '@/core/entities/UserEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,
secure: true, secure: true,
errors: {
incorrectPassword: {
message: 'Incorrect password.',
code: 'INCORRECT_PASSWORD',
id: '141c598d-a825-44c8-9173-cfb9d92be493',
},
},
} as const; } as const;
export const paramDef = { 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 }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
// Compare password // Compare password
const same = await bcrypt.compare(ps.password, profile.password!); const same = await bcrypt.compare(ps.password, profile.password ?? '');
if (!same) { if (!same) {
throw new Error('incorrect password'); throw new ApiError(meta.errors.incorrectPassword);
} }
// Make sure we only delete the user's own creds // 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 type { UserProfilesRepository } from '@/models/index.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,
secure: true, secure: true,
errors: {
incorrectPassword: {
message: 'Incorrect password.',
code: 'INCORRECT_PASSWORD',
id: '7add0395-9901-4098-82f9-4f67af65f775',
},
},
} as const; } as const;
export const paramDef = { 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 }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
// Compare password // Compare password
const same = await bcrypt.compare(ps.password, profile.password!); const same = await bcrypt.compare(ps.password, profile.password ?? '');
if (!same) { if (!same) {
throw new Error('incorrect password'); throw new ApiError(meta.errors.incorrectPassword);
} }
await this.userProfilesRepository.update(me.id, { await this.userProfilesRepository.update(me.id, {

View File

@ -25,7 +25,7 @@ export const meta = {
}, },
accessDenied: { accessDenied: {
message: 'You do not have edit privilege of the channel.', message: 'You do not have edit privilege of this key.',
code: 'ACCESS_DENIED', code: 'ACCESS_DENIED',
id: '1fb7cb09-d46a-4fff-b8df-057708cce513', id: '1fb7cb09-d46a-4fff-b8df-057708cce513',
}, },

View File

@ -9,8 +9,16 @@ import * as assert from 'assert';
import * as crypto from 'node:crypto'; import * as crypto from 'node:crypto';
import cbor from 'cbor'; import cbor from 'cbor';
import * as OTPAuth from 'otpauth'; import * as OTPAuth from 'otpauth';
import { loadConfig } from '../../src/config.js'; import { loadConfig } from '@/config.js';
import { signup, api, post, react, startServer, waitFire } from '../utils.js'; import { api, signup, startServer } from '../utils.js';
import type {
AuthenticationResponseJSON,
AuthenticatorAssertionResponseJSON,
AuthenticatorAttestationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/typescript-types';
import type { INestApplicationContext } from '@nestjs/common'; import type { INestApplicationContext } from '@nestjs/common';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
@ -47,21 +55,18 @@ describe('2要素認証', () => {
const rpIdHash = (): Buffer => { const rpIdHash = (): Buffer => {
return crypto.createHash('sha256') return crypto.createHash('sha256')
.update(Buffer.from(config.hostname, 'utf-8')) .update(Buffer.from(config.host, 'utf-8'))
.digest(); .digest();
}; };
const keyDoneParam = (param: { const keyDoneParam = (param: {
keyName: string, keyName: string,
challengeId: string,
challenge: string,
credentialId: Buffer, credentialId: Buffer,
creationOptions: PublicKeyCredentialCreationOptionsJSON,
}): { }): {
attestationObject: string,
challengeId: string,
clientDataJSON: string,
password: string, password: string,
name: string, name: string,
credential: RegistrationResponseJSON,
} => { } => {
// A COSE encoded public key // A COSE encoded public key
const credentialPublicKey = cbor.encode(new Map<number, unknown>([ const credentialPublicKey = cbor.encode(new Map<number, unknown>([
@ -76,7 +81,7 @@ describe('2要素認証', () => {
// AuthenticatorAssertionResponse.authenticatorData // AuthenticatorAssertionResponse.authenticatorData
// https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData
const credentialIdLength = Buffer.allocUnsafe(2); const credentialIdLength = Buffer.allocUnsafe(2);
credentialIdLength.writeUInt16BE(param.credentialId.length); credentialIdLength.writeUInt16BE(param.credentialId.length, 0);
const authData = Buffer.concat([ const authData = Buffer.concat([
rpIdHash(), // rpIdHash(32) rpIdHash(), // rpIdHash(32)
Buffer.from([0x45]), // flags(1) Buffer.from([0x45]), // flags(1)
@ -88,20 +93,27 @@ describe('2要素認証', () => {
]); ]);
return { return {
attestationObject: cbor.encode({
fmt: 'none',
attStmt: {},
authData,
}).toString('hex'),
challengeId: param.challengeId,
clientDataJSON: JSON.stringify({
type: 'webauthn.create',
challenge: param.challenge,
origin: config.scheme + '://' + config.host,
androidPackageName: 'org.mozilla.firefox',
}),
password, password,
name: param.keyName, name: param.keyName,
credential: <RegistrationResponseJSON>{
id: param.credentialId.toString('base64url'),
rawId: param.credentialId.toString('base64url'),
response: <AuthenticatorAttestationResponseJSON>{
clientDataJSON: Buffer.from(JSON.stringify({
type: 'webauthn.create',
challenge: param.creationOptions.challenge,
origin: config.scheme + '://' + config.host,
androidPackageName: 'org.mozilla.firefox',
}), 'utf-8').toString('base64url'),
attestationObject: cbor.encode({
fmt: 'none',
attStmt: {},
authData,
}).toString('base64url'),
},
clientExtensionResults: {},
type: 'public-key',
},
}; };
}; };
@ -121,17 +133,12 @@ describe('2要素認証', () => {
const signinWithSecurityKeyParam = (param: { const signinWithSecurityKeyParam = (param: {
keyName: string, keyName: string,
challengeId: string,
challenge: string,
credentialId: Buffer, credentialId: Buffer,
requestOptions: PublicKeyCredentialRequestOptionsJSON,
}): { }): {
authenticatorData: string,
credentialId: string,
challengeId: string,
clientDataJSON: string,
username: string, username: string,
password: string, password: string,
signature: string, credential: AuthenticationResponseJSON,
'g-recaptcha-response'?: string | null, 'g-recaptcha-response'?: string | null,
'hcaptcha-response'?: string | null, 'hcaptcha-response'?: string | null,
} => { } => {
@ -144,10 +151,10 @@ describe('2要素認証', () => {
]); ]);
const clientDataJSONBuffer = Buffer.from(JSON.stringify({ const clientDataJSONBuffer = Buffer.from(JSON.stringify({
type: 'webauthn.get', type: 'webauthn.get',
challenge: param.challenge, challenge: param.requestOptions.challenge,
origin: config.scheme + '://' + config.host, origin: config.scheme + '://' + config.host,
androidPackageName: 'org.mozilla.firefox', androidPackageName: 'org.mozilla.firefox',
})); }), 'utf-8');
const hashedclientDataJSON = crypto.createHash('sha256') const hashedclientDataJSON = crypto.createHash('sha256')
.update(clientDataJSONBuffer) .update(clientDataJSONBuffer)
.digest(); .digest();
@ -156,13 +163,19 @@ describe('2要素認証', () => {
.update(Buffer.concat([authenticatorData, hashedclientDataJSON])) .update(Buffer.concat([authenticatorData, hashedclientDataJSON]))
.sign(privateKey); .sign(privateKey);
return { return {
authenticatorData: authenticatorData.toString('hex'),
credentialId: param.credentialId.toString('base64'),
challengeId: param.challengeId,
clientDataJSON: clientDataJSONBuffer.toString('hex'),
username, username,
password, password,
signature: signature.toString('hex'), credential: <AuthenticationResponseJSON>{
id: param.credentialId.toString('base64url'),
rawId: param.credentialId.toString('base64url'),
response: <AuthenticatorAssertionResponseJSON>{
clientDataJSON: clientDataJSONBuffer.toString('base64url'),
authenticatorData: authenticatorData.toString('base64url'),
signature: signature.toString('base64url'),
},
clientExtensionResults: {},
type: 'public-key',
},
'g-recaptcha-response': null, 'g-recaptcha-response': null,
'hcaptcha-response': null, 'hcaptcha-response': null,
}; };
@ -222,19 +235,18 @@ describe('2要素認証', () => {
password, password,
}, alice); }, alice);
assert.strictEqual(registerKeyResponse.status, 200); assert.strictEqual(registerKeyResponse.status, 200);
assert.notEqual(registerKeyResponse.body.challengeId, undefined); assert.notEqual(registerKeyResponse.body.rp, undefined);
assert.notEqual(registerKeyResponse.body.challenge, undefined); assert.notEqual(registerKeyResponse.body.challenge, undefined);
const keyName = 'example-key'; const keyName = 'example-key';
const credentialId = crypto.randomBytes(0x41); const credentialId = crypto.randomBytes(0x41);
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
keyName, keyName,
challengeId: registerKeyResponse.body.challengeId,
challenge: registerKeyResponse.body.challenge,
credentialId, credentialId,
creationOptions: registerKeyResponse.body,
}), alice); }), alice);
assert.strictEqual(keyDoneResponse.status, 200); assert.strictEqual(keyDoneResponse.status, 200);
assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('hex')); assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url'));
assert.strictEqual(keyDoneResponse.body.name, keyName); assert.strictEqual(keyDoneResponse.body.name, keyName);
const usersShowResponse = await api('/users/show', { const usersShowResponse = await api('/users/show', {
@ -248,16 +260,14 @@ describe('2要素認証', () => {
}); });
assert.strictEqual(signinResponse.status, 200); assert.strictEqual(signinResponse.status, 200);
assert.strictEqual(signinResponse.body.i, undefined); assert.strictEqual(signinResponse.body.i, undefined);
assert.notEqual(signinResponse.body.challengeId, undefined);
assert.notEqual(signinResponse.body.challenge, undefined); assert.notEqual(signinResponse.body.challenge, undefined);
assert.notEqual(signinResponse.body.securityKeys, undefined); assert.notEqual(signinResponse.body.allowCredentials, undefined);
assert.strictEqual(signinResponse.body.securityKeys[0].id, credentialId.toString('hex')); assert.strictEqual(signinResponse.body.allowCredentials[0].id, credentialId.toString('base64url'));
const signinResponse2 = await api('/signin', signinWithSecurityKeyParam({ const signinResponse2 = await api('/signin', signinWithSecurityKeyParam({
keyName, keyName,
challengeId: signinResponse.body.challengeId,
challenge: signinResponse.body.challenge,
credentialId, credentialId,
requestOptions: signinResponse.body,
})); }));
assert.strictEqual(signinResponse2.status, 200); assert.strictEqual(signinResponse2.status, 200);
assert.notEqual(signinResponse2.body.i, undefined); assert.notEqual(signinResponse2.body.i, undefined);
@ -283,9 +293,8 @@ describe('2要素認証', () => {
const credentialId = crypto.randomBytes(0x41); const credentialId = crypto.randomBytes(0x41);
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
keyName, keyName,
challengeId: registerKeyResponse.body.challengeId,
challenge: registerKeyResponse.body.challenge,
credentialId, credentialId,
creationOptions: registerKeyResponse.body,
}), alice); }), alice);
assert.strictEqual(keyDoneResponse.status, 200); assert.strictEqual(keyDoneResponse.status, 200);
@ -310,9 +319,8 @@ describe('2要素認証', () => {
const signinResponse2 = await api('/signin', { const signinResponse2 = await api('/signin', {
...signinWithSecurityKeyParam({ ...signinWithSecurityKeyParam({
keyName, keyName,
challengeId: signinResponse.body.challengeId,
challenge: signinResponse.body.challenge,
credentialId, credentialId,
requestOptions: signinResponse.body,
}), }),
password: '', password: '',
}); });
@ -340,23 +348,22 @@ describe('2要素認証', () => {
const credentialId = crypto.randomBytes(0x41); const credentialId = crypto.randomBytes(0x41);
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
keyName, keyName,
challengeId: registerKeyResponse.body.challengeId,
challenge: registerKeyResponse.body.challenge,
credentialId, credentialId,
creationOptions: registerKeyResponse.body,
}), alice); }), alice);
assert.strictEqual(keyDoneResponse.status, 200); assert.strictEqual(keyDoneResponse.status, 200);
const renamedKey = 'other-key'; const renamedKey = 'other-key';
const updateKeyResponse = await api('/i/2fa/update-key', { const updateKeyResponse = await api('/i/2fa/update-key', {
name: renamedKey, name: renamedKey,
credentialId: credentialId.toString('hex'), credentialId: credentialId.toString('base64url'),
}, alice); }, alice);
assert.strictEqual(updateKeyResponse.status, 200); assert.strictEqual(updateKeyResponse.status, 200);
const iResponse = await api('/i', { const iResponse = await api('/i', {
}, alice); }, alice);
assert.strictEqual(iResponse.status, 200); assert.strictEqual(iResponse.status, 200);
const securityKeys = iResponse.body.securityKeysList.filter(s => s.id === credentialId.toString('hex')); const securityKeys = iResponse.body.securityKeysList.filter((s: { id: string; }) => s.id === credentialId.toString('base64url'));
assert.strictEqual(securityKeys.length, 1); assert.strictEqual(securityKeys.length, 1);
assert.strictEqual(securityKeys[0].name, renamedKey); assert.strictEqual(securityKeys[0].name, renamedKey);
assert.notEqual(securityKeys[0].lastUsed, undefined); assert.notEqual(securityKeys[0].lastUsed, undefined);
@ -382,9 +389,8 @@ describe('2要素認証', () => {
const credentialId = crypto.randomBytes(0x41); const credentialId = crypto.randomBytes(0x41);
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
keyName, keyName,
challengeId: registerKeyResponse.body.challengeId,
challenge: registerKeyResponse.body.challenge,
credentialId, credentialId,
creationOptions: registerKeyResponse.body,
}), alice); }), alice);
assert.strictEqual(keyDoneResponse.status, 200); assert.strictEqual(keyDoneResponse.status, 200);

View File

@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@discordapp/twemoji": "14.1.2", "@discordapp/twemoji": "14.1.2",
"@github/webauthn-json": "2.1.1",
"@rollup/plugin-alias": "5.0.0", "@rollup/plugin-alias": "5.0.0",
"@rollup/plugin-json": "6.0.0", "@rollup/plugin-json": "6.0.0",
"@rollup/plugin-replace": "5.0.2", "@rollup/plugin-replace": "5.0.2",

View File

@ -6,16 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<form :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> <form :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
<div class="_gaps_m"> <div class="_gaps_m">
<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null, marginBottom: message ? '1.5em' : null }"></div> <div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div>
<MkInfo v-if="message"> <MkInfo v-if="message">
{{ message }} {{ message }}
</MkInfo> </MkInfo>
<div v-if="!totpLogin" class="normal-signin _gaps_m"> <div v-if="!totpLogin" class="normal-signin _gaps_m">
<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange"> <MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
<template #prefix>@</template> <template #prefix>@</template>
<template #suffix>@{{ host }}</template> <template #suffix>@{{ host }}</template>
</MkInput> </MkInput>
<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password" :withPasswordToggle="true" required data-cy-signin-password> <MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true" required data-cy-signin-password>
<template #prefix><i class="ti ti-lock"></i></template> <template #prefix><i class="ti ti-lock"></i></template>
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template> <template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
</MkInput> </MkInput>
@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }"> <div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }">
<div v-if="user && user.securityKeys" class="twofa-group tap-group"> <div v-if="user && user.securityKeys" class="twofa-group tap-group">
<p>{{ i18n.ts.tapSecurityKey }}</p> <p>{{ i18n.ts.useSecurityKey }}</p>
<MkButton v-if="!queryingKey" @click="queryKey"> <MkButton v-if="!queryingKey" @click="queryKey">
{{ i18n.ts.retry }} {{ i18n.ts.retry }}
</MkButton> </MkButton>
@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<p class="or-msg">{{ i18n.ts.or }}</p> <p class="or-msg">{{ i18n.ts.or }}</p>
</div> </div>
<div class="twofa-group totp-group"> <div class="twofa-group totp-group">
<p style="margin-bottom:0;">{{ i18n.ts.twoStepAuthentication }}</p> <p style="margin-bottom:0;">{{ i18n.ts['2fa'] }}</p>
<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required> <MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required>
<template #label>{{ i18n.ts.password }}</template> <template #label>{{ i18n.ts.password }}</template>
<template #prefix><i class="ti ti-lock"></i></template> <template #prefix><i class="ti ti-lock"></i></template>
@ -51,32 +51,29 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent } from 'vue'; import { defineAsyncComponent } from 'vue';
import { toUnicode } from 'punycode/'; import { toUnicode } from 'punycode/';
import { showSuspendedDialog } from '../scripts/show-suspended-dialog'; import { UserDetailed } from 'misskey-js/built/entities';
import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import { host as configHost } from '@/config'; import { host as configHost } from '@/config';
import { byteify, hexify } from '@/scripts/2fa';
import * as os from '@/os'; import * as os from '@/os';
import { login } from '@/account'; import { login } from '@/account';
import { instance } from '@/instance';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
let signing = $ref(false); let signing = $ref(false);
let user = $ref(null); let user = $ref<UserDetailed | null>(null);
let username = $ref(''); let username = $ref('');
let password = $ref(''); let password = $ref('');
let token = $ref(''); let token = $ref('');
let host = $ref(toUnicode(configHost)); let host = $ref(toUnicode(configHost));
let totpLogin = $ref(false); let totpLogin = $ref(false);
let credential = $ref(null);
let challengeData = $ref(null);
let queryingKey = $ref(false); let queryingKey = $ref(false);
let credentialRequest = $ref<CredentialRequestOptions | null>(null);
let hCaptchaResponse = $ref(null); let hCaptchaResponse = $ref(null);
let reCaptchaResponse = $ref(null); let reCaptchaResponse = $ref(null);
const meta = $computed(() => instance);
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'login', v: any): void; (ev: 'login', v: any): void;
}>(); }>();
@ -99,7 +96,7 @@ const props = defineProps({
}, },
}); });
function onUsernameChange() { function onUsernameChange(): void {
os.api('users/show', { os.api('users/show', {
username: username, username: username,
}).then(userResponse => { }).then(userResponse => {
@ -109,58 +106,46 @@ function onUsernameChange() {
}); });
} }
function onLogin(res) { function onLogin(res: any): Promise<void> | void {
if (props.autoSet) { if (props.autoSet) {
return login(res.i); return login(res.i);
} }
} }
function queryKey() { async function queryKey(): Promise<void> {
queryingKey = true; queryingKey = true;
return navigator.credentials.get({ await webAuthnRequest(credentialRequest)
publicKey: { .catch(() => {
challenge: byteify(challengeData.challenge, 'base64'), queryingKey = false;
allowCredentials: challengeData.securityKeys.map(key => ({ return Promise.reject(null);
id: byteify(key.id, 'hex'), }).then(credential => {
type: 'public-key', credentialRequest = null;
transports: ['usb', 'nfc', 'ble', 'internal'], queryingKey = false;
})), signing = true;
timeout: 60 * 1000, return os.api('signin', {
}, username,
}).catch(() => { password,
queryingKey = false; credential: credential.toJSON(),
return Promise.reject(null); 'hcaptcha-response': hCaptchaResponse,
}).then(credential => { 'g-recaptcha-response': reCaptchaResponse,
queryingKey = false; });
signing = true; }).then(res => {
return os.api('signin', { emit('login', res);
username, return onLogin(res);
password, }).catch(err => {
signature: hexify(credential.response.signature), if (err === null) return;
authenticatorData: hexify(credential.response.authenticatorData), os.alert({
clientDataJSON: hexify(credential.response.clientDataJSON), type: 'error',
credentialId: credential.id, text: i18n.ts.signinFailed,
challengeId: challengeData.challengeId, });
'hcaptcha-response': hCaptchaResponse, signing = false;
'g-recaptcha-response': reCaptchaResponse,
}); });
}).then(res => {
emit('login', res);
return onLogin(res);
}).catch(err => {
if (err === null) return;
os.alert({
type: 'error',
text: i18n.ts.signinFailed,
});
signing = false;
});
} }
function onSubmit() { function onSubmit(): void {
signing = true; signing = true;
if (!totpLogin && user && user.twoFactorEnabled) { if (!totpLogin && user && user.twoFactorEnabled) {
if (window.PublicKeyCredential && user.securityKeys) { if (webAuthnSupported() && user.securityKeys) {
os.api('signin', { os.api('signin', {
username, username,
password, password,
@ -169,9 +154,12 @@ function onSubmit() {
}).then(res => { }).then(res => {
totpLogin = true; totpLogin = true;
signing = false; signing = false;
challengeData = res; credentialRequest = parseRequestOptionsFromJSON({
return queryKey(); publicKey: res,
}).catch(loginFailed); });
})
.then(() => queryKey())
.catch(loginFailed);
} else { } else {
totpLogin = true; totpLogin = true;
signing = false; signing = false;
@ -182,7 +170,7 @@ function onSubmit() {
password, password,
'hcaptcha-response': hCaptchaResponse, 'hcaptcha-response': hCaptchaResponse,
'g-recaptcha-response': reCaptchaResponse, 'g-recaptcha-response': reCaptchaResponse,
token: user && user.twoFactorEnabled ? token : undefined, token: user?.twoFactorEnabled ? token : undefined,
}).then(res => { }).then(res => {
emit('login', res); emit('login', res);
onLogin(res); onLogin(res);
@ -190,7 +178,7 @@ function onSubmit() {
} }
} }
function loginFailed(err) { function loginFailed(err: any): void {
switch (err.id) { switch (err.id) {
case '6cc579cc-885d-43d8-95c2-b8c7fc963280': { case '6cc579cc-885d-43d8-95c2-b8c7fc963280': {
os.alert({ os.alert({
@ -221,7 +209,7 @@ function loginFailed(err) {
break; break;
} }
default: { default: {
console.log(err); console.error(err);
os.alert({ os.alert({
type: 'error', type: 'error',
title: i18n.ts.loginFailed, title: i18n.ts.loginFailed,
@ -230,12 +218,11 @@ function loginFailed(err) {
} }
} }
challengeData = null;
totpLogin = false; totpLogin = false;
signing = false; signing = false;
} }
function resetPassword() { function resetPassword(): void {
os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, { os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
}, 'closed'); }, 'closed');
} }

View File

@ -38,16 +38,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.securityKeyAndPasskey }}</template> <template #label>{{ i18n.ts.securityKeyAndPasskey }}</template>
<div class="_gaps_s"> <div class="_gaps_s">
<MkInfo> <MkInfo>
{{ i18n.ts._2fa.securityKeyInfo }}<br> {{ i18n.ts._2fa.securityKeyInfo }}
<br>
{{ i18n.ts._2fa.chromePasskeyNotSupported }}
</MkInfo> </MkInfo>
<MkInfo v-if="!supportsCredentials" warn> <MkInfo v-if="!webAuthnSupported()" warn>
{{ i18n.ts._2fa.securityKeyNotSupported }} {{ i18n.ts._2fa.securityKeyNotSupported }}
</MkInfo> </MkInfo>
<MkInfo v-else-if="supportsCredentials && !$i.twoFactorEnabled" warn> <MkInfo v-else-if="webAuthnSupported() && !$i.twoFactorEnabled" warn>
{{ i18n.ts._2fa.registerTOTPBeforeKey }} {{ i18n.ts._2fa.registerTOTPBeforeKey }}
</MkInfo> </MkInfo>
@ -75,8 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { ref, defineAsyncComponent } from 'vue'; import { ref, defineAsyncComponent } from 'vue';
import { hostname } from '@/config'; import { supported as webAuthnSupported, create as webAuthnCreate, parseCreationOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
import { byteify, hexify, stringify } from '@/scripts/2fa';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
@ -94,10 +91,9 @@ withDefaults(defineProps<{
first: false, first: false,
}); });
const supportsCredentials = ref(!!navigator.credentials); const usePasswordLessLogin = $computed(() => $i?.usePasswordLessLogin ?? false);
const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin);
async function registerTOTP() { async function registerTOTP(): Promise<void> {
const password = await os.inputText({ const password = await os.inputText({
title: i18n.ts._2fa.registerTOTP, title: i18n.ts._2fa.registerTOTP,
text: i18n.ts._2fa.passwordToTOTP, text: i18n.ts._2fa.passwordToTOTP,
@ -115,7 +111,7 @@ async function registerTOTP() {
}, {}, 'closed'); }, {}, 'closed');
} }
function unregisterTOTP() { function unregisterTOTP(): void {
os.inputText({ os.inputText({
title: i18n.ts.password, title: i18n.ts.password,
type: 'password', type: 'password',
@ -133,7 +129,7 @@ function unregisterTOTP() {
}); });
} }
function renewTOTP() { function renewTOTP(): void {
os.confirm({ os.confirm({
type: 'question', type: 'question',
title: i18n.ts._2fa.renewTOTP, title: i18n.ts._2fa.renewTOTP,
@ -192,8 +188,10 @@ async function addSecurityKey() {
}); });
if (password.canceled) return; if (password.canceled) return;
const challenge: any = await os.apiWithDialog('i/2fa/register-key', { const registrationOptions = parseCreationOptionsFromJSON({
password: password.result, publicKey: await os.apiWithDialog('i/2fa/register-key', {
password: password.result,
}),
}); });
const name = await os.inputText({ const name = await os.inputText({
@ -205,26 +203,8 @@ async function addSecurityKey() {
}); });
if (name.canceled) return; if (name.canceled) return;
const webAuthnCreation = navigator.credentials.create({
publicKey: {
challenge: byteify(challenge.challenge, 'base64'),
rp: {
id: hostname,
name: 'Misskey',
},
user: {
id: byteify($i!.id, 'ascii'),
name: $i!.username,
displayName: $i!.name,
},
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
timeout: 60000,
attestation: 'direct',
},
}) as Promise<PublicKeyCredential & { response: AuthenticatorAttestationResponse; } | null>;
const credential = await os.promiseDialog( const credential = await os.promiseDialog(
webAuthnCreation, webAuthnCreate(registrationOptions),
null, null,
() => {}, // reject () => {}, // reject
i18n.ts._2fa.tapSecurityKey, i18n.ts._2fa.tapSecurityKey,
@ -234,10 +214,7 @@ async function addSecurityKey() {
await os.apiWithDialog('i/2fa/key-done', { await os.apiWithDialog('i/2fa/key-done', {
password: password.result, password: password.result,
name: name.result, name: name.result,
challengeId: challenge.challengeId, credential: credential.toJSON(),
// we convert each 16 bits to a string to serialise
clientDataJSON: stringify(credential.response.clientDataJSON),
attestationObject: hexify(credential.response.attestationObject),
}); });
} }

View File

@ -1,38 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function byteify(string: string, encoding: 'ascii' | 'base64' | 'hex') {
switch (encoding) {
case 'ascii':
return Uint8Array.from(string, c => c.charCodeAt(0));
case 'base64':
return Uint8Array.from(
atob(
string
.replace(/-/g, '+')
.replace(/_/g, '/'),
),
c => c.charCodeAt(0),
);
case 'hex':
return new Uint8Array(
string
.match(/.{1,2}/g)
.map(byte => parseInt(byte, 16)),
);
}
}
export function hexify(buffer: ArrayBuffer) {
return Array.from(new Uint8Array(buffer))
.reduce(
(str, byte) => str + byte.toString(16).padStart(2, '0'),
'',
);
}
export function stringify(buffer: ArrayBuffer) {
return String.fromCharCode(... new Uint8Array(buffer));
}

View File

@ -113,6 +113,9 @@ importers:
'@peertube/http-signature': '@peertube/http-signature':
specifier: 1.7.0 specifier: 1.7.0
version: 1.7.0 version: 1.7.0
'@simplewebauthn/server':
specifier: 8.1.1
version: 8.1.1
'@sinonjs/fake-timers': '@sinonjs/fake-timers':
specifier: 11.1.0 specifier: 11.1.0
version: 11.1.0 version: 11.1.0
@ -481,6 +484,9 @@ importers:
'@jest/globals': '@jest/globals':
specifier: 29.6.4 specifier: 29.6.4
version: 29.6.4 version: 29.6.4
'@simplewebauthn/typescript-types':
specifier: 8.0.0
version: 8.0.0
'@swc/jest': '@swc/jest':
specifier: 0.2.29 specifier: 0.2.29
version: 0.2.29(@swc/core@1.3.82) version: 0.2.29(@swc/core@1.3.82)
@ -637,6 +643,9 @@ importers:
'@discordapp/twemoji': '@discordapp/twemoji':
specifier: 14.1.2 specifier: 14.1.2
version: 14.1.2 version: 14.1.2
'@github/webauthn-json':
specifier: 2.1.1
version: 2.1.1
'@rollup/plugin-alias': '@rollup/plugin-alias':
specifier: 5.0.0 specifier: 5.0.0
version: 5.0.0(rollup@3.28.1) version: 5.0.0(rollup@3.28.1)
@ -3301,6 +3310,54 @@ packages:
resolution: {integrity: sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==} resolution: {integrity: sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==}
dev: false dev: false
/@cbor-extract/cbor-extract-darwin-arm64@2.1.1:
resolution: {integrity: sha512-blVBy5MXz6m36Vx0DfLd7PChOQKEs8lK2bD1WJn/vVgG4FXZiZmZb2GECHFvVPA5T7OnODd9xZiL3nMCv6QUhA==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@cbor-extract/cbor-extract-darwin-x64@2.1.1:
resolution: {integrity: sha512-h6KFOzqk8jXTvkOftyRIWGrd7sKQzQv2jVdTL9nKSf3D2drCvQB/LHUxAOpPXo3pv2clDtKs3xnHalpEh3rDsw==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@cbor-extract/cbor-extract-linux-arm64@2.1.1:
resolution: {integrity: sha512-SxAaRcYf8S0QHaMc7gvRSiTSr7nUYMqbUdErBEu+HYA4Q6UNydx1VwFE68hGcp1qvxcy9yT5U7gA+a5XikfwSQ==}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@cbor-extract/cbor-extract-linux-arm@2.1.1:
resolution: {integrity: sha512-ds0uikdcIGUjPyraV4oJqyVE5gl/qYBpa/Wnh6l6xLE2lj/hwnjT2XcZCChdXwW/YFZ1LUHs6waoYN8PmK0nKQ==}
cpu: [arm]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@cbor-extract/cbor-extract-linux-x64@2.1.1:
resolution: {integrity: sha512-GVK+8fNIE9lJQHAlhOROYiI0Yd4bAZ4u++C2ZjlkS3YmO6hi+FUxe6Dqm+OKWTcMpL/l71N6CQAmaRcb4zyJuA==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@cbor-extract/cbor-extract-win32-x64@2.1.1:
resolution: {integrity: sha512-2Niq1C41dCRIDeD8LddiH+mxGlO7HJ612Ll3D/E73ZWBmycued+8ghTr/Ho3CMOWPUEr08XtyBMVXAjqF+TcKw==}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: false
optional: true
/@colors/colors@1.5.0: /@colors/colors@1.5.0:
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'} engines: {node: '>=0.1.90'}
@ -4066,6 +4123,11 @@ packages:
resolution: {integrity: sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw==} resolution: {integrity: sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw==}
dev: true dev: true
/@github/webauthn-json@2.1.1:
resolution: {integrity: sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ==}
hasBin: true
dev: false
/@hapi/boom@10.0.1: /@hapi/boom@10.0.1:
resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==} resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==}
dependencies: dependencies:
@ -4102,6 +4164,10 @@ packages:
'@hapi/hoek': 11.0.2 '@hapi/hoek': 11.0.2
dev: true dev: true
/@hexagon/base64@1.1.27:
resolution: {integrity: sha512-PdUmzpvcUM3Rh39kvz9RdbPVYhMjBjdV7Suw7ZduP7urRLsZR8l5tzgSWKm7TExwBYDFwTnYrZbnE0rQ3N5NLQ==}
dev: false
/@humanwhocodes/config-array@0.11.10: /@humanwhocodes/config-array@0.11.10:
resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==} resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}
@ -4787,6 +4853,50 @@ packages:
resolution: {integrity: sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==} resolution: {integrity: sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==}
dev: true dev: true
/@peculiar/asn1-android@2.3.6:
resolution: {integrity: sha512-zkYh4DsiRhiNfg6tWaUuRc+huwlb9XJbmeZLrjTz9v76UK1Ehq3EnfJFED6P3sdznW/nqWe46LoM9JrqxcD58g==}
dependencies:
'@peculiar/asn1-schema': 2.3.6
asn1js: 3.0.5
tslib: 2.6.2
dev: false
/@peculiar/asn1-ecc@2.3.6:
resolution: {integrity: sha512-Hu1xzMJQWv8/GvzOiinaE6XiD1/kEhq2C/V89UEoWeZ2fLUcGNIvMxOr/pMyL0OmpRWj/mhCTXOZp4PP+a0aTg==}
dependencies:
'@peculiar/asn1-schema': 2.3.6
'@peculiar/asn1-x509': 2.3.6
asn1js: 3.0.5
tslib: 2.6.2
dev: false
/@peculiar/asn1-rsa@2.3.6:
resolution: {integrity: sha512-DswjJyAXZnvESuImGNTvbNKvh1XApBVqU+r3UmrFFTAI23gv62byl0f5OFKWTNhCf66WQrd3sklpsCZc/4+jwA==}
dependencies:
'@peculiar/asn1-schema': 2.3.6
'@peculiar/asn1-x509': 2.3.6
asn1js: 3.0.5
tslib: 2.6.2
dev: false
/@peculiar/asn1-schema@2.3.6:
resolution: {integrity: sha512-izNRxPoaeJeg/AyH8hER6s+H7p4itk+03QCa4sbxI3lNdseQYCuxzgsuNK8bTXChtLTjpJz6NmXKA73qLa3rCA==}
dependencies:
asn1js: 3.0.5
pvtsutils: 1.3.5
tslib: 2.6.2
dev: false
/@peculiar/asn1-x509@2.3.6:
resolution: {integrity: sha512-dRwX31R1lcbIdzbztiMvLNTDoGptxdV7HocNx87LfKU0fEWh7fTWJjx4oV+glETSy6heF/hJHB2J4RGB3vVSYg==}
dependencies:
'@peculiar/asn1-schema': 2.3.6
asn1js: 3.0.5
ipaddr.js: 2.1.0
pvtsutils: 1.3.5
tslib: 2.6.2
dev: false
/@peertube/http-signature@1.7.0: /@peertube/http-signature@1.7.0:
resolution: {integrity: sha512-aGQIwo6/sWtyyqhVK4e1MtxYz4N1X8CNt6SOtCc+Wnczs5S5ONaLHDDR8LYaGn0MgOwvGgXyuZ5sJIfd7iyoUw==} resolution: {integrity: sha512-aGQIwo6/sWtyyqhVK4e1MtxYz4N1X8CNt6SOtCc+Wnczs5S5ONaLHDDR8LYaGn0MgOwvGgXyuZ5sJIfd7iyoUw==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
@ -5423,6 +5533,26 @@ packages:
resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==}
dev: true dev: true
/@simplewebauthn/server@8.1.1:
resolution: {integrity: sha512-fJ0Ux9eV5oLa6gowHiUXx+oDqh6DhDK/w1oenn8p9+MhZDCXtLOIWl3Crgq5FLnwOuX9NpJzHgmgaOk2b8Tojg==}
engines: {node: '>=16.0.0'}
dependencies:
'@hexagon/base64': 1.1.27
'@peculiar/asn1-android': 2.3.6
'@peculiar/asn1-ecc': 2.3.6
'@peculiar/asn1-rsa': 2.3.6
'@peculiar/asn1-schema': 2.3.6
'@peculiar/asn1-x509': 2.3.6
'@simplewebauthn/typescript-types': 8.0.0
cbor-x: 1.5.4
cross-fetch: 4.0.0
transitivePeerDependencies:
- encoding
dev: false
/@simplewebauthn/typescript-types@8.0.0:
resolution: {integrity: sha512-d7Izb2H+LZJteXMkS8DmpAarD6mZdpIOu/av/yH4/u/3Pd6DKFLyBM3j8BMmUvUqpzvJvHARNrRfQYto58mtTQ==}
/@sinclair/typebox@0.24.51: /@sinclair/typebox@0.24.51:
resolution: {integrity: sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==} resolution: {integrity: sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==}
dev: true dev: true
@ -8909,6 +9039,15 @@ packages:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
/asn1js@3.0.5:
resolution: {integrity: sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==}
engines: {node: '>=12.0.0'}
dependencies:
pvtsutils: 1.3.5
pvutils: 1.1.3
tslib: 2.6.2
dev: false
/assert-never@1.2.1: /assert-never@1.2.1:
resolution: {integrity: sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw==} resolution: {integrity: sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw==}
@ -9572,6 +9711,28 @@ packages:
/caseless@0.12.0: /caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
/cbor-extract@2.1.1:
resolution: {integrity: sha512-1UX977+L+zOJHsp0mWFG13GLwO6ucKgSmSW6JTl8B9GUvACvHeIVpFqhU92299Z6PfD09aTXDell5p+lp1rUFA==}
hasBin: true
requiresBuild: true
dependencies:
node-gyp-build-optional-packages: 5.0.3
optionalDependencies:
'@cbor-extract/cbor-extract-darwin-arm64': 2.1.1
'@cbor-extract/cbor-extract-darwin-x64': 2.1.1
'@cbor-extract/cbor-extract-linux-arm': 2.1.1
'@cbor-extract/cbor-extract-linux-arm64': 2.1.1
'@cbor-extract/cbor-extract-linux-x64': 2.1.1
'@cbor-extract/cbor-extract-win32-x64': 2.1.1
dev: false
optional: true
/cbor-x@1.5.4:
resolution: {integrity: sha512-PVKILDn+Rf6MRhhcyzGXi5eizn1i0i3F8Fe6UMMxXBnWkalq9+C5+VTmlIjAYM4iF2IYF2N+zToqAfYOp+3rfw==}
optionalDependencies:
cbor-extract: 2.1.1
dev: false
/cbor@9.0.1: /cbor@9.0.1:
resolution: {integrity: sha512-/TQOWyamDxvVIv+DY9cOLNuABkoyz8K/F3QE56539pGVYohx0+MEA1f4lChFTX79dBTBS7R1PF6ovH7G+VtBfQ==} resolution: {integrity: sha512-/TQOWyamDxvVIv+DY9cOLNuABkoyz8K/F3QE56539pGVYohx0+MEA1f4lChFTX79dBTBS7R1PF6ovH7G+VtBfQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
@ -10122,6 +10283,14 @@ packages:
- encoding - encoding
dev: false dev: false
/cross-fetch@4.0.0:
resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
dev: false
/cross-spawn@5.1.0: /cross-spawn@5.1.0:
resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==}
dependencies: dependencies:
@ -15022,6 +15191,18 @@ packages:
dependencies: dependencies:
whatwg-url: 5.0.0 whatwg-url: 5.0.0
/node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
dependencies:
whatwg-url: 5.0.0
dev: false
/node-fetch@3.3.2: /node-fetch@3.3.2:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -15030,6 +15211,13 @@ packages:
fetch-blob: 3.2.0 fetch-blob: 3.2.0
formdata-polyfill: 4.0.10 formdata-polyfill: 4.0.10
/node-gyp-build-optional-packages@5.0.3:
resolution: {integrity: sha512-k75jcVzk5wnnc/FMxsf4udAoTEUv2jY3ycfdSd3yWu6Cnd1oee6/CfZJApyscA4FJOmdoixWwiwOyf16RzD5JA==}
hasBin: true
requiresBuild: true
dev: false
optional: true
/node-gyp-build-optional-packages@5.0.7: /node-gyp-build-optional-packages@5.0.7:
resolution: {integrity: sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==} resolution: {integrity: sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==}
hasBin: true hasBin: true
@ -16548,6 +16736,17 @@ packages:
pngjs: 3.4.0 pngjs: 3.4.0
dev: false dev: false
/pvtsutils@1.3.5:
resolution: {integrity: sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==}
dependencies:
tslib: 2.6.2
dev: false
/pvutils@1.1.3:
resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
engines: {node: '>=6.0.0'}
dev: false
/qrcode@1.5.3: /qrcode@1.5.3:
resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==} resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}