feat: passkey support (#11804)
https://github.com/MisskeyIO/misskey/pull/149
This commit is contained in:
parent
bc52d7a4fb
commit
ff9a65e8fa
30 changed files with 800 additions and 1028 deletions
|
@ -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
2
locales/index.d.ts
vendored
|
@ -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;
|
||||||
|
|
|
@ -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: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください"
|
||||||
|
|
49
packages/backend/migration/1691959191872-passkey-support.js
Normal file
49
packages/backend/migration/1691959191872-passkey-support.js
Normal 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"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
252
packages/backend/src/core/WebAuthnService.ts
Normal file
252
packages/backend/src/core/WebAuthnService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
],
|
],
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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, {
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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));
|
|
||||||
}
|
|
199
pnpm-lock.yaml
199
pnpm-lock.yaml
|
@ -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'}
|
||||||
|
|
Loading…
Reference in a new issue