From 986d8a66bc481941acc08f7e3373046c4ed2c01f Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Wed, 5 May 2021 17:09:29 -0700 Subject: [PATCH] Show challenge when requested by server --- _locales/en/messages.json | 40 ++ main.js | 22 + package.json | 3 +- preload.js | 6 + stylesheets/_modules.scss | 34 ++ stylesheets/components/Modal.scss | 77 ++- ts/background.ts | 68 +++ ts/challenge.ts | 485 ++++++++++++++++++ ts/components/CaptchaDialog.stories.tsx | 33 ++ ts/components/CaptchaDialog.tsx | 99 ++++ ts/components/LeftPane.stories.tsx | 46 ++ ts/components/LeftPane.tsx | 12 + ts/components/MainHeader.tsx | 3 + ts/components/Modal.tsx | 12 +- ts/components/NetworkStatus.stories.tsx | 1 + ts/components/Spinner.tsx | 1 + .../conversation/Message.stories.tsx | 9 + ts/components/conversation/Message.tsx | 60 ++- .../conversationList/ConversationListItem.tsx | 1 + ts/main/challengeMain.ts | 50 ++ ts/model-types.d.ts | 16 + ts/models/conversations.ts | 60 +-- ts/models/messages.ts | 129 ++++- ts/state/ducks/conversations.ts | 1 + ts/state/ducks/network.ts | 32 +- ts/state/selectors/network.ts | 5 + ts/state/smart/CaptchaDialog.tsx | 26 + ts/state/smart/LeftPane.tsx | 6 + ts/state/smart/MainHeader.tsx | 1 + ts/test-both/challenge_test.ts | 382 ++++++++++++++ ts/test-both/state/ducks/network_test.ts | 26 + ts/test-both/util/parseRetryAfter_test.ts | 22 + ts/test-node/util/sgnlHref_test.ts | 133 +++-- ts/textsecure/Errors.ts | 33 ++ ts/textsecure/OutgoingMessage.ts | 21 +- ts/textsecure/SendMessage.ts | 7 + ts/textsecure/WebAPI.ts | 29 +- ts/util/lint/exceptions.json | 43 ++ ts/util/parseRetryAfter.ts | 16 + ts/util/sgnlHref.ts | 25 + ts/views/conversation_view.ts | 29 +- ts/window.d.ts | 10 + 42 files changed, 1986 insertions(+), 128 deletions(-) create mode 100644 ts/challenge.ts create mode 100644 ts/components/CaptchaDialog.stories.tsx create mode 100644 ts/components/CaptchaDialog.tsx create mode 100644 ts/main/challengeMain.ts create mode 100644 ts/state/smart/CaptchaDialog.tsx create mode 100644 ts/test-both/challenge_test.ts create mode 100644 ts/test-both/state/ducks/network_test.ts create mode 100644 ts/test-both/util/parseRetryAfter_test.ts create mode 100644 ts/util/parseRetryAfter.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index ec5eb8b1a..e899aaf4e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1505,6 +1505,10 @@ "message": "Send failed", "description": "Shown on outgoing message if it fails to send" }, + "sendPaused": { + "message": "Send paused", + "description": "Shown on outgoing message if it cannot be sent immediately" + }, "partiallySent": { "message": "Partially sent, click for details", "description": "Shown on outgoing message if it is partially sent" @@ -5118,5 +5122,41 @@ "ContactSpoofingReviewDialog__safe-title": { "message": "Your contact", "description": "Header in the contact spoofing review dialog, shown above the \"safe\" user" + }, + "CaptchaDialog__title": { + "message": "Verify to continue messaging", + "description": "Header in the captcha dialog" + }, + "CaptchaDialog__first-paragraph": { + "message": "To help prevent spam on Signal, please complete verification.", + "description": "First paragraph in the captcha dialog" + }, + "CaptchaDialog__second-paragraph": { + "message": "After verifying, you can continue messaging. Any paused messages will automatically be sent.", + "description": "First paragraph in the captcha dialog" + }, + "CaptchaDialog--can-close__title": { + "message": "Continue Without Verifying?", + "description": "Header in the captcha dialog that can be closed" + }, + "CaptchaDialog--can-close__body": { + "message": "If you choose to skip verification, you may miss messages from other people and your messages may fail to send.", + "description": "Body of the captcha dialog that can be closed" + }, + "CaptchaDialog--can_close__skip-verification": { + "message": "Skip verification", + "description": "Skip button of the captcha dialog that can be closed" + }, + "verificationComplete": { + "message": "Verification complete.", + "description": "Displayed after successful captcha" + }, + "verificationFailed": { + "message": "Verification failed. Please retry later.", + "description": "Displayed after unsuccessful captcha" + }, + "deleteForEveryoneFailed": { + "message": "Failed to delete message for everyone. Please retry later.", + "description": "Displayed when delete-for-everyone has failed to send to all recepients" } } diff --git a/main.js b/main.js index d14b652db..737f3e38b 100644 --- a/main.js +++ b/main.js @@ -108,8 +108,10 @@ const OS = require('./ts/OS'); const { isBeta } = require('./ts/util/version'); const { isSgnlHref, + isCaptchaHref, isSignalHttpsLink, parseSgnlHref, + parseCaptchaHref, parseSignalHttpsLink, } = require('./ts/util/sgnlHref'); const { @@ -120,8 +122,10 @@ const { TitleBarVisibility, } = require('./ts/types/Settings'); const { Environment } = require('./ts/environment'); +const { ChallengeMainHandler } = require('./ts/main/challengeMain'); const sql = new MainSQL(); +const challengeHandler = new ChallengeMainHandler(); let sqlInitTimeStart = 0; let sqlInitTimeEnd = 0; @@ -193,6 +197,12 @@ if (!process.mas) { showWindow(); } + const incomingCaptchaHref = getIncomingCaptchaHref(argv); + if (incomingCaptchaHref) { + const { captcha } = parseCaptchaHref(incomingCaptchaHref, logger); + challengeHandler.handleCaptcha(captcha); + return true; + } // Are they trying to open a sgnl:// href? const incomingHref = getIncomingHref(argv); if (incomingHref) { @@ -1391,11 +1401,19 @@ app.on('web-contents-created', (createEvent, contents) => { }); app.setAsDefaultProtocolClient('sgnl'); +app.setAsDefaultProtocolClient('signalcaptcha'); app.on('will-finish-launching', () => { // open-url must be set from within will-finish-launching for macOS // https://stackoverflow.com/a/43949291 app.on('open-url', (event, incomingHref) => { event.preventDefault(); + + if (isCaptchaHref(incomingHref, logger)) { + const { captcha } = parseCaptchaHref(incomingHref, logger); + challengeHandler.handleCaptcha(captcha); + return; + } + handleSgnlHref(incomingHref); }); }); @@ -1656,6 +1674,10 @@ function getIncomingHref(argv) { return argv.find(arg => isSgnlHref(arg, logger)); } +function getIncomingCaptchaHref(argv) { + return argv.find(arg => isCaptchaHref(arg, logger)); +} + function handleSgnlHref(incomingHref) { let command; let args; diff --git a/package.json b/package.json index 4b4418148..16caade12 100644 --- a/package.json +++ b/package.json @@ -367,7 +367,8 @@ "protocols": { "name": "sgnl-url-scheme", "schemes": [ - "sgnl" + "sgnl", + "signalcaptcha" ] }, "asarUnpack": [ diff --git a/preload.js b/preload.js index 5421f1617..878e581b3 100644 --- a/preload.js +++ b/preload.js @@ -172,6 +172,12 @@ try { Whisper.events.trigger('setupAsStandalone'); }); + ipc.on('challenge:response', (_event, response) => { + Whisper.events.trigger('challengeResponse', response); + }); + window.sendChallengeRequest = request => + ipc.send('challenge:request', request); + { let isFullScreen = config.isFullScreen === 'true'; diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index e666e5800..95a10a147 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -297,6 +297,17 @@ ); } } +.module-message__error--paused { + @include light-theme { + @include color-svg( + '../images/icons/v2/error-outline-24.svg', + $color-gray-60 + ); + } + @include dark-theme { + @include color-svg('../images/icons/v2/error-solid-24.svg', $color-gray-45); + } +} .module-message__error--outgoing { left: 8px; @@ -1265,6 +1276,7 @@ margin-bottom: 2px; } +.module-message__metadata__status-icon--paused, .module-message__metadata__status-icon--sending { animation: module-message__metadata__status-icon--spinning 4s linear infinite; @@ -5254,6 +5266,10 @@ button.module-image__border-overlay:focus { } } +.module-spinner__circle--on-captcha { + background-color: $color-white-alpha-40; +} + .module-spinner__circle--on-progress-dialog { @include light-theme { background-color: $color-white; @@ -5268,6 +5284,9 @@ button.module-image__border-overlay:focus { .module-spinner__arc--on-avatar { background-color: $color-white; } +.module-spinner__arc--on-captcha { + background-color: $color-white; +} // Module: Highlighted Message Body @@ -7023,6 +7042,21 @@ button.module-image__border-overlay:focus { ); } } + + &--paused { + @include light-theme { + @include color-svg( + '../images/icons/v2/error-outline-12.svg', + $color-gray-60 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/error-solid-12.svg', + $color-gray-45 + ); + } + } } &__message-search-result-contents { diff --git a/stylesheets/components/Modal.scss b/stylesheets/components/Modal.scss index 51c28fe0c..1af1be052 100644 --- a/stylesheets/components/Modal.scss +++ b/stylesheets/components/Modal.scss @@ -39,20 +39,27 @@ height: 24px; width: 24px; - @include light-theme { - @include color-svg('../images/icons/v2/x-24.svg', $color-gray-75); - } + &::before { + content: ''; + display: block; + width: 100%; + height: 100%; - @include dark-theme { - @include color-svg('../images/icons/v2/x-24.svg', $color-gray-15); - } - - &:focus { - @include keyboard-mode { - background-color: $ultramarine-ui-light; + @include light-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-75); } - @include dark-keyboard-mode { - background-color: $ultramarine-ui-dark; + + @include dark-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-15); + } + + &:focus { + @include keyboard-mode { + background-color: $ultramarine-ui-light; + } + @include dark-keyboard-mode { + background-color: $ultramarine-ui-dark; + } } } } @@ -93,4 +100,50 @@ margin-left: 8px; } } + + // Overrides for a modal with important message + &--important { + padding: 10px 12px 16px 12px; + + .module-Modal__header { + padding: 0; + } + + .module-Modal__body { + padding: 0 12px 4px 12px !important; + } + + .module-Modal__body p { + margin: 0 0 20px 0; + } + + .module-Modal__title { + @include font-title-2; + text-align: center; + margin: 10px 0 22px 0; + + flex-grow: 0; + flex-shrink: 0; + + &--with-x-button { + margin-top: 31px; + } + } + + .module-Modal__footer { + justify-content: center; + margin-top: 27px; + flex-grow: 0; + flex-shrink: 0; + + .module-Button { + flex-grow: 1; + max-width: 152px; + + &:not(:first-child) { + margin-left: 16px; + } + } + } + } } diff --git a/ts/background.ts b/ts/background.ts index b67b85003..4f962f0ae 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -5,6 +5,7 @@ import { DataMessageClass } from './textsecure.d'; import { MessageAttributesType } from './model-types.d'; import { WhatIsThis } from './window.d'; import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings'; +import { ChallengeHandler } from './challenge'; import { isWindowDragElement } from './util/isWindowDragElement'; import { assert } from './util/assert'; import { senderCertificateService } from './services/senderCertificate'; @@ -12,6 +13,7 @@ import { routineProfileRefresh } from './routineProfileRefresh'; import { isMoreRecentThan, isOlderThan } from './util/timestamp'; import { isValidReactionEmoji } from './reactions/isValidReactionEmoji'; import { ConversationModel } from './models/conversations'; +import { getMessageById } from './models/messages'; import { createBatcher } from './util/batcher'; import { updateConversationsWithUuidLookup } from './updateConversationsWithUuidLookup'; import { initializeAllJobQueues } from './jobs/initializeAllJobQueues'; @@ -1439,7 +1441,62 @@ export async function startApp(): Promise { window.textsecure.messaging.sendRequestKeySyncMessage(); } + let challengeHandler: ChallengeHandler | undefined; + async function start() { + challengeHandler = new ChallengeHandler({ + storage: window.storage, + + getMessageById, + + requestChallenge(request) { + window.sendChallengeRequest(request); + }, + + async sendChallengeResponse(data) { + await window.textsecure.messaging.sendChallengeResponse(data); + }, + + onChallengeFailed() { + // TODO: DESKTOP-1530 + // Display humanized `retryAfter` + window.Whisper.ToastView.show( + window.Whisper.CaptchaFailedToast, + document.getElementsByClassName('conversation-stack')[0] || + document.body + ); + }, + + onChallengeSolved() { + window.Whisper.ToastView.show( + window.Whisper.CaptchaSolvedToast, + document.getElementsByClassName('conversation-stack')[0] || + document.body + ); + }, + + setChallengeStatus(challengeStatus) { + window.reduxActions.network.setChallengeStatus(challengeStatus); + }, + }); + window.Whisper.events.on('challengeResponse', response => { + if (!challengeHandler) { + throw new Error('Expected challenge handler to be there'); + } + + challengeHandler.onResponse(response); + }); + + window.storage.onready(async () => { + if (!challengeHandler) { + throw new Error('Expected challenge handler to be there'); + } + + await challengeHandler.load(); + }); + + window.Signal.challengeHandler = challengeHandler; + window.dispatchEvent(new Event('storage_ready')); window.log.info('Cleanup: starting...'); @@ -1661,6 +1718,10 @@ export async function startApp(): Promise { // we get an online event. This waits a bit after getting an 'offline' event // before disconnecting the socket manually. disconnectTimer = setTimeout(disconnect, 1000); + + if (challengeHandler) { + challengeHandler.onOffline(); + } } function onOnline() { @@ -2046,6 +2107,13 @@ export async function startApp(): Promise { ); } }); + + if (!challengeHandler) { + throw new Error('Expected challenge handler to be initialized'); + } + + // Intentionally not awaiting + challengeHandler.onOnline(); } finally { connecting = false; } diff --git a/ts/challenge.ts b/ts/challenge.ts new file mode 100644 index 000000000..6edc0b6bb --- /dev/null +++ b/ts/challenge.ts @@ -0,0 +1,485 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +/* eslint-disable no-restricted-syntax */ + +// `ChallengeHandler` is responsible for: +// 1. tracking the messages that failed to send with 428 error and could be +// retried when user solves the challenge +// 2. presenting the challenge to user and sending the challenge response back +// to the server +// +// The tracked messages are persisted in the database, and are imported back +// to the `ChallengeHandler` on `.load()` call (from `ts/background.ts`). They +// are not immediately retried, however, until `.onOnline()` is called from +// when we are actually online. + +import { MessageModel } from './models/messages'; +import { assert } from './util/assert'; +import { isNotNil } from './util/isNotNil'; +import { isOlderThan } from './util/timestamp'; +import { parseRetryAfter } from './util/parseRetryAfter'; +import { getEnvironment, Environment } from './environment'; + +export type ChallengeResponse = { + readonly captcha: string; +}; + +export type IPCRequest = { + readonly seq: number; +}; + +export type IPCResponse = { + readonly seq: number; + readonly data: ChallengeResponse; +}; + +export enum RetryMode { + Retry = 'Retry', + NoImmediateRetry = 'NoImmediateRetry', +} + +type Handler = { + readonly token: string | undefined; + + resolve(response: ChallengeResponse): void; + reject(error: Error): void; +}; + +export type ChallengeData = { + readonly type: 'recaptcha'; + readonly token: string; + readonly captcha: string; +}; + +export type MinimalMessage = Pick< + MessageModel, + 'id' | 'idForLogging' | 'getLastChallengeError' | 'retrySend' +> & { + isNormalBubble(): boolean; + get(name: 'sent_at'): number; + on(event: 'sent', callback: () => void): void; + off(event: 'sent', callback: () => void): void; +}; + +export type Options = { + readonly storage: { + get(key: string): ReadonlyArray; + put(key: string, value: ReadonlyArray): Promise; + }; + + requestChallenge(request: IPCRequest): void; + + getMessageById(messageId: string): Promise; + + sendChallengeResponse(data: ChallengeData): Promise; + + setChallengeStatus(challengeStatus: 'idle' | 'required' | 'pending'): void; + + onChallengeSolved(): void; + onChallengeFailed(retryAfter?: number): void; + + expireAfter?: number; +}; + +export type StoredEntity = { + readonly messageId: string; + readonly createdAt: number; +}; + +type TrackedEntry = { + readonly message: MinimalMessage; + readonly createdAt: number; +}; + +const DEFAULT_EXPIRE_AFTER = 24 * 3600 * 1000; // one day +const MAX_RETRIES = 5; +const CAPTCHA_URL = 'https://signalcaptchas.org/challenge/generate.html'; +const CAPTCHA_STAGING_URL = + 'https://signalcaptchas.org/staging/challenge/generate.html'; + +function shouldRetrySend(message: MinimalMessage): boolean { + const error = message.getLastChallengeError(); + if (!error || error.retryAfter <= Date.now()) { + return true; + } + + return false; +} + +export function getChallengeURL(): string { + if (getEnvironment() === Environment.Staging) { + return CAPTCHA_STAGING_URL; + } + return CAPTCHA_URL; +} + +// Note that even though this is a class - only one instance of +// `ChallengeHandler` should be in memory at the same time because they could +// overwrite each others storage data. +export class ChallengeHandler { + private isLoaded = false; + + private challengeToken: string | undefined; + + private seq = 0; + + private isOnline = false; + + private readonly responseHandlers = new Map(); + + private readonly trackedMessages = new Map(); + + private readonly retryTimers = new Map(); + + private readonly pendingRetries = new Set(); + + private readonly retryCountById = new Map(); + + constructor(private readonly options: Options) {} + + public async load(): Promise { + if (this.isLoaded) { + return; + } + + this.isLoaded = true; + const stored: ReadonlyArray = + this.options.storage.get('challenge:retry-message-ids') || []; + + window.log.info(`challenge: loading ${stored.length} messages`); + + const entityMap = new Map(); + for (const entity of stored) { + entityMap.set(entity.messageId, entity); + } + + const retryIds = new Set(stored.map(({ messageId }) => messageId)); + + const maybeMessages: ReadonlyArray< + MinimalMessage | undefined + > = await Promise.all( + Array.from(retryIds).map(async messageId => + this.options.getMessageById(messageId) + ) + ); + + const messages: Array = maybeMessages.filter(isNotNil); + + window.log.info(`challenge: loaded ${messages.length} messages`); + + await Promise.all( + messages.map(async message => { + const entity = entityMap.get(message.id); + if (!entity) { + window.log.error( + 'challenge: unexpected missing entity ' + + `for ${message.idForLogging()}` + ); + return; + } + + const expireAfter = this.options.expireAfter || DEFAULT_EXPIRE_AFTER; + if (isOlderThan(entity.createdAt, expireAfter)) { + window.log.info( + `challenge: expired entity for ${message.idForLogging()}` + ); + return; + } + + // The initialization order is following: + // + // 1. `.load()` when the `window.storage` is ready + // 2. `.onOnline()` when we connected to the server + // + // Wait for `.onOnline()` to trigger the retries instead of triggering + // them here immediately (if the message is ready to be retried). + await this.register(message, RetryMode.NoImmediateRetry, entity); + }) + ); + } + + public async onOffline(): Promise { + this.isOnline = false; + + window.log.info('challenge: offline'); + } + + public async onOnline(): Promise { + this.isOnline = true; + + const pending = Array.from(this.pendingRetries.values()); + this.pendingRetries.clear(); + + window.log.info(`challenge: online, retrying ${pending.length} messages`); + + // Retry messages that matured while we were offline + await Promise.all(pending.map(message => this.retryOne(message))); + + await this.retrySend(); + } + + public async register( + message: MinimalMessage, + retry = RetryMode.Retry, + entity?: StoredEntity + ): Promise { + if (this.isRegistered(message)) { + window.log.info( + `challenge: message already registered ${message.idForLogging()}` + ); + return; + } + + this.trackedMessages.set(message.id, { + message, + createdAt: entity ? entity.createdAt : Date.now(), + }); + await this.persist(); + + // Message is already retryable - initiate new send + if (retry === RetryMode.Retry && shouldRetrySend(message)) { + window.log.info( + `challenge: sending message immediately ${message.idForLogging()}` + ); + await this.retryOne(message); + return; + } + + const error = message.getLastChallengeError(); + if (!error) { + window.log.error('Unexpected message without challenge error'); + return; + } + + const waitTime = Math.max(0, error.retryAfter - Date.now()); + const oldTimer = this.retryTimers.get(message.id); + if (oldTimer) { + clearTimeout(oldTimer); + } + this.retryTimers.set( + message.id, + setTimeout(() => { + this.retryTimers.delete(message.id); + + this.retryOne(message); + }, waitTime) + ); + + window.log.info( + `challenge: tracking ${message.idForLogging()} ` + + `with waitTime=${waitTime}` + ); + + if (!error.data.options || !error.data.options.includes('recaptcha')) { + window.log.error( + `challenge: unexpected options ${JSON.stringify(error.data.options)}` + ); + } + + if (!error.data.token) { + window.log.error( + `challenge: no token in challenge error ${JSON.stringify(error.data)}` + ); + } else if (message.isNormalBubble()) { + // Display challenge dialog only for core messages + // (e.g. text, attachment, embedded contact, or sticker) + // + // Note: not waiting on this call intentionally since it waits for + // challenge to be fully completed. + this.solve(error.data.token); + } else { + window.log.info( + `challenge: not a bubble message ${message.idForLogging()}` + ); + } + } + + public onResponse(response: IPCResponse): void { + const handler = this.responseHandlers.get(response.seq); + if (!handler) { + return; + } + + this.responseHandlers.delete(response.seq); + handler.resolve(response.data); + } + + public async unregister(message: MinimalMessage): Promise { + window.log.info(`challenge: unregistered ${message.idForLogging()}`); + this.trackedMessages.delete(message.id); + this.pendingRetries.delete(message); + + const timer = this.retryTimers.get(message.id); + this.retryTimers.delete(message.id); + if (timer) { + clearTimeout(timer); + } + + await this.persist(); + } + + private async persist(): Promise { + assert( + this.isLoaded, + 'ChallengeHandler has to be loaded before persisting new data' + ); + await this.options.storage.put( + 'challenge:retry-message-ids', + Array.from(this.trackedMessages.entries()).map( + ([messageId, { createdAt }]) => { + return { messageId, createdAt }; + } + ) + ); + } + + private isRegistered(message: MinimalMessage): boolean { + return this.trackedMessages.has(message.id); + } + + private async retrySend(force = false): Promise { + window.log.info(`challenge: retrySend force=${force}`); + + const retries = Array.from(this.trackedMessages.values()) + .map(({ message }) => message) + // Sort messages in `sent_at` order + .sort((a, b) => a.get('sent_at') - b.get('sent_at')) + .filter(message => force || shouldRetrySend(message)) + .map(message => this.retryOne(message)); + + await Promise.all(retries); + } + + private async retryOne(message: MinimalMessage): Promise { + // Send is already pending + if (!this.isRegistered(message)) { + return; + } + + // We are not online + if (!this.isOnline) { + this.pendingRetries.add(message); + return; + } + + const retryCount = this.retryCountById.get(message.id) || 0; + window.log.info( + `challenge: retrying sending ${message.idForLogging()}, ` + + `retry count: ${retryCount}` + ); + + if (retryCount === MAX_RETRIES) { + window.log.info( + `challenge: dropping message ${message.idForLogging()}, ` + + 'too many failed retries' + ); + + // Keep the message registered so that we'll retry sending it on app + // restart. + return; + } + + await this.unregister(message); + + let sent = false; + const onSent = () => { + sent = true; + }; + message.on('sent', onSent); + + try { + await message.retrySend(); + } catch (error) { + window.log.error( + `challenge: failed to send ${message.idForLogging()} due to ` + + `error: ${error && error.stack}` + ); + } finally { + message.off('sent', onSent); + } + + if (sent) { + window.log.info(`challenge: message ${message.idForLogging()} sent`); + this.retryCountById.delete(message.id); + if (this.trackedMessages.size === 0) { + this.options.setChallengeStatus('idle'); + } + } else { + window.log.info(`challenge: message ${message.idForLogging()} not sent`); + + this.retryCountById.set(message.id, retryCount + 1); + await this.register(message, RetryMode.NoImmediateRetry); + } + } + + private async solve(token: string): Promise { + const request: IPCRequest = { seq: this.seq }; + this.seq += 1; + + this.options.setChallengeStatus('required'); + this.options.requestChallenge(request); + + this.challengeToken = token || ''; + const response = await new Promise((resolve, reject) => { + this.responseHandlers.set(request.seq, { token, resolve, reject }); + }); + + // Another `.solve()` has completed earlier than us + if (this.challengeToken === undefined) { + return; + } + + const lastToken = this.challengeToken; + this.challengeToken = undefined; + + this.options.setChallengeStatus('pending'); + + window.log.info('challenge: sending challenge to server'); + + try { + await this.sendChallengeResponse({ + type: 'recaptcha', + token: lastToken, + captcha: response.captcha, + }); + } catch (error) { + window.log.error( + `challenge: challenge failure, error: ${error && error.stack}` + ); + this.options.setChallengeStatus('required'); + return; + } + + window.log.info('challenge: challenge success. force sending'); + + this.options.setChallengeStatus('idle'); + + this.retrySend(true); + } + + private async sendChallengeResponse(data: ChallengeData): Promise { + try { + await this.options.sendChallengeResponse(data); + } catch (error) { + if ( + !(error instanceof Error) || + error.name !== 'HTTPError' || + error.code !== 413 || + !error.responseHeaders + ) { + this.options.onChallengeFailed(); + throw error; + } + + const retryAfter = parseRetryAfter( + error.responseHeaders['retry-after'].toString() + ); + + window.log.info(`challenge: retry after ${retryAfter}ms`); + this.options.onChallengeFailed(retryAfter); + return; + } + + this.options.onChallengeSolved(); + } +} diff --git a/ts/components/CaptchaDialog.stories.tsx b/ts/components/CaptchaDialog.stories.tsx new file mode 100644 index 000000000..116d56d34 --- /dev/null +++ b/ts/components/CaptchaDialog.stories.tsx @@ -0,0 +1,33 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useState } from 'react'; +import { action } from '@storybook/addon-actions'; +import { boolean } from '@storybook/addon-knobs'; +import { storiesOf } from '@storybook/react'; + +import { CaptchaDialog } from './CaptchaDialog'; +import { Button } from './Button'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +const story = storiesOf('Components/CaptchaDialog', module); + +const i18n = setupI18n('en', enMessages); + +story.add('CaptchaDialog', () => { + const [isSkipped, setIsSkipped] = useState(false); + + if (isSkipped) { + return ; + } + + return ( + setIsSkipped(true)} + /> + ); +}); diff --git a/ts/components/CaptchaDialog.tsx b/ts/components/CaptchaDialog.tsx new file mode 100644 index 000000000..e450d94f2 --- /dev/null +++ b/ts/components/CaptchaDialog.tsx @@ -0,0 +1,99 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useRef, useState } from 'react'; + +import { LocalizerType } from '../types/Util'; +import { Button, ButtonVariant } from './Button'; +import { Modal } from './Modal'; +import { Spinner } from './Spinner'; + +type PropsType = { + i18n: LocalizerType; + isPending: boolean; + + onContinue: () => void; + onSkip: () => void; +}; + +export function CaptchaDialog(props: Readonly): JSX.Element { + const { i18n, isPending, onSkip, onContinue } = props; + + const [isClosing, setIsClosing] = useState(false); + + const buttonRef = useRef(null); + + const onCancelClick = (event: React.MouseEvent) => { + event.preventDefault(); + setIsClosing(false); + }; + + const onSkipClick = (event: React.MouseEvent) => { + event.preventDefault(); + onSkip(); + }; + + if (isClosing && !isPending) { + return ( + +
+

{i18n('CaptchaDialog--can-close__body')}

+
+ + + + +
+ ); + } + + const onContinueClick = (event: React.MouseEvent) => { + event.preventDefault(); + + onContinue(); + }; + + const updateButtonRef = (button: HTMLButtonElement): void => { + buttonRef.current = button; + if (button) { + button.focus(); + } + }; + + return ( + setIsClosing(true)} + > +
+

