mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-08-31 10:23:10 +01:00
392 lines
9.8 KiB
TypeScript
392 lines
9.8 KiB
TypeScript
// Copyright 2022 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import { ProtocolAddress } from '@signalapp/libsignal-client';
|
|
import assert from 'assert';
|
|
import isPlainObject from 'is-plain-obj';
|
|
import crypto from 'node:crypto';
|
|
import util from 'node:util';
|
|
import type { JsonValue } from 'type-fest';
|
|
|
|
import { DAY_IN_SECONDS } from './constants';
|
|
import { type RegistrationId, ServiceIdKind } from './types';
|
|
import { ParsedUrlQuery } from 'node:querystring';
|
|
import { Device } from './data/device';
|
|
|
|
type PromiseQueueEntry<T> = Readonly<{
|
|
value: T;
|
|
cancel: () => void;
|
|
resolvePush?: () => void;
|
|
}>;
|
|
type ResolveEntry<T> = { resolve: (value: T) => void; cancel: () => void };
|
|
|
|
export type PromiseQueueConfig = Readonly<{
|
|
timeout?: number;
|
|
name: string;
|
|
}>;
|
|
|
|
export function generateRandomE164(): string {
|
|
// Generate random number
|
|
let number = '+141549';
|
|
for (let i = 0; i < 5; i++) {
|
|
number += Math.floor(Math.random() * 10).toString();
|
|
}
|
|
return number;
|
|
}
|
|
|
|
export type ParseAuthHeaderResult =
|
|
| {
|
|
username: string;
|
|
password: string;
|
|
error?: undefined;
|
|
}
|
|
| {
|
|
username?: undefined;
|
|
password?: undefined;
|
|
error: string;
|
|
};
|
|
|
|
function splitOnce(input: string, splitter: string): [string, string] | null {
|
|
const index = input.indexOf(splitter);
|
|
if (index === -1) {
|
|
return null;
|
|
}
|
|
return [input.slice(0, index), input.slice(index + 1)];
|
|
}
|
|
|
|
export function parseAuthHeader(
|
|
header?: string,
|
|
options?: { allowEmptyPassword?: boolean },
|
|
): ParseAuthHeaderResult {
|
|
if (!header) {
|
|
return { error: 'Missing Authorization header' };
|
|
}
|
|
|
|
const [basic, base64] = header.split(/\s+/g, 2);
|
|
if (basic?.toLowerCase() !== 'basic') {
|
|
return { error: `Unsupported authorization type ${basic}` };
|
|
}
|
|
|
|
let decoded: string;
|
|
try {
|
|
assert(base64 != null, 'Missing base64 for basic authorization');
|
|
decoded = Buffer.from(base64, 'base64').toString();
|
|
} catch (error) {
|
|
assert(error instanceof Error);
|
|
return { error: error.message };
|
|
}
|
|
|
|
const parts = splitOnce(decoded, ':');
|
|
if (parts == null) {
|
|
return { error: 'Invalid basic auth' };
|
|
}
|
|
const [username, password] = parts;
|
|
|
|
if (!username) {
|
|
return { error: 'Missing username' };
|
|
}
|
|
|
|
if (!password && !options?.allowEmptyPassword) {
|
|
return { error: 'Missing password' };
|
|
}
|
|
|
|
return { username, password };
|
|
}
|
|
|
|
export class PromiseQueue<T> {
|
|
private readonly defaultTimeout: number | undefined;
|
|
private readonly entries: Array<PromiseQueueEntry<T>> = [];
|
|
private readonly resolvers: Array<ResolveEntry<T>> = [];
|
|
private readonly name;
|
|
|
|
constructor(config: PromiseQueueConfig) {
|
|
this.defaultTimeout = config.timeout;
|
|
this.name = config.name;
|
|
}
|
|
|
|
public get size(): number {
|
|
return this.entries.length;
|
|
}
|
|
|
|
public stop(): void {
|
|
while (this.entries.length > 0) {
|
|
const entry = this.entries[0];
|
|
if (entry) {
|
|
entry.cancel();
|
|
this.entries.shift();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
while (this.resolvers.length > 0) {
|
|
const entry = this.resolvers.shift();
|
|
if (entry) {
|
|
entry.cancel();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
public pushAndWait(
|
|
value: T,
|
|
timeout: number | undefined = this.defaultTimeout,
|
|
): { promise: Promise<void>; cancel: () => void } {
|
|
// We were waiting for `.shift()` already
|
|
const resolveEntry = this.resolvers.shift();
|
|
if (resolveEntry) {
|
|
resolveEntry.resolve(value);
|
|
return { promise: Promise.resolve(), cancel: () => undefined };
|
|
}
|
|
|
|
// Not waiting for `.shift()` - queue.
|
|
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
let timer: NodeJS.Timeout | undefined;
|
|
|
|
const cancel = () => {
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
timer = undefined;
|
|
}
|
|
|
|
const index = this.entries.indexOf(entry);
|
|
if (index === -1) {
|
|
return;
|
|
}
|
|
this.entries.splice(index, 1);
|
|
|
|
reject(new Error(`PromiseQueue(${this.name}) pushAndWait timeout`));
|
|
};
|
|
|
|
if (timeout !== undefined) {
|
|
timer = setTimeout(cancel, timeout);
|
|
}
|
|
|
|
const entry = {
|
|
value,
|
|
cancel,
|
|
resolvePush() {
|
|
if (timer !== undefined) {
|
|
clearTimeout(timer);
|
|
}
|
|
timer = undefined;
|
|
|
|
resolve();
|
|
},
|
|
};
|
|
|
|
this.entries.push(entry);
|
|
|
|
return { promise, cancel };
|
|
}
|
|
|
|
public push(value: T): void {
|
|
// We were waiting for `.shift()` already
|
|
const resolveEntry = this.resolvers.shift();
|
|
if (resolveEntry) {
|
|
resolveEntry.resolve(value);
|
|
return;
|
|
}
|
|
|
|
this.entries.push({ value, cancel: () => undefined });
|
|
}
|
|
|
|
public async shift(
|
|
timeout: number | undefined = this.defaultTimeout,
|
|
): Promise<T> {
|
|
// `.pushAndWait()` was called before us
|
|
const entry = this.entries.shift();
|
|
if (entry) {
|
|
if (entry.resolvePush) {
|
|
entry.resolvePush();
|
|
}
|
|
return entry.value;
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let timer: NodeJS.Timeout | undefined;
|
|
|
|
const resolveEntry = (value: T) => {
|
|
if (timer !== undefined) {
|
|
clearTimeout(timer);
|
|
}
|
|
timer = undefined;
|
|
|
|
resolve(value);
|
|
};
|
|
|
|
const cancel = () => {
|
|
const index = this.resolvers.indexOf(entry);
|
|
if (index === -1) {
|
|
throw new Error(
|
|
`PromiseQueue(${this.name}) resolvers bookkeeping error`,
|
|
);
|
|
}
|
|
this.resolvers.splice(index, 1);
|
|
|
|
reject(new Error(`PromiseQueue(${this.name}) shift timeout`));
|
|
};
|
|
|
|
if (timeout !== undefined) {
|
|
timer = setTimeout(cancel, timeout);
|
|
}
|
|
|
|
const entry = {
|
|
cancel,
|
|
resolve: resolveEntry,
|
|
};
|
|
|
|
this.resolvers.push(entry);
|
|
});
|
|
}
|
|
}
|
|
|
|
export function addressToString(address: ProtocolAddress): string {
|
|
return `${address.name()}.${address.deviceId()}`;
|
|
}
|
|
|
|
export function getTodayInSeconds(): number {
|
|
const now = Date.now() / 1000;
|
|
|
|
return now - (now % DAY_IN_SECONDS);
|
|
}
|
|
|
|
export function daysToSeconds(days: number): number {
|
|
return days * DAY_IN_SECONDS;
|
|
}
|
|
|
|
export function generateRegistrationId(): RegistrationId {
|
|
return Math.max(1, (Math.random() * 0x4000) | 0) as RegistrationId;
|
|
}
|
|
|
|
export function generateDevicePassword(): string {
|
|
return crypto.randomBytes(10).toString('hex');
|
|
}
|
|
|
|
export function toBase64(buf: Uint8Array<ArrayBuffer>): string {
|
|
return Buffer.from(buf).toString('base64');
|
|
}
|
|
|
|
export function toURLSafeBase64(buf: Uint8Array<ArrayBuffer>): string {
|
|
return toBase64(buf)
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=+$/g, '');
|
|
}
|
|
|
|
export function fromBase64(base64: string): Buffer<ArrayBuffer> {
|
|
return Buffer.from(base64, 'base64');
|
|
}
|
|
|
|
export function fromURLSafeBase64(base64: string): Buffer<ArrayBuffer> {
|
|
const source = base64.replace(/-/g, '+').replace(/_/g, '/');
|
|
|
|
// Note that `Buffer.from()` ignores padding anyway so we don't need to
|
|
// restore it.
|
|
return fromBase64(source);
|
|
}
|
|
|
|
export function assertJsonValue(root: unknown): asserts root is JsonValue {
|
|
const issues: Array<string> = [];
|
|
|
|
function visit(node: unknown, path: ReadonlyArray<PropertyKey>) {
|
|
if (
|
|
node === null ||
|
|
typeof node === 'boolean' ||
|
|
(typeof node === 'number' && Number.isFinite(node)) ||
|
|
typeof node === 'string'
|
|
) {
|
|
return;
|
|
} else if (Array.isArray(node)) {
|
|
node.forEach((item, index) => {
|
|
visit(item, path.concat(index));
|
|
});
|
|
return;
|
|
} else if (isPlainObject(node)) {
|
|
Object.entries(node).forEach(([key, item]) => {
|
|
// ignore undefined properties
|
|
if (typeof item !== 'undefined') {
|
|
visit(item, path.concat(key));
|
|
}
|
|
});
|
|
} else {
|
|
issues.push(`${path.join('.')}: ${util.inspect(node)}`);
|
|
}
|
|
}
|
|
|
|
visit(root, ['value']);
|
|
|
|
if (issues.length > 0) {
|
|
throw new TypeError(`Invalid JsonValue:\n${issues.join('\n')}`);
|
|
}
|
|
}
|
|
|
|
export function serviceIdKindFromQuery(
|
|
query: Record<string, string> | ParsedUrlQuery | undefined,
|
|
): ServiceIdKind {
|
|
if (query && (query.identity === 'pni' || query.identity === 'PNI')) {
|
|
return ServiceIdKind.PNI;
|
|
}
|
|
|
|
return ServiceIdKind.ACI;
|
|
}
|
|
|
|
export function booleanFromQuery(
|
|
value: string | ReadonlyArray<string> | undefined,
|
|
defaultValue: boolean,
|
|
): boolean {
|
|
const single = typeof value === 'string' ? value : value?.[0];
|
|
if (single === undefined) {
|
|
return defaultValue;
|
|
}
|
|
|
|
return single.toLowerCase() === 'true';
|
|
}
|
|
|
|
export async function getDevicesKeysResult(
|
|
serviceIdKind: ServiceIdKind,
|
|
devices: ReadonlyArray<Device>,
|
|
): Promise<JsonValue> {
|
|
const [primary] = devices;
|
|
assert(primary !== undefined, 'Empty device list');
|
|
|
|
const identityKey = await primary.getIdentityKey(serviceIdKind);
|
|
|
|
return {
|
|
identityKey: Buffer.from(identityKey.serialize()).toString('base64'),
|
|
devices: await Promise.all(
|
|
devices.map(async (device) => {
|
|
const { signedPreKey, preKey, pqPreKey } =
|
|
await device.popSingleUseKey(serviceIdKind);
|
|
return {
|
|
deviceId: device.deviceId,
|
|
registrationId: device.getCheckedRegistrationId(serviceIdKind),
|
|
signedPreKey: {
|
|
keyId: signedPreKey.keyId,
|
|
publicKey: Buffer.from(signedPreKey.publicKey.serialize()).toString(
|
|
'base64',
|
|
),
|
|
signature: signedPreKey.signature.toString('base64'),
|
|
},
|
|
pqPreKey: {
|
|
keyId: pqPreKey.keyId,
|
|
publicKey: Buffer.from(pqPreKey.publicKey.serialize()).toString(
|
|
'base64',
|
|
),
|
|
signature: pqPreKey.signature.toString('base64'),
|
|
},
|
|
preKey: preKey
|
|
? {
|
|
keyId: preKey.keyId,
|
|
publicKey: Buffer.from(preKey.publicKey.serialize()).toString(
|
|
'base64',
|
|
),
|
|
}
|
|
: null,
|
|
};
|
|
}),
|
|
),
|
|
};
|
|
}
|