Improve challenge handling
This commit is contained in:
parent
e0852abcdc
commit
e3f418105b
105
ts/challenge.ts
105
ts/challenge.ts
|
@ -18,38 +18,40 @@ import { parseRetryAfterWithDefault } from './util/parseRetryAfter';
|
||||||
import { clearTimeoutIfNecessary } from './util/clearTimeoutIfNecessary';
|
import { clearTimeoutIfNecessary } from './util/clearTimeoutIfNecessary';
|
||||||
import { getEnvironment, Environment } from './environment';
|
import { getEnvironment, Environment } from './environment';
|
||||||
import type { StorageInterface } from './types/Storage.d';
|
import type { StorageInterface } from './types/Storage.d';
|
||||||
|
import * as Errors from './types/errors';
|
||||||
import { HTTPError } from './textsecure/Errors';
|
import { HTTPError } from './textsecure/Errors';
|
||||||
import type { SendMessageChallengeData } from './textsecure/Errors';
|
import type { SendMessageChallengeData } from './textsecure/Errors';
|
||||||
import * as log from './logging/log';
|
import * as log from './logging/log';
|
||||||
|
|
||||||
export type ChallengeResponse = {
|
export type ChallengeResponse = Readonly<{
|
||||||
readonly captcha: string;
|
captcha: string;
|
||||||
};
|
}>;
|
||||||
|
|
||||||
export type IPCRequest = {
|
export type IPCRequest = Readonly<{
|
||||||
readonly seq: number;
|
seq: number;
|
||||||
};
|
reason: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type IPCResponse = {
|
export type IPCResponse = Readonly<{
|
||||||
readonly seq: number;
|
seq: number;
|
||||||
readonly data: ChallengeResponse;
|
data: ChallengeResponse;
|
||||||
};
|
}>;
|
||||||
|
|
||||||
type Handler = {
|
type Handler = Readonly<{
|
||||||
readonly token: string | undefined;
|
token: string | undefined;
|
||||||
|
|
||||||
resolve(response: ChallengeResponse): void;
|
resolve(response: ChallengeResponse): void;
|
||||||
reject(error: Error): void;
|
reject(error: Error): void;
|
||||||
};
|
}>;
|
||||||
|
|
||||||
export type ChallengeData = {
|
export type ChallengeData = Readonly<{
|
||||||
readonly type: 'recaptcha';
|
type: 'recaptcha';
|
||||||
readonly token: string;
|
token: string;
|
||||||
readonly captcha: string;
|
captcha: string;
|
||||||
};
|
}>;
|
||||||
|
|
||||||
export type Options = {
|
export type Options = Readonly<{
|
||||||
readonly storage: Pick<StorageInterface, 'get' | 'put'>;
|
storage: Pick<StorageInterface, 'get' | 'put'>;
|
||||||
|
|
||||||
requestChallenge(request: IPCRequest): void;
|
requestChallenge(request: IPCRequest): void;
|
||||||
|
|
||||||
|
@ -63,17 +65,33 @@ export type Options = {
|
||||||
onChallengeFailed(retryAfter?: number): void;
|
onChallengeFailed(retryAfter?: number): void;
|
||||||
|
|
||||||
expireAfter?: number;
|
expireAfter?: number;
|
||||||
};
|
}>;
|
||||||
|
|
||||||
export const STORAGE_KEY = 'challenge:conversations';
|
export const STORAGE_KEY = 'challenge:conversations';
|
||||||
|
|
||||||
export type RegisteredChallengeType = Readonly<{
|
export type RegisteredChallengeType = Readonly<{
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
reason: string;
|
||||||
retryAt?: number;
|
retryAt?: number;
|
||||||
token?: string;
|
token?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
type SolveOptionsType = Readonly<{
|
||||||
|
token: string;
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type MaybeSolveOptionsType = Readonly<{
|
||||||
|
conversationId: string;
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type RequestCaptchaOptionsType = Readonly<{
|
||||||
|
reason: string;
|
||||||
|
token?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
const DEFAULT_EXPIRE_AFTER = 24 * 3600 * 1000; // one day
|
const DEFAULT_EXPIRE_AFTER = 24 * 3600 * 1000; // one day
|
||||||
const CAPTCHA_URL = 'https://signalcaptchas.org/challenge/generate.html';
|
const CAPTCHA_URL = 'https://signalcaptchas.org/challenge/generate.html';
|
||||||
const CAPTCHA_STAGING_URL =
|
const CAPTCHA_STAGING_URL =
|
||||||
|
@ -181,7 +199,7 @@ export class ChallengeHandler {
|
||||||
await this.startAllQueues();
|
await this.startAllQueues();
|
||||||
}
|
}
|
||||||
|
|
||||||
public maybeSolve(conversationId: string): void {
|
public maybeSolve({ conversationId, reason }: MaybeSolveOptionsType): void {
|
||||||
const challenge = this.registeredConversations.get(conversationId);
|
const challenge = this.registeredConversations.get(conversationId);
|
||||||
if (!challenge) {
|
if (!challenge) {
|
||||||
return;
|
return;
|
||||||
|
@ -192,7 +210,7 @@ export class ChallengeHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (challenge.token) {
|
if (challenge.token) {
|
||||||
this.solve(challenge.token);
|
this.solve({ reason, token: challenge.token });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -200,10 +218,11 @@ export class ChallengeHandler {
|
||||||
challenge: RegisteredChallengeType,
|
challenge: RegisteredChallengeType,
|
||||||
data?: SendMessageChallengeData
|
data?: SendMessageChallengeData
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { conversationId } = challenge;
|
const { conversationId, reason } = challenge;
|
||||||
|
const logId = `challenge(${reason})`;
|
||||||
|
|
||||||
if (this.isRegistered(conversationId)) {
|
if (this.isRegistered(conversationId)) {
|
||||||
log.info(`challenge: conversation ${conversationId} already registered`);
|
log.info(`${logId}: conversation ${conversationId} already registered`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -212,9 +231,7 @@ export class ChallengeHandler {
|
||||||
|
|
||||||
// Challenge is already retryable - start the queue
|
// Challenge is already retryable - start the queue
|
||||||
if (shouldStartQueue(challenge)) {
|
if (shouldStartQueue(challenge)) {
|
||||||
log.info(
|
log.info(`${logId}: starting conversation ${conversationId} immediately`);
|
||||||
`challenge: starting conversation ${conversationId} immediately`
|
|
||||||
);
|
|
||||||
await this.startQueue(conversationId);
|
await this.startQueue(conversationId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -234,27 +251,25 @@ export class ChallengeHandler {
|
||||||
}, waitTime)
|
}, waitTime)
|
||||||
);
|
);
|
||||||
log.info(
|
log.info(
|
||||||
`challenge: tracking ${conversationId} with waitTime=${waitTime}`
|
`${logId}: tracking ${conversationId} with waitTime=${waitTime}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
log.info(`challenge: tracking ${conversationId} with no waitTime`);
|
log.info(`${logId}: tracking ${conversationId} with no waitTime`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data && !data.options?.includes('recaptcha')) {
|
if (data && !data.options?.includes('recaptcha')) {
|
||||||
log.error(
|
log.error(`${logId}: unexpected options ${JSON.stringify(data.options)}`);
|
||||||
`challenge: unexpected options ${JSON.stringify(data.options)}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!challenge.token) {
|
if (!challenge.token) {
|
||||||
const dataString = JSON.stringify(data);
|
const dataString = JSON.stringify(data);
|
||||||
log.error(
|
log.error(
|
||||||
`challenge: ${conversationId} is waiting; no token in data ${dataString}`
|
`${logId}: ${conversationId} is waiting; no token in data ${dataString}`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.solve(challenge.token);
|
this.solve({ token: challenge.token, reason });
|
||||||
}
|
}
|
||||||
|
|
||||||
public onResponse(response: IPCResponse): void {
|
public onResponse(response: IPCResponse): void {
|
||||||
|
@ -279,8 +294,11 @@ export class ChallengeHandler {
|
||||||
await this.persist();
|
await this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async requestCaptcha(token = ''): Promise<string> {
|
public async requestCaptcha({
|
||||||
const request: IPCRequest = { seq: this.seq };
|
reason,
|
||||||
|
token = '',
|
||||||
|
}: RequestCaptchaOptionsType): Promise<string> {
|
||||||
|
const request: IPCRequest = { seq: this.seq, reason };
|
||||||
this.seq += 1;
|
this.seq += 1;
|
||||||
|
|
||||||
this.options.requestChallenge(request);
|
this.options.requestChallenge(request);
|
||||||
|
@ -335,12 +353,12 @@ export class ChallengeHandler {
|
||||||
this.options.startQueue(conversationId);
|
this.options.startQueue(conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async solve(token: string): Promise<void> {
|
private async solve({ reason, token }: SolveOptionsType): Promise<void> {
|
||||||
this.solving += 1;
|
this.solving += 1;
|
||||||
this.options.setChallengeStatus('required');
|
this.options.setChallengeStatus('required');
|
||||||
this.challengeToken = token;
|
this.challengeToken = token;
|
||||||
|
|
||||||
const captcha = await this.requestCaptcha(token);
|
const captcha = await this.requestCaptcha({ reason, token });
|
||||||
|
|
||||||
// Another `.solve()` has completed earlier than us
|
// Another `.solve()` has completed earlier than us
|
||||||
if (this.challengeToken === undefined) {
|
if (this.challengeToken === undefined) {
|
||||||
|
@ -353,7 +371,7 @@ export class ChallengeHandler {
|
||||||
|
|
||||||
this.options.setChallengeStatus('pending');
|
this.options.setChallengeStatus('pending');
|
||||||
|
|
||||||
log.info('challenge: sending challenge to server');
|
log.info(`challenge(${reason}): sending challenge to server`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.sendChallengeResponse({
|
await this.sendChallengeResponse({
|
||||||
|
@ -362,13 +380,16 @@ export class ChallengeHandler {
|
||||||
captcha,
|
captcha,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(`challenge: challenge failure, error: ${error && error.stack}`);
|
log.error(
|
||||||
|
`challenge(${reason}): challenge failure, error:`,
|
||||||
|
Errors.toLogFormat(error)
|
||||||
|
);
|
||||||
this.options.setChallengeStatus('required');
|
this.options.setChallengeStatus('required');
|
||||||
this.solving -= 1;
|
this.solving -= 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('challenge: challenge success. force sending');
|
log.info(`challenge(${reason}): challenge success. force sending`);
|
||||||
|
|
||||||
this.options.setChallengeStatus('idle');
|
this.options.setChallengeStatus('idle');
|
||||||
|
|
||||||
|
|
|
@ -133,7 +133,9 @@ export const StandaloneRegistration = ({
|
||||||
setError('Captcha handler is not ready!');
|
setError('Captcha handler is not ready!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const token = await window.Signal.challengeHandler.requestCaptcha();
|
const token = await window.Signal.challengeHandler.requestCaptcha({
|
||||||
|
reason: 'standalone registration',
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
requestVerification(type, number, token);
|
requestVerification(type, number, token);
|
||||||
|
|
|
@ -157,12 +157,15 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
|
||||||
data: Readonly<ConversationQueueJobData>,
|
data: Readonly<ConversationQueueJobData>,
|
||||||
insert?: (job: ParsedJob<ConversationQueueJobData>) => Promise<void>
|
insert?: (job: ParsedJob<ConversationQueueJobData>) => Promise<void>
|
||||||
): Promise<Job<ConversationQueueJobData>> {
|
): Promise<Job<ConversationQueueJobData>> {
|
||||||
const { conversationId } = data;
|
const { conversationId, type } = data;
|
||||||
strictAssert(
|
strictAssert(
|
||||||
window.Signal.challengeHandler,
|
window.Signal.challengeHandler,
|
||||||
'conversationJobQueue.add: Missing challengeHandler!'
|
'conversationJobQueue.add: Missing challengeHandler!'
|
||||||
);
|
);
|
||||||
window.Signal.challengeHandler.maybeSolve(conversationId);
|
window.Signal.challengeHandler.maybeSolve({
|
||||||
|
conversationId,
|
||||||
|
reason: `conversationJobQueue.add(${conversationId}, ${type})`,
|
||||||
|
});
|
||||||
|
|
||||||
return super.add(data, insert);
|
return super.add(data, insert);
|
||||||
}
|
}
|
||||||
|
@ -382,6 +385,9 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
retryAt: toProcess.retryAt,
|
retryAt: toProcess.retryAt,
|
||||||
token: toProcess.data?.token,
|
token: toProcess.data?.token,
|
||||||
|
reason:
|
||||||
|
'conversationJobQueue.run(' +
|
||||||
|
`${conversation.idForLogging()}, ${type}, ${timestamp})`,
|
||||||
},
|
},
|
||||||
toProcess.data
|
toProcess.data
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
/* eslint-disable no-console */
|
|
||||||
|
|
||||||
import type { IpcMainEvent } from 'electron';
|
import type { IpcMainEvent } from 'electron';
|
||||||
import { ipcMain as ipc } from 'electron';
|
import { ipcMain as ipc } from 'electron';
|
||||||
|
|
||||||
|
import * as log from '../logging/log';
|
||||||
import type { IPCRequest, IPCResponse, ChallengeResponse } from '../challenge';
|
import type { IPCRequest, IPCResponse, ChallengeResponse } from '../challenge';
|
||||||
|
|
||||||
export class ChallengeMainHandler {
|
export class ChallengeMainHandler {
|
||||||
|
@ -19,6 +19,12 @@ export class ChallengeMainHandler {
|
||||||
|
|
||||||
const { handlers } = this;
|
const { handlers } = this;
|
||||||
this.handlers = [];
|
this.handlers = [];
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
'challengeMain.handleCaptcha: sending captcha response to ' +
|
||||||
|
`${handlers.length} handlers`,
|
||||||
|
response
|
||||||
|
);
|
||||||
for (const resolve of handlers) {
|
for (const resolve of handlers) {
|
||||||
resolve(response);
|
resolve(response);
|
||||||
}
|
}
|
||||||
|
@ -28,13 +34,17 @@ export class ChallengeMainHandler {
|
||||||
event: IpcMainEvent,
|
event: IpcMainEvent,
|
||||||
request: IPCRequest
|
request: IPCRequest
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log('Received challenge request, waiting for response');
|
const logId = `challengeMain.onRequest(${request.reason})`;
|
||||||
|
log.info(`${logId}: received challenge request, waiting for response`);
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
const data = await new Promise<ChallengeResponse>(resolve => {
|
const data = await new Promise<ChallengeResponse>(resolve => {
|
||||||
this.handlers.push(resolve);
|
this.handlers.push(resolve);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Sending challenge response', data);
|
const duration = Date.now() - start;
|
||||||
|
log.info(`${logId}: got response after ${duration}ms`);
|
||||||
|
|
||||||
const ipcResponse: IPCResponse = {
|
const ipcResponse: IPCResponse = {
|
||||||
seq: request.seq,
|
seq: request.seq,
|
||||||
|
|
|
@ -56,6 +56,7 @@ describe('ChallengeHandler', () => {
|
||||||
token: '1',
|
token: '1',
|
||||||
retryAt: NOW + DEFAULT_RETRY_AFTER,
|
retryAt: NOW + DEFAULT_RETRY_AFTER,
|
||||||
createdAt: NOW - SECOND,
|
createdAt: NOW - SECOND,
|
||||||
|
reason: 'test',
|
||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -144,10 +144,10 @@ export class SendMessageNetworkError extends ReplayableError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SendMessageChallengeData = {
|
export type SendMessageChallengeData = Readonly<{
|
||||||
readonly token?: string;
|
token?: string;
|
||||||
readonly options?: ReadonlyArray<string>;
|
options?: ReadonlyArray<string>;
|
||||||
};
|
}>;
|
||||||
|
|
||||||
export class SendMessageChallengeError extends ReplayableError {
|
export class SendMessageChallengeError extends ReplayableError {
|
||||||
public identifier: string;
|
public identifier: string;
|
||||||
|
|
Loading…
Reference in New Issue