diff --git a/_locales/en/messages.json b/_locales/en/messages.json index ed0bc0003..27fe37269 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2947,6 +2947,18 @@ "message": "Conversation marked unread", "description": "A toast that shows up when user marks a conversation as unread" }, + "icu:AuthArtCreator--dialog--message": { + "messageformat": "Would you like to open Signal Sticker Pack Creator?", + "description": "A body of the dialog that is presented when user tries to open Signal Sticker Pack Creator from a link" + }, + "icu:AuthArtCreator--dialog--confirm": { + "messageformat": "Confirm", + "description": "A buttle title for confirming Signal Sticker Pack Creator dialog" + }, + "icu:AuthArtCreator--dialog--dismiss": { + "messageformat": "Dismiss", + "description": "A buttle title for dismissing Signal Sticker Pack Creator dialog" + }, "StickerCreator--title": { "message": "Sticker pack creator", "description": "The title of the Sticker Pack Creator window" @@ -3147,6 +3159,10 @@ "message": "Please set up Signal on your phone and desktop to use the Sticker Pack Creator", "description": "The error message which appears when the user has not linked their account and attempts to use the Sticker Creator" }, + "icu:ArtCreator--Authentication--error": { + "messageformat": "Please set up Signal on your phone and desktop to use the Sticker Art Creator", + "description": "The error message which appears when the user has not linked their account and attempts to use the Art Creator" + }, "Reactions--remove": { "message": "Remove reaction", "describe": "Shown when you want to remove a reaction you've made" diff --git a/app/main.ts b/app/main.ts index 346d346f4..4cb926020 100644 --- a/app/main.ts +++ b/app/main.ts @@ -417,6 +417,7 @@ async function prepareUrl( storageUrl: config.get('storageUrl'), updatesUrl: config.get('updatesUrl'), resourcesUrl: config.get('resourcesUrl'), + artCreatorUrl: config.get('artCreatorUrl'), cdnUrl0: config.get('cdn').get('0'), cdnUrl2: config.get('cdn').get('2'), certificateAuthority: config.get('certificateAuthority'), @@ -1004,6 +1005,11 @@ ipc.handle('database-ready', async () => { getLogger().info('sending `database-ready`'); }); +ipc.handle('open-art-creator', (_event, { username, password }) => { + const baseUrl = config.get('artCreatorUrl'); + drop(shell.openExternal(`${baseUrl}/#auth=${username}:${password}`)); +}); + ipc.on('show-window', () => { showWindow(); }); @@ -1409,6 +1415,25 @@ async function showStickerCreator() { await safeLoadURL(stickerCreatorWindow, await appUrl); } +async function openArtCreator() { + if (!(await getIsLinked())) { + const message = getResolvedMessagesLocale().i18n( + 'icu:ArtCreator--Authentication--error' + ); + + await dialog.showMessageBox({ + type: 'warning', + message, + }); + + return; + } + + if (mainWindow) { + mainWindow.webContents.send('open-art-creator'); + } +} + let debugLogWindow: BrowserWindow | undefined; async function showDebugLogWindow() { if (debugLogWindow) { @@ -1963,6 +1988,7 @@ function setupMenu(options?: Partial) { // actions forceUpdate, + openArtCreator, openContactUs, openForums, openJoinTheBeta, @@ -2348,6 +2374,14 @@ function handleSgnlHref(incomingHref: string) { ? Buffer.from(packKeyHex, 'hex').toString('base64') : ''; mainWindow.webContents.send('show-sticker-pack', { packId, packKey }); + } else if (command === 'art-auth') { + const token = args?.get('token'); + const pubKeyBase64 = args?.get('pub_key'); + + mainWindow.webContents.send('authorize-art-creator', { + token, + pubKeyBase64, + }); } else if (command === 'signal.group' && hash) { getLogger().info('Showing group from sgnl protocol link'); mainWindow.webContents.send('show-group-via-link', { hash }); @@ -2549,6 +2583,8 @@ ipc.handle('getMenuOptions', async () => { ipc.handle('executeMenuAction', async (_event, action: MenuActionType) => { if (action === 'forceUpdate') { drop(forceUpdate()); + } else if (action === 'openArtCreator') { + drop(openArtCreator()); } else if (action === 'openContactUs') { openContactUs(); } else if (action === 'openForums') { diff --git a/app/menu.ts b/app/menu.ts index ac7570080..250a26947 100644 --- a/app/menu.ts +++ b/app/menu.ts @@ -38,6 +38,7 @@ export const createTemplate = ( showKeyboardShortcuts, showSettings, showStickerCreator, + openArtCreator, } = options; const template: MenuListType = [ @@ -46,7 +47,7 @@ export const createTemplate = ( submenu: [ { label: i18n('mainMenuCreateStickers'), - click: showStickerCreator, + click: isProduction ? showStickerCreator : openArtCreator, }, { label: i18n('mainMenuSettings'), diff --git a/config/default.json b/config/default.json index 91bf9a1e3..79ecc6d27 100644 --- a/config/default.json +++ b/config/default.json @@ -10,6 +10,7 @@ "contentProxyUrl": "http://contentproxy.signal.org:443", "updatesUrl": "https://updates2.signal.org/desktop", "resourcesUrl": "https://updates2.signal.org", + "artCreatorUrl": "https://create.staging.signal.art", "updatesPublicKey": "05fd7dd3de7149dc0a127909fee7de0f7620ddd0de061b37a2c303e37de802a401", "sfuUrl": "https://sfu.voip.signal.org/", "updatesEnabled": false, diff --git a/protos/ArtCreator.proto b/protos/ArtCreator.proto new file mode 100644 index 000000000..378bd38ee --- /dev/null +++ b/protos/ArtCreator.proto @@ -0,0 +1,14 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +package signalservice; + +message ArtProvisioningEnvelope { + optional bytes publicKey = 1; + optional bytes ciphertext = 2; +} + +message ArtProvisioningMessage { + optional string username = 1; + optional string password = 2; +} diff --git a/sticker-creator/window/phase3-sticker-functions.ts b/sticker-creator/window/phase3-sticker-functions.ts index e1b0ed0ea..63539f6ba 100644 --- a/sticker-creator/window/phase3-sticker-functions.ts +++ b/sticker-creator/window/phase3-sticker-functions.ts @@ -38,6 +38,7 @@ const WebAPI = initializeWebAPI({ storageUrl: config.storageUrl, updatesUrl: config.updatesUrl, resourcesUrl: config.resourcesUrl, + artCreatorUrl: config.artCreatorUrl, directoryConfig: config.directoryConfig, cdnUrlObject: { 0: config.cdnUrl0, diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index edbc886a6..2fc20fe14 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3522,7 +3522,7 @@ button.module-image__border-overlay:focus { } } -.module-spinner__circle--on-captcha { +.module-spinner__circle--on-primary-button { background-color: $color-white-alpha-40; } @@ -3540,7 +3540,7 @@ button.module-image__border-overlay:focus { .module-spinner__arc--on-avatar { background-color: $color-white; } -.module-spinner__arc--on-captcha { +.module-spinner__arc--on-primary-button { background-color: $color-white; } diff --git a/ts/components/CaptchaDialog.tsx b/ts/components/CaptchaDialog.tsx index b922d79e6..7172c5e67 100644 --- a/ts/components/CaptchaDialog.tsx +++ b/ts/components/CaptchaDialog.tsx @@ -82,7 +82,7 @@ export function CaptchaDialog(props: Readonly): JSX.Element { variant={ButtonVariant.Primary} > {isPending ? ( - + ) : ( 'Continue' )} diff --git a/ts/components/ConfirmationDialog.tsx b/ts/components/ConfirmationDialog.tsx index 3695b9de7..ce36de885 100644 --- a/ts/components/ConfirmationDialog.tsx +++ b/ts/components/ConfirmationDialog.tsx @@ -10,11 +10,13 @@ import { ModalHost } from './ModalHost'; import { ModalPage } from './Modal'; import type { Theme } from '../util/theme'; import { useAnimated } from '../hooks/useAnimated'; +import { Spinner } from './Spinner'; export type ActionSpec = { text: string; action: () => unknown; style?: 'affirmative' | 'negative'; + autoClose?: boolean; }; export type OwnProps = Readonly<{ @@ -22,6 +24,7 @@ export type OwnProps = Readonly<{ dialogName: string; cancelButtonVariant?: ButtonVariant; cancelText?: string; + isSpinning?: boolean; children?: React.ReactNode; hasXButton?: boolean; i18n: LocalizerType; @@ -65,6 +68,7 @@ export const ConfirmationDialog = React.memo(function ConfirmationDialogInner({ children, hasXButton, i18n, + isSpinning, moduleClassName, noMouseClose, noDefaultCancelButton, @@ -99,7 +103,7 @@ export const ConfirmationDialog = React.memo(function ConfirmationDialogInner({ const footer = ( <> - {!noDefaultCancelButton ? ( + {!isSpinning && !noDefaultCancelButton ? ( ))} diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx index a2ad9ad20..612a88584 100644 --- a/ts/components/GlobalModalContainer.tsx +++ b/ts/components/GlobalModalContainer.tsx @@ -7,6 +7,7 @@ import type { ForwardMessagePropsType, UserNotFoundModalStateType, SafetyNumberChangedBlockingDataType, + AuthorizeArtCreatorDataType, } from '../state/ducks/globalModals'; import type { LocalizerType, ThemeType } from '../types/Util'; import { missingCaseError } from '../util/missingCaseError'; @@ -66,6 +67,11 @@ export type PropsType = { // WhatsNewModal isWhatsNewVisible: boolean; hideWhatsNewModal: () => unknown; + // AuthArtCreatorModal + authArtCreatorData?: AuthorizeArtCreatorDataType; + isAuthorizingArtCreator?: boolean; + cancelAuthorizeArtCreator: () => unknown; + confirmAuthorizeArtCreator: () => unknown; }; export function GlobalModalContainer({ @@ -110,6 +116,11 @@ export function GlobalModalContainer({ // WhatsNewModal hideWhatsNewModal, isWhatsNewVisible, + // AuthArtCreatorModal + authArtCreatorData, + isAuthorizingArtCreator, + cancelAuthorizeArtCreator, + confirmAuthorizeArtCreator, }: PropsType): JSX.Element | null { // We want the following dialogs to show in this order: // 1. Errors @@ -200,5 +211,28 @@ export function GlobalModalContainer({ ); } + if (authArtCreatorData) { + return ( + + {i18n('icu:AuthArtCreator--dialog--message')} + + ); + } + return null; } diff --git a/ts/components/Spinner.tsx b/ts/components/Spinner.tsx index 9030a1d33..85d8ca798 100644 --- a/ts/components/Spinner.tsx +++ b/ts/components/Spinner.tsx @@ -13,7 +13,7 @@ export const SpinnerDirections = [ 'outgoing', 'incoming', 'on-background', - 'on-captcha', + 'on-primary-button', 'on-progress-dialog', 'on-avatar', ] as const; diff --git a/ts/components/TitleBarContainer.tsx b/ts/components/TitleBarContainer.tsx index 683b05e45..4f6a5faf8 100644 --- a/ts/components/TitleBarContainer.tsx +++ b/ts/components/TitleBarContainer.tsx @@ -226,6 +226,7 @@ export function TitleBarContainer(props: PropsType): JSX.Element { // actions forceUpdate: () => executeMenuAction('forceUpdate'), + openArtCreator: () => executeMenuAction('openArtCreator'), openContactUs: () => executeMenuAction('openContactUs'), openForums: () => executeMenuAction('openForums'), openJoinTheBeta: () => executeMenuAction('openJoinTheBeta'), diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index 4a3917174..6d20e0d09 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -12,13 +12,20 @@ import type { StateType as RootStateType } from '../reducer'; import type { UUIDStringType } from '../../types/UUID'; import * as SingleServePromise from '../../services/singleServePromise'; import * as Stickers from '../../types/Stickers'; +import * as Errors from '../../types/errors'; import { getMessageById } from '../../messages/getMessageById'; import { getMessagePropsSelector } from '../selectors/message'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper'; import { useBoundActions } from '../../hooks/useBoundActions'; import { isGroupV1 } from '../../util/whatTypeOfConversation'; +import { authorizeArtCreator } from '../../textsecure/authorizeArtCreator'; +import type { AuthorizeArtCreatorOptionsType } from '../../textsecure/authorizeArtCreator'; import { getGroupMigrationMembers } from '../../groups'; +import * as log from '../../logging/log'; +import { ToastType } from '../../types/Toast'; +import { SHOW_TOAST } from './toast'; +import type { ShowToastActionType } from './toast'; // State @@ -29,6 +36,8 @@ export type SafetyNumberChangedBlockingDataType = ReadonlyDeep<{ promiseUuid: UUIDStringType; source?: SafetyNumberChangeSource; }>; +export type AuthorizeArtCreatorDataType = + ReadonlyDeep; type MigrateToGV2PropsType = ReadonlyDeep<{ areWeInvited: boolean; @@ -56,6 +65,8 @@ export type GlobalModalsStateType = ReadonlyDeep<{ safetyNumberChangedBlockingData?: SafetyNumberChangedBlockingDataType; safetyNumberModalContactId?: string; stickerPackPreviewId?: string; + isAuthorizingArtCreator?: boolean; + authArtCreatorData?: AuthorizeArtCreatorDataType; userNotFoundModalState?: UserNotFoundModalStateType; }>; @@ -89,6 +100,12 @@ const CLOSE_ERROR_MODAL = 'globalModals/CLOSE_ERROR_MODAL'; const SHOW_ERROR_MODAL = 'globalModals/SHOW_ERROR_MODAL'; const CLOSE_SHORTCUT_GUIDE_MODAL = 'globalModals/CLOSE_SHORTCUT_GUIDE_MODAL'; const SHOW_SHORTCUT_GUIDE_MODAL = 'globalModals/SHOW_SHORTCUT_GUIDE_MODAL'; +const SHOW_AUTH_ART_CREATOR = 'globalModals/SHOW_AUTH_ART_CREATOR'; +const CANCEL_AUTH_ART_CREATOR = 'globalModals/CANCEL_AUTH_ART_CREATOR'; +const CONFIRM_AUTH_ART_CREATOR_PENDING = + 'globalModals/CONFIRM_AUTH_ART_CREATOR_PENDING'; +const CONFIRM_AUTH_ART_CREATOR_FULFILLED = + 'globalModals/CONFIRM_AUTH_ART_CREATOR_FULFILLED'; export type ContactModalStateType = ReadonlyDeep<{ contactId: string; @@ -216,6 +233,23 @@ type ShowShortcutGuideModalActionType = ReadonlyDeep<{ type: typeof SHOW_SHORTCUT_GUIDE_MODAL; }>; +export type ShowAuthArtCreatorActionType = ReadonlyDeep<{ + type: typeof SHOW_AUTH_ART_CREATOR; + payload: AuthorizeArtCreatorDataType; +}>; + +type CancelAuthArtCreatorActionType = ReadonlyDeep<{ + type: typeof CANCEL_AUTH_ART_CREATOR; +}>; + +type ConfirmAuthArtCreatorPendingActionType = ReadonlyDeep<{ + type: typeof CONFIRM_AUTH_ART_CREATOR_PENDING; +}>; + +type ConfirmAuthArtCreatorFulfilledActionType = ReadonlyDeep<{ + type: typeof CONFIRM_AUTH_ART_CREATOR_FULFILLED; +}>; + export type GlobalModalsActionType = ReadonlyDeep< | StartMigrationToGV2ActionType | CloseGV2MigrationDialogActionType @@ -235,6 +269,10 @@ export type GlobalModalsActionType = ReadonlyDeep< | ShowErrorModalActionType | CloseShortcutGuideModalActionType | ShowShortcutGuideModalActionType + | CancelAuthArtCreatorActionType + | ConfirmAuthArtCreatorPendingActionType + | ConfirmAuthArtCreatorFulfilledActionType + | ShowAuthArtCreatorActionType | ToggleForwardMessageModalActionType | ToggleProfileEditorActionType | ToggleProfileEditorErrorActionType @@ -270,6 +308,9 @@ export const actions = { showErrorModal, closeShortcutGuideModal, showShortcutGuideModal, + showAuthorizeArtCreator, + cancelAuthorizeArtCreator, + confirmAuthorizeArtCreator, }; export const useGlobalModalActions = (): BoundActionCreatorsMapObject< @@ -540,6 +581,73 @@ function showShortcutGuideModal(): ShowShortcutGuideModalActionType { }; } +function cancelAuthorizeArtCreator(): ThunkAction< + void, + RootStateType, + unknown, + CancelAuthArtCreatorActionType +> { + return async (dispatch, getState) => { + const data = getState().globalModals.authArtCreatorData; + + if (!data) { + return; + } + + dispatch({ + type: CANCEL_AUTH_ART_CREATOR, + }); + }; +} + +export function showAuthorizeArtCreator( + data: AuthorizeArtCreatorDataType +): ShowAuthArtCreatorActionType { + return { + type: SHOW_AUTH_ART_CREATOR, + payload: data, + }; +} + +export function confirmAuthorizeArtCreator(): ThunkAction< + void, + RootStateType, + unknown, + | ConfirmAuthArtCreatorPendingActionType + | ConfirmAuthArtCreatorFulfilledActionType + | CancelAuthArtCreatorActionType + | ShowToastActionType +> { + return async (dispatch, getState) => { + const data = getState().globalModals.authArtCreatorData; + + if (!data) { + dispatch({ type: CANCEL_AUTH_ART_CREATOR }); + return; + } + + dispatch({ + type: CONFIRM_AUTH_ART_CREATOR_PENDING, + }); + + try { + await authorizeArtCreator(data); + } catch (err) { + log.error('authorizeArtCreator failed', Errors.toLogFormat(err)); + dispatch({ + type: SHOW_TOAST, + payload: { + toastType: ToastType.Error, + }, + }); + } + + dispatch({ + type: CONFIRM_AUTH_ART_CREATOR_FULFILLED, + }); + }; +} + // Reducer export function getEmptyState(): GlobalModalsStateType { @@ -718,5 +826,35 @@ export function reducer( }; } + if (action.type === CANCEL_AUTH_ART_CREATOR) { + return { + ...state, + authArtCreatorData: undefined, + }; + } + + if (action.type === SHOW_AUTH_ART_CREATOR) { + return { + ...state, + isAuthorizingArtCreator: false, + authArtCreatorData: action.payload, + }; + } + + if (action.type === CONFIRM_AUTH_ART_CREATOR_PENDING) { + return { + ...state, + isAuthorizingArtCreator: true, + }; + } + + if (action.type === CONFIRM_AUTH_ART_CREATOR_FULFILLED) { + return { + ...state, + isAuthorizingArtCreator: false, + authArtCreatorData: undefined, + }; + } + return state; } diff --git a/ts/state/smart/GlobalModalContainer.tsx b/ts/state/smart/GlobalModalContainer.tsx index 5ab2b6ed9..e90057516 100644 --- a/ts/state/smart/GlobalModalContainer.tsx +++ b/ts/state/smart/GlobalModalContainer.tsx @@ -66,6 +66,8 @@ export function SmartGlobalModalContainer(): JSX.Element { safetyNumberModalContactId, stickerPackPreviewId, userNotFoundModalState, + isAuthorizingArtCreator, + authArtCreatorData, } = useSelector( state => state.globalModals ); @@ -75,6 +77,8 @@ export function SmartGlobalModalContainer(): JSX.Element { hideWhatsNewModal, hideUserNotFoundModal, toggleSignalConnectionsModal, + cancelAuthorizeArtCreator, + confirmAuthorizeArtCreator, } = useGlobalModalActions(); const renderAddUserToAnotherGroup = useCallback(() => { @@ -143,6 +147,10 @@ export function SmartGlobalModalContainer(): JSX.Element { theme={theme} toggleSignalConnectionsModal={toggleSignalConnectionsModal} userNotFoundModalState={userNotFoundModalState} + isAuthorizingArtCreator={isAuthorizingArtCreator} + authArtCreatorData={authArtCreatorData} + cancelAuthorizeArtCreator={cancelAuthorizeArtCreator} + confirmAuthorizeArtCreator={confirmAuthorizeArtCreator} /> ); } diff --git a/ts/test-node/app/menu_test.ts b/ts/test-node/app/menu_test.ts index 16210e887..ad6c1bc31 100644 --- a/ts/test-node/app/menu_test.ts +++ b/ts/test-node/app/menu_test.ts @@ -11,6 +11,7 @@ import { load as loadLocale } from '../../../app/locale'; import type { MenuListType } from '../../types/menu'; const forceUpdate = stub(); +const openArtCreator = stub(); const openContactUs = stub(); const openForums = stub(); const openJoinTheBeta = stub(); @@ -213,6 +214,7 @@ describe('createTemplate', () => { const actions = { forceUpdate, + openArtCreator, openContactUs, openForums, openJoinTheBeta, diff --git a/ts/textsecure/SocketManager.ts b/ts/textsecure/SocketManager.ts index debc2d598..6b9ab7d05 100644 --- a/ts/textsecure/SocketManager.ts +++ b/ts/textsecure/SocketManager.ts @@ -34,6 +34,7 @@ const JITTER = 5 * durations.SECOND; export type SocketManagerOptions = Readonly<{ url: string; + artCreatorUrl: string; certificateAuthority: string; version: string; proxyUrl?: string; @@ -276,6 +277,27 @@ export class SocketManager extends EventListener { }).getResult(); } + // Creates new WebSocket for Art Creator provisioning + public async connectExternalSocket({ + url, + extraHeaders, + }: { + url: string; + extraHeaders?: Record; + }): Promise { + return connectWebSocket({ + name: 'art-creator-provisioning', + url, + version: this.options.version, + proxyAgent: this.proxyAgent, + extraHeaders, + + createResource(socket: WebSocket): WebSocket { + return socket; + }, + }).getResult(); + } + // Fetch-compatible wrapper around underlying unauthenticated/authenticated // websocket resources. This wrapper supports only limited number of features // of node-fetch despite being API compatible. diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 33a7d95ef..b68d3cb96 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -15,6 +15,7 @@ import PQueue from 'p-queue'; import { v4 as getGuid } from 'uuid'; import { z } from 'zod'; import type { Readable } from 'stream'; +import type { connection as WebSocket } from 'websocket'; import { assertDev, strictAssert } from '../util/assert'; import { isRecord } from '../util/isRecord'; @@ -477,7 +478,6 @@ const URL_CALLS = { config: 'v1/config', deliveryCert: 'v1/certificate/delivery', devices: 'v1/devices', - directoryAuth: 'v1/directory/auth', directoryAuthV2: 'v2/directory/auth', discovery: 'v1/discovery', getGroupAvatarUpload: 'v1/groups/avatar/form', @@ -486,6 +486,7 @@ const URL_CALLS = { getOnboardingStoryManifest: 'dynamic/desktop/stories/onboarding/manifest.json', getStickerPackUpload: 'v1/sticker/pack/form', + getArtAuth: 'v1/art/auth', groupLog: 'v1/groups/logs', groupJoinedAtVersion: 'v1/groups/joined_at_version', groups: 'v1/groups', @@ -537,7 +538,6 @@ const WEBSOCKET_CALLS = new Set([ 'supportUnauthenticatedDelivery', // Directory - 'directoryAuth', 'directoryAuthV2', // Storage @@ -552,6 +552,7 @@ type InitializeOptionsType = { storageUrl: string; updatesUrl: string; resourcesUrl: string; + artCreatorUrl: string; cdnUrlObject: { readonly '0': string; readonly [propName: string]: string; @@ -831,6 +832,13 @@ export type ReportMessageOptionsType = Readonly<{ token?: string; }>; +const artAuthZod = z.object({ + username: z.string(), + password: z.string(), +}); + +export type ArtAuthType = z.infer; + export type WebAPIType = { startRegistration(): unknown; finishRegistration(baton: unknown): void; @@ -848,6 +856,7 @@ export type WebAPIType = { version: string, imageFiles: Array ) => Promise>; + getArtAuth: () => Promise; getAttachment: (cdnKey: string, cdnNumber?: number) => Promise; getAvatar: (path: string) => Promise; getDevices: () => Promise; @@ -901,6 +910,7 @@ export type WebAPIType = { getProvisioningResource: ( handler: IRequestHandler ) => Promise; + getArtProvisioningSocket: (token: string) => Promise; getSenderCertificate: ( withUuid?: boolean ) => Promise; @@ -1073,6 +1083,7 @@ export function initialize({ storageUrl, updatesUrl, resourcesUrl, + artCreatorUrl, directoryConfig, cdnUrlObject, certificateAuthority, @@ -1092,6 +1103,9 @@ export function initialize({ if (!isString(resourcesUrl)) { throw new Error('WebAPI.initialize: Invalid updatesUrl (general)'); } + if (!isString(artCreatorUrl)) { + throw new Error('WebAPI.initialize: Invalid artCreatorUrl'); + } if (!isObject(cdnUrlObject)) { throw new Error('WebAPI.initialize: Invalid cdnUrlObject'); } @@ -1139,6 +1153,7 @@ export function initialize({ const socketManager = new SocketManager({ url, + artCreatorUrl, certificateAuthority, version, proxyUrl, @@ -1224,6 +1239,8 @@ export function initialize({ fetchLinkPreviewMetadata, finishRegistration, getAccountForUsername, + getArtAuth, + getArtProvisioningSocket, getAttachment, getAvatar, getBadgeImageFile, @@ -2982,6 +2999,15 @@ export function initialize({ return socketManager.getProvisioningResource(handler); } + function getArtProvisioningSocket(token: string): Promise { + return socketManager.connectExternalSocket({ + url: `${artCreatorUrl}/api/socket?token=${token}`, + extraHeaders: { + origin: artCreatorUrl, + }, + }); + } + async function cdsLookup({ e164s, acis = [], @@ -2995,5 +3021,19 @@ export function initialize({ returnAcisWithoutUaks, }); } + + // + // Art + // + + async function getArtAuth(): Promise { + const response = await _ajax({ + call: 'getArtAuth', + httpType: 'GET', + responseType: 'json', + }); + + return artAuthZod.parse(response); + } } } diff --git a/ts/textsecure/WebSocket.ts b/ts/textsecure/WebSocket.ts index af2afcac1..2b361269b 100644 --- a/ts/textsecure/WebSocket.ts +++ b/ts/textsecure/WebSocket.ts @@ -24,7 +24,7 @@ export type IResource = { export type ConnectOptionsType = Readonly<{ name: string; url: string; - certificateAuthority: string; + certificateAuthority?: string; version: string; proxyAgent?: ReturnType; timeout?: number; diff --git a/ts/textsecure/authorizeArtCreator.ts b/ts/textsecure/authorizeArtCreator.ts new file mode 100644 index 000000000..9fe3759a5 --- /dev/null +++ b/ts/textsecure/authorizeArtCreator.ts @@ -0,0 +1,59 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { promisify } from 'util'; + +import { SignalService as Proto } from '../protobuf'; +import { calculateAgreement, generateKeyPair } from '../Curve'; +import { encryptAttachment, deriveSecrets } from '../Crypto'; +import * as Bytes from '../Bytes'; + +const PROVISIONING_INFO = 'Art Service Provisioning Message'; + +export type AuthorizeArtCreatorOptionsType = Readonly<{ + token: string; + pubKeyBase64: string; +}>; + +export async function authorizeArtCreator({ + token, + pubKeyBase64: theirPubKeyBase64, +}: AuthorizeArtCreatorOptionsType): Promise { + const { server } = window.textsecure; + if (!server) { + throw new Error('Server not ready'); + } + + const auth = await server.getArtAuth(); + + const ourKeys = generateKeyPair(); + const theirPubKey = Bytes.fromBase64(theirPubKeyBase64); + + const secret = calculateAgreement(theirPubKey, ourKeys.privKey); + const [aesKey, macKey] = deriveSecrets( + secret, + new Uint8Array(64), + Bytes.fromString(PROVISIONING_INFO) + ); + const keys = Bytes.concatenate([aesKey, macKey]); + + const { ciphertext } = encryptAttachment( + Proto.ArtProvisioningMessage.encode({ + ...auth, + }).finish(), + keys + ); + + const envelope = Proto.ArtProvisioningEnvelope.encode({ + publicKey: ourKeys.pubKey, + ciphertext, + }).finish(); + + const socket = await server.getArtProvisioningSocket(token); + + try { + await promisify(socket.sendBytes).call(socket, Buffer.from(envelope)); + } finally { + socket.close(1000, 'goodbye'); + } +} diff --git a/ts/types/RendererConfig.ts b/ts/types/RendererConfig.ts index ba90a81b8..0bdf144ba 100644 --- a/ts/types/RendererConfig.ts +++ b/ts/types/RendererConfig.ts @@ -26,6 +26,7 @@ export type DirectoryConfigType = z.infer; export const rendererConfigSchema = z.object({ appInstance: configOptionalStringSchema, appStartInitialSpellcheckSetting: z.boolean(), + artCreatorUrl: configRequiredStringSchema, buildCreation: z.number(), buildExpiration: z.number(), cdnUrl0: configRequiredStringSchema, diff --git a/ts/types/menu.ts b/ts/types/menu.ts index 7ed0f0bbd..81fdf19b4 100644 --- a/ts/types/menu.ts +++ b/ts/types/menu.ts @@ -15,6 +15,7 @@ export type MenuOptionsType = Readonly<{ export type MenuActionsType = Readonly<{ forceUpdate: () => unknown; + openArtCreator: () => unknown; openContactUs: () => unknown; openForums: () => unknown; openJoinTheBeta: () => unknown; diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts index 454889e0a..f1a967f86 100644 --- a/ts/util/createIPCEvents.ts +++ b/ts/util/createIPCEvents.ts @@ -19,6 +19,7 @@ import type { SystemTraySetting } from '../types/SystemTraySetting'; import { parseSystemTraySetting } from '../types/SystemTraySetting'; import type { ConversationType } from '../state/ducks/conversations'; +import type { AuthorizeArtCreatorDataType } from '../state/ducks/globalModals'; import { calling } from '../services/calling'; import { getConversationsWithCustomColorSelector } from '../state/selectors/conversations'; import { getCustomColors } from '../state/selectors/items'; @@ -85,6 +86,7 @@ export type IPCEventsValuesType = { }; export type IPCEventsCallbacksType = { + openArtCreator(): Promise; getAvailableIODevices(): Promise<{ availableCameras: Array< Pick @@ -94,6 +96,7 @@ export type IPCEventsCallbacksType = { }>; addCustomColor: (customColor: CustomColorType) => void; addDarkOverlay: () => void; + authorizeArtCreator: (data: AuthorizeArtCreatorDataType) => void; deleteAllData: () => Promise; deleteAllMyStories: () => Promise; closeDB: () => Promise; @@ -188,6 +191,15 @@ export function createIPCEvents( }; return { + openArtCreator: async () => { + const auth = await window.textsecure.server?.getArtAuth(); + if (!auth) { + return; + } + + window.openArtCreator(auth); + }, + getDeviceName: () => window.textsecure.storage.user.getDeviceName(), getZoomFactor: () => window.storage.get('zoomFactor', 1), @@ -439,6 +451,14 @@ export function createIPCEvents( }); document.body.prepend(newOverlay); }, + authorizeArtCreator: (data: AuthorizeArtCreatorDataType) => { + // We can get these events even if the user has never linked this instance. + if (!window.Signal.Util.Registration.everDone()) { + log.warn('authorizeArtCreator: Not registered, returning early'); + return; + } + window.reduxActions.globalModals.showAuthorizeArtCreator(data); + }, removeDarkOverlay: () => { const elems = document.querySelectorAll('.dark-overlay'); diff --git a/ts/window.d.ts b/ts/window.d.ts index f1750a613..7cfabbe4d 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -148,6 +148,7 @@ declare global { localeMessages: { [key: string]: { message: string } }; isBehindProxy: () => boolean; + openArtCreator: (opts: { username: string; password: string }) => void; enterKeyboardMode: () => void; enterMouseMode: () => void; diff --git a/ts/windows/main/phase1-ipc.ts b/ts/windows/main/phase1-ipc.ts index 50e5a9d6a..37c7fc6e8 100644 --- a/ts/windows/main/phase1-ipc.ts +++ b/ts/windows/main/phase1-ipc.ts @@ -15,6 +15,7 @@ import * as log from '../../logging/log'; import * as Errors from '../../types/errors'; import { strictAssert } from '../../util/assert'; +import { drop } from '../../util/drop'; // It is important to call this as early as possible window.i18n = SignalContext.i18n; @@ -297,6 +298,24 @@ ipc.on('show-group-via-link', (_event, info) => { } }); +ipc.on('open-art-creator', () => { + drop(window.Events.openArtCreator()); +}); +window.openArtCreator = ({ + username, + password, +}: { + username: string; + password: string; +}) => { + return ipc.invoke('open-art-creator', { username, password }); +}; + +ipc.on('authorize-art-creator', (_event, info) => { + const { token, pubKeyBase64 } = info; + window.Events.authorizeArtCreator?.({ token, pubKeyBase64 }); +}); + ipc.on('show-conversation-via-signal.me', (_event, info) => { const { hash } = info; strictAssert(typeof hash === 'string', 'Got an invalid hash over IPC'); diff --git a/ts/windows/main/phase2-dependencies.ts b/ts/windows/main/phase2-dependencies.ts index 9fb3b7724..662851951 100644 --- a/ts/windows/main/phase2-dependencies.ts +++ b/ts/windows/main/phase2-dependencies.ts @@ -26,6 +26,7 @@ window.WebAPI = window.textsecure.WebAPI.initialize({ storageUrl: config.storageUrl, updatesUrl: config.updatesUrl, resourcesUrl: config.resourcesUrl, + artCreatorUrl: config.artCreatorUrl, directoryConfig: config.directoryConfig, cdnUrlObject: { 0: config.cdnUrl0,