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:
Mary 2019-07-03 07:18:07 -04:00 committed by syuilo
parent f17e229c1e
commit fd94b817ab
21 changed files with 1376 additions and 64 deletions

View File

@ -601,6 +601,8 @@ common/views/components/signin.vue:
signin-with-github: "Sign in with GitHub" signin-with-github: "Sign in with GitHub"
signin-with-discord: "Sign in with Discord" signin-with-discord: "Sign in with Discord"
login-failed: "Logging in has failed. Make sure you have entered the correct username and password." 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: common/views/components/signup.vue:
invitation-code: "Invitation code" invitation-code: "Invitation code"
invitation-info: "If you do not have an invitation code, please contact an <a href=\"{}\">administrator</a>." 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/" 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!" caution: "If you lose access to your registered device, you won't be able to connect to Misskey anymore!"
register: "Register a device" register: "Register a device"
already-registered: "This device is already registered" already-registered: "Your account is currently registered to an authenticator application"
unregister: "Unregister" unregister: "Unregister"
unregistered: "Two-factor authentication has been disabled." unregistered: "Two-factor authentication has been disabled."
enter-password: "Enter the password" enter-password: "Enter the password"
@ -997,6 +999,15 @@ desktop/views/components/settings.2fa.vue:
success: "Settings saved!" success: "Settings saved!"
failed: "Failed to setup. Please ensure that the token is correct." 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." 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: common/views/components/media-image.vue:
sensitive: "NSFW" sensitive: "NSFW"
click-to-show: "Click to show" click-to-show: "Click to show"

View File

@ -646,6 +646,8 @@ common/views/components/signin.vue:
signin-with-github: "GitHubでログイン" signin-with-github: "GitHubでログイン"
signin-with-discord: "Discordでログイン" signin-with-discord: "Discordでログイン"
login-failed: "ログインできませんでした。ユーザー名とパスワードを確認してください。" login-failed: "ログインできませんでした。ユーザー名とパスワードを確認してください。"
tap-key: "セキュリティキーをクリックしてログイン"
enter-2fa-code: "認証コードを入力してください"
common/views/components/signup.vue: common/views/components/signup.vue:
invitation-code: "招待コード" invitation-code: "招待コード"
@ -1100,6 +1102,15 @@ desktop/views/components/settings.2fa.vue:
success: "設定が完了しました!" success: "設定が完了しました!"
failed: "設定に失敗しました。トークンに誤りがないかご確認ください。" failed: "設定に失敗しました。トークンに誤りがないかご確認ください。"
info: "次回サインインからは、同様にパスワードに加えてデバイスに表示されているトークンを入力します。" 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: common/views/components/media-image.vue:
sensitive: "閲覧注意" sensitive: "閲覧注意"

View 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"`);
}
}

View File

@ -39,6 +39,7 @@
"@koa/cors": "3.0.0", "@koa/cors": "3.0.0",
"@types/bcryptjs": "2.4.2", "@types/bcryptjs": "2.4.2",
"@types/bull": "3.5.15", "@types/bull": "3.5.15",
"@types/cbor": "2.0.0",
"@types/dateformat": "3.0.0", "@types/dateformat": "3.0.0",
"@types/deep-equal": "1.0.1", "@types/deep-equal": "1.0.1",
"@types/double-ended-queue": "2.1.1", "@types/double-ended-queue": "2.1.1",
@ -104,9 +105,11 @@
"autosize": "4.0.2", "autosize": "4.0.2",
"autwh": "0.1.0", "autwh": "0.1.0",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"bootstrap": "4.3.1",
"bootstrap-vue": "2.0.0-rc.13", "bootstrap-vue": "2.0.0-rc.13",
"bull": "3.10.0", "bull": "3.10.0",
"cafy": "15.1.1", "cafy": "15.1.1",
"cbor": "4.1.5",
"chai": "4.2.0", "chai": "4.2.0",
"chalk": "2.4.2", "chalk": "2.4.2",
"cli-highlight": "2.1.1", "cli-highlight": "2.1.1",
@ -148,6 +151,7 @@
"jsdom": "15.1.1", "jsdom": "15.1.1",
"json5": "2.1.0", "json5": "2.1.0",
"json5-loader": "3.0.0", "json5-loader": "3.0.0",
"jsrsasign": "8.0.12",
"katex": "0.10.2", "katex": "0.10.2",
"koa": "2.7.0", "koa": "2.7.0",
"koa-bodyparser": "4.2.1", "koa-bodyparser": "4.2.1",

