Refactor Signal app routing

This commit is contained in:
Jamie Kyle 2023-11-02 12:42:31 -07:00 committed by GitHub
parent 86e6c2499c
commit 3ef0d221d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1347 additions and 1044 deletions

View file

@ -9,6 +9,8 @@ import * as log from './logging/log';
import { explodePromise } from './util/explodePromise';
import { ipcInvoke } from './sql/channels';
import { SECOND } from './util/durations';
import { isSignalRoute } from './util/signalRoutes';
import { strictAssert } from './util/assert';
type ResolveType = (data: unknown) => void;
@ -28,6 +30,7 @@ export type CIType = {
ignorePastEvents?: boolean;
}
) => unknown;
openSignalRoute(url: string): Promise<void>;
};
export function getCI(deviceName: string): CIType {
@ -133,6 +136,20 @@ export function getCI(deviceName: string): CIType {
return window.ConversationController.getConversationId(address);
}
async function openSignalRoute(url: string) {
strictAssert(
isSignalRoute(url),
`openSignalRoute: not a valid signal route ${url}`
);
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.hidden = true;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
return {
deviceName,
getConversationId,
@ -141,5 +158,6 @@ export function getCI(deviceName: string): CIType {
setProvisioningURL,
solveChallenge,
waitForEvent,
openSignalRoute,
};
}

View file

@ -95,6 +95,7 @@ import { ReadStatus } from './messages/MessageReadStatus';
import { SeenStatus } from './MessageSeenStatus';
import { incrementMessageCounter } from './util/incrementMessageCounter';
import { sleep } from './util/sleep';
import { groupInvitesRoute } from './util/signalRoutes';
type AccessRequiredEnum = Proto.AccessControl.AccessRequired;
@ -383,16 +384,16 @@ export function buildGroupLink(
},
}).finish();
const hash = toWebSafeBase64(Bytes.toBase64(bytes));
const inviteCode = toWebSafeBase64(Bytes.toBase64(bytes));
return `https://signal.group/#${hash}`;
return groupInvitesRoute.toWebUrl({ inviteCode }).toString();
}
export function parseGroupLink(hash: string): {
export function parseGroupLink(value: string): {
masterKey: string;
inviteLinkPassword: string;
} {
const base64 = fromWebSafeBase64(hash);
const base64 = fromWebSafeBase64(value);
const buffer = Bytes.fromBase64(base64);
const inviteLinkProto = Proto.GroupInviteLink.decode(buffer);

View file

@ -30,11 +30,11 @@ import { isGroupV1 } from '../util/whatTypeOfConversation';
import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper';
import { sleep } from '../util/sleep';
export async function joinViaLink(hash: string): Promise<void> {
export async function joinViaLink(value: string): Promise<void> {
let inviteLinkPassword: string;
let masterKey: string;
try {
({ inviteLinkPassword, masterKey } = parseGroupLink(hash));
({ inviteLinkPassword, masterKey } = parseGroupLink(value));
} catch (error: unknown) {
const errorString = Errors.toLogFormat(error);
log.error(`joinViaLink: Failed to parse group link ${errorString}`);

View file

@ -36,8 +36,8 @@ type NotificationDataType = Readonly<{
export type NotificationClickData = Readonly<{
conversationId: string;
messageId?: string;
storyId?: string;
messageId: string | null;
storyId: string | null;
}>;
export type WindowsNotificationData = {
avatarPath?: string;
@ -208,8 +208,8 @@ class NotificationService extends EventEmitter {
window.IPC.showWindow();
window.Events.showConversationViaNotification({
conversationId,
messageId,
storyId,
messageId: messageId ?? null,
storyId: storyId ?? null,
});
} else if (type === NotificationType.IncomingGroupCall) {
window.IPC.showWindow();

View file

@ -18,9 +18,9 @@ import { DEFAULT_CONVERSATION_COLOR } from '../../types/Colors';
import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji';
import { isBeta } from '../../util/version';
import { DurationInSeconds } from '../../util/durations';
import { generateUsernameLink } from '../../util/sgnlHref';
import * as Bytes from '../../Bytes';
import { getUserNumber, getUserACI } from './user';
import { contactByEncryptedUsernameRoute } from '../../util/signalRoutes';
const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320;
@ -112,7 +112,9 @@ export const getUsernameLink = createSelector(
const content = Bytes.concatenate([entropy, serverId]);
return generateUsernameLink(Bytes.toBase64(content));
return contactByEncryptedUsernameRoute
.toWebUrl({ encryptedUsername: Bytes.toBase64(content) })
.toString();
}
);

View file

@ -136,6 +136,13 @@ export class App extends EventEmitter {
return this.app.firstWindow();
}
public async openSignalRoute(url: URL | string): Promise<void> {
const window = await this.getWindow();
await window.evaluate(
`window.SignalCI.openSignalRoute(${JSON.stringify(url.toString())})`
);
}
// EventEmitter types
public override on(type: 'close', callback: () => void): this;

View file

@ -9,11 +9,11 @@ import createDebug from 'debug';
import * as durations from '../../util/durations';
import { uuidToBytes } from '../../util/uuidToBytes';
import { generateUsernameLink } from '../../util/sgnlHref';
import { MY_STORY_ID } from '../../types/Stories';
import { Bootstrap } from '../bootstrap';
import type { App } from '../bootstrap';
import { bufferToUuid } from '../helpers';
import { contactByEncryptedUsernameRoute } from '../../util/signalRoutes';
export const debug = createDebug('mock:test:username');
@ -310,9 +310,14 @@ describe('pnp/username', function (this: Mocha.Suite) {
CARL_USERNAME
);
const linkUrl = generateUsernameLink(
Buffer.concat([entropy, uuidToBytes(serverId)]).toString('base64')
);
const linkUrl = contactByEncryptedUsernameRoute
.toWebUrl({
encryptedUsername: Buffer.concat([
entropy,
uuidToBytes(serverId),
]).toString('base64'),
})
.toString();
debug('sending link to Note to Self');
await phone.sendText(desktop, linkUrl, {

View file

@ -0,0 +1,79 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import * as durations from '../../util/durations';
import type { Bootstrap, App } from '../bootstrap';
import {
artAddStickersRoute,
showConversationRoute,
} from '../../util/signalRoutes';
import {
initStorage,
STICKER_PACKS,
storeStickerPacks,
} from '../storage/fixtures';
import { strictAssert } from '../../util/assert';
describe('routing', function (this: Mocha.Suite) {
this.timeout(durations.MINUTE);
let bootstrap: Bootstrap;
let app: App;
beforeEach(async () => {
({ bootstrap, app } = await initStorage());
});
afterEach(async function (this: Mocha.Context) {
await bootstrap.maybeSaveLogs(this.currentTest, app);
await app.close();
await bootstrap.teardown();
});
it('artAddStickersRoute', async () => {
const { server } = bootstrap;
const stickerPack = STICKER_PACKS[0];
await storeStickerPacks(server, [stickerPack]);
const stickerUrl = artAddStickersRoute.toWebUrl({
packId: stickerPack.id.toString('hex'),
packKey: stickerPack.key.toString('hex'),
});
await app.openSignalRoute(stickerUrl);
const page = await app.getWindow();
const title = page.locator(
'.module-sticker-manager__preview-modal__footer--title',
{ hasText: 'Test Stickerpack' }
);
await title.waitFor();
assert.isTrue(await title.isVisible());
});
it('showConversationRoute', async () => {
const { contacts } = bootstrap;
const [friend] = contacts;
const page = await app.getWindow();
await page.locator('#LeftPane').waitFor();
const conversationId = await page.evaluate(
serviceId => window.SignalCI?.getConversationId(serviceId),
friend.toContact().aci
);
strictAssert(
typeof conversationId === 'string',
'conversationId must exist'
);
const conversationUrl = showConversationRoute.toAppUrl({
conversationId,
messageId: null,
storyId: null,
});
await app.openSignalRoute(conversationUrl);
const title = page.locator(
'.module-ConversationHeader__header__info__title',
{ hasText: 'Alice Smith' }
);
await title.waitFor();
assert.isTrue(await title.isVisible());
});
});

View file

@ -2,13 +2,22 @@
// SPDX-License-Identifier: AGPL-3.0-only
import createDebug from 'debug';
import type { Group, PrimaryDevice } from '@signalapp/mock-server';
import type {
Group,
PrimaryDevice,
Server,
StorageStateRecord,
} from '@signalapp/mock-server';
import { StorageState, Proto } from '@signalapp/mock-server';
import path from 'path';
import fs from 'fs/promises';
import { range } from 'lodash';
import { App } from '../playwright';
import { Bootstrap } from '../bootstrap';
import type { BootstrapOptions } from '../bootstrap';
import { MY_STORY_ID } from '../../types/Stories';
import { uuidToBytes } from '../../util/uuidToBytes';
import { artAddStickersRoute } from '../../util/signalRoutes';
export const debug = createDebug('mock:test:storage');
@ -123,3 +132,77 @@ export async function initStorage(
throw error;
}
}
export const FIXTURES = path.join(__dirname, '..', '..', '..', 'fixtures');
export const EMPTY = new Uint8Array(0);
export type StickerPackType = Readonly<{
id: Buffer;
key: Buffer;
stickerCount: number;
}>;
export const STICKER_PACKS: ReadonlyArray<StickerPackType> = [
{
id: Buffer.from('c40ed069cdc2b91eccfccf25e6bcddfc', 'hex'),
key: Buffer.from(
'cefadd6e81c128680aead1711eb5c92c10f63bdfbc78528a4519ba682de396e4',
'hex'
),
stickerCount: 1,
},
{
id: Buffer.from('ae8fedafda4768fd3384d4b3b9db963d', 'hex'),
key: Buffer.from(
'53f4aa8b95e1c2e75afab2328fe67eb6d7affbcd4f50cd4da89dfc325dbc73ca',
'hex'
),
stickerCount: 1,
},
];
export function getStickerPackLink(pack: StickerPackType): string {
return artAddStickersRoute
.toWebUrl({
packId: pack.id.toString('hex'),
packKey: pack.key.toString('hex'),
})
.toString();
}
export function getStickerPackRecordPredicate(
pack: StickerPackType
): (record: StorageStateRecord) => boolean {
return ({ type, record }: StorageStateRecord): boolean => {
if (type !== IdentifierType.STICKER_PACK) {
return false;
}
return pack.id.equals(record.stickerPack?.packId ?? EMPTY);
};
}
export async function storeStickerPacks(
server: Server,
stickerPacks: ReadonlyArray<StickerPackType>
): Promise<void> {
await Promise.all(
stickerPacks.map(async ({ id, stickerCount }) => {
const hexId = id.toString('hex');
await server.storeStickerPack({
id,
manifest: await fs.readFile(
path.join(FIXTURES, `stickerpack-${hexId}.bin`)
),
stickers: await Promise.all(
range(0, stickerCount).map(async index =>
fs.readFile(
path.join(FIXTURES, `stickerpack-${hexId}-${index}.bin`)
)
)
),
});
})
);
}

View file

@ -2,66 +2,21 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { range } from 'lodash';
import { Proto } from '@signalapp/mock-server';
import type { StorageStateRecord } from '@signalapp/mock-server';
import fs from 'fs/promises';
import path from 'path';
import * as durations from '../../util/durations';
import type { App, Bootstrap } from './fixtures';
import { initStorage, debug } from './fixtures';
import {
initStorage,
debug,
STICKER_PACKS,
EMPTY,
storeStickerPacks,
getStickerPackRecordPredicate,
getStickerPackLink,
} from './fixtures';
const { StickerPackOperation } = Proto.SyncMessage;
const FIXTURES = path.join(__dirname, '..', '..', '..', 'fixtures');
const IdentifierType = Proto.ManifestRecord.Identifier.Type;
const EMPTY = new Uint8Array(0);
export type StickerPackType = Readonly<{
id: Buffer;
key: Buffer;
stickerCount: number;
}>;
const STICKER_PACKS: ReadonlyArray<StickerPackType> = [
{
id: Buffer.from('c40ed069cdc2b91eccfccf25e6bcddfc', 'hex'),
key: Buffer.from(
'cefadd6e81c128680aead1711eb5c92c10f63bdfbc78528a4519ba682de396e4',
'hex'
),
stickerCount: 1,
},
{
id: Buffer.from('ae8fedafda4768fd3384d4b3b9db963d', 'hex'),
key: Buffer.from(
'53f4aa8b95e1c2e75afab2328fe67eb6d7affbcd4f50cd4da89dfc325dbc73ca',
'hex'
),
stickerCount: 1,
},
];
function getStickerPackLink(pack: StickerPackType): string {
return (
`https://signal.art/addstickers/#pack_id=${pack.id.toString('hex')}&` +
`pack_key=${pack.key.toString('hex')}`
);
}
function getStickerPackRecordPredicate(
pack: StickerPackType
): (record: StorageStateRecord) => boolean {
return ({ type, record }: StorageStateRecord): boolean => {
if (type !== IdentifierType.STICKER_PACK) {
return false;
}
return pack.id.equals(record.stickerPack?.packId ?? EMPTY);
};
}
describe('storage service', function (this: Mocha.Suite) {
this.timeout(durations.MINUTE);
@ -70,28 +25,8 @@ describe('storage service', function (this: Mocha.Suite) {
beforeEach(async () => {
({ bootstrap, app } = await initStorage());
const { server } = bootstrap;
await Promise.all(
STICKER_PACKS.map(async ({ id, stickerCount }) => {
const hexId = id.toString('hex');
await server.storeStickerPack({
id,
manifest: await fs.readFile(
path.join(FIXTURES, `stickerpack-${hexId}.bin`)
),
stickers: await Promise.all(
range(0, stickerCount).map(async index =>
fs.readFile(
path.join(FIXTURES, `stickerpack-${hexId}-${index}.bin`)
)
)
),
});
})
);
await storeStickerPacks(server, STICKER_PACKS);
});
afterEach(async function (this: Mocha.Context) {

View file

@ -1,27 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { size } from '../../util/iterables';
import { getProvisioningUrl } from '../../util/getProvisioningUrl';
// It'd be nice to run these tests in the renderer, too, but [Chromium's `URL` doesn't
// handle `sgnl:` links correctly][0].
//
// [0]: https://bugs.chromium.org/p/chromium/issues/detail?id=869291
describe('getProvisioningUrl', () => {
it('returns a URL with a UUID and public key', () => {
const uuid = 'a08bf1fd-1799-427f-a551-70af747e3956';
const publicKey = new Uint8Array([9, 8, 7, 6, 5, 4, 3]);
const result = getProvisioningUrl(uuid, publicKey);
const resultUrl = new URL(result);
assert.strictEqual(resultUrl.protocol, 'sgnl:');
assert.strictEqual(resultUrl.host, 'linkdevice');
assert.strictEqual(size(resultUrl.searchParams.entries()), 2);
assert.strictEqual(resultUrl.searchParams.get('uuid'), uuid);
assert.strictEqual(resultUrl.searchParams.get('pub_key'), 'CQgHBgUEAw==');
});
});

View file

@ -1,530 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import Sinon from 'sinon';
import type { LoggerType } from '../../types/Logging';
import {
isSgnlHref,
isCaptchaHref,
isSignalHttpsLink,
parseSgnlHref,
parseCaptchaHref,
parseE164FromSignalDotMeHash,
parseUsernameBase64FromSignalDotMeHash,
parseSignalHttpsLink,
generateUsernameLink,
rewriteSignalHrefsIfNecessary,
} from '../../util/sgnlHref';
function shouldNeverBeCalled() {
assert.fail('This should never be called');
}
const explodingLogger: LoggerType = {
fatal: shouldNeverBeCalled,
error: shouldNeverBeCalled,
warn: shouldNeverBeCalled,
info: shouldNeverBeCalled,
debug: shouldNeverBeCalled,
trace: shouldNeverBeCalled,
};
describe('sgnlHref', () => {
[
{ 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;
assert.isFalse(check(castToString(undefined), logger));
assert.isFalse(check(castToString(null), logger));
assert.isFalse(check(castToString(123), logger));
Sinon.assert.calledThrice(logger.warn);
});
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 "${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 "${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(check(`${protocol}:foo`, 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(check(invalid, explodingLogger));
const valid = new URL(`${protocol}://example`);
assert.isTrue(check(valid, explodingLogger));
});
});
});
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/#pack_id=234234&pack_key=342342',
explodingLogger
)
);
assert.isFalse(
isSignalHttpsLink(
'sgnl://signal.art/addstickers/#pack_id=234234&pack_key=342342',
explodingLogger
)
);
assert.isFalse(
isSignalHttpsLink(
'signal://signal.group/#AD234Dq342dSDJWE',
explodingLogger
)
);
});
it('returns false if missing path/hash/query', () => {
assert.isFalse(
isSignalHttpsLink('https://signal.group/', explodingLogger)
);
assert.isFalse(isSignalHttpsLink('https://signal.art/', explodingLogger));
assert.isFalse(isSignalHttpsLink('https://signal.me/', explodingLogger));
});
it('returns false if the URL is not a valid Signal URL', () => {
assert.isFalse(isSignalHttpsLink('https://signal.org', explodingLogger));
assert.isFalse(isSignalHttpsLink('https://example.com', explodingLogger));
});
it('returns true if the protocol is "https:"', () => {
assert.isTrue(
isSignalHttpsLink(
'https://signal.group/#AD234Dq342dSDJWE',
explodingLogger
)
);
assert.isTrue(
isSignalHttpsLink(
'https://signal.group/AD234Dq342dSDJWE',
explodingLogger
)
);
assert.isTrue(
isSignalHttpsLink(
'https://signal.group/?AD234Dq342dSDJWE',
explodingLogger
)
);
assert.isTrue(
isSignalHttpsLink(
'https://signal.art/addstickers/#pack_id=234234&pack_key=342342',
explodingLogger
)
);
assert.isTrue(
isSignalHttpsLink(
'HTTPS://signal.art/addstickers/#pack_id=234234&pack_key=342342',
explodingLogger
)
);
assert.isTrue(
isSignalHttpsLink('https://signal.me/#p/+32423432', 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/#AD234Dq342dSDJWE',
explodingLogger
)
);
});
it('accepts URL objects', () => {
const invalid = new URL('sgnl://example.com');
assert.isFalse(isSignalHttpsLink(invalid, explodingLogger));
const valid = new URL('https://signal.art/#AD234Dq342dSDJWE');
assert.isTrue(isSignalHttpsLink(valid, explodingLogger));
});
});
describe('parseSgnlHref', () => {
it('returns a null command for invalid URLs', () => {
['', 'sgnl', 'https://example/?foo=bar'].forEach(href => {
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
command: null,
args: new Map<never, never>(),
hash: undefined,
});
});
});
it('parses the command for URLs with no arguments', () => {
[
'sgnl://foo',
'sgnl://foo/',
'sgnl://foo?',
'SGNL://foo?',
'sgnl://user:pass@foo',
'sgnl://foo/path/data',
].forEach(href => {
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
command: 'foo',
args: new Map<string, string>(),
hash: undefined,
});
});
});
it("parses a command's arguments", () => {
assert.deepEqual(
parseSgnlHref(
'sgnl://Foo?bar=baz&qux=Quux&num=123&empty=&encoded=hello%20world',
explodingLogger
),
{
command: 'Foo',
args: new Map([
['bar', 'baz'],
['qux', 'Quux'],
['num', '123'],
['empty', ''],
['encoded', 'hello world'],
]),
hash: undefined,
}
);
});
it('treats the port as part of the command', () => {
assert.propertyVal(
parseSgnlHref('sgnl://foo:1234', explodingLogger),
'command',
'foo:1234'
);
});
it('ignores duplicate query parameters', () => {
assert.deepPropertyVal(
parseSgnlHref('sgnl://x?foo=bar&foo=totally-ignored', explodingLogger),
'args',
new Map([['foo', 'bar']])
);
});
it('includes hash', () => {
[
'sgnl://foo?bar=baz#somehash',
'sgnl://user:pass@foo?bar=baz#somehash',
].forEach(href => {
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
command: 'foo',
args: new Map([['bar', 'baz']]),
hash: 'somehash',
});
});
});
it('ignores other parts of the URL', () => {
[
'sgnl://foo?bar=baz',
'sgnl://foo/?bar=baz',
'sgnl://foo/lots/of/path?bar=baz',
'sgnl://user:pass@foo?bar=baz',
].forEach(href => {
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
command: 'foo',
args: new Map([['bar', 'baz']]),
hash: undefined,
});
});
});
it("doesn't do anything fancy with arrays or objects in the query string", () => {
// The `qs` module does things like this, which we don't want.
assert.deepPropertyVal(
parseSgnlHref('sgnl://x?foo[]=bar&foo[]=baz', explodingLogger),
'args',
new Map([['foo[]', 'bar']])
);
assert.deepPropertyVal(
parseSgnlHref('sgnl://x?foo[bar][baz]=foobarbaz', explodingLogger),
'args',
new Map([['foo[bar][baz]', 'foobarbaz']])
);
});
});
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('parseE164FromSignalDotMeHash', () => {
it('returns undefined for invalid inputs', () => {
[
'',
' p/+18885551234',
'p/+18885551234 ',
'x/+18885551234',
'p/+notanumber',
'p/7c7e87a0-3b74-4efd-9a00-6eb8b1dd5be8',
'p/+08885551234',
'p/18885551234',
].forEach(hash => {
assert.isUndefined(parseE164FromSignalDotMeHash(hash));
});
});
it('returns the E164 for valid inputs', () => {
assert.strictEqual(
parseE164FromSignalDotMeHash('p/+18885551234'),
'+18885551234'
);
assert.strictEqual(
parseE164FromSignalDotMeHash('p/+441632960104'),
'+441632960104'
);
});
});
describe('parseUsernameBase64FromSignalDotMeHash', () => {
it('returns undefined for invalid inputs', () => {
['', ' eu/+18885551234', 'z/18885551234'].forEach(hash => {
assert.isUndefined(parseUsernameBase64FromSignalDotMeHash(hash));
});
});
it('returns the username for valid inputs', () => {
assert.strictEqual(
parseUsernameBase64FromSignalDotMeHash(
'eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe'
),
'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe'
);
});
});
describe('generateUsernameLink', () => {
it('generates regular link', () => {
assert.strictEqual(
generateUsernameLink(
'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe'
),
'https://signal.me/#eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe'
);
});
it('generates short link', () => {
assert.strictEqual(
generateUsernameLink(
'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe',
{ short: true }
),
'signal.me/#eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe'
);
});
});
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>(),
hash: undefined,
});
});
});
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',
}
);
});
it('handles signal.me links', () => {
assert.deepEqual(
parseSignalHttpsLink(
'https://signal.me/#p/+18885551234',
explodingLogger
),
{
command: 'signal.me',
args: new Map<never, never>(),
hash: 'p/+18885551234',
}
);
});
});
describe('rewriteSignalHrefsIfNecessary', () => {
it('rewrites http://signal.group hrefs, making them use HTTPS', () => {
assert.strictEqual(
rewriteSignalHrefsIfNecessary('http://signal.group/#abc123'),
'https://signal.group/#abc123'
);
});
it('rewrites http://signal.art hrefs, making them use HTTPS', () => {
assert.strictEqual(
rewriteSignalHrefsIfNecessary(
'http://signal.art/addstickers/#pack_id=abc123'
),
'https://signal.art/addstickers/#pack_id=abc123'
);
});
it('rewrites http://signal.me hrefs, making them use HTTPS', () => {
assert.strictEqual(
rewriteSignalHrefsIfNecessary('http://signal.me/#p/+18885551234'),
'https://signal.me/#p/+18885551234'
);
});
it('removes auth if present', () => {
assert.strictEqual(
rewriteSignalHrefsIfNecessary(
'http://user:pass@signal.group/ab?c=d#ef'
),
'https://signal.group/ab?c=d#ef'
);
assert.strictEqual(
rewriteSignalHrefsIfNecessary(
'https://user:pass@signal.group/ab?c=d#ef'
),
'https://signal.group/ab?c=d#ef'
);
});
it('does nothing to other hrefs', () => {
[
// Normal URLs
'http://example.com',
// Already HTTPS
'https://signal.art/addstickers/#pack_id=abc123',
// Different port
'http://signal.group:1234/abc?d=e#fg',
// Different subdomain
'http://subdomain.signal.group/#abcdef',
// Different protocol
'ftp://signal.group/#abc123',
'ftp://user:pass@signal.group/#abc123',
].forEach(href => {
assert.strictEqual(rewriteSignalHrefsIfNecessary(href), href);
});
});
});
});

