feat: パスキーに対応 (MisskeyIO#149)
This commit is contained in:
parent
001f6377d4
commit
690a4d5d53
|
@ -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 {
|
||||
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('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,
|
||||
}).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,38 +104,26 @@ 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(() => {
|
||||
await WebAuthnRequest(credentialRequest)
|
||||
.catch(() => {
|
||||
queryingKey = false;
|
||||
return Promise.reject(null);
|
||||
}).then(credential => {
|
||||
credentialRequest = null;
|
||||
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,
|
||||
credential: credential.toJSON(),
|
||||
'hcaptcha-response': hCaptchaResponse,
|
||||
'g-recaptcha-response': reCaptchaResponse,
|
||||
});
|
||||
|
@ -157,10 +140,10 @@ function queryKey() {
|
|||
});
|
||||
}
|
||||
|
||||
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', {
|
||||
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
197
pnpm-lock.yaml
|
@ -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'}
|
||||
|
|
Loading…
Reference in a new issue