Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions proto/api.proto
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ enum AttestorVersion {
ATTESTOR_VERSION_2_0_1 = 4;
ATTESTOR_VERSION_3_0_0 = 5;
ATTESTOR_VERSION_3_1_0 = 6;
ATTESTOR_VERSION_3_2_0 = 7;
}

enum ErrorCode {
Expand Down
23 changes: 20 additions & 3 deletions src/client/create-claim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
makeDefaultOPRFOperator,
makeHttpResponseParser,
preparePacketsForReveal,
REDACTION_CHAR_CODE,
redactSlices,
uint8ArrayToStr,
unixTimestampSeconds
Expand Down Expand Up @@ -512,9 +513,25 @@ async function _createClaimOnAttestor<N extends ProviderName>(

revealedPackets.push(...packets.filter(p => p.sender === 'server'))
} else {
for(const {
block, redactedPlaintext, overshotToprfFromPrevBlock, toprfs, oprfRawMarkers
} of serverPacketsToReveal) {
const revealByBlock = new Map(
serverPacketsToReveal.map(r => [r.block, r])
)
// walk every server block in order: revealed blocks are proven via
// zk, fully-redacted blocks get an asterisk placeholder so the local
// receipt stays byte-aligned for chunked dechunking
for(const sb of serverBlocks) {
const reveal = revealByBlock.get(sb)
if(!reveal) {
revealedPackets.push({
sender: 'server',
message: new Uint8Array(sb.plaintext.length).fill(REDACTION_CHAR_CODE)
})
continue
}

const {
block, redactedPlaintext, overshotToprfFromPrevBlock, toprfs, oprfRawMarkers
} = reveal
setRevealOfMessage(block.message, {
type: 'zk',
redactedPlaintext,
Expand Down
2 changes: 1 addition & 1 deletion src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const DNS_SERVERS = [
// 10m
export const MAX_CLAIM_TIMESTAMP_DIFF_S = 10 * 60

export const CURRENT_ATTESTOR_VERSION = AttestorVersion.ATTESTOR_VERSION_3_1_0
export const CURRENT_ATTESTOR_VERSION = AttestorVersion.ATTESTOR_VERSION_3_2_0

export const DEFAULT_METADATA: InitRequest = {
signatureType: ServiceSignatureType.SERVICE_SIGNATURE_TYPE_ETH,
Expand Down
9 changes: 8 additions & 1 deletion src/proto/api.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

109 changes: 90 additions & 19 deletions src/providers/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ const HTTP_PROVIDER: Provider<'http'> = {
throw new Error('Failed to find response body')
}

const revealFraming = shouldRevealChunkFraming(ctx.version)

const reveals: RedactedOrHashedArraySlice[] = [
{ fromIndex: 0, toIndex: headerEndIndex }
]
Expand All @@ -230,20 +232,39 @@ const HTTP_PROVIDER: Provider<'http'> = {
reveals.push(res.headerIndices['date'])
}

//reveal transfer-encoding header so the verifier can dechunk the body
if(revealFraming && res.headerIndices['transfer-encoding']) {
reveals.push(res.headerIndices['transfer-encoding'])
}

const body = uint8ArrayToBinaryStr(res.body)

const redactions: RedactedOrHashedArraySlice[] = []
for(const rs of params.responseRedactions || []) {
const processor = processRedactionRequest(
body, rs, bodyStartIdx, res.chunks
body, rs, bodyStartIdx, res.chunks, revealFraming
)
for(const { reveal, redactions: reds } of processor) {
reveals.push(reveal)
redactions.push(...reds)
}
}

reveals.sort((a, b) => a.toIndex - b.toIndex)
//reveal all chunk framing (size lines + terminator) so the verifier
//can dechunk; chunk data stays redacted unless a redaction reveals it
if(revealFraming && res.chunks?.length) {
let prev = res.headerEndIdx + 4
for(const chunk of res.chunks) {
reveals.push({ fromIndex: prev, toIndex: chunk.fromIndex })
prev = chunk.toIndex
}

reveals.push({ fromIndex: prev, toIndex: response.length })
}

//reveals can overlap (e.g. a redaction reveal spanning chunk framing),
//so redact the complement of their union
reveals.sort((a, b) => a.fromIndex - b.fromIndex)

if(reveals.length > 1) {
let currentIndex = 0
Expand All @@ -252,10 +273,12 @@ const HTTP_PROVIDER: Provider<'http'> = {
redactions.push({ fromIndex: currentIndex, toIndex: r.fromIndex })
}

currentIndex = r.toIndex
currentIndex = Math.max(currentIndex, r.toIndex)
}

redactions.push({ fromIndex: currentIndex, toIndex: response.length })
if(currentIndex < response.length) {
redactions.push({ fromIndex: currentIndex, toIndex: response.length })
}
}

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

const serverBlocks = receipt
const allServerBlocks = receipt
.filter(s => s.sender === 'server')
.map((r) => r.message)
// filter out fully redacted blocks
.filter(b => !b.every(b => b === REDACTION_CHAR_CODE))
// drop leading fully-redacted (handshake) blocks so the response starts
// at the status line, but keep redacted body records so chunked byte
// alignment is preserved for dechunking
const firstRealIdx = allServerBlocks
.findIndex(b => !b.every(x => x === REDACTION_CHAR_CODE))
const serverBlocks = firstRealIdx === -1
? []
: allServerBlocks.slice(firstRealIdx)
const response = concatArrays(...serverBlocks)

let res: string
Expand Down Expand Up @@ -389,12 +418,19 @@ const HTTP_PROVIDER: Provider<'http'> = {
}


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


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

Expand Down Expand Up @@ -510,6 +546,43 @@ function shouldRevealCrlf({ version }: ProviderCtx) {
return version >= AttestorVersion.ATTESTOR_VERSION_2_0_1
}

// revealing chunk framing (instead of redacting it) + dechunking on the
// verifier is a breaking change; older clients still redact framing and rely
// on the asterisk-collapse matching path
function shouldRevealChunkFraming(version: AttestorVersion) {
return version >= AttestorVersion.ATTESTOR_VERSION_3_2_0
}

// decode an HTTP/1.1 chunked body; chunk-size lines must be revealed.
// redaction asterisks inside chunk data are length-preserving, so the
// declared sizes still hold and are copied through verbatim
function dechunkResponseBody(body: string) {
Comment thread
Scratch-net marked this conversation as resolved.
let out = ''
let i = 0
while(i < body.length) {
const lineEnd = body.indexOf('\r\n', i)
if(lineEnd === -1) {
break
}

const sizeStr = body.slice(i, lineEnd).split(';')[0].trim()
const size = Number.parseInt(sizeStr, 16)
if(Number.isNaN(size)) {
throw new Error(`Invalid chunk size "${sizeStr}" while dechunking response`)
}

i = lineEnd + 2
if(size === 0) {
break
}

out += body.slice(i, i + size)
i += size + 2
}

return out
}

function getHostPort(params: ProviderParams<'http'>, secretParams: ProviderSecretParams<'http'>) {
const { host } = new URL(getURL(params, secretParams))
if(!host) {
Expand Down Expand Up @@ -550,6 +623,7 @@ function* processRedactionRequest(
rs: HTTPResponseRedaction,
bodyStartIdx: number,
resChunks: ArraySlice[] | undefined,
revealFraming: boolean,
): Generator<RedactionItem> {
let element = body
let elementIdx = 0
Expand Down Expand Up @@ -665,22 +739,19 @@ function* processRedactionRequest(

function* addRedaction(
hash: RedactedOrHashedArraySlice['hash'] | null = rs.hash,
_resChunks = resChunks
): Generator<RedactionItem> {
if(elementIdx < 0 || !elementLength) {
return
}

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

yield {
reveal,
redactions: getRedactionsForChunkHeaders(
reveal.fromIndex,
reveal.toIndex,
_resChunks
)
}
// new clients leave chunk framing revealed (verifier dechunks); older
// clients redact the framing inside a reveal that spans chunks
const redactions = revealFraming
? []
: getRedactionsForChunkHeaders(reveal.fromIndex, reveal.toIndex, resChunks)
yield { reveal, redactions }
}

function getReveal(
Expand Down
12 changes: 10 additions & 2 deletions src/server/utils/assert-valid-claim-request.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { areUint8ArraysEqual, concatenateUint8Arrays } from '@reclaimprotocol/tls'
import { areUint8ArraysEqual, AUTH_TAG_BYTE_LENGTH, concatenateUint8Arrays, SUPPORTED_CIPHER_SUITE_MAP } from '@reclaimprotocol/tls'
import type { ZKEngine } from '@reclaimprotocol/zk-symmetric-crypto'

import type {
Expand Down Expand Up @@ -457,7 +457,15 @@ export async function decryptTranscript(
plaintextLength = plaintext.length
} else {
plaintext = content
plaintextLength = plaintext.length
// ciphertext = explicit-nonce + plaintext + auth-tag; strip both so the
// reconstructed redacted length matches the true plaintext length.
// TLS1.2 AES-GCM prepends an 8-byte explicit nonce; TLS1.3 and
// ChaCha20-Poly1305 have none
const { cipher } = SUPPORTED_CIPHER_SUITE_MAP[cipherSuite]
const explicitNonceLength = tlsVersion !== 'TLS1_3' && cipher.includes('GCM')
? 8
: 0
plaintextLength = content.length - explicitNonceLength - AUTH_TAG_BYTE_LENGTH
}

decryptedTranscript.push({
Expand Down
Loading
Loading