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:
+36
-10
@@ -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
|
||||
}
|
||||
|
||||
+214
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user