Skip to content

Commit 51afb43

Browse files
authored
[FIX] Reveal chunk headers to unambiguously handle chunked responses (#90)
* [FIX] Reveal chunk headers to unambiguously handle chunked encoded responses
1 parent 5b1495f commit 51afb43

7 files changed

Lines changed: 248 additions & 94 deletions

File tree

proto/api.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ enum AttestorVersion {
2525
ATTESTOR_VERSION_2_0_1 = 4;
2626
ATTESTOR_VERSION_3_0_0 = 5;
2727
ATTESTOR_VERSION_3_1_0 = 6;
28+
ATTESTOR_VERSION_3_2_0 = 7;
2829
}
2930

3031
enum ErrorCode {

src/client/create-claim.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
makeDefaultOPRFOperator,
3030
makeHttpResponseParser,
3131
preparePacketsForReveal,
32+
REDACTION_CHAR_CODE,
3233
redactSlices,
3334
uint8ArrayToStr,
3435
unixTimestampSeconds
@@ -512,9 +513,25 @@ async function _createClaimOnAttestor<N extends ProviderName>(
512513

513514
revealedPackets.push(...packets.filter(p => p.sender === 'server'))
514515
} else {
515-
for(const {
516-
block, redactedPlaintext, overshotToprfFromPrevBlock, toprfs, oprfRawMarkers
517-
} of serverPacketsToReveal) {
516+
const revealByBlock = new Map(
517+
serverPacketsToReveal.map(r => [r.block, r])
518+
)
519+
// walk every server block in order: revealed blocks are proven via
520+
// zk, fully-redacted blocks get an asterisk placeholder so the local
521+
// receipt stays byte-aligned for chunked dechunking
522+
for(const sb of serverBlocks) {
523+
const reveal = revealByBlock.get(sb)
524+
if(!reveal) {
525+
revealedPackets.push({
526+
sender: 'server',
527+
message: new Uint8Array(sb.plaintext.length).fill(REDACTION_CHAR_CODE)
528+
})
529+
continue
530+
}
531+
532+
const {
533+
block, redactedPlaintext, overshotToprfFromPrevBlock, toprfs, oprfRawMarkers
534+
} = reveal
518535
setRevealOfMessage(block.message, {
519536
type: 'zk',
520537
redactedPlaintext,

src/config/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const DNS_SERVERS = [
2828
// 10m
2929
export const MAX_CLAIM_TIMESTAMP_DIFF_S = 10 * 60
3030

31-
export const CURRENT_ATTESTOR_VERSION = AttestorVersion.ATTESTOR_VERSION_3_1_0
31+
export const CURRENT_ATTESTOR_VERSION = AttestorVersion.ATTESTOR_VERSION_3_2_0
3232

3333
export const DEFAULT_METADATA: InitRequest = {
3434
signatureType: ServiceSignatureType.SERVICE_SIGNATURE_TYPE_ETH,

src/proto/api.ts

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/providers/http/index.ts

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
findIndexInUint8Array,
3030
getHttpRequestDataFromTranscript,
3131
logger,
32+
makeHttpResponseParser,
3233
REDACTION_CHAR,
3334
REDACTION_CHAR_CODE,
3435
strToUint8Array,
@@ -207,6 +208,8 @@ const HTTP_PROVIDER: Provider<'http'> = {
207208
throw new Error('Failed to find response body')
208209
}
209210

211+
const revealFraming = shouldRevealChunkFraming(ctx.version)
212+
210213
const reveals: RedactedOrHashedArraySlice[] = [
211214
{ fromIndex: 0, toIndex: headerEndIndex }
212215
]
@@ -230,20 +233,39 @@ const HTTP_PROVIDER: Provider<'http'> = {
230233
reveals.push(res.headerIndices['date'])
231234
}
232235

236+
//reveal transfer-encoding header so the verifier can dechunk the body
237+
if(revealFraming && res.headerIndices['transfer-encoding']) {
238+
reveals.push(res.headerIndices['transfer-encoding'])
239+
}
240+
233241
const body = uint8ArrayToBinaryStr(res.body)
234242

235243
const redactions: RedactedOrHashedArraySlice[] = []
236244
for(const rs of params.responseRedactions || []) {
237245
const processor = processRedactionRequest(
238-
body, rs, bodyStartIdx, res.chunks
246+
body, rs, bodyStartIdx, res.chunks, revealFraming
239247
)
240248
for(const { reveal, redactions: reds } of processor) {
241249
reveals.push(reveal)
242250
redactions.push(...reds)
243251
}
244252
}
245253

246-
reveals.sort((a, b) => a.toIndex - b.toIndex)
254+
//reveal all chunk framing (size lines + terminator) so the verifier
255+
//can dechunk; chunk data stays redacted unless a redaction reveals it
256+
if(revealFraming && res.chunks?.length) {
257+
let prev = res.headerEndIdx + 4
258+
for(const chunk of res.chunks) {
259+
reveals.push({ fromIndex: prev, toIndex: chunk.fromIndex })
260+
prev = chunk.toIndex
261+
}
262+
263+
reveals.push({ fromIndex: prev, toIndex: response.length })
264+
}
265+
266+
//reveals can overlap (e.g. a redaction reveal spanning chunk framing),
267+
//so redact the complement of their union
268+
reveals.sort((a, b) => a.fromIndex - b.fromIndex)
247269

248270
if(reveals.length > 1) {
249271
let currentIndex = 0
@@ -252,10 +274,12 @@ const HTTP_PROVIDER: Provider<'http'> = {
252274
redactions.push({ fromIndex: currentIndex, toIndex: r.fromIndex })
253275
}
254276

255-
currentIndex = r.toIndex
277+
currentIndex = Math.max(currentIndex, r.toIndex)
256278
}
257279

258-
redactions.push({ fromIndex: currentIndex, toIndex: response.length })
280+
if(currentIndex < response.length) {
281+
redactions.push({ fromIndex: currentIndex, toIndex: response.length })
282+
}
259283
}
260284

261285
for(const r of reveals) {
@@ -321,11 +345,17 @@ const HTTP_PROVIDER: Provider<'http'> = {
321345
throw new Error(`Connection header must be "close", got "${connectionHeader}"`)
322346
}
323347

324-
const serverBlocks = receipt
348+
const allServerBlocks = receipt
325349
.filter(s => s.sender === 'server')
326350
.map((r) => r.message)
327-
// filter out fully redacted blocks
328-
.filter(b => !b.every(b => b === REDACTION_CHAR_CODE))
351+
// drop leading fully-redacted (handshake) blocks so the response starts
352+
// at the status line, but keep redacted body records so chunked byte
353+
// alignment is preserved for dechunking
354+
const firstRealIdx = allServerBlocks
355+
.findIndex(b => !b.every(x => x === REDACTION_CHAR_CODE))
356+
const serverBlocks = firstRealIdx === -1
357+
? []
358+
: allServerBlocks.slice(firstRealIdx)
329359
const response = concatArrays(...serverBlocks)
330360

331361
let res: string
@@ -389,12 +419,19 @@ const HTTP_PROVIDER: Provider<'http'> = {
389419
}
390420

391421

392-
//remove asterisks to account for chunks in the middle of revealed strings
393-
if(!secretParams) {
422+
if(shouldRevealChunkFraming(clientVersion)) {
423+
//dechunk the body so matches see contiguous content; redaction
424+
//asterisks are preserved as boundaries between revealed slices
425+
const headersStr = res.slice(0, bodyStart)
426+
res = /transfer-encoding:\s*chunked/i.test(headersStr)
427+
? headersStr + dechunkResponseBody(res.slice(bodyStart))
428+
: res
429+
} else if(!secretParams) {
430+
//legacy clients redact chunk framing; collapse asterisk runs so
431+
//matches can span the redacted framing
394432
res = res.slice(bodyStart).replace(/\*{3,}/g, '')
395433
}
396434

397-
398435
for(const { type, value, invert } of params.responseMatches || []) {
399436
const inv = Boolean(invert) // explicitly cast to boolean
400437

@@ -510,6 +547,23 @@ function shouldRevealCrlf({ version }: ProviderCtx) {
510547
return version >= AttestorVersion.ATTESTOR_VERSION_2_0_1
511548
}
512549

550+
// revealing chunk framing (instead of redacting it) + dechunking on the
551+
// verifier is a breaking change; older clients still redact framing and rely
552+
// on the asterisk-collapse matching path
553+
function shouldRevealChunkFraming(version: AttestorVersion) {
554+
return version >= AttestorVersion.ATTESTOR_VERSION_3_2_0
555+
}
556+
557+
// dechunk a revealed body by reusing the parser: wrap it in a minimal
558+
// (already-validated) header block. streamEnded() skipped to tolerate trailing bytes
559+
function dechunkResponseBody(body: string) {
560+
const parser = makeHttpResponseParser()
561+
parser.onChunk(
562+
strToUint8Array('HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n' + body)
563+
)
564+
return uint8ArrayToStr(parser.res.body ?? new Uint8Array())
565+
}
566+
513567
function getHostPort(params: ProviderParams<'http'>, secretParams: ProviderSecretParams<'http'>) {
514568
const { host } = new URL(getURL(params, secretParams))
515569
if(!host) {
@@ -550,6 +604,7 @@ function* processRedactionRequest(
550604
rs: HTTPResponseRedaction,
551605
bodyStartIdx: number,
552606
resChunks: ArraySlice[] | undefined,
607+
revealFraming: boolean,
553608
): Generator<RedactionItem> {
554609
let element = body
555610
let elementIdx = 0
@@ -665,22 +720,19 @@ function* processRedactionRequest(
665720

666721
function* addRedaction(
667722
hash: RedactedOrHashedArraySlice['hash'] | null = rs.hash,
668-
_resChunks = resChunks
669723
): Generator<RedactionItem> {
670724
if(elementIdx < 0 || !elementLength) {
671725
return
672726
}
673727

674728
const reveal = getReveal(elementIdx, elementLength, hash || undefined)
675729

676-
yield {
677-
reveal,
678-
redactions: getRedactionsForChunkHeaders(
679-
reveal.fromIndex,
680-
reveal.toIndex,
681-
_resChunks
682-
)
683-
}
730+
// new clients leave chunk framing revealed (verifier dechunks); older
731+
// clients redact the framing inside a reveal that spans chunks
732+
const redactions = revealFraming
733+
? []
734+
: getRedactionsForChunkHeaders(reveal.fromIndex, reveal.toIndex, resChunks)
735+
yield { reveal, redactions }
684736
}
685737

686738
function getReveal(

src/server/utils/assert-valid-claim-request.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { areUint8ArraysEqual, concatenateUint8Arrays } from '@reclaimprotocol/tls'
1+
import { areUint8ArraysEqual, AUTH_TAG_BYTE_LENGTH, concatenateUint8Arrays, SUPPORTED_CIPHER_SUITE_MAP } from '@reclaimprotocol/tls'
22
import type { ZKEngine } from '@reclaimprotocol/zk-symmetric-crypto'
33

44
import type {
@@ -457,7 +457,15 @@ export async function decryptTranscript(
457457
plaintextLength = plaintext.length
458458
} else {
459459
plaintext = content
460-
plaintextLength = plaintext.length
460+
// ciphertext = explicit-nonce + plaintext + auth-tag; strip both so the
461+
// reconstructed redacted length matches the true plaintext length.
462+
// TLS1.2 AES-GCM prepends an 8-byte explicit nonce; TLS1.3 and
463+
// ChaCha20-Poly1305 have none
464+
const { cipher } = SUPPORTED_CIPHER_SUITE_MAP[cipherSuite]
465+
const explicitNonceLength = tlsVersion !== 'TLS1_3' && cipher.includes('GCM')
466+
? 8
467+
: 0
468+
plaintextLength = content.length - explicitNonceLength - AUTH_TAG_BYTE_LENGTH
461469
}
462470

463471
decryptedTranscript.push({

0 commit comments

Comments
 (0)