Fix group send endorsements for new members

Co-authored-by: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com>
This commit is contained in:
automated-signal 2024-09-16 16:44:46 -05:00 committed by GitHub
parent f71183119f
commit ced1962879
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 106 additions and 43 deletions

View file

@ -3986,7 +3986,7 @@ async function updateGroupViaLogs({
if ( if (
cachedEndorsementsExpiration != null && cachedEndorsementsExpiration != null &&
!isValidGroupSendEndorsementsExpiration(cachedEndorsementsExpiration) !isValidGroupSendEndorsementsExpiration(cachedEndorsementsExpiration * 1000)
) { ) {
log.info( log.info(
`updateGroupViaLogs/${logId}: Group had invalid endorsements expiration (${cachedEndorsementsExpiration}), fetching new endorsements` `updateGroupViaLogs/${logId}: Group had invalid endorsements expiration (${cachedEndorsementsExpiration}), fetching new endorsements`

View file

@ -144,8 +144,24 @@ export class App extends EventEmitter {
); );
} }
private async checkForFatalTestErrors(): Promise<void> {
const count = await this.getPendingEventCount('fatalTestError');
if (count === 0) {
return;
}
for (let i = 0; i < count; i += 1) {
// eslint-disable-next-line no-await-in-loop, no-console
console.error(await this.waitForEvent('fatalTestError'));
}
throw new Error('App had fatal test errors');
}
public async close(): Promise<void> { public async close(): Promise<void> {
await this.app.close(); try {
await this.checkForFatalTestErrors();
} finally {
await this.app.close();
}
} }
public async getWindow(): Promise<Page> { public async getWindow(): Promise<Page> {

View file

@ -25,6 +25,8 @@ import type { GroupV2MemberType } from '../model-types';
import { DurationInSeconds } from './durations'; import { DurationInSeconds } from './durations';
import { ToastType } from '../types/Toast'; import { ToastType } from '../types/Toast';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import { isTestOrMockEnvironment } from '../environment';
import { isAlpha } from './version';
export function decodeGroupSendEndorsementResponse({ export function decodeGroupSendEndorsementResponse({
groupId, groupId,
@ -116,6 +118,14 @@ export function decodeGroupSendEndorsementResponse({
const TWO_DAYS = DurationInSeconds.fromDays(2); const TWO_DAYS = DurationInSeconds.fromDays(2);
const TWO_HOURS = DurationInSeconds.fromHours(2); const TWO_HOURS = DurationInSeconds.fromHours(2);
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`;
}
export function isValidGroupSendEndorsementsExpiration( export function isValidGroupSendEndorsementsExpiration(
expiration: number expiration: number
): boolean { ): boolean {
@ -127,11 +137,11 @@ export function isValidGroupSendEndorsementsExpiration(
export class GroupSendEndorsementState { export class GroupSendEndorsementState {
#combinedEndorsement: GroupSendCombinedEndorsementRecord; #combinedEndorsement: GroupSendCombinedEndorsementRecord;
#memberEndorsements = new Map< #otherMemberEndorsements = new Map<
ServiceIdString, ServiceIdString,
GroupSendMemberEndorsementRecord GroupSendMemberEndorsementRecord
>(); >();
#memberEndorsementsAcis = new Set<AciString>(); #otherMemberEndorsementsAcis = new Set<AciString>();
#groupSecretParamsBase64: string; #groupSecretParamsBase64: string;
#endorsementCache = new WeakMap<Uint8Array, GroupSendEndorsement>(); #endorsementCache = new WeakMap<Uint8Array, GroupSendEndorsement>();
@ -140,11 +150,15 @@ export class GroupSendEndorsementState {
groupSecretParamsBase64: string groupSecretParamsBase64: string
) { ) {
this.#combinedEndorsement = data.combinedEndorsement; this.#combinedEndorsement = data.combinedEndorsement;
for (const endorsement of data.memberEndorsements) {
this.#memberEndorsements.set(endorsement.memberAci, endorsement);
this.#memberEndorsementsAcis.add(endorsement.memberAci);
}
this.#groupSecretParamsBase64 = groupSecretParamsBase64; this.#groupSecretParamsBase64 = groupSecretParamsBase64;
const ourAci = window.textsecure.storage.user.getCheckedAci();
for (const endorsement of data.memberEndorsements) {
if (endorsement.memberAci !== ourAci) {
this.#otherMemberEndorsements.set(endorsement.memberAci, endorsement);
this.#otherMemberEndorsementsAcis.add(endorsement.memberAci);
}
}
} }
isSafeExpirationRange(): boolean { isSafeExpirationRange(): boolean {
@ -158,7 +172,7 @@ export class GroupSendEndorsementState {
} }
hasMember(serviceId: ServiceIdString): boolean { hasMember(serviceId: ServiceIdString): boolean {
return this.#memberEndorsements.has(serviceId); return this.#otherMemberEndorsements.has(serviceId);
} }
#toEndorsement(contents: Uint8Array) { #toEndorsement(contents: Uint8Array) {
@ -173,19 +187,12 @@ export class GroupSendEndorsementState {
// Strategy 1: Faster when we're sending to most of the group members // Strategy 1: Faster when we're sending to most of the group members
// `combined.byRemoving(combine(difference(members, sends)))` // `combined.byRemoving(combine(difference(members, sends)))`
#subtractMemberEndorsements( #subtractMemberEndorsements(
serviceIds: Set<ServiceIdString> difference: Set<ServiceIdString>
): GroupSendEndorsement { ): GroupSendEndorsement {
const difference = this.#memberEndorsementsAcis.difference(serviceIds);
const ourAci = window.textsecure.storage.user.getCheckedAci();
const toRemove: Array<GroupSendEndorsement> = []; const toRemove: Array<GroupSendEndorsement> = [];
for (const serviceId of difference) {
if (serviceId === ourAci) {
// Note: Combined endorsement does not include our aci
continue;
}
const memberEndorsement = this.#memberEndorsements.get(serviceId); for (const serviceId of difference) {
const memberEndorsement = this.#otherMemberEndorsements.get(serviceId);
strictAssert( strictAssert(
memberEndorsement, memberEndorsement,
'serializeGroupSendEndorsementFullToken: Missing endorsement' 'serializeGroupSendEndorsementFullToken: Missing endorsement'
@ -204,7 +211,7 @@ export class GroupSendEndorsementState {
serviceIds: Set<ServiceIdString> serviceIds: Set<ServiceIdString>
): GroupSendEndorsement { ): GroupSendEndorsement {
const memberEndorsements = Array.from(serviceIds).map(serviceId => { const memberEndorsements = Array.from(serviceIds).map(serviceId => {
const memberEndorsement = this.#memberEndorsements.get(serviceId); const memberEndorsement = this.#otherMemberEndorsements.get(serviceId);
strictAssert( strictAssert(
memberEndorsement, memberEndorsement,
'serializeGroupSendEndorsementFullToken: Missing endorsement' 'serializeGroupSendEndorsementFullToken: Missing endorsement'
@ -217,17 +224,28 @@ export class GroupSendEndorsementState {
buildToken(serviceIds: Set<ServiceIdString>): GroupSendToken { buildToken(serviceIds: Set<ServiceIdString>): GroupSendToken {
const sendCount = serviceIds.size; const sendCount = serviceIds.size;
const memberCount = this.#memberEndorsements.size; const otherMemberCount = this.#otherMemberEndorsements.size;
const logId = `GroupSendEndorsementState.buildToken(${sendCount} of ${memberCount})`; 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)})`
);
let endorsement: GroupSendEndorsement; let endorsement: GroupSendEndorsement;
if (sendCount === memberCount - 1) { if (difference.size === 0) {
log.info(`${logId}: combinedEndorsement`); log.info(`${logId}: combinedEndorsement`);
// Note: Combined endorsement does not include our aci
endorsement = this.#toEndorsement(this.#combinedEndorsement.endorsement); endorsement = this.#toEndorsement(this.#combinedEndorsement.endorsement);
} else if (sendCount > (memberCount - 1) / 2) { } else if (difference.size < otherMemberCount / 2) {
log.info(`${logId}: subtractMemberEndorsements`); log.info(`${logId}: subtractMemberEndorsements`);
endorsement = this.#subtractMemberEndorsements(serviceIds); endorsement = this.#subtractMemberEndorsements(difference);
} else { } else {
log.info(`${logId}: combineMemberEndorsements`); log.info(`${logId}: combineMemberEndorsements`);
endorsement = this.#combineMemberEndorsements(serviceIds); endorsement = this.#combineMemberEndorsements(serviceIds);
@ -251,8 +269,13 @@ export class GroupSendEndorsementState {
export function onFailedToSendWithEndorsements(error: Error): void { export function onFailedToSendWithEndorsements(error: Error): void {
log.error('onFailedToSendWithEndorsements', Errors.toLogFormat(error)); log.error('onFailedToSendWithEndorsements', Errors.toLogFormat(error));
window.reduxActions.toast.showToast({ if (isTestOrMockEnvironment() || isAlpha(window.getVersion())) {
toastType: ToastType.FailedToSendWithEndorsements, window.reduxActions.toast.showToast({
}); toastType: ToastType.FailedToSendWithEndorsements,
});
}
if (window.SignalCI) {
window.SignalCI.handleEvent('fatalTestError', error);
}
assertDev(false, 'We should never fail to send with endorsements'); assertDev(false, 'We should never fail to send with endorsements');
} }

View file

@ -72,6 +72,7 @@ import {
} from './groupSendEndorsements'; } from './groupSendEndorsements';
import { maybeUpdateGroup } from '../groups'; import { maybeUpdateGroup } from '../groups';
import type { GroupSendToken } from '../types/GroupSendEndorsements'; import type { GroupSendToken } from '../types/GroupSendEndorsements';
import { isAciString } from './isAciString';
const UNKNOWN_RECIPIENT = 404; const UNKNOWN_RECIPIENT = 404;
const INCORRECT_AUTH_KEY = 401; const INCORRECT_AUTH_KEY = 401;
@ -361,16 +362,18 @@ export async function sendToGroupViaSenderKey(
let groupSendEndorsementState: GroupSendEndorsementState | null = null; let groupSendEndorsementState: GroupSendEndorsementState | null = null;
if (groupId != null) { if (groupId != null) {
strictAssert(conversation, 'Must have conversation for endorsements');
const data = await DataReader.getGroupSendEndorsementsData(groupId); const data = await DataReader.getGroupSendEndorsementsData(groupId);
if (data == null) { if (data == null) {
onFailedToSendWithEndorsements( if (conversation.isMember(ourAci)) {
new Error( onFailedToSendWithEndorsements(
`sendToGroupViaSenderKey/${logId}: Missing all endorsements for group` new Error(
) `sendToGroupViaSenderKey/${logId}: Missing all endorsements for group`
); )
);
}
} else { } else {
strictAssert(conversation, 'Must have conversation for endorsements');
log.info( log.info(
`sendToGroupViaSenderKey/${logId}: Loaded endorsements for ${data.memberEndorsements.length} members` `sendToGroupViaSenderKey/${logId}: Loaded endorsements for ${data.memberEndorsements.length} members`
); );
@ -572,9 +575,13 @@ export async function sendToGroupViaSenderKey(
let accessKeys: Buffer | undefined; let accessKeys: Buffer | undefined;
if (groupSendEndorsementState != null) { if (groupSendEndorsementState != null) {
strictAssert(conversation, 'Must have conversation for endorsements'); strictAssert(conversation, 'Must have conversation for endorsements');
groupSendToken = groupSendEndorsementState.buildToken( try {
new Set(senderKeyRecipients) groupSendToken = groupSendEndorsementState.buildToken(
); new Set(senderKeyRecipients)
);
} catch (error) {
onFailedToSendWithEndorsements(error);
}
} else { } else {
accessKeys = getXorOfAccessKeys(devicesForSenderKey, { story }); accessKeys = getXorOfAccessKeys(devicesForSenderKey, { story });
} }
@ -689,6 +696,10 @@ export async function sendToGroupViaSenderKey(
} }
} }
if (groupSendEndorsementState != null) {
onFailedToSendWithEndorsements(error);
}
log.error( log.error(
`sendToGroupViaSenderKey/${logId}: Returned unexpected error code: ${ `sendToGroupViaSenderKey/${logId}: Returned unexpected error code: ${
error.code error.code
@ -1259,8 +1270,7 @@ function isValidSenderKeyRecipient(
} }
if (groupSendEndorsementState != null) { if (groupSendEndorsementState != null) {
const memberEndorsement = groupSendEndorsementState.hasMember(serviceId); if (!groupSendEndorsementState.hasMember(serviceId)) {
if (memberEndorsement == null) {
onFailedToSendWithEndorsements( onFailedToSendWithEndorsements(
new Error( new Error(
`isValidSenderKeyRecipient: Sending to ${serviceId}, missing endorsement` `isValidSenderKeyRecipient: Sending to ${serviceId}, missing endorsement`
@ -1429,6 +1439,12 @@ async function fetchKeysForServiceId(
'private' 'private'
); );
let useGroupSendEndorsement = isAciString(serviceId);
if (!groupSendEndorsementState?.hasMember(serviceId)) {
log.error(`fetchKeysForServiceId: ${serviceId} does not have endorsements`);
useGroupSendEndorsement = false;
}
try { try {
// Note: we have no way to make an unrestricted unauthenticated key fetch as part of a // Note: we have no way to make an unrestricted unauthenticated key fetch as part of a
// story send, so we hardcode story=false. // story send, so we hardcode story=false.
@ -1436,8 +1452,13 @@ async function fetchKeysForServiceId(
story: false, story: false,
}); });
const groupSendToken = let groupSendToken: GroupSendToken | null = null;
groupSendEndorsementState?.buildToken(new Set([serviceId])) ?? null;
if (useGroupSendEndorsement && groupSendEndorsementState != null) {
groupSendToken = groupSendEndorsementState.buildToken(
new Set([serviceId])
);
}
const { accessKeyFailed } = await getKeysForServiceId( const { accessKeyFailed } = await getKeysForServiceId(
serviceId, serviceId,
@ -1456,6 +1477,9 @@ async function fetchKeysForServiceId(
await DataWriter.updateConversation(emptyConversation.attributes); await DataWriter.updateConversation(emptyConversation.attributes);
} }
} catch (error: unknown) { } catch (error: unknown) {
if (useGroupSendEndorsement) {
onFailedToSendWithEndorsements(error as Error);
}
if (error instanceof UnregisteredUserError) { if (error instanceof UnregisteredUserError) {
await markServiceIdUnregistered(serviceId); await markServiceIdUnregistered(serviceId);
return; return;