feat: パスキーに対応 (MisskeyIO#149)
This commit is contained in:
		| @@ -1686,7 +1686,6 @@ _2fa: | ||||
|   securityKeyNotSupported: "Dein Browser unterstützt keine Security-Tokens." | ||||
|   registerTOTPBeforeKey: "Um einen Security-Token oder einen Passkey zu registrieren, musst du zuerst eine Authentifizierungs-App registrieren." | ||||
|   securityKeyInfo: "Du kannst neben Fingerabdruck- oder PIN-Authentifizierung auf deinem Gerät auch Anmeldung mit Hilfe eines FIDO2-kompatiblen Hardware-Sicherheitsschlüssels einrichten." | ||||
|   chromePasskeyNotSupported: "Chrome-Passkeys werden zur Zeit nicht unterstützt." | ||||
|   registerSecurityKey: "Security-Token oder Passkey registrieren" | ||||
|   securityKeyName: "Schlüsselname eingeben" | ||||
|   tapSecurityKey: "Bitten folge den Anweisungen deines Browsers zur Registrierung" | ||||
|   | ||||
| @@ -413,6 +413,7 @@ token: "Token" | ||||
| 2fa: "Two-factor authentication" | ||||
| totp: "Authenticator App" | ||||
| totpDescription: "Use an authenticator app to enter one-time passwords" | ||||
| useSecurityKey: "Please use the security key or passkey according to the browser or device instructions." | ||||
| moderator: "Moderator" | ||||
| moderation: "Moderation" | ||||
| nUsersMentioned: "Mentioned by {n} users" | ||||
| @@ -1693,7 +1694,6 @@ _2fa: | ||||
|   securityKeyNotSupported: "Your browser does not support security keys." | ||||
|   registerTOTPBeforeKey: "Please set up an authenticator app to register a security or pass key." | ||||
|   securityKeyInfo: "Besides fingerprint or PIN authentication, you can also setup authentication via hardware security keys that support FIDO2 to further secure your account." | ||||
|   chromePasskeyNotSupported: "Chrome passkeys are currently not supported." | ||||
|   registerSecurityKey: "Register a security or pass key" | ||||
|   securityKeyName: "Enter a key name" | ||||
|   tapSecurityKey: "Please follow your browser to register the security or pass key" | ||||
|   | ||||
| @@ -1686,7 +1686,6 @@ _2fa: | ||||
|   securityKeyNotSupported: "Tu navegador no soporta claves de autenticación." | ||||
|   registerTOTPBeforeKey: "Please set up an authenticator app to register a security or pass key.\npor favor. configura una aplicación de autenticación para registrar una llave de seguridad." | ||||
|   securityKeyInfo: "Se puede configurar el inicio de sesión usando una clave de seguridad de hardware que soporte FIDO2 o con un certificado de huella digital o con un PIN" | ||||
|   chromePasskeyNotSupported: "Las llaves de seguridad de Chrome no son soportadas por el momento." | ||||
|   registerSecurityKey: "Registrar una llave de seguridad" | ||||
|   securityKeyName: "Ingresa un nombre para la clave" | ||||
|   tapSecurityKey: "Por favor, sigue tu navegador para registrar una llave de seguridad" | ||||
|   | ||||
| @@ -1660,7 +1660,6 @@ _2fa: | ||||
|   securityKeyNotSupported: "Peramban kamu tidak mendukung security key." | ||||
|   registerTOTPBeforeKey: "Mohon atur aplikasi autentikator untuk mendaftarkan security key atau passkey." | ||||
|   securityKeyInfo: "Kamu dapat memasang otentikasi WebAuthN untuk mengamankan proses login lebih lanjut dengan tidak hanya perangkat keras kunci keamanan yang mendukung FIDO2, namun juga sidik jari atau otentikasi PIN pada perangkatmu." | ||||
|   chromePasskeyNotSupported: "Passkey Chrome saat ini tidak didukung." | ||||
|   registerSecurityKey: "Daftarkan security key atau passkey." | ||||
|   securityKeyName: "Masukkan nama key." | ||||
|   tapSecurityKey: "Mohon ikuti peramban kamu untuk mendaftarkan security key atau passkey" | ||||
|   | ||||
							
								
								
									
										2
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -416,6 +416,7 @@ export interface Locale { | ||||
|     "2fa": string; | ||||
|     "totp": string; | ||||
|     "totpDescription": string; | ||||
|     "useSecurityKey": string; | ||||
|     "moderator": string; | ||||
|     "moderation": string; | ||||
|     "nUsersMentioned": string; | ||||
| @@ -1824,7 +1825,6 @@ export interface Locale { | ||||
|         "securityKeyNotSupported": string; | ||||
|         "registerTOTPBeforeKey": string; | ||||
|         "securityKeyInfo": string; | ||||
|         "chromePasskeyNotSupported": string; | ||||
|         "registerSecurityKey": string; | ||||
|         "securityKeyName": string; | ||||
|         "tapSecurityKey": string; | ||||
|   | ||||
| @@ -1686,7 +1686,6 @@ _2fa: | ||||
|   securityKeyNotSupported: "Il tuo browser non supporta le chiavi di sicurezza." | ||||
|   registerTOTPBeforeKey: "Ti occorre un'app di autenticazione con OTP, prima di registrare la chiave di sicurezza." | ||||
|   securityKeyInfo: "È possibile impostare il dispositivo per accedere utilizzando una chiave di sicurezza hardware che supporta FIDO2 o un'impronta digitale o un PIN sul dispositivo." | ||||
|   chromePasskeyNotSupported: "Le passkey di Chrome non sono attualmente supportate." | ||||
|   registerSecurityKey: "Registra la chiave di sicurezza" | ||||
|   securityKeyName: "Inserisci il nome della chiave" | ||||
|   tapSecurityKey: "Segui le istruzioni del browser e registra la chiave di sicurezza." | ||||
|   | ||||
| @@ -413,6 +413,7 @@ token: "確認コード" | ||||
| 2fa: "二要素認証" | ||||
| totp: "認証アプリ" | ||||
| totpDescription: "認証アプリを使ってワンタイムパスワードを入力" | ||||
| useSecurityKey: "ブラウザまたはデバイスの指示に従って、セキュリティキーまたはパスキーを使用してください。" | ||||
| moderator: "モデレーター" | ||||
| moderation: "モデレーション" | ||||
| nUsersMentioned: "{n}人が投稿" | ||||
| @@ -1742,7 +1743,6 @@ _2fa: | ||||
|   securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。" | ||||
|   registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。" | ||||
|   securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。" | ||||
|   chromePasskeyNotSupported: "Chromeのパスキーは現在サポートしていません。" | ||||
|   registerSecurityKey: "セキュリティキー・パスキーを登録する" | ||||
|   securityKeyName: "キーの名前を入力" | ||||
|   tapSecurityKey: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください" | ||||
|   | ||||
| @@ -1686,7 +1686,6 @@ _2fa: | ||||
|   securityKeyNotSupported: "今使とるブラウザはセキュリティキーに対応してへんのやってさ。" | ||||
|   registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するんやったら、まず認証アプリを設定してーな。" | ||||
|   securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーか端末の指紋認証やPINを使ってログインするように設定できるで。" | ||||
|   chromePasskeyNotSupported: "Chromeのパスキーは今んとこ対応してないねん。" | ||||
|   registerSecurityKey: "セキュリティキー・パスキーを登録するわ" | ||||
|   securityKeyName: "キーの名前を入れてーや" | ||||
|   tapSecurityKey: "ブラウザが言うこと聞いて、セキュリティキーとかパスキー登録しといでや" | ||||
|   | ||||
| @@ -412,6 +412,7 @@ token: "토큰" | ||||
| 2fa: "2단계 인증" | ||||
| totp: "인증 앱" | ||||
| totpDescription: "인증 앱을 사용하여 일회성 비밀번호 입력" | ||||
| useSecurityKey: "브라우저 또는 장치의 안내에 따라 보안 키 또는 패스키를 사용해 주세요." | ||||
| moderator: "모더레이터" | ||||
| moderation: "모더레이션" | ||||
| nUsersMentioned: "{n}명이 언급함" | ||||
| @@ -1685,7 +1686,6 @@ _2fa: | ||||
|   securityKeyNotSupported: "이 브라우저는 보안 키를 지원하지 않습니다." | ||||
|   registerTOTPBeforeKey: "보안 키 또는 패스키를 등록하려면 인증 앱을 등록하십시오." | ||||
|   securityKeyInfo: "FIDO2를 지원하는 하드웨어 보안 키 혹은 디바이스의 지문인식이나 화면잠금 PIN을 이용해서 로그인하도록 설정할 수 있습니다." | ||||
|   chromePasskeyNotSupported: "현재 Chrome의 패스키는 지원되지 않습니다." | ||||
|   registerSecurityKey: "보안 키 또는 패스키 등록" | ||||
|   securityKeyName: "키 이름 입력" | ||||
|   tapSecurityKey: "브라우저의 지시에 따라 보안 키 또는 패스키를 등록하여 주십시오" | ||||
|   | ||||
| @@ -1576,7 +1576,6 @@ _2fa: | ||||
|   securityKeyNotSupported: "Ваш браузер не поддерживает ключи безопасности." | ||||
|   registerTOTPBeforeKey: "Чтобы зарегистрировать ключ безопасности и пароль, сначала настройте приложение аутентификации." | ||||
|   securityKeyInfo: "Вы можете настроить вход с помощью аппаратного ключа безопасности, поддерживающего FIDO2, или отпечатка пальца или PIN-кода на устройстве." | ||||
|   chromePasskeyNotSupported: "В настоящее время Chrome не поддерживает пароль-ключи." | ||||
|   registerSecurityKey: "Зарегистрируйте ключ безопасности ・Passkey" | ||||
|   securityKeyName: "Введите имя для ключа" | ||||
|   tapSecurityKey: "Пожалуйста, следуйте инструкциям в вашем браузере, чтобы зарегистрировать свой ключ безопасности или пароль" | ||||
|   | ||||
| @@ -1686,7 +1686,6 @@ _2fa: | ||||
|   securityKeyNotSupported: "เบราว์เซอร์ของคุณไม่รองรับคีย์ความปลอดภัยนะ" | ||||
|   registerTOTPBeforeKey: "กรุณาตั้งค่าแอปยืนยันตัวตนเพื่อลงทะเบียนรหัสความปลอดภัยหรือรหัสผ่าน" | ||||
|   securityKeyInfo: "นอกจากนี้การตรวจสอบความถูกต้องด้วยลายนิ้วมือหรือ PIN แล้ว คุณยังสามารถตั้งค่าการตรวจสอบสิทธิ์ผ่านคีย์ความปลอดภัยของฮาร์ดแวร์ที่รองรับ FIDO2 เพื่อเพิ่มความปลอดภัยให้กับบัญชีของคุณ" | ||||
|   chromePasskeyNotSupported: "ขณะนี้ยังไม่รองรับรหัสผ่านของ Chrome" | ||||
|   registerSecurityKey: "ลงทะเบียนรหัสความปลอดภัยหรือรหัสผ่าน" | ||||
|   securityKeyName: "ป้อนชื่อคีย์" | ||||
|   tapSecurityKey: "กรุณาทำตามเบราว์เซอร์ของคุณเพื่อลงทะเบียนรหัสความปลอดภัยหรือรหัสผ่าน" | ||||
|   | ||||
| @@ -1686,7 +1686,6 @@ _2fa: | ||||
|   securityKeyNotSupported: "您的浏览器不支持安全密钥。" | ||||
|   registerTOTPBeforeKey: "要注册安全密钥或 Passkey,请先设置验证器应用程序。" | ||||
|   securityKeyInfo: "注册兼容 WebAuthn 的密钥,例如支持 FIDO2 的硬件安全密钥、设备上的生物识别功能、PIN 码以及 Passkey 等。" | ||||
|   chromePasskeyNotSupported: "目前不支持 Chrome 的 Passkey。" | ||||
|   registerSecurityKey: "注册安全密钥或 Passkey" | ||||
|   securityKeyName: "输入密钥名称" | ||||
|   tapSecurityKey: "请按照浏览器说明操作来注册安全密钥或 Passkey。" | ||||
|   | ||||
| @@ -1686,7 +1686,6 @@ _2fa: | ||||
|   securityKeyNotSupported: "您的瀏覽器不支援安全金鑰。" | ||||
|   registerTOTPBeforeKey: "如要註冊安全金鑰或 Passkey,請先設定驗證應用程式。" | ||||
|   securityKeyInfo: "您可以設定使用支援 FIDO2 的硬體安全鎖、終端設備的指紋認證,或者 PIN 碼來登入。" | ||||
|   chromePasskeyNotSupported: "目前不支援 Chrome 的 Passkey。" | ||||
|   registerSecurityKey: "註冊安全金鑰或 Passkey" | ||||
|   securityKeyName: "輸入金鑰名稱" | ||||
|   tapSecurityKey: "按照瀏覽器的說明註冊安全金鑰或 Passkey。" | ||||
|   | ||||
							
								
								
									
										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"`); | ||||
|     } | ||||
| } | ||||
| @@ -74,6 +74,7 @@ | ||||
| 		"@nestjs/core": "10.1.0", | ||||
| 		"@nestjs/testing": "10.1.0", | ||||
| 		"@peertube/http-signature": "1.7.0", | ||||
| 		"@simplewebauthn/server": "^7.4.0", | ||||
| 		"@sinonjs/fake-timers": "10.3.0", | ||||
| 		"@swc/cli": "0.1.62", | ||||
| 		"@swc/core": "1.3.70", | ||||
| @@ -163,6 +164,7 @@ | ||||
| 	}, | ||||
| 	"devDependencies": { | ||||
| 		"@jest/globals": "29.6.1", | ||||
| 		"@simplewebauthn/typescript-types": "^7.4.0", | ||||
| 		"@swc/jest": "0.2.26", | ||||
| 		"@types/accepts": "1.3.5", | ||||
| 		"@types/archiver": "5.3.2", | ||||
|   | ||||
| @@ -8,7 +8,6 @@ import { ChartManagementService } from '@/core/chart/ChartManagementService.js'; | ||||
| import { QueueProcessorService } from '@/queue/QueueProcessorService.js'; | ||||
| import { NestLogger } from '@/NestLogger.js'; | ||||
| import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js'; | ||||
| import { JanitorService } from '@/daemons/JanitorService.js'; | ||||
| import { QueueStatsService } from '@/daemons/QueueStatsService.js'; | ||||
| import { ServerStatsService } from '@/daemons/ServerStatsService.js'; | ||||
| import { ServerService } from '@/server/ServerService.js'; | ||||
| @@ -25,7 +24,6 @@ export async function server() { | ||||
|  | ||||
| 	if (process.env.NODE_ENV !== 'test') { | ||||
| 		app.get(ChartManagementService).start(); | ||||
| 		app.get(JanitorService).start(); | ||||
| 		app.get(QueueStatsService).start(); | ||||
| 		app.get(ServerStatsService).start(); | ||||
| 	} | ||||
|   | ||||
| @@ -43,7 +43,6 @@ import { RelayService } from './RelayService.js'; | ||||
| import { RoleService } from './RoleService.js'; | ||||
| import { S3Service } from './S3Service.js'; | ||||
| import { SignupService } from './SignupService.js'; | ||||
| import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js'; | ||||
| import { UserBlockingService } from './UserBlockingService.js'; | ||||
| import { CacheService } from './CacheService.js'; | ||||
| import { UserFollowingService } from './UserFollowingService.js'; | ||||
| @@ -52,6 +51,7 @@ import { UserListService } from './UserListService.js'; | ||||
| import { UserMutingService } from './UserMutingService.js'; | ||||
| import { UserSuspendService } from './UserSuspendService.js'; | ||||
| import { VideoProcessingService } from './VideoProcessingService.js'; | ||||
| import { WebAuthnService } from './WebAuthnService.js'; | ||||
| import { WebhookService } from './WebhookService.js'; | ||||
| import { ProxyAccountService } from './ProxyAccountService.js'; | ||||
| import { UtilityService } from './UtilityService.js'; | ||||
| @@ -169,7 +169,6 @@ const $RelayService: Provider = { provide: 'RelayService', useExisting: RelaySer | ||||
| const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService }; | ||||
| const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service }; | ||||
| const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService }; | ||||
| const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService }; | ||||
| const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService }; | ||||
| const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService }; | ||||
| const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService }; | ||||
| @@ -178,6 +177,7 @@ const $UserListService: Provider = { provide: 'UserListService', useExisting: Us | ||||
| const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService }; | ||||
| const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; | ||||
| const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService }; | ||||
| const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService }; | ||||
| const $WebhookService: Provider = { provide: 'WebhookService', useExisting: WebhookService }; | ||||
| const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService }; | ||||
| const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService }; | ||||
| @@ -298,7 +298,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		RoleService, | ||||
| 		S3Service, | ||||
| 		SignupService, | ||||
| 		TwoFactorAuthenticationService, | ||||
| 		UserBlockingService, | ||||
| 		CacheService, | ||||
| 		UserFollowingService, | ||||
| @@ -307,6 +306,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		UserMutingService, | ||||
| 		UserSuspendService, | ||||
| 		VideoProcessingService, | ||||
| 		WebAuthnService, | ||||
| 		WebhookService, | ||||
| 		UtilityService, | ||||
| 		FileInfoService, | ||||
| @@ -420,7 +420,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$RoleService, | ||||
| 		$S3Service, | ||||
| 		$SignupService, | ||||
| 		$TwoFactorAuthenticationService, | ||||
| 		$UserBlockingService, | ||||
| 		$CacheService, | ||||
| 		$UserFollowingService, | ||||
| @@ -429,6 +428,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$UserMutingService, | ||||
| 		$UserSuspendService, | ||||
| 		$VideoProcessingService, | ||||
| 		$WebAuthnService, | ||||
| 		$WebhookService, | ||||
| 		$UtilityService, | ||||
| 		$FileInfoService, | ||||
| @@ -543,7 +543,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		RoleService, | ||||
| 		S3Service, | ||||
| 		SignupService, | ||||
| 		TwoFactorAuthenticationService, | ||||
| 		UserBlockingService, | ||||
| 		CacheService, | ||||
| 		UserFollowingService, | ||||
| @@ -552,6 +551,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		UserMutingService, | ||||
| 		UserSuspendService, | ||||
| 		VideoProcessingService, | ||||
| 		WebAuthnService, | ||||
| 		WebhookService, | ||||
| 		UtilityService, | ||||
| 		FileInfoService, | ||||
| @@ -664,7 +664,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$RoleService, | ||||
| 		$S3Service, | ||||
| 		$SignupService, | ||||
| 		$TwoFactorAuthenticationService, | ||||
| 		$UserBlockingService, | ||||
| 		$CacheService, | ||||
| 		$UserFollowingService, | ||||
| @@ -673,6 +672,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$UserMutingService, | ||||
| 		$UserSuspendService, | ||||
| 		$VideoProcessingService, | ||||
| 		$WebAuthnService, | ||||
| 		$WebhookService, | ||||
| 		$UtilityService, | ||||
| 		$FileInfoService, | ||||
|   | ||||
| @@ -1,450 +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 { UsersRepository } from '@/models/index.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, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	@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, | ||||
| 					}; | ||||
| 				}, | ||||
| 			}, | ||||
| 		}; | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										250
									
								
								packages/backend/src/core/WebAuthnService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										250
									
								
								packages/backend/src/core/WebAuthnService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,250 @@ | ||||
