From d431264b9a216c6abaa892215df3f1c2d8e43831 Mon Sep 17 00:00:00 2001 From: EdamAmex Date: Tue, 7 Jul 2026 20:56:16 +0900 Subject: [PATCH] fix(call): resolve T103 timeout in 1:1 audio calls Root cause diagnosed by @botnick in #198: the responder never considers its conn_req satisfied because linejs was missing the BEPI/MC media-channel handshake and had inconsistent channel IDs. Changes: 1. Channel ID consistency: for 1:1 calls, set localMediaChanId equal to srcChanId so the conn_rsp body mChanId and cc-header srcChanId match (the native app keeps them identical). 2. DATA channel SRTP: derive a recv SRTP context for the peer's DATA stream (pt=98) in 1:1 calls, not just group calls. Without this, inbound data-channel packets fail auth tag verification. 3. MC JOIN_REQ / CHANGE_REQ handlers: the peer drives a BEPI media handshake after answer (join_req, change_req) that linejs never answered. Added handlers that respond with join_rsp (0x3285), change_rsp (0x3286), and check_rpt (0x3287). 4. bepi_channel_open (0x1102): send the SC-layer channel-open message after join_rsp, matching the native app's post-answer sequence. 5. Refactored defaultOneToOneDataSessionPayload to share stream spec with the new MC response handlers. --- .../client/features/call/planet/schema.ts | 43 ++++++ .../client/features/call/planet/transport.ts | 137 +++++++++++++++++- 2 files changed, 176 insertions(+), 4 deletions(-) diff --git a/packages/linejs/client/features/call/planet/schema.ts b/packages/linejs/client/features/call/planet/schema.ts index 9f50d87..7511f0c 100644 --- a/packages/linejs/client/features/call/planet/schema.ts +++ b/packages/linejs/client/features/call/planet/schema.ts @@ -1134,6 +1134,49 @@ export function packMcDataSessionPayload(body: Uint8Array): Uint8Array { return concatSchemaBytes([header, body, new Uint8Array(pad)]); } +// ─── mc join/change responses (1:1 BEPI handshake) ───────────────────── + +export interface McSessionRsp { + result?: number; + relCode?: number; + relPhrase?: string; + data?: Uint8Array; +} + +export function packMcJoinRsp(r: McSessionRsp): Uint8Array { + const b: Buf = { bytes: [] }; + if (r.result !== undefined) emitUint32(b, 1, r.result); + if (r.relCode !== undefined) emitUint32(b, 2, r.relCode); + if (r.relPhrase !== undefined) emitString(b, 3, r.relPhrase); + if (r.data) emitMessage(b, 17, r.data); + return finalize(b); +} + +export function packMcChangeRsp(r: McSessionRsp): Uint8Array { + const b: Buf = { bytes: [] }; + if (r.result !== undefined) emitUint32(b, 1, r.result); + if (r.relCode !== undefined) emitUint32(b, 2, r.relCode); + if (r.relPhrase !== undefined) emitString(b, 3, r.relPhrase); + if (r.data) emitMessage(b, 17, r.data); + return finalize(b); +} + +export function packMcCheckRpt(strmSpec: Uint8Array): Uint8Array { + const b: Buf = { bytes: [] }; + emitMessage(b, 17, strmSpec); + return finalize(b); +} + +export function packBepiChannelOpen(token: bigint): Uint8Array { + const inner: Buf = { bytes: [] }; + emitSfixed64(inner, 1, token); + const mid: Buf = { bytes: [] }; + emitMessage(mid, 3, finalize(inner)); + const outer: Buf = { bytes: [] }; + emitMessage(outer, 2, finalize(mid)); + return finalize(outer); +} + // ─── cc_msg oneof wrapper ─────────────────────────────────────────────── export function wrapCcMsg( diff --git a/packages/linejs/client/features/call/planet/transport.ts b/packages/linejs/client/features/call/planet/transport.ts index e1386a6..08f821e 100644 --- a/packages/linejs/client/features/call/planet/transport.ts +++ b/packages/linejs/client/features/call/planet/transport.ts @@ -59,6 +59,7 @@ import { extractRmtNonceFromReply, MC_MSG, type NativeSetupOffer, + packBepiChannelOpen, packCcConnRsp, packCcInfoReq, packCcInfoRsp, @@ -66,9 +67,12 @@ import { packCcRelReq, packCcSetupReq, packKeepaliveReq, + packMcChangeRsp, + packMcCheckRpt, packMcDataReq, packMcDataRsp, packMcDataSessionPayload, + packMcJoinRsp, packNativeGroupParticipateOffer, packNativeSetupOffer, packPlanetCcMsg, @@ -308,9 +312,13 @@ const CASSINI_MSG_ID_CC_BASE = 0x2140; const CASSINI_MSG_ID_SETUP_REQ = 0x2141; const CASSINI_MSG_ID_REL_REQ = 0x2145; const CASSINI_MSG_ID_GROUP_PARTICIPATE_REQ = 0x214e; +const CASSINI_MSG_ID_MC_JOIN_RSP = 0x3285; +const CASSINI_MSG_ID_MC_CHANGE_RSP = 0x3286; +const CASSINI_MSG_ID_MC_CHECK_RPT = 0x3287; const CASSINI_MSG_ID_MC_DATA_REQ = 0x3189; const CASSINI_MSG_ID_MC_DATA_RSP = 0x3289; const CASSINI_MSG_ID_KEEPALIVE_REQ = 0x1101; +const CASSINI_MSG_ID_BEPI_OPEN = 0x1102; const REGULAR_TAIL_CONTROL_BASE = 0x18; const REGULAR_TAIL_RAW_BASE = 0x48; const PINHOLE_PROBE_COUNT = 16; @@ -623,9 +631,9 @@ function defaultAndroidUserAgent(deviceInfo?: string): PlanetUserAgent { }; } -function defaultOneToOneDataSessionPayload(): Uint8Array { +function defaultOneToOneStrmSpec(): Uint8Array { const state = { paused: false, code: 0 }; - const strmSpec = packStrmSpec({ + return packStrmSpec({ strms: [ { ssrc: 102, @@ -672,7 +680,10 @@ function defaultOneToOneDataSessionPayload(): Uint8Array { probeBrMaxKbps: 200, }, }); - return packMcDataSessionPayload(strmSpec); +} + +function defaultOneToOneDataSessionPayload(): Uint8Array { + return packMcDataSessionPayload(defaultOneToOneStrmSpec()); } function defaultSetupFeatures(): Uint8Array[] { @@ -942,6 +953,7 @@ export class PlanetTransport implements CallTransport { #srtpSend?: SrtpCryptoContext; #srtpRecv?: SrtpCryptoContext; #groupDataSrtpSend?: SrtpCryptoContext; + #dataSrtpRecv?: SrtpCryptoContext; #mediaKeyMode?: PlanetMediaKeyMode; #mediaKeyCandidates: MediaKeyCandidate[] = []; #rtp?: { @@ -1039,7 +1051,7 @@ export class PlanetTransport implements CallTransport { this.#localMediaChanId = BigInt(randomNativeGroupMediaChanId()); } else { this.#srcChanId = BigInt(randomNativeLargeId()); - this.#localMediaChanId = BigInt(randomNativeLargeId()); + this.#localMediaChanId = this.#srcChanId; } this.#nextSeq = randomInitialFrameSeq(); this.#setupSent = false; @@ -1061,6 +1073,7 @@ export class PlanetTransport implements CallTransport { this.#srtpSend = undefined; this.#srtpRecv = undefined; this.#groupDataSrtpSend = undefined; + this.#dataSrtpRecv = undefined; this.#mediaKeyMode = undefined; this.#mediaKeyCandidates = []; this.#rtp = undefined; @@ -1228,6 +1241,20 @@ export class PlanetTransport implements CallTransport { }, ).catch(() => {}); } + if (msg.mc.bodyTag === MC_MSG.JOIN_REQ) { + void this.#sendMcJoinRsp( + incoming as PlanetIncomingMessage & { + message: ReturnType; + }, + ).catch(() => {}); + } + if (msg.mc.bodyTag === MC_MSG.CHANGE_REQ) { + void this.#sendMcChangeRsp( + incoming as PlanetIncomingMessage & { + message: ReturnType; + }, + ).catch(() => {}); + } } this.#debug({ type: "planet_msg", @@ -2067,11 +2094,21 @@ export class PlanetTransport implements CallTransport { this.#srtpRecv = initial.recvContext; this.#groupDataSrtpSend = undefined; this.#groupDataRtp = undefined; + this.#dataSrtpRecv = undefined; if (this.#groupJoined && local.material.mediaSecret.length === 30) { this.#groupDataSrtpSend = await deriveSrtpContext( derivePlanetMediaStreamKeying(local.material.mediaSecret, "DATA"), ); } + if ( + !this.#groupJoined && + local.material.mediaSecret.length === 30 && + peerOffer.mediaSecret?.length === 30 + ) { + this.#dataSrtpRecv = await deriveSrtpContext( + derivePlanetMediaStreamKeying(peerOffer.mediaSecret, "DATA"), + ); + } this.#mediaKeyMode = requestedMode; const fallbackHost = route.mediaHost ?? (this.#opts.preferIpv6 && route.cscfHost6 @@ -2245,6 +2282,98 @@ export class PlanetTransport implements CallTransport { ); } + async #sendMcJoinRsp( + request: PlanetIncomingMessage & { + message: ReturnType; + }, + ): Promise { + const rsp = packMcJoinRsp({ + result: 0, + data: defaultOneToOneStrmSpec(), + }); + const mcBody = wrapMcMsg(MC_MSG.JOIN_RSP, rsp); + const mcMsg = packPlanetMcMsg( + { + cid: request.message.mc?.hdr?.cid ?? this.#callUuid ?? "mc-join-rsp", + srcChanId: this.#localMediaChanId, + dstChanId: request.message.mc?.hdr?.srcChanId ?? + this.#remoteMediaChanId, + }, + mcBody, + ); + this.#debug({ type: "mc_join_rsp_sent" }); + await this.#sendEnvelope( + { kind: "mc", data: mcMsg }, + { msgId: CASSINI_MSG_ID_MC_JOIN_RSP }, + ); + void this.#sendBepiChannelOpen().catch(() => {}); + void this.#sendMcCheckRpt(request).catch(() => {}); + } + + async #sendMcChangeRsp( + request: PlanetIncomingMessage & { + message: ReturnType; + }, + ): Promise { + const rsp = packMcChangeRsp({ + result: 0, + data: defaultOneToOneStrmSpec(), + }); + const mcBody = wrapMcMsg(MC_MSG.CHANGE_RSP, rsp); + const mcMsg = packPlanetMcMsg( + { + cid: request.message.mc?.hdr?.cid ?? this.#callUuid ?? "mc-change-rsp", + srcChanId: this.#localMediaChanId, + dstChanId: request.message.mc?.hdr?.srcChanId ?? + this.#remoteMediaChanId, + }, + mcBody, + ); + this.#debug({ type: "mc_change_rsp_sent" }); + await this.#sendEnvelope( + { kind: "mc", data: mcMsg }, + { msgId: CASSINI_MSG_ID_MC_CHANGE_RSP }, + ); + } + + async #sendMcCheckRpt( + request: PlanetIncomingMessage & { + message: ReturnType; + }, + ): Promise { + const rpt = packMcCheckRpt(defaultOneToOneStrmSpec()); + const mcBody = wrapMcMsg(MC_MSG.CHECK_RPT, rpt); + const mcMsg = packPlanetMcMsg( + { + cid: request.message.mc?.hdr?.cid ?? this.#callUuid ?? "mc-check-rpt", + srcChanId: this.#localMediaChanId, + dstChanId: request.message.mc?.hdr?.srcChanId ?? + this.#remoteMediaChanId, + }, + mcBody, + ); + this.#debug({ type: "mc_check_rpt_sent" }); + await this.#sendEnvelope( + { kind: "mc", data: mcMsg }, + { msgId: CASSINI_MSG_ID_MC_CHECK_RPT }, + ); + } + + async #sendBepiChannelOpen(): Promise { + const token = BigInt( + "0x" + + Array.from(crypto.getRandomValues(new Uint8Array(8))) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""), + ); + const data = packBepiChannelOpen(token); + this.#debug({ type: "bepi_channel_open_sent" }); + await this.#sendEnvelope( + { kind: "sc", data }, + { msgId: CASSINI_MSG_ID_BEPI_OPEN }, + ); + } + async #sendDuplicateConnRsp( request: PlanetIncomingMessage & { message: ReturnType;