fix(quic): finish P1 + A2 audit follow-ups (PR #2873)
P1 (header protection — eliminate remaining HP allocations):
Add HeaderProtection.maskInto(hpKey, src, srcOffset, scratch16, dstMask)
that writes the 5-byte mask into a caller-owned buffer using a
caller-owned 16-byte AES scratch. Add `hpScratch` (16 bytes) and
`hpMask` (5 bytes) to PacketProtection — same per-direction
single-threaded analysis as nonceScratch from the prior commit. Thread
both buffers through Short/LongHeaderPacket build / parseAndDecrypt /
peekKeyPhase as optional parameters paired with nonceScratch; production
call sites in QuicConnectionWriter / QuicConnectionParser pass
`proto.hpScratch` + `proto.hpMask`. With both this commit and 09b28b8d
the AES-128-GCM hot path now allocates ZERO bytes for HP + nonce per
packet (down from 16-byte sample slice + 16-byte AES output + 5-byte
mask + 12-byte nonce = 49 bytes/pkt).
ChaCha20HeaderProtection.maskInto routes through the same shape but
still allocates a fresh 5-byte ciphertext + 12-byte nonce slice
internally — Quartz's `ChaCha20Core.chaCha20Xor` SPI takes a
standalone nonce ByteArray. Documented as a future cleanup; AES is
the dominant path in production.
A2 (TlsRunningSha256 — lazy fallback accumulator):
Probe `digest.clone()` ONCE at construction. Conscrypt's clone
support is a build-time property (native bridge presence), not state-
dependent, so a single probe is a reliable signal. Devices where the
probe succeeds skip the byte accumulator entirely (zero overhead);
devices where it fails get the byte-accumulator path from the very
first update so the first snapshot already has the complete
transcript. Replaces the previous "always accumulate" shape that
wasted a few KB per handshake on every device, including the
overwhelming majority that support clone.
All quic JVM unit tests pass; spotless applied.
https://claude.ai/code/session_01EBHtGLy5o7FUR5qfcpHUUx
This commit is contained in:
@@ -46,6 +46,23 @@ class PacketProtection(
|
|||||||
* invocation.
|
* invocation.
|
||||||
*/
|
*/
|
||||||
val nonceScratch: ByteArray = ByteArray(iv.size)
|
val nonceScratch: ByteArray = ByteArray(iv.size)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-direction header-protection scratch — 16 bytes of AES-ECB output
|
||||||
|
* for [hp.maskInto][com.vitorpamplona.quic.crypto.HeaderProtection.maskInto].
|
||||||
|
* Reused per packet so the AES output no longer allocates (round-5 #P1).
|
||||||
|
* Same single-direction thread-safety rationale as [nonceScratch].
|
||||||
|
*/
|
||||||
|
val hpScratch: ByteArray = ByteArray(16)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-direction header-protection mask buffer — 5 bytes per RFC 9001
|
||||||
|
* §5.4.3. Filled by [hp.maskInto][com.vitorpamplona.quic.crypto.HeaderProtection.maskInto]
|
||||||
|
* and immediately consumed by [com.vitorpamplona.quic.crypto.applyHeaderProtectionMask]
|
||||||
|
* (write path) or by inline first-byte / packet-number unmasking
|
||||||
|
* (parse path). Consume before the next mask call on this instance.
|
||||||
|
*/
|
||||||
|
val hpMask: ByteArray = ByteArray(5)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All four encryption levels we ever see in a QUIC client connection. */
|
/** All four encryption levels we ever see in a QUIC client connection. */
|
||||||
|
|||||||
@@ -321,6 +321,8 @@ private fun feedLongHeaderPacket(
|
|||||||
hpKey = proto.hpKey,
|
hpKey = proto.hpKey,
|
||||||
largestReceivedInSpace = state.pnSpace.largestReceived,
|
largestReceivedInSpace = state.pnSpace.largestReceived,
|
||||||
nonceScratch = proto.nonceScratch,
|
nonceScratch = proto.nonceScratch,
|
||||||
|
hpScratch = proto.hpScratch,
|
||||||
|
hpMask = proto.hpMask,
|
||||||
)
|
)
|
||||||
if (parsed == null) {
|
if (parsed == null) {
|
||||||
conn.qlogObserver.onPacketDropped(
|
conn.qlogObserver.onPacketDropped(
|
||||||
@@ -393,6 +395,8 @@ private fun feedShortHeaderPacket(
|
|||||||
dcidLen = conn.sourceConnectionId.length,
|
dcidLen = conn.sourceConnectionId.length,
|
||||||
hp = live.hp,
|
hp = live.hp,
|
||||||
hpKey = live.hpKey,
|
hpKey = live.hpKey,
|
||||||
|
hpScratch = live.hpScratch,
|
||||||
|
hpMask = live.hpMask,
|
||||||
)
|
)
|
||||||
if (peek == null) {
|
if (peek == null) {
|
||||||
conn.qlogObserver.onPacketDropped(
|
conn.qlogObserver.onPacketDropped(
|
||||||
@@ -444,6 +448,8 @@ private fun feedShortHeaderPacket(
|
|||||||
hpKey = live.hpKey,
|
hpKey = live.hpKey,
|
||||||
largestReceivedInSpace = state.pnSpace.largestReceived,
|
largestReceivedInSpace = state.pnSpace.largestReceived,
|
||||||
nonceScratch = live.nonceScratch,
|
nonceScratch = live.nonceScratch,
|
||||||
|
hpScratch = live.hpScratch,
|
||||||
|
hpMask = live.hpMask,
|
||||||
)
|
)
|
||||||
rotateOnSuccess = null
|
rotateOnSuccess = null
|
||||||
} else {
|
} else {
|
||||||
@@ -463,6 +469,8 @@ private fun feedShortHeaderPacket(
|
|||||||
hpKey = prev.hpKey,
|
hpKey = prev.hpKey,
|
||||||
largestReceivedInSpace = state.pnSpace.largestReceived,
|
largestReceivedInSpace = state.pnSpace.largestReceived,
|
||||||
nonceScratch = prev.nonceScratch,
|
nonceScratch = prev.nonceScratch,
|
||||||
|
hpScratch = prev.hpScratch,
|
||||||
|
hpMask = prev.hpMask,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (priorTry != null) {
|
if (priorTry != null) {
|
||||||
@@ -493,6 +501,8 @@ private fun feedShortHeaderPacket(
|
|||||||
hpKey = nextPhase.hpKey,
|
hpKey = nextPhase.hpKey,
|
||||||
largestReceivedInSpace = state.pnSpace.largestReceived,
|
largestReceivedInSpace = state.pnSpace.largestReceived,
|
||||||
nonceScratch = nextPhase.nonceScratch,
|
nonceScratch = nextPhase.nonceScratch,
|
||||||
|
hpScratch = nextPhase.hpScratch,
|
||||||
|
hpMask = nextPhase.hpMask,
|
||||||
)
|
)
|
||||||
rotateOnSuccess = nextPhase
|
rotateOnSuccess = nextPhase
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,6 +360,8 @@ private fun buildBestLevelPacket(
|
|||||||
proto.hpKey,
|
proto.hpKey,
|
||||||
largestAckedInSpace = -1L,
|
largestAckedInSpace = -1L,
|
||||||
nonceScratch = proto.nonceScratch,
|
nonceScratch = proto.nonceScratch,
|
||||||
|
hpScratch = proto.hpScratch,
|
||||||
|
hpMask = proto.hpMask,
|
||||||
)
|
)
|
||||||
emitQlogSent(conn, EncryptionLevel.APPLICATION, pn, built.size, frames)
|
emitQlogSent(conn, EncryptionLevel.APPLICATION, pn, built.size, frames)
|
||||||
return built
|
return built
|
||||||
@@ -407,6 +409,8 @@ private fun buildLongHeaderPacket(
|
|||||||
proto.hpKey,
|
proto.hpKey,
|
||||||
largestAckedInSpace = -1L,
|
largestAckedInSpace = -1L,
|
||||||
nonceScratch = proto.nonceScratch,
|
nonceScratch = proto.nonceScratch,
|
||||||
|
hpScratch = proto.hpScratch,
|
||||||
|
hpMask = proto.hpMask,
|
||||||
)
|
)
|
||||||
emitQlogSent(conn, level, pn, built.size, frames)
|
emitQlogSent(conn, level, pn, built.size, frames)
|
||||||
return built
|
return built
|
||||||
@@ -562,6 +566,8 @@ private fun buildLongHeaderFromFrames(
|
|||||||
proto.hpKey,
|
proto.hpKey,
|
||||||
largestAckedInSpace = -1L,
|
largestAckedInSpace = -1L,
|
||||||
nonceScratch = proto.nonceScratch,
|
nonceScratch = proto.nonceScratch,
|
||||||
|
hpScratch = proto.hpScratch,
|
||||||
|
hpMask = proto.hpMask,
|
||||||
)
|
)
|
||||||
// Step E: retain the packet for RFC 9002 retransmit. Initial /
|
// Step E: retain the packet for RFC 9002 retransmit. Initial /
|
||||||
// Handshake packets carry CRYPTO frames; loss detection runs at
|
// Handshake packets carry CRYPTO frames; loss detection runs at
|
||||||
@@ -916,6 +922,8 @@ private fun buildApplicationPacket(
|
|||||||
proto.hpKey,
|
proto.hpKey,
|
||||||
largestAckedInSpace = -1L,
|
largestAckedInSpace = -1L,
|
||||||
nonceScratch = proto.nonceScratch,
|
nonceScratch = proto.nonceScratch,
|
||||||
|
hpScratch = proto.hpScratch,
|
||||||
|
hpMask = proto.hpMask,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// 0-RTT — long header type=0x01. Same Application packet
|
// 0-RTT — long header type=0x01. Same Application packet
|
||||||
@@ -938,6 +946,8 @@ private fun buildApplicationPacket(
|
|||||||
proto.hpKey,
|
proto.hpKey,
|
||||||
largestAckedInSpace = -1L,
|
largestAckedInSpace = -1L,
|
||||||
nonceScratch = proto.nonceScratch,
|
nonceScratch = proto.nonceScratch,
|
||||||
|
hpScratch = proto.hpScratch,
|
||||||
|
hpMask = proto.hpMask,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ sealed class HeaderProtection {
|
|||||||
/**
|
/**
|
||||||
* Compute the HP mask from a 16-byte standalone sample buffer. Retained
|
* Compute the HP mask from a 16-byte standalone sample buffer. Retained
|
||||||
* for callers (mostly tests) that already have a heap-allocated sample
|
* for callers (mostly tests) that already have a heap-allocated sample
|
||||||
* blob — the production hot path uses [maskAt] to avoid the slice.
|
* blob — the production hot path uses [maskInto] to avoid every per-
|
||||||
|
* packet allocation.
|
||||||
*/
|
*/
|
||||||
abstract fun mask(
|
abstract fun mask(
|
||||||
hpKey: ByteArray,
|
hpKey: ByteArray,
|
||||||
@@ -42,15 +43,36 @@ sealed class HeaderProtection {
|
|||||||
/**
|
/**
|
||||||
* Compute the HP mask from a 16-byte sample window inside [src] starting
|
* Compute the HP mask from a 16-byte sample window inside [src] starting
|
||||||
* at [srcOffset]. Avoids the `copyOfRange` of the sample on every
|
* at [srcOffset]. Avoids the `copyOfRange` of the sample on every
|
||||||
* outbound and inbound packet (round-5 #P1). Returns a freshly allocated
|
* outbound and inbound packet (round-5 #P1) but still allocates a
|
||||||
* 5-byte mask — the per-packet allocation budget drops from
|
* fresh 5-byte mask + an internal 16-byte AES scratch on each call.
|
||||||
* `16 (sample) + 16 (cipher output) + 5 (mask)` to `16 + 5` for AES-ECB.
|
* Use [maskInto] when caller-owned scratch + mask buffers are
|
||||||
|
* available (the production writer/parser path).
|
||||||
*/
|
*/
|
||||||
abstract fun maskAt(
|
abstract fun maskAt(
|
||||||
hpKey: ByteArray,
|
hpKey: ByteArray,
|
||||||
src: ByteArray,
|
src: ByteArray,
|
||||||
srcOffset: Int,
|
srcOffset: Int,
|
||||||
): ByteArray
|
): ByteArray
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocation-free HP mask: write the 5 mask bytes into [dstMask], using
|
||||||
|
* [scratch16] (caller-owned 16-byte buffer) for the AES-ECB output
|
||||||
|
* intermediate. Returns [dstMask] for fluent use.
|
||||||
|
*
|
||||||
|
* Both buffers are MUTATED — callers must consume the mask before the
|
||||||
|
* next [maskInto] call on the same buffers. The hot QUIC writer/parser
|
||||||
|
* path in `Short/LongHeaderPacket.build` + `parseAndDecrypt` keeps a
|
||||||
|
* per-PacketProtection scratch + mask pair and consumes the mask
|
||||||
|
* immediately during [com.vitorpamplona.quic.crypto.applyHeaderProtectionMask],
|
||||||
|
* so the lifetime is trivially safe.
|
||||||
|
*/
|
||||||
|
abstract fun maskInto(
|
||||||
|
hpKey: ByteArray,
|
||||||
|
src: ByteArray,
|
||||||
|
srcOffset: Int,
|
||||||
|
scratch16: ByteArray,
|
||||||
|
dstMask: ByteArray,
|
||||||
|
): ByteArray
|
||||||
}
|
}
|
||||||
|
|
||||||
/** AES-128-ECB header protection. Implemented via the platform AES helper. */
|
/** AES-128-ECB header protection. Implemented via the platform AES helper. */
|
||||||
@@ -71,15 +93,23 @@ class AesEcbHeaderProtection(
|
|||||||
hpKey: ByteArray,
|
hpKey: ByteArray,
|
||||||
src: ByteArray,
|
src: ByteArray,
|
||||||
srcOffset: Int,
|
srcOffset: Int,
|
||||||
|
): ByteArray = maskInto(hpKey, src, srcOffset, ByteArray(16), ByteArray(5))
|
||||||
|
|
||||||
|
override fun maskInto(
|
||||||
|
hpKey: ByteArray,
|
||||||
|
src: ByteArray,
|
||||||
|
srcOffset: Int,
|
||||||
|
scratch16: ByteArray,
|
||||||
|
dstMask: ByteArray,
|
||||||
): ByteArray {
|
): ByteArray {
|
||||||
require(srcOffset >= 0 && srcOffset + 16 <= src.size) { "HP sample window out of range" }
|
require(srcOffset >= 0 && srcOffset + 16 <= src.size) { "HP sample window out of range" }
|
||||||
require(hpKey.size in setOf(16, 24, 32)) { "AES-ECB key must be 16/24/32 bytes" }
|
require(hpKey.size in setOf(16, 24, 32)) { "AES-ECB key must be 16/24/32 bytes" }
|
||||||
val scratch = ByteArray(16)
|
require(scratch16.size == 16) { "AES scratch must be 16 bytes" }
|
||||||
aesEncryptOneBlock.encryptInto(hpKey, src, srcOffset, scratch, 0)
|
require(dstMask.size >= 5) { "HP mask buffer must be at least 5 bytes" }
|
||||||
|
aesEncryptOneBlock.encryptInto(hpKey, src, srcOffset, scratch16, 0)
|
||||||
// Mask is the first 5 bytes per RFC 9001 §5.4.3.
|
// Mask is the first 5 bytes per RFC 9001 §5.4.3.
|
||||||
val mask = ByteArray(5)
|
scratch16.copyInto(dstMask, 0, 0, 5)
|
||||||
scratch.copyInto(mask, 0, 0, 5)
|
return dstMask
|
||||||
return mask
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +129,32 @@ class ChaCha20HeaderProtection(
|
|||||||
hpKey: ByteArray,
|
hpKey: ByteArray,
|
||||||
src: ByteArray,
|
src: ByteArray,
|
||||||
srcOffset: Int,
|
srcOffset: Int,
|
||||||
|
): ByteArray = chacha20Mask(hpKey, src, srcOffset)
|
||||||
|
|
||||||
|
override fun maskInto(
|
||||||
|
hpKey: ByteArray,
|
||||||
|
src: ByteArray,
|
||||||
|
srcOffset: Int,
|
||||||
|
scratch16: ByteArray,
|
||||||
|
dstMask: ByteArray,
|
||||||
|
): ByteArray {
|
||||||
|
require(dstMask.size >= 5) { "HP mask buffer must be at least 5 bytes" }
|
||||||
|
// ChaCha20 HP unavoidably allocates a fresh 5-byte ciphertext via the
|
||||||
|
// [ChaCha20BlockEncrypt] SPI, plus a 12-byte nonce slice (Quartz's
|
||||||
|
// `ChaCha20Core.chaCha20Xor` takes a standalone nonce). Copy the
|
||||||
|
// result into the caller's [dstMask] so call-site shape matches the
|
||||||
|
// AES-ECB path; a future pass could push src+offset through the
|
||||||
|
// ChaCha20 SPI to fully eliminate the slice. [scratch16] is unused
|
||||||
|
// here.
|
||||||
|
val mask = chacha20Mask(hpKey, src, srcOffset)
|
||||||
|
mask.copyInto(dstMask, 0, 0, 5)
|
||||||
|
return dstMask
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chacha20Mask(
|
||||||
|
hpKey: ByteArray,
|
||||||
|
src: ByteArray,
|
||||||
|
srcOffset: Int,
|
||||||
): ByteArray {
|
): ByteArray {
|
||||||
require(srcOffset >= 0 && srcOffset + 16 <= src.size) { "ChaCha20 HP sample window out of range" }
|
require(srcOffset >= 0 && srcOffset + 16 <= src.size) { "ChaCha20 HP sample window out of range" }
|
||||||
require(hpKey.size == 32) { "ChaCha20 HP key must be 32 bytes" }
|
require(hpKey.size == 32) { "ChaCha20 HP key must be 32 bytes" }
|
||||||
@@ -107,10 +163,6 @@ class ChaCha20HeaderProtection(
|
|||||||
((src[srcOffset + 1].toInt() and 0xFF) shl 8) or
|
((src[srcOffset + 1].toInt() and 0xFF) shl 8) or
|
||||||
((src[srcOffset + 2].toInt() and 0xFF) shl 16) or
|
((src[srcOffset + 2].toInt() and 0xFF) shl 16) or
|
||||||
((src[srcOffset + 3].toInt() and 0xFF) shl 24)
|
((src[srcOffset + 3].toInt() and 0xFF) shl 24)
|
||||||
// The nonce slice is unavoidable as long as we delegate to Quartz's
|
|
||||||
// `ChaCha20Core.chaCha20Xor(plaintext, key, nonce, counter)` which
|
|
||||||
// takes a standalone nonce ByteArray. A future pass could thread
|
|
||||||
// src + offset all the way down.
|
|
||||||
val nonce = src.copyOfRange(srcOffset + 4, srcOffset + 16)
|
val nonce = src.copyOfRange(srcOffset + 4, srcOffset + 16)
|
||||||
return chacha20Encrypt.encrypt(hpKey, nonce, counter, ByteArray(5))
|
return chacha20Encrypt.encrypt(hpKey, nonce, counter, ByteArray(5))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,9 +73,12 @@ object LongHeaderPacket {
|
|||||||
hp: HeaderProtection,
|
hp: HeaderProtection,
|
||||||
hpKey: ByteArray,
|
hpKey: ByteArray,
|
||||||
largestAckedInSpace: Long,
|
largestAckedInSpace: Long,
|
||||||
// Round-5 #P2: optional 12-byte scratch the writer can reuse across
|
// Round-5 #P2 + #P1: optional caller-owned scratch buffers. When
|
||||||
// packets. Default = allocate fresh (preserves test ergonomics).
|
// provided the build path allocates nothing for nonce / HP work.
|
||||||
|
// Default = allocate fresh on each call (preserves tests).
|
||||||
nonceScratch: ByteArray? = null,
|
nonceScratch: ByteArray? = null,
|
||||||
|
hpScratch: ByteArray? = null,
|
||||||
|
hpMask: ByteArray? = null,
|
||||||
): ByteArray {
|
): ByteArray {
|
||||||
val pnLen =
|
val pnLen =
|
||||||
com.vitorpamplona.quic.connection.PacketNumberSpaceState.encodeLength(
|
com.vitorpamplona.quic.connection.PacketNumberSpaceState.encodeLength(
|
||||||
@@ -142,11 +145,17 @@ object LongHeaderPacket {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Apply header protection. Sample is 16 bytes starting 4 bytes after pnOffset.
|
// Apply header protection. Sample is 16 bytes starting 4 bytes after pnOffset.
|
||||||
// Round-5 #P1: read the sample window directly from `packet` instead
|
// Round-5 #P1: read the sample window directly from `packet`. When
|
||||||
// of allocating a 16-byte copyOfRange per outbound long-header packet.
|
// caller-owned hp scratch + mask are provided the HP path allocates
|
||||||
|
// zero bytes per packet; otherwise allocate fresh (test path).
|
||||||
val sampleStart = pnOffset + 4
|
val sampleStart = pnOffset + 4
|
||||||
require(sampleStart + 16 <= packet.size) { "packet too short for HP sample" }
|
require(sampleStart + 16 <= packet.size) { "packet too short for HP sample" }
|
||||||
val mask = hp.maskAt(hpKey, packet, sampleStart)
|
val mask =
|
||||||
|
if (hpScratch != null && hpMask != null) {
|
||||||
|
hp.maskInto(hpKey, packet, sampleStart, hpScratch, hpMask)
|
||||||
|
} else {
|
||||||
|
hp.maskAt(hpKey, packet, sampleStart)
|
||||||
|
}
|
||||||
applyHeaderProtectionMask(packet, firstByteOffset, pnOffset, pnLen, mask)
|
applyHeaderProtectionMask(packet, firstByteOffset, pnOffset, pnLen, mask)
|
||||||
|
|
||||||
return packet
|
return packet
|
||||||
@@ -169,9 +178,12 @@ object LongHeaderPacket {
|
|||||||
hp: HeaderProtection,
|
hp: HeaderProtection,
|
||||||
hpKey: ByteArray,
|
hpKey: ByteArray,
|
||||||
largestReceivedInSpace: Long,
|
largestReceivedInSpace: Long,
|
||||||
// Round-5 #P2: optional 12-byte scratch the parser reuses across
|
// Round-5 #P2 + #P1: optional caller-owned scratch buffers. When
|
||||||
// inbound packets. Default = allocate fresh (preserves tests).
|
// provided the parse path allocates nothing for nonce / HP work.
|
||||||
|
// Default = allocate fresh on each call (preserves tests).
|
||||||
nonceScratch: ByteArray? = null,
|
nonceScratch: ByteArray? = null,
|
||||||
|
hpScratch: ByteArray? = null,
|
||||||
|
hpMask: ByteArray? = null,
|
||||||
): ParseResult? {
|
): ParseResult? {
|
||||||
val packetStart = offset
|
val packetStart = offset
|
||||||
val r = QuicReader(bytes, offset)
|
val r = QuicReader(bytes, offset)
|
||||||
@@ -209,7 +221,12 @@ object LongHeaderPacket {
|
|||||||
// Sample for HP starts at pnOffset + 4.
|
// Sample for HP starts at pnOffset + 4.
|
||||||
val sampleStart = pnOffset + 4
|
val sampleStart = pnOffset + 4
|
||||||
if (sampleStart + 16 > bytes.size) return null
|
if (sampleStart + 16 > bytes.size) return null
|
||||||
val mask = hp.maskAt(hpKey, bytes, sampleStart)
|
val mask =
|
||||||
|
if (hpScratch != null && hpMask != null) {
|
||||||
|
hp.maskInto(hpKey, bytes, sampleStart, hpScratch, hpMask)
|
||||||
|
} else {
|
||||||
|
hp.maskAt(hpKey, bytes, sampleStart)
|
||||||
|
}
|
||||||
|
|
||||||
// Make a private copy of the packet so we can mutate the header in place.
|
// Make a private copy of the packet so we can mutate the header in place.
|
||||||
val packetEnd = pnOffset + length
|
val packetEnd = pnOffset + length
|
||||||
|
|||||||
@@ -54,9 +54,16 @@ object ShortHeaderPacket {
|
|||||||
hp: HeaderProtection,
|
hp: HeaderProtection,
|
||||||
hpKey: ByteArray,
|
hpKey: ByteArray,
|
||||||
largestAckedInSpace: Long,
|
largestAckedInSpace: Long,
|
||||||
// Round-5 #P2: optional 12-byte scratch the writer can reuse across
|
// Round-5 #P2: optional 12-byte AEAD-nonce scratch the writer can
|
||||||
// packets. Default = allocate fresh (preserves test ergonomics).
|
// reuse across packets. Default = allocate fresh (preserves tests).
|
||||||
nonceScratch: ByteArray? = null,
|
nonceScratch: ByteArray? = null,
|
||||||
|
// Round-5 #P1: optional 16-byte AES-ECB scratch + 5-byte mask
|
||||||
|
// buffer for header protection. When BOTH are provided the call
|
||||||
|
// site reuses them across packets and the HP path allocates
|
||||||
|
// nothing per packet. Either-null (or both-null) falls back to
|
||||||
|
// the maskAt path which allocates fresh (preserves tests).
|
||||||
|
hpScratch: ByteArray? = null,
|
||||||
|
hpMask: ByteArray? = null,
|
||||||
): ByteArray {
|
): ByteArray {
|
||||||
val pnLen = PacketNumberSpaceState.encodeLength(plain.packetNumber, largestAckedInSpace)
|
val pnLen = PacketNumberSpaceState.encodeLength(plain.packetNumber, largestAckedInSpace)
|
||||||
require(pnLen in 1..4)
|
require(pnLen in 1..4)
|
||||||
@@ -107,9 +114,15 @@ object ShortHeaderPacket {
|
|||||||
|
|
||||||
val sampleStart = pnOffset + 4
|
val sampleStart = pnOffset + 4
|
||||||
require(sampleStart + 16 <= packet.size) { "packet too short for HP sample" }
|
require(sampleStart + 16 <= packet.size) { "packet too short for HP sample" }
|
||||||
// Round-5 #P1: read the sample window directly from `packet` — pre-
|
// Round-5 #P1: when caller-owned HP scratch + mask buffers are
|
||||||
// fix this allocated a fresh 16-byte ByteArray on every outbound.
|
// provided, write the mask in-place — zero per-packet allocation.
|
||||||
val mask = hp.maskAt(hpKey, packet, sampleStart)
|
// Otherwise allocate fresh (test path).
|
||||||
|
val mask =
|
||||||
|
if (hpScratch != null && hpMask != null) {
|
||||||
|
hp.maskInto(hpKey, packet, sampleStart, hpScratch, hpMask)
|
||||||
|
} else {
|
||||||
|
hp.maskAt(hpKey, packet, sampleStart)
|
||||||
|
}
|
||||||
applyHeaderProtectionMask(packet, firstByteOffset, pnOffset, pnLen, mask)
|
applyHeaderProtectionMask(packet, firstByteOffset, pnOffset, pnLen, mask)
|
||||||
return packet
|
return packet
|
||||||
}
|
}
|
||||||
@@ -136,6 +149,10 @@ object ShortHeaderPacket {
|
|||||||
dcidLen: Int,
|
dcidLen: Int,
|
||||||
hp: HeaderProtection,
|
hp: HeaderProtection,
|
||||||
hpKey: ByteArray,
|
hpKey: ByteArray,
|
||||||
|
// Round-5 #P1: optional caller-owned HP scratch + mask. Both null
|
||||||
|
// = allocate fresh (test path); both non-null = zero-alloc.
|
||||||
|
hpScratch: ByteArray? = null,
|
||||||
|
hpMask: ByteArray? = null,
|
||||||
): Peek? {
|
): Peek? {
|
||||||
if (offset >= bytes.size) return null
|
if (offset >= bytes.size) return null
|
||||||
val first = bytes[offset].toInt() and 0xFF
|
val first = bytes[offset].toInt() and 0xFF
|
||||||
@@ -148,7 +165,12 @@ object ShortHeaderPacket {
|
|||||||
val pnOffset = offset + 1 + dcidLen
|
val pnOffset = offset + 1 + dcidLen
|
||||||
val sampleStart = pnOffset + 4
|
val sampleStart = pnOffset + 4
|
||||||
if (sampleStart + 16 > bytes.size) return null
|
if (sampleStart + 16 > bytes.size) return null
|
||||||
val mask = hp.maskAt(hpKey, bytes, sampleStart)
|
val mask =
|
||||||
|
if (hpScratch != null && hpMask != null) {
|
||||||
|
hp.maskInto(hpKey, bytes, sampleStart, hpScratch, hpMask)
|
||||||
|
} else {
|
||||||
|
hp.maskAt(hpKey, bytes, sampleStart)
|
||||||
|
}
|
||||||
val unprotectedFirst = first xor (mask[0].toInt() and 0x1F)
|
val unprotectedFirst = first xor (mask[0].toInt() and 0x1F)
|
||||||
return Peek(
|
return Peek(
|
||||||
keyPhase = (unprotectedFirst and 0x04) != 0,
|
keyPhase = (unprotectedFirst and 0x04) != 0,
|
||||||
@@ -172,9 +194,12 @@ object ShortHeaderPacket {
|
|||||||
hp: HeaderProtection,
|
hp: HeaderProtection,
|
||||||
hpKey: ByteArray,
|
hpKey: ByteArray,
|
||||||
largestReceivedInSpace: Long,
|
largestReceivedInSpace: Long,
|
||||||
// Round-5 #P2: optional 12-byte scratch the parser reuses across
|
// Round-5 #P2 + #P1: optional caller-owned scratch buffers. When
|
||||||
// inbound packets. Default = allocate fresh (preserves tests).
|
// provided the parse path allocates nothing for nonce / HP work.
|
||||||
|
// Default = allocate fresh on each call (preserves tests).
|
||||||
nonceScratch: ByteArray? = null,
|
nonceScratch: ByteArray? = null,
|
||||||
|
hpScratch: ByteArray? = null,
|
||||||
|
hpMask: ByteArray? = null,
|
||||||
): ParseResult? {
|
): ParseResult? {
|
||||||
if (offset >= bytes.size) return null
|
if (offset >= bytes.size) return null
|
||||||
val first = bytes[offset].toInt() and 0xFF
|
val first = bytes[offset].toInt() and 0xFF
|
||||||
@@ -185,7 +210,12 @@ object ShortHeaderPacket {
|
|||||||
val pnOffset = offset + 1 + dcidLen
|
val pnOffset = offset + 1 + dcidLen
|
||||||
val sampleStart = pnOffset + 4
|
val sampleStart = pnOffset + 4
|
||||||
if (sampleStart + 16 > bytes.size) return null
|
if (sampleStart + 16 > bytes.size) return null
|
||||||
val mask = hp.maskAt(hpKey, bytes, sampleStart)
|
val mask =
|
||||||
|
if (hpScratch != null && hpMask != null) {
|
||||||
|
hp.maskInto(hpKey, bytes, sampleStart, hpScratch, hpMask)
|
||||||
|
} else {
|
||||||
|
hp.maskAt(hpKey, bytes, sampleStart)
|
||||||
|
}
|
||||||
val packetEnd = bytes.size
|
val packetEnd = bytes.size
|
||||||
val packet = bytes.copyOfRange(offset, packetEnd)
|
val packet = bytes.copyOfRange(offset, packetEnd)
|
||||||
val localPnOffset = pnOffset - offset
|
val localPnOffset = pnOffset - offset
|
||||||
|
|||||||
@@ -28,9 +28,14 @@ import java.security.MessageDigest
|
|||||||
* stock JDK SHA-256 providers AND by Android's Conscrypt
|
* stock JDK SHA-256 providers AND by Android's Conscrypt
|
||||||
* `OpenSSLMessageDigestJDK` on shipping releases — but a handful of API
|
* `OpenSSLMessageDigestJDK` on shipping releases — but a handful of API
|
||||||
* 26–28 builds have been reported to throw `CloneNotSupportedException`
|
* 26–28 builds have been reported to throw `CloneNotSupportedException`
|
||||||
* from the native digest. Detect that on first snapshot and fall back to
|
* from the native digest.
|
||||||
* one-shot SHA-256 over an accumulated byte buffer; we keep accumulating
|
*
|
||||||
* regardless so the fallback always has the complete transcript to hash.
|
* We probe `clone()` ONCE at construction: if it works the JCA-clone path
|
||||||
|
* runs for the lifetime of the instance with zero side-buffer overhead;
|
||||||
|
* if it doesn't we switch to byte-accumulator-then-one-shot-SHA-256 mode
|
||||||
|
* from the start, so the very first snapshot already has the complete
|
||||||
|
* transcript. Working devices (the overwhelming majority) pay nothing
|
||||||
|
* for the fallback.
|
||||||
*
|
*
|
||||||
* Single-thread per instance: the TlsClient state machine is the sole caller,
|
* Single-thread per instance: the TlsClient state machine is the sole caller,
|
||||||
* driven by the QUIC connection's lock, so no synchronization is needed.
|
* driven by the QUIC connection's lock, so no synchronization is needed.
|
||||||
@@ -38,36 +43,45 @@ import java.security.MessageDigest
|
|||||||
actual class TlsRunningSha256 actual constructor() {
|
actual class TlsRunningSha256 actual constructor() {
|
||||||
private val digest: MessageDigest = MessageDigest.getInstance("SHA-256")
|
private val digest: MessageDigest = MessageDigest.getInstance("SHA-256")
|
||||||
|
|
||||||
// Parallel byte accumulator — small (a few KB for a TLS transcript),
|
/**
|
||||||
// and only consulted on the cloneable-digest fallback path. Keeping it
|
* Result of the one-time `digest.clone()` probe. Conscrypt's clone
|
||||||
// populated unconditionally costs one `ByteArrayOutputStream.write` per
|
* support is a property of the build (native bridge presence), not of
|
||||||
// [update] but avoids a "first snapshot fails, we have no history"
|
* the digest's internal state, so a single probe is a reliable signal
|
||||||
// failure mode on the broken-clone Android builds.
|
* — repeated clones on the same instance behave identically.
|
||||||
private val accumulator = ByteArrayOutputStream(512)
|
*/
|
||||||
private var cloneable = true
|
private val cloneable: Boolean =
|
||||||
|
try {
|
||||||
|
digest.clone()
|
||||||
|
true
|
||||||
|
} catch (_: CloneNotSupportedException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Byte accumulator allocated only on the broken-clone fallback path.
|
||||||
|
* Holds every byte fed to [update] so [snapshot] can one-shot
|
||||||
|
* SHA-256 the full transcript. On clone-capable devices this stays
|
||||||
|
* `null` and [update] never touches it.
|
||||||
|
*/
|
||||||
|
private val accumulator: ByteArrayOutputStream? =
|
||||||
|
if (cloneable) null else ByteArrayOutputStream(512)
|
||||||
|
|
||||||
actual fun update(bytes: ByteArray) {
|
actual fun update(bytes: ByteArray) {
|
||||||
digest.update(bytes)
|
digest.update(bytes)
|
||||||
accumulator.write(bytes)
|
accumulator?.write(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun snapshot(): ByteArray {
|
actual fun snapshot(): ByteArray {
|
||||||
if (cloneable) {
|
if (cloneable) {
|
||||||
try {
|
// Cheap path — independent digest object with current internal
|
||||||
// Cloning the digest is the cheap path — independent digest
|
// state, no consume.
|
||||||
// object with the current internal state, no consume.
|
val clone = digest.clone() as MessageDigest
|
||||||
val clone = digest.clone() as MessageDigest
|
return clone.digest()
|
||||||
return clone.digest()
|
|
||||||
} catch (_: CloneNotSupportedException) {
|
|
||||||
// Latch the fallback so we don't pay the JCA throw on every
|
|
||||||
// subsequent snapshot.
|
|
||||||
cloneable = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Fallback: one-shot SHA-256 over the accumulated transcript bytes.
|
// Fallback: one-shot SHA-256 over the accumulated transcript bytes.
|
||||||
// `MessageDigest.getInstance("SHA-256")` is mandated on every JCA
|
// `MessageDigest.getInstance("SHA-256")` is mandated on every JCA
|
||||||
// provider — only the `.clone()` capability varies — so this is
|
// provider — only `.clone()` capability varies — so this is
|
||||||
// guaranteed to work on the same device that rejected the clone.
|
// guaranteed to work on the same device that rejected the clone.
|
||||||
return MessageDigest.getInstance("SHA-256").digest(accumulator.toByteArray())
|
return MessageDigest.getInstance("SHA-256").digest(accumulator!!.toByteArray())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user