From 1ea8762eeb6b43871e2101995e12668a6380fb45 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 16 Mar 2021 17:44:31 +0100 Subject: [PATCH] WebAuthn (#633) --- jslib | 2 +- src/app/accounts/two-factor.component.html | 19 +-- src/app/app.module.ts | 6 +- .../settings/two-factor-setup.component.html | 2 +- .../settings/two-factor-setup.component.ts | 12 +- .../settings/two-factor-verify.component.ts | 4 +- ...tml => two-factor-webauthn.component.html} | 33 ++--- ...nt.ts => two-factor-webauthn.component.ts} | 99 ++++++-------- src/connectors/common-webauthn.ts | 62 +++++++++ src/connectors/common.ts | 15 +++ src/connectors/duo.ts | 17 +-- src/connectors/sso.ts | 20 +-- src/connectors/webauthn-fallback.html | 36 ++++++ src/connectors/webauthn-fallback.ts | 118 +++++++++++++++++ src/connectors/webauthn.html | 16 +++ src/connectors/webauthn.scss | 5 + src/connectors/webauthn.ts | 122 ++++++++++++++++++ src/images/two-factor/7.png | Bin 0 -> 1620 bytes src/locales/en/messages.json | 39 ++++++ src/scss/styles.scss | 11 ++ src/services/webPlatformUtils.service.ts | 7 +- webpack.config.js | 12 ++ 22 files changed, 516 insertions(+), 141 deletions(-) rename src/app/settings/{two-factor-u2f.component.html => two-factor-webauthn.component.html} (79%) rename src/app/settings/{two-factor-u2f.component.ts => two-factor-webauthn.component.ts} (62%) create mode 100644 src/connectors/common-webauthn.ts create mode 100644 src/connectors/common.ts create mode 100644 src/connectors/webauthn-fallback.html create mode 100644 src/connectors/webauthn-fallback.ts create mode 100644 src/connectors/webauthn.html create mode 100644 src/connectors/webauthn.scss create mode 100644 src/connectors/webauthn.ts create mode 100644 src/images/two-factor/7.png diff --git a/jslib b/jslib index f80e89465ff..f20af0cd7c9 160000 --- a/jslib +++ b/jslib @@ -1 +1 @@ -Subproject commit f80e89465ffc004705d2941301c0ffb6bfd71d1a +Subproject commit f20af0cd7c90adc07783950bed197b5d47892d6f diff --git a/src/app/accounts/two-factor.component.html b/src/app/accounts/two-factor.component.html index 0dbedf5cafb..72f99be6287 100644 --- a/src/app/accounts/two-factor.component.html +++ b/src/app/accounts/two-factor.component.html @@ -33,16 +33,10 @@ required appAutofocus appInputVerbatim autocomplete="new-password"> - -

- - {{'loading' | i18n}} -

- -

{{'insertU2f' | i18n}}

- -
+ +
+ +
@@ -51,7 +45,7 @@ + *ngIf="form.loading && selectedProviderType === providerType.WebAuthn" aria-hidden="true">
@@ -65,7 +59,7 @@
- diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 2224e4d76ea..df1b5420aa2 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -135,8 +135,8 @@ import { TwoFactorDuoComponent } from './settings/two-factor-duo.component'; import { TwoFactorEmailComponent } from './settings/two-factor-email.component'; import { TwoFactorRecoveryComponent } from './settings/two-factor-recovery.component'; import { TwoFactorSetupComponent } from './settings/two-factor-setup.component'; -import { TwoFactorU2fComponent } from './settings/two-factor-u2f.component'; import { TwoFactorVerifyComponent } from './settings/two-factor-verify.component'; +import { TwoFactorWebAuthnComponent } from './settings/two-factor-webauthn.component'; import { TwoFactorYubiKeyComponent } from './settings/two-factor-yubikey.component'; import { UpdateKeyComponent } from './settings/update-key.component'; import { UpdateLicenseComponent } from './settings/update-license.component'; @@ -399,8 +399,8 @@ registerLocaleData(localeZhTw, 'zh-TW'); TwoFactorOptionsComponent, TwoFactorRecoveryComponent, TwoFactorSetupComponent, - TwoFactorU2fComponent, TwoFactorVerifyComponent, + TwoFactorWebAuthnComponent, TwoFactorYubiKeyComponent, UnsecuredWebsitesReportComponent, UpdateKeyComponent, @@ -454,7 +454,7 @@ registerLocaleData(localeZhTw, 'zh-TW'); TwoFactorEmailComponent, TwoFactorOptionsComponent, TwoFactorRecoveryComponent, - TwoFactorU2fComponent, + TwoFactorWebAuthnComponent, TwoFactorYubiKeyComponent, UpdateKeyComponent, ], diff --git a/src/app/settings/two-factor-setup.component.html b/src/app/settings/two-factor-setup.component.html index 7b629208769..04fe750e130 100644 --- a/src/app/settings/two-factor-setup.component.html +++ b/src/app/settings/two-factor-setup.component.html @@ -51,4 +51,4 @@ - + diff --git a/src/app/settings/two-factor-setup.component.ts b/src/app/settings/two-factor-setup.component.ts index 1ee49e2a0fc..58aeac78580 100644 --- a/src/app/settings/two-factor-setup.component.ts +++ b/src/app/settings/two-factor-setup.component.ts @@ -23,7 +23,7 @@ import { TwoFactorAuthenticatorComponent } from './two-factor-authenticator.comp import { TwoFactorDuoComponent } from './two-factor-duo.component'; import { TwoFactorEmailComponent } from './two-factor-email.component'; import { TwoFactorRecoveryComponent } from './two-factor-recovery.component'; -import { TwoFactorU2fComponent } from './two-factor-u2f.component'; +import { TwoFactorWebAuthnComponent } from './two-factor-webauthn.component'; import { TwoFactorYubiKeyComponent } from './two-factor-yubikey.component'; @Component({ @@ -34,9 +34,9 @@ export class TwoFactorSetupComponent implements OnInit { @ViewChild('recoveryTemplate', { read: ViewContainerRef, static: true }) recoveryModalRef: ViewContainerRef; @ViewChild('authenticatorTemplate', { read: ViewContainerRef, static: true }) authenticatorModalRef: ViewContainerRef; @ViewChild('yubikeyTemplate', { read: ViewContainerRef, static: true }) yubikeyModalRef: ViewContainerRef; - @ViewChild('u2fTemplate', { read: ViewContainerRef, static: true }) u2fModalRef: ViewContainerRef; @ViewChild('duoTemplate', { read: ViewContainerRef, static: true }) duoModalRef: ViewContainerRef; @ViewChild('emailTemplate', { read: ViewContainerRef, static: true }) emailModalRef: ViewContainerRef; + @ViewChild('webAuthnTemplate', { read: ViewContainerRef, static: true }) webAuthnModalRef: ViewContainerRef; organizationId: string; providers: any[] = []; @@ -117,10 +117,10 @@ export class TwoFactorSetupComponent implements OnInit { this.updateStatus(enabled, TwoFactorProviderType.Email); }); break; - case TwoFactorProviderType.U2f: - const u2fComp = this.openModal(this.u2fModalRef, TwoFactorU2fComponent); - u2fComp.onUpdated.subscribe((enabled: boolean) => { - this.updateStatus(enabled, TwoFactorProviderType.U2f); + case TwoFactorProviderType.WebAuthn: + const webAuthnComp = this.openModal(this.webAuthnModalRef, TwoFactorWebAuthnComponent); + webAuthnComp.onUpdated.subscribe((enabled: boolean) => { + this.updateStatus(enabled, TwoFactorProviderType.WebAuthn); }); break; default: diff --git a/src/app/settings/two-factor-verify.component.ts b/src/app/settings/two-factor-verify.component.ts index 104dd9b1691..6c55a95fd30 100644 --- a/src/app/settings/two-factor-verify.component.ts +++ b/src/app/settings/two-factor-verify.component.ts @@ -59,8 +59,8 @@ export class TwoFactorVerifyComponent { case TwoFactorProviderType.Email: this.formPromise = this.apiService.getTwoFactorEmail(request); break; - case TwoFactorProviderType.U2f: - this.formPromise = this.apiService.getTwoFactorU2f(request); + case TwoFactorProviderType.WebAuthn: + this.formPromise = this.apiService.getTwoFactorWebAuthn(request); break; case TwoFactorProviderType.Authenticator: this.formPromise = this.apiService.getTwoFactorAuthenticator(request); diff --git a/src/app/settings/two-factor-u2f.component.html b/src/app/settings/two-factor-webauthn.component.html similarity index 79% rename from src/app/settings/two-factor-u2f.component.html rename to src/app/settings/two-factor-webauthn.component.html index af0a4d6b0d8..69f68114b8f 100644 --- a/src/app/settings/two-factor-u2f.component.html +++ b/src/app/settings/two-factor-webauthn.component.html @@ -4,7 +4,7 @@
@@ -74,22 +67,22 @@
- + {{'twoFactorU2fWaiting' | i18n}}... - + {{'twoFactorU2fClickSave' | i18n}} - + {{'twoFactorU2fProblemReadingTryAgain' | i18n}} + + + + + + diff --git a/src/connectors/webauthn-fallback.ts b/src/connectors/webauthn-fallback.ts new file mode 100644 index 00000000000..ed16ba61cd5 --- /dev/null +++ b/src/connectors/webauthn-fallback.ts @@ -0,0 +1,118 @@ +import { getQsParam } from './common'; +import { b64Decode, buildDataString } from './common-webauthn'; + +// tslint:disable-next-line +require('./webauthn.scss'); + +let parentUrl: string = null; +let parentOrigin: string = null; +let sentSuccess = false; + +let locales: any = {}; + +document.addEventListener('DOMContentLoaded', async () => { + const locale = getQsParam('locale'); + + const filePath = `locales/${locale}/messages.json?cache=${process.env.CACHE_TAG}`; + const localesResult = await fetch(filePath); + locales = await localesResult.json(); + + document.getElementById('msg').innerText = translate('webAuthnFallbackMsg'); + document.getElementById('remember-label').innerText = translate('rememberMe'); + document.getElementById('webauthn-button').innerText = translate('webAuthnAuthenticate'); + + document.getElementById('spinner').classList.add('d-none'); + const content = document.getElementById('content'); + content.classList.add('d-block'); + content.classList.remove('d-none'); +}); + +function translate(id: string) { + return locales[id]?.message || ''; +} + +(window as any).init = () => { + start(); +}; + +function start() { + if (sentSuccess) { + return; + } + + if (!('credentials' in navigator)) { + error(translate('webAuthnNotSupported')); + return; + } + + const data = getQsParam('data'); + if (!data) { + error('No data.'); + return; + } + + parentUrl = getQsParam('parent'); + if (!parentUrl) { + error('No parent.'); + return; + } else { + parentUrl = decodeURIComponent(parentUrl); + parentOrigin = new URL(parentUrl).origin; + } + + let json: any; + try { + const jsonString = b64Decode(data); + json = JSON.parse(jsonString); + } + catch (e) { + error('Cannot parse data.'); + return; + } + + initWebAuthn(json); +} + +async function initWebAuthn(obj: any) { + const challenge = obj.challenge.replace(/-/g, '+').replace(/_/g, '/'); + obj.challenge = Uint8Array.from(atob(challenge), c => c.charCodeAt(0)); + + // fix escaping. Change this to coerce + obj.allowCredentials.forEach((listItem: any) => { + const fixedId = listItem.id.replace(/\_/g, '/').replace(/\-/g, '+'); + listItem.id = Uint8Array.from(atob(fixedId), c => c.charCodeAt(0)); + }); + + try { + const assertedCredential = await navigator.credentials.get({ publicKey: obj }) as PublicKeyCredential; + + if (sentSuccess) { + return; + } + + const dataString = buildDataString(assertedCredential); + const remember = (document.getElementById('remember') as HTMLInputElement).checked; + window.postMessage({ command: 'webAuthnResult', data: dataString, remember: remember }, '*'); + + sentSuccess = true; + success(translate('webAuthnSuccess')); + } catch (err) { + error(err); + } +} + +function error(message: string) { + const el = document.getElementById('msg'); + el.innerHTML = message; + el.classList.add('alert'); + el.classList.add('alert-danger'); +} + +function success(message: string) { + (document.getElementById('webauthn-button') as HTMLButtonElement).disabled = true; + + const el = document.getElementById('msg'); + el.innerHTML = message; + el.classList.add('alert'); + el.classList.add('alert-success'); +} diff --git a/src/connectors/webauthn.html b/src/connectors/webauthn.html new file mode 100644 index 00000000000..15a7541dea3 --- /dev/null +++ b/src/connectors/webauthn.html @@ -0,0 +1,16 @@ + + + + + + Bitwarden WebAuthn Connector + + + + +
+ +
+ + + diff --git a/src/connectors/webauthn.scss b/src/connectors/webauthn.scss new file mode 100644 index 00000000000..bf821966e0a --- /dev/null +++ b/src/connectors/webauthn.scss @@ -0,0 +1,5 @@ +@import "../scss/styles.scss"; + +body { + min-width: 0px !important; +} diff --git a/src/connectors/webauthn.ts b/src/connectors/webauthn.ts new file mode 100644 index 00000000000..1d210802ad6 --- /dev/null +++ b/src/connectors/webauthn.ts @@ -0,0 +1,122 @@ +import { getQsParam } from './common'; +import { b64Decode, buildDataString } from './common-webauthn'; + +// tslint:disable-next-line +require('./webauthn.scss'); + +document.addEventListener('DOMContentLoaded', () => { + init(); + + const text = getQsParam('btnText'); + if (text) { + document.getElementById('webauthn-button').innerText = decodeURI(text); + } +}); + +let parentUrl: string = null; +let parentOrigin: string = null; +let stopWebAuthn = false; +let sentSuccess = false; +let obj: any = null; + +function init() { + start(); + onMessage(); + info('ready'); +} + +function start() { + sentSuccess = false; + + if (!('credentials' in navigator)) { + error('WebAuthn is not supported in this browser.'); + return; + } + + const data = getQsParam('data'); + if (!data) { + error('No data.'); + return; + } + + parentUrl = getQsParam('parent'); + if (!parentUrl) { + error('No parent.'); + return; + } else { + parentUrl = decodeURIComponent(parentUrl); + parentOrigin = new URL(parentUrl).origin; + } + + try { + const jsonString = b64Decode(data); + obj = JSON.parse(jsonString); + } + catch (e) { + error('Cannot parse data.'); + return; + } + + const challenge = obj.challenge.replace(/-/g, '+').replace(/_/g, '/'); + obj.challenge = Uint8Array.from(atob(challenge), c => c.charCodeAt(0)); + + // fix escaping. Change this to coerce + obj.allowCredentials.forEach((listItem: any) => { + const fixedId = listItem.id.replace(/\_/g, '/').replace(/\-/g, '+'); + listItem.id = Uint8Array.from(atob(fixedId), c => c.charCodeAt(0)); + }); + + stopWebAuthn = false; + + if (navigator.userAgent.indexOf(' Safari/') !== -1 && navigator.userAgent.indexOf('Chrome') === -1) { + // TODO: Hide image, show button + } else { + executeWebAuthn(); + } +} + +function executeWebAuthn() { + if (stopWebAuthn) { + return; + } + + navigator.credentials.get({ publicKey: obj }) + .then(success) + .catch(err => error('WebAuth Error: ' + err)); +} + +(window as any).executeWebAuthn = executeWebAuthn; + +function onMessage() { + window.addEventListener('message', event => { + if (!event.origin || event.origin === '' || event.origin !== parentOrigin) { + return; + } + + if (event.data === 'stop') { + stopWebAuthn = true; + } + else if (event.data === 'start' && stopWebAuthn) { + start(); + } + }, false); +} + +function error(message: string) { + parent.postMessage('error|' + message, parentUrl); +} + +function success(assertedCredential: PublicKeyCredential) { + if (sentSuccess) { + return; + } + + const dataString = buildDataString(assertedCredential); + parent.postMessage('success|' + dataString, parentUrl); + sentSuccess = true; +} + +function info(message: string) { + parent.postMessage('info|' + message, parentUrl); +} + diff --git a/src/images/two-factor/7.png b/src/images/two-factor/7.png new file mode 100644 index 0000000000000000000000000000000000000000..7098e4c76843820c156cc6045ab73c113bd161ea GIT binary patch literal 1620 zcmV-a2CMmrP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1?fpdK~z{r)tGCD zRaF$n&z(_=2y!gS$`4~k^ne^|q>oKR59mV*B{@t@AcSwuXB?l z1?^K-kB6*zRA4mL++_TqpvfngI2yQ1^pob;o;D91($Q+-XyYCcYu=PLM~)^bn<45o zI?eiPpl4*llC)J0jp>MVoon3{g(f*}ulRduGdQ#!x^Er$qn zJ^>v@ou#mVG`*&ufmBW&FdicgLXDwnv*26PRGTx+b`zF z%#7)0yg1He2lqk@-z|>IMH5poSz|m095&qS()d$MJQ(=y_(9~yRrhe`90`VNBjZiX z5SP7+jE}EYnr}3jmvA9s*k|l3XwL#X5q02=NX94;v?%5EHl)Rw-zoiWl!1zs%_;XfYlXVZaX2G`&AL zYNI=sMG?qADz>vkEuv*2fFU`wwf0uTIIh4_OROLpCwcBPdHASQ_vqJ806>D41(|QO zmTHo4+`k`s>Q{Q$-=ej$_MXs_uCpT-kV>_>S}b-rk;8EVdfD$7ugLhIgK9sLcGtzCAzxMEkL}i6K(_6k zgHhS#F3Kt=g|;>_l~ERl;x z7RWAl(G{tfP!dz)I9B&-p%u>4S%&j<>3T<#)^Wd=;oc=i(*|o4^ zIsGSz#)??W-y)D1DJPROet#VZ9=Op!=FpL%CLreU&(c=FyXq_c4N@#^%RU6-Mb4Lw znK60a;`PgRp0IFm!JIWq_HX@m+LiZhn0L{|&#qW-%?n$0Ui#dsx0UAmH1=oFg(wm} zF%I0x93k8}W#nfx-%nFb2KAgc#yE-5aFp#3@VIgCrtuhLw_}GKTRc+}ovCl>*LO|s zGL~bg;wTTZOMTBTY=(et-3y^QkzUT^-z8FpbIjqyFqhtCXZY@ThBee7cD%16{&4hw z@Q3I*2;%I%tB{4_j&BI+1q6qGFJjTJ(h);O((m_*-zhohO#M@ALD~vNhrg*s)ZdHK z?z-sFxQ|5oUJ2hLkBM;BQ<~cy<-21*H~$+Z*TbX6;}6BG3KQ*8KX)5z^C15(fB%#Q z#2o&*hzzTjDLnUarO>-;#XKk&j8DjmR!!Vq+f%y4<=&b2Kkd7otl79gXD>r$m S5~#-j0000WebAuthn verified successfully!
You may close this tab." } } diff --git a/src/scss/styles.scss b/src/scss/styles.scss index be32ef9e2f3..598fea83b30 100644 --- a/src/scss/styles.scss +++ b/src/scss/styles.scss @@ -604,6 +604,17 @@ app-user-billing { } } +#web-authn-frame { + background: url('../images/loading.svg') 0 0 no-repeat; + height: 290px; + + iframe { + width: 100%; + height: 100%; + border: none; + } +} + #bt-dropin-container { background: url('../images/loading.svg') 0 0 no-repeat; min-height: 50px; diff --git a/src/services/webPlatformUtils.service.ts b/src/services/webPlatformUtils.service.ts index 1bf2d0383c6..977b7f276b6 100644 --- a/src/services/webPlatformUtils.service.ts +++ b/src/services/webPlatformUtils.service.ts @@ -158,11 +158,8 @@ export class WebPlatformUtilsService implements PlatformUtilsService { return process.env.APPLICATION_VERSION || '-'; } - supportsU2f(win: Window): boolean { - if (win != null && (win as any).u2f != null) { - return true; - } - return this.isChrome() || ((this.isEdge() || this.isOpera() || this.isVivaldi()) && !Utils.isMobileBrowser); + supportsWebAuthn(win: Window): boolean { + return (typeof(PublicKeyCredential) !== 'undefined'); } supportsDuo(): boolean { diff --git a/webpack.config.js b/webpack.config.js index 90c4c24b7a8..e37ee076b4f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -88,6 +88,16 @@ const plugins = [ filename: 'u2f-connector.html', chunks: ['connectors/u2f'], }), + new HtmlWebpackPlugin({ + template: './src/connectors/webauthn.html', + filename: 'webauthn-connector.html', + chunks: ['connectors/webauthn'], + }), + new HtmlWebpackPlugin({ + template: './src/connectors/webauthn-fallback.html', + filename: 'webauthn-fallback-connector.html', + chunks: ['connectors/webauthn-fallback'], + }), new HtmlWebpackPlugin({ template: './src/connectors/sso.html', filename: 'sso-connector.html', @@ -158,6 +168,8 @@ const config = { 'app/polyfills': './src/app/polyfills.ts', 'app/main': './src/app/main.ts', 'connectors/u2f': './src/connectors/u2f.js', + 'connectors/webauthn': './src/connectors/webauthn.ts', + 'connectors/webauthn-fallback': './src/connectors/webauthn-fallback.ts', 'connectors/duo': './src/connectors/duo.ts', 'connectors/sso': './src/connectors/sso.ts', },