Implement Webauthn 🎉 (#5088)
* Implement Webauthn 🎉
* Share hexifyAB
* Move hr inside template and add AttestationChallenges janitor daemon
* Apply suggestions from code review
Co-Authored-By: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
* Add newline at the end of file
* Fix stray newline in promise chain
* Ignore var in try{}catch(){} block
Co-Authored-By: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
* Add missing comma
* Add missing semicolon
* Support more attestation formats
* add support for more key types and linter pass
* Refactor
* Refactor
* credentialId --> id
* Fix
* Improve readability
* Add indexes
* fixes for credentialId->id
* Avoid changing store state
* Fix syntax error and code style
* Remove unused import
* Refactor of getkey API
* Create 1561706992953-webauthn.ts
* Update ja-JP.yml
* Add type annotations
* Fix code style
* Specify depedency version
* Fix code style
* Fix janitor daemon and login requesting 2FA regardless of status
			
			
This commit is contained in:
		
							parent
							
								
									f17e229c1e
								
							
						
					
					
						commit
						fd94b817ab
					
				
					 21 changed files with 1376 additions and 64 deletions
				
			
		|  | @ -601,6 +601,8 @@ common/views/components/signin.vue: | |||
|   signin-with-github: "Sign in with GitHub" | ||||
|   signin-with-discord: "Sign in with Discord" | ||||
|   login-failed: "Logging in has failed. Make sure you have entered the correct username and password." | ||||
|   tap-key: "Activate your security key by tapping or clicking it to login" | ||||
|   enter-2fa-code: "Enter your 2FA code below" | ||||
| common/views/components/signup.vue: | ||||
|   invitation-code: "Invitation code" | ||||
|   invitation-info: "If you do not have an invitation code, please contact an <a href=\"{}\">administrator</a>." | ||||
|  | @ -984,7 +986,7 @@ desktop/views/components/settings.2fa.vue: | |||
|   url: "https://www.google.com/landing/2step/" | ||||
|   caution: "If you lose access to your registered device, you won't be able to connect to Misskey anymore!" | ||||
|   register: "Register a device" | ||||
|   already-registered: "This device is already registered" | ||||
|   already-registered: "Your account is currently registered to an authenticator application" | ||||
|   unregister: "Unregister" | ||||
|   unregistered: "Two-factor authentication has been disabled." | ||||
|   enter-password: "Enter the password" | ||||
|  | @ -997,6 +999,15 @@ desktop/views/components/settings.2fa.vue: | |||
|   success: "Settings saved!" | ||||
|   failed: "Failed to setup. Please ensure that the token is correct." | ||||
|   info: "From the next time you sign in to Misskey, the token displayed on your device will be necessary too, as well as the password." | ||||
|   totp-header: "Authenticator App" | ||||
|   security-key-header: "Security Keys" | ||||
|   security-key: "You can use a hardware security key supporting FIDO2 to log into your account for enhanced security. When you sign-in, you'll need a registered security key or your authenticator app." | ||||
|   last-used: "Last used:" | ||||
|   activate-key: "Please activate your security key by tapping or clicking it" | ||||
|   security-key-name: "Key Name" | ||||
|   register-security-key: "Finish Key Registration" | ||||
|   something-went-wrong: "Oops! Something went wrong while trying to register your key:" | ||||
|   key-unregistered: "Key Removed" | ||||
| common/views/components/media-image.vue: | ||||
|   sensitive: "NSFW" | ||||
|   click-to-show: "Click to show" | ||||
|  |  | |||
|  | @ -646,6 +646,8 @@ common/views/components/signin.vue: | |||
|   signin-with-github: "GitHubでログイン" | ||||
|   signin-with-discord: "Discordでログイン" | ||||
|   login-failed: "ログインできませんでした。ユーザー名とパスワードを確認してください。" | ||||
|   tap-key: "セキュリティキーをクリックしてログイン" | ||||
|   enter-2fa-code: "認証コードを入力してください" | ||||
| 
 | ||||
| common/views/components/signup.vue: | ||||
|   invitation-code: "招待コード" | ||||
|  | @ -1100,6 +1102,15 @@ desktop/views/components/settings.2fa.vue: | |||
|   success: "設定が完了しました!" | ||||
|   failed: "設定に失敗しました。トークンに誤りがないかご確認ください。" | ||||
|   info: "次回サインインからは、同様にパスワードに加えてデバイスに表示されているトークンを入力します。" | ||||
|   totp-header: "認証アプリ" | ||||
|   security-key-header: "セキュリティキー" | ||||
|   security-key: "セキュリティを強化するために、FIDO2をサポートするハードウェアセキュリティキーを使用してアカウントにログインできます。 サインインの際は、登録されたセキュリティキーまたは認証アプリが必要になります。" | ||||
|   last-used: "最後の使用:" | ||||
|   activate-key: "クリックしてセキュリティキーをアクティベートしてください" | ||||
|   security-key-name: "キー名" | ||||
|   register-security-key: "キーの登録を完了" | ||||
|   something-went-wrong: "わー! キーを登録する際に問題が発生しました:" | ||||
|   key-unregistered: "キーが削除されました" | ||||
| 
 | ||||
| common/views/components/media-image.vue: | ||||
|   sensitive: "閲覧注意" | ||||
|  |  | |||
							
								
								
									
										29
									
								
								migration/1561706992953-webauthn.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								migration/1561706992953-webauthn.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | |||
| import {MigrationInterface, QueryRunner} from "typeorm"; | ||||
| 
 | ||||
| export class webauthn1561706992953 implements MigrationInterface { | ||||
| 
 | ||||
|     public async up(queryRunner: QueryRunner): Promise<any> { | ||||
|         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(`CREATE TABLE "user_security_key" ("id" character varying NOT NULL, "userId" character varying(32) NOT NULL, "publicKey" character varying NOT NULL, "lastUsed" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(30) NOT NULL, CONSTRAINT "PK_3e508571121ab39c5f85d10c166" PRIMARY KEY ("id"))`); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_ff9ca3b5f3ee3d0681367a9b44" ON "user_security_key" ("userId") `); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_0d7718e562dcedd0aa5cf2c9f7" ON "user_security_key" ("publicKey") `); | ||||
|         await queryRunner.query(`ALTER TABLE "user_profile" ADD "securityKeysAvailable" boolean NOT NULL DEFAULT false`); | ||||
|         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(`ALTER TABLE "user_security_key" ADD CONSTRAINT "FK_ff9ca3b5f3ee3d0681367a9b447" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); | ||||
|     } | ||||
| 
 | ||||
|     public async down(queryRunner: QueryRunner): Promise<any> { | ||||
|         await queryRunner.query(`ALTER TABLE "user_security_key" DROP CONSTRAINT "FK_ff9ca3b5f3ee3d0681367a9b447"`); | ||||
|         await queryRunner.query(`ALTER TABLE "attestation_challenge" DROP CONSTRAINT "FK_f1a461a618fa1755692d0e0d592"`); | ||||
|         await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "securityKeysAvailable"`); | ||||
|         await queryRunner.query(`DROP INDEX "IDX_0d7718e562dcedd0aa5cf2c9f7"`); | ||||
|         await queryRunner.query(`DROP INDEX "IDX_ff9ca3b5f3ee3d0681367a9b44"`); | ||||
|         await queryRunner.query(`DROP TABLE "user_security_key"`); | ||||
|         await queryRunner.query(`DROP INDEX "IDX_47efb914aed1f72dd39a306c7b"`); | ||||
|         await queryRunner.query(`DROP INDEX "IDX_f1a461a618fa1755692d0e0d59"`); | ||||
|         await queryRunner.query(`DROP TABLE "attestation_challenge"`); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -39,6 +39,7 @@ | |||
| 		"@koa/cors": "3.0.0", | ||||
| 		"@types/bcryptjs": "2.4.2", | ||||
| 		"@types/bull": "3.5.15", | ||||
| 		"@types/cbor": "2.0.0", | ||||
| 		"@types/dateformat": "3.0.0", | ||||
| 		"@types/deep-equal": "1.0.1", | ||||
| 		"@types/double-ended-queue": "2.1.1", | ||||
|  | @ -104,9 +105,11 @@ | |||
| 		"autosize": "4.0.2", | ||||
| 		"autwh": "0.1.0", | ||||
| 		"bcryptjs": "2.4.3", | ||||
| 		"bootstrap": "4.3.1", | ||||
| 		"bootstrap-vue": "2.0.0-rc.13", | ||||
| 		"bull": "3.10.0", | ||||
| 		"cafy": "15.1.1", | ||||
| 		"cbor": "4.1.5", | ||||
| 		"chai": "4.2.0", | ||||
| 		"chalk": "2.4.2", | ||||
| 		"cli-highlight": "2.1.1", | ||||
|  | @ -148,6 +151,7 @@ | |||
| 		"jsdom": "15.1.1", | ||||
| 		"json5": "2.1.0", | ||||
| 		"json5-loader": "3.0.0", | ||||
|     "jsrsasign": "8.0.12", | ||||
| 		"katex": "0.10.2", | ||||
| 		"koa": "2.7.0", | ||||
| 		"koa-bodyparser": "4.2.1", | ||||
|  |  | |||
|  | @ -79,6 +79,7 @@ export async function masterMain() { | |||
| 		require('../daemons/server-stats').default(); | ||||
| 		require('../daemons/notes-stats').default(); | ||||
| 		require('../daemons/queue-stats').default(); | ||||
| 		require('../daemons/janitor').default(); | ||||
| 	} | ||||
| 
 | ||||
| 	bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true); | ||||
|  |  | |||
							
								
								
									
										5
									
								
								src/client/app/common/scripts/2fa.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/client/app/common/scripts/2fa.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | |||
| export function hexifyAB(buffer) { | ||||
| 	return Array.from(new Uint8Array(buffer)) | ||||
| 		.map(item => item.toString(16).padStart(2, 0)) | ||||
| 		.join(''); | ||||
| } | ||||
|  | @ -1,11 +1,54 @@ | |||
| <template> | ||||
| <div class="2fa"> | ||||
| <div class="2fa totp-section"> | ||||
| 	<p style="margin-top:0;">{{ $t('intro') }}<a :href="$t('url')" target="_blank">{{ $t('detail') }}</a></p> | ||||
| 	<ui-info warn>{{ $t('caution') }}</ui-info> | ||||
| 	<p v-if="!data && !$store.state.i.twoFactorEnabled"><ui-button @click="register">{{ $t('register') }}</ui-button></p> | ||||
| 	<template v-if="$store.state.i.twoFactorEnabled"> | ||||
| 		<h2 class="heading">{{ $t('totp-header') }}</h2> | ||||
| 		<p>{{ $t('already-registered') }}</p> | ||||
| 		<ui-button @click="unregister">{{ $t('unregister') }}</ui-button> | ||||
| 
 | ||||
| 		<template v-if="supportsCredentials"> | ||||
| 			<hr class="totp-method-sep"> | ||||
| 
 | ||||
| 			<h2 class="heading">{{ $t('security-key-header') }}</h2> | ||||
| 			<p>{{ $t('security-key') }}</p> | ||||
| 			<div class="key-list"> | ||||
| 				<div class="key" v-for="key in $store.state.i.securityKeysList"> | ||||
| 					<h3> | ||||
| 						{{ key.name }} | ||||
| 					</h3> | ||||
| 					<div class="last-used"> | ||||
| 						{{ $t('last-used') }} | ||||
| 						<mk-time :time="key.lastUsed"/> | ||||
| 					</div> | ||||
| 					<ui-button @click="unregisterKey(key)"> | ||||
| 						{{ $t('unregister') }} | ||||
| 					</ui-button> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 
 | ||||
| 			<ui-info warn v-if="registration && registration.error">{{ $t('something-went-wrong') }} {{ registration.error }}</ui-info> | ||||
| 			<ui-button v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('register') }}</ui-button> | ||||
| 
 | ||||
| 			<ol v-if="registration && !registration.error"> | ||||
| 				<li v-if="registration.stage >= 0"> | ||||
| 					{{ $t('activate-key') }} | ||||
| 					<fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 0" /> | ||||
| 				</li> | ||||
| 				<li v-if="registration.stage >= 1"> | ||||
| 					<ui-form :disabled="registration.stage != 1 || registration.saving"> | ||||
| 						<ui-input v-model="keyName" :max="30"> | ||||
| 							<span>{{ $t('security-key-name') }}</span> | ||||
| 						</ui-input> | ||||
| 						<ui-button @click="registerKey" :disabled="this.keyName.length == 0"> | ||||
| 							{{ $t('register-security-key') }} | ||||
| 						</ui-button> | ||||
| 						<fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" /> | ||||
| 					</ui-form> | ||||
| 				</li> | ||||
| 			</ol> | ||||
| 		</template> | ||||
| 	</template> | ||||
| 	<div v-if="data && !$store.state.i.twoFactorEnabled"> | ||||
| 		<ol> | ||||
|  | @ -24,12 +67,21 @@ | |||
| <script lang="ts"> | ||||
| import Vue from 'vue'; | ||||
| import i18n from '../../../../i18n'; | ||||
| import { hostname } from '../../../../config'; | ||||
| import { hexifyAB } from '../../../scripts/2fa'; | ||||
| 
 | ||||
| function stringifyAB(buffer) { | ||||
| 	return String.fromCharCode.apply(null, new Uint8Array(buffer)); | ||||
| } | ||||
| 
 | ||||
| export default Vue.extend({ | ||||
| 	i18n: i18n('desktop/views/components/settings.2fa.vue'), | ||||
| 	data() { | ||||
| 		return { | ||||
| 			data: null, | ||||
| 			supportsCredentials: !!navigator.credentials, | ||||
| 			registration: null, | ||||
| 			keyName: '', | ||||
| 			token: null | ||||
| 		}; | ||||
| 	}, | ||||
|  | @ -76,7 +128,116 @@ export default Vue.extend({ | |||
| 			}).catch(() => { | ||||
| 				this.$notify(this.$t('failed')); | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		registerKey() { | ||||
| 			this.registration.saving = true; | ||||
| 			this.$root.api('i/2fa/key-done', { | ||||
| 				password: this.registration.password, | ||||
| 				name: this.keyName, | ||||
| 				challengeId: this.registration.challengeId, | ||||
| 				// we convert each 16 bits to a string to serialise | ||||
| 				clientDataJSON: stringifyAB(this.registration.credential.response.clientDataJSON), | ||||
| 				attestationObject: hexifyAB(this.registration.credential.response.attestationObject) | ||||
| 			}).then(key => { | ||||
| 				this.registration = null; | ||||
| 				key.lastUsed = new Date(); | ||||
| 				this.$notify(this.$t('success')); | ||||
| 			}) | ||||
| 		}, | ||||
| 
 | ||||
| 		unregisterKey(key) { | ||||
| 			this.$root.dialog({ | ||||
| 				title: this.$t('enter-password'), | ||||
| 				input: { | ||||
| 					type: 'password' | ||||
| 				} | ||||
| 			}).then(({ canceled, result: password }) => { | ||||
| 				if (canceled) return; | ||||
| 				return this.$root.api('i/2fa/remove-key', { | ||||
| 					password, | ||||
| 					credentialId: key.id | ||||
| 				}).then(() => { | ||||
| 					this.$notify(this.$t('key-unregistered')); | ||||
| 				}); | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		addSecurityKey() { | ||||
| 			this.$root.dialog({ | ||||
| 				title: this.$t('enter-password'), | ||||
| 				input: { | ||||
| 					type: 'password' | ||||
| 				} | ||||
| 			}).then(({ canceled, result: password }) => { | ||||
| 				if (canceled) return; | ||||
| 				this.$root.api('i/2fa/register-key', { | ||||
| 					password | ||||
| 				}).then(registration => { | ||||
| 					this.registration = { | ||||
| 						password, | ||||
| 						challengeId: registration.challengeId, | ||||
| 						stage: 0, | ||||
| 						publicKeyOptions: { | ||||
| 							challenge: Buffer.from( | ||||
| 								registration.challenge | ||||
| 									.replace(/\-/g, "+") | ||||
| 									.replace(/_/g, "/"), | ||||
| 								'base64' | ||||
| 							), | ||||
| 							rp: { | ||||
| 								id: hostname, | ||||
| 								name: 'Misskey' | ||||
| 							}, | ||||
| 							user: { | ||||
| 								id: Uint8Array.from(this.$store.state.i.id, c => c.charCodeAt(0)), | ||||
| 								name: this.$store.state.i.username, | ||||
| 								displayName: this.$store.state.i.name, | ||||
| 							}, | ||||
| 							pubKeyCredParams: [{alg: -7, type: 'public-key'}], | ||||
| 							timeout: 60000, | ||||
| 							attestation: 'direct' | ||||
| 						}, | ||||
| 						saving: true | ||||
| 					}; | ||||
| 					return navigator.credentials.create({ | ||||
| 						publicKey: this.registration.publicKeyOptions | ||||
| 					}); | ||||
| 				}).then(credential => { | ||||
| 					this.registration.credential = credential; | ||||
| 					this.registration.saving = false; | ||||
| 					this.registration.stage = 1; | ||||
| 				}).catch(err => { | ||||
| 					console.warn('Error while registering?', err); | ||||
| 					this.registration.error = err.message; | ||||
| 					this.registration.stage = -1; | ||||
| 				}); | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="stylus" scoped> | ||||
| .totp-section | ||||
| 	.totp-method-sep | ||||
| 		margin 1.5em 0 1em | ||||
| 		border none | ||||
| 		border-top solid var(--lineWidth) var(--faceDivider) | ||||
| 
 | ||||
| 	h2.heading | ||||
| 		margin 0 | ||||
| 
 | ||||
| 	.key | ||||
| 		padding 1em | ||||
| 		margin 0.5em 0 | ||||
| 		background #161616 | ||||
| 		border-radius 6px | ||||
| 
 | ||||
| 		h3 | ||||
| 			margin-top 0 | ||||
| 			margin-bottom .3em | ||||
| 
 | ||||
| 		.last-used | ||||
| 			margin-bottom .5em | ||||
| </style> | ||||
|  |  | |||
|  | @ -1,23 +1,40 @@ | |||
| <template> | ||||
| <form class="mk-signin" :class="{ signing }" @submit.prevent="onSubmit"> | ||||
| <form class="mk-signin" :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> | ||||
| 	<div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div> | ||||
| 	<ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange"> | ||||
| 		<span>{{ $t('username') }}</span> | ||||
| 		<template #prefix>@</template> | ||||
| 		<template #suffix>@{{ host }}</template> | ||||
| 	</ui-input> | ||||
| 	<ui-input v-model="password" type="password" :with-password-toggle="true" required> | ||||
| 		<span>{{ $t('password') }}</span> | ||||
| 		<template #prefix><fa icon="lock"/></template> | ||||
| 	</ui-input> | ||||
| 	<ui-input v-if="user && user.twoFactorEnabled" v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required> | ||||
| 		<span>{{ $t('@.2fa') }}</span> | ||||
| 		<template #prefix><fa icon="gavel"/></template> | ||||
| 	</ui-input> | ||||
| 	<ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('@.signin') }}</ui-button> | ||||
| 	<p v-if="meta && meta.enableTwitterIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/twitter`"><fa :icon="['fab', 'twitter']"/> {{ $t('signin-with-twitter') }}</a></p> | ||||
| 	<p v-if="meta && meta.enableGithubIntegration"  style="margin: 8px 0;"><a :href="`${apiUrl}/signin/github`"><fa :icon="['fab', 'github']"/> {{ $t('signin-with-github') }}</a></p> | ||||
| 	<p v-if="meta && meta.enableDiscordIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/discord`"><fa :icon="['fab', 'discord']"/> {{ $t('signin-with-discord') /* TODO: Make these layouts better */ }}</a></p> | ||||
| 	<div class="normal-signin" v-if="!totpLogin"> | ||||
| 		<ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange"> | ||||
| 			<span>{{ $t('username') }}</span> | ||||
| 			<template #prefix>@</template> | ||||
| 			<template #suffix>@{{ host }}</template> | ||||
| 		</ui-input> | ||||
| 		<ui-input v-model="password" type="password" :with-password-toggle="true" required> | ||||
| 			<span>{{ $t('password') }}</span> | ||||
| 			<template #prefix><fa icon="lock"/></template> | ||||
| 		</ui-input> | ||||
| 		<ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('@.signin') }}</ui-button> | ||||
| 		<p v-if="meta && meta.enableTwitterIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/twitter`"><fa :icon="['fab', 'twitter']"/> {{ $t('signin-with-twitter') }}</a></p> | ||||
| 		<p v-if="meta && meta.enableGithubIntegration"  style="margin: 8px 0;"><a :href="`${apiUrl}/signin/github`"><fa :icon="['fab', 'github']"/> {{ $t('signin-with-github') }}</a></p> | ||||
| 		<p v-if="meta && meta.enableDiscordIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/discord`"><fa :icon="['fab', 'discord']"/> {{ $t('signin-with-discord') /* TODO: Make these layouts better */ }}</a></p> | ||||
| 	</div> | ||||
| 	<div class="2fa-signin" v-if="totpLogin" :class="{ securityKeys: user && user.securityKeys }"> | ||||
| 		<div v-if="user && user.securityKeys" class="twofa-group tap-group"> | ||||
| 			<p>{{ $t('tap-key') }}</p> | ||||
| 			<ui-button @click="queryKey" v-if="!queryingKey"> | ||||
| 				{{ $t('@.error.retry') }} | ||||
| 			</ui-button> | ||||
| 		</div> | ||||
| 		<div class="or-hr" v-if="user && user.securityKeys"> | ||||
| 			<p class="or-msg">{{ $t('or') }}</p> | ||||
| 		</div> | ||||
| 		<div class="twofa-group totp-group"> | ||||
| 			<p style="margin-bottom:0;">{{ $t('enter-2fa-code') }}</p> | ||||
| 			<ui-input v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required> | ||||
| 				<span>{{ $t('@.2fa') }}</span> | ||||
| 				<template #prefix><fa icon="gavel"/></template> | ||||
| 			</ui-input> | ||||
| 			<ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('@.signin') }}</ui-button> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </form> | ||||
| </template> | ||||
| 
 | ||||
