diff --git a/ts/groups.ts b/ts/groups.ts index 18aa340647a5..4e87f7cb29de 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -3986,7 +3986,7 @@ async function updateGroupViaLogs({ if ( cachedEndorsementsExpiration != null && - !isValidGroupSendEndorsementsExpiration(cachedEndorsementsExpiration) + !isValidGroupSendEndorsementsExpiration(cachedEndorsementsExpiration * 1000) ) { log.info( `updateGroupViaLogs/${logId}: Group had invalid endorsements expiration (${cachedEndorsementsExpiration}), fetching new endorsements` diff --git a/ts/test-mock/playwright.ts b/ts/test-mock/playwright.ts index 5ca25ba88ae8..32e61728f9c8 100644 --- a/ts/test-mock/playwright.ts +++ b/ts/test-mock/playwright.ts @@ -144,8 +144,24 @@ export class App extends EventEmitter { ); } + private async checkForFatalTestErrors(): Promise { + 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 { - await this.app.close(); + try { + await this.checkForFatalTestErrors(); + } finally { + await this.app.close(); + } } public async getWindow(): Promise { diff --git a/ts/util/groupSendEndorsements.ts b/ts/util/groupSendEndorsements.ts index ba933ab8e7a1..7a9d8f67399f 100644 --- a/ts/util/groupSendEndorsements.ts +++ b/ts/util/groupSendEndorsements.ts @@ -25,6 +25,8 @@ import type { GroupV2MemberType } from '../model-types'; import { DurationInSeconds } from './durations'; import { ToastType } from '../types/Toast'; import * as Errors from '../types/errors'; +import { isTestOrMockEnvironment } from '../environment'; +import { isAlpha } from './version'; export function decodeGroupSendEndorsementResponse({ groupId, @@ -116,6 +118,14 @@ export function decodeGroupSendEndorsementResponse({ const TWO_DAYS = DurationInSeconds.fromDays(2); const TWO_HOURS = DurationInSeconds.fromHours(2); +function logServiceIds(list: Iterable) { + 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( expiration: number ): boolean { @@ -127,11 +137,11 @@ export function isValidGroupSendEndorsementsExpiration( export class GroupSendEndorsementState { #combinedEndorsement: GroupSendCombinedEndorsementRecord; - #memberEndorsements = new Map< + #otherMemberEndorsements = new Map< ServiceIdString, GroupSendMemberEndorsementRecord >(); - #memberEndorsementsAcis = new Set(); + #otherMemberEndorsementsAcis = new Set(); #groupSecretParamsBase64: string; #endorsementCache = new WeakMap(); @@ -140,11 +150,15 @@ export class GroupSendEndorsementState { groupSecretParamsBase64: string ) { this.#combinedEndorsement = data.combinedEndorsement; - for (const endorsement of data.memberEndorsements) { - this.#memberEndorsements.set(endorsement.memberAci, endorsement); - this.#memberEndorsementsAcis.add(endorsement.memberAci); - } 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 { @@ -158,7 +172,7 @@ export class GroupSendEndorsementState { } hasMember(serviceId: ServiceIdString): boolean { - return this.#memberEndorsements.has(serviceId); + return this.#otherMemberEndorsements.has(serviceId); } #toEndorsement(contents: Uint8Array) { @@ -173,19 +187,12 @@ export class GroupSendEndorsementState { // Strategy 1: Faster when we're sending to most of the group members // `combined.byRemoving(combine(difference(members, sends)))` #subtractMemberEndorsements( - serviceIds: Set + difference: Set ): GroupSendEndorsement { - const difference = this.#memberEndorsementsAcis.difference(serviceIds); - const ourAci = window.textsecure.storage.user.getCheckedAci(); - const toRemove: Array = []; - 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( memberEndorsement, 'serializeGroupSendEndorsementFullToken: Missing endorsement' @@ -204,7 +211,7 @@ export class GroupSendEndorsementState { serviceIds: Set ): GroupSendEndorsement { const memberEndorsements = Array.from(serviceIds).map(serviceId => { - const memberEndorsement = this.#memberEndorsements.get(serviceId); + const memberEndorsement = this.#otherMemberEndorsements.get(serviceId); strictAssert( memberEndorsement, 'serializeGroupSendEndorsementFullToken: Missing endorsement' @@ -217,17 +224,28 @@ export class GroupSendEndorsementState { buildToken(serviceIds: Set): GroupSendToken { const sendCount = serviceIds.size; - const memberCount = this.#memberEndorsements.size; - const logId = `GroupSendEndorsementState.buildToken(${sendCount} of ${memberCount})`; + 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)})` + ); let endorsement: GroupSendEndorsement; - if (sendCount === memberCount - 1) { + if (difference.size === 0) { log.info(`${logId}: combinedEndorsement`); - // Note: Combined endorsement does not include our aci endorsement = this.#toEndorsement(this.#combinedEndorsement.endorsement); - } else if (sendCount > (memberCount - 1) / 2) { + } else if (difference.size < otherMemberCount / 2) { log.info(`${logId}: subtractMemberEndorsements`); - endorsement = this.#subtractMemberEndorsements(serviceIds); + endorsement = this.#subtractMemberEndorsements(difference); } else { log.info(`${logId}: combineMemberEndorsements`); endorsement = this.#combineMemberEndorsements(serviceIds); @@ -251,8 +269,13 @@ export class GroupSendEndorsementState { export function onFailedToSendWithEndorsements(error: Error): void { log.error('onFailedToSendWithEndorsements', Errors.toLogFormat(error)); - window.reduxActions.toast.showToast({ - toastType: ToastType.FailedToSendWithEndorsements, - }); + if (isTestOrMockEnvironment() || isAlpha(window.getVersion())) { + 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'); } diff --git a/ts/util/sendToGroup.ts b/ts/util/sendToGroup.ts index c35ddc4256b0..6d2eda9a7ed6 100644 --- a/ts/util/sendToGroup.ts +++ b/ts/util/sendToGroup.ts @@ -72,6 +72,7 @@ import { } from './groupSendEndorsements'; import { maybeUpdateGroup } from '../groups'; import type { GroupSendToken } from '../types/GroupSendEndorsements'; +import { isAciString } from './isAciString'; const UNKNOWN_RECIPIENT = 404; const INCORRECT_AUTH_KEY = 401; @@ -361,16 +362,18 @@ export async function sendToGroupViaSenderKey( let groupSendEndorsementState: GroupSendEndorsementState | null = null; if (groupId != null) { + strictAssert(conversation, 'Must have conversation for endorsements'); + const data = await DataReader.getGroupSendEndorsementsData(groupId); if (data == null) { - onFailedToSendWithEndorsements( - new Error( - `sendToGroupViaSenderKey/${logId}: Missing all endorsements for group` - ) - ); + if (conversation.isMember(ourAci)) { + onFailedToSendWithEndorsements( + new Error( + `sendToGroupViaSenderKey/${logId}: Missing all endorsements for group` + ) + ); + } } else { - strictAssert(conversation, 'Must have conversation for endorsements'); - log.info( `sendToGroupViaSenderKey/${logId}: Loaded endorsements for ${data.memberEndorsements.length} members` ); @@ -572,9 +575,13 @@ export async function sendToGroupViaSenderKey( let accessKeys: Buffer | undefined; if (groupSendEndorsementState != null) { strictAssert(conversation, 'Must have conversation for endorsements'); - groupSendToken = groupSendEndorsementState.buildToken( - new Set(senderKeyRecipients) - ); + try { + groupSendToken = groupSendEndorsementState.buildToken( + new Set(senderKeyRecipients) + ); + } catch (error) { + onFailedToSendWithEndorsements(error); + } } else { accessKeys = getXorOfAccessKeys(devicesForSenderKey, { story }); } @@ -689,6 +696,10 @@ export async function sendToGroupViaSenderKey( } } + if (groupSendEndorsementState != null) { + onFailedToSendWithEndorsements(error); + } + log.error( `sendToGroupViaSenderKey/${logId}: Returned unexpected error code: ${ error.code @@ -1259,8 +1270,7 @@ function isValidSenderKeyRecipient( } if (groupSendEndorsementState != null) { - const memberEndorsement = groupSendEndorsementState.hasMember(serviceId); - if (memberEndorsement == null) { + if (!groupSendEndorsementState.hasMember(serviceId)) { onFailedToSendWithEndorsements( new Error( `isValidSenderKeyRecipient: Sending to ${serviceId}, missing endorsement` @@ -1429,6 +1439,12 @@ async function fetchKeysForServiceId( 'private' ); + let useGroupSendEndorsement = isAciString(serviceId); + if (!groupSendEndorsementState?.hasMember(serviceId)) { + log.error(`fetchKeysForServiceId: ${serviceId} does not have endorsements`); + useGroupSendEndorsement = false; + } + try { // Note: we have no way to make an unrestricted unauthenticated key fetch as part of a // story send, so we hardcode story=false. @@ -1436,8 +1452,13 @@ async function fetchKeysForServiceId( story: false, }); - const groupSendToken = - groupSendEndorsementState?.buildToken(new Set([serviceId])) ?? null; + let groupSendToken: GroupSendToken | null = null; + + if (useGroupSendEndorsement && groupSendEndorsementState != null) { + groupSendToken = groupSendEndorsementState.buildToken( + new Set([serviceId]) + ); + } const { accessKeyFailed } = await getKeysForServiceId( serviceId, @@ -1456,6 +1477,9 @@ async function fetchKeysForServiceId( await DataWriter.updateConversation(emptyConversation.attributes); } } catch (error: unknown) { + if (useGroupSendEndorsement) { + onFailedToSendWithEndorsements(error as Error); + } if (error instanceof UnregisteredUserError) { await markServiceIdUnregistered(serviceId); return;