Merge pull request #2734 from vitorpamplona/claude/fix-moq-protocol-exception-q1gTL

Fix header protection sampling for short QUIC payloads
This commit is contained in:
Vitor Pamplona
2026-05-05 10:56:49 -04:00
committed by GitHub
4 changed files with 283 additions and 15 deletions
@@ -70,14 +70,39 @@ class CreateNestViewModel : ViewModel() {
fun bindAccountIfMissing(accountViewModel: AccountViewModel) {
if (account != null) return
account = accountViewModel
// Seed the URL fields from the user's saved kind-10112 list so
// first-time use flows naturally from the Settings screen. If
// the list is empty (or the user hasn't published one yet), keep
// the nostrnests.com defaults already in [FormState.defaults].
seedDefaultsFromAccount()
}
/**
* Reset the form to fresh defaults and re-seed the service / endpoint
* URLs from the user's saved kind-10112 list. Called after a
* successful publish so the next open of the sheet doesn't show
* stale fields (room name, summary, image URL) or — worse — a stuck
* `isPublishing=true` Submit button. The ViewModel is keyed by the
* user's pubkey, so without this reset the same instance survives
* across sheet open/close cycles.
*/
fun resetForm() {
_state.value = FormState.defaults()
seedDefaultsFromAccount()
}
/**
* Seed the URL fields from the user's saved kind-10112 list so
* first-time use flows naturally from the Settings screen. If the
* list is empty (or the user hasn't published one yet), keep the
* nostrnests.com defaults already in [FormState.defaults].
*/
private fun seedDefaultsFromAccount() {
val first =
accountViewModel.account.nestsServers.flow.value
.firstOrNull()
if (first != null && first.relay.startsWith("http") && first.auth.startsWith("http")) {
account
?.account
?.nestsServers
?.flow
?.value
?.firstOrNull()
?: return
if (first.relay.startsWith("http") && first.auth.startsWith("http")) {
_state.update { it.copy(serviceUrl = first.auth, endpointUrl = first.relay) }
}
}
@@ -287,9 +312,10 @@ class CreateNestViewModel : ViewModel() {
return null
}
// Don't reset isPublishing here — the sheet dismisses on success
// and the VM is scoped per-composition so the next open starts
// fresh anyway.
// Reset the form so the next open of the sheet starts fresh —
// the VM is keyed by pubkey via `viewModel(key = …)` and is
// reused across sheet open/close cycles.
resetForm()
return RoomLaunchInfo(
addressValue = signed.address().toValue(),
authBaseUrl = service,
@@ -94,8 +94,13 @@ object LongHeaderPacket {
w.writeVarint(plain.token.size.toLong())
w.writeBytes(plain.token)
}
// Length covers PN bytes + payload + AEAD tag.
val lengthValue = pnLen + plain.payload.size + aead.tagLength
// RFC 9001 §5.4.2: pad the plaintext so that pnLen + payload >= 4 — the
// 16-byte AEAD tag then provides the rest of the 20 bytes the HP sample
// window needs after pnOffset. Trailing 0x00 bytes decode as PADDING
// frames (RFC 9000 §19.1). Length covers PN bytes + (padded) payload +
// AEAD tag, so the padded size must feed into lengthValue.
val paddedPlaintext = padForHeaderProtectionSample(plain.payload, pnLen)
val lengthValue = pnLen + paddedPlaintext.size + aead.tagLength
w.writeVarint(lengthValue.toLong())
val pnOffset = w.size
// Encode the packet number big-endian, low bytes
@@ -104,9 +109,9 @@ object LongHeaderPacket {
}
val headerBytes = w.toByteArray()
// Encrypt payload
// Encrypt padded payload
val nonce = aeadNonce(iv, plain.packetNumber)
val ciphertext = aead.seal(key, nonce, headerBytes, plain.payload)
val ciphertext = aead.seal(key, nonce, headerBytes, paddedPlaintext)
// Concatenate header + ciphertext
val packet = ByteArray(headerBytes.size + ciphertext.size)
@@ -69,8 +69,14 @@ object ShortHeaderPacket {
}
val headerBytes = w.toByteArray()
// RFC 9001 §5.4.2: pad the plaintext so that pnLen + payload >= 4. The
// 16-byte AEAD tag then guarantees the encrypted output has the 20
// bytes following pnOffset that header-protection sampling needs.
// Trailing 0x00 bytes decode as PADDING frames (RFC 9000 §19.1).
val paddedPlaintext = padForHeaderProtectionSample(plain.payload, pnLen)
val nonce = aeadNonce(iv, plain.packetNumber)
val ciphertext = aead.seal(key, nonce, headerBytes, plain.payload)
val ciphertext = aead.seal(key, nonce, headerBytes, paddedPlaintext)
val packet = ByteArray(headerBytes.size + ciphertext.size)
headerBytes.copyInto(packet, 0)
@@ -143,3 +149,20 @@ object ShortHeaderPacket {
val consumed: Int,
)
}
/**
* Return [payload] padded with trailing zero bytes so that
* `pnLen + paddedSize >= 4`. After AEAD seal adds the 16-byte tag, the
* resulting ciphertext satisfies the RFC 9001 §5.4.2 requirement that 20
* bytes follow the start of the packet number for the HP sample.
*/
internal fun padForHeaderProtectionSample(
payload: ByteArray,
pnLen: Int,
): ByteArray {
val minSize = (4 - pnLen).coerceAtLeast(0)
if (payload.size >= minSize) return payload
val padded = ByteArray(minSize)
payload.copyInto(padded, 0)
return padded
}
@@ -0,0 +1,214 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quic.packet
import com.vitorpamplona.quic.connection.ConnectionId
import com.vitorpamplona.quic.crypto.Aes128Gcm
import com.vitorpamplona.quic.crypto.AesEcbHeaderProtection
import com.vitorpamplona.quic.crypto.InitialSecrets
import com.vitorpamplona.quic.crypto.PlatformAesOneBlock
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* RFC 9001 §5.4.2: header protection samples 16 bytes starting 4 bytes after
* the packet number offset. The sender MUST pad short plaintext payloads so
* that pnLen + payload >= 4 — otherwise the encrypted output is too short
* for the sample window and the build path tripped a `require` with
* `packet too short for HP sample`.
*
* The crash was reachable from the application path whenever
* [com.vitorpamplona.quic.connection.QuicConnectionWriter.buildApplicationPacket]
* produced a packet whose only frame was a single-byte PING (e.g. a PTO
* probe with no ACKs queued and no streams to drain) while the packet
* number space was still small enough to encode in 1 byte.
*/
class ShortPayloadHeaderProtectionTest {
private val dcid = ConnectionId("8394c8f03e515708".hexToByteArray())
private val scid = ConnectionId("01020304".hexToByteArray())
private val proto = InitialSecrets.derive(dcid.bytes)
private val hp = AesEcbHeaderProtection(PlatformAesOneBlock)
@Test
fun short_header_with_one_byte_ping_payload_round_trips() {
// 0x01 is the on-wire encoding of a PING frame. With pnLen=1 this is
// exactly the path that crashed in production (NestRx audio rooms).
val pingPayload = byteArrayOf(0x01)
val plain =
ShortHeaderPlaintextPacket(
dcid = dcid,
packetNumber = 0L,
payload = pingPayload,
)
val wire =
ShortHeaderPacket.build(
plain = plain,
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestAckedInSpace = -1L,
)
val parsed =
ShortHeaderPacket.parseAndDecrypt(
bytes = wire,
offset = 0,
dcidLen = dcid.length,
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestReceivedInSpace = -1L,
)
assertNotNull(parsed)
assertEquals(0L, parsed.packet.packetNumber)
// Plaintext is padded to 3 bytes (pnLen=1 → minPayload=3); the
// original PING frame survives at offset 0 and trailing zeros decode
// as PADDING frames per RFC 9000 §19.1.
assertTrue(parsed.packet.payload.size >= 3)
assertEquals(0x01.toByte(), parsed.packet.payload[0])
for (i in 1 until parsed.packet.payload.size) {
assertEquals(0x00.toByte(), parsed.packet.payload[i])
}
}
@Test
fun short_header_with_empty_payload_round_trips() {
// An empty payload is a degenerate input but should not crash the
// builder — it should pad to satisfy the HP sample window.
val plain =
ShortHeaderPlaintextPacket(
dcid = dcid,
packetNumber = 0L,
payload = ByteArray(0),
)
val wire =
ShortHeaderPacket.build(
plain = plain,
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestAckedInSpace = -1L,
)
val parsed =
ShortHeaderPacket.parseAndDecrypt(
bytes = wire,
offset = 0,
dcidLen = dcid.length,
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestReceivedInSpace = -1L,
)
assertNotNull(parsed)
assertEquals(0L, parsed.packet.packetNumber)
}
@Test
fun long_header_with_short_payload_round_trips() {
// Same hazard at the long-header level: a PING-only Initial or
// Handshake packet would also trip the HP sample require.
val pingPayload = byteArrayOf(0x01)
val wire =
LongHeaderPacket.build(
plain =
LongHeaderPlaintextPacket(
type = LongHeaderType.HANDSHAKE,
dcid = dcid,
scid = scid,
packetNumber = 0L,
payload = pingPayload,
),
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestAckedInSpace = -1L,
)
val parsed =
LongHeaderPacket.parseAndDecrypt(
bytes = wire,
offset = 0,
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestReceivedInSpace = -1L,
)
assertNotNull(parsed)
assertEquals(0L, parsed.packet.packetNumber)
assertTrue(parsed.packet.payload.size >= 3)
assertEquals(0x01.toByte(), parsed.packet.payload[0])
for (i in 1 until parsed.packet.payload.size) {
assertEquals(0x00.toByte(), parsed.packet.payload[i])
}
}
@Test
fun payload_at_or_above_threshold_is_unchanged() {
// pnLen=1, payload=4: already satisfies pnLen+payload >= 4. The
// builder MUST NOT add gratuitous padding — a regression there would
// bloat every outbound 1-RTT packet.
val payload = byteArrayOf(0x01, 0x02, 0x03, 0x04)
val plain =
ShortHeaderPlaintextPacket(
dcid = dcid,
packetNumber = 0L,
payload = payload,
)
val wire =
ShortHeaderPacket.build(
plain = plain,
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestAckedInSpace = -1L,
)
val parsed =
ShortHeaderPacket.parseAndDecrypt(
bytes = wire,
offset = 0,
dcidLen = dcid.length,
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestReceivedInSpace = -1L,
)
assertNotNull(parsed)
assertContentEquals(payload, parsed.packet.payload)
}
}