|  | @ -26,6 +43,7 @@ import Vue from 'vue'; | |||
| import i18n from '../../../i18n'; | ||||
| import { apiUrl, host } from '../../../config'; | ||||
| import { toUnicode } from 'punycode'; | ||||
| import { hexifyAB } from '../../scripts/2fa'; | ||||
| 
 | ||||
| export default Vue.extend({ | ||||
| 	i18n: i18n('common/views/components/signin.vue'), | ||||
|  | @ -47,7 +65,11 @@ export default Vue.extend({ | |||
| 			token: '', | ||||
| 			apiUrl, | ||||
| 			host: toUnicode(host), | ||||
| 			meta: null | ||||
| 			meta: null, | ||||
| 			totpLogin: false, | ||||
| 			credential: null, | ||||
| 			challengeData: null, | ||||
| 			queryingKey: false, | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
|  | @ -68,23 +90,87 @@ export default Vue.extend({ | |||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		onSubmit() { | ||||
| 			this.signing = true; | ||||
| 
 | ||||
| 			this.$root.api('signin', { | ||||
| 				username: this.username, | ||||
| 				password: this.password, | ||||
| 				token: this.user && this.user.twoFactorEnabled ? this.token : undefined | ||||
| 		queryKey() { | ||||
| 			this.queryingKey = true; | ||||
| 			return navigator.credentials.get({ | ||||
| 				publicKey: { | ||||
| 					challenge: Buffer.from( | ||||
| 						this.challengeData.challenge | ||||
| 							.replace(/\-/g, '+') | ||||
| 							.replace(/_/g, '/'), | ||||
| 							'base64' | ||||
| 					), | ||||
| 					allowCredentials: this.challengeData.securityKeys.map(key => ({ | ||||
| 						id: Buffer.from(key.id, 'hex'), | ||||
| 						type: 'public-key', | ||||
| 						transports: ['usb', 'ble', 'nfc'] | ||||
| 					})), | ||||
| 					timeout: 60 * 1000 | ||||
| 				} | ||||
| 			}).catch(err => { | ||||
| 				this.queryingKey = false; | ||||
| 				console.warn(err); | ||||
| 				return Promise.reject(null); | ||||
| 			}).then(credential => { | ||||
| 				this.queryingKey = false; | ||||
| 				this.signing = true; | ||||
| 				return this.$root.api('signin', { | ||||
| 					username: this.username, | ||||
| 					password: this.password, | ||||
| 					signature: hexifyAB(credential.response.signature), | ||||
| 					authenticatorData: hexifyAB(credential.response.authenticatorData), | ||||
| 					clientDataJSON: hexifyAB(credential.response.clientDataJSON), | ||||
| 					credentialId: credential.id, | ||||
| 					challengeId: this.challengeData.challengeId | ||||
| 				}); | ||||
| 			}).then(res => { | ||||
| 				localStorage.setItem('i', res.i); | ||||
| 				location.reload(); | ||||
| 			}).catch(() => { | ||||
| 			}).catch(err => { | ||||
| 				if(err === null) return; | ||||
| 				console.error(err); | ||||
| 				this.$root.dialog({ | ||||
| 					type: 'error', | ||||
| 					text: this.$t('login-failed') | ||||
| 				}); | ||||
| 				this.signing = false; | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		onSubmit() { | ||||
| 			this.signing = true; | ||||
| 
 | ||||
| 			if (!this.totpLogin && this.user && this.user.twoFactorEnabled) { | ||||
| 				if (window.PublicKeyCredential && this.user.securityKeys) { | ||||
| 					this.$root.api('i/2fa/getkeys', { | ||||
| 						username: this.username, | ||||
| 						password: this.password | ||||
| 					}).then(res => { | ||||
| 						this.totpLogin = true; | ||||
| 						this.signing = false; | ||||
| 						this.challengeData = res; | ||||
| 						return this.queryKey(); | ||||
| 					}); | ||||
| 				} else { | ||||
| 					this.totpLogin = true; | ||||
| 					this.signing = false; | ||||
| 				} | ||||
| 			} else { | ||||
| 				this.$root.api('signin', { | ||||
| 					username: this.username, | ||||
| 					password: this.password, | ||||
| 					token: this.user && this.user.twoFactorEnabled ? this.token : undefined | ||||
| 				}).then(res => { | ||||
| 					localStorage.setItem('i', res.i); | ||||
| 					location.reload(); | ||||
| 				}).catch(() => { | ||||
| 					this.$root.dialog({ | ||||
| 						type: 'error', | ||||
| 						text: this.$t('login-failed') | ||||
| 					}); | ||||
| 					this.signing = false; | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
|  | @ -94,6 +180,48 @@ export default Vue.extend({ | |||
| .mk-signin | ||||
| 	color #555 | ||||
| 
 | ||||
| 	.or-hr, | ||||
| 	.or-hr .or-msg, | ||||
| 	.twofa-group, | ||||
| 	.twofa-group p | ||||
| 		color var(--text) | ||||
| 
 | ||||
| 	.tap-group > button | ||||
| 		margin-bottom 1em | ||||
| 
 | ||||
| 	.securityKeys .or-hr | ||||
| 		& | ||||
| 			position relative | ||||
| 
 | ||||
| 		.or-msg | ||||
| 			&:before | ||||
| 				right 100% | ||||
| 				margin-right 0.125em | ||||
| 
 | ||||
| 			&:after | ||||
| 				left 100% | ||||
| 				margin-left 0.125em | ||||
| 
 | ||||
| 			&:before, &:after | ||||
| 				content "" | ||||
| 				position absolute | ||||
| 				top 50% | ||||
| 				width 100% | ||||
| 				height 2px | ||||
| 				background #555 | ||||
| 
 | ||||
| 			& | ||||
| 				position relative | ||||
| 				margin auto | ||||
| 				left 0 | ||||
| 				right 0 | ||||
| 				top 0 | ||||
| 				bottom 0 | ||||
| 				font-size 1.5em | ||||
| 				height 1.5em | ||||
| 				width 3em | ||||
| 				text-align center | ||||
| 
 | ||||
| 	&.signing | ||||
| 		&, * | ||||
| 			cursor wait !important | ||||
|  |  | |||
							
								
								
									
										18
									
								
								src/daemons/janitor.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/daemons/janitor.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | |||
| const interval = 30 * 60 * 1000; | ||||
| import { AttestationChallenges } from '../models'; | ||||
| import { LessThan } from 'typeorm'; | ||||
| 
 | ||||
| /** | ||||
|  * Clean up database occasionally | ||||
|  */ | ||||
| export default function() { | ||||
| 	async function tick() { | ||||
| 		await AttestationChallenges.delete({ | ||||
| 			createdAt: LessThan(new Date(new Date().getTime() - 5 * 60 * 1000)) | ||||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| 	tick(); | ||||
| 
 | ||||
| 	setInterval(tick, interval); | ||||
| } | ||||
|  | @ -43,6 +43,8 @@ import { Poll } from '../models/entities/poll'; | |||
| import { UserKeypair } from '../models/entities/user-keypair'; | ||||
| import { UserPublickey } from '../models/entities/user-publickey'; | ||||
| import { UserProfile } from '../models/entities/user-profile'; | ||||
| import { UserSecurityKey } from '../models/entities/user-security-key'; | ||||
| import { AttestationChallenge } from '../models/entities/attestation-challenge'; | ||||
| import { Page } from '../models/entities/page'; | ||||
| import { PageLike } from '../models/entities/page-like'; | ||||
| 
 | ||||
|  | @ -96,6 +98,8 @@ export const entities = [ | |||
| 	UserGroupJoining, | ||||
| 	UserGroupInvite, | ||||
| 	UserNotePining, | ||||
| 	UserSecurityKey, | ||||
| 	AttestationChallenge, | ||||
| 	Following, | ||||
| 	FollowRequest, | ||||
| 	Muting, | ||||
|  | @ -146,7 +150,7 @@ export function initDb(justBorrow = false, sync = false, log = false) { | |||
| 			options: { | ||||
| 				host: config.redis.host, | ||||
| 				port: config.redis.port, | ||||
| 				options:{ | ||||
| 				options: { | ||||
| 					password: config.redis.pass, | ||||
| 					prefix: config.redis.prefix, | ||||
| 					db: config.redis.db || 0 | ||||
|  |  | |||
							
								
								
									
										46
									
								
								src/models/entities/attestation-challenge.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/models/entities/attestation-challenge.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,46 @@ | |||
| import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm'; | ||||
| import { User } from './user'; | ||||
| import { id } from '../id'; | ||||
| 
 | ||||
| @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; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | @ -76,6 +76,11 @@ export class UserProfile { | |||
| 	}) | ||||
| 	public twoFactorEnabled: boolean; | ||||
| 
 | ||||
| 	@Column('boolean', { | ||||
| 		default: false, | ||||
| 	}) | ||||
| 	public securityKeysAvailable: boolean; | ||||
| 
 | ||||
| 	@Column('varchar', { | ||||
| 		length: 128, nullable: true, | ||||
| 		comment: 'The password hash of the User. It will be null if the origin of the user is local.' | ||||
|  |  | |||
							
								
								
									
										48
									
								
								src/models/entities/user-security-key.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/models/entities/user-security-key.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,48 @@ | |||
| import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm'; | ||||
| import { User } from './user'; | ||||
| import { id } from '../id'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class UserSecurityKey { | ||||
| 	@PrimaryColumn('varchar', { | ||||
| 		comment: 'Variable-length id given to navigator.credentials.get()' | ||||
| 	}) | ||||
| 	public id: string; | ||||
| 
 | ||||
| 	@Index() | ||||
| 	@Column(id()) | ||||
| 	public userId: User['id']; | ||||
| 
 | ||||
| 	@ManyToOne(type => User, { | ||||
| 		onDelete: 'CASCADE' | ||||
| 	}) | ||||
| 	@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; | ||||
| 
 | ||||
| 	constructor(data: Partial<UserSecurityKey>) { | ||||
| 		if (data == null) return; | ||||
| 
 | ||||
| 		for (const [k, v] of Object.entries(data)) { | ||||
| 			(this as any)[k] = v; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | @ -37,6 +37,8 @@ import { FollowingRepository } from './repositories/following'; | |||
| import { AbuseUserReportRepository } from './repositories/abuse-user-report'; | ||||
| import { AuthSessionRepository } from './repositories/auth-session'; | ||||
| import { UserProfile } from './entities/user-profile'; | ||||
| import { AttestationChallenge } from './entities/attestation-challenge'; | ||||
| import { UserSecurityKey } from './entities/user-security-key'; | ||||
| import { HashtagRepository } from './repositories/hashtag'; | ||||
| import { PageRepository } from './repositories/page'; | ||||
| import { PageLikeRepository } from './repositories/page-like'; | ||||
|  | @ -52,6 +54,8 @@ export const PollVotes = getRepository(PollVote); | |||
| export const Users = getCustomRepository(UserRepository); | ||||
| export const UserProfiles = getRepository(UserProfile); | ||||
| export const UserKeypairs = getRepository(UserKeypair); | ||||
| export const AttestationChallenges = getRepository(AttestationChallenge); | ||||
| export const UserSecurityKeys = getRepository(UserSecurityKey); | ||||
| export const UserPublickeys = getRepository(UserPublickey); | ||||
| export const UserLists = getCustomRepository(UserListRepository); | ||||
| export const UserListJoinings = getRepository(UserListJoining); | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| import $ from 'cafy'; | ||||
| import { EntityRepository, Repository, In } from 'typeorm'; | ||||
| import { User, ILocalUser, IRemoteUser } from '../entities/user'; | ||||
| import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserGroupJoinings } from '..'; | ||||
| import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings } from '..'; | ||||
| import { ensure } from '../../prelude/ensure'; | ||||
| import config from '../../config'; | ||||
| import { SchemaType } from '../../misc/schema'; | ||||
|  | @ -156,6 +156,11 @@ export class UserRepository extends Repository<User> { | |||
| 					detail: true | ||||
| 				}), | ||||
| 				twoFactorEnabled: profile!.twoFactorEnabled, | ||||
| 				securityKeys: profile!.twoFactorEnabled | ||||
| 					? UserSecurityKeys.count({ | ||||
| 						userId: user.id | ||||
| 					}).then(result => result >= 1) | ||||
| 					: false, | ||||
| 				twitter: profile!.twitter ? { | ||||
| 					id: profile!.twitterUserId, | ||||
| 					screenName: profile!.twitterScreenName | ||||
|  | @ -195,6 +200,15 @@ export class UserRepository extends Repository<User> { | |||
| 				clientData: profile!.clientData, | ||||
| 				email: profile!.email, | ||||
| 				emailVerified: profile!.emailVerified, | ||||
| 				securityKeysList: profile!.twoFactorEnabled | ||||
| 					? UserSecurityKeys.find({ | ||||
| 						where: { | ||||
| 							userId: user.id | ||||
| 						}, | ||||
| 						select: ['id', 'name', 'lastUsed'] | ||||
| 					}) | ||||
| 					: [] | ||||
| 
 | ||||
| 			} : {}), | ||||
| 
 | ||||
| 			...(relation ? { | ||||
|  |  | |||
							
								
								
									
										422
									
								
								src/server/api/2fa.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										422
									
								
								src/server/api/2fa.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,422 @@ | |||
| import * as crypto from 'crypto'; | ||||
| import config from '../../config'; | ||||
| import * as jsrsasign from 'jsrsasign'; | ||||
| 
 | ||||
| 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]); | ||||
| 		const algorithm = certificate.getSignatureAlgorithmField(); | ||||
| 		const signatureHex = certificate.getSignatureValueHex(); | ||||
| 
 | ||||
| 		// Verify against CA
 | ||||
| 		const Signature = new jsrsasign.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` | ||||
| 	); | ||||
| } | ||||
| 
 | ||||
| export function hash(data: Buffer) { | ||||
| 	return crypto | ||||
| 		.createHash('sha256') | ||||
| 		.update(data) | ||||
| 		.digest(); | ||||
| } | ||||
| 
 | ||||
| export function verifyLogin({ | ||||
| 	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 (hash(clientData.challenge).toString('hex') != challenge) { | ||||
| 		throw new Error('challenge mismatch'); | ||||
| 	} | ||||
| 	if (clientData.origin != config.scheme + '://' + config.host) { | ||||
| 		throw new Error('origin mismatch'); | ||||
| 	} | ||||
| 
 | ||||
| 	const verificationData = Buffer.concat( | ||||
| 		[authenticatorData, hash(clientDataJSON)], | ||||
| 		32 + authenticatorData.length | ||||
| 	); | ||||
| 
 | ||||
| 	return crypto | ||||
| 		.createVerify('SHA256') | ||||
| 		.update(verificationData) | ||||
| 		.verify(PEMString(publicKey), signature); | ||||
| } | ||||
| 
 | ||||
| export const procedures = { | ||||
| 	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 = 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 => 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 | ||||
| 			}; | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
							
								
								
									
										67
									
								
								src/server/api/endpoints/i/2fa/getkeys.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/server/api/endpoints/i/2fa/getkeys.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,67 @@ | |||
| import $ from 'cafy'; | ||||
| import * as bcrypt from 'bcryptjs'; | ||||
| import * as crypto from 'crypto'; | ||||
| import define from '../../../define'; | ||||
| import { UserProfiles, UserSecurityKeys, AttestationChallenges } from '../../../../../models'; | ||||
| import { ensure } from '../../../../../prelude/ensure'; | ||||
| import { promisify } from 'util'; | ||||
| import { hash } from '../../../2fa'; | ||||
| import { genId } from '../../../../../misc/gen-id'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
| 
 | ||||
| 	secure: true, | ||||
| 
 | ||||
| 	params: { | ||||
| 		password: { | ||||
| 			validator: $.str | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| const randomBytes = promisify(crypto.randomBytes); | ||||
| 
 | ||||
| export default define(meta, async (ps, user) => { | ||||
| 	const profile = await UserProfiles.findOne(user.id).then(ensure); | ||||
| 
 | ||||
| 	// Compare password
 | ||||
| 	const same = await bcrypt.compare(ps.password, profile.password!); | ||||
| 
 | ||||
| 	if (!same) { | ||||
| 		throw new Error('incorrect password'); | ||||
| 	} | ||||
| 
 | ||||
| 	const keys = await UserSecurityKeys.find({ | ||||
| 		userId: user.id | ||||
| 	}); | ||||
| 
 | ||||
| 	if (keys.length === 0) { | ||||
| 		throw new Error('no keys found'); | ||||
| 	} | ||||
| 
 | ||||
| 	// 32 byte challenge
 | ||||
| 	const entropy = await randomBytes(32); | ||||
| 	const challenge = entropy.toString('base64') | ||||
| 		.replace(/=/g, '') | ||||
| 		.replace(/\+/g, '-') | ||||
| 		.replace(/\//g, '_'); | ||||
| 
 | ||||
| 	const challengeId = genId(); | ||||
| 
 | ||||
| 	await AttestationChallenges.save({ | ||||
| 		userId: user.id, | ||||
| 		id: challengeId, | ||||
| 		challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'), | ||||
| 		createdAt: new Date(), | ||||
| 		registrationChallenge: false | ||||
| 	}); | ||||
| 
 | ||||
| 	return { | ||||
| 		challenge, | ||||
| 		challengeId, | ||||
| 		securityKeys: keys.map(key => ({ | ||||
| 			id: key.id | ||||
| 		})) | ||||
| 	}; | ||||
| }); | ||||
							
								
								
									
										151
									
								
								src/server/api/endpoints/i/2fa/key-done.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								src/server/api/endpoints/i/2fa/key-done.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,151 @@ | |||
| import $ from 'cafy'; | ||||
| import * as bcrypt from 'bcryptjs'; | ||||
| import { promisify } from 'util'; | ||||
| import * as cbor from 'cbor'; | ||||
| import define from '../../../define'; | ||||
| import { | ||||
| 	UserProfiles, | ||||
| 	UserSecurityKeys, | ||||
| 	AttestationChallenges, | ||||
| 	Users | ||||
| } from '../../../../../models'; | ||||
| import { ensure } from '../../../../../prelude/ensure'; | ||||
| import config from '../../../../../config'; | ||||
| import { procedures, hash } from '../../../2fa'; | ||||
| import { publishMainStream } from '../../../../../services/stream'; | ||||
| 
 | ||||
| const cborDecodeFirst = promisify(cbor.decodeFirst); | ||||
| 
 | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
| 
 | ||||
| 	secure: true, | ||||
| 
 | ||||
| 	params: { | ||||
| 		clientDataJSON: { | ||||
| 			validator: $.str | ||||
| 		}, | ||||
| 		attestationObject: { | ||||
| 			validator: $.str | ||||
| 		}, | ||||
| 		password: { | ||||
| 			validator: $.str | ||||
| 		}, | ||||
| 		challengeId: { | ||||
| 			validator: $.str | ||||
| 		}, | ||||
| 		name: { | ||||
| 			validator: $.str | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| const rpIdHashReal = hash(Buffer.from(config.hostname, 'utf-8')); | ||||
| 
 | ||||
| export default define(meta, async (ps, user) => { | ||||
| 	const profile = await UserProfiles.findOne(user.id).then(ensure); | ||||
| 
 | ||||
| 	// Compare password
 | ||||
| 	const same = await bcrypt.compare(ps.password, profile.password!); | ||||
| 
 | ||||
| 	if (!same) { | ||||
| 		throw new Error('incorrect password'); | ||||
| 	} | ||||
| 
 | ||||
| 	if (!profile.twoFactorEnabled) { | ||||
| 		throw new Error('2fa not enabled'); | ||||
| 	} | ||||
| 
 | ||||
| 	const clientData = JSON.parse(ps.clientDataJSON); | ||||
| 
 | ||||
| 	if (clientData.type != 'webauthn.create') { | ||||
| 		throw new Error('not a creation attestation'); | ||||
| 	} | ||||
| 	if (clientData.origin != config.scheme + '://' + config.host) { | ||||
| 		throw new Error('origin mismatch'); | ||||
| 	} | ||||
| 
 | ||||
| 	const clientDataJSONHash = 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]; | ||||
| 
 | ||||
| 	// tslint: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'); | ||||
| 	} | ||||
| 
 | ||||
| 	if (!procedures[attestation.fmt]) { | ||||
| 		throw new Error('unsupported fmt'); | ||||
| 	} | ||||
| 
 | ||||
| 	const verificationData = procedures[attestation.fmt].verify({ | ||||
| 		attStmt: attestation.attStmt, | ||||
| 		authenticatorData: authData, | ||||
| 		clientDataHash: clientDataJSONHash, | ||||
| 		credentialId, | ||||
| 		publicKey, | ||||
| 		rpIdHash | ||||
| 	}); | ||||
| 	if (!verificationData.valid) throw new Error('signature invalid'); | ||||
| 
 | ||||
| 	const attestationChallenge = await AttestationChallenges.findOne({ | ||||
| 		userId: user.id, | ||||
| 		id: ps.challengeId, | ||||
| 		registrationChallenge: true, | ||||
| 		challenge: hash(clientData.challenge).toString('hex') | ||||
| 	}); | ||||
| 
 | ||||
| 	if (!attestationChallenge) { | ||||
| 		throw new Error('non-existent challenge'); | ||||
| 	} | ||||
| 
 | ||||
| 	await AttestationChallenges.delete({ | ||||
| 		userId: user.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'); | ||||
| 
 | ||||
| 	await UserSecurityKeys.save({ | ||||
| 		userId: user.id, | ||||
| 		id: credentialIdString, | ||||
| 		lastUsed: new Date(), | ||||
| 		name: ps.name, | ||||
| 		publicKey: verificationData.publicKey.toString('hex') | ||||
| 	}); | ||||
| 
 | ||||
| 	// Publish meUpdated event
 | ||||
| 	publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, { | ||||
| 		detail: true, | ||||
| 		includeSecrets: true | ||||
| 	})); | ||||
| 
 | ||||
| 	return { | ||||
| 		id: credentialIdString, | ||||
| 		name: ps.name | ||||
| 	}; | ||||
| }); | ||||
							
								
								
									
										60
									
								
								src/server/api/endpoints/i/2fa/register-key.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/server/api/endpoints/i/2fa/register-key.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,60 @@ | |||
| import $ from 'cafy'; | ||||
| import * as bcrypt from 'bcryptjs'; | ||||
| import define from '../../../define'; | ||||
| import { UserProfiles, AttestationChallenges } from '../../../../../models'; | ||||
| import { ensure } from '../../../../../prelude/ensure'; | ||||
| import { promisify } from 'util'; | ||||
| import * as crypto from 'crypto'; | ||||
| import { genId } from '../../../../../misc/gen-id'; | ||||
| import { hash } from '../../../2fa'; | ||||
| 
 | ||||
| const randomBytes = promisify(crypto.randomBytes); | ||||
| 
 | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
| 
 | ||||
| 	secure: true, | ||||
| 
 | ||||
| 	params: { | ||||
| 		password: { | ||||
| 			validator: $.str | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| export default define(meta, async (ps, user) => { | ||||
| 	const profile = await UserProfiles.findOne(user.id).then(ensure); | ||||
| 
 | ||||
| 	// Compare password
 | ||||
| 	const same = await bcrypt.compare(ps.password, profile.password!); | ||||
| 
 | ||||
| 	if (!same) { | ||||
| 		throw new Error('incorrect password'); | ||||
| 	} | ||||
| 
 | ||||
| 	if (!profile.twoFactorEnabled) { | ||||
| 		throw new Error('2fa not enabled'); | ||||
| 	} | ||||
| 
 | ||||
| 	// 32 byte challenge
 | ||||
| 	const entropy = await randomBytes(32); | ||||
| 	const challenge = entropy.toString('base64') | ||||
| 		.replace(/=/g, '') | ||||
| 		.replace(/\+/g, '-') | ||||
| 		.replace(/\//g, '_'); | ||||
| 
 | ||||
| 	const challengeId = genId(); | ||||
| 
 | ||||
| 	await AttestationChallenges.save({ | ||||
| 		userId: user.id, | ||||
| 		id: challengeId, | ||||
| 		challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'), | ||||
| 		createdAt: new Date(), | ||||
| 		registrationChallenge: true | ||||
| 	}); | ||||
| 
 | ||||
| 	return { | ||||
| 		challengeId, | ||||
| 		challenge | ||||
| 	}; | ||||
| }); | ||||
							
								
								
									
										46
									
								
								src/server/api/endpoints/i/2fa/remove-key.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/server/api/endpoints/i/2fa/remove-key.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,46 @@ | |||
| import $ from 'cafy'; | ||||
| import * as bcrypt from 'bcryptjs'; | ||||
| import define from '../../../define'; | ||||
| import { UserProfiles, UserSecurityKeys, Users } from '../../../../../models'; | ||||
| import { ensure } from '../../../../../prelude/ensure'; | ||||
| import { publishMainStream } from '../../../../../services/stream'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
| 
 | ||||
| 	secure: true, | ||||
| 
 | ||||
| 	params: { | ||||
| 		password: { | ||||
| 			validator: $.str | ||||
| 		}, | ||||
| 		credentialId: { | ||||
| 			validator: $.str | ||||
| 		}, | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| export default define(meta, async (ps, user) => { | ||||
| 	const profile = await UserProfiles.findOne(user.id).then(ensure); | ||||
| 
 | ||||
| 	// Compare password
 | ||||
| 	const same = await bcrypt.compare(ps.password, profile.password!); | ||||
| 
 | ||||
| 	if (!same) { | ||||
| 		throw new Error('incorrect password'); | ||||
| 	} | ||||
| 
 | ||||
| 	// Make sure we only delete the user's own creds
 | ||||
| 	await UserSecurityKeys.delete({ | ||||
| 		userId: user.id, | ||||
| 		id: ps.credentialId | ||||
| 	}); | ||||
| 
 | ||||
| 	// Publish meUpdated event
 | ||||
| 	publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, { | ||||
| 		detail: true, | ||||
| 		includeSecrets: true | ||||
| 	})); | ||||
| 
 | ||||
| 	return {}; | ||||
| }); | ||||
|  | @ -4,10 +4,11 @@ import * as speakeasy from 'speakeasy'; | |||
| import { publishMainStream } from '../../../services/stream'; | ||||
| import signin from '../common/signin'; | ||||
| import config from '../../../config'; | ||||
| import { Users, Signins, UserProfiles } from '../../../models'; | ||||
| import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges } from '../../../models'; | ||||
| import { ILocalUser } from '../../../models/entities/user'; | ||||
| import { genId } from '../../../misc/gen-id'; | ||||
| import { ensure } from '../../../prelude/ensure'; | ||||
| import { verifyLogin, hash } from '../2fa'; | ||||
| 
 | ||||
| export default async (ctx: Koa.BaseContext) => { | ||||
| 	ctx.set('Access-Control-Allow-Origin', config.url); | ||||
|  | @ -51,40 +52,116 @@ export default async (ctx: Koa.BaseContext) => { | |||
| 	// Compare password
 | ||||
| 	const same = await bcrypt.compare(password, profile.password!); | ||||
| 
 | ||||
| 	if (same) { | ||||
| 		if (profile.twoFactorEnabled) { | ||||
| 			const verified = (speakeasy as any).totp.verify({ | ||||
| 				secret: profile.twoFactorSecret, | ||||
| 				encoding: 'base32', | ||||
| 				token: token | ||||
| 			}); | ||||
| 
 | ||||
| 			if (verified) { | ||||
| 				signin(ctx, user); | ||||
| 			} else { | ||||
| 				ctx.throw(403, { | ||||
| 					error: 'invalid token' | ||||
| 				}); | ||||
| 			} | ||||
| 		} else { | ||||
| 			signin(ctx, user); | ||||
| 		} | ||||
| 	} else { | ||||
| 		ctx.throw(403, { | ||||
| 			error: 'incorrect password' | ||||
| 	async function fail(status?: number, failure?: {error: string}) { | ||||
| 		// Append signin history
 | ||||
| 		const record = await Signins.save({ | ||||
| 			id: genId(), | ||||
| 			createdAt: new Date(), | ||||
| 			userId: user.id, | ||||
| 			ip: ctx.ip, | ||||
| 			headers: ctx.headers, | ||||
| 			success: !!(status || failure) | ||||
| 		}); | ||||
| 
 | ||||
| 		// Publish signin event
 | ||||
| 		publishMainStream(user.id, 'signin', await Signins.pack(record)); | ||||
| 
 | ||||
| 		if (status && failure) { | ||||
| 			ctx.throw(status, failure); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Append signin history
 | ||||
| 	const record = await Signins.save({ | ||||
| 		id: genId(), | ||||
| 		createdAt: new Date(), | ||||
| 		userId: user.id, | ||||
| 		ip: ctx.ip, | ||||
| 		headers: ctx.headers, | ||||
| 		success: same | ||||
| 	}); | ||||
| 	if (!same) { | ||||
| 		await fail(403, { | ||||
| 			error: 'incorrect password' | ||||
| 		}); | ||||
| 		return; | ||||
| 	} | ||||
| 
 | ||||
| 	// Publish signin event
 | ||||
| 	publishMainStream(user.id, 'signin', await Signins.pack(record)); | ||||
| 	if (!profile.twoFactorEnabled) { | ||||
| 		signin(ctx, user); | ||||
| 		return; | ||||
| 	} | ||||
| 
 | ||||
| 	if (token) { | ||||
| 		const verified = (speakeasy as any).totp.verify({ | ||||
| 			secret: profile.twoFactorSecret, | ||||
| 			encoding: 'base32', | ||||
| 			token: token | ||||
| 		}); | ||||
| 
 | ||||
| 		if (verified) { | ||||
| 			signin(ctx, user); | ||||
| 			return; | ||||
| 		} else { | ||||
| 			await fail(403, { | ||||
| 				error: 'invalid token' | ||||
| 			}); | ||||
| 			return; | ||||
| 		} | ||||
| 	} else { | ||||
| 		const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex'); | ||||
| 		const clientData = JSON.parse(clientDataJSON.toString('utf-8')); | ||||
| 		const challenge = await AttestationChallenges.findOne({ | ||||
| 			userId: user.id, | ||||
| 			id: body.challengeId, | ||||
| 			registrationChallenge: false, | ||||
| 			challenge: hash(clientData.challenge).toString('hex') | ||||
| 		}); | ||||
| 
 | ||||
| 		if (!challenge) { | ||||
| 			await fail(403, { | ||||
| 				error: 'non-existent challenge' | ||||
| 			}); | ||||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| 		await AttestationChallenges.delete({ | ||||
| 			userId: user.id, | ||||
| 			id: body.challengeId | ||||
| 		}); | ||||
| 
 | ||||
| 		if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) { | ||||
| 			await fail(403, { | ||||
| 				error: 'non-existent challenge' | ||||
| 			}); | ||||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| 		const securityKey = await UserSecurityKeys.findOne({ | ||||
| 			id: Buffer.from( | ||||
| 				body.credentialId | ||||
| 					.replace(/\-/g, '+') | ||||
| 					.replace(/_/g, '/'), | ||||
| 					'base64' | ||||
| 			).toString('hex') | ||||
| 		}); | ||||
| 
 | ||||
| 		if (!securityKey) { | ||||
| 			await fail(403, { | ||||
| 				error: 'invalid credentialId' | ||||
| 			}); | ||||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| 		const isValid = verifyLogin({ | ||||
| 			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) { | ||||
| 			signin(ctx, user); | ||||
| 		} else { | ||||
| 			await fail(403, { | ||||
| 				error: 'invalid challenge data' | ||||
| 			}); | ||||
| 			return; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	await fail(); | ||||
| }; | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue