diff --git a/packages/clipper/src/background/background.ts b/packages/clipper/src/background/background.ts index 096d149d77b..cc47a5b7c2c 100644 --- a/packages/clipper/src/background/background.ts +++ b/packages/clipper/src/background/background.ts @@ -5,6 +5,20 @@ import packageInfo from '../../package.json' const isFirefox = navigator.userAgent.indexOf('Firefox/') !== -1 +const openClipperAuthPanel = async (pane: 'sign-in' | 'register') => { + const popupPath = await browserAction.getPopup({}) + const url = new URL(popupPath, runtime.getURL('/')) + url.searchParams.set('route', 'extension') + url.searchParams.set('pane', pane) + + await windows.create({ + type: 'detached_panel', + url: url.toString(), + width: 350, + height: 300, + }) +} + const openPopupAndClipSelection = async (payload: ClipPayload) => { await storage.local.set({ clip: payload }) @@ -33,6 +47,8 @@ runtime.onMessage.addListener(async (message: RuntimeMessage) => { return } void openPopupAndClipSelection(message.payload) + } else if (message.type === RuntimeMessageTypes.OpenClipperAuthPanel) { + await openClipperAuthPanel(message.pane) } else if (message.type === RuntimeMessageTypes.CaptureVisibleTab) { return await tabs.captureVisibleTab(undefined, { format: 'png', diff --git a/packages/clipper/src/manifest.v2.json b/packages/clipper/src/manifest.v2.json index 162e565cf7d..2f88f46b531 100644 --- a/packages/clipper/src/manifest.v2.json +++ b/packages/clipper/src/manifest.v2.json @@ -27,7 +27,7 @@ "browser_specific_settings": { "gecko": { "id": "{9f917dfe-accd-4d3a-9685-33c3ac0ca643}", - "strict_min_version": "48.0" + "strict_min_version": "150.0" } } } diff --git a/packages/clipper/src/manifest.v3.json b/packages/clipper/src/manifest.v3.json index 76ce7108f06..ee78fdaf3c4 100644 --- a/packages/clipper/src/manifest.v3.json +++ b/packages/clipper/src/manifest.v3.json @@ -28,7 +28,7 @@ "browser_specific_settings": { "gecko": { "id": "{9f917dfe-accd-4d3a-9685-33c3ac0ca643}", - "strict_min_version": "48.0" + "strict_min_version": "150.0" } } } diff --git a/packages/clipper/src/types/message.ts b/packages/clipper/src/types/message.ts index b365203f635..1b986d22023 100644 --- a/packages/clipper/src/types/message.ts +++ b/packages/clipper/src/types/message.ts @@ -10,6 +10,7 @@ export const RuntimeMessageTypes = { ToggleScreenshotMode: 'toggle-screenshot-mode', CaptureVisibleTab: 'capture-visible-tab', RunHttpRequest: 'run-http-request', + OpenClipperAuthPanel: 'open-clipper-auth-panel', } as const export type RuntimeMessageType = (typeof RuntimeMessageTypes)[keyof typeof RuntimeMessageTypes] @@ -33,6 +34,7 @@ export type RuntimeMessageReturnTypes = { [RuntimeMessageTypes.StartNodeSelection]: void [RuntimeMessageTypes.ToggleScreenshotMode]: void [RuntimeMessageTypes.RunHttpRequest]: void + [RuntimeMessageTypes.OpenClipperAuthPanel]: void } export type RuntimeMessage = @@ -48,11 +50,16 @@ export type RuntimeMessage = type: typeof RuntimeMessageTypes.ToggleScreenshotMode enabled: boolean } + | { + type: typeof RuntimeMessageTypes.OpenClipperAuthPanel + pane: 'sign-in' | 'register' + } | { type: Exclude< RuntimeMessageType, | MessagesWithClipPayload | typeof RuntimeMessageTypes.ToggleScreenshotMode | typeof RuntimeMessageTypes.RunHttpRequest + | typeof RuntimeMessageTypes.OpenClipperAuthPanel > } diff --git a/packages/clipper/src/utils/sendMessageToActiveTab.ts b/packages/clipper/src/utils/sendMessageToActiveTab.ts index a7e5130de35..ec633d6130c 100644 --- a/packages/clipper/src/utils/sendMessageToActiveTab.ts +++ b/packages/clipper/src/utils/sendMessageToActiveTab.ts @@ -4,11 +4,15 @@ import { RuntimeMessage, RuntimeMessageReturnTypes } from '../types/message' export default async function sendMessageToActiveTab( message: T, ): Promise { - const [activeTab] = await tabs.query({ active: true, currentWindow: true, windowType: 'normal' }) + const [activeTab] = await tabs.query({ active: true, lastFocusedWindow: true }) - if (!activeTab || !activeTab.id) { + if (!activeTab?.id) { return } - return await tabs.sendMessage(activeTab.id, message) + try { + return await tabs.sendMessage(activeTab.id, message) + } catch { + return + } } diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts index 79977ffc904..876b4e73e08 100644 --- a/packages/snjs/lib/Application/Application.ts +++ b/packages/snjs/lib/Application/Application.ts @@ -82,6 +82,7 @@ import { CreateEncryptedBackupFile, WebSocketsService, PreferencesServiceEvent, + AuthenticatorManager, } from '@standardnotes/services' import { SNNote, @@ -955,6 +956,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli return this.dependencies.get(TYPES.AddAuthenticator) } + fetchAuthenticatorRegistrationOptions(): Promise | null> { + return this.dependencies.get(TYPES.AuthenticatorManager).generateRegistrationOptions() + } + get listAuthenticators(): ListAuthenticators { return this.dependencies.get(TYPES.ListAuthenticators) } diff --git a/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticator.ts b/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticator.ts index d8bceb528ee..9a7990b525f 100644 --- a/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticator.ts +++ b/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticator.ts @@ -31,7 +31,8 @@ export class AddAuthenticator implements UseCaseInterface { ) } - const registrationOptions = await this.authenticatorClient.generateRegistrationOptions() + const registrationOptions = + dto.registrationOptions ?? (await this.authenticatorClient.generateRegistrationOptions()) if (registrationOptions === null) { return Result.fail('Could not generate authenticator registration options') } diff --git a/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticatorDTO.ts b/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticatorDTO.ts index 77aa3d8bd42..c47fd81d3e5 100644 --- a/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticatorDTO.ts +++ b/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticatorDTO.ts @@ -1,4 +1,6 @@ export interface AddAuthenticatorDTO { userUuid: string authenticatorName: string + /** When provided, skips the server round-trip so WebAuthn can run in the same user gesture. */ + registrationOptions?: Record } diff --git a/packages/web/src/javascripts/Application/WebApplication.ts b/packages/web/src/javascripts/Application/WebApplication.ts index b3ac644b147..7751183cad3 100644 --- a/packages/web/src/javascripts/Application/WebApplication.ts +++ b/packages/web/src/javascripts/Application/WebApplication.ts @@ -29,6 +29,7 @@ import { action, computed, makeObservable, observable } from 'mobx' import { startAuthentication, startRegistration } from '@simplewebauthn/browser' import { PanelResizedData } from '@/Types/PanelResizedData' import { getBlobFromBase64, isDesktopApplication, isDev } from '@/Utils' +import { prepareWebAuthnRegistrationOptions } from '@/Utils/prepareWebAuthnRegistrationOptions' import { ArchiveManager, AutolockService, @@ -128,9 +129,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter deviceInterface.environment === Environment.Mobile ? 250 : ApplicationOptionsDefaults.sleepBetweenBatches, allowMultipleSelection: deviceInterface.environment !== Environment.Mobile, allowNoteSelectionStatePersistence: deviceInterface.environment !== Environment.Mobile, - u2fAuthenticatorRegistrationPromptFunction: startRegistration as unknown as ( - registrationOptions: Record, - ) => Promise>, + u2fAuthenticatorRegistrationPromptFunction: ((registrationOptions: Record) => + startRegistration( + prepareWebAuthnRegistrationOptions(registrationOptions) as unknown as Parameters[0], + )) as unknown as (registrationOptions: Record) => Promise>, u2fAuthenticatorVerificationPromptFunction: startAuthentication as unknown as ( authenticationOptions: Record, ) => Promise>, diff --git a/packages/web/src/javascripts/Components/ChallengeModal/U2FPrompt.tsx b/packages/web/src/javascripts/Components/ChallengeModal/U2FPrompt.tsx index 87de464b32f..848e968a4ec 100644 --- a/packages/web/src/javascripts/Components/ChallengeModal/U2FPrompt.tsx +++ b/packages/web/src/javascripts/Components/ChallengeModal/U2FPrompt.tsx @@ -1,15 +1,17 @@ -import { Username } from '@standardnotes/snjs' +import { Environment, Username } from '@standardnotes/snjs' import { ChallengePrompt } from '@standardnotes/services' -import { RefObject, useState } from 'react' +import { RefObject, useCallback, useState } from 'react' import { c } from 'ttag' import { WebApplication } from '@/Application/WebApplication' +import { IS_FIREFOX } from '@/Components/SuperEditor/Lexical/Shared/environment' import Button from '../Button/Button' import Icon from '../Icon/Icon' import { InputValue } from './InputValue' import U2FPromptIframeContainer from './U2FPromptIframeContainer' +import U2FPromptFirefoxNative from './U2FPromptFirefoxNative' import { isAndroid } from '@standardnotes/ui-services' type Props = { @@ -24,11 +26,31 @@ const U2FPrompt = ({ application, onValueChange, prompt, buttonRef, contextData const [authenticatorResponse, setAuthenticatorResponse] = useState | null>(null) const [error, setError] = useState('') + const handleNativeResponse = useCallback( + (response: Record) => { + onValueChange(response, prompt) + }, + [onValueChange, prompt], + ) + if (!application.isFullU2FClient && !isAndroid()) { + const apiHost = application.getHost.execute().getValue() || window.defaultSyncServer + + if (application.environment === Environment.Clipper && IS_FIREFOX) { + return ( + + ) + } + return ( { onValueChange(response, prompt) }} diff --git a/packages/web/src/javascripts/Components/ChallengeModal/U2FPromptFirefoxNative.tsx b/packages/web/src/javascripts/Components/ChallengeModal/U2FPromptFirefoxNative.tsx new file mode 100644 index 00000000000..ed4d7a4a6f8 --- /dev/null +++ b/packages/web/src/javascripts/Components/ChallengeModal/U2FPromptFirefoxNative.tsx @@ -0,0 +1,81 @@ +import { log, LoggingDomain } from '@/Logging' +import { getU2fRelyingPartyId } from '@/Constants/U2FConstants' +import { startAuthentication } from '@simplewebauthn/browser' +import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/typescript-types' +import { RefObject, useCallback, useState } from 'react' +import { c } from 'ttag' + +import Button from '../Button/Button' + +type Props = { + contextData?: Record + onResponse: (response: Record) => void + apiHost: string + buttonRef: RefObject +} + +const U2FPromptFirefoxNative = ({ contextData, onResponse, apiHost, buttonRef }: Props) => { + const [pending, setPending] = useState(false) + const [error, setError] = useState('') + + const authenticateWithSecurityKey = useCallback(async () => { + const username = (contextData as { username: string } | undefined)?.username + if (!username) { + setError(c('B1.Account.SignIn.Error').t`No username provided`) + return + } + + setPending(true) + setError('') + + try { + const response = await fetch(`${apiHost}/v1/authenticators/generate-authentication-options`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username }), + }) + + const jsonResponse = await response.json() + if (!jsonResponse.data?.options) { + throw new Error(c('B1.Account.SignIn.Error').t`No options returned from server`) + } + + const options = jsonResponse.data.options as PublicKeyCredentialRequestOptionsJSON + + const rpId = getU2fRelyingPartyId(apiHost, options) + + log(LoggingDomain.U2F, 'Starting native WebAuthn authentication', { + username, + apiHost, + rpId, + }) + + const assertionResponse = await startAuthentication({ + ...options, + rpId, + }) + + log(LoggingDomain.U2F, 'Native WebAuthn authentication completed', { id: assertionResponse.id }) + + onResponse(assertionResponse as unknown as Record) + } catch (authError) { + setError(authError instanceof Error ? authError.message : String(authError)) + console.error(authError) + } finally { + setPending(false) + } + }, [contextData, apiHost, onResponse]) + + return ( +
+ {error &&
{error}
} + +
+ ) +} + +export default U2FPromptFirefoxNative diff --git a/packages/web/src/javascripts/Components/ClipperView/ClipperView.tsx b/packages/web/src/javascripts/Components/ClipperView/ClipperView.tsx index e3cb780e9fb..52cc06aed2d 100644 --- a/packages/web/src/javascripts/Components/ClipperView/ClipperView.tsx +++ b/packages/web/src/javascripts/Components/ClipperView/ClipperView.tsx @@ -39,6 +39,18 @@ import LinkedItemBubble from '../LinkedItems/LinkedItemBubble' import StyledTooltip from '../StyledTooltip/StyledTooltip' import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem' import Spinner from '../Spinner/Spinner' +import { IS_FIREFOX } from '@/Components/SuperEditor/Lexical/Shared/environment' + +const getAuthPaneFromUrl = () => { + const pane = new URLSearchParams(window.location.search).get('pane') + if (pane === 'sign-in') { + return AccountMenuPane.SignIn + } + if (pane === 'register') { + return AccountMenuPane.Register + } + return undefined +} const ClipperView = ({ applicationGroup }: { applicationGroup: WebApplicationGroup }) => { const application = useApplication() @@ -54,6 +66,8 @@ const ClipperView = ({ applicationGroup }: { applicationGroup: WebApplicationGro }) .catch(console.error) }, []) + const isDetachedPanel = (currentWindow?.type as string | undefined) === 'detached_panel' + const isFirefoxAuthPanel = IS_FIREFOX && getAuthPaneFromUrl() !== undefined const isFirefoxPopup = !!currentWindow && currentWindow.type === 'popup' && currentWindow.incognito === false const [user, setUser] = useState(() => application.sessions.getUser()) @@ -74,6 +88,16 @@ const ClipperView = ({ applicationGroup }: { applicationGroup: WebApplicationGro return application.addEventObserver(async (event) => { switch (event) { case ApplicationEvent.SignedIn: + setUser(application.sessions.getUser()) + setIsEntitled( + application.features.getFeatureStatus( + NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.Clipper).getValue(), + ) === FeatureStatus.Entitled, + ) + if (isFirefoxAuthPanel) { + window.close() + } + break case ApplicationEvent.SignedOut: case ApplicationEvent.UserRolesChanged: setUser(application.sessions.getUser()) @@ -126,15 +150,37 @@ const ClipperView = ({ applicationGroup }: { applicationGroup: WebApplicationGro void application.setPreference(PrefKey.ClipperDefaultTagUuid, undefined) }, [application]) - const [menuPane, setMenuPane] = useState() + const [menuPane, setMenuPane] = useState(getAuthPaneFromUrl) + + const openFirefoxAuthPanel = useCallback(async (pane: 'sign-in' | 'register') => { + try { + await runtime.sendMessage({ type: RuntimeMessageTypes.OpenClipperAuthPanel, pane }) + } catch (error) { + console.error(error) + setMenuPane(pane === 'sign-in' ? AccountMenuPane.SignIn : AccountMenuPane.Register) + return + } + + window.close() + }, []) const activateRegisterPane = useCallback(() => { + if (IS_FIREFOX && !isFirefoxAuthPanel && !isDetachedPanel) { + void openFirefoxAuthPanel('register') + return + } + setMenuPane(AccountMenuPane.Register) - }, [setMenuPane]) + }, [isDetachedPanel, isFirefoxAuthPanel, openFirefoxAuthPanel]) const activateSignInPane = useCallback(() => { + if (IS_FIREFOX && !isFirefoxAuthPanel && !isDetachedPanel) { + void openFirefoxAuthPanel('sign-in') + return + } + setMenuPane(AccountMenuPane.SignIn) - }, [setMenuPane]) + }, [isDetachedPanel, isFirefoxAuthPanel, openFirefoxAuthPanel]) const showSignOutConfirmation = useCallback(async () => { if ( @@ -155,7 +201,7 @@ const ClipperView = ({ applicationGroup }: { applicationGroup: WebApplicationGro void sendMessageToActiveTab({ type: RuntimeMessageTypes.ToggleScreenshotMode, enabled: isScreenshotMode, - }) + }).catch(() => undefined) }, [isScreenshotMode]) const [hasSelection, setHasSelection] = useState(false) @@ -323,16 +369,26 @@ const ClipperView = ({ applicationGroup }: { applicationGroup: WebApplicationGro } if (!user) { - return menuPane ? ( -
- setMenuPane(undefined)} - /> -
- ) : ( + if (menuPane || isFirefoxAuthPanel) { + return ( +
+ { + if (isFirefoxAuthPanel && pane === AccountMenuPane.GeneralMenu) { + window.close() + return + } + setMenuPane(pane) + }} + closeMenu={() => (isFirefoxAuthPanel ? window.close() : setMenuPane(undefined))} + /> +
+ ) + } + + return ( diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FAddDeviceView.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FAddDeviceView.tsx index b32d968a491..4ea5ea722fb 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FAddDeviceView.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FAddDeviceView.tsx @@ -1,4 +1,4 @@ -import { FunctionComponent, useCallback, useState } from 'react' +import { FunctionComponent, useCallback, useEffect, useState } from 'react' import { observer } from 'mobx-react-lite' import { AddAuthenticator } from '@standardnotes/snjs' @@ -19,6 +19,27 @@ const U2FAddDeviceView: FunctionComponent = ({ addAuthenticator, onDevice const [deviceName, setDeviceName] = useState('') const [errorMessage, setErrorMessage] = useState('') + const [registrationOptions, setRegistrationOptions] = useState | null>(null) + const [isLoadingOptions, setIsLoadingOptions] = useState(true) + + useEffect(() => { + let isActive = true + + void application.fetchAuthenticatorRegistrationOptions().then((options) => { + if (!isActive) { + return + } + setRegistrationOptions(options) + setIsLoadingOptions(false) + if (options === null) { + setErrorMessage(c('B6.Preferences.Security.Error').t`Could not prepare security key registration`) + } + }) + + return () => { + isActive = false + } + }, [application]) const handleDeviceNameChange = useCallback((deviceName: string) => { setDeviceName(deviceName) @@ -30,6 +51,11 @@ const U2FAddDeviceView: FunctionComponent = ({ addAuthenticator, onDevice return } + if (!registrationOptions) { + setErrorMessage(c('B6.Preferences.Security.Error').t`Could not prepare security key registration`) + return + } + const user = application.sessions.getUser() if (user === undefined) { setErrorMessage(c('B6.Preferences.Security.Error').t`User not found`) @@ -39,6 +65,7 @@ const U2FAddDeviceView: FunctionComponent = ({ addAuthenticator, onDevice const authenticatorAddedOrError = await addAuthenticator.execute({ userUuid: user.uuid, authenticatorName: deviceName, + registrationOptions, }) if (authenticatorAddedOrError.isFailed()) { setErrorMessage(authenticatorAddedOrError.getError()) @@ -47,7 +74,15 @@ const U2FAddDeviceView: FunctionComponent = ({ addAuthenticator, onDevice onDeviceAddingModalToggle(false) await onDeviceAdded() - }, [deviceName, setErrorMessage, application, addAuthenticator, onDeviceAddingModalToggle, onDeviceAdded]) + }, [ + deviceName, + registrationOptions, + setErrorMessage, + application, + addAuthenticator, + onDeviceAddingModalToggle, + onDeviceAdded, + ]) const closeModal = () => { onDeviceAddingModalToggle(false) @@ -77,6 +112,7 @@ const U2FAddDeviceView: FunctionComponent = ({ addAuthenticator, onDevice type: 'primary', onClick: handleAddDeviceClick, mobileSlot: 'right', + disabled: isLoadingOptions || registrationOptions === null, }, ]} > diff --git a/packages/web/src/javascripts/Constants/U2FConstants.ts b/packages/web/src/javascripts/Constants/U2FConstants.ts new file mode 100644 index 00000000000..64e4dbc7f75 --- /dev/null +++ b/packages/web/src/javascripts/Constants/U2FConstants.ts @@ -0,0 +1,28 @@ +/** Relying party ID used when registering and authenticating hardware keys for Standard Notes. */ +export const U2F_RELYING_PARTY_ID = 'app.standardnotes.com' + +/** + * Stable WebAuthn origin for the Firefox clipper extension (SHA-256 of gecko.id, a–p encoded). + * Must be included in the auth server's U2F_EXPECTED_ORIGIN allowlist. + * @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Use_the_web_authn_api + */ +export const FIREFOX_CLIPPER_WEBAUTHN_ORIGIN = + 'moz-extension://mbmeiccjaghdhkomcnlbapkpmhjgcjdonnphcmpgbgnlpbepegcaeomhoinfmflc' + +export function isLocalU2fApiHost(apiHost: string): boolean { + try { + const hostname = new URL(apiHost).hostname + return hostname === 'localhost' || hostname === '127.0.0.1' + } catch { + return false + } +} + +/** Prod clipper always uses app.standardnotes.com; local servers use their configured rpId. */ +export function getU2fRelyingPartyId(apiHost: string, serverOptions?: { rpId?: string }): string { + if (isLocalU2fApiHost(apiHost)) { + return serverOptions?.rpId ?? new URL(apiHost).hostname + } + + return U2F_RELYING_PARTY_ID +} diff --git a/packages/web/src/javascripts/Utils/prepareWebAuthnRegistrationOptions.ts b/packages/web/src/javascripts/Utils/prepareWebAuthnRegistrationOptions.ts new file mode 100644 index 00000000000..287213f45e1 --- /dev/null +++ b/packages/web/src/javascripts/Utils/prepareWebAuthnRegistrationOptions.ts @@ -0,0 +1,26 @@ +import { isDev } from '@/Utils' + +/** Align rpId with the page hostname for local WebAuthn development. */ +export function prepareWebAuthnRegistrationOptions(options: Record): Record { + if (!isDev) { + return options + } + + const hostname = window.location.hostname + if (hostname !== 'localhost' && hostname !== '127.0.0.1') { + return options + } + + const rp = options.rp as { id?: string; name?: string } | undefined + if (!rp) { + return options + } + + return { + ...options, + rp: { + ...rp, + id: hostname, + }, + } +}