@@ -10,6 +10,7 @@ import type {
1010 AuthMode ,
1111 AuthOkPayload ,
1212 AuthResponsePayload ,
13+ ExternalAccessRejectionReason ,
1314 WSEnvelope ,
1415} from "../service_worker/external_access/types" ;
1516
@@ -41,6 +42,7 @@ export type ExternalAccessAuth = { mode: "session"; key: string } | { mode: "pai
4142export interface ExternalAccessConnectParam {
4243 url : string ;
4344 auth : ExternalAccessAuth ;
45+ instanceId : string ;
4446}
4547
4648// ---------------------------------------------------------------------------------------------
@@ -105,7 +107,6 @@ export function normalizePairingCode(code: string): string {
105107 . replace ( / [ I L ] / g, "1" ) ;
106108}
107109
108- // HMAC-SHA-256(key, utf8(context || firstHex || secondHex)) → 小写 hex。
109110export async function computeHandshakeHmac (
110111 keyBytes : Bytes ,
111112 context : string ,
@@ -150,18 +151,20 @@ export async function decryptLongTermKey(encKeyBytes: Bytes, ivB64: string, ciph
150151// 收到 $session.authenticate 后,按当前模式计算响应,并带出握手参数以验证 $session.authenticated。
151152async function buildAuthResponse (
152153 auth : ExternalAccessAuth ,
154+ instanceId : string ,
153155 nonceD : string
154156) : Promise < { payload : AuthResponsePayload ; verifyKey : Bytes ; nonceE : string ; enc ?: Bytes } > {
155157 const nonceE = randomNonceHex ( ) ;
156158 const mode : AuthMode = auth . mode ;
159+ const peer : AuthResponsePayload [ "peer" ] = { kind : "scriptcat" , instanceId } ;
157160 if ( auth . mode === "session" ) {
158161 const keyBytes = hexToBytes ( auth . key ) ;
159- const hmac = await computeHandshakeHmac ( keyBytes , CRYPTO . context . sessionExt , nonceD , nonceE ) ;
160- return { payload : { mode, nonceE, hmac } , verifyKey : keyBytes , nonceE } ;
162+ const hmac = await computeHandshakeHmac ( keyBytes , CRYPTO . context . scriptcatSessionExt + instanceId , nonceD , nonceE ) ;
163+ return { payload : { mode, nonceE, hmac, peer } , verifyKey : keyBytes , nonceE } ;
161164 }
162165 const { mac, enc } = await derivePairingKeys ( auth . code ) ;
163- const hmac = await computeHandshakeHmac ( mac , CRYPTO . context . pairExt , nonceD , nonceE ) ;
164- return { payload : { mode, nonceE, hmac } , verifyKey : mac , nonceE, enc } ;
166+ const hmac = await computeHandshakeHmac ( mac , CRYPTO . context . scriptcatPairExt + instanceId , nonceD , nonceE ) ;
167+ return { payload : { mode, nonceE, hmac, peer } , verifyKey : mac , nonceE, enc } ;
165168}
166169
167170export class ExternalAccessConnect {
@@ -172,6 +175,10 @@ export class ExternalAccessConnect {
172175 private epoch = 0 ;
173176 private currentParams : ExternalAccessConnectParam | null = null ;
174177 private handshakeComplete = false ;
178+ private opened = false ;
179+ private capabilitiesId ?: string ;
180+ private pairedKey ?: string ;
181+ private conflict = false ;
175182 // 已发出认证响应、等待 $session.authenticated 校验期间保留的握手素材。
176183 private pendingVerify : { verifyKey : Bytes ; nonceD : string ; nonceE : string ; mode : AuthMode ; enc ?: Bytes } | null =
177184 null ;
@@ -205,7 +212,6 @@ export class ExternalAccessConnect {
205212 private startSession ( params : ExternalAccessConnectParam ) : void {
206213 this . dispose ( ) ;
207214 this . currentParams = params ;
208- this . epoch ++ ;
209215 this . connect ( this . epoch ) ;
210216 }
211217
@@ -224,22 +230,39 @@ export class ExternalAccessConnect {
224230 } , CONFIG . CONNECT_TIMEOUT ) ;
225231
226232 this . ws . onopen = ( ) => this . handleOpen ( sessionEpoch ) ;
227- this . ws . onmessage = ( ev ) => void this . handleMessage ( ev , sessionEpoch ) ;
228- this . ws . onclose = ( ) => this . handleClose ( sessionEpoch ) ;
233+ // daemon 可连续发 authenticated 与 hello。按线上的顺序完成异步密码校验,再处理后续帧和关闭事件。
234+ let frames : Promise < void > | undefined ;
235+ this . ws . onmessage = ( ev ) => {
236+ const processing = ( frames ?? Promise . resolve ( ) )
237+ . then ( ( ) => this . handleMessage ( ev , sessionEpoch ) )
238+ . catch ( ( error : unknown ) => {
239+ this . logger . warn ( "Failed to process WebSocket frame" , Logger . E ( error ) ) ;
240+ if ( sessionEpoch === this . epoch ) this . ws ?. close ( ) ;
241+ } ) ;
242+ frames = processing ;
243+ void processing . then ( ( ) => {
244+ if ( frames === processing ) frames = undefined ;
245+ } ) ;
246+ } ;
247+ this . ws . onclose = ( { code } ) => {
248+ if ( frames ) void frames . then ( ( ) => this . handleClose ( sessionEpoch , code ) ) ;
249+ else this . handleClose ( sessionEpoch , code ) ;
250+ } ;
229251 this . ws . onerror = ( ev ) => this . handleError ( ev , sessionEpoch ) ;
230252 } catch ( e ) {
231253 this . logger . error ( "WebSocket creation failed" , Logger . E ( e ) ) ;
232- this . handleError ( e , sessionEpoch ) ;
254+ this . handleClose ( sessionEpoch , 1006 ) ;
233255 }
234256 }
235257
236258 private handleOpen ( sessionEpoch : number ) : void {
237259 if ( sessionEpoch !== this . epoch ) return ;
260+ this . opened = true ;
238261 this . logger . info ( "WebSocket connected, awaiting $session.authenticate" ) ;
239262 this . clearTimer ( "connectTimeoutTimer" ) ;
240- // 握手必须在 authTimeoutMs 内完成,否则断开重连(PROTOCOL §3) 。
263+ // 认证与能力声明必须在 authTimeoutMs 内完成,否则接入/改名调用可能永不返回 。
241264 this . authTimeoutTimer = setTimeout ( ( ) => {
242- if ( sessionEpoch === this . epoch && ! this . handshakeComplete ) {
265+ if ( sessionEpoch === this . epoch ) {
243266 this . logger . warn ( "Auth handshake timeout" ) ;
244267 this . ws ?. close ( ) ;
245268 }
@@ -264,6 +287,22 @@ export class ExternalAccessConnect {
264287 return ;
265288 }
266289
290+ if ( ! envelope . method && envelope . id && envelope . id === this . capabilitiesId ) {
291+ this . capabilitiesId = undefined ;
292+ if ( envelope . error ) {
293+ this . conflict = envelope . error . data ?. code === "CONFLICT" ;
294+ this . ws ?. close ( ) ;
295+ } else {
296+ const key = this . pairedKey ;
297+ if ( key && this . currentParams ) this . currentParams = { ...this . currentParams , auth : { mode : "session" , key } } ;
298+ this . pairedKey = undefined ;
299+ this . reconnectDelay = CONFIG . BASE_RECONNECT_DELAY ;
300+ this . clearTimer ( "authTimeoutTimer" ) ;
301+ await this . relay . established ( key ) ;
302+ }
303+ return ;
304+ }
305+
267306 switch ( envelope . method ) {
268307 case "$session.ping" :
269308 if ( envelope . id ) this . rawSend ( { jsonrpc : "2.0" , id : envelope . id , result : { } } ) ;
@@ -281,12 +320,12 @@ export class ExternalAccessConnect {
281320 }
282321
283322 private async handleHandshakeMessage ( envelope : WSEnvelope , sessionEpoch : number ) : Promise < void > {
284- const auth = this . currentParams ?. auth ;
285- if ( ! auth ) return ;
323+ if ( ! this . currentParams ) return ;
324+ const { auth, instanceId } = this . currentParams ;
286325
287326 if ( envelope . method === "$session.authenticate" && envelope . id ) {
288327 const { nonceD } = envelope . params as AuthChallengePayload ;
289- const built = await buildAuthResponse ( auth , nonceD ) ;
328+ const built = await buildAuthResponse ( auth , instanceId , nonceD ) ;
290329 if ( sessionEpoch !== this . epoch ) return ;
291330 // 保存验证素材,供随后的 $session.authenticated 校验使用。
292331 this . pendingVerify = {
@@ -311,20 +350,26 @@ export class ExternalAccessConnect {
311350
312351 private async verifyAuthOk ( payload : AuthOkPayload , sessionEpoch : number ) : Promise < void > {
313352 const pending = this . pendingVerify ;
314- if ( ! pending ) {
353+ if ( ! pending || ! this . currentParams ) {
315354 this . ws ?. close ( ) ;
316355 return ;
317356 }
318- const context = pending . mode === "session" ? CRYPTO . context . sessionDaemon : CRYPTO . context . pairDaemon ;
319- const expected = await computeHandshakeHmac ( pending . verifyKey , context , pending . nonceE , pending . nonceD ) ;
357+ const context =
358+ pending . mode === "session" ? CRYPTO . context . scriptcatSessionDaemon : CRYPTO . context . scriptcatPairDaemon ;
359+ const expected = await computeHandshakeHmac (
360+ pending . verifyKey ,
361+ context + this . currentParams . instanceId ,
362+ pending . nonceE ,
363+ pending . nonceD
364+ ) ;
320365 if ( sessionEpoch !== this . epoch ) return ;
321366 if ( ! constantTimeEqualHex ( expected , payload . hmac || "" ) ) {
322367 this . logger . warn ( "$session.authenticated HMAC mismatch, closing" ) ;
323368 this . ws ?. close ( ) ;
324369 return ;
325370 }
326371
327- // 配对模式:解密 daemon 下发的长期密钥 K,持久化交给 SW,并把自身切到会话模式供后续重连 。
372+ // daemon 在接受名称之前已消费配对码并下发密钥;名称冲突时这把密钥不能落盘 。
328373 if ( pending . mode === "pairing" ) {
329374 if ( ! pending . enc || ! payload . key ) {
330375 this . logger . warn ( "pairing $session.authenticated missing key, closing" ) ;
@@ -333,14 +378,11 @@ export class ExternalAccessConnect {
333378 }
334379 const key = await decryptLongTermKey ( pending . enc , payload . key . iv , payload . key . ciphertext ) ;
335380 if ( sessionEpoch !== this . epoch ) return ;
336- if ( this . currentParams ) this . currentParams = { ...this . currentParams , auth : { mode : "session" , key } } ;
337- void this . relay . paired ( key ) ;
381+ this . pairedKey = key ;
338382 }
339383
340384 this . pendingVerify = null ;
341385 this . handshakeComplete = true ;
342- this . clearTimer ( "authTimeoutTimer" ) ;
343- this . reconnectDelay = CONFIG . BASE_RECONNECT_DELAY ;
344386 this . logger . info ( "Auth handshake complete" ) ;
345387 }
346388
@@ -351,6 +393,7 @@ export class ExternalAccessConnect {
351393 this . logger . warn ( "Dropped outbound JSON-RPC message before handshake" , { method : envelope . method } ) ;
352394 return ;
353395 }
396+ if ( envelope . method === "$session.capabilities" ) this . capabilitiesId = envelope . id ;
354397 this . rawSend ( envelope ) ;
355398 }
356399
@@ -360,16 +403,19 @@ export class ExternalAccessConnect {
360403 }
361404 }
362405
363- private handleClose ( sessionEpoch : number ) : void {
364- if ( sessionEpoch !== this . epoch ) return ;
365- this . clearTimer ( "connectTimeoutTimer" ) ;
366- this . clearTimer ( "authTimeoutTimer" ) ;
406+ private handleClose ( sessionEpoch : number , code : number ) : void {
407+ if ( sessionEpoch !== this . epoch || ! this . currentParams ) return ;
408+ const reason : ExternalAccessRejectionReason | undefined = this . conflict
409+ ? "name-taken"
410+ : this . opened && ! this . handshakeComplete && code === 1008
411+ ? "refused"
412+ : undefined ;
413+ const mode = this . currentParams . auth . mode ;
367414 this . ws = null ;
368- this . handshakeComplete = false ;
369- this . pendingVerify = null ;
370- this . logger . debug ( "WebSocket connection closed" ) ;
371- void this . relay . disconnected ( ) ;
372- this . scheduleReconnect ( ) ;
415+ this . dispose ( ) ;
416+ if ( reason ) this . currentParams = null ;
417+ void this . relay . disconnected ( reason , mode ) ;
418+ if ( ! reason ) this . scheduleReconnect ( ) ;
373419 }
374420
375421 private handleError ( ev : Event | Error | unknown , sessionEpoch : number ) : void {
@@ -378,7 +424,7 @@ export class ExternalAccessConnect {
378424 event : ev instanceof Event ? ev . type : undefined ,
379425 error : ev instanceof Error ? ev . message : String ( ev ) ,
380426 } ) ;
381- this . scheduleReconnect ( ) ;
427+ // 浏览器会紧接着发 close,只有 close 才能区分网络错误与 1008 拒绝。
382428 }
383429
384430 private scheduleReconnect ( ) : void {
@@ -402,6 +448,11 @@ export class ExternalAccessConnect {
402448 }
403449
404450 private dispose ( ) : void {
451+ this . epoch ++ ;
452+ this . opened = false ;
453+ this . capabilitiesId = undefined ;
454+ this . pairedKey = undefined ;
455+ this . conflict = false ;
405456 this . clearTimer ( "reconnectTimer" ) ;
406457 this . clearTimer ( "connectTimeoutTimer" ) ;
407458 this . clearTimer ( "authTimeoutTimer" ) ;
0 commit comments