diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index f729c6b0..a91a33e3 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -6926,6 +6926,39 @@ extension QUICConnection { return false } + // RFC 9000 ยง5.1.2: + // + // An endpoint SHOULD allow for sending and tracking a number of + // RETIRE_CONNECTION_ID frames of at least twice the value of the + // active_connection_id_limit transport parameter. An endpoint MUST + // NOT forget a connection ID without retiring it, though it MAY choose + // to treat having connection IDs in need of retirement that exceed this + // limit as a connection error of type CONNECTION_ID_LIMIT_ERROR. + // + // RETIRE_CONNECTION_ID frames only leave the queue once they are sent. + // Count what this frame would queue below before queueing any of it: + // one for each connection ID under its Retire Prior To, and one for + // its own connection ID if an earlier frame already retired that + // sequence number. A frame that queues nothing is always accepted. + let queuedRetireCount = withPendingItemsForKeyState { $0.retireConnectionIDs.count } + var newRetireCount = remoteCIDs.managedConnectionIDs.count(where: { + $0.sequenceNumber < frame.retirePriorToSequence + }) + if frame.sequence < retiredRemoteCIDSequenceNumberThreshold { + newRetireCount += 1 + } + if newRetireCount > 0 && queuedRetireCount + newRetireCount > 2 * remoteCIDs.activeConnectionIDLimit { + log.error( + "Received NEW_CONNECTION_ID frame retiring \(newRetireCount) connection IDs with \(queuedRetireCount) RETIRE_CONNECTION_ID frames queued" + ) + close( + with: .connectionIDLimitError, + "NEW_CONNECTION_ID: too many connection IDs to retire", + in: &eventContext + ) + return false + } + // RFC9000: // "Upon receipt of an increased Retire Prior To field, the peer MUST // stop using the corresponding connection IDs and retire them with diff --git a/Tests/QUICTests/ConnectionIDRotationTests.swift b/Tests/QUICTests/ConnectionIDRotationTests.swift index 641608a8..21b6e46f 100644 --- a/Tests/QUICTests/ConnectionIDRotationTests.swift +++ b/Tests/QUICTests/ConnectionIDRotationTests.swift @@ -72,6 +72,179 @@ final class ConnectionIDRotationTests: XCTestCase { return path } + // A RETIRE_CONNECTION_ID frame only leaves the queue once it is sent, and nothing is sent + // here. A peer that keeps supplying NEW_CONNECTION_ID frames below its own Retire Prior To + // gets one queued per frame, so the connection has to close once the queue reaches twice the + // active connection ID limit instead of letting it grow with the peer's frame count. + func testRetireConnectionIDQueueIsCapped() { + let expectation = XCTestExpectation() + connection.context.async { + let oldCID = QUICConnectionID([0xA1, 0xA2, 0xA3, 0xA4])! + let newCID = QUICConnectionID([0xB1, 0xB2, 0xB3, 0xB4])! + + let path = self.makePath(dcid: oldCID, sequenceNumber: 0, used: true) + self.connection.currentPath = path + + let retireLimit = 2 * self.connection.remoteCIDs.activeConnectionIDLimit + let frameCount = UInt64(4 * retireLimit) + + let maxQueued = self.connection.fromExternal { eventContext in + // Raise the retire threshold above every sequence number sent below. + let rotation = FrameNewConnectionID( + sequence: frameCount + 1, + retirePriorToSequence: frameCount + 1, + connectionID: newCID, + statelessResetToken: QUICStatelessResetToken() + ) + _ = self.connection.processNewConnectionIDFrame(rotation, in: &eventContext) + + var maxQueued = 0 + for sequence in 1...frameCount { + let frame = FrameNewConnectionID( + sequence: sequence, + retirePriorToSequence: 0, + connectionID: QUICConnectionID([0xC0, UInt8(sequence >> 8), UInt8(sequence & 0xFF)])!, + statelessResetToken: QUICStatelessResetToken() + ) + guard self.connection.processNewConnectionIDFrame(frame, in: &eventContext) else { + break + } + maxQueued = max( + maxQueued, + self.connection.withPendingItemsForKeyState { $0.retireConnectionIDs.count } + ) + } + return maxQueued + } + + XCTAssertEqual( + self.connection.closeError?.code, + QUICTransportError.QUICTransportErrorCode.connectionIDLimitError.rawValue, + "Connection should close with CONNECTION_ID_LIMIT_ERROR once the queue reaches the limit" + ) + // The check runs before a frame queues anything, so the frame that arrives with the + // queue at the limit closes the connection instead of being queued. + XCTAssertEqual( + maxQueued, + retireLimit, + "Queue should stop growing at the limit" + ) + + expectation.fulfill() + } + wait(for: [expectation], timeout: 5.0) + } + + // Queues `count` RETIRE_CONNECTION_ID frames: one for the DCID in use, retired by a frame that + // raises Retire Prior To to `threshold`, and one for each later frame below `threshold`. + private func queueRetireConnectionIDs( + count: Int, + threshold: UInt64, + in eventContext: inout NetworkContext.EventContext + ) { + let rotation = FrameNewConnectionID( + sequence: threshold, + retirePriorToSequence: threshold, + connectionID: QUICConnectionID([0xB1, 0xB2, 0xB3, 0xB4])!, + statelessResetToken: QUICStatelessResetToken() + ) + _ = connection.processNewConnectionIDFrame(rotation, in: &eventContext) + for sequence in 1..