| /* | ||||
|  * 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 { User } 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: User['id'], userName: string, userDisplayName?: string): Promise<PublicKeyCredentialCreationOptionsJSON> { | ||||
| 		const relyingParty = await this.getRelyingParty(); | ||||
| 		const keys = await this.userSecurityKeysRepository.findBy({ | ||||
| 			userId: userId, | ||||
| 		}); | ||||
|  | ||||
| 		const registrationOptions = 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: User['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: User['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 = 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: User['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 { CoreModule } from '@/core/CoreModule.js'; | ||||
| import { GlobalModule } from '@/GlobalModule.js'; | ||||
| import { JanitorService } from './JanitorService.js'; | ||||
| import { QueueStatsService } from './QueueStatsService.js'; | ||||
| import { ServerStatsService } from './ServerStatsService.js'; | ||||
|  | ||||
| @@ -16,12 +15,10 @@ import { ServerStatsService } from './ServerStatsService.js'; | ||||
| 		CoreModule, | ||||
| 	], | ||||
| 	providers: [ | ||||
| 		JanitorService, | ||||
| 		QueueStatsService, | ||||
| 		ServerStatsService, | ||||
| 	], | ||||
| 	exports: [ | ||||
| 		JanitorService, | ||||
| 		QueueStatsService, | ||||
| 		ServerStatsService, | ||||
| 	], | ||||
|   | ||||
| @@ -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.Timer; | ||||
|  | ||||
| 	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(); | ||||
| 	} | ||||
| } | ||||
| @@ -27,7 +27,6 @@ export const DI = { | ||||
| 	userProfilesRepository: Symbol('userProfilesRepository'), | ||||
| 	userKeypairsRepository: Symbol('userKeypairsRepository'), | ||||
| 	userPendingsRepository: Symbol('userPendingsRepository'), | ||||
| 	attestationChallengesRepository: Symbol('attestationChallengesRepository'), | ||||
| 	userSecurityKeysRepository: Symbol('userSecurityKeysRepository'), | ||||
| 	userPublickeysRepository: Symbol('userPublickeysRepository'), | ||||
| 	userListsRepository: Symbol('userListsRepository'), | ||||
|   | ||||
| @@ -5,7 +5,7 @@ | ||||
|  | ||||
| import { Module } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo, UserListFavorite, AbuseReportResolver } from './index.js'; | ||||
| import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo, UserListFavorite, AbuseReportResolver } from './index.js'; | ||||
| import type { DataSource } from 'typeorm'; | ||||
| import type { Provider } from '@nestjs/common'; | ||||
|  | ||||
| @@ -93,12 +93,6 @@ const $userPendingsRepository: Provider = { | ||||
| 	inject: [DI.db], | ||||
| }; | ||||
|  | ||||
| const $attestationChallengesRepository: Provider = { | ||||
| 	provide: DI.attestationChallengesRepository, | ||||
| 	useFactory: (db: DataSource) => db.getRepository(AttestationChallenge), | ||||
| 	inject: [DI.db], | ||||
| }; | ||||
|  | ||||
| const $userSecurityKeysRepository: Provider = { | ||||
| 	provide: DI.userSecurityKeysRepository, | ||||
| 	useFactory: (db: DataSource) => db.getRepository(UserSecurityKey), | ||||
| @@ -429,7 +423,6 @@ const $abuseReportResolversRepository: Provider = { | ||||
| 		$userProfilesRepository, | ||||
| 		$userKeypairsRepository, | ||||
| 		$userPendingsRepository, | ||||
| 		$attestationChallengesRepository, | ||||
| 		$userSecurityKeysRepository, | ||||
| 		$userPublickeysRepository, | ||||
| 		$userListsRepository, | ||||
| @@ -498,7 +491,6 @@ const $abuseReportResolversRepository: Provider = { | ||||
| 		$userProfilesRepository, | ||||
| 		$userKeypairsRepository, | ||||
| 		$userPendingsRepository, | ||||
| 		$attestationChallengesRepository, | ||||
| 		$userSecurityKeysRepository, | ||||
| 		$userPublickeysRepository, | ||||
| 		$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 { User } from './User.js'; | ||||
|  | ||||
| @Entity() | ||||
| export class AttestationChallenge { | ||||
| 	@PrimaryColumn(id()) | ||||
| 	public id: string; | ||||
|  | ||||
| 	@Index() | ||||
| 	@PrimaryColumn(id()) | ||||
| 	public userId: User['id']; | ||||
|  | ||||
| 	@ManyToOne(type => User, { | ||||
| 		onDelete: 'CASCADE', | ||||
| 	}) | ||||
| 	@JoinColumn() | ||||
| 	public user: User | 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<AttestationChallenge>) { | ||||
| 		if (data == null) return; | ||||
|  | ||||
| 		for (const [k, v] of Object.entries(data)) { | ||||
| 			(this as any)[k] = v; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -24,25 +24,48 @@ export class UserSecurityKey { | ||||
| 	@JoinColumn() | ||||
| 	public user: User | null; | ||||
|  | ||||
| 	@Index() | ||||
| 	@Column('varchar', { | ||||
| 		comment: | ||||
| 			'Variable-length public key used to verify attestations (hex-encoded).', | ||||
| 	}) | ||||
| 	public publicKey: string; | ||||
|  | ||||
| 	@Column('timestamp with time zone', { | ||||
| 		comment: | ||||
| 			'The date of the last time the UserSecurityKey was successfully validated.', | ||||
| 	}) | ||||
| 	public lastUsed: Date; | ||||
|  | ||||
| 	@Column('varchar', { | ||||
| 		comment: 'User-defined name for this key', | ||||
| 		length: 30, | ||||
| 	}) | ||||
| 	public name: string; | ||||
|  | ||||
| 	@Index() | ||||
| 	@Column('varchar', { | ||||
| 		comment: 'The public key of the UserSecurityKey, hex-encoded.', | ||||
| 	}) | ||||
| 	public publicKey: string; | ||||
|  | ||||
| 	@Column('bigint', { | ||||
| 		comment: 'The number of times the UserSecurityKey was validated.', | ||||
| 		default: 0, | ||||
| 	}) | ||||
| 	public counter: number; | ||||
|  | ||||
| 	@Column('timestamp with time zone', { | ||||
| 		comment: 'Timestamp of the last time the UserSecurityKey was used.', | ||||
| 		default: () => 'now()', | ||||
| 	}) | ||||
| 	public lastUsed: Date; | ||||
|  | ||||
| 	@Column('varchar', { | ||||
| 		comment: 'The type of Backup Eligibility in authenticator data', | ||||
| 		length: 32, nullable: true, | ||||
| 	}) | ||||
| 	public credentialDeviceType?: string; | ||||
|  | ||||
| 	@Column('boolean', { | ||||
| 		comment: 'Whether or not the credential has been backed up', | ||||
| 		nullable: true, | ||||
| 	}) | ||||
| 	public credentialBackedUp?: boolean; | ||||
|  | ||||
| 	@Column('varchar', { | ||||
| 		comment: 'The type of the credential returned by the browser', | ||||
| 		length: 32, array: true, nullable: true, | ||||
| 	}) | ||||
| 	public transports?: string[]; | ||||
|  | ||||
| 	constructor(data: Partial<UserSecurityKey>) { | ||||
| 		if (data == null) return; | ||||
|  | ||||
|   | ||||
| @@ -11,7 +11,6 @@ import { Announcement } from '@/models/entities/Announcement.js'; | ||||
| import { AnnouncementRead } from '@/models/entities/AnnouncementRead.js'; | ||||
| import { Antenna } from '@/models/entities/Antenna.js'; | ||||
| import { App } from '@/models/entities/App.js'; | ||||
| import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js'; | ||||
| import { AuthSession } from '@/models/entities/AuthSession.js'; | ||||
| import { Blocking } from '@/models/entities/Blocking.js'; | ||||
| import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; | ||||
| @@ -81,7 +80,6 @@ export { | ||||
| 	AnnouncementRead, | ||||
| 	Antenna, | ||||
| 	App, | ||||
| 	AttestationChallenge, | ||||
| 	AuthSession, | ||||
| 	Blocking, | ||||
| 	ChannelFollowing, | ||||
| @@ -150,7 +148,6 @@ export type AnnouncementsRepository = Repository<Announcement>; | ||||
| export type AnnouncementReadsRepository = Repository<AnnouncementRead>; | ||||
| export type AntennasRepository = Repository<Antenna>; | ||||
| export type AppsRepository = Repository<App>; | ||||
| export type AttestationChallengesRepository = Repository<AttestationChallenge>; | ||||
| export type AuthSessionsRepository = Repository<AuthSession>; | ||||
| export type BlockingsRepository = Repository<Blocking>; | ||||
| export type ChannelFollowingsRepository = Repository<ChannelFollowing>; | ||||
|   | ||||
| @@ -19,7 +19,6 @@ import { Announcement } from '@/models/entities/Announcement.js'; | ||||
| import { AnnouncementRead } from '@/models/entities/AnnouncementRead.js'; | ||||
| import { Antenna } from '@/models/entities/Antenna.js'; | ||||
| import { App } from '@/models/entities/App.js'; | ||||
| import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js'; | ||||
| import { AuthSession } from '@/models/entities/AuthSession.js'; | ||||
| import { Blocking } from '@/models/entities/Blocking.js'; | ||||
| import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; | ||||
| @@ -145,7 +144,6 @@ export const entities = [ | ||||
| 	UserNotePining, | ||||
| 	UserSecurityKey, | ||||
| 	UsedUsername, | ||||
| 	AttestationChallenge, | ||||
| 	Following, | ||||
| 	FollowRequest, | ||||
| 	Muting, | ||||
|   | ||||
| @@ -3,22 +3,26 @@ | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { randomBytes } from 'node:crypto'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import bcrypt from 'bcryptjs'; | ||||
| import * as OTPAuth from 'otpauth'; | ||||
| import { IsNull } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js'; | ||||
| import type { | ||||
| 	SigninsRepository, | ||||
| 	UserProfilesRepository, | ||||
| 	UsersRepository, | ||||
| } from '@/models/index.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import { getIpHash } from '@/misc/get-ip-hash.js'; | ||||
| import type { LocalUser } from '@/models/entities/User.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { WebAuthnService } from '@/core/WebAuthnService.js'; | ||||
| import { RateLimiterService } from './RateLimiterService.js'; | ||||
| import { SigninService } from './SigninService.js'; | ||||
| import type { FastifyRequest, FastifyReply } from 'fastify'; | ||||
| import type { AuthenticationResponseJSON } from '@simplewebauthn/typescript-types'; | ||||
| import type { FastifyReply, FastifyRequest } from 'fastify'; | ||||
|  | ||||
| @Injectable() | ||||
| export class SigninApiService { | ||||
| @@ -29,22 +33,16 @@ export class SigninApiService { | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.userSecurityKeysRepository) | ||||
| 		private userSecurityKeysRepository: UserSecurityKeysRepository, | ||||
|  | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
|  | ||||
| 		@Inject(DI.attestationChallengesRepository) | ||||
| 		private attestationChallengesRepository: AttestationChallengesRepository, | ||||
|  | ||||
| 		@Inject(DI.signinsRepository) | ||||
| 		private signinsRepository: SigninsRepository, | ||||
|  | ||||
| 		private idService: IdService, | ||||
| 		private rateLimiterService: RateLimiterService, | ||||
| 		private signinService: SigninService, | ||||
| 		private twoFactorAuthenticationService: TwoFactorAuthenticationService, | ||||
| 		private webAuthnService: WebAuthnService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| @@ -55,11 +53,7 @@ export class SigninApiService { | ||||
| 				username: string; | ||||
| 				password: string; | ||||
| 				token?: string; | ||||
| 				signature?: string; | ||||
| 				authenticatorData?: string; | ||||
| 				clientDataJSON?: string; | ||||
| 				credentialId?: string; | ||||
| 				challengeId?: string; | ||||
| 				credential?: AuthenticationResponseJSON; | ||||
| 			}; | ||||
| 		}>, | ||||
| 		reply: FastifyReply, | ||||
| @@ -181,64 +175,16 @@ export class SigninApiService { | ||||
| 			} else { | ||||
| 				return this.signinService.signin(request, reply, user); | ||||
| 			} | ||||
| 		} else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) { | ||||
| 		} else if (body.credential) { | ||||
| 			if (!same && !profile.usePasswordLessLogin) { | ||||
| 				return await fail(403, { | ||||
| 					id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', | ||||
| 				}); | ||||
| 			} | ||||
|  | ||||
| 			const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex'); | ||||
| 			const clientData = JSON.parse(clientDataJSON.toString('utf-8')); | ||||
| 			const challenge = await this.attestationChallengesRepository.findOneBy({ | ||||
| 				userId: user.id, | ||||
| 				id: body.challengeId, | ||||
| 				registrationChallenge: false, | ||||
| 				challenge: this.twoFactorAuthenticationService.hash(clientData.challenge).toString('hex'), | ||||
| 			}); | ||||
| 			const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential); | ||||
|  | ||||
| 			if (!challenge) { | ||||
| 				return await fail(403, { | ||||
| 					id: '2715a88a-2125-4013-932f-aa6fe72792da', | ||||
| 				}); | ||||
| 			} | ||||
|  | ||||
| 			await this.attestationChallengesRepository.delete({ | ||||
| 				userId: user.id, | ||||
| 				id: body.challengeId, | ||||
| 			}); | ||||
|  | ||||
| 			if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) { | ||||
| 				return await fail(403, { | ||||
| 					id: '2715a88a-2125-4013-932f-aa6fe72792da', | ||||
| 				}); | ||||
| 			} | ||||
|  | ||||
| 			const securityKey = await this.userSecurityKeysRepository.findOneBy({ | ||||
| 				id: Buffer.from( | ||||
| 					body.credentialId | ||||
| 						.replace(/-/g, '+') | ||||
| 						.replace(/_/g, '/'), | ||||
| 					'base64', | ||||
| 				).toString('hex'), | ||||
| 			}); | ||||
|  | ||||
| 			if (!securityKey) { | ||||
| 				return await fail(403, { | ||||
| 					id: '66269679-aeaf-4474-862b-eb761197e046', | ||||
| 				}); | ||||
| 			} | ||||
|  | ||||
| 			const isValid = this.twoFactorAuthenticationService.verifySignin({ | ||||
| 				publicKey: Buffer.from(securityKey.publicKey, 'hex'), | ||||
| 				authenticatorData: Buffer.from(body.authenticatorData, 'hex'), | ||||
| 				clientDataJSON, | ||||
| 				clientData, | ||||
| 				signature: Buffer.from(body.signature, 'hex'), | ||||
| 				challenge: challenge.challenge, | ||||
| 			}); | ||||
|  | ||||
| 			if (isValid) { | ||||
| 			if (authorized) { | ||||
| 				return this.signinService.signin(request, reply, user); | ||||
| 			} else { | ||||
| 				return await fail(403, { | ||||
| @@ -252,42 +198,11 @@ export class SigninApiService { | ||||
| 				}); | ||||
| 			} | ||||
|  | ||||
| 			const keys = await this.userSecurityKeysRepository.findBy({ | ||||
| 				userId: user.id, | ||||
| 			}); | ||||
|  | ||||
| 			if (keys.length === 0) { | ||||
| 				return await fail(403, { | ||||
| 					id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4', | ||||
| 				}); | ||||
| 			} | ||||
|  | ||||
| 			// 32 byte challenge | ||||
| 			const challenge = randomBytes(32).toString('base64') | ||||
| 				.replace(/=/g, '') | ||||
| 				.replace(/\+/g, '-') | ||||
| 				.replace(/\//g, '_'); | ||||
|  | ||||
| 			const challengeId = this.idService.genId(); | ||||
|  | ||||
| 			await this.attestationChallengesRepository.insert({ | ||||
| 				userId: user.id, | ||||
| 				id: challengeId, | ||||
| 				challenge: this.twoFactorAuthenticationService.hash(Buffer.from(challenge, 'utf-8')).toString('hex'), | ||||
| 				createdAt: new Date(), | ||||
| 				registrationChallenge: false, | ||||
| 			}); | ||||
| 			const authRequest = await this.webAuthnService.initiateAuthentication(user.id); | ||||
|  | ||||
| 			reply.code(200); | ||||
| 			return { | ||||
| 				challenge, | ||||
| 				challengeId, | ||||
| 				securityKeys: keys.map(key => ({ | ||||
| 					id: key.id, | ||||
| 				})), | ||||
| 			}; | ||||
| 			return authRequest; | ||||
| 		} | ||||
| 	// never get here | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -3,156 +3,86 @@ | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { promisify } from 'node:util'; | ||||
| import bcrypt from 'bcryptjs'; | ||||
| import cbor from 'cbor'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js'; | ||||
| import type { AttestationChallengesRepository, UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js'; | ||||
|  | ||||
| const cborDecodeFirst = promisify(cbor.decodeFirst) as any; | ||||
| import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js'; | ||||
| import { WebAuthnService } from '@/core/WebAuthnService.js'; | ||||
| import { ApiError } from '@/server/api/error.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
|  | ||||
| 	secure: true, | ||||
|  | ||||
| 	errors: { | ||||
| 		incorrectPassword: { | ||||
| 			message: 'Incorrect password.', | ||||
| 			code: 'INCORRECT_PASSWORD', | ||||
| 			id: '0d7ec6d2-e652-443e-a7bf-9ee9a0cd77b0', | ||||
| 		}, | ||||
|  | ||||
| 		twoFactorNotEnabled: { | ||||
| 			message: '2fa not enabled.', | ||||
| 			code: 'TWO_FACTOR_NOT_ENABLED', | ||||
| 			id: '798d6847-b1ed-4f9c-b1f9-163c42655995', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  | ||||
| export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		clientDataJSON: { type: 'string' }, | ||||
| 		attestationObject: { type: 'string' }, | ||||
| 		password: { type: 'string' }, | ||||
| 		challengeId: { type: 'string' }, | ||||
| 		name: { type: 'string', minLength: 1, maxLength: 30 }, | ||||
| 		credential: { type: 'object' }, | ||||
| 	}, | ||||
| 	required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'], | ||||
| 	required: ['password', 'name', 'credential'], | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
|  | ||||
| 		@Inject(DI.userSecurityKeysRepository) | ||||
| 		private userSecurityKeysRepository: UserSecurityKeysRepository, | ||||
|  | ||||
| 		@Inject(DI.attestationChallengesRepository) | ||||
| 		private attestationChallengesRepository: AttestationChallengesRepository, | ||||
|  | ||||
| 		private webAuthnService: WebAuthnService, | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private twoFactorAuthenticationService: TwoFactorAuthenticationService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const rpIdHashReal = this.twoFactorAuthenticationService.hash(Buffer.from(this.config.hostname, 'utf-8')); | ||||
|  | ||||
| 			const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); | ||||
|  | ||||
| 			// Compare password | ||||
| 			const same = await bcrypt.compare(ps.password, profile.password!); | ||||
| 			const same = await bcrypt.compare(ps.password, profile.password ?? ''); | ||||
|  | ||||
| 			if (!same) { | ||||
| 				throw new Error('incorrect password'); | ||||
| 				throw new ApiError(meta.errors.incorrectPassword); | ||||
| 			} | ||||
|  | ||||
| 			if (!profile.twoFactorEnabled) { | ||||
| 				throw new Error('2fa not enabled'); | ||||
| 				throw new ApiError(meta.errors.twoFactorNotEnabled); | ||||
| 			} | ||||
|  | ||||
| 			const clientData = JSON.parse(ps.clientDataJSON); | ||||
|  | ||||
| 			if (clientData.type !== 'webauthn.create') { | ||||
| 				throw new Error('not a creation attestation'); | ||||
| 			} | ||||
| 			if (clientData.origin !== this.config.scheme + '://' + this.config.host) { | ||||
| 				throw new Error('origin mismatch'); | ||||
| 			} | ||||
|  | ||||
| 			const clientDataJSONHash = this.twoFactorAuthenticationService.hash(Buffer.from(ps.clientDataJSON, 'utf-8')); | ||||
|  | ||||
| 			const attestation = await cborDecodeFirst(ps.attestationObject); | ||||
|  | ||||
| 			const rpIdHash = attestation.authData.slice(0, 32); | ||||
| 			if (!rpIdHashReal.equals(rpIdHash)) { | ||||
| 				throw new Error('rpIdHash mismatch'); | ||||
| 			} | ||||
|  | ||||
| 			const flags = attestation.authData[32]; | ||||
|  | ||||
| 			// eslint:disable-next-line:no-bitwise | ||||
| 			if (!(flags & 1)) { | ||||
| 				throw new Error('user not present'); | ||||
| 			} | ||||
|  | ||||
| 			const authData = Buffer.from(attestation.authData); | ||||
| 			const credentialIdLength = authData.readUInt16BE(53); | ||||
| 			const credentialId = authData.slice(55, 55 + credentialIdLength); | ||||
| 			const publicKeyData = authData.slice(55 + credentialIdLength); | ||||
| 			const publicKey: Map<number, any> = await cborDecodeFirst(publicKeyData); | ||||
| 			if (publicKey.get(3) !== -7) { | ||||
| 				throw new Error('alg mismatch'); | ||||
| 			} | ||||
|  | ||||
| 			const procedures = this.twoFactorAuthenticationService.getProcedures(); | ||||
|  | ||||
| 			if (!(procedures as any)[attestation.fmt]) { | ||||
| 				throw new Error(`unsupported fmt: ${attestation.fmt}. Supported ones: ${Object.keys(procedures)}`); | ||||
| 			} | ||||
|  | ||||
| 			const verificationData = (procedures as any)[attestation.fmt].verify({ | ||||
| 				attStmt: attestation.attStmt, | ||||
| 				authenticatorData: authData, | ||||
| 				clientDataHash: clientDataJSONHash, | ||||
| 				credentialId, | ||||
| 				publicKey, | ||||
| 				rpIdHash, | ||||
| 			}); | ||||
| 			if (!verificationData.valid) throw new Error('signature invalid'); | ||||
|  | ||||
| 			const attestationChallenge = await this.attestationChallengesRepository.findOneBy({ | ||||
| 				userId: me.id, | ||||
| 				id: ps.challengeId, | ||||
| 				registrationChallenge: true, | ||||
| 				challenge: this.twoFactorAuthenticationService.hash(clientData.challenge).toString('hex'), | ||||
| 			}); | ||||
|  | ||||
| 			if (!attestationChallenge) { | ||||
| 				throw new Error('non-existent challenge'); | ||||
| 			} | ||||
|  | ||||
| 			await this.attestationChallengesRepository.delete({ | ||||
| 				userId: me.id, | ||||
| 				id: ps.challengeId, | ||||
| 			}); | ||||
|  | ||||
| 			// Expired challenge (> 5min old) | ||||
| 			if ( | ||||
| 				new Date().getTime() - attestationChallenge.createdAt.getTime() >= | ||||
| 		5 * 60 * 1000 | ||||
| 			) { | ||||
| 				throw new Error('expired challenge'); | ||||
| 			} | ||||
|  | ||||
| 			const credentialIdString = credentialId.toString('hex'); | ||||
| 			const keyInfo = await this.webAuthnService.verifyRegistration(me.id, ps.credential); | ||||
|  | ||||
| 			const credentialId = Buffer.from(keyInfo.credentialID).toString('base64url'); | ||||
| 			await this.userSecurityKeysRepository.insert({ | ||||
| 				id: credentialId, | ||||
| 				userId: me.id, | ||||
| 				id: credentialIdString, | ||||
| 				lastUsed: new Date(), | ||||
| 				name: ps.name, | ||||
| 				publicKey: verificationData.publicKey.toString('hex'), | ||||
| 				publicKey: Buffer.from(keyInfo.credentialPublicKey).toString('base64url'), | ||||
| 				counter: keyInfo.counter, | ||||
| 				credentialDeviceType: keyInfo.credentialDeviceType, | ||||
| 				credentialBackedUp: keyInfo.credentialBackedUp, | ||||
| 				transports: keyInfo.transports, | ||||
| 			}); | ||||
|  | ||||
| 			// Publish meUpdated event | ||||
| @@ -162,7 +92,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 			})); | ||||
|  | ||||
| 			return { | ||||
| 				id: credentialIdString, | ||||
| 				id: credentialId, | ||||
| 				name: ps.name, | ||||
| 			}; | ||||
| 		}); | ||||
|   | ||||
| @@ -3,22 +3,38 @@ | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { promisify } from 'node:util'; | ||||
| import * as crypto from 'node:crypto'; | ||||
| import bcrypt from 'bcryptjs'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import type { UserProfilesRepository, AttestationChallengesRepository } from '@/models/index.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js'; | ||||
| import type { UserProfilesRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| const randomBytes = promisify(crypto.randomBytes); | ||||
| import { WebAuthnService } from '@/core/WebAuthnService.js'; | ||||
| import { ApiError } from '@/server/api/error.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
|  | ||||
| 	secure: true, | ||||
|  | ||||
| 	errors: { | ||||
| 		userNotFound: { | ||||
| 			message: 'User not found.', | ||||
| 			code: 'USER_NOT_FOUND', | ||||
| 			id: '652f899f-66d4-490e-993e-6606c8ec04c3', | ||||
| 		}, | ||||
|  | ||||
| 		incorrectPassword: { | ||||
| 			message: 'Incorrect password.', | ||||
| 			code: 'INCORRECT_PASSWORD', | ||||
| 			id: '38769596-efe2-4faf-9bec-abbb3f2cd9ba', | ||||
| 		}, | ||||
|  | ||||
| 		twoFactorNotEnabled: { | ||||
| 			message: '2fa not enabled.', | ||||
| 			code: 'TWO_FACTOR_NOT_ENABLED', | ||||
| 			id: 'bf32b864-449b-47b8-974e-f9a5468546f1', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  | ||||
| export const paramDef = { | ||||
| @@ -36,47 +52,36 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
|  | ||||
| 		@Inject(DI.attestationChallengesRepository) | ||||
| 		private attestationChallengesRepository: AttestationChallengesRepository, | ||||
|  | ||||
| 		private idService: IdService, | ||||
| 		private twoFactorAuthenticationService: TwoFactorAuthenticationService, | ||||
| 		private webAuthnService: WebAuthnService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); | ||||
| 			const profile = await this.userProfilesRepository.findOne({ | ||||
| 				where: { | ||||
| 					userId: me.id, | ||||
| 				}, | ||||
| 				relations: ['user'], | ||||
| 			}); | ||||
|  | ||||
| 			if (profile == null) { | ||||
| 				throw new ApiError(meta.errors.userNotFound); | ||||
| 			} | ||||
|  | ||||
| 			// Compare password | ||||
| 			const same = await bcrypt.compare(ps.password, profile.password!); | ||||
| 			const same = await bcrypt.compare(ps.password, profile.password ?? ''); | ||||
|  | ||||
| 			if (!same) { | ||||
| 				throw new Error('incorrect password'); | ||||
| 				throw new ApiError(meta.errors.incorrectPassword); | ||||
| 			} | ||||
|  | ||||
| 			if (!profile.twoFactorEnabled) { | ||||
| 				throw new Error('2fa not enabled'); | ||||
| 				throw new ApiError(meta.errors.twoFactorNotEnabled); | ||||
| 			} | ||||
|  | ||||
| 			// 32 byte challenge | ||||
| 			const entropy = await randomBytes(32); | ||||
| 			const challenge = entropy.toString('base64') | ||||
| 				.replace(/=/g, '') | ||||
| 				.replace(/\+/g, '-') | ||||
| 				.replace(/\//g, '_'); | ||||
|  | ||||
| 			const challengeId = this.idService.genId(); | ||||
|  | ||||
| 			await this.attestationChallengesRepository.insert({ | ||||
| 				userId: me.id, | ||||
| 				id: challengeId, | ||||
| 				challenge: this.twoFactorAuthenticationService.hash(Buffer.from(challenge, 'utf-8')).toString('hex'), | ||||
| 				createdAt: new Date(), | ||||
| 				registrationChallenge: true, | ||||
| 			}); | ||||
|  | ||||
| 			return { | ||||
| 				challengeId, | ||||
| 				challenge, | ||||
| 			}; | ||||
| 			return await this.webAuthnService.initiateRegistration( | ||||
| 				me.id, | ||||
| 				profile.user?.username ?? me.id, | ||||
| 				profile.user?.name ?? undefined, | ||||
| 			); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -11,11 +11,20 @@ import type { UserProfilesRepository } from '@/models/index.js'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import { ApiError } from '@/server/api/error.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
|  | ||||
| 	secure: true, | ||||
|  | ||||
| 	errors: { | ||||
| 		incorrectPassword: { | ||||
| 			message: 'Incorrect password.', | ||||
| 			code: 'INCORRECT_PASSWORD', | ||||
| 			id: '78d6c839-20c9-4c66-b90a-fc0542168b48', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  | ||||
| export const paramDef = { | ||||
| @@ -40,10 +49,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 			const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); | ||||
|  | ||||
| 			// Compare password | ||||
| 			const same = await bcrypt.compare(ps.password, profile.password!); | ||||
| 			const same = await bcrypt.compare(ps.password, profile.password ?? ''); | ||||
|  | ||||
| 			if (!same) { | ||||
| 				throw new Error('incorrect password'); | ||||
| 				throw new ApiError(meta.errors.incorrectPassword); | ||||
| 			} | ||||
|  | ||||
| 			// Generate user's secret key | ||||
|   | ||||
| @@ -10,11 +10,20 @@ import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/model | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '@/server/api/error.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
|  | ||||
| 	secure: true, | ||||
|  | ||||
| 	errors: { | ||||
| 		incorrectPassword: { | ||||
| 			message: 'Incorrect password.', | ||||
| 			code: 'INCORRECT_PASSWORD', | ||||
| 			id: '141c598d-a825-44c8-9173-cfb9d92be493', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  | ||||
| export const paramDef = { | ||||
| @@ -43,10 +52,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 			const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); | ||||
|  | ||||
| 			// Compare password | ||||
| 			const same = await bcrypt.compare(ps.password, profile.password!); | ||||
| 			const same = await bcrypt.compare(ps.password, profile.password ?? ''); | ||||
|  | ||||
| 			if (!same) { | ||||
| 				throw new Error('incorrect password'); | ||||
| 				throw new ApiError(meta.errors.incorrectPassword); | ||||
| 			} | ||||
|  | ||||
| 			// Make sure we only delete the user's own creds | ||||
|   | ||||
| @@ -10,11 +10,20 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import type { UserProfilesRepository } from '@/models/index.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '@/server/api/error.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
|  | ||||
| 	secure: true, | ||||
|  | ||||
| 	errors: { | ||||
| 		incorrectPassword: { | ||||
| 			message: 'Incorrect password.', | ||||
| 			code: 'INCORRECT_PASSWORD', | ||||
| 			id: '7add0395-9901-4098-82f9-4f67af65f775', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  | ||||
| export const paramDef = { | ||||
| @@ -39,10 +48,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 			const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); | ||||
|  | ||||
| 			// Compare password | ||||
| 			const same = await bcrypt.compare(ps.password, profile.password!); | ||||
| 			const same = await bcrypt.compare(ps.password, profile.password ?? ''); | ||||
|  | ||||
| 			if (!same) { | ||||
| 				throw new Error('incorrect password'); | ||||
| 				throw new ApiError(meta.errors.incorrectPassword); | ||||
| 			} | ||||
|  | ||||
| 			await this.userProfilesRepository.update(me.id, { | ||||
|   | ||||
| @@ -6,7 +6,7 @@ | ||||
| import bcrypt from 'bcryptjs'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js'; | ||||
| import type { UserSecurityKeysRepository } from '@/models/index.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| @@ -25,7 +25,7 @@ export const meta = { | ||||
| 		}, | ||||
|  | ||||
| 		accessDenied: { | ||||
| 			message: 'You do not have edit privilege of the channel.', | ||||
| 			message: 'You do not have edit privilege of this key.', | ||||
| 			code: 'ACCESS_DENIED', | ||||
| 			id: '1fb7cb09-d46a-4fff-b8df-057708cce513', | ||||
| 		}, | ||||
| @@ -48,9 +48,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 		@Inject(DI.userSecurityKeysRepository) | ||||
| 		private userSecurityKeysRepository: UserSecurityKeysRepository, | ||||
|  | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 	) { | ||||
|   | ||||
| @@ -9,8 +9,16 @@ import * as assert from 'assert'; | ||||
| import * as crypto from 'node:crypto'; | ||||
| import cbor from 'cbor'; | ||||
| import * as OTPAuth from 'otpauth'; | ||||
| import { loadConfig } from '../../src/config.js'; | ||||
| import { signup, api, post, react, startServer, waitFire } from '../utils.js'; | ||||
| import { loadConfig } from '@/config.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 * as misskey from 'misskey-js'; | ||||
|  | ||||
| @@ -47,21 +55,18 @@ describe('2要素認証', () => { | ||||
|  | ||||
| 	const rpIdHash = (): Buffer => { | ||||
| 		return crypto.createHash('sha256') | ||||
| 			.update(Buffer.from(config.hostname, 'utf-8')) | ||||
| 			.update(Buffer.from(config.host, 'utf-8')) | ||||
| 			.digest(); | ||||
| 	}; | ||||
|  | ||||
| 	const keyDoneParam = (param: { | ||||
| 		keyName: string, | ||||
| 		challengeId: string, | ||||
| 		challenge: string, | ||||
| 		credentialId: Buffer, | ||||
| 		creationOptions: PublicKeyCredentialCreationOptionsJSON, | ||||
| 	}): { | ||||
| 		attestationObject: string, | ||||
| 		challengeId: string, | ||||
| 		clientDataJSON: string, | ||||
| 		password: string, | ||||
| 		name: string, | ||||
| 		credential: RegistrationResponseJSON, | ||||
| 	} => { | ||||
| 		// A COSE encoded public key | ||||
| 		const credentialPublicKey = cbor.encode(new Map<number, unknown>([ | ||||
| @@ -76,7 +81,7 @@ describe('2要素認証', () => { | ||||
| 		// AuthenticatorAssertionResponse.authenticatorData | ||||
| 		// https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData | ||||
| 		const credentialIdLength = Buffer.allocUnsafe(2); | ||||
| 		credentialIdLength.writeUInt16BE(param.credentialId.length); | ||||
| 		credentialIdLength.writeUInt16BE(param.credentialId.length, 0); | ||||
| 		const authData = Buffer.concat([ | ||||
| 			rpIdHash(), // rpIdHash(32) | ||||
| 			Buffer.from([0x45]), // flags(1) | ||||
| @@ -88,20 +93,27 @@ describe('2要素認証', () => { | ||||
| 		]); | ||||
|  | ||||
| 		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, | ||||
| 			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: { | ||||
| 		keyName: string, | ||||
| 		challengeId: string, | ||||
| 		challenge: string, | ||||
| 		credentialId: Buffer, | ||||
| 		requestOptions: PublicKeyCredentialRequestOptionsJSON, | ||||
| 	}): { | ||||
| 		authenticatorData: string, | ||||
| 		credentialId: string, | ||||
| 		challengeId: string, | ||||
| 		clientDataJSON: string, | ||||
| 		username: string, | ||||
| 		password: string, | ||||
| 		signature: string, | ||||
| 		credential: AuthenticationResponseJSON, | ||||
| 		'g-recaptcha-response'?: string | null, | ||||
| 		'hcaptcha-response'?: string | null, | ||||
| 	} => { | ||||
| @@ -144,10 +151,10 @@ describe('2要素認証', () => { | ||||
| 		]); | ||||
| 		const clientDataJSONBuffer = Buffer.from(JSON.stringify({ | ||||
| 			type: 'webauthn.get', | ||||
| 			challenge: param.challenge, | ||||
| 			challenge: param.requestOptions.challenge, | ||||
| 			origin: config.scheme + '://' + config.host, | ||||
| 			androidPackageName: 'org.mozilla.firefox', | ||||
| 		})); | ||||
| 		}), 'utf-8'); | ||||
| 		const hashedclientDataJSON = crypto.createHash('sha256') | ||||
| 			.update(clientDataJSONBuffer) | ||||
| 			.digest(); | ||||
| @@ -156,13 +163,19 @@ describe('2要素認証', () => { | ||||
| 			.update(Buffer.concat([authenticatorData, hashedclientDataJSON])) | ||||
| 			.sign(privateKey); | ||||
| 		return { | ||||
| 			authenticatorData: authenticatorData.toString('hex'), | ||||
| 			credentialId: param.credentialId.toString('base64'), | ||||
| 			challengeId: param.challengeId, | ||||
| 			clientDataJSON: clientDataJSONBuffer.toString('hex'), | ||||
| 			username, | ||||
| 			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, | ||||
| 			'hcaptcha-response': null, | ||||
| 		}; | ||||
| @@ -222,19 +235,18 @@ describe('2要素認証', () => { | ||||
| 			password, | ||||
| 		}, alice); | ||||
| 		assert.strictEqual(registerKeyResponse.status, 200); | ||||
| 		assert.notEqual(registerKeyResponse.body.challengeId, undefined); | ||||
| 		assert.notEqual(registerKeyResponse.body.rp, undefined); | ||||
| 		assert.notEqual(registerKeyResponse.body.challenge, undefined); | ||||
|  | ||||
| 		const keyName = 'example-key'; | ||||
| 		const credentialId = crypto.randomBytes(0x41); | ||||
| 		const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ | ||||
| 			keyName, | ||||
| 			challengeId: registerKeyResponse.body.challengeId, | ||||
| 			challenge: registerKeyResponse.body.challenge, | ||||
| 			credentialId, | ||||
| 			creationOptions: registerKeyResponse.body, | ||||
| 		}), alice); | ||||
| 		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); | ||||
|  | ||||
| 		const usersShowResponse = await api('/users/show', { | ||||
| @@ -248,16 +260,14 @@ describe('2要素認証', () => { | ||||
| 		}); | ||||
| 		assert.strictEqual(signinResponse.status, 200); | ||||
| 		assert.strictEqual(signinResponse.body.i, undefined); | ||||
| 		assert.notEqual(signinResponse.body.challengeId, undefined); | ||||
| 		assert.notEqual(signinResponse.body.challenge, undefined); | ||||
| 		assert.notEqual(signinResponse.body.securityKeys, undefined); | ||||
| 		assert.strictEqual(signinResponse.body.securityKeys[0].id, credentialId.toString('hex')); | ||||
| 		assert.notEqual(signinResponse.body.allowCredentials, undefined); | ||||
| 		assert.strictEqual(signinResponse.body.allowCredentials[0].id, credentialId.toString('base64url')); | ||||
|  | ||||
| 		const signinResponse2 = await api('/signin', signinWithSecurityKeyParam({ | ||||
| 			keyName, | ||||
| 			challengeId: signinResponse.body.challengeId, | ||||
| 			challenge: signinResponse.body.challenge, | ||||
| 			credentialId, | ||||
| 			requestOptions: signinResponse.body, | ||||
| 		})); | ||||
| 		assert.strictEqual(signinResponse2.status, 200); | ||||
| 		assert.notEqual(signinResponse2.body.i, undefined); | ||||
| @@ -283,9 +293,8 @@ describe('2要素認証', () => { | ||||
| 		const credentialId = crypto.randomBytes(0x41); | ||||
| 		const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ | ||||
| 			keyName, | ||||
| 			challengeId: registerKeyResponse.body.challengeId, | ||||
| 			challenge: registerKeyResponse.body.challenge, | ||||
| 			credentialId, | ||||
| 			creationOptions: registerKeyResponse.body, | ||||
| 		}), alice); | ||||
| 		assert.strictEqual(keyDoneResponse.status, 200); | ||||
|  | ||||
| @@ -310,9 +319,8 @@ describe('2要素認証', () => { | ||||
| 		const signinResponse2 = await api('/signin', { | ||||
| 			...signinWithSecurityKeyParam({ | ||||
| 				keyName, | ||||
| 				challengeId: signinResponse.body.challengeId, | ||||
| 				challenge: signinResponse.body.challenge, | ||||
| 				credentialId, | ||||
| 				requestOptions: signinResponse.body, | ||||
| 			}), | ||||
| 			password: '', | ||||
| 		}); | ||||
| @@ -340,23 +348,22 @@ describe('2要素認証', () => { | ||||
| 		const credentialId = crypto.randomBytes(0x41); | ||||
| 		const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ | ||||
| 			keyName, | ||||
| 			challengeId: registerKeyResponse.body.challengeId, | ||||
| 			challenge: registerKeyResponse.body.challenge, | ||||
| 			credentialId, | ||||
| 			creationOptions: registerKeyResponse.body, | ||||
| 		}), alice); | ||||
| 		assert.strictEqual(keyDoneResponse.status, 200); | ||||
|  | ||||
| 		const renamedKey = 'other-key'; | ||||
| 		const updateKeyResponse = await api('/i/2fa/update-key', { | ||||
| 			name: renamedKey, | ||||
| 			credentialId: credentialId.toString('hex'), | ||||
| 			credentialId: credentialId.toString('base64url'), | ||||
| 		}, alice); | ||||
| 		assert.strictEqual(updateKeyResponse.status, 200); | ||||
|  | ||||
| 		const iResponse = await api('/i', { | ||||
| 		}, alice); | ||||
| 		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[0].name, renamedKey); | ||||
| 		assert.notEqual(securityKeys[0].lastUsed, undefined); | ||||
| @@ -382,9 +389,8 @@ describe('2要素認証', () => { | ||||
| 		const credentialId = crypto.randomBytes(0x41); | ||||
| 		const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ | ||||
| 			keyName, | ||||
| 			challengeId: registerKeyResponse.body.challengeId, | ||||
| 			challenge: registerKeyResponse.body.challenge, | ||||
| 			credentialId, | ||||
| 			creationOptions: registerKeyResponse.body, | ||||
| 		}), alice); | ||||
| 		assert.strictEqual(keyDoneResponse.status, 200); | ||||
|  | ||||
|   | ||||
| @@ -16,6 +16,7 @@ | ||||
| 	}, | ||||
| 	"dependencies": { | ||||
| 		"@discordapp/twemoji": "14.1.2", | ||||
| 		"@github/webauthn-json": "^2.1.1", | ||||
| 		"@rollup/plugin-alias": "5.0.0", | ||||
| 		"@rollup/plugin-json": "6.0.0", | ||||
| 		"@rollup/plugin-replace": "5.0.2", | ||||
|   | ||||
| @@ -6,16 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <template> | ||||
| <form :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> | ||||
| 	<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"> | ||||
| 			{{ message }} | ||||
| 		</MkInfo> | ||||
| 		<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 #suffix>@{{ host }}</template> | ||||
| 			</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 #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template> | ||||
| 			</MkInput> | ||||
| @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 		</div> | ||||
| 		<div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }"> | ||||
| 			<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"> | ||||
| 					{{ i18n.ts.retry }} | ||||
| 				</MkButton> | ||||
| @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 				<p class="or-msg">{{ i18n.ts.or }}</p> | ||||
| 			</div> | ||||
| 			<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> | ||||
| 					<template #label>{{ i18n.ts.password }}</template> | ||||
| 					<template #prefix><i class="ti ti-lock"></i></template> | ||||
| @@ -51,35 +51,30 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <script lang="ts" setup> | ||||
| import { defineAsyncComponent } from 'vue'; | ||||
| 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 MkInput from '@/components/MkInput.vue'; | ||||
| import MkInfo from '@/components/MkInfo.vue'; | ||||
| import { host as configHost } from '@/config'; | ||||
| import { byteify, hexify } from '@/scripts/2fa'; | ||||
| import * as os from '@/os'; | ||||
| import { login } from '@/account'; | ||||
| import { instance } from '@/instance'; | ||||
| import { i18n } from '@/i18n'; | ||||
|  | ||||
| let signing = $ref(false); | ||||
| let user = $ref(null); | ||||
| let user = $ref<UserDetailed | null>(null); | ||||
| let username = $ref(''); | ||||
| let password = $ref(''); | ||||
| let token = $ref(''); | ||||
| let host = $ref(toUnicode(configHost)); | ||||
| let totpLogin = $ref(false); | ||||
| let credential = $ref(null); | ||||
| let challengeData = $ref(null); | ||||
| let queryingKey = $ref(false); | ||||
| let credentialRequest = $ref<CredentialRequestOptions | null>(null); | ||||
| let hCaptchaResponse = $ref(null); | ||||
| let reCaptchaResponse = $ref(null); | ||||
|  | ||||
| const meta = $computed(() => instance); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'login', v: any): void; | ||||
| }>(); | ||||
| const emit = defineEmits<(ev: 'login', v: any) => void>(); | ||||
|  | ||||
| const props = defineProps({ | ||||
| 	withAvatar: { | ||||
| @@ -99,7 +94,7 @@ const props = defineProps({ | ||||
| 	}, | ||||
| }); | ||||
|  | ||||
| function onUsernameChange() { | ||||
| function onUsernameChange(): void { | ||||
| 	os.api('users/show', { | ||||
| 		username: username, | ||||
| 	}).then(userResponse => { | ||||
| @@ -109,58 +104,46 @@ function onUsernameChange() { | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function onLogin(res) { | ||||
| function onLogin(res: any): Promise<void> | void { | ||||
| 	if (props.autoSet) { | ||||
| 		return login(res.i); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| function queryKey() { | ||||
| async function queryKey(): Promise<void> { | ||||
| 	queryingKey = true; | ||||
| 	return navigator.credentials.get({ | ||||
| 		publicKey: { | ||||
| 			challenge: byteify(challengeData.challenge, 'base64'), | ||||
| 			allowCredentials: challengeData.securityKeys.map(key => ({ | ||||
| 				id: byteify(key.id, 'hex'), | ||||
| 				type: 'public-key', | ||||
| 				transports: ['usb', 'nfc', 'ble', 'internal'], | ||||
| 			})), | ||||
| 			timeout: 60 * 1000, | ||||
| 		}, | ||||
| 	}).catch(() => { | ||||
| 		queryingKey = false; | ||||
| 		return Promise.reject(null); | ||||
| 	}).then(credential => { | ||||
| 		queryingKey = false; | ||||
| 		signing = true; | ||||
| 		return os.api('signin', { | ||||
| 			username, | ||||
| 			password, | ||||
| 			signature: hexify(credential.response.signature), | ||||
| 			authenticatorData: hexify(credential.response.authenticatorData), | ||||
| 			clientDataJSON: hexify(credential.response.clientDataJSON), | ||||
| 			credentialId: credential.id, | ||||
| 			challengeId: challengeData.challengeId, | ||||
| 			'hcaptcha-response': hCaptchaResponse, | ||||
| 			'g-recaptcha-response': reCaptchaResponse, | ||||
| 	await WebAuthnRequest(credentialRequest) | ||||
| 		.catch(() => { | ||||
| 			queryingKey = false; | ||||
| 			return Promise.reject(null); | ||||
| 		}).then(credential => { | ||||
| 			credentialRequest = null; | ||||
| 			queryingKey = false; | ||||
| 			signing = true; | ||||
| 			return os.api('signin', { | ||||
| 				username, | ||||
| 				password, | ||||
| 				credential: credential.toJSON(), | ||||
| 				'hcaptcha-response': hCaptchaResponse, | ||||
| 				'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; | ||||
| 		}); | ||||
| 	}).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; | ||||
| 	if (!totpLogin && user && user.twoFactorEnabled) { | ||||
| 		if (window.PublicKeyCredential && user.securityKeys) { | ||||
| 		if (WebAuthnSupported() && user.securityKeys) { | ||||
| 			os.api('signin', { | ||||
| 				username, | ||||
| 				password, | ||||
| @@ -169,9 +152,12 @@ function onSubmit() { | ||||
| 			}).then(res => { | ||||
| 				totpLogin = true; | ||||
| 				signing = false; | ||||
| 				challengeData = res; | ||||
| 				return queryKey(); | ||||
| 			}).catch(loginFailed); | ||||
| 				credentialRequest = parseRequestOptionsFromJSON({ | ||||
| 					publicKey: res, | ||||
| 				}); | ||||
| 			}) | ||||
| 				.then(() => queryKey()) | ||||
| 				.catch(loginFailed); | ||||
| 		} else { | ||||
| 			totpLogin = true; | ||||
| 			signing = false; | ||||
| @@ -182,7 +168,7 @@ function onSubmit() { | ||||
| 			password, | ||||
| 			'hcaptcha-response': hCaptchaResponse, | ||||
| 			'g-recaptcha-response': reCaptchaResponse, | ||||
| 			token: user && user.twoFactorEnabled ? token : undefined, | ||||
| 			token: user?.twoFactorEnabled ? token : undefined, | ||||
| 		}).then(res => { | ||||
| 			emit('login', res); | ||||
| 			onLogin(res); | ||||
| @@ -190,7 +176,7 @@ function onSubmit() { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| function loginFailed(err) { | ||||
| function loginFailed(err: any): void { | ||||
| 	switch (err.id) { | ||||
| 		case '6cc579cc-885d-43d8-95c2-b8c7fc963280': { | ||||
| 			os.alert({ | ||||
| @@ -221,7 +207,7 @@ function loginFailed(err) { | ||||
| 			break; | ||||
| 		} | ||||
| 		default: { | ||||
| 			console.log(err); | ||||
| 			console.error(err); | ||||
| 			os.alert({ | ||||
| 				type: 'error', | ||||
| 				title: i18n.ts.loginFailed, | ||||
| @@ -230,12 +216,11 @@ function loginFailed(err) { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	challengeData = null; | ||||
| 	totpLogin = false; | ||||
| 	signing = false; | ||||
| } | ||||
|  | ||||
| function resetPassword() { | ||||
| function resetPassword(): void { | ||||
| 	os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, { | ||||
| 	}, 'closed'); | ||||
| } | ||||
| @@ -246,8 +231,7 @@ function resetPassword() { | ||||
| 	margin: 0 auto 0 auto; | ||||
| 	width: 64px; | ||||
| 	height: 64px; | ||||
| 	background: #ddd; | ||||
| 	background-position: center; | ||||
| 	background: #ddd center; | ||||
| 	background-size: cover; | ||||
| 	border-radius: 100%; | ||||
| } | ||||
|   | ||||
| @@ -35,17 +35,13 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 			<template #icon><i class="ti ti-key"></i></template> | ||||
| 			<template #label>{{ i18n.ts.securityKeyAndPasskey }}</template> | ||||
| 			<div class="_gaps_s"> | ||||
| 				<MkInfo> | ||||
| 					{{ i18n.ts._2fa.securityKeyInfo }}<br> | ||||
| 					<br> | ||||
| 					{{ i18n.ts._2fa.chromePasskeyNotSupported }} | ||||
| 				</MkInfo> | ||||
| 				<MkInfo>{{ i18n.ts._2fa.securityKeyInfo }}</MkInfo> | ||||
|  | ||||
| 				<MkInfo v-if="!supportsCredentials" warn> | ||||
| 				<MkInfo v-if="!WebAuthnSupported()" warn> | ||||
| 					{{ i18n.ts._2fa.securityKeyNotSupported }} | ||||
| 				</MkInfo> | ||||
|  | ||||
| 				<MkInfo v-else-if="supportsCredentials && !$i.twoFactorEnabled" warn> | ||||
| 				<MkInfo v-else-if="WebAuthnSupported() && !$i.twoFactorEnabled" warn> | ||||
| 					{{ i18n.ts._2fa.registerTOTPBeforeKey }} | ||||
| 				</MkInfo> | ||||
|  | ||||
| @@ -72,9 +68,8 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { ref, defineAsyncComponent } from 'vue'; | ||||
| import { hostname } from '@/config'; | ||||
| import { byteify, hexify, stringify } from '@/scripts/2fa'; | ||||
| import { defineAsyncComponent } from 'vue'; | ||||
| import { supported as WebAuthnSupported, create as WebAuthnCreate, parseCreationOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInfo from '@/components/MkInfo.vue'; | ||||
| import MkSwitch from '@/components/MkSwitch.vue'; | ||||
| @@ -92,11 +87,10 @@ withDefaults(defineProps<{ | ||||
| 	first: false, | ||||
| }); | ||||
|  | ||||
| const twoFactorData = ref<any>(null); | ||||
| const supportsCredentials = ref(!!navigator.credentials); | ||||
| const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin); | ||||
| const usePasswordLessLogin = $computed(() => $i?.usePasswordLessLogin ?? false); | ||||
| let twoFactorData = $ref<{ qr: string; url: string; secret: string; label: string; issuer: string } | null>(null); | ||||
|  | ||||
| async function registerTOTP() { | ||||
| async function registerTOTP(): Promise<void> { | ||||
| 	const password = await os.inputText({ | ||||
| 		title: i18n.ts._2fa.registerTOTP, | ||||
| 		text: i18n.ts._2fa.passwordToTOTP, | ||||
| @@ -105,7 +99,8 @@ async function registerTOTP() { | ||||
| 	}); | ||||
| 	if (password.canceled) return; | ||||
|  | ||||
| 	const twoFactorData = await os.apiWithDialog('i/2fa/register', { | ||||
| 	twoFactorData = <{ qr: string; url: string; secret: string; label: string; issuer: string }> | ||||
| 	await os.apiWithDialog('i/2fa/register', { | ||||
| 		password: password.result, | ||||
| 	}); | ||||
|  | ||||
| @@ -126,7 +121,8 @@ async function registerTOTP() { | ||||
| 	}); | ||||
| 	if (token.canceled) return; | ||||
|  | ||||
| 	const { backupCodes } = await os.apiWithDialog('i/2fa/done', { | ||||
| 	const { backupCodes } = <{ backupCodes: string[] }> | ||||
| 	await os.apiWithDialog('i/2fa/done', { | ||||
| 		token: token.result.toString(), | ||||
| 	}); | ||||
|  | ||||
| @@ -136,7 +132,7 @@ async function registerTOTP() { | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function unregisterTOTP() { | ||||
| function unregisterTOTP(): void { | ||||
| 	os.inputText({ | ||||
| 		title: i18n.ts.password, | ||||
| 		type: 'password', | ||||
| @@ -154,7 +150,7 @@ function unregisterTOTP() { | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function renewTOTP() { | ||||
| function renewTOTP(): void { | ||||
| 	os.confirm({ | ||||
| 		type: 'question', | ||||
| 		title: i18n.ts._2fa.renewTOTP, | ||||
| @@ -167,7 +163,7 @@ function renewTOTP() { | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| async function unregisterKey(key) { | ||||
| async function unregisterKey(key): Promise<void> { | ||||
| 	const confirm = await os.confirm({ | ||||
| 		type: 'question', | ||||
| 		title: i18n.ts._2fa.removeKey, | ||||
| @@ -185,11 +181,15 @@ async function unregisterKey(key) { | ||||
| 	await os.apiWithDialog('i/2fa/remove-key', { | ||||
| 		password: password.result, | ||||
| 		credentialId: key.id, | ||||
| 	}); | ||||
| 	os.success(); | ||||
| 	}) | ||||
| 		.then(() => os.success()) | ||||
| 		.catch(error => os.alert({ | ||||
| 			type: 'error', | ||||
| 			text: error, | ||||
| 		})); | ||||
| } | ||||
|  | ||||
| async function renameKey(key) { | ||||
| async function renameKey(key): Promise<void> { | ||||
| 	const name = await os.inputText({ | ||||
| 		title: i18n.ts.rename, | ||||
| 		default: key.name, | ||||
| @@ -205,7 +205,7 @@ async function renameKey(key) { | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| async function addSecurityKey() { | ||||
| async function addSecurityKey(): Promise<void> { | ||||
| 	const password = await os.inputText({ | ||||
| 		title: i18n.ts.password, | ||||
| 		type: 'password', | ||||
| @@ -213,8 +213,10 @@ async function addSecurityKey() { | ||||
| 	}); | ||||
| 	if (password.canceled) return; | ||||
|  | ||||
| 	const challenge: any = await os.apiWithDialog('i/2fa/register-key', { | ||||
| 		password: password.result, | ||||
| 	const registrationOptions = parseCreationOptionsFromJSON({ | ||||
| 		publicKey: await os.apiWithDialog('i/2fa/register-key', { | ||||
| 			password: password.result, | ||||
| 		}), | ||||
| 	}); | ||||
|  | ||||
| 	const name = await os.inputText({ | ||||
| @@ -226,26 +228,8 @@ async function addSecurityKey() { | ||||
| 	}); | ||||
| 	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( | ||||
| 		webAuthnCreation, | ||||
| 		WebAuthnCreate(registrationOptions), | ||||
| 		null, | ||||
| 		() => {}, // ユーザーのキャンセルはrejectなのでエラーダイアログを出さない | ||||
| 		i18n.ts._2fa.tapSecurityKey, | ||||
| @@ -255,14 +239,11 @@ async function addSecurityKey() { | ||||
| 	await os.apiWithDialog('i/2fa/key-done', { | ||||
| 		password: password.result, | ||||
| 		name: name.result, | ||||
| 		challengeId: challenge.challengeId, | ||||
| 		// we convert each 16 bits to a string to serialise | ||||
| 		clientDataJSON: stringify(credential.response.clientDataJSON), | ||||
| 		attestationObject: hexify(credential.response.attestationObject), | ||||
| 		credential: credential.toJSON(), | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| async function updatePasswordLessLogin(value: boolean) { | ||||
| async function updatePasswordLessLogin(value: boolean): Promise<void> { | ||||
| 	await os.apiWithDialog('i/2fa/password-less', { | ||||
| 		value, | ||||
| 	}); | ||||
|   | ||||
| @@ -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)); | ||||
| } | ||||
| @@ -2477,7 +2477,6 @@ type MeDetailed = UserDetailed & { | ||||
|     mutingNotificationTypes: string[]; | ||||
|     noCrawle: boolean; | ||||
|     receiveAnnouncementEmail: boolean; | ||||
|     usePasswordLessLogin: boolean; | ||||
|     unreadAnnouncements: Announcement[]; | ||||
|     [other: string]: any; | ||||
| }; | ||||
| @@ -2798,6 +2797,7 @@ type UserDetailed = UserLite & { | ||||
|     publicReactions: boolean; | ||||
|     securityKeys: boolean; | ||||
|     twoFactorEnabled: boolean; | ||||
|     usePasswordLessLogin: boolean; | ||||
|     updatedAt: DateString | null; | ||||
|     uri: string | null; | ||||
|     url: string | null; | ||||
|   | ||||
| @@ -67,6 +67,7 @@ export type UserDetailed = UserLite & { | ||||
| 	publicReactions: boolean; | ||||
| 	securityKeys: boolean; | ||||
| 	twoFactorEnabled: boolean; | ||||
| 	usePasswordLessLogin: boolean; | ||||
| 	updatedAt: DateString | null; | ||||
| 	uri: string | null; | ||||
| 	url: string | null; | ||||
| @@ -105,7 +106,6 @@ export type MeDetailed = UserDetailed & { | ||||
| 	mutingNotificationTypes: string[]; | ||||
| 	noCrawle: boolean; | ||||
| 	receiveAnnouncementEmail: boolean; | ||||
| 	usePasswordLessLogin: boolean; | ||||
| 	unreadAnnouncements: Announcement[]; | ||||
| 	[other: string]: any; | ||||
| }; | ||||
|   | ||||
							
								
								
									
										197
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										197
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							| @@ -122,6 +122,9 @@ importers: | ||||
|       '@peertube/http-signature': | ||||
|         specifier: 1.7.0 | ||||
|         version: 1.7.0 | ||||
|       '@simplewebauthn/server': | ||||
|         specifier: ^7.4.0 | ||||
|         version: 7.4.0 | ||||
|       '@sinonjs/fake-timers': | ||||
|         specifier: 10.3.0 | ||||
|         version: 10.3.0 | ||||
| @@ -472,6 +475,9 @@ importers: | ||||
|       '@jest/globals': | ||||
|         specifier: 29.6.1 | ||||
|         version: 29.6.1 | ||||
|       '@simplewebauthn/typescript-types': | ||||
|         specifier: ^7.4.0 | ||||
|         version: 7.4.0 | ||||
|       '@swc/jest': | ||||
|         specifier: 0.2.26 | ||||
|         version: 0.2.26(@swc/core@1.3.70) | ||||
| @@ -610,6 +616,9 @@ importers: | ||||
|       '@discordapp/twemoji': | ||||
|         specifier: 14.1.2 | ||||
|         version: 14.1.2 | ||||
|       '@github/webauthn-json': | ||||
|         specifier: ^2.1.1 | ||||
|         version: 2.1.1 | ||||
|       '@rollup/plugin-alias': | ||||
|         specifier: 5.0.0 | ||||
|         version: 5.0.0(rollup@3.26.3) | ||||
| @@ -4144,6 +4153,54 @@ packages: | ||||
|     resolution: {integrity: sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==} | ||||
|     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: | ||||
|     resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} | ||||
|     engines: {node: '>=0.1.90'} | ||||
| @@ -4872,6 +4929,11 @@ packages: | ||||
|       hashlru: 2.3.0 | ||||
|     dev: false | ||||
|  | ||||
|   /@github/webauthn-json@2.1.1: | ||||
|     resolution: {integrity: sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ==} | ||||
|     hasBin: true | ||||
|     dev: false | ||||
|  | ||||
|   /@hapi/hoek@9.3.0: | ||||
|     resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} | ||||
|     dev: true | ||||
| @@ -4882,6 +4944,10 @@ packages: | ||||
|       '@hapi/hoek': 9.3.0 | ||||
|     dev: true | ||||
|  | ||||
|   /@hexagon/base64@1.1.27: | ||||
|     resolution: {integrity: sha512-PdUmzpvcUM3Rh39kvz9RdbPVYhMjBjdV7Suw7ZduP7urRLsZR8l5tzgSWKm7TExwBYDFwTnYrZbnE0rQ3N5NLQ==} | ||||
|     dev: false | ||||
|  | ||||
|   /@humanwhocodes/config-array@0.11.10: | ||||
|     resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==} | ||||
|     engines: {node: '>=10.10.0'} | ||||
| @@ -5549,6 +5615,50 @@ packages: | ||||
|     resolution: {integrity: sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==} | ||||
|     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.0 | ||||
|     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.0 | ||||
|     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.0 | ||||
|     dev: false | ||||
|  | ||||
|   /@peculiar/asn1-schema@2.3.6: | ||||
|     resolution: {integrity: sha512-izNRxPoaeJeg/AyH8hER6s+H7p4itk+03QCa4sbxI3lNdseQYCuxzgsuNK8bTXChtLTjpJz6NmXKA73qLa3rCA==} | ||||
|     dependencies: | ||||
|       asn1js: 3.0.5 | ||||
|       pvtsutils: 1.3.3 | ||||
|       tslib: 2.6.0 | ||||
|     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.3 | ||||
|       tslib: 2.6.0 | ||||
|     dev: false | ||||
|  | ||||
|   /@peertube/http-signature@1.7.0: | ||||
|     resolution: {integrity: sha512-aGQIwo6/sWtyyqhVK4e1MtxYz4N1X8CNt6SOtCc+Wnczs5S5ONaLHDDR8LYaGn0MgOwvGgXyuZ5sJIfd7iyoUw==} | ||||
|     engines: {node: '>=0.10'} | ||||
| @@ -5695,6 +5805,38 @@ packages: | ||||
|     resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} | ||||
|     dev: true | ||||
|  | ||||
|   /@simplewebauthn/iso-webcrypto@7.4.0: | ||||
|     resolution: {integrity: sha512-LSx8zghjH+z9IFOhBdDv2AyhqnzDUCYFxFiwJbToowOigCgf4Y8fyZle9Y+0NS232bIoU6j/lgv5iT32m3eGyA==} | ||||
|     dependencies: | ||||
|       '@simplewebauthn/typescript-types': 7.4.0 | ||||
|       '@types/node': 18.11.18 | ||||
|     dev: false | ||||
|  | ||||
|   /@simplewebauthn/server@7.4.0: | ||||
|     resolution: {integrity: sha512-Y6jj2WsE3zBDagSdOg3b7+SMw7zHku0Od45Q1ZpA19Wd5aUbV2mH281SbdhFN4UuKcGQSeeAgUObAWHvgxNOVA==} | ||||
|     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/iso-webcrypto': 7.4.0 | ||||
|       '@simplewebauthn/typescript-types': 7.4.0 | ||||
|       '@types/debug': 4.1.7 | ||||
|       '@types/node': 18.11.18 | ||||
|       cbor-x: 1.5.3 | ||||
|       cross-fetch: 3.1.6 | ||||
|       debug: 4.3.4(supports-color@8.1.1) | ||||
|     transitivePeerDependencies: | ||||
|       - encoding | ||||
|       - supports-color | ||||
|     dev: false | ||||
|  | ||||
|   /@simplewebauthn/typescript-types@7.4.0: | ||||
|     resolution: {integrity: sha512-8/ZjHeUPe210Bt5oyaOIGx4h8lHdsQs19BiOT44gi/jBEgK7uBGA0Fy7NRsyh777al3m6WM0mBf0UR7xd4R7WQ==} | ||||
|  | ||||
|   /@sinclair/typebox@0.24.51: | ||||
|     resolution: {integrity: sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==} | ||||
|     dev: true | ||||
| @@ -7973,7 +8115,6 @@ packages: | ||||
|     resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==} | ||||
|     dependencies: | ||||
|       '@types/ms': 0.7.31 | ||||
|     dev: true | ||||
|  | ||||
|   /@types/detect-port@1.3.2: | ||||
|     resolution: {integrity: sha512-xxgAGA2SAU4111QefXPSp5eGbDm/hW6zhvYl9IeEPZEry9F4d66QAHm5qpUXjb6IsevZV/7emAEx5MhP6O192g==} | ||||
| @@ -8211,7 +8352,6 @@ packages: | ||||
|  | ||||
|   /@types/ms@0.7.31: | ||||
|     resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} | ||||
|     dev: true | ||||
|  | ||||
|   /@types/node-fetch@2.6.4: | ||||
|     resolution: {integrity: sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==} | ||||
| @@ -9390,6 +9530,15 @@ packages: | ||||
|     dependencies: | ||||
|       safer-buffer: 2.1.2 | ||||
|  | ||||
|   /asn1js@3.0.5: | ||||
|     resolution: {integrity: sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==} | ||||
|     engines: {node: '>=12.0.0'} | ||||
|     dependencies: | ||||
|       pvtsutils: 1.3.3 | ||||
|       pvutils: 1.1.3 | ||||
|       tslib: 2.6.0 | ||||
|     dev: false | ||||
|  | ||||
|   /assert-never@1.2.1: | ||||
|     resolution: {integrity: sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw==} | ||||
|  | ||||
| @@ -10204,6 +10353,28 @@ packages: | ||||
|   /caseless@0.12.0: | ||||
|     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.3: | ||||
|     resolution: {integrity: sha512-adrN0S67C7jY2hgqeGcw+Uj6iEGLQa5D/p6/9YNl5AaVIYJaJz/bARfWsP8UikBZWbhS27LN0DJK4531vo9ODw==} | ||||
|     optionalDependencies: | ||||
|       cbor-extract: 2.1.1 | ||||
|     dev: false | ||||
|  | ||||
|   /cbor@9.0.0: | ||||
|     resolution: {integrity: sha512-87cFgOKxjUOnGpNeQMBVER4Mc/rZAk9xC+Ygfx5FLCAUt/tpVHphuZC5fJmp/KSDsEsBEDIPtEt0YbD/GFQw8Q==} | ||||
|     engines: {node: '>=16'} | ||||
| @@ -16421,6 +16592,13 @@ packages: | ||||
|       fetch-blob: 3.2.0 | ||||
|       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: | ||||
|     resolution: {integrity: sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==} | ||||
|     hasBin: true | ||||
| @@ -18038,6 +18216,17 @@ packages: | ||||
|       pngjs: 3.4.0 | ||||
|     dev: false | ||||
|  | ||||
|   /pvtsutils@1.3.3: | ||||
|     resolution: {integrity: sha512-6sAOMlXyrJ+8tRN5IAaYfuYZRp1C2uJ0SyDynEFxL+VY8kCRib9Lpj/+KPaNFpaQWr/iRik5nrzz6iaNlxgEGA==} | ||||
|     dependencies: | ||||
|       tslib: 2.6.1 | ||||
|     dev: false | ||||
|  | ||||
|   /pvutils@1.1.3: | ||||
|     resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==} | ||||
|     engines: {node: '>=6.0.0'} | ||||
|     dev: false | ||||
|  | ||||
|   /q@1.5.1: | ||||
|     resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} | ||||
|     engines: {node: '>=0.6.0', teleport: '>=0.2.0'} | ||||
| @@ -20344,6 +20533,10 @@ packages: | ||||
|   /tslib@2.6.0: | ||||
|     resolution: {integrity: sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==} | ||||
|  | ||||
|   /tslib@2.6.1: | ||||
|     resolution: {integrity: sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==} | ||||
|     dev: false | ||||
|  | ||||
|   /tsutils@3.21.0(typescript@5.1.6): | ||||
|     resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} | ||||
|     engines: {node: '>= 6'} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 まっちゃとーにゅ
					まっちゃとーにゅ