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 466b7d11..bfe9e51f 100644 --- a/Sources/SwiftNetwork/QUIC/Protector.swift +++ b/Sources/SwiftNetwork/QUIC/Protector.swift @@ -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, @@ -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. + // 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)) @@ -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. @@ -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 @@ -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) { } diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index c8c67719..b857c0a4 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 + // 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 @@ -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]? @@ -1934,6 +1938,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, transferredPacket, path: path, ack: &ack, + protector: &protector, in: &eventContext ) } @@ -2037,6 +2042,10 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, } } + if !packet.longHeader, !state.isTerminal { + updateKeysIfNeeded(in: &eventContext) + } + if unvalidatedPath { sendFrames(on: path, in: &eventContext) } @@ -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 @@ -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( @@ -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 @@ -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) diff --git a/Tests/QUICTests/ProtectorTests.swift b/Tests/QUICTests/ProtectorTests.swift index 46c0b7dc..65b163ea 100644 --- a/Tests/QUICTests/ProtectorTests.swift +++ b/Tests/QUICTests/ProtectorTests.swift @@ -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 { + 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] = [ 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 30b565e1..72b1b250 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkQUICHarnessTests.swift @@ -517,6 +517,151 @@ 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, + 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") + 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.protector.failedDecryptionCount, 0) + XCTAssertEqual(serverInstance.protector.failedDecryptionCount, 0) + XCTAssertNil(clientInstance.closeError) + XCTAssertNil(serverInstance.closeError) + } + self.wait(for: [expectation], timeout: 5.0) + } + ) + } + + // 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) }