{i18n('CaptchaDialog__first-paragraph')}

+

{i18n('CaptchaDialog__second-paragraph')}

+
+ + + +
+ ); +} diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index 797960b69..e3433e7e9 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -4,9 +4,11 @@ import * as React from 'react'; import { action } from '@storybook/addon-actions'; +import { select } from '@storybook/addon-knobs'; import { storiesOf } from '@storybook/react'; import { LeftPane, LeftPaneMode, PropsType } from './LeftPane'; +import { CaptchaDialog } from './CaptchaDialog'; import { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem'; import { MessageSearchResult } from './conversationList/MessageSearchResult'; import { setup as setupI18n } from '../../js/modules/i18n'; @@ -106,6 +108,12 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ modeSpecificProps: defaultModeSpecificProps, openConversationInternal: action('openConversationInternal'), regionCode: 'US', + challengeStatus: select( + 'challengeStatus', + ['idle', 'required', 'pending'], + 'idle' + ), + setChallengeStatus: action('setChallengeStatus'), renderExpiredBuildDialog: () =>
, renderMainHeader: () =>
, renderMessageSearchResult: (id: string, style: React.CSSProperties) => ( @@ -126,6 +134,14 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ renderNetworkStatus: () =>
, renderRelinkDialog: () =>
, renderUpdateDialog: () =>
, + renderCaptchaDialog: () => ( + + ), selectedConversationId: undefined, selectedMessageId: undefined, setComposeSearchTerm: action('setComposeSearchTerm'), @@ -468,3 +484,33 @@ story.add('Compose: some contacts, some groups, with a search term', () => ( })} /> )); + +// Captcha flow + +story.add('Captcha dialog: required', () => ( + +)); + +story.add('Captcha dialog: pending', () => ( + +)); diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index bc210d541..d8cd8fac8 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -79,6 +79,8 @@ export type PropsType = { selectedConversationId: undefined | string; selectedMessageId: undefined | string; regionCode: string; + challengeStatus: 'idle' | 'required' | 'pending'; + setChallengeStatus: (status: 'idle') => void; // Action Creators cantAddContactToGroup: (conversationId: string) => void; @@ -110,6 +112,7 @@ export type PropsType = { renderNetworkStatus: () => JSX.Element; renderRelinkDialog: () => JSX.Element; renderUpdateDialog: () => JSX.Element; + renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element; }; export const LeftPane: React.FC = ({ @@ -121,6 +124,8 @@ export const LeftPane: React.FC = ({ createGroup, i18n, modeSpecificProps, + challengeStatus, + setChallengeStatus, openConversationInternal, renderExpiredBuildDialog, renderMainHeader, @@ -128,6 +133,7 @@ export const LeftPane: React.FC = ({ renderNetworkStatus, renderRelinkDialog, renderUpdateDialog, + renderCaptchaDialog, selectedConversationId, selectedMessageId, setComposeSearchTerm, @@ -464,6 +470,12 @@ export const LeftPane: React.FC = ({ {footerContents && (
{footerContents}
)} + {challengeStatus !== 'idle' && + renderCaptchaDialog({ + onSkip() { + setChallengeStatus('idle'); + }, + })}
); }; diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx index ba4d2fece..efdc08d98 100644 --- a/ts/components/MainHeader.tsx +++ b/ts/components/MainHeader.tsx @@ -32,6 +32,7 @@ export type PropsType = { isMe?: boolean; name?: string; color?: ColorType; + disabled?: boolean; isVerified?: boolean; profileName?: string; title: string; @@ -339,6 +340,7 @@ export class MainHeader extends React.Component { const { avatarPath, color, + disabled, i18n, name, startComposing, @@ -437,6 +439,7 @@ export class MainHeader extends React.Component { /> )} { onClose(); }} /> )} - {title &&

{title}

} + {title && ( +

+ {title} +

+ )}
)}
{ return renderBothDirections(props); }); +story.add('Paused', () => { + const props = createProps({ + status: 'paused', + text: 'I am up to a challenge', + }); + + return renderBothDirections(props); +}); + story.add('Partial Send', () => { const props = createProps({ status: 'partial-sent', diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 8a1bde557..00b8c59c0 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -67,6 +67,7 @@ const THREE_HOURS = 3 * 60 * 60 * 1000; export const MessageStatuses = [ 'delivered', 'error', + 'paused', 'partial-sent', 'read', 'sending', @@ -522,8 +523,31 @@ export class Message extends React.Component { const isError = status === 'error' && direction === 'outgoing'; const isPartiallySent = status === 'partial-sent' && direction === 'outgoing'; + const isPaused = status === 'paused'; + + if (isError || isPartiallySent || isPaused) { + let statusInfo: React.ReactChild; + if (isError) { + statusInfo = i18n('sendFailed'); + } else if (isPaused) { + statusInfo = i18n('sendPaused'); + } else { + statusInfo = ( + + ); + } - if (isError || isPartiallySent) { return ( { 'module-message__metadata__date--with-image-no-caption': withImageNoCaption, })} > - {isError ? ( - i18n('sendFailed') - ) : ( - - )} + {statusInfo} ); } @@ -1232,7 +1241,15 @@ export class Message extends React.Component { public renderError(isCorrectSide: boolean): JSX.Element | null { const { status, direction } = this.props; - if (!isCorrectSide || (status !== 'error' && status !== 'partial-sent')) { + if (!isCorrectSide) { + return null; + } + + if ( + status !== 'paused' && + status !== 'error' && + status !== 'partial-sent' + ) { return null; } @@ -1241,7 +1258,8 @@ export class Message extends React.Component {
@@ -1446,7 +1464,9 @@ export class Message extends React.Component { const { canDeleteForEveryone } = this.state; const showRetry = - (status === 'error' || status === 'partial-sent') && + (status === 'paused' || + status === 'error' || + status === 'partial-sent') && direction === 'outgoing'; const multipleAttachments = attachments && attachments.length > 1; diff --git a/ts/components/conversationList/ConversationListItem.tsx b/ts/components/conversationList/ConversationListItem.tsx index 007761bb5..dd1d79c52 100644 --- a/ts/components/conversationList/ConversationListItem.tsx +++ b/ts/components/conversationList/ConversationListItem.tsx @@ -27,6 +27,7 @@ export const MessageStatuses = [ 'sent', 'delivered', 'read', + 'paused', 'error', 'partial-sent', ] as const; diff --git a/ts/main/challengeMain.ts b/ts/main/challengeMain.ts new file mode 100644 index 000000000..6cb77c955 --- /dev/null +++ b/ts/main/challengeMain.ts @@ -0,0 +1,50 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +/* eslint-disable no-restricted-syntax, no-console */ + +import { ipcMain as ipc, IpcMainEvent } from 'electron'; + +import { IPCRequest, IPCResponse, ChallengeResponse } from '../challenge'; + +export class ChallengeMainHandler { + private handlers: Array<(response: ChallengeResponse) => void> = []; + + constructor() { + this.initialize(); + } + + public handleCaptcha(captcha: string): void { + const response: ChallengeResponse = { captcha }; + + const { handlers } = this; + this.handlers = []; + for (const resolve of handlers) { + resolve(response); + } + } + + private async onRequest( + event: IpcMainEvent, + request: IPCRequest + ): Promise { + console.log('Received challenge request, waiting for response'); + + const data = await new Promise(resolve => { + this.handlers.push(resolve); + }); + + console.log('Sending challenge response', data); + + const ipcResponse: IPCResponse = { + seq: request.seq, + data, + }; + event.sender.send('challenge:response', ipcResponse); + } + + private initialize(): void { + ipc.on('challenge:request', (event, request) => { + this.onRequest(event, request); + }); + } +} diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 810513a60..8456e6c6f 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -13,6 +13,7 @@ import { LastMessageStatus, } from './state/ducks/conversations'; import { SendOptionsType } from './textsecure/SendMessage'; +import { SendMessageChallengeData } from './textsecure/Errors'; import { AccessRequiredEnum, MemberRoleEnum, @@ -42,6 +43,8 @@ type TaskResultType = any; export type CustomError = Error & { identifier?: string; number?: string; + data?: object; + retryAfter?: number; }; export type GroupMigrationType = { @@ -62,6 +65,13 @@ export type QuotedMessageType = { text: string; }; +export type RetryOptions = Readonly<{ + type: 'session-reset'; + uuid: string; + e164: string; + now: number; +}>; + export type MessageAttributesType = { bodyPending: boolean; bodyRanges: BodyRangesType; @@ -113,6 +123,7 @@ export type MessageAttributesType = { }>; read_by: Array; requiredProtocolVersion: number; + retryOptions?: RetryOptions; sent: boolean; sourceDevice: string | number; snippet: unknown; @@ -325,6 +336,11 @@ export type VerificationOptions = { viaSyncMessage?: boolean; }; +export type ShallowChallengeError = CustomError & { + readonly retryAfter: number; + readonly data: SendMessageChallengeData; +}; + export declare class ConversationModelCollectionType extends Backbone.Collection { resetLookups(): void; } diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index dbecb9291..04316f587 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -3028,8 +3028,6 @@ export class ConversationModel extends window.Backbone fromId: window.ConversationController.getOurConversationId(), }); - window.Whisper.Deletes.onDelete(deleteModel); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const destination = this.getSendTarget()!; const recipients = this.getRecipients(); @@ -3108,7 +3106,16 @@ export class ConversationModel extends window.Backbone // anything to the database. message.doNotSave = true; - return message.send(this.wrapSend(promise)); + const result = await message.send(this.wrapSend(promise)); + + if (!message.hasSuccessfulDelivery()) { + // This is handled by `conversation_view` which displays a toast on + // send error. + throw new Error('No successful delivery for delete for everyone'); + } + window.Whisper.Deletes.onDelete(deleteModel); + + return result; }).catch(error => { window.log.error( 'Error sending deleteForEveryone', @@ -3138,7 +3145,6 @@ export class ConversationModel extends window.Backbone timestamp, fromSync: true, }); - window.Whisper.Reactions.onReaction(reactionModel); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const destination = this.getSendTarget()!; @@ -3239,15 +3245,17 @@ export class ConversationModel extends window.Backbone ); })(); - return message.send(this.wrapSend(promise)); - }).catch(error => { - window.log.error('Error sending reaction', reaction, target, error); + const result = await message.send(this.wrapSend(promise)); - const reverseReaction = reactionModel.clone(); - reverseReaction.set('remove', !reverseReaction.get('remove')); - window.Whisper.Reactions.onReaction(reverseReaction); + if (!message.hasSuccessfulDelivery()) { + // This is handled by `conversation_view` which displays a toast on + // send error. + throw new Error('No successful delivery for reaction'); + } - throw error; + window.Whisper.Reactions.onReaction(reactionModel); + + return result; }); } @@ -4167,25 +4175,17 @@ export class ConversationModel extends window.Backbone const message = window.MessageController.register(model.id, model); this.addSingleMessage(message); - const options = await this.getSendOptions(); - message.send( - this.wrapSend( - // TODO: DESKTOP-724 - // resetSession returns `Array` which is incompatible with the - // expected promise return values. `[]` is truthy and wrapSend assumes - // it's a valid callback result type - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - window.textsecure.messaging.resetSession( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.get('uuid')!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.get('e164')!, - now, - options - ) - ) - ); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const uuid = this.get('uuid')!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const e164 = this.get('e164')!; + + message.sendUtilityMessageWithRetry({ + type: 'session-reset', + uuid, + e164, + now, + }); } } diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 98d508950..0d9a5c525 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -4,6 +4,8 @@ import { CustomError, MessageAttributesType, + RetryOptions, + ShallowChallengeError, QuotedMessageType, WhatIsThis, } from '../model-types.d'; @@ -1041,6 +1043,9 @@ export class MessageModel extends window.Backbone.Model { const sentTo = this.get('sent_to') || []; if (this.hasErrors()) { + if (this.getLastChallengeError()) { + return 'paused'; + } if (sent || sentTo.length > 0) { return 'partial-sent'; } @@ -2000,6 +2005,8 @@ export class MessageModel extends window.Backbone.Model { 'code', 'number', 'identifier', + 'retryAfter', + 'data', 'reason' ) as Required; } @@ -2009,6 +2016,13 @@ export class MessageModel extends window.Backbone.Model { this.set({ errors }); + if ( + !this.doNotSave && + errors.some(error => error.name === 'SendMessageChallengeError') + ) { + await window.Signal.challengeHandler.register(this); + } + if (!skipSave && !this.doNotSave) { await window.Signal.Data.saveMessage(this.attributes, { Message: window.Whisper.Message, @@ -2109,7 +2123,13 @@ export class MessageModel extends window.Backbone.Model { return null; } - this.set({ errors: undefined }); + const retryOptions = this.get('retryOptions'); + + this.set({ errors: undefined, retryOptions: undefined }); + + if (retryOptions) { + return this.sendUtilityMessageWithRetry(retryOptions); + } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const conversation = this.getConversation()!; @@ -2252,11 +2272,35 @@ export class MessageModel extends window.Backbone.Model { e.name === 'MessageError' || e.name === 'OutgoingMessageError' || e.name === 'SendMessageNetworkError' || + e.name === 'SendMessageChallengeError' || e.name === 'SignedPreKeyRotationError' || e.name === 'OutgoingIdentityKeyError' ); } + public getLastChallengeError(): ShallowChallengeError | undefined { + const errors: ReadonlyArray | undefined = this.get('errors'); + if (!errors) { + return undefined; + } + + const challengeErrors = errors + .filter((error): error is ShallowChallengeError => { + return ( + error.name === 'SendMessageChallengeError' && + _.isNumber(error.retryAfter) && + _.isObject(error.data) + ); + }) + .sort((a, b) => a.retryAfter - b.retryAfter); + + return challengeErrors.pop(); + } + + public hasSuccessfulDelivery(): boolean { + return (this.get('sent_to') || []).length !== 0; + } + canDeleteForEveryone(): boolean { // is someone else's message if (this.isIncoming()) { @@ -2423,6 +2467,7 @@ export class MessageModel extends window.Backbone.Model { (e.name === 'MessageError' || e.name === 'OutgoingMessageError' || e.name === 'SendMessageNetworkError' || + e.name === 'SendMessageChallengeError' || e.name === 'SignedPreKeyRotationError' || e.name === 'OutgoingIdentityKeyError') ); @@ -2564,6 +2609,59 @@ export class MessageModel extends window.Backbone.Model { }); } + // Currently used only for messages that have to be retried when the server + // responds with 428 and we have to retry sending the message on challenge + // solution. + // + // Supported types of messages: + // * `session-reset` see `endSession` in `ts/models/conversations.ts` + async sendUtilityMessageWithRetry(options: RetryOptions): Promise { + if (options.type === 'session-reset') { + const conv = this.getConversation(); + if (!conv) { + throw new Error( + `Failed to find conversation for message: ${this.idForLogging()}` + ); + } + if (!window.textsecure.messaging) { + throw new Error('Offline'); + } + + this.set({ + retryOptions: options, + }); + + const sendOptions = await conv.getSendOptions(); + + // We don't have to check `sent_to` here, because: + // + // 1. This happens only in private conversations + // 2. Messages to different device ids for the same identifier are sent + // in a single request to the server. So partial success is not + // possible. + await this.send( + conv.wrapSend( + // TODO: DESKTOP-724 + // resetSession returns `Array` which is incompatible with the + // expected promise return values. `[]` is truthy and wrapSend assumes + // it's a valid callback result type + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + window.textsecure.messaging.resetSession( + options.uuid, + options.e164, + options.now, + sendOptions + ) + ) + ); + + return; + } + + throw new Error(`Unsupported retriable type: ${options.type}`); + } + async sendSyncMessageOnly(dataMessage: ArrayBuffer): Promise { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const conv = this.getConversation()!; @@ -2590,7 +2688,7 @@ export class MessageModel extends window.Backbone.Model { }); } catch (result) { const errors = (result && result.errors) || [new Error('Unknown error')]; - this.set({ errors }); + this.saveErrors(errors); } finally { await window.Signal.Data.saveMessage(this.attributes, { Message: window.Whisper.Message, @@ -4094,6 +4192,33 @@ export class MessageModel extends window.Backbone.Model { } } +export async function getMessageById( + messageId: string +): Promise { + let message = window.MessageController.getById(messageId); + if (message) { + return message; + } + + try { + message = await window.Signal.Data.getMessageById(messageId, { + Message: window.Whisper.Message, + }); + } catch (error) { + window.log.error( + `failed to load message with id ${messageId} ` + + `due to error ${error && error.stack}` + ); + } + + if (!message) { + return undefined; + } + + message = window.MessageController.register(message.id, message); + return message; +} + window.Whisper.Message = MessageModel; window.Whisper.Message.getLongMessageAttachment = ({ diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 28116d0b6..2302cd159 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -42,6 +42,7 @@ export type DBConversationType = { }; export type LastMessageStatus = + | 'paused' | 'error' | 'partial-sent' | 'sending' diff --git a/ts/state/ducks/network.ts b/ts/state/ducks/network.ts index 392ca14e2..e8d340b5f 100644 --- a/ts/state/ducks/network.ts +++ b/ts/state/ducks/network.ts @@ -11,6 +11,7 @@ export type NetworkStateType = { isOnline: boolean; socketStatus: SocketStatus; withinConnectingGracePeriod: boolean; + challengeStatus: 'required' | 'pending' | 'idle'; }; // Actions @@ -18,6 +19,7 @@ export type NetworkStateType = { const CHECK_NETWORK_STATUS = 'network/CHECK_NETWORK_STATUS'; const CLOSE_CONNECTING_GRACE_PERIOD = 'network/CLOSE_CONNECTING_GRACE_PERIOD'; const RELINK_DEVICE = 'network/RELINK_DEVICE'; +const SET_CHALLENGE_STATUS = 'network/SET_CHALLENGE_STATUS'; export type CheckNetworkStatusPayloadType = { isOnline: boolean; @@ -37,10 +39,18 @@ type RelinkDeviceActionType = { type: 'network/RELINK_DEVICE'; }; +type SetChallengeStatusActionType = { + type: 'network/SET_CHALLENGE_STATUS'; + payload: { + challengeStatus: NetworkStateType['challengeStatus']; + }; +}; + export type NetworkActionType = | CheckNetworkStatusAction | CloseConnectingGracePeriodActionType - | RelinkDeviceActionType; + | RelinkDeviceActionType + | SetChallengeStatusActionType; // Action Creators @@ -67,19 +77,30 @@ function relinkDevice(): RelinkDeviceActionType { }; } +function setChallengeStatus( + challengeStatus: NetworkStateType['challengeStatus'] +): SetChallengeStatusActionType { + return { + type: SET_CHALLENGE_STATUS, + payload: { challengeStatus }, + }; +} + export const actions = { checkNetworkStatus, closeConnectingGracePeriod, relinkDevice, + setChallengeStatus, }; // Reducer -function getEmptyState(): NetworkStateType { +export function getEmptyState(): NetworkStateType { return { isOnline: navigator.onLine, socketStatus: WebSocket.OPEN, withinConnectingGracePeriod: true, + challengeStatus: 'idle', }; } @@ -105,5 +126,12 @@ export function reducer( }; } + if (action.type === SET_CHALLENGE_STATUS) { + return { + ...state, + challengeStatus: action.payload.challengeStatus, + }; + } + return state; } diff --git a/ts/state/selectors/network.ts b/ts/state/selectors/network.ts index 2f5ef5a26..e594ef3d1 100644 --- a/ts/state/selectors/network.ts +++ b/ts/state/selectors/network.ts @@ -22,3 +22,8 @@ export const hasNetworkDialog = createSelector( socketStatus === WebSocket.CLOSED || socketStatus === WebSocket.CLOSING) ); + +export const isChallengePending = createSelector( + getNetwork, + ({ challengeStatus }) => challengeStatus === 'pending' +); diff --git a/ts/state/smart/CaptchaDialog.tsx b/ts/state/smart/CaptchaDialog.tsx new file mode 100644 index 000000000..d1972b246 --- /dev/null +++ b/ts/state/smart/CaptchaDialog.tsx @@ -0,0 +1,26 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { connect } from 'react-redux'; +import { mapDispatchToProps } from '../actions'; +import { CaptchaDialog } from '../../components/CaptchaDialog'; +import { StateType } from '../reducer'; +import { getIntl } from '../selectors/user'; +import { isChallengePending } from '../selectors/network'; +import { getChallengeURL } from '../../challenge'; + +const mapStateToProps = (state: StateType) => { + return { + ...state.updates, + isPending: isChallengePending(state), + i18n: getIntl(state), + + onContinue() { + document.location.href = getChallengeURL(); + }, + }; +}; + +const smart = connect(mapStateToProps, mapDispatchToProps); + +export const SmartCaptchaDialog = smart(CaptchaDialog); diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index 84481a6f2..e42c02fd3 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -41,6 +41,7 @@ import { SmartMessageSearchResult } from './MessageSearchResult'; import { SmartNetworkStatus } from './NetworkStatus'; import { SmartRelinkDialog } from './RelinkDialog'; import { SmartUpdateDialog } from './UpdateDialog'; +import { SmartCaptchaDialog } from './CaptchaDialog'; // Workaround: A react component's required properties are filtering up through connect() // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 @@ -69,6 +70,9 @@ function renderRelinkDialog(): JSX.Element { function renderUpdateDialog(): JSX.Element { return ; } +function renderCaptchaDialog({ onSkip }: { onSkip(): void }): JSX.Element { + return ; +} const getModeSpecificProps = ( state: StateType @@ -136,12 +140,14 @@ const mapStateToProps = (state: StateType) => { showArchived: getShowArchived(state), i18n: getIntl(state), regionCode: getRegionCode(state), + challengeStatus: state.network.challengeStatus, renderExpiredBuildDialog, renderMainHeader, renderMessageSearchResult, renderNetworkStatus, renderRelinkDialog, renderUpdateDialog, + renderCaptchaDialog, }; }; diff --git a/ts/state/smart/MainHeader.tsx b/ts/state/smart/MainHeader.tsx index 1bc77fa88..f71abdec6 100644 --- a/ts/state/smart/MainHeader.tsx +++ b/ts/state/smart/MainHeader.tsx @@ -24,6 +24,7 @@ import { getMe, getSelectedConversation } from '../selectors/conversations'; const mapStateToProps = (state: StateType) => { return { + disabled: state.network.challengeStatus !== 'idle', searchTerm: getQuery(state), searchConversationId: getSearchConversationId(state), searchConversationName: getSearchConversationName(state), diff --git a/ts/test-both/challenge_test.ts b/ts/test-both/challenge_test.ts new file mode 100644 index 000000000..62b6700ce --- /dev/null +++ b/ts/test-both/challenge_test.ts @@ -0,0 +1,382 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +/* eslint-disable no-await-in-loop, no-restricted-syntax */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { assert } from 'chai'; +import { noop } from 'lodash'; +import * as sinon from 'sinon'; + +import { sleep } from '../util/sleep'; +import { ChallengeHandler, MinimalMessage } from '../challenge'; + +type CreateMessageOptions = { + readonly sentAt?: number; + readonly retryAfter?: number; + readonly isNormalBubble?: boolean; +}; + +type CreateHandlerOptions = { + readonly challenge?: boolean; + readonly challengeError?: Error; + readonly expireAfter?: number; + readonly onChallengeSolved?: () => void; + readonly onChallengeFailed?: (retryAfter?: number) => void; +}; + +describe('ChallengeHandler', () => { + const storage = new Map(); + const messageStorage = new Map(); + let challengeStatus = 'idle'; + let sent: Array = []; + + beforeEach(() => { + storage.clear(); + messageStorage.clear(); + challengeStatus = 'idle'; + sent = []; + }); + + const createMessage = ( + id: string, + options: CreateMessageOptions = {} + ): MinimalMessage => { + const { + sentAt = 0, + isNormalBubble = true, + retryAfter = Date.now() + 25, + } = options; + + const testLocalSent = sent; + + const events = new Map void>(); + + return { + id, + idForLogging: () => id, + isNormalBubble() { + return isNormalBubble; + }, + getLastChallengeError() { + return { + name: 'Ignored', + message: 'Ignored', + retryAfter, + data: { token: 'token', options: ['recaptcha'] }, + }; + }, + get(name) { + assert.equal(name, 'sent_at'); + return sentAt; + }, + on(name, handler) { + if (events.get(name)) { + throw new Error('Duplicate event'); + } + events.set(name, handler); + }, + off(name, handler) { + assert.equal(events.get(name), handler); + events.delete(name); + }, + async retrySend() { + await sleep(5); + const handler = events.get('sent'); + if (!handler) { + throw new Error('Expected handler'); + } + handler(); + testLocalSent.push(this.id); + }, + }; + }; + + const createHandler = async ({ + challenge = false, + challengeError, + expireAfter, + onChallengeSolved = noop, + onChallengeFailed = noop, + }: CreateHandlerOptions = {}): Promise => { + const handler = new ChallengeHandler({ + expireAfter, + + storage: { + get(key) { + return storage.get(key); + }, + async put(key, value) { + storage.set(key, value); + }, + }, + + onChallengeSolved, + onChallengeFailed, + + requestChallenge(request) { + if (!challenge) { + return; + } + + setTimeout(() => { + handler.onResponse({ + seq: request.seq, + data: { captcha: 'captcha' }, + }); + }, 5); + }, + + async getMessageById(messageId) { + return messageStorage.get(messageId); + }, + + async sendChallengeResponse() { + if (challengeError) { + throw challengeError; + } + }, + + setChallengeStatus(status) { + challengeStatus = status; + }, + }); + await handler.load(); + await handler.onOnline(); + return handler; + }; + + const isInStorage = (messageId: string) => { + return (storage.get('challenge:retry-message-ids') || []).some( + ({ messageId: storageId }: { messageId: string }) => { + return storageId === messageId; + } + ); + }; + + it('should automatically retry after timeout', async () => { + const handler = await createHandler(); + + const one = createMessage('1'); + messageStorage.set('1', one); + + await handler.register(one); + assert.isTrue(isInStorage(one.id)); + assert.equal(challengeStatus, 'required'); + + await sleep(50); + + assert.deepEqual(sent, ['1']); + assert.equal(challengeStatus, 'idle'); + assert.isFalse(isInStorage(one.id)); + }); + + it('should send challenge response', async () => { + const handler = await createHandler({ challenge: true }); + + const one = createMessage('1', { retryAfter: Date.now() + 100000 }); + messageStorage.set('1', one); + + await handler.register(one); + assert.equal(challengeStatus, 'required'); + + await sleep(50); + + assert.deepEqual(sent, ['1']); + assert.isFalse(isInStorage(one.id)); + assert.equal(challengeStatus, 'idle'); + }); + + it('should send old messages', async () => { + const handler = await createHandler(); + + const retryAfter = Date.now() + 50; + + // Put messages in reverse order to validate that the send order is correct + const messages = [ + createMessage('3', { sentAt: 3, retryAfter }), + createMessage('2', { sentAt: 2, retryAfter }), + createMessage('1', { sentAt: 1, retryAfter }), + ]; + for (const message of messages) { + messageStorage.set(message.id, message); + await handler.register(message); + } + + assert.equal(challengeStatus, 'required'); + assert.deepEqual(sent, []); + + assert.equal(challengeStatus, 'required'); + for (const message of messages) { + assert.isTrue( + isInStorage(message.id), + `${message.id} should be in storage` + ); + } + + await handler.onOffline(); + + // Wait for messages to mature + await sleep(50); + + // Create new handler to load old messages from storage + await createHandler(); + for (const message of messages) { + await handler.unregister(message); + } + + for (const message of messages) { + assert.isFalse( + isInStorage(message.id), + `${message.id} should not be in storage` + ); + } + + // The order has to be correct + assert.deepEqual(sent, ['1', '2', '3']); + assert.equal(challengeStatus, 'idle'); + }); + + it('should send message immediately if it is ready', async () => { + const handler = await createHandler(); + + const one = createMessage('1', { retryAfter: Date.now() - 100 }); + await handler.register(one); + + assert.equal(challengeStatus, 'idle'); + assert.deepEqual(sent, ['1']); + }); + + it('should not change challenge status on non-bubble messages', async () => { + const handler = await createHandler(); + + const one = createMessage('1', { isNormalBubble: false }); + await handler.register(one); + + assert.equal(challengeStatus, 'idle'); + assert.deepEqual(sent, []); + + await sleep(50); + assert.deepEqual(sent, ['1']); + }); + + it('should not retry expired messages', async () => { + const handler = await createHandler(); + + const bubble = createMessage('1'); + messageStorage.set('1', bubble); + await handler.register(bubble); + assert.isTrue(isInStorage(bubble.id)); + + const newHandler = await createHandler({ + challenge: true, + expireAfter: -1, + }); + await handler.unregister(bubble); + + challengeStatus = 'idle'; + await newHandler.load(); + + assert.equal(challengeStatus, 'idle'); + assert.deepEqual(sent, []); + + await sleep(25); + + assert.equal(challengeStatus, 'idle'); + assert.deepEqual(sent, []); + assert.isFalse(isInStorage(bubble.id)); + }); + + it('should send messages that matured while we were offline', async () => { + const handler = await createHandler(); + + const one = createMessage('1'); + messageStorage.set('1', one); + await handler.register(one); + + assert.isTrue(isInStorage(one.id)); + assert.deepEqual(sent, []); + assert.equal(challengeStatus, 'required'); + + await handler.onOffline(); + + // Let messages mature + await sleep(50); + + assert.isTrue(isInStorage(one.id)); + assert.deepEqual(sent, []); + assert.equal(challengeStatus, 'required'); + + // Go back online + await handler.onOnline(); + + assert.isFalse(isInStorage(one.id)); + assert.deepEqual(sent, [one.id]); + assert.equal(challengeStatus, 'idle'); + }); + + it('should not retry more than 5 times', async () => { + const handler = await createHandler(); + + const one = createMessage('1', { + retryAfter: Date.now() + 50, + }); + messageStorage.set('1', one); + await handler.register(one); + + const retrySend = sinon.stub(one, 'retrySend'); + + assert.isTrue(isInStorage(one.id)); + assert.deepEqual(sent, []); + assert.equal(challengeStatus, 'required'); + + // Let it spam the server + await sleep(100); + + assert.isTrue(isInStorage(one.id)); + assert.deepEqual(sent, []); + assert.equal(challengeStatus, 'required'); + + sinon.assert.callCount(retrySend, 5); + }); + + it('should trigger onChallengeSolved', async () => { + const onChallengeSolved = sinon.stub(); + + const handler = await createHandler({ + challenge: true, + onChallengeSolved, + }); + + const one = createMessage('1', { + retryAfter: Date.now() + 1, + }); + messageStorage.set('1', one); + await handler.register(one); + + // Let the challenge go through + await sleep(50); + + sinon.assert.calledOnce(onChallengeSolved); + }); + + it('should trigger onChallengeFailed', async () => { + const onChallengeFailed = sinon.stub(); + + const handler = await createHandler({ + challenge: true, + challengeError: new Error('custom failure'), + onChallengeFailed, + }); + + const one = createMessage('1', { + retryAfter: Date.now() + 1, + }); + messageStorage.set('1', one); + await handler.register(one); + + // Let the challenge go through + await sleep(50); + + sinon.assert.calledOnce(onChallengeFailed); + }); +}); diff --git a/ts/test-both/state/ducks/network_test.ts b/ts/test-both/state/ducks/network_test.ts new file mode 100644 index 000000000..4b0224a89 --- /dev/null +++ b/ts/test-both/state/ducks/network_test.ts @@ -0,0 +1,26 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { actions, getEmptyState, reducer } from '../../../state/ducks/network'; + +describe('both/state/ducks/network', () => { + describe('setChallengeStatus', () => { + const { setChallengeStatus } = actions; + + it('updates whether we need to complete a server challenge', () => { + const idleState = reducer(getEmptyState(), setChallengeStatus('idle')); + assert.equal(idleState.challengeStatus, 'idle'); + + const requiredState = reducer(idleState, setChallengeStatus('required')); + assert.equal(requiredState.challengeStatus, 'required'); + + const pendingState = reducer( + requiredState, + setChallengeStatus('pending') + ); + assert.equal(pendingState.challengeStatus, 'pending'); + }); + }); +}); diff --git a/ts/test-both/util/parseRetryAfter_test.ts b/ts/test-both/util/parseRetryAfter_test.ts new file mode 100644 index 000000000..855c003f5 --- /dev/null +++ b/ts/test-both/util/parseRetryAfter_test.ts @@ -0,0 +1,22 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { parseRetryAfter } from '../../util/parseRetryAfter'; + +describe('parseRetryAfter', () => { + it('should return 0 on invalid input', () => { + assert.equal(parseRetryAfter('nope'), 1000); + assert.equal(parseRetryAfter('1ff'), 1000); + }); + + it('should return milleseconds on valid input', () => { + assert.equal(parseRetryAfter('100'), 100000); + }); + + it('should return apply minimum value', () => { + assert.equal(parseRetryAfter('0'), 1000); + assert.equal(parseRetryAfter('-1'), 1000); + }); +}); diff --git a/ts/test-node/util/sgnlHref_test.ts b/ts/test-node/util/sgnlHref_test.ts index bcb1806f4..9b12c8269 100644 --- a/ts/test-node/util/sgnlHref_test.ts +++ b/ts/test-node/util/sgnlHref_test.ts @@ -7,8 +7,10 @@ import { LoggerType } from '../../types/Logging'; import { isSgnlHref, + isCaptchaHref, isSignalHttpsLink, parseSgnlHref, + parseCaptchaHref, parseSignalHttpsLink, } from '../../util/sgnlHref'; @@ -26,65 +28,71 @@ const explodingLogger: LoggerType = { }; describe('sgnlHref', () => { - describe('isSgnlHref', () => { - it('returns false for non-strings', () => { - const logger = { - ...explodingLogger, - warn: Sinon.spy(), - }; + [ + { protocol: 'sgnl', check: isSgnlHref, name: 'isSgnlHref' }, + { protocol: 'signalcaptcha', check: isCaptchaHref, name: 'isCaptchaHref' }, + ].forEach(({ protocol, check, name }) => { + describe(name, () => { + it('returns false for non-strings', () => { + const logger = { + ...explodingLogger, + warn: Sinon.spy(), + }; - const castToString = (value: unknown): string => value as string; + const castToString = (value: unknown): string => value as string; - assert.isFalse(isSgnlHref(castToString(undefined), logger)); - assert.isFalse(isSgnlHref(castToString(null), logger)); - assert.isFalse(isSgnlHref(castToString(123), logger)); + assert.isFalse(check(castToString(undefined), logger)); + assert.isFalse(check(castToString(null), logger)); + assert.isFalse(check(castToString(123), logger)); - Sinon.assert.calledThrice(logger.warn); - }); + Sinon.assert.calledThrice(logger.warn); + }); - it('returns false for invalid URLs', () => { - assert.isFalse(isSgnlHref('', explodingLogger)); - assert.isFalse(isSgnlHref('sgnl', explodingLogger)); - assert.isFalse(isSgnlHref('sgnl://::', explodingLogger)); - }); + it('returns false for invalid URLs', () => { + assert.isFalse(check('', explodingLogger)); + assert.isFalse(check(protocol, explodingLogger)); + assert.isFalse(check(`${protocol}://::`, explodingLogger)); + }); - it('returns false if the protocol is not "sgnl:"', () => { - assert.isFalse(isSgnlHref('https://example', explodingLogger)); - assert.isFalse( - isSgnlHref( - 'https://signal.art/addstickers/?pack_id=abc', - explodingLogger - ) - ); - assert.isFalse(isSgnlHref('signal://example', explodingLogger)); - }); + it(`returns false if the protocol is not "${protocol}:"`, () => { + assert.isFalse(check('https://example', explodingLogger)); + assert.isFalse( + check('https://signal.art/addstickers/?pack_id=abc', explodingLogger) + ); + assert.isFalse(check('signal://example', explodingLogger)); + }); - it('returns true if the protocol is "sgnl:"', () => { - assert.isTrue(isSgnlHref('sgnl://', explodingLogger)); - assert.isTrue(isSgnlHref('sgnl://example', explodingLogger)); - assert.isTrue(isSgnlHref('sgnl://example.com', explodingLogger)); - assert.isTrue(isSgnlHref('SGNL://example', explodingLogger)); - assert.isTrue(isSgnlHref('sgnl://example?foo=bar', explodingLogger)); - assert.isTrue(isSgnlHref('sgnl://example/', explodingLogger)); - assert.isTrue(isSgnlHref('sgnl://example#', explodingLogger)); + it(`returns true if the protocol is "${protocol}:"`, () => { + assert.isTrue(check(`${protocol}://`, explodingLogger)); + assert.isTrue(check(`${protocol}://example`, explodingLogger)); + assert.isTrue(check(`${protocol}://example.com`, explodingLogger)); + assert.isTrue( + check(`${protocol.toUpperCase()}://example`, explodingLogger) + ); + assert.isTrue(check(`${protocol}://example?foo=bar`, explodingLogger)); + assert.isTrue(check(`${protocol}://example/`, explodingLogger)); + assert.isTrue(check(`${protocol}://example#`, explodingLogger)); - assert.isTrue(isSgnlHref('sgnl:foo', explodingLogger)); + assert.isTrue(check(`${protocol}:foo`, explodingLogger)); - assert.isTrue(isSgnlHref('sgnl://user:pass@example', explodingLogger)); - assert.isTrue(isSgnlHref('sgnl://example.com:1234', explodingLogger)); - assert.isTrue( - isSgnlHref('sgnl://example.com/extra/path/data', explodingLogger) - ); - assert.isTrue( - isSgnlHref('sgnl://example/?foo=bar#hash', explodingLogger) - ); - }); + assert.isTrue( + check(`${protocol}://user:pass@example`, explodingLogger) + ); + assert.isTrue(check(`${protocol}://example.com:1234`, explodingLogger)); + assert.isTrue( + check(`${protocol}://example.com/extra/path/data`, explodingLogger) + ); + assert.isTrue( + check(`${protocol}://example/?foo=bar#hash`, explodingLogger) + ); + }); - it('accepts URL objects', () => { - const invalid = new URL('https://example.com'); - assert.isFalse(isSgnlHref(invalid, explodingLogger)); - const valid = new URL('sgnl://example'); - assert.isTrue(isSgnlHref(valid, explodingLogger)); + it('accepts URL objects', () => { + const invalid = new URL('https://example.com'); + assert.isFalse(check(invalid, explodingLogger)); + const valid = new URL(`${protocol}://example`); + assert.isTrue(check(valid, explodingLogger)); + }); }); }); @@ -255,6 +263,31 @@ describe('sgnlHref', () => { }); }); + describe('parseCaptchaHref', () => { + it('throws on invalid URLs', () => { + ['', 'sgnl', 'https://example/?foo=bar'].forEach(href => { + assert.throws( + () => parseCaptchaHref(href, explodingLogger), + 'Not a captcha href' + ); + }); + }); + + it('parses the command for URLs with no arguments', () => { + [ + 'signalcaptcha://foo', + 'signalcaptcha://foo?x=y', + 'signalcaptcha://a:b@foo?x=y', + 'signalcaptcha://foo#hash', + 'signalcaptcha://foo/', + ].forEach(href => { + assert.deepEqual(parseCaptchaHref(href, explodingLogger), { + captcha: 'foo', + }); + }); + }); + }); + describe('parseSignalHttpsLink', () => { it('returns a null command for invalid URLs', () => { ['', 'https', 'https://example/?foo=bar'].forEach(href => { diff --git a/ts/textsecure/Errors.ts b/ts/textsecure/Errors.ts index 19b13758b..6b1ad21b3 100644 --- a/ts/textsecure/Errors.ts +++ b/ts/textsecure/Errors.ts @@ -4,6 +4,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable max-classes-per-file */ +import { parseRetryAfter } from '../util/parseRetryAfter'; + function appendStack(newError: Error, originalError: Error) { // eslint-disable-next-line no-param-reassign newError.stack += `\nOriginal stack:\n${originalError.stack}`; @@ -104,6 +106,37 @@ export class SendMessageNetworkError extends ReplayableError { } } +export type SendMessageChallengeData = { + readonly token?: string; + readonly options?: ReadonlyArray; +}; + +export class SendMessageChallengeError extends ReplayableError { + public identifier: string; + + public readonly data: SendMessageChallengeData | undefined; + + public readonly retryAfter: number; + + constructor(identifier: string, httpError: Error) { + super({ + name: 'SendMessageChallengeError', + message: httpError.message, + }); + + [this.identifier] = identifier.split('.'); + this.code = httpError.code; + this.data = httpError.response; + + const headers = httpError.responseHeaders || {}; + + this.retryAfter = + Date.now() + parseRetryAfter(headers['retry-after'].toString()); + + appendStack(this, httpError); + } +} + export class SignedPreKeyRotationError extends ReplayableError { constructor() { super({ diff --git a/ts/textsecure/OutgoingMessage.ts b/ts/textsecure/OutgoingMessage.ts index 62585858c..7cc0f4109 100644 --- a/ts/textsecure/OutgoingMessage.ts +++ b/ts/textsecure/OutgoingMessage.ts @@ -34,6 +34,7 @@ import { OutgoingIdentityKeyError, OutgoingMessageError, SendMessageNetworkError, + SendMessageChallengeError, UnregisteredUserError, } from './Errors'; import { isValidNumber } from '../types/PhoneNumber'; @@ -163,12 +164,16 @@ export default class OutgoingMessage { let error = providedError; if (!error || (error.name === 'HTTPError' && error.code !== 404)) { - error = new OutgoingMessageError( - identifier, - this.message.toArrayBuffer(), - this.timestamp, - error - ); + if (error && error.code === 428) { + error = new SendMessageChallengeError(identifier, error); + } else { + error = new OutgoingMessageError( + identifier, + this.message.toArrayBuffer(), + this.timestamp, + error + ); + } } error.reason = reason; @@ -370,10 +375,14 @@ export default class OutgoingMessage { if (e.name === 'HTTPError' && e.code !== 409 && e.code !== 410) { // 409 and 410 should bubble and be handled by doSendMessage // 404 should throw UnregisteredUserError + // 428 should throw SendMessageChallengeError // all other network errors can be retried later. if (e.code === 404) { throw new UnregisteredUserError(identifier, e); } + if (e.code === 428) { + throw new SendMessageChallengeError(identifier, e); + } throw new SendMessageNetworkError(identifier, jsonData, e); } throw e; diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 8e62bb109..412238666 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -16,6 +16,7 @@ import { GroupCredentialsType, GroupLogResponseType, ProxiedRequestOptionsType, + ChallengeType, WebAPIType, } from './WebAPI'; import createTaskWithTimeout from './TaskWithTimeout'; @@ -1915,4 +1916,10 @@ export default class MessageSender { ): Promise { return this.server.getGroupExternalCredential(options); } + + public async sendChallengeResponse( + challengeResponse: ChallengeType + ): Promise { + return this.server.sendChallengeResponse(challengeResponse); + } } diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 01641a97b..cd9d96bc3 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -306,7 +306,8 @@ function getContentType(response: Response) { return null; } -type HeaderListType = { [name: string]: string }; +type FetchHeaderListType = { [name: string]: string }; +type HeaderListType = { [name: string]: string | ReadonlyArray }; type HTTPCodeType = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; type RedactUrl = (url: string) => string; @@ -397,7 +398,7 @@ async function _promiseAjax( 'User-Agent': getUserAgent(options.version), 'X-Signal-Agent': 'OWD', ...options.headers, - } as HeaderListType, + } as FetchHeaderListType, redirect: options.redirect, agent, ca: options.certificateAuthority, @@ -500,6 +501,7 @@ async function _promiseAjax( makeHTTPError( 'promiseAjax: invalid response', response.status, + response.headers.raw(), result, options.stack ) @@ -563,6 +565,7 @@ async function _promiseAjax( makeHTTPError( 'promiseAjax: error response', response.status, + response.headers.raw(), result, options.stack ) @@ -576,7 +579,7 @@ async function _promiseAjax( window.log.error(options.type, url, 0, 'Error'); } const stack = `${e.stack}\nInitial stack:\n${options.stack}`; - reject(makeHTTPError('promiseAjax catch', 0, e.toString(), stack)); + reject(makeHTTPError('promiseAjax catch', 0, {}, e.toString(), stack)); }); }); } @@ -614,6 +617,7 @@ declare global { interface Error { code?: number | string; response?: any; + responseHeaders?: HeaderListType; warn?: boolean; } } @@ -621,6 +625,7 @@ declare global { function makeHTTPError( message: string, providedCode: number, + headers: HeaderListType, response: any, stack?: string ) { @@ -628,6 +633,7 @@ function makeHTTPError( const e = new Error(`${message}; code: ${code}`); e.name = 'HTTPError'; e.code = code; + e.responseHeaders = headers; if (DEBUG && response) { e.stack += `\nresponse: ${response}`; } @@ -670,6 +676,7 @@ const URL_CALLS = { supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery', updateDeviceName: 'v1/accounts/name', whoami: 'v1/accounts/whoami', + challenge: 'v1/challenge', }; type InitializeOptionsType = { @@ -875,6 +882,7 @@ export type WebAPIType = { options: GroupCredentialsType ) => Promise; whoami: () => Promise; + sendChallengeResponse: (challengeResponse: ChallengeType) => Promise; getConfig: () => Promise< Array<{ name: string; enabled: boolean; value: string | null }> >; @@ -912,6 +920,12 @@ export type ServerKeysType = { identityKey: ArrayBuffer; }; +export type ChallengeType = { + readonly type: 'recaptcha'; + readonly token: string; + readonly captcha: string; +}; + export type ProxiedRequestOptionsType = { returnArrayBuffer?: boolean; start?: number; @@ -1035,6 +1049,7 @@ export function initialize({ updateDeviceName, uploadGroupAvatar, whoami, + sendChallengeResponse, }; async function _ajax(param: AjaxOptionsType): Promise { @@ -1105,6 +1120,14 @@ export function initialize({ }); } + async function sendChallengeResponse(challengeResponse: ChallengeType) { + return _ajax({ + call: 'challenge', + httpType: 'PUT', + jsonData: challengeResponse, + }); + } + async function getConfig() { type ResType = { config: Array<{ name: string; enabled: boolean; value: string | null }>; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 4cd6b48e8..85e6b9e10 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -14140,6 +14140,41 @@ "updated": "2021-01-06T00:47:54.313Z", "reasonDetail": "Needed to render remote video elements. Doesn't interact with the DOM." }, + { + "rule": "jQuery-load(", + "path": "ts/challenge.js", + "line": " async load() {", + "reasonCategory": "falseMatch", + "updated": "2021-05-05T23:11:22.692Z" + }, + { + "rule": "jQuery-load(", + "path": "ts/challenge.js", + "line": " // 1. `.load()` when the `window.storage` is ready", + "reasonCategory": "falseMatch", + "updated": "2021-05-05T23:11:22.692Z" + }, + { + "rule": "jQuery-load(", + "path": "ts/challenge.ts", + "line": "// to the `ChallengeHandler` on `.load()` call (from `ts/background.ts`). They", + "reasonCategory": "falseMatch", + "updated": "2021-05-05T23:11:22.692Z" + }, + { + "rule": "jQuery-load(", + "path": "ts/challenge.ts", + "line": " public async load(): Promise {", + "reasonCategory": "falseMatch", + "updated": "2021-05-05T23:11:22.692Z" + }, + { + "rule": "jQuery-load(", + "path": "ts/challenge.ts", + "line": " // 1. `.load()` when the `window.storage` is ready", + "reasonCategory": "falseMatch", + "updated": "2021-05-05T23:11:22.692Z" + }, { "rule": "React-useRef", "path": "ts/components/AvatarInput.js", @@ -14212,6 +14247,14 @@ "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Used to get the local video element for rendering." }, + { + "rule": "React-useRef", + "path": "ts/components/CaptchaDialog.js", + "line": " const buttonRef = react_1.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2021-05-05T23:11:22.692Z", + "reasonDetail": "Used only to set focus" + }, { "rule": "React-createRef", "path": "ts/components/CaptionEditor.js", diff --git a/ts/util/parseRetryAfter.ts b/ts/util/parseRetryAfter.ts new file mode 100644 index 000000000..862a395ab --- /dev/null +++ b/ts/util/parseRetryAfter.ts @@ -0,0 +1,16 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { isNormalNumber } from './isNormalNumber'; + +const ONE_SECOND = 1000; +const MINIMAL_RETRY_AFTER = ONE_SECOND; + +export function parseRetryAfter(value: string): number { + let retryAfter = parseInt(value, 10); + if (!isNormalNumber(retryAfter) || retryAfter.toString() !== value) { + retryAfter = 0; + } + + return Math.max(retryAfter * ONE_SECOND, MINIMAL_RETRY_AFTER); +} diff --git a/ts/util/sgnlHref.ts b/ts/util/sgnlHref.ts index b9902ec27..eecfb0145 100644 --- a/ts/util/sgnlHref.ts +++ b/ts/util/sgnlHref.ts @@ -23,6 +23,14 @@ export function isSgnlHref(value: string | URL, logger: LoggerType): boolean { return url !== null && url.protocol === 'sgnl:'; } +export function isCaptchaHref( + value: string | URL, + logger: LoggerType +): boolean { + const url = parseUrl(value, logger); + return url !== null && url.protocol === 'signalcaptcha:'; +} + export function isSignalHttpsLink( value: string | URL, logger: LoggerType @@ -64,6 +72,23 @@ export function parseSgnlHref( }; } +type ParsedCaptchaHref = { + readonly captcha: string; +}; +export function parseCaptchaHref( + href: URL | string, + logger: LoggerType +): ParsedCaptchaHref { + const url = parseUrl(href, logger); + if (!url || !isCaptchaHref(url, logger)) { + throw new Error('Not a captcha href'); + } + + return { + captcha: url.host, + }; +} + export function parseSignalHttpsLink( href: string, logger: LoggerType diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 9635e28ea..a0ebf1654 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -85,6 +85,18 @@ Whisper.BlockedGroupToast = Whisper.ToastView.extend({ }, }); +Whisper.CaptchaSolvedToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('verificationComplete') }; + }, +}); + +Whisper.CaptchaFailedToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('verificationFailed') }; + }, +}); + Whisper.LeftGroupToast = Whisper.ToastView.extend({ render_attributes() { return { toastMessage: window.i18n('youLeftTheGroup') }; @@ -237,6 +249,12 @@ Whisper.ReactionFailedToast = Whisper.ToastView.extend({ }, }); +Whisper.DeleteForEveryoneFailedToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('deleteForEveryoneFailed') }; + }, +}); + Whisper.GroupLinkCopiedToast = Whisper.ToastView.extend({ render_attributes() { return { toastMessage: window.i18n('GroupLinkManagement--clipboard') }; @@ -2788,7 +2806,16 @@ Whisper.ConversationView = Whisper.View.extend({ message: window.i18n('deleteForEveryoneWarning'), okText: window.i18n('delete'), resolve: async () => { - await this.model.sendDeleteForEveryoneMessage(message.get('sent_at')); + try { + await this.model.sendDeleteForEveryoneMessage(message.get('sent_at')); + } catch (error) { + window.log.error( + 'Error sending delete-for-everyone', + error, + messageId + ); + this.showToast(Whisper.DeleteForEveryoneFailedToast); + } this.resetPanel(); }, }); diff --git a/ts/window.d.ts b/ts/window.d.ts index cc0a0018a..e102d46d6 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -18,6 +18,10 @@ import { MessageAttributesType, } from './model-types.d'; import { ContactRecordIdentityState, TextSecureType } from './textsecure.d'; +import { + ChallengeHandler, + IPCRequest as IPCChallengeRequest, +} from './challenge'; import { WebAPIConnectType } from './textsecure/WebAPI'; import { uploadDebugLogs } from './logging/debuglogs'; import { CallingClass } from './services/calling'; @@ -216,6 +220,7 @@ declare global { showWindow: () => void; showSettings: () => void; shutdown: () => void; + sendChallengeRequest: (request: IPCChallengeRequest) => void; setAutoHideMenuBar: (value: WhatIsThis) => void; setBadgeCount: (count: number) => void; setMenuBarVisibility: (value: WhatIsThis) => void; @@ -522,6 +527,7 @@ declare global { getInitialState: () => WhatIsThis; load: () => void; }; + challengeHandler: ChallengeHandler; }; ConversationController: ConversationController; @@ -580,6 +586,7 @@ export type DCodeIOType = { }; type MessageControllerType = { + getById: (id: string) => MessageModel | undefined; findBySender: (sender: string) => MessageModel | null; findBySentAt: (sentAt: number) => MessageModel | null; register: (id: string, model: MessageModel) => MessageModel; @@ -739,6 +746,8 @@ export type WhisperType = { BlockedGroupToast: typeof window.Whisper.ToastView; BlockedToast: typeof window.Whisper.ToastView; CannotMixImageAndNonImageAttachmentsToast: typeof window.Whisper.ToastView; + CaptchaSolvedToast: typeof window.Whisper.ToastView; + CaptchaFailedToast: typeof window.Whisper.ToastView; DangerousFileTypeToast: typeof window.Whisper.ToastView; ExpiredToast: typeof window.Whisper.ToastView; FileSavedToast: typeof window.Whisper.ToastView; @@ -753,6 +762,7 @@ export type WhisperType = { OriginalNotFoundToast: typeof window.Whisper.ToastView; PinnedConversationsFullToast: typeof window.Whisper.ToastView; ReactionFailedToast: typeof window.Whisper.ToastView; + DeleteForEveryoneFailedToast: typeof window.Whisper.ToastView; TapToViewExpiredIncomingToast: typeof window.Whisper.ToastView; TapToViewExpiredOutgoingToast: typeof window.Whisper.ToastView; TimerConflictToast: typeof window.Whisper.ToastView;