View File

@ -79,6 +79,7 @@ export async function masterMain() {
require('../daemons/server-stats').default(); require('../daemons/server-stats').default();
require('../daemons/notes-stats').default(); require('../daemons/notes-stats').default();
require('../daemons/queue-stats').default(); require('../daemons/queue-stats').default();
require('../daemons/janitor').default();
} }
bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true); bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true);

View File

@ -0,0 +1,5 @@
export function hexifyAB(buffer) {
return Array.from(new Uint8Array(buffer))
.map(item => item.toString(16).padStart(2, 0))
.join('');
}

View File

@ -1,11 +1,54 @@
<template> <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> <p style="margin-top:0;">{{ $t('intro') }}<a :href="$t('url')" target="_blank">{{ $t('detail') }}</a></p>
<ui-info warn>{{ $t('caution') }}</ui-info> <ui-info warn>{{ $t('caution') }}</ui-info>
<p v-if="!data && !$store.state.i.twoFactorEnabled"><ui-button @click="register">{{ $t('register') }}</ui-button></p> <p v-if="!data && !$store.state.i.twoFactorEnabled"><ui-button @click="register">{{ $t('register') }}</ui-button></p>
<template v-if="$store.state.i.twoFactorEnabled"> <template v-if="$store.state.i.twoFactorEnabled">
<h2 class="heading">{{ $t('totp-header') }}</h2>
<p>{{ $t('already-registered') }}</p> <p>{{ $t('already-registered') }}</p>
<ui-button @click="unregister">{{ $t('unregister') }}</ui-button> <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> </template>
<div v-if="data && !$store.state.i.twoFactorEnabled"> <div v-if="data && !$store.state.i.twoFactorEnabled">
<ol> <ol>
@ -24,12 +67,21 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import i18n from '../../../../i18n'; 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({ export default Vue.extend({
i18n: i18n('desktop/views/components/settings.2fa.vue'), i18n: i18n('desktop/views/components/settings.2fa.vue'),
data() { data() {
return { return {
data: null, data: null,
supportsCredentials: !!navigator.credentials,
registration: null,
keyName: '',
token: null token: null
}; };
}, },
@ -76,7 +128,116 @@ export default Vue.extend({
}).catch(() => { }).catch(() => {
this.$notify(this.$t('failed')); 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> </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>

View File

@ -1,23 +1,40 @@
<template> <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> <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"> <div class="normal-signin" v-if="!totpLogin">
<span>{{ $t('username') }}</span> <ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange">
<template #prefix>@</template> <span>{{ $t('username') }}</span>
<template #suffix>@{{ host }}</template> <template #prefix>@</template>
</ui-input> <template #suffix>@{{ host }}</template>
<ui-input v-model="password" type="password" :with-password-toggle="true" required> </ui-input>
<span>{{ $t('password') }}</span> <ui-input v-model="password" type="password" :with-password-toggle="true" required>
<template #prefix><fa icon="lock"/></template> <span>{{ $t('password') }}</span>
</ui-input> <template #prefix><fa icon="lock"/></template>
<ui-input v-if="user && user.twoFactorEnabled" v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required> </ui-input>
<span>{{ $t('@.2fa') }}</span> <ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('@.signin') }}</ui-button>
<template #prefix><fa icon="gavel"/></template> <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>
</ui-input> <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>
<ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('@.signin') }}</ui-button> <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>
<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> </div>
<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> <div class="2fa-signin" v-if="totpLogin" :class="{ securityKeys: user && user.securityKeys }">
<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 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> </form>
</template> </template>
@ -26,6 +43,7 @@ import Vue from 'vue';
import i18n from '../../../i18n'; import i18n from '../../../i18n';
import { apiUrl, host } from '../../../config'; import { apiUrl, host } from '../../../config';
import { toUnicode } from 'punycode'; import { toUnicode } from 'punycode';
import { hexifyAB } from '../../scripts/2fa';
export default Vue.extend({ export default Vue.extend({
i18n: i18n('common/views/components/signin.vue'), i18n: i18n('common/views/components/signin.vue'),
@ -47,7 +65,11 @@ export default Vue.extend({
token: '', token: '',
apiUrl, apiUrl,
host: toUnicode(host), host: toUnicode(host),
meta: null meta: null,
totpLogin: false,
credential: null,
challengeData: null,
queryingKey: false,
}; };
}, },
@ -68,23 +90,87 @@ export default Vue.extend({
}); });
}, },
onSubmit() { queryKey() {
this.signing = true; this.queryingKey = true;
return navigator.credentials.get({
this.$root.api('signin', { publicKey: {
username: this.username, challenge: Buffer.from(
password: this.password, this.challengeData.challenge
token: this.user && this.user.twoFactorEnabled ? this.token : undefined .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 => { }).then(res => {
localStorage.setItem('i', res.i); localStorage.setItem('i', res.i);
location.reload(); location.reload();
}).catch(() => { }).catch(err => {
if(err === null) return;
console.error(err);
this.$root.dialog({ this.$root.dialog({
type: 'error', type: 'error',
text: this.$t('login-failed') text: this.$t('login-failed')
}); });
this.signing = false; 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 .mk-signin
color #555 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 &.signing
&, * &, *
cursor wait !important cursor wait !important

18
src/daemons/janitor.ts Normal file
View 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);
}

View File

@ -43,6 +43,8 @@ import { Poll } from '../models/entities/poll';
import { UserKeypair } from '../models/entities/user-keypair'; import { UserKeypair } from '../models/entities/user-keypair';
import { UserPublickey } from '../models/entities/user-publickey'; import { UserPublickey } from '../models/entities/user-publickey';
import { UserProfile } from '../models/entities/user-profile'; 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 { Page } from '../models/entities/page';
import { PageLike } from '../models/entities/page-like'; import { PageLike } from '../models/entities/page-like';
@ -96,6 +98,8 @@ export const entities = [
UserGroupJoining, UserGroupJoining,
UserGroupInvite, UserGroupInvite,
UserNotePining, UserNotePining,
UserSecurityKey,
AttestationChallenge,
Following, Following,
FollowRequest, FollowRequest,
Muting, Muting,
@ -146,7 +150,7 @@ export function initDb(justBorrow = false, sync = false, log = false) {
options: { options: {
host: config.redis.host, host: config.redis.host,
port: config.redis.port, port: config.redis.port,
options:{ options: {
password: config.redis.pass, password: config.redis.pass,
prefix: config.redis.prefix, prefix: config.redis.prefix,
db: config.redis.db || 0 db: config.redis.db || 0

View 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;
}
}
}

View File

@ -76,6 +76,11 @@ export class UserProfile {
}) })
public twoFactorEnabled: boolean; public twoFactorEnabled: boolean;
@Column('boolean', {
default: false,
})
public securityKeysAvailable: boolean;
@Column('varchar', { @Column('varchar', {
length: 128, nullable: true, length: 128, nullable: true,
comment: 'The password hash of the User. It will be null if the origin of the user is local.' comment: 'The password hash of the User. It will be null if the origin of the user is local.'

View 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;
}
}
}

View File

@ -37,6 +37,8 @@ import { FollowingRepository } from './repositories/following';
import { AbuseUserReportRepository } from './repositories/abuse-user-report'; import { AbuseUserReportRepository } from './repositories/abuse-user-report';
import { AuthSessionRepository } from './repositories/auth-session'; import { AuthSessionRepository } from './repositories/auth-session';
import { UserProfile } from './entities/user-profile'; 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 { HashtagRepository } from './repositories/hashtag';
import { PageRepository } from './repositories/page'; import { PageRepository } from './repositories/page';
import { PageLikeRepository } from './repositories/page-like'; import { PageLikeRepository } from './repositories/page-like';
@ -52,6 +54,8 @@ export const PollVotes = getRepository(PollVote);
export const Users = getCustomRepository(UserRepository); export const Users = getCustomRepository(UserRepository);
export const UserProfiles = getRepository(UserProfile); export const UserProfiles = getRepository(UserProfile);
export const UserKeypairs = getRepository(UserKeypair); export const UserKeypairs = getRepository(UserKeypair);
export const AttestationChallenges = getRepository(AttestationChallenge);
export const UserSecurityKeys = getRepository(UserSecurityKey);
export const UserPublickeys = getRepository(UserPublickey); export const UserPublickeys = getRepository(UserPublickey);
export const UserLists = getCustomRepository(UserListRepository); export const UserLists = getCustomRepository(UserListRepository);
export const UserListJoinings = getRepository(UserListJoining); export const UserListJoinings = getRepository(UserListJoining);

View File

@ -1,7 +1,7 @@
import $ from 'cafy'; import $ from 'cafy';
import { EntityRepository, Repository, In } from 'typeorm'; import { EntityRepository, Repository, In } from 'typeorm';
import { User, ILocalUser, IRemoteUser } from '../entities/user'; 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 { ensure } from '../../prelude/ensure';
import config from '../../config'; import config from '../../config';
import { SchemaType } from '../../misc/schema'; import { SchemaType } from '../../misc/schema';
@ -156,6 +156,11 @@ export class UserRepository extends Repository<User> {
detail: true detail: true
}), }),
twoFactorEnabled: profile!.twoFactorEnabled, twoFactorEnabled: profile!.twoFactorEnabled,
securityKeys: profile!.twoFactorEnabled
? UserSecurityKeys.count({
userId: user.id
}).then(result => result >= 1)
: false,
twitter: profile!.twitter ? { twitter: profile!.twitter ? {
id: profile!.twitterUserId, id: profile!.twitterUserId,
screenName: profile!.twitterScreenName screenName: profile!.twitterScreenName
@ -195,6 +200,15 @@ export class UserRepository extends Repository<User> {
clientData: profile!.clientData, clientData: profile!.clientData,
email: profile!.email, email: profile!.email,
emailVerified: profile!.emailVerified, emailVerified: profile!.emailVerified,
securityKeysList: profile!.twoFactorEnabled
? UserSecurityKeys.find({
where: {
userId: user.id
},
select: ['id', 'name', 'lastUsed']
})
: []
} : {}), } : {}),
...(relation ? { ...(relation ? {

422
src/server/api/2fa.ts Normal file
View 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
};
}
}
};

View 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
}))
};
});

