Skip to content

Commit

Permalink
Create group link previews; don't open Signal links in browser first;…
Browse files Browse the repository at this point in the history
… allow ephemeral download of previously-error'd pack
  • Loading branch information
scottnonnenberg-signal committed Feb 10, 2021
1 parent f832b01 commit e10ae03
Show file tree
Hide file tree
Showing 8 changed files with 338 additions and 33 deletions.
2 changes: 2 additions & 0 deletions js/modules/link_previews.d.ts
Expand Up @@ -7,6 +7,8 @@ export function findLinks(text: string, caretLocation?: number): Array<string>;

export function getDomain(href: string): string;

export function isGroupLink(href: string): boolean;

export function isLinkSneaky(link: string): boolean;

export function isStickerPack(href: string): boolean;
5 changes: 5 additions & 0 deletions js/modules/link_previews.js
Expand Up @@ -12,6 +12,7 @@ const linkify = LinkifyIt();
module.exports = {
findLinks,
getDomain,
isGroupLink,
isLinkSafeToPreview,
isLinkSneaky,
isStickerPack,
Expand All @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion js/modules/stickers.js
Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions main.js
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand Down
45 changes: 25 additions & 20 deletions ts/groups.ts
Expand Up @@ -3809,6 +3809,30 @@ async function applyGroupChange({
};
}

export async function decryptGroupAvatar(
avatarKey: string,
secretParamsBase64: string
): Promise<ArrayBuffer> {
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(
Expand All @@ -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) {
Expand Down
111 changes: 110 additions & 1 deletion ts/test-node/util/sgnlHref_test.ts
Expand Up @@ -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');
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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<never, never>(),
});
});
});

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<never, never>(),
hash: 'data',
}
);
});
});
});
57 changes: 57 additions & 0 deletions ts/util/sgnlHref.ts
Expand Up @@ -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<never, never> }
| { command: string; args: Map<string, string>; hash: string | undefined };
Expand All @@ -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<never, never>() };
}

if (url.host === 'signal.art') {
const hash = url.hash.slice(1);
const hashParams = new URLSearchParams(hash);

const args = new Map<string, string>();
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<never, never>() };
}

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<string, string>(),
hash: url.hash ? url.hash.slice(1) : undefined,
};
}

return { command: null, args: new Map<never, never>() };
}

0 comments on commit e10ae03

Please sign in to comment.