Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions Sources/SwiftNetwork/QUIC/PacketParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,8 @@ struct PacketParser: ~Copyable, PrefixedLoggable {
(connection.keyState == .phase0 || connection.keyState == .phase1)
&& (packet.keyState == .phase0 || packet.keyState == .phase1)
&& connection.keyState != packet.keyState
// While a locally-initiated key update is pending, the other phase is the peer's previous one
&& !connection.keyUpdatePending
) {
protector.trafficUpdate(previousKeyState: connection.keyState)
}
Expand Down
66 changes: 65 additions & 1 deletion Sources/SwiftNetwork/QUIC/Protector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ struct SecFramerKeys: ~Copyable {
#if canImport(CommonCrypto)
let headerProtectionCryptor: HeaderProtectionCryptor
#endif
// Number of packets sealed with this key (RFC 9001, Section 6.6)
var sealCount: UInt64 = 0

init(
key: SymmetricKey,
Expand Down Expand Up @@ -736,10 +738,21 @@ struct Protector: ~Copyable, PrefixedLoggable {
repeating: PacketNumber.initial,
count: PacketNumberSpace.allCases.count
)
// RFC 9001, Section 6.6: an AES-GCM key must not seal more than 2^23 packets.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RFC uses a conservative value. In practice, packets are never 64KB, so our limit is around ~2^28, but we do need to limit it based on the RFC.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, kept 2^23 cuz cheap and unconditional

// ChaCha20-Poly1305 has no reachable confidentiality limit.
let aesGCMConfidentialityLimit: UInt64
// Packets that failed authentication, across all keys (RFC 9001, Section 6.6)
var failedDecryptionCount: UInt64 = 0

init(isClient: Bool, destinationCID: QUICConnectionID, logPrefixer: LogPrefixer) {
init(
isClient: Bool,
destinationCID: QUICConnectionID,
logPrefixer: LogPrefixer,
aesGCMConfidentialityLimit: UInt64 = 1 << 23
) {
self.isClient = isClient
self.log = logPrefixer
self.aesGCMConfidentialityLimit = aesGCMConfidentialityLimit
// N.B. right now the Protector only supports AESGCM
for _ in 0..<5 {
writeFramer.append(SecFramerKeys.empty(type: .aesGCM))
Expand Down Expand Up @@ -1050,7 +1063,12 @@ struct Protector: ~Copyable, PrefixedLoggable {
guard let keyStateIndex: Int = packet.keyState?.rawValue else {
throw QUICError.protector(SecFramerError.noFramerFound)
}
guard !sealLimitReached(keys: writeFramer[keyStateIndex], limit: aesGCMConfidentialityLimit) else {
log.error("AEAD confidentiality limit reached for \(packet.keyState!.description)")
throw QUICError.protector(SecFramerError.sealingFailed)
}
try Self.sealInner(&packet, frame: &frame, keys: writeFramer[keyStateIndex])
writeFramer[keyStateIndex].sealCount += 1
QUICSignpost.sealEnd(signpostInterval)
// Upon success, increment the sequence number all the way to the
// the last recently used one because there could be gaps.
Expand Down Expand Up @@ -1415,6 +1433,36 @@ struct Protector: ~Copyable, PrefixedLoggable {
trafficUpdate(previousKeyState: previousKeyState, isWrite: true)
}

@inline(always)
private func sealLimitReached(keys: borrowing SecFramerKeys, limit: UInt64) -> Bool {
keys.type == .aesGCM && keys.sealCount >= limit
}

/// Whether the write key has used up half of its confidentiality limit, so
/// that a key update can complete before the limit is reached.
@inline(always)
func keyUpdateNeeded(for keyState: PacketKeyState) -> Bool {
sealLimitReached(keys: writeFramer[keyState.rawValue], limit: aesGCMConfidentialityLimit / 2)
}

/// Whether the write key has a single packet left before its confidentiality limit.
/// That packet is kept for a CONNECTION_CLOSE.
@inline(always)
func sealLimitImminent(for keyState: PacketKeyState) -> Bool {
sealLimitReached(keys: writeFramer[keyState.rawValue], limit: aesGCMConfidentialityLimit - 1)
}

/// The number of packets that may fail authentication (RFC 9001, Section 6.6).
func integrityLimit(for keyState: PacketKeyState) -> UInt64 {
keyType(keys: readFramer[keyState.rawValue]) == .chaChaPoly ? 1 << 36 : 1 << 52
}

/// Counts a packet that failed authentication and returns whether the integrity limit is exceeded.
mutating func failedDecryption(for keyState: PacketKeyState) -> Bool {
failedDecryptionCount += 1
return failedDecryptionCount > integrityLimit(for: keyState)
}

@inline(always)
func getPacketNumber(
for packetNumberSpace: PacketNumberSpace
Expand Down Expand Up @@ -1579,6 +1627,22 @@ struct Protector: ~Copyable, PrefixedLoggable {
func trafficUpdate(previousKeyState: PacketKeyState) {
}

func keyUpdateNeeded(for keyState: PacketKeyState) -> Bool {
false
}

func sealLimitImminent(for keyState: PacketKeyState) -> Bool {
false
}

func integrityLimit(for keyState: PacketKeyState) -> UInt64 {
.max
}

func failedDecryption(for keyState: PacketKeyState) -> Bool {
false
}

func open(_ packet: inout Packet, frame: inout Frame) throws(QUICError) {
}

Expand Down
54 changes: 52 additions & 2 deletions Sources/SwiftNetwork/QUIC/QUICConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,10 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol,
private(set) var packetParser: PacketParser

private(set) var keyState = PacketKeyState.initial
// Set from initiating a key update until the peer responds in the new key phase
private(set) var keyUpdatePending = false
// The first application packet number of the current key phase (RFC 9001, Section 6.1)
private var keyPhaseFirstPacketNumber: PacketNumber = 0
var remoteMaxDatagramFrameSize = 0
var remoteMaximumUDPPayloadSize = 0

Expand Down Expand Up @@ -413,7 +417,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol,
private(set) var maximumConcurrentBidirectionalStreams: Int?
private(set) var maximumConcurrentUnidirectionalStreams: Int?

private var originalDCID: QUICConnectionID
private(set) var originalDCID: QUICConnectionID
var initialDCID: QUICConnectionID?
var initialToken: [UInt8]?
var newToken: [UInt8]?
Expand Down Expand Up @@ -1934,6 +1938,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol,
transferredPacket,
path: path,
ack: &ack,
protector: &protector,
in: &eventContext
)
}
Expand Down Expand Up @@ -2037,6 +2042,10 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol,
}
}

if !packet.longHeader, !state.isTerminal {
updateKeysIfNeeded(in: &eventContext)
}

if unvalidatedPath {
sendFrames(on: path, in: &eventContext)
}
Expand Down Expand Up @@ -2078,6 +2087,32 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol,
return true
}

// RFC 9001, Section 6.6: initiate a key update before the AEAD confidentiality limit, or
// close while the key can still protect the CONNECTION_CLOSE. This runs after the frames
// of a packet so that an ACK it carries counts.
private func updateKeysIfNeeded(in eventContext: inout NetworkContext.EventContext) {
guard _slowPath(protector.keyUpdateNeeded(for: keyState)) else {
return
}
// RFC 9001, Section 6.1: not before the handshake is confirmed, nor before the peer has
// acknowledged a packet from the current key phase
if !keyUpdatePending, isHandshakeConfirmed,
largestAckedApplicationPacketNumber >= keyPhaseFirstPacketNumber
{
log.notice("Initiating key update from \(keyState)")
protector.trafficUpdate(previousKeyState: keyState)
keyState = keyState == .phase0 ? .phase1 : .phase0
keyPhaseFirstPacketNumber = protector.getPacketNumber(for: .applicationData)
keyUpdatePending = true
} else if protector.sealLimitImminent(for: keyState) {
close(with: .aeadLimitReached, "key update not possible", in: &eventContext)
} else if !keyUpdatePending, isHandshakeConfirmed {
// Nothing from this key phase has been acknowledged, which stays that way for
// an endpoint that only sends ACKs. Elicit an acknowledgment.
withPendingItems(for: .applicationData) { $0.ping = true }
}
}

private func handleInboundVersionNegotiation(
_ packet: borrowing Packet,
in eventContext: inout NetworkContext.EventContext
Expand Down Expand Up @@ -2540,15 +2575,19 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol,
_ packet: borrowing Packet,
path: QUICPath,
ack: inout Ack,
protector: inout Protector,
in eventContext: inout NetworkContext.EventContext
) -> Bool {
guard let packetKeyState = packet.keyState else {
log.error("Received short header without keystate set")
return false
}
if packetKeyState != keyState {
if packetKeyState == keyState {
keyUpdatePending = false
} else if !keyUpdatePending {
log.notice("Switching to keystate \(packetKeyState)")
keyState = packetKeyState
keyPhaseFirstPacketNumber = protector.getPacketNumber(for: .applicationData)
}

ack.append(
Expand Down Expand Up @@ -4076,6 +4115,12 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol,
guard protector.sealKeyReady(for: keyState) else {
return false
}
// RFC 9001, Section 6.6: the last packet a key may protect is kept for a CONNECTION_CLOSE
if _slowPath(protector.sealLimitImminent(for: keyState)),
!pendingItems.connectionClose, !pendingItems.applicationClose
{
return false
}

var largestAcked = largestAckedPacketNumber(space: packetNumberSpace)
largestAcked = largestAcked.value == Int.max ? PacketNumber.none : largestAcked
Expand Down Expand Up @@ -4645,6 +4690,11 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol,
_ packet: borrowing Packet,
in eventContext: inout NetworkContext.EventContext
) {
if let keyState = packet.keyState, protector.failedDecryption(for: keyState) {
closeError = QUICTransportError(.aeadLimitReached, "AEAD integrity limit reached")
close(in: &eventContext)
return
}
if packet.tagLength == Constants.statelessResetTokenSize,
let packetToken = packet.tag,
let statelessToken = QUICStatelessResetToken(packetToken)
Expand Down
130 changes: 130 additions & 0 deletions Tests/QUICTests/ProtectorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1762,6 +1762,136 @@ final class ProtectorTests: XCTestCase {
)
}

private func sealOne(
_ protector: inout Protector,
number: Int64,
keyState: PacketKeyState
) throws {
var frame = Frame(copyBuffer: [UInt8](repeating: 0, count: 133))
defer {
frame.finalize(success: true)
}
var packet = Packet(
number: PacketNumber(number),
lastAcked: 0,
keyState: keyState
)
packet.overrideSentNumberSize = .twoBytes

packet.headerLength = 17
packet.payloadLength = 116
packet.tagLength = 16
packet.packetNumberLength = 2

try protector.seal(&packet, frame: &frame)
}

func testConfidentialityLimit() throws {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please add a test for failedDecryptionCount?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added testIntegrityLimit in 866641c, the count increments per failed packet, carries over a key update and failedDrcryption(for:) returns once count exceeds integrity limit. nb failedDecrytionCount was made settable similar to aesGCMConfidentialityLimit, if that's ok?

@deadcaf3 deadcaf3 Oct 5, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nb section 6.6 goes 2^52 limit for aes gcm and 2^36 for chacha20-poly1305, documented in the description

let cid = QUICConnectionID([0x59, 0x26, 0xf7, 0x05, 0xd0, 0xe0, 0x97, 0x98])!
let secret = SymmetricKey(data: [UInt8](repeating: 0x2a, count: 32))
let defaultProtector = Protector(
isClient: true,
destinationCID: cid,
logPrefixer: protectorTestsLogPrefixer
)
XCTAssertEqual(defaultProtector.aesGCMConfidentialityLimit, 1 << 23)

var protector = Protector(
isClient: true,
destinationCID: cid,
logPrefixer: protectorTestsLogPrefixer,
aesGCMConfidentialityLimit: 4
)
for isWrite in [true, false] {
protector.keyUpdate(
for: .application,
cipherSuite: .aesGCM128SHA256,
secret: secret,
isWrite: isWrite
)
}
XCTAssertEqual(protector.integrityLimit(for: .phase0), 1 << 52)

try sealOne(&protector, number: 0, keyState: .phase0)
XCTAssertFalse(protector.keyUpdateNeeded(for: .phase0))
try sealOne(&protector, number: 1, keyState: .phase0)
XCTAssertTrue(protector.keyUpdateNeeded(for: .phase0))
XCTAssertFalse(protector.sealLimitImminent(for: .phase0))
try sealOne(&protector, number: 2, keyState: .phase0)
// Only the packet kept for a CONNECTION_CLOSE is left
XCTAssertTrue(protector.sealLimitImminent(for: .phase0))
try sealOne(&protector, number: 3, keyState: .phase0)
// The key has reached its limit and must not seal another packet
XCTAssertThrowsError(try sealOne(&protector, number: 4, keyState: .phase0))

// The updated key starts a new count
protector.trafficUpdate(previousKeyState: .phase0)
XCTAssertFalse(protector.keyUpdateNeeded(for: .phase1))
try sealOne(&protector, number: 4, keyState: .phase1)

// ChaCha20-Poly1305 has no confidentiality limit to enforce
var chachaProtector = Protector(
isClient: true,
destinationCID: cid,
logPrefixer: protectorTestsLogPrefixer,
aesGCMConfidentialityLimit: 4
)
for isWrite in [true, false] {
chachaProtector.keyUpdate(
for: .application,
cipherSuite: .chacha20Poly1350SHA256,
secret: secret,
isWrite: isWrite
)
}
XCTAssertEqual(chachaProtector.integrityLimit(for: .phase0), 1 << 36)
for number: Int64 in 0..<5 {
try sealOne(&chachaProtector, number: number, keyState: .phase0)
}
XCTAssertFalse(chachaProtector.keyUpdateNeeded(for: .phase0))
}

func testIntegrityLimit() throws {
let cid = QUICConnectionID([0x59, 0x26, 0xf7, 0x05, 0xd0, 0xe0, 0x97, 0x98])!
let secret = SymmetricKey(data: [UInt8](repeating: 0x2a, count: 32))
let limits: [(TLSCipherSuite, UInt64)] = [
(.aesGCM128SHA256, 1 << 52),
(.chacha20Poly1350SHA256, 1 << 36),
]
for (cipherSuite, limit) in limits {
var protector = Protector(
isClient: true,
destinationCID: cid,
logPrefixer: protectorTestsLogPrefixer
)
for isWrite in [true, false] {
protector.keyUpdate(
for: .application,
cipherSuite: cipherSuite,
secret: secret,
isWrite: isWrite
)
}
XCTAssertEqual(protector.integrityLimit(for: .phase0), limit)
XCTAssertEqual(protector.failedDecryptionCount, 0)

XCTAssertFalse(protector.failedDecryption(for: .phase0))
XCTAssertEqual(protector.failedDecryptionCount, 1)

// The count covers all keys, so a key update does not reset it
protector.trafficUpdate(previousKeyState: .phase0)
XCTAssertFalse(protector.failedDecryption(for: .phase1))
XCTAssertEqual(protector.failedDecryptionCount, 2)

// Reaching the limit is allowed, exceeding it is not
protector.failedDecryptionCount = limit - 1
XCTAssertFalse(protector.failedDecryption(for: .phase1))
XCTAssertEqual(protector.failedDecryptionCount, limit)
XCTAssertTrue(protector.failedDecryption(for: .phase1))
XCTAssertEqual(protector.failedDecryptionCount, limit + 1)
}
}

func testRetryProtectionOpen() throws {
// RFC 9001 Appendix A.4 Retry
let retryPacket: [UInt8] = [
Expand Down
Loading