From e10ae03bb75e24a0d00f52439e9556eabf415ea9 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Wed, 10 Feb 2021 14:39:26 -0800 Subject: [PATCH] Create group link previews; don't open Signal links in browser first; allow ephemeral download of previously-error'd pack --- js/modules/link_previews.d.ts | 2 + js/modules/link_previews.js | 5 ++ js/modules/stickers.js | 7 +- main.js | 23 +++++- ts/groups.ts | 45 ++++++----- ts/test-node/util/sgnlHref_test.ts | 111 +++++++++++++++++++++++++- ts/util/sgnlHref.ts | 57 ++++++++++++++ ts/views/conversation_view.ts | 121 ++++++++++++++++++++++++++--- 8 files changed, 338 insertions(+), 33 deletions(-) diff --git a/js/modules/link_previews.d.ts b/js/modules/link_previews.d.ts index 7339759a13c7..7c5c67a9f1b2 100644 --- a/js/modules/link_previews.d.ts +++ b/js/modules/link_previews.d.ts @@ -7,6 +7,8 @@ export function findLinks(text: string, caretLocation?: number): Array; export function getDomain(href: string): string; +export function isGroupLink(href: string): boolean; + export function isLinkSneaky(link: string): boolean; export function isStickerPack(href: string): boolean; diff --git a/js/modules/link_previews.js b/js/modules/link_previews.js index 6d593eaac371..22ceeca31727 100644 --- a/js/modules/link_previews.js +++ b/js/modules/link_previews.js @@ -12,6 +12,7 @@ const linkify = LinkifyIt(); module.exports = { findLinks, getDomain, + isGroupLink, isLinkSafeToPreview, isLinkSneaky, isStickerPack, @@ -34,6 +35,10 @@ function isStickerPack(link) { return (link || '').startsWith('https://signal.art/addstickers/'); } +function isGroupLink(link) { + return (link || '').startsWith('https://signal.group/'); +} + function findLinks(text, caretLocation) { const haveCaretLocation = isNumber(caretLocation); const textLength = text ? text.length : 0; diff --git a/js/modules/stickers.js b/js/modules/stickers.js index fefe0c636148..6c53e3f21d40 100644 --- a/js/modules/stickers.js +++ b/js/modules/stickers.js @@ -354,7 +354,12 @@ async function downloadEphemeralPack(packId, packKey) { } = getReduxStickerActions(); const existingPack = getStickerPack(packId); - if (existingPack) { + if ( + existingPack && + (existingPack.status === 'downloaded' || + existingPack.status === 'installed' || + existingPack.status === 'pending') + ) { log.warn( `Ephemeral download for pack ${redactPackId( packId diff --git a/main.js b/main.js index 11b61f9481d1..ecafd8619076 100644 --- a/main.js +++ b/main.js @@ -99,7 +99,12 @@ const { const { installPermissionsHandler } = require('./app/permissions'); const OS = require('./ts/OS'); const { isBeta } = require('./ts/util/version'); -const { isSgnlHref, parseSgnlHref } = require('./ts/util/sgnlHref'); +const { + isSgnlHref, + isSignalHttpsLink, + parseSgnlHref, + parseSignalHttpsLink, +} = require('./ts/util/sgnlHref'); const { toggleMaximizedBrowserWindow, } = require('./ts/util/toggleMaximizedBrowserWindow'); @@ -227,6 +232,11 @@ async function handleUrl(event, target) { const { protocol, hostname } = url.parse(target); const isDevServer = config.enableHttp && hostname === 'localhost'; // We only want to specially handle urls that aren't requesting the dev server + if (isSgnlHref(target) || isSignalHttpsLink(target)) { + handleSgnlHref(target); + return; + } + if ((protocol === 'http:' || protocol === 'https:') && !isDevServer) { try { await shell.openExternal(target); @@ -1476,7 +1486,16 @@ function getIncomingHref(argv) { } function handleSgnlHref(incomingHref) { - const { command, args, hash } = parseSgnlHref(incomingHref, logger); + let command; + let args; + let hash; + + if (isSgnlHref(incomingHref)) { + ({ command, args, hash } = parseSgnlHref(incomingHref, logger)); + } else if (isSignalHttpsLink(incomingHref)) { + ({ command, args, hash } = parseSignalHttpsLink(incomingHref, logger)); + } + if (command === 'addstickers' && mainWindow && mainWindow.webContents) { console.log('Opening sticker pack from sgnl protocol link'); const packId = args.get('pack_id'); diff --git a/ts/groups.ts b/ts/groups.ts index e7d56c487094..5793f20ff6d2 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -3809,6 +3809,30 @@ async function applyGroupChange({ }; } +export async function decryptGroupAvatar( + avatarKey: string, + secretParamsBase64: string +): Promise { + const sender = window.textsecure.messaging; + if (!sender) { + throw new Error( + 'decryptGroupAvatar: textsecure.messaging is not available!' + ); + } + + const ciphertext = await sender.getGroupAvatar(avatarKey); + const clientZkGroupCipher = getClientZkGroupCipher(secretParamsBase64); + const plaintext = decryptGroupBlob(clientZkGroupCipher, ciphertext); + const blob = window.textsecure.protobuf.GroupAttributeBlob.decode(plaintext); + if (blob.content !== 'avatar') { + throw new Error( + `decryptGroupAvatar: Returned blob had incorrect content: ${blob.content}` + ); + } + + return blob.avatar.toArrayBuffer(); +} + // Ovewriting result.avatar as part of functionality /* eslint-disable no-param-reassign */ export async function applyNewAvatar( @@ -3825,30 +3849,11 @@ export async function applyNewAvatar( // Group has avatar; has it changed? if (newAvatar && (!result.avatar || result.avatar.url !== newAvatar)) { - const sender = window.textsecure.messaging; - if (!sender) { - throw new Error( - 'applyNewAvatar: textsecure.messaging is not available!' - ); - } - if (!result.secretParams) { throw new Error('applyNewAvatar: group was missing secretParams!'); } - const ciphertext = await sender.getGroupAvatar(newAvatar); - const clientZkGroupCipher = getClientZkGroupCipher(result.secretParams); - const plaintext = decryptGroupBlob(clientZkGroupCipher, ciphertext); - const blob = window.textsecure.protobuf.GroupAttributeBlob.decode( - plaintext - ); - if (blob.content !== 'avatar') { - throw new Error( - `applyNewAvatar: Returned blob had incorrect content: ${blob.content}` - ); - } - - const data = blob.avatar.toArrayBuffer(); + const data = await decryptGroupAvatar(newAvatar, result.secretParams); const hash = await computeHash(data); if (result.avatar && result.avatar.path && result.avatar.hash !== hash) { diff --git a/ts/test-node/util/sgnlHref_test.ts b/ts/test-node/util/sgnlHref_test.ts index b09c9ff78707..bcb1806f4357 100644 --- a/ts/test-node/util/sgnlHref_test.ts +++ b/ts/test-node/util/sgnlHref_test.ts @@ -5,7 +5,12 @@ import { assert } from 'chai'; import Sinon from 'sinon'; import { LoggerType } from '../../types/Logging'; -import { isSgnlHref, parseSgnlHref } from '../../util/sgnlHref'; +import { + isSgnlHref, + isSignalHttpsLink, + parseSgnlHref, + parseSignalHttpsLink, +} from '../../util/sgnlHref'; function shouldNeverBeCalled() { assert.fail('This should never be called'); @@ -83,6 +88,67 @@ describe('sgnlHref', () => { }); }); + describe('isSignalHttpsLink', () => { + it('returns false for non-strings', () => { + const logger = { + ...explodingLogger, + warn: Sinon.spy(), + }; + + const castToString = (value: unknown): string => value as string; + + assert.isFalse(isSignalHttpsLink(castToString(undefined), logger)); + assert.isFalse(isSignalHttpsLink(castToString(null), logger)); + assert.isFalse(isSignalHttpsLink(castToString(123), logger)); + + Sinon.assert.calledThrice(logger.warn); + }); + + it('returns false for invalid URLs', () => { + assert.isFalse(isSignalHttpsLink('', explodingLogger)); + assert.isFalse(isSignalHttpsLink('https', explodingLogger)); + assert.isFalse(isSignalHttpsLink('https://::', explodingLogger)); + }); + + it('returns false if the protocol is not "https:"', () => { + assert.isFalse(isSignalHttpsLink('sgnl://signal.art', explodingLogger)); + assert.isFalse( + isSignalHttpsLink( + 'sgnl://signal.art/addstickers/?pack_id=abc', + explodingLogger + ) + ); + assert.isFalse( + isSignalHttpsLink('signal://signal.group', explodingLogger) + ); + }); + + it('returns true if the protocol is "https:"', () => { + assert.isTrue(isSignalHttpsLink('https://signal.group', explodingLogger)); + assert.isTrue(isSignalHttpsLink('https://signal.art', explodingLogger)); + assert.isTrue(isSignalHttpsLink('HTTPS://signal.art', explodingLogger)); + }); + + it('returns false if username or password are set', () => { + assert.isFalse( + isSignalHttpsLink('https://user:password@signal.group', explodingLogger) + ); + }); + + it('returns false if port is set', () => { + assert.isFalse( + isSignalHttpsLink('https://signal.group:1234', explodingLogger) + ); + }); + + it('accepts URL objects', () => { + const invalid = new URL('sgnl://example.com'); + assert.isFalse(isSignalHttpsLink(invalid, explodingLogger)); + const valid = new URL('https://signal.art'); + assert.isTrue(isSignalHttpsLink(valid, explodingLogger)); + }); + }); + describe('parseSgnlHref', () => { it('returns a null command for invalid URLs', () => { ['', 'sgnl', 'https://example/?foo=bar'].forEach(href => { @@ -188,4 +254,47 @@ describe('sgnlHref', () => { ); }); }); + + describe('parseSignalHttpsLink', () => { + it('returns a null command for invalid URLs', () => { + ['', 'https', 'https://example/?foo=bar'].forEach(href => { + assert.deepEqual(parseSignalHttpsLink(href, explodingLogger), { + command: null, + args: new Map(), + }); + }); + }); + + it('handles signal.art links', () => { + assert.deepEqual( + parseSignalHttpsLink( + 'https://signal.art/addstickers/#pack_id=baz&pack_key=Quux&num=123&empty=&encoded=hello%20world', + explodingLogger + ), + { + command: 'addstickers', + args: new Map([ + ['pack_id', 'baz'], + ['pack_key', 'Quux'], + ['num', '123'], + ['empty', ''], + ['encoded', 'hello world'], + ]), + hash: + 'pack_id=baz&pack_key=Quux&num=123&empty=&encoded=hello%20world', + } + ); + }); + + it('handles signal.group links', () => { + assert.deepEqual( + parseSignalHttpsLink('https://signal.group/#data', explodingLogger), + { + command: 'signal.group', + args: new Map(), + hash: 'data', + } + ); + }); + }); }); diff --git a/ts/util/sgnlHref.ts b/ts/util/sgnlHref.ts index 0ae037dafdc5..b9902ec27b41 100644 --- a/ts/util/sgnlHref.ts +++ b/ts/util/sgnlHref.ts @@ -23,6 +23,21 @@ export function isSgnlHref(value: string | URL, logger: LoggerType): boolean { return url !== null && url.protocol === 'sgnl:'; } +export function isSignalHttpsLink( + value: string | URL, + logger: LoggerType +): boolean { + const url = parseUrl(value, logger); + return Boolean( + url && + !url.username && + !url.password && + !url.port && + url.protocol === 'https:' && + (url.host === 'signal.group' || url.host === 'signal.art') + ); +} + type ParsedSgnlHref = | { command: null; args: Map } | { command: string; args: Map; hash: string | undefined }; @@ -48,3 +63,45 @@ export function parseSgnlHref( hash: url.hash ? url.hash.slice(1) : undefined, }; } + +export function parseSignalHttpsLink( + href: string, + logger: LoggerType +): ParsedSgnlHref { + const url = parseUrl(href, logger); + if (!url || !isSignalHttpsLink(url, logger)) { + return { command: null, args: new Map() }; + } + + if (url.host === 'signal.art') { + const hash = url.hash.slice(1); + const hashParams = new URLSearchParams(hash); + + const args = new Map(); + hashParams.forEach((value, key) => { + if (!args.has(key)) { + args.set(key, value); + } + }); + + if (!args.get('pack_id') || !args.get('pack_key')) { + return { command: null, args: new Map() }; + } + + return { + command: url.pathname.replace(/\//g, ''), + args, + hash: url.hash ? url.hash.slice(1) : undefined, + }; + } + + if (url.host === 'signal.group') { + return { + command: url.host, + args: new Map(), + hash: url.hash ? url.hash.slice(1) : undefined, + }; + } + + return { command: null, args: new Map() }; +} diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 0d7e2ce6435e..24eb9eb0627b 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -12,16 +12,18 @@ type GroupV2PendingMemberType = import('../model-types.d').GroupV2PendingMemberT type MediaItemType = import('../components/LightboxGallery').MediaItemType; type MessageType = import('../state/ducks/conversations').MessageType; +type GetLinkPreviewImageResult = { + data: ArrayBuffer; + size: number; + contentType: string; + width?: number; + height?: number; +}; + type GetLinkPreviewResult = { title: string; url: string; - image: { - data: ArrayBuffer; - size: number; - contentType: string; - width: number; - height: number; - }; + image?: GetLinkPreviewImageResult; description: string | null; date: number | null; }; @@ -3580,7 +3582,10 @@ Whisper.ConversationView = Whisper.View.extend({ this.renderLinkPreview(); }, - async getStickerPackPreview(url: any) { + async getStickerPackPreview( + url: string, + abortSignal: any + ): Promise { const isPackDownloaded = (pack: any) => pack && (pack.status === 'downloaded' || pack.status === 'installed'); const isPackValid = (pack: any) => @@ -3604,7 +3609,12 @@ Whisper.ConversationView = Whisper.View.extend({ await window.Signal.Stickers.downloadEphemeralPack(id, keyBase64); } + if (abortSignal.aborted) { + return null; + } + const pack = window.Signal.Stickers.getStickerPack(id); + if (!isPackValid(pack)) { return null; } @@ -3619,6 +3629,10 @@ Whisper.ConversationView = Whisper.View.extend({ ? await window.Signal.Migrations.readTempData(sticker.path) : await window.Signal.Migrations.readStickerData(sticker.path); + if (abortSignal.aborted) { + return null; + } + return { title, url, @@ -3628,6 +3642,8 @@ Whisper.ConversationView = Whisper.View.extend({ size: data.byteLength, contentType: 'image/webp', }, + description: null, + date: null, }; } catch (error) { window.log.error( @@ -3642,12 +3658,99 @@ Whisper.ConversationView = Whisper.View.extend({ } }, + async getGroupPreview( + url: string, + abortSignal: any + ): Promise { + let urlObject; + try { + urlObject = new URL(url); + } catch (err) { + return null; + } + + const { hash } = urlObject; + if (!hash) { + return null; + } + const groupData = hash.slice(1); + + const { + inviteLinkPassword, + masterKey, + } = window.Signal.Groups.parseGroupLink(groupData); + + const fields = window.Signal.Groups.deriveGroupFields( + window.Signal.Crypto.base64ToArrayBuffer(masterKey) + ); + const id = window.Signal.Crypto.arrayBufferToBase64(fields.id); + const logId = `groupv2(${id})`; + const secretParams = window.Signal.Crypto.arrayBufferToBase64( + fields.secretParams + ); + + window.log.info(`getGroupPreview/${logId}: Fetching pre-join state`); + const result = await window.Signal.Groups.getPreJoinGroupInfo( + inviteLinkPassword, + masterKey + ); + + if (abortSignal.aborted) { + return null; + } + + const title = + window.Signal.Groups.decryptGroupTitle(result.title, secretParams) || + window.i18n('unknownGroup'); + const description = + result.memberCount === 1 || result.memberCount === undefined + ? window.i18n('GroupV2--join--member-count--single') + : window.i18n('GroupV2--join--member-count--multiple', { + count: result.memberCount.toString(), + }); + let image: undefined | GetLinkPreviewImageResult; + + if (result.avatar) { + try { + const data = await window.Signal.Groups.decryptGroupAvatar( + result.avatar, + secretParams + ); + image = { + data, + size: data.byteLength, + contentType: 'image/jpeg', + }; + } catch (error) { + const errorString = error && error.stack ? error.stack : error; + window.log.error( + `getGroupPreview/${logId}: Failed to fetch avatar ${errorString}` + ); + } + } + + if (abortSignal.aborted) { + return null; + } + + return { + title, + description, + url, + image, + date: null, + }; + }, + async getPreview( url: string, abortSignal: any ): Promise { if (window.Signal.LinkPreviews.isStickerPack(url)) { - return this.getStickerPackPreview(url); + return this.getStickerPackPreview(url, abortSignal); + } + if (window.Signal.LinkPreviews.isGroupLink(url)) { + return this.getGroupPreview(url, abortSignal); } // This is already checked elsewhere, but we want to be extra-careful.