View file

@ -0,0 +1,205 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import type { ParsedSignalRoute } from '../../util/signalRoutes';
import {
isSignalRoute,
parseSignalRoute,
toSignalRouteAppUrl,
toSignalRouteUrl,
toSignalRouteWebUrl,
} from '../../util/signalRoutes';
describe('signalRoutes', () => {
type CheckConfig = {
hasAppUrl: boolean;
hasWebUrl: boolean;
isRoute: boolean;
};
function createCheck(options: Partial<CheckConfig> = {}) {
const config: CheckConfig = {
hasAppUrl: true,
hasWebUrl: true,
isRoute: true,
...options,
};
// Different than `isRoute` because of normalization
const hasRouteUrl = config.hasAppUrl || config.hasWebUrl;
return function check(input: string, expected: ParsedSignalRoute | null) {
const url = new URL(input);
assert.deepEqual(parseSignalRoute(url), expected);
assert.deepEqual(isSignalRoute(url), config.isRoute);
assert.deepEqual(toSignalRouteUrl(url) != null, hasRouteUrl);
assert.deepEqual(toSignalRouteAppUrl(url) != null, config.hasAppUrl);
assert.deepEqual(toSignalRouteWebUrl(url) != null, config.hasWebUrl);
};
}
it('nonsense', () => {
const check = createCheck({
isRoute: false,
hasAppUrl: false,
hasWebUrl: false,
});
// Charles Entertainment Cheese, what are you doing here?
check('https://www.chuckecheese.com/#p/+1234567890', null);
// Non-route signal urls
check('https://signal.me', null);
check('sgnl://signal.me/#p', null);
check('sgnl://signal.me/#p/', null);
check('sgnl://signal.me/p/+1234567890', null);
check('https://signal.me/?p/+1234567890', null);
});
it('normalize', () => {
const check = createCheck({ isRoute: false, hasAppUrl: true });
check('http://username:password@signal.me:8888/#p/+1234567890', null);
});
it('contactByPhoneNumber', () => {
const result: ParsedSignalRoute = {
key: 'contactByPhoneNumber',
args: { phoneNumber: '+1234567890' },
};
const check = createCheck();
check('https://signal.me/#p/+1234567890', result);
check('https://signal.me#p/+1234567890', result);
check('sgnl://signal.me/#p/+1234567890', result);
check('sgnl://signal.me#p/+1234567890', result);
});
it('contactByEncryptedUsername', () => {
const result: ParsedSignalRoute = {
key: 'contactByEncryptedUsername',
args: { encryptedUsername: 'foobar' },
};
const check = createCheck();
check('https://signal.me/#eu/foobar', result);
check('https://signal.me#eu/foobar', result);
check('sgnl://signal.me/#eu/foobar', result);
check('sgnl://signal.me#eu/foobar', result);
});
it('groupInvites', () => {
const result: ParsedSignalRoute = {
key: 'groupInvites',
args: { inviteCode: 'foobar' },
};
const check = createCheck();
check('https://signal.group/#foobar', result);
check('https://signal.group#foobar', result);
check('sgnl://signal.group/#foobar', result);
check('sgnl://signal.group#foobar', result);
check('sgnl://joingroup/#foobar', result);
check('sgnl://joingroup#foobar', result);
});
it('linkDevice', () => {
const result: ParsedSignalRoute = {
key: 'linkDevice',
args: { uuid: 'foo', pubKey: 'bar' },
};
const check = createCheck({ hasWebUrl: false });
check('sgnl://linkdevice/?uuid=foo&pub_key=bar', result);
check('sgnl://linkdevice?uuid=foo&pub_key=bar', result);
});
it('captcha', () => {
const result: ParsedSignalRoute = {
key: 'captcha',
args: { captchaId: 'foobar' },
};
const check = createCheck({ hasWebUrl: false });
check('signalcaptcha://foobar', result);
});
it('linkCall', () => {
const result: ParsedSignalRoute = {
key: 'linkCall',
args: { key: 'foobar' },
};
const check = createCheck();
check('https://signal.link/call/#key=foobar', result);
check('https://signal.link/call#key=foobar', result);
check('sgnl://signal.link/call/#key=foobar', result);
check('sgnl://signal.link/call#key=foobar', result);
});
it('artAuth', () => {
const result: ParsedSignalRoute = {
key: 'artAuth',
args: { token: 'foo', pubKey: 'bar' },
};
const check = createCheck({ hasWebUrl: false });
check('sgnl://art-auth/?token=foo&pub_key=bar', result);
check('sgnl://art-auth?token=foo&pub_key=bar', result);
});
it('artAddStickers', () => {
const result: ParsedSignalRoute = {
key: 'artAddStickers',
args: { packId: 'foo', packKey: 'bar' },
};
const check = createCheck();
check('https://signal.art/addstickers/#pack_id=foo&pack_key=bar', result);
check('https://signal.art/addstickers#pack_id=foo&pack_key=bar', result);
check('sgnl://addstickers/?pack_id=foo&pack_key=bar', result);
check('sgnl://addstickers?pack_id=foo&pack_key=bar', result);
});
it('showConversation', () => {
const check = createCheck({ isRoute: true, hasWebUrl: false });
const args1 = 'conversationId=abc';
const args2 = 'conversationId=abc&messageId=def';
const args3 = 'conversationId=abc&messageId=def&storyId=ghi';
const result1: ParsedSignalRoute = {
key: 'showConversation',
args: { conversationId: 'abc', messageId: null, storyId: null },
};
const result2: ParsedSignalRoute = {
key: 'showConversation',
args: { conversationId: 'abc', messageId: 'def', storyId: null },
};
const result3: ParsedSignalRoute = {
key: 'showConversation',
args: { conversationId: 'abc', messageId: 'def', storyId: 'ghi' },
};
check(`sgnl://show-conversation/?${args1}`, result1);
check(`sgnl://show-conversation?${args1}`, result1);
check(`sgnl://show-conversation/?${args2}`, result2);
check(`sgnl://show-conversation?${args2}`, result2);
check(`sgnl://show-conversation/?${args3}`, result3);
check(`sgnl://show-conversation?${args3}`, result3);
});
it('startCallLobby', () => {
const result: ParsedSignalRoute = {
key: 'startCallLobby',
args: { conversationId: 'abc' },
};
const check = createCheck({ isRoute: true, hasWebUrl: false });
check('sgnl://start-call-lobby/?conversationId=abc', result);
check('sgnl://start-call-lobby?conversationId=abc', result);
});
it('showWindow', () => {
const result: ParsedSignalRoute = {
key: 'showWindow',
args: {},
};
const check = createCheck({ isRoute: true, hasWebUrl: false });
check('sgnl://show-window/', result);
check('sgnl://show-window', result);
});
it('setIsPresenting', () => {
const result: ParsedSignalRoute = {
key: 'setIsPresenting',
args: {},
};
const check = createCheck({ isRoute: true, hasWebUrl: false });
check('sgnl://set-is-presenting/', result);
check('sgnl://set-is-presenting', result);
});
});

