Support server-determined build expiration

This commit is contained in:
Josh Perez 2020-09-09 18:50:44 -04:00 committed by Scott Nonnenberg
parent a04f9a0e51
commit d87335f5a6
14 changed files with 147 additions and 28 deletions

View file

@ -1543,6 +1543,25 @@
await window.Signal.RemoteConfig.maybeRefreshRemoteConfig(); await window.Signal.RemoteConfig.maybeRefreshRemoteConfig();
}); });
// Listen for changes to the `desktop.clientExpiration` remote flag
window.Signal.RemoteConfig.onChange(
'desktop.clientExpiration',
({ value }) => {
const remoteBuildExpirationTimestamp = window.Signal.Util.parseRemoteClientExpiration(
value
);
if (remoteBuildExpirationTimestamp) {
window.storage.put(
'remoteBuildExpiration',
remoteBuildExpirationTimestamp
);
window.reduxActions.expiration.hydrateExpirationStatus(
window.Signal.Util.hasExpired()
);
}
}
);
// Listen for changes to the `desktop.messageRequests` remote configuration flag // Listen for changes to the `desktop.messageRequests` remote configuration flag
const removeMessageRequestListener = window.Signal.RemoteConfig.onChange( const removeMessageRequestListener = window.Signal.RemoteConfig.onChange(
'desktop.messageRequests', 'desktop.messageRequests',

View file

@ -34,7 +34,15 @@ try {
window.getEnvironment = () => config.environment; window.getEnvironment = () => config.environment;
window.getAppInstance = () => config.appInstance; window.getAppInstance = () => config.appInstance;
window.getVersion = () => config.version; window.getVersion = () => config.version;
window.getExpiration = () => config.buildExpiration; window.getExpiration = () => {
const remoteBuildExpiration = window.storage.get('remoteBuildExpiration');
if (remoteBuildExpiration) {
return remoteBuildExpiration < config.buildExpiration
? remoteBuildExpiration
: config.buildExpiration;
}
return config.buildExpiration;
};
window.getNodeVersion = () => config.node_version; window.getNodeVersion = () => config.node_version;
window.getHostName = () => config.hostname; window.getHostName = () => config.hostname;
window.getServerTrustRoot = () => config.serverTrustRoot; window.getServerTrustRoot = () => config.serverTrustRoot;

View file

@ -1,11 +1,16 @@
import { get, throttle } from 'lodash'; import { get, throttle } from 'lodash';
import { WebAPIType } from './textsecure/WebAPI'; import { WebAPIType } from './textsecure/WebAPI';
type ConfigKeyType = 'desktop.messageRequests' | 'desktop.gv2' | 'desktop.cds'; type ConfigKeyType =
| 'desktop.messageRequests'
| 'desktop.gv2'
| 'desktop.cds'
| 'desktop.clientExpiration';
type ConfigValueType = { type ConfigValueType = {
name: ConfigKeyType; name: ConfigKeyType;
enabled: boolean; enabled: boolean;
enabledAt?: number; enabledAt?: number;
value?: unknown;
}; };
type ConfigMapType = { [key: string]: ConfigValueType }; type ConfigMapType = { [key: string]: ConfigValueType };
type ConfigListenerType = (value: ConfigValueType) => unknown; type ConfigListenerType = (value: ConfigValueType) => unknown;
@ -51,31 +56,36 @@ export const refreshRemoteConfig = async () => {
// The old configuration is not set as the initial value in reduce because // The old configuration is not set as the initial value in reduce because
// flags may have been deleted // flags may have been deleted
const oldConfig = config; const oldConfig = config;
config = newConfig.reduce((previous, { name, enabled }) => { config = newConfig.reduce((acc, { name, enabled, value }) => {
const previouslyEnabled: boolean = get(oldConfig, [name, 'enabled'], false); const previouslyEnabled: boolean = get(oldConfig, [name, 'enabled'], false);
const previousValue: unknown = get(oldConfig, [name, 'value'], undefined);
// If a flag was previously not enabled and is now enabled, record the time it was enabled // If a flag was previously not enabled and is now enabled, record the time it was enabled
const enabledAt: number | undefined = const enabledAt: number | undefined =
previouslyEnabled && enabled ? now : get(oldConfig, [name, 'enabledAt']); previouslyEnabled && enabled ? now : get(oldConfig, [name, 'enabledAt']);
const value = { const configValue = {
name: name as ConfigKeyType, name: name as ConfigKeyType,
enabled, enabled,
enabledAt, enabledAt,
value,
}; };
const hasChanged =
previouslyEnabled !== enabled || previousValue !== configValue.value;
// If enablement changes at all, notify listeners // If enablement changes at all, notify listeners
const currentListeners = listeners[name] || []; const currentListeners = listeners[name] || [];
if (previouslyEnabled !== enabled) { if (hasChanged) {
window.log.info(`Remote Config: Flag ${name} has been enabled`); window.log.info(`Remote Config: Flag ${name} has changed`);
currentListeners.forEach(listener => { currentListeners.forEach(listener => {
listener(value); listener(configValue);
}); });
} }
// Return new configuration object // Return new configuration object
return { return {
...previous, ...acc,
[name]: value, [name]: configValue,
}; };
}, {}); }, {});

View file

@ -62,7 +62,7 @@ type ToggleVerifiedFulfilledActionType = {
payload: ToggleVerifiedAsyncActionType; payload: ToggleVerifiedAsyncActionType;
}; };
export type SafetyNumberActionTypes = export type SafetyNumberActionType =
| GenerateActionType | GenerateActionType
| GenerateFulfilledActionType | GenerateFulfilledActionType
| ToggleVerifiedActionType | ToggleVerifiedActionType
@ -161,7 +161,7 @@ function getEmptyState(): SafetyNumberStateType {
export function reducer( export function reducer(
state: SafetyNumberStateType = getEmptyState(), state: SafetyNumberStateType = getEmptyState(),
action: SafetyNumberActionTypes action: SafetyNumberActionType
): SafetyNumberStateType { ): SafetyNumberStateType {
if (action.type === TOGGLE_VERIFIED_PENDING) { if (action.type === TOGGLE_VERIFIED_PENDING) {
const { contact } = action.payload; const { contact } = action.payload;

View file

@ -106,7 +106,7 @@ type SearchInConversationActionType = {
}; };
}; };
export type SEARCH_TYPES = export type SearchActionType =
| SearchMessagesResultsKickoffActionType | SearchMessagesResultsKickoffActionType
| SearchDiscussionsResultsKickoffActionType | SearchDiscussionsResultsKickoffActionType
| SearchMessagesResultsFulfilledActionType | SearchMessagesResultsFulfilledActionType
@ -336,7 +336,7 @@ function getEmptyState(): SearchStateType {
// tslint:disable-next-line cyclomatic-complexity max-func-body-length // tslint:disable-next-line cyclomatic-complexity max-func-body-length
export function reducer( export function reducer(
state: SearchStateType = getEmptyState(), state: SearchStateType = getEmptyState(),
action: SEARCH_TYPES action: SearchActionType
): SearchStateType { ): SearchStateType {
if (action.type === 'SHOW_ARCHIVED_CONVERSATIONS') { if (action.type === 'SHOW_ARCHIVED_CONVERSATIONS') {
return getEmptyState(); return getEmptyState();

View file

@ -32,12 +32,12 @@ import {
} from './ducks/network'; } from './ducks/network';
import { import {
reducer as safetyNumber, reducer as safetyNumber,
SafetyNumberActionTypes, SafetyNumberActionType,
SafetyNumberStateType, SafetyNumberStateType,
} from './ducks/safetyNumber'; } from './ducks/safetyNumber';
import { import {
reducer as search, reducer as search,
SEARCH_TYPES as SearchActionType, SearchActionType,
SearchStateType, SearchStateType,
} from './ducks/search'; } from './ducks/search';
import { import {
@ -73,7 +73,7 @@ export type ActionsType =
| ConversationActionType | ConversationActionType
| ItemsActionType | ItemsActionType
| NetworkActionType | NetworkActionType
| SafetyNumberActionTypes | SafetyNumberActionType
| StickersActionType | StickersActionType
| SearchActionType | SearchActionType
| UpdatesActionType; | UpdatesActionType;

25
ts/state/types.ts Normal file
View file

@ -0,0 +1,25 @@
import { actions as calling } from './ducks/calling';
import { actions as conversations } from './ducks/conversations';
import { actions as emojis } from './ducks/emojis';
import { actions as expiration } from './ducks/expiration';
import { actions as items } from './ducks/items';
import { actions as network } from './ducks/network';
import { actions as safetyNumber } from './ducks/safetyNumber';
import { actions as search } from './ducks/search';
import { actions as stickers } from './ducks/stickers';
import { actions as updates } from './ducks/updates';
import { actions as user } from './ducks/user';
export type ReduxActions = {
calling: typeof calling;
conversations: typeof conversations;
emojis: typeof emojis;
expiration: typeof expiration;
items: typeof items;
network: typeof network;
safetyNumber: typeof safetyNumber;
search: typeof search;
stickers: typeof stickers;
updates: typeof updates;
user: typeof user;
};

View file

@ -30,6 +30,7 @@ import {
getRandomValue, getRandomValue,
splitUuids, splitUuids,
} from '../Crypto'; } from '../Crypto';
import { getUserAgent } from '../util/getUserAgent';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import { v4 as getGuid } from 'uuid'; import { v4 as getGuid } from 'uuid';
@ -243,7 +244,8 @@ function _createSocket(
{ {
certificateAuthority, certificateAuthority,
proxyUrl, proxyUrl,
}: { certificateAuthority: string; proxyUrl?: string } version,
}: { certificateAuthority: string; proxyUrl?: string; version: string }
) { ) {
let requestOptions; let requestOptions;
if (proxyUrl) { if (proxyUrl) {
@ -256,8 +258,10 @@ function _createSocket(
ca: certificateAuthority, ca: certificateAuthority,
}; };
} }
const headers = {
return new WebSocket(url, undefined, undefined, undefined, requestOptions, { 'User-Agent': getUserAgent(version),
};
return new WebSocket(url, undefined, undefined, headers, requestOptions, {
maxReceivedFrameSize: 0x210000, maxReceivedFrameSize: 0x210000,
}); });
} }
@ -366,7 +370,7 @@ async function _promiseAjax(
method: options.type, method: options.type,
body: options.data, body: options.data,
headers: { headers: {
'User-Agent': `Signal Desktop ${options.version}`, 'User-Agent': getUserAgent(options.version),
'X-Signal-Agent': 'OWD', 'X-Signal-Agent': 'OWD',
...options.headers, ...options.headers,
} as HeaderListType, } as HeaderListType,
@ -412,6 +416,13 @@ async function _promiseAjax(
fetch(url, fetchOptions) fetch(url, fetchOptions)
// tslint:disable-next-line max-func-body-length // tslint:disable-next-line max-func-body-length
.then(async response => { .then(async response => {
// Build expired!
if (response.status === 499) {
window.log.error('Error: build expired');
window.storage.put('remoteBuildExpiration', Date.now());
window.reduxActions.expiration.hydrateExpirationStatus(true);
}
let resultPromise; let resultPromise;
if ( if (
(options.responseType === 'json' || (options.responseType === 'json' ||
@ -797,7 +808,9 @@ export type WebAPIType = {
options: GroupCredentialsType options: GroupCredentialsType
) => Promise<string>; ) => Promise<string>;
whoami: () => Promise<any>; whoami: () => Promise<any>;
getConfig: () => Promise<Array<{ name: string; enabled: boolean }>>; getConfig: () => Promise<
Array<{ name: string; enabled: boolean; value: string | null }>
>;
}; };
export type SignedPreKeyType = { export type SignedPreKeyType = {
@ -1021,7 +1034,7 @@ export function initialize({
async function getConfig() { async function getConfig() {
type ResType = { type ResType = {
config: Array<{ name: string; enabled: boolean }>; config: Array<{ name: string; enabled: boolean; value: string | null }>;
}; };
const res: ResType = await _ajax({ const res: ResType = await _ajax({
call: 'config', call: 'config',
@ -2023,7 +2036,7 @@ export function initialize({
return _createSocket( return _createSocket(
`${fixedScheme}/v1/websocket/?login=${login}&password=${pass}&agent=OWD&version=${clientVersion}`, `${fixedScheme}/v1/websocket/?login=${login}&password=${pass}&agent=OWD&version=${clientVersion}`,
{ certificateAuthority, proxyUrl } { certificateAuthority, proxyUrl, version }
); );
} }
@ -2037,7 +2050,7 @@ export function initialize({
return _createSocket( return _createSocket(
`${fixedScheme}/v1/websocket/provisioning/?agent=OWD&version=${clientVersion}`, `${fixedScheme}/v1/websocket/provisioning/?agent=OWD&version=${clientVersion}`,
{ certificateAuthority, proxyUrl } { certificateAuthority, proxyUrl, version }
); );
} }

View file

@ -22,6 +22,7 @@ import { app, BrowserWindow, dialog, ipcMain } from 'electron';
import { getTempPath } from '../../app/attachments'; import { getTempPath } from '../../app/attachments';
import { Dialogs } from '../types/Dialogs'; import { Dialogs } from '../types/Dialogs';
import { getUserAgent } from '../util/getUserAgent';
// @ts-ignore // @ts-ignore
import * as packageJson from '../../package.json'; import * as packageJson from '../../package.json';
@ -324,7 +325,7 @@ function getGotOptions(): GotOptions<null> {
ca, ca,
headers: { headers: {
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
'User-Agent': `Signal Desktop ${packageJson.version}`, 'User-Agent': getUserAgent(packageJson.version),
}, },
useElectronNet: false, useElectronNet: false,
}; };

3
ts/util/getUserAgent.ts Normal file
View file

@ -0,0 +1,3 @@
export function getUserAgent(appVersion: string): string {
return `Signal-Desktop/${appVersion}`;
}

View file

@ -11,11 +11,13 @@ import {
getPlaceholder as getSafetyNumberPlaceholder, getPlaceholder as getSafetyNumberPlaceholder,
} from './safetyNumber'; } from './safetyNumber';
import { getStringForProfileChange } from './getStringForProfileChange'; import { getStringForProfileChange } from './getStringForProfileChange';
import { getUserAgent } from './getUserAgent';
import { hasExpired } from './hasExpired'; import { hasExpired } from './hasExpired';
import { isFileDangerous } from './isFileDangerous'; import { isFileDangerous } from './isFileDangerous';
import { makeLookup } from './makeLookup'; import { makeLookup } from './makeLookup';
import { migrateColor } from './migrateColor'; import { migrateColor } from './migrateColor';
import { missingCaseError } from './missingCaseError'; import { missingCaseError } from './missingCaseError';
import { parseRemoteClientExpiration } from './parseRemoteClientExpiration';
import * as zkgroup from './zkgroup'; import * as zkgroup from './zkgroup';
export { export {
@ -28,12 +30,14 @@ export {
generateSecurityNumber, generateSecurityNumber,
getSafetyNumberPlaceholder, getSafetyNumberPlaceholder,
getStringForProfileChange, getStringForProfileChange,
getUserAgent,
GoogleChrome, GoogleChrome,
hasExpired, hasExpired,
isFileDangerous, isFileDangerous,
makeLookup, makeLookup,
migrateColor, migrateColor,
missingCaseError, missingCaseError,
parseRemoteClientExpiration,
Registration, Registration,
zkgroup, zkgroup,
}; };

View file

@ -12920,7 +12920,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "ts/textsecure/WebAPI.js", "path": "ts/textsecure/WebAPI.js",
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(quote, 'binary', window.dcodeIO.ByteBuffer.LITTLE_ENDIAN);", "line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(quote, 'binary', window.dcodeIO.ByteBuffer.LITTLE_ENDIAN);",
"lineNumber": 1213, "lineNumber": 1223,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z" "updated": "2020-09-08T23:07:22.682Z"
}, },
@ -12928,8 +12928,8 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "ts/textsecure/WebAPI.ts", "path": "ts/textsecure/WebAPI.ts",
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(", "line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(",
"lineNumber": 2063, "lineNumber": 2076,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z" "updated": "2020-09-08T23:07:22.682Z"
} }
] ]

View file

@ -0,0 +1,33 @@
import semver from 'semver';
type RemoteVersion = {
'min-version': string;
iso8601: string;
};
export function parseRemoteClientExpiration(
remoteExpirationValue: string
): number | null {
const remoteVersions = JSON.parse(remoteExpirationValue) || [];
const ourVersion = window.getVersion();
return remoteVersions.reduce(
(acc: number | null, remoteVersion: RemoteVersion) => {
const minVersion = remoteVersion['min-version'];
const { iso8601 } = remoteVersion;
if (semver.gt(minVersion, ourVersion)) {
const timestamp = new Date(iso8601).getTime();
if (!acc) {
return timestamp;
}
return timestamp < acc ? timestamp : acc;
}
return acc;
},
null
);
}

3
ts/window.d.ts vendored
View file

@ -22,6 +22,7 @@ import { LocalizerType } from './types/Util';
import { CallHistoryDetailsType } from './types/Calling'; import { CallHistoryDetailsType } from './types/Calling';
import { ColorType } from './types/Colors'; import { ColorType } from './types/Colors';
import { ConversationController } from './ConversationController'; import { ConversationController } from './ConversationController';
import { ReduxActions } from './state/types';
import { SendOptionsType } from './textsecure/SendMessage'; import { SendOptionsType } from './textsecure/SendMessage';
import AccountManager from './textsecure/AccountManager'; import AccountManager from './textsecure/AccountManager';
import Data from './sql/Client'; import Data from './sql/Client';
@ -49,6 +50,7 @@ declare global {
getSocketStatus: () => number; getSocketStatus: () => number;
getTitle: () => string; getTitle: () => string;
waitForEmptyEventQueue: () => Promise<void>; waitForEmptyEventQueue: () => Promise<void>;
getVersion: () => string;
showCallingPermissionsPopup: (forCamera: boolean) => Promise<void>; showCallingPermissionsPopup: (forCamera: boolean) => Promise<void>;
i18n: LocalizerType; i18n: LocalizerType;
isValidGuid: (maybeGuid: string) => boolean; isValidGuid: (maybeGuid: string) => boolean;
@ -65,6 +67,7 @@ declare global {
}; };
normalizeUuids: (obj: any, paths: Array<string>, context: string) => any; normalizeUuids: (obj: any, paths: Array<string>, context: string) => any;
platform: string; platform: string;
reduxActions: ReduxActions;
restart: () => void; restart: () => void;
showWindow: () => void; showWindow: () => void;
setBadgeCount: (count: number) => void; setBadgeCount: (count: number) => void;