From ef31208da00f0a7e78d6c9040c775a7fd557ae6b Mon Sep 17 00:00:00 2001 From: deadcafe Date: Mon, 5 Oct 2026 01:27:33 +0530 Subject: [PATCH 1/5] QUIC: enforce AEAD usage limits and initiate key updates - Count the packets sealed with each key and refuse to seal once an AES-GCM key reaches its confidentiality limit of 2^23 packets (RFC 9001, Section 6.6) - Initiate a 1-RTT key update once the current key has used half of that limit, keeping the previous read keys until the peer responds in the new key phase - Count packets that fail authentication and close the connection with AEAD_LIMIT_REACHED at the integrity limit, or when the peer never responds to a key update - Add tests covering the per-key limit and an echo with key updates --- Sources/SwiftNetwork/QUIC/PacketParser.swift | 2 + Sources/SwiftNetwork/QUIC/Protector.swift | 35 ++++++++ .../SwiftNetwork/QUIC/QUICConnection.swift | 26 +++++- Tests/QUICTests/ProtectorTests.swift | 80 +++++++++++++++++++ .../SwiftNetworkQUICHarnessTests.swift | 37 +++++++++ 5 files changed, 179 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftNetwork/QUIC/PacketParser.swift b/Sources/SwiftNetwork/QUIC/PacketParser.swift index 40b22418..17772e4c 100644 --- a/Sources/SwiftNetwork/QUIC/PacketParser.swift +++ b/Sources/SwiftNetwork/QUIC/PacketParser.swift @@ -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) } diff --git a/Sources/SwiftNetwork/QUIC/Protector.swift b/Sources/SwiftNetwork/QUIC/Protector.swift index 922479b7..fe902946 100644 --- a/Sources/SwiftNetwork/QUIC/Protector.swift +++ b/Sources/SwiftNetwork/QUIC/Protector.swift @@ -172,6 +172,8 @@ struct SecFramerKeys: ~Copyable { let type: KeyType let isEmpty: Bool let log: LogPrefixer + // Number of packets sealed with this key (RFC 9001, Section 6.6) + var sealCount: UInt64 = 0 init( key: SymmetricKey, @@ -688,6 +690,9 @@ 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. + // ChaCha20-Poly1305 has no reachable confidentiality limit. + var aesGCMConfidentialityLimit: UInt64 = 1 << 23 init(isClient: Bool, destinationCID: QUICConnectionID, logPrefixer: LogPrefixer) { self.isClient = isClient @@ -984,7 +989,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. @@ -1349,6 +1359,23 @@ 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) + } + + /// 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 + } + @inline(always) func getPacketNumber( for packetNumberSpace: PacketNumberSpace @@ -1513,6 +1540,14 @@ struct Protector: ~Copyable, PrefixedLoggable { func trafficUpdate(previousKeyState: PacketKeyState) { } + func keyUpdateNeeded(for keyState: PacketKeyState) -> Bool { + false + } + + func integrityLimit(for keyState: PacketKeyState) -> UInt64 { + .max + } + func open(_ packet: inout Packet, frame: inout Frame) throws(QUICError) { } diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index f729c6b0..63ced489 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -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 + // Packets that failed authentication, across all keys (RFC 9001, Section 6.6) + private(set) var failedDecryptionCount: UInt64 = 0 var remoteMaxDatagramFrameSize = 0 var remoteMaximumUDPPayloadSize = 0 @@ -1941,6 +1945,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, transferredPacket, path: path, ack: &ack, + protector: &protector, in: &eventContext ) } @@ -2547,15 +2552,28 @@ 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 + // RFC 9001, Section 6.6: initiate a key update before the AEAD confidentiality limit + if _slowPath(isHandshakeConfirmed && protector.keyUpdateNeeded(for: keyState)) { + log.notice("Initiating key update from \(keyState)") + protector.trafficUpdate(previousKeyState: keyState) + keyState = keyState == .phase0 ? .phase1 : .phase0 + keyUpdatePending = true + } + } else if !keyUpdatePending { log.notice("Switching to keystate \(packetKeyState)") keyState = packetKeyState + } else if protector.keyUpdateNeeded(for: keyState) { + close(with: .aeadLimitReached, "peer did not respond to key update", in: &eventContext) + return false } ack.append( @@ -4635,6 +4653,12 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, _ packet: borrowing Packet, in eventContext: inout NetworkContext.EventContext ) { + failedDecryptionCount += 1 + if let keyState = packet.keyState, failedDecryptionCount > protector.integrityLimit(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) diff --git a/Tests/QUICTests/ProtectorTests.swift b/Tests/QUICTests/ProtectorTests.swift index 46c0b7dc..f3f7dfbd 100644 --- a/Tests/QUICTests/ProtectorTests.swift +++ b/Tests/QUICTests/ProtectorTests.swift @@ -1762,6 +1762,86 @@ 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 { + let cid = QUICConnectionID([0x59, 0x26, 0xf7, 0x05, 0xd0, 0xe0, 0x97, 0x98])! + let secret = SymmetricKey(data: [UInt8](repeating: 0x2a, count: 32)) + var protector = Protector( + isClient: true, + destinationCID: cid, + logPrefixer: protectorTestsLogPrefixer + ) + for isWrite in [true, false] { + protector.keyUpdate( + for: .application, + cipherSuite: .aesGCM128SHA256, + secret: secret, + isWrite: isWrite + ) + } + XCTAssertEqual(protector.aesGCMConfidentialityLimit, 1 << 23) + XCTAssertEqual(protector.integrityLimit(for: .phase0), 1 << 52) + protector.aesGCMConfidentialityLimit = 4 + + try sealOne(&protector, number: 0, keyState: .phase0) + XCTAssertFalse(protector.keyUpdateNeeded(for: .phase0)) + try sealOne(&protector, number: 1, keyState: .phase0) + XCTAssertTrue(protector.keyUpdateNeeded(for: .phase0)) + try sealOne(&protector, number: 2, keyState: .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 + ) + for isWrite in [true, false] { + chachaProtector.keyUpdate( + for: .application, + cipherSuite: .chacha20Poly1350SHA256, + secret: secret, + isWrite: isWrite + ) + } + XCTAssertEqual(chachaProtector.integrityLimit(for: .phase0), 1 << 36) + chachaProtector.aesGCMConfidentialityLimit = 4 + for number: Int64 in 0..<5 { + try sealOne(&chachaProtector, number: number, keyState: .phase0) + } + XCTAssertFalse(chachaProtector.keyUpdateNeeded(for: .phase0)) + } + func testRetryProtectionOpen() throws { // RFC 9001 Appendix A.4 Retry let retryPacket: [UInt8] = [ diff --git a/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift index 47aff810..08b74e61 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift @@ -517,6 +517,43 @@ final class SwiftNetworkQUICHarnessTests: NetTestCase { ) } + // RFC 9001, Section 6.6: with a lowered AEAD confidentiality limit the client has to + // initiate key updates during the transfer. + func testQUICEchoWithKeyUpdates() { + QUICTestHarness().runQUICTest( + blockSize: 10240, + blockCount: 32, + afterHandshake: { harness in + let expectation = XCTestExpectation(description: "Wait to lower the AEAD limit") + harness.context.async { + harness.state?.clientInstance.protector.aesGCMConfidentialityLimit = 128 + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 5.0) + }, + afterData: { harness in + let expectation = XCTestExpectation(description: "Wait to validate key updates") + harness.context.async { + defer { expectation.fulfill() } + guard let clientInstance = harness.state?.clientInstance, + let serverInstance = harness.state?.serverInstance + else { + XCTFail("State needs to be present to proceed") + return + } + // Phase 1 keys only exist once a key update has happened + XCTAssertTrue(clientInstance.protector.sealKeyReady(for: .phase1)) + XCTAssertTrue(serverInstance.protector.sealKeyReady(for: .phase1)) + XCTAssertEqual(clientInstance.failedDecryptionCount, 0) + XCTAssertEqual(serverInstance.failedDecryptionCount, 0) + XCTAssertNil(clientInstance.closeError) + XCTAssertNil(serverInstance.closeError) + } + self.wait(for: [expectation], timeout: 5.0) + } + ) + } + func testQUICEcho40KiBMultistream() { QUICTestHarness().runQUICTest(streamCount: 4, blockSize: 10240, blockCount: 4) } From e7ab354cff671abb6bdebd6ce3bfd2d7dc26889e Mon Sep 17 00:00:00 2001 From: deadcafe Date: Mon, 5 Oct 2026 22:41:13 +0530 Subject: [PATCH 2/5] QUIC: move failed decryption count into Protector - Protector now counts the packets that fail authentication and checks the count against the integrity limit (RFC 9001, Section 6.6) - QUICConnection only acts on the result --- Sources/SwiftNetwork/QUIC/Protector.swift | 12 ++++++++++++ Sources/SwiftNetwork/QUIC/QUICConnection.swift | 5 +---- .../SwiftNetworkQUICHarnessTests.swift | 4 ++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Sources/SwiftNetwork/QUIC/Protector.swift b/Sources/SwiftNetwork/QUIC/Protector.swift index fe902946..69854d81 100644 --- a/Sources/SwiftNetwork/QUIC/Protector.swift +++ b/Sources/SwiftNetwork/QUIC/Protector.swift @@ -693,6 +693,8 @@ struct Protector: ~Copyable, PrefixedLoggable { // RFC 9001, Section 6.6: an AES-GCM key must not seal more than 2^23 packets. // ChaCha20-Poly1305 has no reachable confidentiality limit. var aesGCMConfidentialityLimit: UInt64 = 1 << 23 + // Packets that failed authentication, across all keys (RFC 9001, Section 6.6) + private(set) var failedDecryptionCount: UInt64 = 0 init(isClient: Bool, destinationCID: QUICConnectionID, logPrefixer: LogPrefixer) { self.isClient = isClient @@ -1376,6 +1378,12 @@ struct Protector: ~Copyable, PrefixedLoggable { 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 @@ -1548,6 +1556,10 @@ struct Protector: ~Copyable, PrefixedLoggable { .max } + func failedDecryption(for keyState: PacketKeyState) -> Bool { + false + } + func open(_ packet: inout Packet, frame: inout Frame) throws(QUICError) { } diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index 63ced489..5b330a03 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -215,8 +215,6 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, 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 - // Packets that failed authentication, across all keys (RFC 9001, Section 6.6) - private(set) var failedDecryptionCount: UInt64 = 0 var remoteMaxDatagramFrameSize = 0 var remoteMaximumUDPPayloadSize = 0 @@ -4653,8 +4651,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, _ packet: borrowing Packet, in eventContext: inout NetworkContext.EventContext ) { - failedDecryptionCount += 1 - if let keyState = packet.keyState, failedDecryptionCount > protector.integrityLimit(for: keyState) { + if let keyState = packet.keyState, protector.failedDecryption(for: keyState) { closeError = QUICTransportError(.aeadLimitReached, "AEAD integrity limit reached") close(in: &eventContext) return diff --git a/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift index 08b74e61..22f698a4 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift @@ -544,8 +544,8 @@ final class SwiftNetworkQUICHarnessTests: NetTestCase { // Phase 1 keys only exist once a key update has happened XCTAssertTrue(clientInstance.protector.sealKeyReady(for: .phase1)) XCTAssertTrue(serverInstance.protector.sealKeyReady(for: .phase1)) - XCTAssertEqual(clientInstance.failedDecryptionCount, 0) - XCTAssertEqual(serverInstance.failedDecryptionCount, 0) + XCTAssertEqual(clientInstance.protector.failedDecryptionCount, 0) + XCTAssertEqual(serverInstance.protector.failedDecryptionCount, 0) XCTAssertNil(clientInstance.closeError) XCTAssertNil(serverInstance.closeError) } From 866641cda34f2ee1e4e3059b0b6d7768cd7f9443 Mon Sep 17 00:00:00 2001 From: deadcafe Date: Mon, 5 Oct 2026 23:00:11 +0530 Subject: [PATCH 3/5] QUIC: add test for failedDecryptionCount - Cover the failed decryption count and the integrity limit for AES-GCM and ChaCha20-Poly1305 (RFC 9001, Section 6.6) - Make failedDecryptionCount settable so the test can reach the limit --- Sources/SwiftNetwork/QUIC/Protector.swift | 2 +- Tests/QUICTests/ProtectorTests.swift | 41 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftNetwork/QUIC/Protector.swift b/Sources/SwiftNetwork/QUIC/Protector.swift index 69854d81..4d995672 100644 --- a/Sources/SwiftNetwork/QUIC/Protector.swift +++ b/Sources/SwiftNetwork/QUIC/Protector.swift @@ -694,7 +694,7 @@ struct Protector: ~Copyable, PrefixedLoggable { // ChaCha20-Poly1305 has no reachable confidentiality limit. var aesGCMConfidentialityLimit: UInt64 = 1 << 23 // Packets that failed authentication, across all keys (RFC 9001, Section 6.6) - private(set) var failedDecryptionCount: UInt64 = 0 + var failedDecryptionCount: UInt64 = 0 init(isClient: Bool, destinationCID: QUICConnectionID, logPrefixer: LogPrefixer) { self.isClient = isClient diff --git a/Tests/QUICTests/ProtectorTests.swift b/Tests/QUICTests/ProtectorTests.swift index f3f7dfbd..2f787ca6 100644 --- a/Tests/QUICTests/ProtectorTests.swift +++ b/Tests/QUICTests/ProtectorTests.swift @@ -1842,6 +1842,47 @@ final class ProtectorTests: XCTestCase { 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] = [ From 7189db5e828cb23f5ce8f50ea0a2b1d27af60920 Mon Sep 17 00:00:00 2001 From: deadcafe Date: Wed, 7 Oct 2026 01:56:25 +0530 Subject: [PATCH 4/5] QUIC: make aesGCMConfidentialityLimit a let - Protector takes the limit in init, defaulting to 2^23 packets (RFC 9001, Section 6.6) - Tests pass a lower limit at init. The harness test builds the client's Protector before the handshake, so originalDCID becomes readable --- Sources/SwiftNetwork/QUIC/Protector.swift | 10 ++++++++-- Sources/SwiftNetwork/QUIC/QUICConnection.swift | 2 +- Tests/QUICTests/ProtectorTests.swift | 16 +++++++++++----- Tests/SwiftNetworkTests/QUICTestHarness.swift | 4 ++++ .../SwiftNetworkQUICHarnessTests.swift | 14 +++++++------- 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/Sources/SwiftNetwork/QUIC/Protector.swift b/Sources/SwiftNetwork/QUIC/Protector.swift index 0d91f034..765053d5 100644 --- a/Sources/SwiftNetwork/QUIC/Protector.swift +++ b/Sources/SwiftNetwork/QUIC/Protector.swift @@ -740,13 +740,19 @@ struct Protector: ~Copyable, PrefixedLoggable { ) // RFC 9001, Section 6.6: an AES-GCM key must not seal more than 2^23 packets. // ChaCha20-Poly1305 has no reachable confidentiality limit. - var aesGCMConfidentialityLimit: UInt64 = 1 << 23 + 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)) diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index 658fc3e3..d3b5ca98 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -415,7 +415,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]? diff --git a/Tests/QUICTests/ProtectorTests.swift b/Tests/QUICTests/ProtectorTests.swift index 2f787ca6..69db35b1 100644 --- a/Tests/QUICTests/ProtectorTests.swift +++ b/Tests/QUICTests/ProtectorTests.swift @@ -1789,11 +1789,19 @@ final class ProtectorTests: XCTestCase { func testConfidentialityLimit() throws { let cid = QUICConnectionID([0x59, 0x26, 0xf7, 0x05, 0xd0, 0xe0, 0x97, 0x98])! let secret = SymmetricKey(data: [UInt8](repeating: 0x2a, count: 32)) - var protector = Protector( + 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, @@ -1802,9 +1810,7 @@ final class ProtectorTests: XCTestCase { isWrite: isWrite ) } - XCTAssertEqual(protector.aesGCMConfidentialityLimit, 1 << 23) XCTAssertEqual(protector.integrityLimit(for: .phase0), 1 << 52) - protector.aesGCMConfidentialityLimit = 4 try sealOne(&protector, number: 0, keyState: .phase0) XCTAssertFalse(protector.keyUpdateNeeded(for: .phase0)) @@ -1824,7 +1830,8 @@ final class ProtectorTests: XCTestCase { var chachaProtector = Protector( isClient: true, destinationCID: cid, - logPrefixer: protectorTestsLogPrefixer + logPrefixer: protectorTestsLogPrefixer, + aesGCMConfidentialityLimit: 4 ) for isWrite in [true, false] { chachaProtector.keyUpdate( @@ -1835,7 +1842,6 @@ final class ProtectorTests: XCTestCase { ) } XCTAssertEqual(chachaProtector.integrityLimit(for: .phase0), 1 << 36) - chachaProtector.aesGCMConfidentialityLimit = 4 for number: Int64 in 0..<5 { try sealOne(&chachaProtector, number: number, keyState: .phase0) } diff --git a/Tests/SwiftNetworkTests/QUICTestHarness.swift b/Tests/SwiftNetworkTests/QUICTestHarness.swift index ca58b88f..f19ec6af 100644 --- a/Tests/SwiftNetworkTests/QUICTestHarness.swift +++ b/Tests/SwiftNetworkTests/QUICTestHarness.swift @@ -161,6 +161,7 @@ class QUICTestHarness { timeout: TimeInterval = 5.0, clientOptions: ProtocolOptions = QUICProtocol.options(), serverOptions: ProtocolOptions = QUICProtocol.options(), + beforeHandshake: ((QUICConnection) -> Void)? = nil, // Block to run on the client before the handshake starts bridgeObserveFirstByteHandler: BridgeObserveFirstByteHandler = nil, bridgeObserveFrameHandler: BridgeObserveFrameHandler = nil, clientMTU: Int = 1500, @@ -189,6 +190,7 @@ class QUICTestHarness { handshakeExpectation.fulfill() return } + beforeHandshake?(clientInstance) let clientInstanceIdentifier = clientQUICStreamListener.identifier self.updateQUICOptions(clientOptions, server: false, datagram: datagram) clientOptions.setLogID( @@ -1071,6 +1073,7 @@ class QUICTestHarness { sendMaxStreamUpdate: Bool = false, validateMetrics: Bool = false, extraServerCIDs: [(QUICConnectionID, QUICStatelessResetToken)] = .init(), + beforeHandshake: ((QUICConnection) -> Void)? = nil, // Block to run on the client before the handshake starts afterHandshake: ((QUICTestHarness) -> Void)? = nil, // Block to run after handshake is complete afterData: ((QUICTestHarness) -> Void)? = nil, // Block to run after handshake is complete bridgeObserveFirstByteHandler: BridgeObserveFirstByteHandler = nil, @@ -1097,6 +1100,7 @@ class QUICTestHarness { timeout: timeout, clientOptions: clientOptions, serverOptions: serverOptions, + beforeHandshake: beforeHandshake, bridgeObserveFirstByteHandler: bridgeObserveFirstByteHandler, bridgeObserveFrameHandler: bridgeObserveFrameHandler, clientMTU: clientMTU, diff --git a/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift index a984b505..d349c0cb 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift @@ -523,13 +523,13 @@ final class SwiftNetworkQUICHarnessTests: NetTestCase { QUICTestHarness().runQUICTest( blockSize: 10240, blockCount: 32, - afterHandshake: { harness in - let expectation = XCTestExpectation(description: "Wait to lower the AEAD limit") - harness.context.async { - harness.state?.clientInstance.protector.aesGCMConfidentialityLimit = 128 - expectation.fulfill() - } - self.wait(for: [expectation], timeout: 5.0) + beforeHandshake: { clientInstance in + clientInstance.protector = Protector( + isClient: true, + destinationCID: clientInstance.originalDCID, + logPrefixer: clientInstance.logPrefixer, + aesGCMConfidentialityLimit: 128 + ) }, afterData: { harness in let expectation = XCTestExpectation(description: "Wait to validate key updates") From cd507e3031dc60024aadc61fe3306b0278a44d20 Mon Sep 17 00:00:00 2001 From: deadcafe Date: Wed, 7 Oct 2026 04:15:49 +0530 Subject: [PATCH 5/5] QUIC: gate key updates on an ACK and reserve a packet for the close - A key update is only initiated once the peer has acknowledged a packet from the current key phase (RFC 9001, Section 6.1). The decision moves after the frames of a packet so that an ACK it carries counts, and an endpoint that only sends ACKs elicits one with a PING - The last packet a key may protect is kept for a CONNECTION_CLOSE. Once that is all that is left and no key update is possible, the connection closes with AEAD_LIMIT_REACHED (RFC 9001, Section 6.6) - Tests: a client that runs into the limit closes and the server receives its CONNECTION_CLOSE, and a client that only receives keeps updating --- Sources/SwiftNetwork/QUIC/Protector.swift | 11 ++ .../SwiftNetwork/QUIC/QUICConnection.swift | 49 ++++++-- Tests/QUICTests/ProtectorTests.swift | 3 + .../SwiftNetworkQUICHarnessTests.swift | 108 ++++++++++++++++++ 4 files changed, 161 insertions(+), 10 deletions(-) diff --git a/Sources/SwiftNetwork/QUIC/Protector.swift b/Sources/SwiftNetwork/QUIC/Protector.swift index 765053d5..bfe9e51f 100644 --- a/Sources/SwiftNetwork/QUIC/Protector.swift +++ b/Sources/SwiftNetwork/QUIC/Protector.swift @@ -1445,6 +1445,13 @@ struct Protector: ~Copyable, PrefixedLoggable { 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 @@ -1624,6 +1631,10 @@ struct Protector: ~Copyable, PrefixedLoggable { false } + func sealLimitImminent(for keyState: PacketKeyState) -> Bool { + false + } + func integrityLimit(for keyState: PacketKeyState) -> UInt64 { .max } diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index d3b5ca98..b857c0a4 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -215,6 +215,8 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, 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 @@ -2040,6 +2042,10 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, } } + if !packet.longHeader, !state.isTerminal { + updateKeysIfNeeded(in: &eventContext) + } + if unvalidatedPath { sendFrames(on: path, in: &eventContext) } @@ -2081,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 @@ -2552,19 +2584,10 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, } if packetKeyState == keyState { keyUpdatePending = false - // RFC 9001, Section 6.6: initiate a key update before the AEAD confidentiality limit - if _slowPath(isHandshakeConfirmed && protector.keyUpdateNeeded(for: keyState)) { - log.notice("Initiating key update from \(keyState)") - protector.trafficUpdate(previousKeyState: keyState) - keyState = keyState == .phase0 ? .phase1 : .phase0 - keyUpdatePending = true - } } else if !keyUpdatePending { log.notice("Switching to keystate \(packetKeyState)") keyState = packetKeyState - } else if protector.keyUpdateNeeded(for: keyState) { - close(with: .aeadLimitReached, "peer did not respond to key update", in: &eventContext) - return false + keyPhaseFirstPacketNumber = protector.getPacketNumber(for: .applicationData) } ack.append( @@ -4092,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 diff --git a/Tests/QUICTests/ProtectorTests.swift b/Tests/QUICTests/ProtectorTests.swift index 69db35b1..65b163ea 100644 --- a/Tests/QUICTests/ProtectorTests.swift +++ b/Tests/QUICTests/ProtectorTests.swift @@ -1816,7 +1816,10 @@ final class ProtectorTests: XCTestCase { 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)) diff --git a/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift index d349c0cb..72b1b250 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift @@ -554,6 +554,114 @@ final class SwiftNetworkQUICHarnessTests: NetTestCase { ) } + // RFC 9001, Section 6.6: the client's packets are still on the delayed link when the + // server's arrive, so the server has acknowledged nothing that would let the client + // update its key before the lowered AEAD confidentiality limit runs out. The client has + // to close with AEAD_LIMIT_REACHED, and its key must still protect that CONNECTION_CLOSE. + func testQUICAEADLimitReachedClosesConnection() { + let harness = QUICTestHarness() + do { + try harness.quicHandshake( + // Delays what the server receives + serverLinkDelay: .milliseconds(100), + beforeHandshake: { clientInstance in + clientInstance.protector = Protector( + isClient: true, + destinationCID: clientInstance.originalDCID, + logPrefixer: clientInstance.logPrefixer, + aesGCMConfidentialityLimit: 8 + ) + } + ) + } catch { + XCTFail("Handshake failed: \(error)") + return + } + guard let clientStream = harness.createNewStream(identifier: "C1"), + let serverStream = harness.createNewStream(identifier: "S1", serverInitiated: true) + else { + XCTFail("Failed to create the streams") + return + } + harness.context.async { + XCTAssertTrue(clientStream.write([UInt8](repeating: 0x41, count: 32768))) + // Every write is a packet for the client to process + for _ in 0..<8 { + harness.context.async { + XCTAssertTrue(serverStream.write([0x42])) + } + } + } + + var clientError: QUICTransportError? + var serverError: QUICTransportError? + var serverReceivedConnectionClose = false + for _ in 0..<50 where !serverReceivedConnectionClose { + _ = XCTWaiter.wait(for: [XCTestExpectation(description: "Let the transfer run")], timeout: 0.1) + let expectation = XCTestExpectation(description: "Wait to read the close errors") + harness.context.async { + clientError = harness.state?.clientInstance.closeError + serverError = harness.state?.serverInstance.closeError + serverReceivedConnectionClose = harness.state?.serverInstance.receivedConnectionClose ?? false + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 5.0) + } + let aeadLimitReached = QUICTransportError(.aeadLimitReached).code + XCTAssertEqual(clientError?.code, aeadLimitReached) + // The server only learns the error from the client's CONNECTION_CLOSE + XCTAssertTrue(serverReceivedConnectionClose) + XCTAssertEqual(serverError?.code, aeadLimitReached) + } + + // RFC 9001, Section 6.1: a client that only receives sends nothing the server would + // acknowledge, so it has to elicit an ACK before it may update its key. + func testQUICKeyUpdatesWhileOnlyReceiving() { + let harness = QUICTestHarness() + do { + try harness.quicHandshake( + beforeHandshake: { clientInstance in + clientInstance.protector = Protector( + isClient: true, + destinationCID: clientInstance.originalDCID, + logPrefixer: clientInstance.logPrefixer, + aesGCMConfidentialityLimit: 8 + ) + } + ) + } catch { + XCTFail("Handshake failed: \(error)") + return + } + guard let serverStream = harness.createNewStream(identifier: "S1", serverInitiated: true) else { + XCTFail("Failed to create server stream") + return + } + // The client never reads the stream, so all it sends are ACKs, one per write + for _ in 0..<24 { + harness.context.async { + XCTAssertTrue(serverStream.write([0x42])) + } + _ = XCTWaiter.wait(for: [XCTestExpectation(description: "Let the client acknowledge")], timeout: 0.04) + } + + let expectation = XCTestExpectation(description: "Wait to validate key updates") + harness.context.async { + defer { expectation.fulfill() } + guard let clientInstance = harness.state?.clientInstance, + let serverInstance = harness.state?.serverInstance + else { + XCTFail("State needs to be present to proceed") + return + } + // Phase 1 keys only exist once a key update has happened + XCTAssertTrue(clientInstance.protector.sealKeyReady(for: .phase1)) + XCTAssertNil(clientInstance.closeError) + XCTAssertNil(serverInstance.closeError) + } + self.wait(for: [expectation], timeout: 5.0) + } + func testQUICEcho40KiBMultistream() { QUICTestHarness().runQUICTest(streamCount: 4, blockSize: 10240, blockCount: 4) }