View file

@ -52,12 +52,12 @@ import { isMoreRecentThan, isOlderThan } from '../util/timestamp';
import { ourProfileKeyService } from '../services/ourProfileKey';
import { assertDev, strictAssert } from '../util/assert';
import { getRegionCodeForNumber } from '../util/libphonenumberUtil';
import { getProvisioningUrl } from '../util/getProvisioningUrl';
import { isNotNil } from '../util/isNotNil';
import { missingCaseError } from '../util/missingCaseError';
import { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log';
import type { StorageAccessType } from '../types/Storage';
import { linkDeviceRoute } from '../util/signalRoutes';
type StorageKeyByServiceIdKind = {
[kind in ServiceIdKind]: keyof StorageAccessType;
@ -358,7 +358,12 @@ export default class AccountManager extends EventTarget {
if (!uuid) {
throw new Error('registerSecondDevice: expected a UUID');
}
const url = getProvisioningUrl(uuid, pubKey);
const url = linkDeviceRoute
.toAppUrl({
uuid,
pubKey: Bytes.toBase64(pubKey),
})
.toString();
window.SignalCI?.setProvisioningURL(url);

View file

@ -9,6 +9,7 @@ import { maybeParseUrl } from '../util/url';
import { replaceEmojiWithSpaces } from '../util/emoji';
import type { AttachmentWithHydratedData } from './Attachment';
import { artAddStickersRoute, groupInvitesRoute } from '../util/signalRoutes';
export type LinkPreviewImage = AttachmentWithHydratedData;
@ -95,11 +96,13 @@ export function shouldLinkifyMessage(
}
export function isStickerPack(link = ''): boolean {
return link.startsWith('https://signal.art/addstickers/');
const url = maybeParseUrl(link);
return url?.protocol === 'https:' && artAddStickersRoute.isMatch(url);
}
export function isGroupLink(link = ''): boolean {
return link.startsWith('https://signal.group/');
const url = maybeParseUrl(link);
return url?.protocol === 'https:' && groupInvitesRoute.isMatch(url);
}
export function findLinks(text: string, caretLocation?: number): Array<string> {

View file

@ -35,16 +35,14 @@ import * as durations from './durations';
import type { DurationInSeconds } from './durations';
import { isPhoneNumberSharingEnabled } from './isPhoneNumberSharingEnabled';
import * as Registration from './registration';
import {
parseE164FromSignalDotMeHash,
parseUsernameBase64FromSignalDotMeHash,
} from './sgnlHref';
import { lookupConversationWithoutServiceId } from './lookupConversationWithoutServiceId';
import * as log from '../logging/log';
import { deleteAllMyStories } from './deleteAllMyStories';
import { isEnabled } from '../RemoteConfig';
import type { NotificationClickData } from '../services/notifications';
import { StoryViewModeType, StoryViewTargetType } from '../types/Stories';
import { isValidE164 } from './isValidE164';
import { fromWebSafeBase64 } from './webSafeBase64';
type SentMediaQualityType = 'standard' | 'high';
type ThemeType = 'light' | 'dark' | 'system';
@ -121,9 +119,12 @@ export type IPCEventsCallbacksType = {
resetAllChatColors: () => void;
resetDefaultChatColor: () => void;
showConversationViaNotification: (data: NotificationClickData) => void;
showConversationViaSignalDotMe: (hash: string) => Promise<void>;
showConversationViaSignalDotMe: (
kind: string,
value: string
) => Promise<void>;
showKeyboardShortcuts: () => void;
showGroupViaLink: (x: string) => Promise<void>;
showGroupViaLink: (value: string) => Promise<void>;
showReleaseNotes: () => void;
showStickerPack: (packId: string, key: string) => void;
shutdown: () => Promise<void>;
@ -497,14 +498,14 @@ export function createIPCEvents(
}
window.reduxActions.globalModals.showStickerPackPreview(packId, key);
},
showGroupViaLink: async hash => {
showGroupViaLink: async value => {
// We can get these events even if the user has never linked this instance.
if (!Registration.everDone()) {
log.warn('showGroupViaLink: Not registered, returning early');
return;
}
try {
await window.Signal.Groups.joinViaLink(hash);
await window.Signal.Groups.joinViaLink(value);
} catch (error) {
log.error(
'showGroupViaLink: Ran into an error!',
@ -532,14 +533,14 @@ export function createIPCEvents(
} else {
window.reduxActions.conversations.showConversation({
conversationId,
messageId,
messageId: messageId ?? undefined,
});
}
} else {
window.reduxActions.app.openInbox();
}
},
async showConversationViaSignalDotMe(hash: string) {
async showConversationViaSignalDotMe(kind: string, value: string) {
if (!Registration.everDone()) {
log.info(
'showConversationViaSignalDotMe: Not registered, returning early'
@ -549,45 +550,35 @@ export function createIPCEvents(
const { showUserNotFoundModal } = window.reduxActions.globalModals;
const maybeE164 = parseE164FromSignalDotMeHash(hash);
if (maybeE164) {
const convoId = await lookupConversationWithoutServiceId({
type: 'e164',
e164: maybeE164,
phoneNumber: maybeE164,
showUserNotFoundModal,
setIsFetchingUUID: noop,
});
if (convoId) {
window.reduxActions.conversations.showConversation({
conversationId: convoId,
let conversationId: string | undefined;
if (kind === 'phoneNumber') {
if (isValidE164(value, true)) {
conversationId = await lookupConversationWithoutServiceId({
type: 'e164',
e164: value,
phoneNumber: value,
showUserNotFoundModal,
setIsFetchingUUID: noop,
});
}
} else if (kind === 'encryptedUsername') {
const usernameBase64 = fromWebSafeBase64(value);
const username = await resolveUsernameByLinkBase64(usernameBase64);
if (username != null) {
conversationId = await lookupConversationWithoutServiceId({
type: 'username',
username,
showUserNotFoundModal,
setIsFetchingUUID: noop,
});
return;
}
// We will show not found modal on error
return;
}
const maybeUsernameBase64 = parseUsernameBase64FromSignalDotMeHash(hash);
let username: string | undefined;
if (maybeUsernameBase64) {
username = await resolveUsernameByLinkBase64(maybeUsernameBase64);
}
if (username) {
const convoId = await lookupConversationWithoutServiceId({
type: 'username',
username,
showUserNotFoundModal,
setIsFetchingUUID: noop,
if (conversationId != null) {
window.reduxActions.conversations.showConversation({
conversationId,
});
if (convoId) {
window.reduxActions.conversations.showConversation({
conversationId: convoId,
});
return;
}
// We will show not found modal on error
return;
}

View file

@ -1,14 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as Bytes from '../Bytes';
export function getProvisioningUrl(
uuid: string,
publicKey: Uint8Array
): string {
const url = new URL('sgnl://linkdevice');
url.searchParams.set('uuid', uuid);
url.searchParams.set('pub_key', Bytes.toBase64(publicKey));
return url.toString();
}

View file

@ -1,196 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { LoggerType } from '../types/Logging';
import { maybeParseUrl } from './url';
import { isValidE164 } from './isValidE164';
import { fromWebSafeBase64, toWebSafeBase64 } from './webSafeBase64';
const SIGNAL_HOSTS = new Set(['signal.group', 'signal.art', 'signal.me']);
const SIGNAL_DOT_ME_E164_PREFIX = 'p/';
function parseUrl(value: string | URL, logger: LoggerType): undefined | URL {
if (value instanceof URL) {
return value;
}
if (typeof value === 'string') {
return maybeParseUrl(value);
}
logger.warn('Tried to parse a sgnl:// URL but got an unexpected type');
return undefined;
}
export function isSgnlHref(value: string | URL, logger: LoggerType): boolean {
const url = parseUrl(value, logger);
return Boolean(url?.protocol === 'sgnl:');
}
export function isCaptchaHref(
value: string | URL,
logger: LoggerType
): boolean {
const url = parseUrl(value, logger);
return Boolean(url?.protocol === 'signalcaptcha:');
}
// A link to a signal 'action' domain with private data in path/hash/query. We could
// open a browser, but it will just link back to us. We will parse it locally instead.
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:' &&
SIGNAL_HOSTS.has(url.host) &&
(url.hash || url.pathname !== '/' || url.search)
);
}
type ParsedSgnlHref =
| { command: null; args: Map<never, never>; hash: undefined }
| { command: string; args: Map<string, string>; hash: string | undefined };
export function parseSgnlHref(
href: string,
logger: LoggerType
): ParsedSgnlHref {
const url = parseUrl(href, logger);
if (!url || !isSgnlHref(url, logger)) {
return { command: null, args: new Map<never, never>(), hash: undefined };
}
const args = new Map<string, string>();
url.searchParams.forEach((value, key) => {
if (!args.has(key)) {
args.set(key, value);
}
});
return {
command: url.host,
args,
hash: url.hash ? url.hash.slice(1) : undefined,
};
}
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
): ParsedSgnlHref {
const url = parseUrl(href, logger);
if (!url || !isSignalHttpsLink(url, logger)) {
return { command: null, args: new Map<never, never>(), hash: undefined };
}
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>(), hash: undefined };
}
return {
command: url.pathname.replace(/\//g, ''),
args,
hash: url.hash ? url.hash.slice(1) : undefined,
};
}
if (url.host === 'signal.group' || url.host === 'signal.me') {
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>(), hash: undefined };
}
export function parseE164FromSignalDotMeHash(hash: string): undefined | string {
if (!hash.startsWith(SIGNAL_DOT_ME_E164_PREFIX)) {
return;
}
const maybeE164 = hash.slice(SIGNAL_DOT_ME_E164_PREFIX.length);
return isValidE164(maybeE164, true) ? maybeE164 : undefined;
}
export function parseUsernameBase64FromSignalDotMeHash(
hash: string
): undefined | string {
const match = hash.match(/^eu\/([a-zA-Z0-9_-]{64})$/);
if (!match) {
return;
}
return fromWebSafeBase64(match[1]);
}
/**
* Converts `http://signal.group/#abc` to `https://signal.group/#abc`. Does the same for
* other Signal hosts, like signal.me. Does nothing to other URLs. Expects a valid href.
*/
export function rewriteSignalHrefsIfNecessary(href: string): string {
const resultUrl = new URL(href);
const isHttp = resultUrl.protocol === 'http:';
const isHttpOrHttps = isHttp || resultUrl.protocol === 'https:';
if (SIGNAL_HOSTS.has(resultUrl.host) && isHttpOrHttps) {
if (isHttp) {
resultUrl.protocol = 'https:';
}
resultUrl.username = '';
resultUrl.password = '';
return resultUrl.href;
}
return href;
}
export type GenerateUsernameLinkOptionsType = Readonly<{
short?: boolean;
}>;
export function generateUsernameLink(
base64: string,
{ short = false }: GenerateUsernameLinkOptionsType = {}
): string {
const shortVersion = `signal.me/#eu/${toWebSafeBase64(base64)}`;
if (short) {
return shortVersion;
}
return `https://${shortVersion}`;
}

739
ts/util/signalRoutes.ts Normal file
View file

@ -0,0 +1,739 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import 'urlpattern-polyfill';
// We need to use the Node.js version of `URL` because chromium's `URL` doesn't
// support custom protocols correctly.
import { URL } from 'url';
import { z } from 'zod';
import { strictAssert } from './assert';
function toUrl(input: URL | string): URL | null {
if (input instanceof URL) {
return input;
}
try {
return new URL(input);
} catch {
return null;
}
}
/**
* List of protocols that are used by Signal routes.
*/
const SignalRouteProtocols = ['https:', 'sgnl:', 'signalcaptcha:'] as const;
/**
* List of hostnames that are used by Signal routes.
* This doesn't include app-only routes like `linkdevice` or `verify`.
*/
const SignalRouteHostnames = [
'signal.me',
'signal.group',
'signal.link',
'signal.art',
] as const;
/**
* Type to help maintain {@link SignalRouteHostnames}, real hostnames should go there.
*/
type AllHostnamePatterns =
| typeof SignalRouteHostnames[number]
| 'verify'
| 'linkdevice'
| 'addstickers'
| 'art-auth'
| 'joingroup'
| 'show-conversation'
| 'start-call-lobby'
| 'show-window'
| 'set-is-presenting'
| ':captchaId'
| '';
/**
* Uses the `URLPattern` syntax to match URLs.
*/
type PatternString = string & { __pattern?: never };
type PatternInput = {
hash?: PatternString;
search?: PatternString;
};
type URLMatcher = (input: URL) => URLPatternResult | null;
function _pattern(
protocol: typeof SignalRouteProtocols[number],
hostname: AllHostnamePatterns,
pathname: PatternString,
init: PatternInput
): URLMatcher {
strictAssert(protocol.endsWith(':'), 'protocol must end with `:`');
strictAssert(!hostname.endsWith('/'), 'hostname must not end with `/`');
strictAssert(
!(hostname === '' && pathname !== ''),
'hostname cannot be empty string if pathname is not empty string'
);
strictAssert(
!pathname.endsWith('/'),
'pathname trailing slash must be optional `{/}?`'
);
const urlPattern = new URLPattern({
username: '',
password: '',
port: '',
// any of these can be patterns
hostname,
pathname,
search: init.search ?? '',
hash: init.hash ?? '',
} satisfies Omit<Required<URLPatternInit>, 'baseURL' | 'protocol'>);
return function match(input) {
const url = toUrl(input);
if (url == null) {
return null;
}
// We need to check protocol separately because `URL` and `URLPattern` don't
// properly support custom protocols
if (url.protocol !== protocol) {
return null;
}
return urlPattern.exec(url);
};
}
type PartialNullable<T> = {
[P in keyof T]?: T[P] | null;
};
type RouteConfig<Args extends object> = {
patterns: Array<URLMatcher>;
schema: z.ZodType<Args>;
parse(result: URLPatternResult): PartialNullable<Args>;
toWebUrl?(args: Args): URL;
toAppUrl?(args: Args): URL;
};
type SignalRoute<Key extends string, Args extends object> = {
isMatch(input: URL | string): boolean;
fromUrl(input: URL | string): RouteResult<Key, Args> | null;
toWebUrl(args: Args): URL;
toAppUrl(args: Args): URL;
};
type RouteResult<Key extends string, Args extends object> = {
key: Key;
args: Args;
};
let _routeCount = 0;
function _route<Key extends string, Args extends object>(
key: Key,
config: RouteConfig<Args>
): SignalRoute<Key, Args> {
_routeCount += 1;
return {
isMatch(input) {
const url = toUrl(input);
if (url == null) {
return false;
}
return config.patterns.some(matcher => {
return matcher(url) != null;
});
},
fromUrl(input) {
const url = toUrl(input);
if (url == null) {
return null;
}
for (const matcher of config.patterns) {
const result = matcher(url);
if (result) {
return {
key,
args: config.schema.parse(config.parse(result)),
};
}
}
return null;
},
toWebUrl(args) {
if (config.toWebUrl) {
return config.toWebUrl(config.schema.parse(args));
}
throw new Error('Route does not support web URLs');
},
toAppUrl(args) {
if (config.toAppUrl) {
return config.toAppUrl(config.schema.parse(args));
}
throw new Error('Route does not support app URLs');
},
};
}
const paramSchema = z.string().min(1);
const optionalParamSchema = paramSchema.nullish().default(null);
/**
* signal.me by phone number
* @example
* ```ts
* contactByPhoneNumberRoute.toWebUrl({
* phoneNumber: "+1234567890",
* })
* // URL { "https://signal.me/#p/+1234567890" }
* ```
*/
export const contactByPhoneNumberRoute = _route('contactByPhoneNumber', {
patterns: [
_pattern('https:', 'signal.me', '{/}?', { hash: 'p/:phoneNumber' }),
_pattern('sgnl:', 'signal.me', '{/}?', { hash: 'p/:phoneNumber' }),
],
schema: z.object({
phoneNumber: paramSchema, // E164 (with +)
}),
parse(result) {
return {
phoneNumber: paramSchema.parse(result.hash.groups.phoneNumber),
};
},
toWebUrl(args) {
return new URL(`https://signal.me/#p/${args.phoneNumber}`);
},
toAppUrl(args) {
return new URL(`sgnl://signal.me/#p/${args.phoneNumber}`);
},
});
/**
* signal.me by encrypted username
* @example
* ```ts
* contactByEncryptedUsernameRoute.toWebUrl({
* encryptedUsername: "123",
* })
* // URL { "https://signal.me/#eu/123" }
* ```
*/
export const contactByEncryptedUsernameRoute = _route(
'contactByEncryptedUsername',
{
patterns: [
_pattern('https:', 'signal.me', '{/}?', {
hash: 'eu/:encryptedUsername',
}),
_pattern('sgnl:', 'signal.me', '{/}?', { hash: 'eu/:encryptedUsername' }),
],
schema: z.object({
encryptedUsername: paramSchema, // base64url (32 bytes of entropy + 16 bytes of big-endian UUID)
}),
parse(result) {
return {
encryptedUsername: result.hash.groups.encryptedUsername,
};
},
toWebUrl(args) {
return new URL(`https://signal.me/#eu/${args.encryptedUsername}`);
},
toAppUrl(args) {
return new URL(`sgnl://signal.me/#eu/${args.encryptedUsername}`);
},
}
);
/**
* Group invites
* @example
* ```ts
* groupInvitesRoute.toWebUrl({
* inviteCode: "123",
* })
* // URL { "https://signal.group/#123" }
* ```
*/
export const groupInvitesRoute = _route('groupInvites', {
patterns: [
_pattern('https:', 'signal.group', '{/}?', {
hash: ':inviteCode([^\\/]+)',
}),
_pattern('sgnl:', 'signal.group', '{/}?', {
hash: ':inviteCode([^\\/]+)',
}),
_pattern('sgnl:', 'joingroup', '{/}?', { hash: ':inviteCode([^\\/]+)' }),
],
schema: z.object({
inviteCode: paramSchema, // base64url (GroupInviteLink proto)
}),
parse(result) {
return {
inviteCode: result.hash.groups.inviteCode,
};
},
toWebUrl(args) {
return new URL(`https://signal.group/#${args.inviteCode}`);
},
toAppUrl(args) {
return new URL(`sgnl://signal.group/#${args.inviteCode}`);
},
});
/**
* Device linking QR code
* @example
* ```ts
* linkDeviceRoute.toAppUrl({
* uuid: "123",
* pubKey: "abc",
* })
* // URL { "sgnl://linkdevice?uuid=123&pub_key=abc" }
* ```
*/
export const linkDeviceRoute = _route('linkDevice', {
patterns: [_pattern('sgnl:', 'linkdevice', '{/}?', { search: ':params' })],
schema: z.object({
uuid: paramSchema, // base64url?
pubKey: paramSchema, // percent-encoded base64 (with padding) of PublicKey with type byte included
}),
parse(result) {
const params = new URLSearchParams(result.search.groups.params);
return {
uuid: params.get('uuid'),
pubKey: params.get('pub_key'),
};
},
toAppUrl(args) {
const params = new URLSearchParams({
uuid: args.uuid,
pub_key: args.pubKey,
});
return new URL(`sgnl://linkdevice?${params.toString()}`);
},
});
/**
* Captchas
* @example
* ```ts
* captchaRoute.toAppUrl({
* captchaId: "123",
* })
* // URL { "signalcaptcha://123" }
* ```
*/
export const captchaRoute = _route('captcha', {
patterns: [_pattern('signalcaptcha:', ':captchaId', '', {})],
schema: z.object({
captchaId: paramSchema, // opaque
}),
parse(result) {
return {
captchaId: result.hostname.groups.captchaId,
};
},
toAppUrl(args) {
return new URL(`signalcaptcha://${args.captchaId}`);
},
});
/**
* Join a call with a link.
* @example
* ```ts
* linkCallRoute.toWebUrl({
* key: "123",
* })
* // URL { "https://signal.link/call#key=123" }
*/
export const linkCallRoute = _route('linkCall', {
patterns: [
_pattern('https:', 'signal.link', '/call{/}?', { hash: ':params' }),
_pattern('sgnl:', 'signal.link', '/call{/}?', { hash: ':params' }),
],
schema: z.object({
key: paramSchema, // ConsonantBase16
}),
parse(result) {
const params = new URLSearchParams(result.hash.groups.params);
return {
key: params.get('key'),
};
},
toWebUrl(args) {
const params = new URLSearchParams({ key: args.key });
return new URL(`https://signal.link/call#${params.toString()}`);
},
toAppUrl(args) {
const params = new URLSearchParams({ key: args.key });
return new URL(`sgnl://signal.link/call#${params.toString()}`);
},
});
/**
* Sticker packs
* @example
* ```ts
* artAddStickersRoute.toWebUrl({
* packId: "123",
* packKey: "abc",
* })
* // URL { "https://signal.art/addstickers#pack_id=123&pack_key=abc" }
* ```
*/
export const artAddStickersRoute = _route('artAddStickers', {
patterns: [
_pattern('https:', 'signal.art', '/addstickers{/}?', { hash: ':params' }),
_pattern('sgnl:', 'addstickers', '{/}?', { search: ':params' }),
],
schema: z.object({
packId: paramSchema, // hexadecimal
packKey: paramSchema, // hexadecimal
}),
parse(result) {
const params = new URLSearchParams(
result.hash.groups.params ?? result.search.groups.params
);
return {
packId: params.get('pack_id'),
packKey: params.get('pack_key'),
};
},
toWebUrl(args) {
const params = new URLSearchParams({
pack_id: args.packId,
pack_key: args.packKey,
});
return new URL(`https://signal.art/addstickers#${params.toString()}`);
},
toAppUrl(args) {
const params = new URLSearchParams({
pack_id: args.packId,
pack_key: args.packKey,
});
return new URL(`sgnl://addstickers?${params.toString()}`);
},
});
/**
* Art Service Authentication
* @example
* ```ts
* artAuthRoute.toAppUrl({
* token: "123",
* pubKey: "abc",
* })
* // URL { "sgnl://art-auth?token=123&pub_key=abc" }
*/
export const artAuthRoute = _route('artAuth', {
patterns: [_pattern('sgnl:', 'art-auth', '{/}?', { search: ':params' })],
schema: z.object({
token: paramSchema, // opaque
pubKey: paramSchema, // base64url
}),
parse(result) {
const params = new URLSearchParams(result.search.groups.params);
return {
token: params.get('token'),
pubKey: params.get('pub_key'),
};
},
toAppUrl(args) {
const params = new URLSearchParams({
token: args.token,
pub_key: args.pubKey,
});
return new URL(`sgnl://art-auth?${params.toString()}`);
},
});
/**
* Show a conversation
* @example
* ```ts
* showConversationRoute.toAppUrl({
* conversationId: "123",
* messageId: "abc",
* storyId: "def",
* })
* // URL { "sgnl://show-conversation?conversationId=123&messageId=abc&storyId=def" }
* ```
*/
export const showConversationRoute = _route('showConversation', {
patterns: [
_pattern('sgnl:', 'show-conversation', '{/}?', { search: ':params' }),
],
schema: z.object({
conversationId: paramSchema,
messageId: optionalParamSchema,
storyId: optionalParamSchema,
}),
parse(result) {
const params = new URLSearchParams(result.search.groups.params);
return {
conversationId: params.get('conversationId'),
messageId: params.get('messageId'),
storyId: params.get('storyId'),
};
},
toAppUrl(args) {
const params = new URLSearchParams({
conversationId: args.conversationId,
});
if (args.messageId != null) {
params.set('messageId', args.messageId);
}
if (args.storyId != null) {
params.set('storyId', args.storyId);
}
return new URL(`sgnl://show-conversation?${params.toString()}`);
},
});
/**
* Start a call lobby
* @example
* ```ts
* startCallLobbyRoute.toAppUrl({
* conversationId: "123",
* })
* // URL { "sgnl://start-call-lobby?conversationId=123" }
* ```
*/
export const startCallLobbyRoute = _route('startCallLobby', {
patterns: [
_pattern('sgnl:', 'start-call-lobby', '{/}?', { search: ':params' }),
],
schema: z.object({
conversationId: paramSchema,
}),
parse(result) {
const params = new URLSearchParams(result.search.groups.params);
return {
conversationId: params.get('conversationId'),
};
},
toAppUrl(args) {
const params = new URLSearchParams({
conversationId: args.conversationId,
});
return new URL(`sgnl://start-call-lobby?${params.toString()}`);
},
});
/**
* Show window
* @example
* ```ts
* showWindowRoute.toAppUrl({})
* // URL { "sgnl://show-window" }
*/
export const showWindowRoute = _route('showWindow', {
patterns: [_pattern('sgnl:', 'show-window', '{/}?', {})],
schema: z.object({}),
parse() {
return {};
},
toAppUrl() {
return new URL('sgnl://show-window');
},
});
/**
* Set is presenting
* @example
* ```ts
* setIsPresentingRoute.toAppUrl({})
* // URL { "sgnl://set-is-presenting" }
* ```
*/
export const setIsPresentingRoute = _route('setIsPresenting', {
patterns: [_pattern('sgnl:', 'set-is-presenting', '{/}?', {})],
schema: z.object({}),
parse() {
return {};
},
toAppUrl() {
return new URL('sgnl://set-is-presenting');
},
});
/**
* Should include all routes for matching purposes.
* @internal
*/
const _allSignalRoutes = [
contactByPhoneNumberRoute,
contactByEncryptedUsernameRoute,
groupInvitesRoute,
linkDeviceRoute,
captchaRoute,
linkCallRoute,
artAddStickersRoute,
artAuthRoute,
showConversationRoute,
startCallLobbyRoute,
showWindowRoute,
setIsPresentingRoute,
] as const;
strictAssert(
_allSignalRoutes.length === _routeCount,
'Forgot to add route to routes list'
);
/**
* A parsed route with the `key` of the route and its parsed `args`.
* @example
* ```ts
* parseSignalRoute(new URL("https://signal.me/#p/+1234567890"))
* // {
* // key: "contactByPhoneNumber",
* // args: { phoneNumber: "+1234567890" },
* // }
* ```
*/
export type ParsedSignalRoute = NonNullable<
ReturnType<typeof _allSignalRoutes[number]['fromUrl']>
>;
/** @internal */
type MatchedSignalRoute = {
route: SignalRoute<string, object>;
parsed: ParsedSignalRoute;
};
/** @internal */
function _matchSignalRoute(input: URL | string): MatchedSignalRoute | null {
const url = toUrl(input);
if (url == null) {
return null;
}
for (const route of _allSignalRoutes) {
const parsed = route.fromUrl(url);
if (parsed != null) {
return { route, parsed };
}
}
return null;
}
/** @internal */
function _normalizeUrl(url: URL | string): URL | null {
const newUrl = toUrl(url);
if (newUrl == null) {
return null;
}
newUrl.port = '';
newUrl.username = '';
newUrl.password = '';
if (newUrl.protocol === 'http:') {
newUrl.protocol = 'https:';
}
return newUrl;
}
/**
* Check if a URL matches a route.
* @example
* ```ts
* isSignalRoute(new URL("https://signal.me/#p/+1234567890")) // true
* isSignalRoute(new URL("sgnl://signal.me/#p/+1234567890")) // true
* isSignalRoute(new URL("https://signal.me")) // false
* isSignalRoute(new URL("https://example.com")) // false
* ```
*/
export function isSignalRoute(input: URL | string): boolean {
return _matchSignalRoute(input) != null;
}
/**
* Maybe parse a URL into a matching route with the 'key' of the route and its
* parsed args.
* If it we can't match it to a route, return null.
* @example
* ```ts
* parseSignalRoute(new URL("https://signal.me/#p/+1234567890"))
* // { key: "contactByPhoneNumber", args: { phoneNumber: "+1234567890" } }
* parseSignalRoute(new URL("sgnl://signal.me/#p/+1234567890"))
* // { key: "contactByPhoneNumber", args: { phoneNumber: "+1234567890" } }
* parseSignalRoute(new URL("https://example.com"))
* // null
* ```
*/
export function parseSignalRoute(
input: URL | string
): ParsedSignalRoute | null {
return _matchSignalRoute(input)?.parsed ?? null;
}
/**
* Maybe normalize a URL into a matching route URL.
* If it we can't match it to a route, return null.
* @example
* ```ts
* toSignalRouteUrl(new URL("http://username:password@signal.me/#p/+1234567890"))
* // URL { "https://signal.me/#p/+1234567890" }
* toSignalRouteUrl(new URL("sgnl://signal.me/#p/+1234567890"))
* // URL { "sgnl://signal.me/#p/+1234567890" }
* toSignalRouteUrl(new URL("https://example.com"))
* // null
* ```
*/
export function toSignalRouteUrl(input: URL | string): URL | null {
const normalizedUrl = _normalizeUrl(input);
if (normalizedUrl == null) {
return null;
}
return _matchSignalRoute(normalizedUrl) != null ? normalizedUrl : null;
}
/**
* Maybe normalize a URL into a matching route **App** URL.
* If it we can't match it to a route, return null.
* @example
* ```ts
* toSignalRouteAppUrl(new URL("https://signal.me/#p/+1234567890"))
* // URL { "sgnl://signal.me/#p/+1234567890" }
* toSignalRouteAppUrl(new URL("https://example.com"))
* // null
* ```
*/
export function toSignalRouteAppUrl(input: URL | string): URL | null {
const normalizedUrl = _normalizeUrl(input);
if (normalizedUrl == null) {
return null;
}
const match = _matchSignalRoute(normalizedUrl);
try {
return match?.route.toAppUrl(match.parsed.args) ?? null;
} catch {
return null;
}
}
/**
* Maybe normalize a URL into a matching route **Web** URL.
* If it we can't match it to a route, return null.
* @example
* ```ts
* toSignalRouteWebUrl(new URL("sgnl://signal.me/#p/+1234567890"))
* // URL { "https://signal.me/#p/+1234567890" }
* toSignalRouteWebUrl(new URL("https://example.com"))
* // null
* ```
*/
export function toSignalRouteWebUrl(input: URL | string): URL | null {
const normalizedUrl = _normalizeUrl(input);
if (normalizedUrl == null) {
return null;
}
const match = _matchSignalRoute(normalizedUrl);
try {
return match?.route.toWebUrl(match.parsed.args) ?? null;
} catch {
return null;
}
}

View file

@ -5,7 +5,7 @@ export function maybeParseUrl(value: string): undefined | URL {
if (typeof value === 'string') {
try {
return new URL(value);
} catch (err) {
} catch {
/* Errors are ignored. */
}
}

View file

@ -289,24 +289,18 @@ ipc.on('delete-all-data', async () => {
});
ipc.on('show-sticker-pack', (_event, info) => {
const { packId, packKey } = info;
const { showStickerPack } = window.Events;
if (showStickerPack) {
showStickerPack(packId, packKey);
}
window.Events.showStickerPack?.(info.packId, info.packKey);
});
ipc.on('show-group-via-link', (_event, info) => {
const { hash } = info;
const { showGroupViaLink } = window.Events;
if (showGroupViaLink) {
void showGroupViaLink(hash);
}
strictAssert(typeof info.value === 'string', 'Got an invalid value over IPC');
drop(window.Events.showGroupViaLink?.(info.value));
});
ipc.on('open-art-creator', () => {
drop(window.Events.openArtCreator());
});
window.openArtCreator = ({
username,
password,
@ -318,8 +312,7 @@ window.openArtCreator = ({
};
ipc.on('authorize-art-creator', (_event, info) => {
const { token, pubKeyBase64 } = info;
window.Events.authorizeArtCreator?.({ token, pubKeyBase64 });
window.Events.authorizeArtCreator?.(info);
});
ipc.on('start-call-lobby', (_event, { conversationId }) => {
@ -328,9 +321,11 @@ ipc.on('start-call-lobby', (_event, { conversationId }) => {
isVideoCall: true,
});
});
ipc.on('show-window', () => {
window.IPC.showWindow();
});
ipc.on('set-is-presenting', () => {
window.reduxActions?.calling?.setPresenting();
});
@ -345,12 +340,13 @@ ipc.on(
}
);
ipc.on('show-conversation-via-signal.me', (_event, info) => {
const { hash } = info;
strictAssert(typeof hash === 'string', 'Got an invalid hash over IPC');
const { kind, value } = info;
strictAssert(typeof kind === 'string', 'Got an invalid kind over IPC');
strictAssert(typeof value === 'string', 'Got an invalid value over IPC');
const { showConversationViaSignalDotMe } = window.Events;
if (showConversationViaSignalDotMe) {
void showConversationViaSignalDotMe(hash);
void showConversationViaSignalDotMe(kind, value);
}
});