2024-05-20 18:15:39 +00:00
|
|
|
// Copyright 2024 Signal Messenger, LLC
|
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import { Aci } from '@signalapp/libsignal-client';
|
2024-09-06 17:52:19 +00:00
|
|
|
import type {
|
|
|
|
GroupSendCombinedEndorsementRecord,
|
|
|
|
GroupSendMemberEndorsementRecord,
|
|
|
|
GroupSendToken,
|
|
|
|
} from '../types/GroupSendEndorsements';
|
2024-05-20 18:15:39 +00:00
|
|
|
import {
|
|
|
|
groupSendEndorsementsDataSchema,
|
2024-09-06 17:52:19 +00:00
|
|
|
toGroupSendToken,
|
2024-05-20 18:15:39 +00:00
|
|
|
type GroupSendEndorsementsData,
|
|
|
|
} from '../types/GroupSendEndorsements';
|
2024-09-17 23:11:25 +00:00
|
|
|
import { devDebugger, strictAssert } from './assert';
|
2024-05-20 18:15:39 +00:00
|
|
|
import {
|
|
|
|
GroupSecretParams,
|
2024-09-06 17:52:19 +00:00
|
|
|
GroupSendEndorsement,
|
2024-05-20 18:15:39 +00:00
|
|
|
GroupSendEndorsementsResponse,
|
|
|
|
ServerPublicParams,
|
|
|
|
} from './zkgroup';
|
2024-09-06 17:52:19 +00:00
|
|
|
import type { AciString, ServiceIdString } from '../types/ServiceId';
|
2024-05-20 18:15:39 +00:00
|
|
|
import { fromAciObject } from '../types/ServiceId';
|
|
|
|
import * as log from '../logging/log';
|
|
|
|
import type { GroupV2MemberType } from '../model-types';
|
2024-09-06 17:52:19 +00:00
|
|
|
import { DurationInSeconds } from './durations';
|
|
|
|
import { ToastType } from '../types/Toast';
|
|
|
|
import * as Errors from '../types/errors';
|
2024-09-16 19:37:38 +00:00
|
|
|
import { isTestOrMockEnvironment } from '../environment';
|
|
|
|
import { isAlpha } from './version';
|
2024-05-20 18:15:39 +00:00
|
|
|
|
|
|
|
export function decodeGroupSendEndorsementResponse({
|
|
|
|
groupId,
|
|
|
|
groupSendEndorsementResponse,
|
|
|
|
groupSecretParamsBase64,
|
|
|
|
groupMembersV2,
|
|
|
|
}: {
|
|
|
|
groupId: string;
|
|
|
|
groupSendEndorsementResponse: Uint8Array;
|
|
|
|
groupSecretParamsBase64: string;
|
|
|
|
groupMembersV2: ReadonlyArray<GroupV2MemberType>;
|
|
|
|
}): GroupSendEndorsementsData {
|
2024-07-31 22:32:52 +00:00
|
|
|
const idForLogging = `groupv2(${groupId})`;
|
2024-05-20 18:15:39 +00:00
|
|
|
|
|
|
|
strictAssert(
|
|
|
|
groupSendEndorsementResponse != null,
|
|
|
|
'Missing groupSendEndorsementResponse'
|
|
|
|
);
|
|
|
|
|
|
|
|
strictAssert(
|
|
|
|
groupSendEndorsementResponse.byteLength > 0,
|
|
|
|
'Received empty groupSendEndorsementResponse'
|
|
|
|
);
|
|
|
|
|
|
|
|
const response = new GroupSendEndorsementsResponse(
|
|
|
|
Buffer.from(groupSendEndorsementResponse)
|
|
|
|
);
|
|
|
|
|
2024-09-06 17:52:19 +00:00
|
|
|
const expiration = response.getExpiration().getTime() / 1000;
|
2024-05-20 18:15:39 +00:00
|
|
|
|
|
|
|
const localUser = Aci.parseFromServiceIdString(
|
|
|
|
window.textsecure.storage.user.getCheckedAci()
|
|
|
|
);
|
|
|
|
|
|
|
|
const groupSecretParams = new GroupSecretParams(
|
|
|
|
Buffer.from(groupSecretParamsBase64, 'base64')
|
|
|
|
);
|
|
|
|
|
|
|
|
const serverPublicParams = new ServerPublicParams(
|
|
|
|
Buffer.from(window.getServerPublicParams(), 'base64')
|
|
|
|
);
|
|
|
|
|
|
|
|
const groupMembers = groupMembersV2.map(member => {
|
|
|
|
return Aci.parseFromServiceIdString(member.aci);
|
|
|
|
});
|
|
|
|
|
|
|
|
const receivedEndorsements = response.receiveWithServiceIds(
|
|
|
|
groupMembers,
|
|
|
|
localUser,
|
|
|
|
groupSecretParams,
|
|
|
|
serverPublicParams
|
|
|
|
);
|
|
|
|
|
|
|
|
const { combinedEndorsement, endorsements } = receivedEndorsements;
|
|
|
|
|
|
|
|
strictAssert(
|
|
|
|
endorsements.length === groupMembers.length,
|
|
|
|
`Member endorsements must match input array (expected: ${groupMembers.length}, actual: ${endorsements.length})`
|
|
|
|
);
|
|
|
|
|
|
|
|
log.info(
|
|
|
|
`decodeGroupSendEndorsementResponse: Received endorsements (group: ${idForLogging}, expiration: ${expiration}, members: ${groupMembers.length})`
|
|
|
|
);
|
|
|
|
|
2024-09-06 17:52:19 +00:00
|
|
|
const groupEndorsementsData: GroupSendEndorsementsData = {
|
2024-05-20 18:15:39 +00:00
|
|
|
combinedEndorsement: {
|
|
|
|
groupId,
|
|
|
|
expiration,
|
|
|
|
endorsement: combinedEndorsement.getContents(),
|
|
|
|
},
|
|
|
|
memberEndorsements: groupMembers.map((groupMember, index) => {
|
|
|
|
const endorsement = endorsements.at(index);
|
|
|
|
strictAssert(
|
|
|
|
endorsement != null,
|
|
|
|
`Missing endorsement at index ${index}`
|
|
|
|
);
|
|
|
|
return {
|
|
|
|
groupId,
|
|
|
|
memberAci: fromAciObject(groupMember),
|
|
|
|
expiration,
|
|
|
|
endorsement: endorsement.getContents(),
|
|
|
|
};
|
|
|
|
}),
|
|
|
|
};
|
|
|
|
|
2024-09-06 17:52:19 +00:00
|
|
|
return groupSendEndorsementsDataSchema.parse(groupEndorsementsData);
|
|
|
|
}
|
|
|
|
|
|
|
|
const TWO_DAYS = DurationInSeconds.fromDays(2);
|
|
|
|
const TWO_HOURS = DurationInSeconds.fromHours(2);
|
|
|
|
|
2024-09-16 19:37:38 +00:00
|
|
|
function logServiceIds(list: Iterable<string>) {
|
|
|
|
const items = Array.from(list);
|
|
|
|
if (items.length <= 5) {
|
|
|
|
return items.join(', ');
|
|
|
|
}
|
|
|
|
return `${items.slice(0, 4).join(', ')}, and ${items.length - 4} others`;
|
|
|
|
}
|
|
|
|
|
2024-09-06 17:52:19 +00:00
|
|
|
export function isValidGroupSendEndorsementsExpiration(
|
|
|
|
expiration: number
|
|
|
|
): boolean {
|
|
|
|
const expSeconds = DurationInSeconds.fromMillis(expiration);
|
|
|
|
const nowSeconds = DurationInSeconds.fromMillis(Date.now());
|
|
|
|
const distance = Math.trunc(expSeconds - nowSeconds);
|
|
|
|
return distance <= TWO_DAYS && distance > TWO_HOURS;
|
|
|
|
}
|
|
|
|
|
|
|
|
export class GroupSendEndorsementState {
|
|
|
|
#combinedEndorsement: GroupSendCombinedEndorsementRecord;
|
2024-09-16 19:37:38 +00:00
|
|
|
#otherMemberEndorsements = new Map<
|
2024-09-06 17:52:19 +00:00
|
|
|
ServiceIdString,
|
|
|
|
GroupSendMemberEndorsementRecord
|
|
|
|
>();
|
2024-09-16 19:37:38 +00:00
|
|
|
#otherMemberEndorsementsAcis = new Set<AciString>();
|
2024-09-06 17:52:19 +00:00
|
|
|
#groupSecretParamsBase64: string;
|
|
|
|
#endorsementCache = new WeakMap<Uint8Array, GroupSendEndorsement>();
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
data: GroupSendEndorsementsData,
|
|
|
|
groupSecretParamsBase64: string
|
|
|
|
) {
|
|
|
|
this.#combinedEndorsement = data.combinedEndorsement;
|
2024-09-16 19:37:38 +00:00
|
|
|
this.#groupSecretParamsBase64 = groupSecretParamsBase64;
|
|
|
|
|
|
|
|
const ourAci = window.textsecure.storage.user.getCheckedAci();
|
2024-09-06 17:52:19 +00:00
|
|
|
for (const endorsement of data.memberEndorsements) {
|
2024-09-16 19:37:38 +00:00
|
|
|
if (endorsement.memberAci !== ourAci) {
|
|
|
|
this.#otherMemberEndorsements.set(endorsement.memberAci, endorsement);
|
|
|
|
this.#otherMemberEndorsementsAcis.add(endorsement.memberAci);
|
|
|
|
}
|
2024-09-06 17:52:19 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
isSafeExpirationRange(): boolean {
|
|
|
|
return isValidGroupSendEndorsementsExpiration(
|
|
|
|
this.getExpiration().getTime()
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
getExpiration(): Date {
|
|
|
|
return new Date(this.#combinedEndorsement.expiration * 1000);
|
|
|
|
}
|
|
|
|
|
|
|
|
hasMember(serviceId: ServiceIdString): boolean {
|
2024-09-16 19:37:38 +00:00
|
|
|
return this.#otherMemberEndorsements.has(serviceId);
|
2024-09-06 17:52:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
#toEndorsement(contents: Uint8Array) {
|
|
|
|
let endorsement = this.#endorsementCache.get(contents);
|
|
|
|
if (endorsement == null) {
|
|
|
|
endorsement = new GroupSendEndorsement(Buffer.from(contents));
|
|
|
|
this.#endorsementCache.set(contents, endorsement);
|
|
|
|
}
|
|
|
|
return endorsement;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Strategy 1: Faster when we're sending to most of the group members
|
|
|
|
// `combined.byRemoving(combine(difference(members, sends)))`
|
|
|
|
#subtractMemberEndorsements(
|
2024-09-16 19:37:38 +00:00
|
|
|
difference: Set<ServiceIdString>
|
2024-09-06 17:52:19 +00:00
|
|
|
): GroupSendEndorsement {
|
|
|
|
const toRemove: Array<GroupSendEndorsement> = [];
|
|
|
|
|
2024-09-16 19:37:38 +00:00
|
|
|
for (const serviceId of difference) {
|
|
|
|
const memberEndorsement = this.#otherMemberEndorsements.get(serviceId);
|
2024-09-06 17:52:19 +00:00
|
|
|
strictAssert(
|
|
|
|
memberEndorsement,
|
|
|
|
'serializeGroupSendEndorsementFullToken: Missing endorsement'
|
|
|
|
);
|
|
|
|
toRemove.push(this.#toEndorsement(memberEndorsement.endorsement));
|
|
|
|
}
|
|
|
|
|
|
|
|
return this.#toEndorsement(
|
|
|
|
this.#combinedEndorsement.endorsement
|
|
|
|
).byRemoving(GroupSendEndorsement.combine(toRemove));
|
|
|
|
}
|
|
|
|
|
|
|
|
// Strategy 2: Faster when we're not sending to most of the group members
|
|
|
|
// `combine(sends)`
|
|
|
|
#combineMemberEndorsements(
|
|
|
|
serviceIds: Set<ServiceIdString>
|
|
|
|
): GroupSendEndorsement {
|
|
|
|
const memberEndorsements = Array.from(serviceIds).map(serviceId => {
|
2024-09-16 19:37:38 +00:00
|
|
|
const memberEndorsement = this.#otherMemberEndorsements.get(serviceId);
|
2024-09-06 17:52:19 +00:00
|
|
|
strictAssert(
|
|
|
|
memberEndorsement,
|
|
|
|
'serializeGroupSendEndorsementFullToken: Missing endorsement'
|
|
|
|
);
|
|
|
|
return this.#toEndorsement(memberEndorsement.endorsement);
|
|
|
|
});
|
|
|
|
|
|
|
|
return GroupSendEndorsement.combine(memberEndorsements);
|
|
|
|
}
|
|
|
|
|
|
|
|
buildToken(serviceIds: Set<ServiceIdString>): GroupSendToken {
|
|
|
|
const sendCount = serviceIds.size;
|
2024-09-16 19:37:38 +00:00
|
|
|
const otherMemberCount = this.#otherMemberEndorsements.size;
|
|
|
|
const logId = `GroupSendEndorsementState.buildToken(${sendCount} of ${otherMemberCount})`;
|
|
|
|
|
|
|
|
const missing = serviceIds.difference(this.#otherMemberEndorsementsAcis);
|
|
|
|
if (missing.size !== 0) {
|
|
|
|
throw new Error(
|
|
|
|
`Attempted to build token with memberAcis we don't have endorsements for (${logServiceIds(missing)})`
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
const difference = this.#otherMemberEndorsementsAcis.difference(serviceIds);
|
|
|
|
log.info(
|
|
|
|
`buildToken: Endorsements without sends ${difference.size}: ${logServiceIds(difference)})`
|
|
|
|
);
|
2024-09-06 17:52:19 +00:00
|
|
|
|
|
|
|
let endorsement: GroupSendEndorsement;
|
2024-09-16 19:37:38 +00:00
|
|
|
if (difference.size === 0) {
|
2024-09-06 17:52:19 +00:00
|
|
|
log.info(`${logId}: combinedEndorsement`);
|
|
|
|
endorsement = this.#toEndorsement(this.#combinedEndorsement.endorsement);
|
2024-09-16 19:37:38 +00:00
|
|
|
} else if (difference.size < otherMemberCount / 2) {
|
2024-09-06 17:52:19 +00:00
|
|
|
log.info(`${logId}: subtractMemberEndorsements`);
|
2024-09-16 19:37:38 +00:00
|
|
|
endorsement = this.#subtractMemberEndorsements(difference);
|
2024-09-06 17:52:19 +00:00
|
|
|
} else {
|
|
|
|
log.info(`${logId}: combineMemberEndorsements`);
|
|
|
|
endorsement = this.#combineMemberEndorsements(serviceIds);
|
|
|
|
}
|
|
|
|
|
|
|
|
const groupSecretParams = new GroupSecretParams(
|
|
|
|
Buffer.from(this.#groupSecretParamsBase64, 'base64')
|
|
|
|
);
|
|
|
|
|
|
|
|
const expiration = this.getExpiration();
|
|
|
|
|
|
|
|
strictAssert(
|
|
|
|
isValidGroupSendEndorsementsExpiration(expiration.getTime()),
|
|
|
|
'Cannot build token with invalid expiration'
|
|
|
|
);
|
|
|
|
|
|
|
|
const fullToken = endorsement.toFullToken(groupSecretParams, expiration);
|
|
|
|
return toGroupSendToken(fullToken.serialize());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export function onFailedToSendWithEndorsements(error: Error): void {
|
|
|
|
log.error('onFailedToSendWithEndorsements', Errors.toLogFormat(error));
|
2024-09-16 19:37:38 +00:00
|
|
|
if (isTestOrMockEnvironment() || isAlpha(window.getVersion())) {
|
|
|
|
window.reduxActions.toast.showToast({
|
|
|
|
toastType: ToastType.FailedToSendWithEndorsements,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
if (window.SignalCI) {
|
|
|
|
window.SignalCI.handleEvent('fatalTestError', error);
|
|
|
|
}
|
2024-09-17 23:11:25 +00:00
|
|
|
devDebugger();
|
2024-05-20 18:15:39 +00:00
|
|
|
}
|