View 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
};
});

View 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
};
});

View 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 {};
});

View File

@ -4,10 +4,11 @@ import * as speakeasy from 'speakeasy';
import { publishMainStream } from '../../../services/stream'; import { publishMainStream } from '../../../services/stream';
import signin from '../common/signin'; import signin from '../common/signin';
import config from '../../../config'; 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 { ILocalUser } from '../../../models/entities/user';
import { genId } from '../../../misc/gen-id'; import { genId } from '../../../misc/gen-id';
import { ensure } from '../../../prelude/ensure'; import { ensure } from '../../../prelude/ensure';
import { verifyLogin, hash } from '../2fa';
export default async (ctx: Koa.BaseContext) => { export default async (ctx: Koa.BaseContext) => {
ctx.set('Access-Control-Allow-Origin', config.url); ctx.set('Access-Control-Allow-Origin', config.url);
@ -51,40 +52,116 @@ export default async (ctx: Koa.BaseContext) => {
// Compare password // Compare password
const same = await bcrypt.compare(password, profile.password!); const same = await bcrypt.compare(password, profile.password!);
if (same) { async function fail(status?: number, failure?: {error: string}) {
if (profile.twoFactorEnabled) { // Append signin history
const verified = (speakeasy as any).totp.verify({ const record = await Signins.save({
secret: profile.twoFactorSecret, id: genId(),
encoding: 'base32', createdAt: new Date(),
token: token userId: user.id,
}); ip: ctx.ip,
headers: ctx.headers,
if (verified) { success: !!(status || failure)
signin(ctx, user);
} else {
ctx.throw(403, {
error: 'invalid token'
});
}
} else {
signin(ctx, user);
}
} else {
ctx.throw(403, {
error: 'incorrect password'
}); });
// Publish signin event
publishMainStream(user.id, 'signin', await Signins.pack(record));
if (status && failure) {
ctx.throw(status, failure);
}
} }
// Append signin history if (!same) {
const record = await Signins.save({ await fail(403, {
id: genId(), error: 'incorrect password'
createdAt: new Date(), });
userId: user.id, return;
ip: ctx.ip, }
headers: ctx.headers,
success: same
});
// Publish signin event if (!profile.twoFactorEnabled) {
publishMainStream(user.id, 'signin', await Signins.pack(record)); 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();
}; };