diff --git a/jslib b/jslib index f80e89465f..f20af0cd7c 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 0dbedf5caf..72f99be628 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 2224e4d76e..df1b5420aa 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 7b62920876..04fe750e13 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 1ee49e2a0f..58aeac7858 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 104dd9b169..6c55a95fd3 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 af0a4d6b0d..69f68114b8 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 0000000000..ed16ba61cd --- /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 0000000000..15a7541dea --- /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 0000000000..bf821966e0 --- /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 0000000000..1d210802ad --- /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 0000000000..7098e4c768 Binary files /dev/null and b/src/images/two-factor/7.png differ diff --git a/src/locales/en/messages.json b/src/locales/en/messages.json index 7f88d2b8e0..51434de4f2 100644 --- a/src/locales/en/messages.json +++ b/src/locales/en/messages.json @@ -725,6 +725,15 @@ "u2fTitle": { "message": "FIDO U2F Security Key" }, + "webAuthnTitle": { + "message": "FIDO2 WebAuthn" + }, + "webAuthnDesc": { + "message": "Use any WebAuthn enabled security key to access your account." + }, + "webAuthnMigrated": { + "message": "(Migrated from FIDO)" + }, "emailTitle": { "message": "Email" }, @@ -1260,6 +1269,15 @@ } } }, + "webAuthnkeyX": { + "message": "WebAuthn Key $INDEX$", + "placeholders": { + "index": { + "content": "$1", + "example": "2" + } + } + }, "nfcSupport": { "message": "NFC Support" }, @@ -1305,6 +1323,9 @@ "removeU2fConfirmation": { "message": "Are you sure you want to remove this security key?" }, + "twoFactorWebAuthnAdd": { + "message": "Add a WebAuthn security key to your account" + }, "readKey": { "message": "Read Key" }, @@ -1338,6 +1359,12 @@ "twoFactorU2fProblemReadingTryAgain": { "message": "There was a problem reading the security key. Try again." }, + "twoFactorWebAuthnWarning": { + "message": "Due to platform limitations, WebAuthn cannot be used on all Bitwarden applications. You should enable another two-step login provider so that you can access your account when WebAuthn cannot be used. Supported platforms:" + }, + "twoFactorWebAuthnSupportWeb": { + "message": "Web vault and browser extensions on a desktop/laptop with a WebAuthn enabled browser (Chrome, Opera, Vivaldi, or Firefox with FIDO U2F enabled)." + }, "twoFactorRecoveryYourCode": { "message": "Your Bitwarden two-step login recovery code" }, @@ -3762,5 +3789,17 @@ }, "dateParsingError": { "message": "There was an error saving your deletion and expiration dates." + }, + "webAuthnFallbackMsg": { + "message": "To verify your 2FA please click the button below." + }, + "webAuthnAuthenticate": { + "message": "Authenticate WebAutn" + }, + "webAuthnNotSupported": { + "message": "WebAuthn is not supported in this browser." + }, + "webAuthnSuccess": { + "message": "WebAuthn verified successfully!
You may close this tab." } } diff --git a/src/scss/styles.scss b/src/scss/styles.scss index be32ef9e2f..598fea83b3 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 1bf2d0383c..977b7f276b 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 90c4c24b7a..e37ee076b4 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', },