@@ -6,7 +6,7 @@ import type { CertificateInfo } from '#src/proto/tee-bundle.ts'
66import type { TeeBundleData } from '#src/server/utils/tee-verification.ts'
77import type { Logger } from '#src/types/general.ts'
88import { AttestorError } from '#src/utils/error.ts'
9- import { REDACTION_CHAR_CODE } from '#src/utils/index.ts'
9+ import { makeHttpResponseParser , REDACTION_CHAR_CODE , strToUint8Array , uint8ArrayToStr } from '#src/utils/index.ts'
1010
1111// Types specific to transcript reconstruction
1212export interface TeeTranscriptData {
@@ -133,14 +133,8 @@ async function reconstructConsolidatedResponse(bundleData: TeeBundleData, logger
133133 // Apply response redaction ranges to the reconstructed response
134134 let processedResponse = applyResponseRedactionRanges ( reconstructedResponse , kOutputPayload . responseRedactionRanges , logger )
135135
136- // Apply OPRF replacements BEFORE trimming leading asterisks
137- if ( oprfResults && oprfResults . length > 0 ) {
138- logger . info ( `Applying ${ oprfResults . length } OPRF replacements before trimming` )
139- const { replaceOprfRanges } = await import ( '#src/server/utils/tee-oprf-verification.ts' )
140- processedResponse = replaceOprfRanges ( processedResponse , oprfResults , logger )
141- }
142-
143- // Count leading asterisks
136+ // Trim leading (NewSessionTicket) and trailing (close_notify/alert) asterisks
137+ // BEFORE OPRF/dechunk so downstream positions are stable.
144138 let leadingAsterisks = 0
145139 for ( const element of processedResponse ) {
146140 if ( element === REDACTION_CHAR_CODE ) {
@@ -150,7 +144,6 @@ async function reconstructConsolidatedResponse(bundleData: TeeBundleData, logger
150144 }
151145 }
152146
153- // Count trailing asterisks (may contain undesired data like alerts)
154147 let trailingAsterisks = 0
155148 for ( let i = processedResponse . length - 1 ; i >= leadingAsterisks ; i -- ) {
156149 if ( processedResponse [ i ] === REDACTION_CHAR_CODE ) {
@@ -160,10 +153,136 @@ async function reconstructConsolidatedResponse(bundleData: TeeBundleData, logger
160153 }
161154 }
162155
163- const finalLength = processedResponse . length - leadingAsterisks - trailingAsterisks
164- logger . info ( `After processing: ${ processedResponse . length } bytes, ${ leadingAsterisks } leading and ${ trailingAsterisks } trailing asterisks trimmed, final: ${ finalLength } bytes` )
156+ processedResponse = processedResponse . slice ( leadingAsterisks , processedResponse . length - trailingAsterisks )
157+
158+ // OPRF positions are in pre-trim coords; shift them into trimmed coords.
159+ let oprf = oprfResults ?. map ( r => ( { ...r , position : r . position - leadingAsterisks } ) )
160+
161+ // TEE flow, new clients: chunk framing is revealed, so dechunk the body HERE —
162+ // BEFORE the length-changing OPRF replacement. If we replaced first, the inserted
163+ // hashes (longer than the matched bytes) would shift every subsequent chunk-size
164+ // offset and the verifier's dechunk would desync ("got more data after response
165+ // was complete"). Non-TEE / legacy flows leave framing in place and dechunk inside
166+ // the http provider using the same parser.
167+ const dechunked = dechunkRevealedResponse ( processedResponse , oprf , logger )
168+ processedResponse = dechunked . response
169+ oprf = dechunked . oprfResults
170+
171+ // Apply OPRF replacements on the now-contiguous body (length growth is harmless).
172+ if ( oprf && oprf . length > 0 ) {
173+ logger . info ( `Applying ${ oprf . length } OPRF replacements` )
174+ const { replaceOprfRanges } = await import ( '#src/server/utils/tee-oprf-verification.ts' )
175+ processedResponse = replaceOprfRanges ( processedResponse , oprf , logger )
176+ }
177+
178+ logger . info ( `After processing: ${ processedResponse . length } bytes (${ leadingAsterisks } leading, ${ trailingAsterisks } trailing asterisks trimmed)` )
179+ return processedResponse
180+ }
181+
182+ // Synthetic header the http provider prepends when dechunking a revealed body.
183+ const DECHUNK_SYNTH_HEADER = 'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n'
184+
185+ /**
186+ * TEE flow only: when chunk framing is revealed (new clients), dechunk the response
187+ * body up-front and remap OPRF positions into the dechunked body, so the subsequent
188+ * length-changing OPRF replacement can't desync chunk-size offsets. The
189+ * `transfer-encoding: chunked` token is blanked so the http provider does not dechunk
190+ * a second time. Returns the input unchanged when framing isn't revealed (legacy) or
191+ * the response isn't chunked — those are dechunked inside the provider instead.
192+ */
193+ function dechunkRevealedResponse (
194+ response : Uint8Array ,
195+ oprfResults : Array < { position : number , length : number , output : Uint8Array } > | undefined ,
196+ logger : Logger
197+ ) : { response : Uint8Array , oprfResults ?: Array < { position : number , length : number , output : Uint8Array } > } {
198+ const headerEnd = findHeaderEnd ( response )
199+ if ( headerEnd < 0 ) {
200+ return { response, oprfResults }
201+ }
202+
203+ const bodyStart = headerEnd + 4
204+ const headersStr = uint8ArrayToStr ( response . slice ( 0 , headerEnd ) )
205+ if ( ! / t r a n s f e r - e n c o d i n g : \s * c h u n k e d / i. test ( headersStr ) ) {
206+ return { response, oprfResults }
207+ }
208+
209+ // Dechunk via the same synthetic-header parse the http provider uses, so chunk
210+ // detection is identical. res.chunks positions are offset by the synthetic prefix.
211+ const parser = makeHttpResponseParser ( )
212+ parser . onChunk ( strToUint8Array ( DECHUNK_SYNTH_HEADER ) )
213+ parser . onChunk ( response . slice ( bodyStart ) )
214+ const chunks = parser . res . chunks
215+ if ( ! chunks || chunks . length === 0 ) {
216+ return { response, oprfResults }
217+ }
218+
219+ const dechunkedBody = parser . res . body ?? new Uint8Array ( )
220+
221+ // Blank the transfer-encoding token so the provider's dechunk is skipped.
222+ const headerRegion = response . slice ( 0 , bodyStart )
223+ const teMatch = / t r a n s f e r - e n c o d i n g : \s * c h u n k e d / i. exec ( uint8ArrayToStr ( headerRegion ) )
224+ if ( teMatch ) {
225+ for ( let i = teMatch . index ; i < teMatch . index + teMatch [ 0 ] . length ; i ++ ) {
226+ headerRegion [ i ] = 0x78 // 'x'
227+ }
228+ }
229+
230+ const dechunkedResponse = new Uint8Array ( headerRegion . length + dechunkedBody . length )
231+ dechunkedResponse . set ( headerRegion , 0 )
232+ dechunkedResponse . set ( dechunkedBody , headerRegion . length )
233+
234+ const synthLen = DECHUNK_SYNTH_HEADER . length
235+ const remapped = oprfResults ?. map ( r => ( {
236+ ...r ,
237+ position : chunkedToDechunkedPos ( r . position , bodyStart , synthLen , chunks )
238+ } ) )
239+
240+ logger . info ( `TEE dechunk before OPRF: ${ response . length } -> ${ dechunkedResponse . length } bytes, ${ chunks . length } chunks` )
241+ return { response : dechunkedResponse , oprfResults : remapped }
242+ }
243+
244+ // Index of the "\r\n\r\n" header/body separator, or -1.
245+ function findHeaderEnd ( response : Uint8Array ) : number {
246+ for ( let i = 0 ; i + 3 < response . length ; i ++ ) {
247+ if ( response [ i ] === 0x0d && response [ i + 1 ] === 0x0a
248+ && response [ i + 2 ] === 0x0d && response [ i + 3 ] === 0x0a ) {
249+ return i
250+ }
251+ }
252+
253+ return - 1
254+ }
255+
256+ // Map a position in the original (chunked) response to its position in the dechunked
257+ // response. `chunks` are in synthetic-prefixed coords (fromIndex/toIndex point to chunk
258+ // DATA), so subtract `synthLen` to get body-relative offsets.
259+ function chunkedToDechunkedPos (
260+ pos : number ,
261+ bodyStart : number ,
262+ synthLen : number ,
263+ chunks : Array < { fromIndex : number , toIndex : number } >
264+ ) : number {
265+ if ( pos < bodyStart ) {
266+ return pos
267+ }
268+
269+ const bodyOff = pos - bodyStart
270+ let acc = 0
271+ for ( const c of chunks ) {
272+ const cf = c . fromIndex - synthLen
273+ const ct = c . toIndex - synthLen
274+ if ( bodyOff >= cf && bodyOff < ct ) {
275+ return bodyStart + acc + ( bodyOff - cf )
276+ }
277+
278+ if ( bodyOff === ct ) {
279+ return bodyStart + acc + ( ct - cf )
280+ }
281+
282+ acc += ct - cf
283+ }
165284
166- return processedResponse . slice ( leadingAsterisks , processedResponse . length - trailingAsterisks )
285+ return bodyStart + acc
167286}
168287
169288// Removed legacy packet-based extraction functions since we now use consolidated streams
0 commit comments