diff --git a/PcapPlusPlusCore/Dissection/SwiftPacketDissector.swift b/PcapPlusPlusCore/Dissection/SwiftPacketDissector.swift index ae986e0..4f22ddf 100644 --- a/PcapPlusPlusCore/Dissection/SwiftPacketDissector.swift +++ b/PcapPlusPlusCore/Dissection/SwiftPacketDissector.swift @@ -1547,8 +1547,16 @@ extension TransportProtocolHint { return .dns case .http1: return .http1 + case .http2: + return .http2 + case .http3: + return .http3 case .tls: return .tls + case .dtls: + return .dtls + case .quic: + return .quic case .websocket: return .websocket case .payload: diff --git a/PcapPlusPlusCore/Dissection/WiresharkEpanSession.swift b/PcapPlusPlusCore/Dissection/WiresharkEpanSession.swift index c765976..0012451 100644 --- a/PcapPlusPlusCore/Dissection/WiresharkEpanSession.swift +++ b/PcapPlusPlusCore/Dissection/WiresharkEpanSession.swift @@ -29,6 +29,14 @@ struct WiresharkTCPFollowFields { let isTruncated: Bool } +struct WiresharkDecryptedFollowFields { + let protocolName: DecryptedStreamProtocol + let client: PacketEndpoint + let server: PacketEndpoint + let request: DecryptedStreamPayload + let response: DecryptedStreamPayload +} + struct WiresharkTCPStreamIndexEntry: Sendable, Equatable { let packetIdentifier: UInt64 let streamIdentifier: UInt32 @@ -261,10 +269,11 @@ final class WiresharkEpanSession { ) } try session.finishFirstPass() - return try session.followObservedTCPStream( + return try session.followObservedStream( containing: selectedRecord, records: records, limits: limits, + protocolName: "TCP", progressOffset: records.count, progressTotal: totalWorkCount, progress: progress, @@ -280,10 +289,11 @@ final class WiresharkEpanSession { progress: TCPFollowProgressHandler?, shouldCancel: TCPFollowCancellationCheck? ) throws -> WiresharkTCPFollowFields { - try followObservedTCPStream( + try followObservedStream( containing: selectedRecord, records: records, limits: limits, + protocolName: "TCP", progressOffset: 0, progressTotal: records.count, progress: progress, @@ -291,17 +301,46 @@ final class WiresharkEpanSession { ) } - private func followObservedTCPStream( + private func followObservedStream( containing selectedRecord: NativePacketRecord, records: [NativePacketRecord], limits: TCPFollowLimits, + protocolName: String, progressOffset: Int, progressTotal: Int, progress: TCPFollowProgressHandler?, shouldCancel: TCPFollowCancellationCheck? ) throws -> WiresharkTCPFollowFields { try Self.validateFollowRequest(selectedRecord: selectedRecord, records: records, limits: limits) + return try followObservedStream( + containing: selectedRecord, + limits: limits, + protocolName: protocolName, + progressOffset: progressOffset, + progressTotal: progressTotal, + progress: progress, + shouldCancel: shouldCancel, + replay: { consume in + for record in records { + if try !consume(record) { + break + } + } + } + ) + } + // Consume replayed packets one at a time so stopped live captures never load all packet bytes into memory. + private func followObservedStream( + containing selectedRecord: NativePacketRecord, + limits: TCPFollowLimits, + protocolName: String, + progressOffset: Int, + progressTotal: Int, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + replay: (_ consume: (NativePacketRecord) throws -> Bool) throws -> Void + ) throws -> WiresharkTCPFollowFields { var followIsActive = false defer { if followIsActive { @@ -309,7 +348,10 @@ final class WiresharkEpanSession { } } try withContext(for: selectedRecord) { context in - guard TCPViewerWiresharkSessionBeginFollowTCPStream(handle, context) else { + let didBegin = protocolName.withCString { name in + TCPViewerWiresharkSessionBeginFollowStream(handle, context, name) + } + guard didBegin else { if let criticalError = criticalExceptionErrorIfNeeded() { throw criticalError } @@ -318,7 +360,8 @@ final class WiresharkEpanSession { } followIsActive = true - for (index, record) in records.enumerated() { + var processedPacketCount = 0 + try replay { record in if shouldCancel?() == true { throw NativeNSError(.operationCancelled, "TCP stream reassembly was cancelled.") } @@ -335,14 +378,13 @@ final class WiresharkEpanSession { } throw unavailableError() } + processedPacketCount += 1 Self.reportFollowProgress( - processedPacketCount: progressOffset + index + 1, + processedPacketCount: progressOffset + processedPacketCount, totalPacketCount: progressTotal, handler: progress ) - if status == TCPViewerWiresharkFollowPacketLimitReached { - break - } + return status != TCPViewerWiresharkFollowPacketLimitReached } guard let resultPointer = TCPViewerWiresharkSessionFinishFollowTCPStream( @@ -378,6 +420,174 @@ final class WiresharkEpanSession { ) } + // Build a temporary first pass, then let Wireshark choose TLS, DTLS, or QUIC follow semantics. + static func followDecryptedStreamInTemporarySession( + containing selectedRecord: NativePacketRecord, + records: [NativePacketRecord], + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck? + ) throws -> WiresharkDecryptedFollowFields { + guard records.contains(where: { $0.identifier == selectedRecord.identifier }) else { + throw NativeNSError(.fileReadFailed, "The selected packet is not available in the stream snapshot.") + } + return try followDecryptedStreamInTemporarySession( + containing: selectedRecord, + recordCount: records.count, + replay: { consume in + for record in records { + if try !consume(record) { + break + } + } + }, + limits: limits, + progress: progress, + shouldCancel: shouldCancel + ) + } + + // Build a temporary first pass while reading each retained packet only when Wireshark needs it. + static func followDecryptedStreamInTemporarySession( + containing selectedRecord: NativePacketRecord, + recordCount: Int, + replay: (_ consume: (NativePacketRecord) throws -> Bool) throws -> Void, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck? + ) throws -> WiresharkDecryptedFollowFields { + guard TCPViewerWiresharkHasTLSKeyLog() else { + throw NativeNSError(.unavailableFeature, "No TLS key-log file is selected. Open Tools → TLS Key Log… first.") + } + let session = try WiresharkEpanSession(purpose: .follow) + let totalWorkCount = recordCount > Int.max / 2 ? Int.max : recordCount * 2 + var processedPacketCount = 0 + try replay { record in + if shouldCancel?() == true { + throw NativeNSError(.operationCancelled, "TLS stream decryption was cancelled.") + } + try session.observe(record) + processedPacketCount += 1 + reportFollowProgress( + processedPacketCount: processedPacketCount, + totalPacketCount: totalWorkCount, + handler: progress + ) + return true + } + try session.finishFirstPass() + return try session.followObservedDecryptedStream( + containing: selectedRecord, + replay: replay, + limits: limits, + progressOffset: recordCount, + progressTotal: totalWorkCount, + progress: progress, + shouldCancel: shouldCancel + ) + } + + func followObservedDecryptedStream( + containing selectedRecord: NativePacketRecord, + records: [NativePacketRecord], + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck? + ) throws -> WiresharkDecryptedFollowFields { + guard TCPViewerWiresharkHasTLSKeyLog() else { + throw NativeNSError(.unavailableFeature, "No TLS key-log file is selected. Open Tools → TLS Key Log… first.") + } + guard records.contains(where: { $0.identifier == selectedRecord.identifier }) else { + throw NativeNSError(.fileReadFailed, "The selected packet is not available in the stream snapshot.") + } + return try followObservedDecryptedStream( + containing: selectedRecord, + replay: { consume in + for record in records { + if try !consume(record) { + break + } + } + }, + limits: limits, + progressOffset: 0, + progressTotal: records.count, + progress: progress, + shouldCancel: shouldCancel + ) + } + + private func followObservedDecryptedStream( + containing selectedRecord: NativePacketRecord, + replay: (_ consume: (NativePacketRecord) throws -> Bool) throws -> Void, + limits: DecryptedStreamLimits, + progressOffset: Int, + progressTotal: Int, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck? + ) throws -> WiresharkDecryptedFollowFields { + let followLimits = TCPFollowLimits( + maximumPayloadBytes: limits.maximumBytesPerDirection, + maximumRecordCount: limits.maximumRecordCount + ) + var lastError: Error? + for protocolName in [DecryptedStreamProtocol.tls, .dtls, .quic] { + do { + let fields = try followObservedStream( + containing: selectedRecord, + limits: followLimits, + protocolName: protocolName.rawValue, + progressOffset: progressOffset, + progressTotal: progressTotal, + progress: progress, + shouldCancel: shouldCancel, + replay: replay + ) + return decryptedFields(protocolName: protocolName, fields: fields, limit: limits.maximumBytesPerDirection) + } catch { + if shouldCancel?() == true { + throw error + } + lastError = error + } + } + throw lastError ?? NativeNSError(.unavailableFeature, "Select a TLS, DTLS, or QUIC packet to decrypt its stream.") + } + + private func decryptedFields( + protocolName: DecryptedStreamProtocol, + fields: WiresharkTCPFollowFields, + limit: Int + ) -> WiresharkDecryptedFollowFields { + var request = Data() + var response = Data() + for record in fields.records { + switch record.direction { + case .clientToServer: + let remaining = max(limit - request.count, 0) + request.append(record.data.prefix(remaining)) + case .serverToClient: + let remaining = max(limit - response.count, 0) + response.append(record.data.prefix(remaining)) + } + } + return WiresharkDecryptedFollowFields( + protocolName: protocolName, + client: fields.client, + server: fields.server, + request: DecryptedStreamPayload( + data: request, + observedByteCount: fields.clientByteCount, + isTruncated: fields.isTruncated || fields.clientByteCount > request.count + ), + response: DecryptedStreamPayload( + data: response, + observedByteCount: fields.serverByteCount, + isTruncated: fields.isTruncated || fields.serverByteCount > response.count + ) + ) + } + private static func validateFollowRequest( selectedRecord: NativePacketRecord, records: [NativePacketRecord], diff --git a/PcapPlusPlusCore/Dissection/WiresharkEpanShim.cpp b/PcapPlusPlusCore/Dissection/WiresharkEpanShim.cpp index 1387e1e..6e1d837 100644 --- a/PcapPlusPlusCore/Dissection/WiresharkEpanShim.cpp +++ b/PcapPlusPlusCore/Dissection/WiresharkEpanShim.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -1160,6 +1161,12 @@ class WiresharkRuntime { WiresharkCriticalExceptionReports criticalExceptionReports_; }; +std::string &TLSKeyLogPath() +{ + static std::string path; + return path; +} + } // namespace struct TCPViewerWiresharkSession { @@ -1196,7 +1203,10 @@ struct TCPViewerWiresharkSession { bool tcpIndexTapRegistered = false; bool collectingTCPStreamIndex = false; bool followTruncated = false; + bool followUsesPerDirectionLimit = false; uint64_t followPayloadByteCount = 0; + uint64_t followObservedByteCountByDirection[2] = {}; + std::vector followRetainedPayloadByDirection[2]; GList *followNewestPayloadItem = nullptr; std::string personalConfigurationDirectory; bool disabled = false; @@ -1668,11 +1678,16 @@ struct TCPViewerWiresharkSession { followReferenceFrame = frame_data{}; nstime_set_zero(&followElapsedTime); followTruncated = false; + followUsesPerDirectionLimit = false; followPayloadByteCount = 0; + followObservedByteCountByDirection[0] = 0; + followObservedByteCountByDirection[1] = 0; + followRetainedPayloadByDirection[0].clear(); + followRetainedPayloadByDirection[1].clear(); followNewestPayloadItem = nullptr; } - bool beginTCPFollowLocked(const PacketContextView &selectedContext) + bool beginFollowLocked(const PacketContextView &selectedContext, const char *protocolName) { cancelFollowLocked(); if (!hasSession() || activeSession() != this) { @@ -1680,19 +1695,19 @@ struct TCPViewerWiresharkSession { return false; } if (activeFollowSession() != nullptr && activeFollowSession() != this) { - unavailableReason = "Another TCP stream is already being reassembled."; + unavailableReason = "Another stream is already being reassembled."; return false; } // Followers are keyed by Wireshark's case-sensitive protocol short name. - tcpFollower = get_follow_by_name("TCP"); + tcpFollower = protocolName == nullptr ? nullptr : get_follow_by_name(protocolName); if (tcpFollower == nullptr) { - unavailableReason = "Wireshark TCP stream following is unavailable."; + unavailableReason = "Wireshark stream following is unavailable for this protocol."; return false; } const auto frameMatch = frameNumberByPacketIdentifier.find(selectedContext.packetIdentifier); if (frameMatch == frameNumberByPacketIdentifier.end()) { - unavailableReason = "The selected packet is not present in the TCP stream snapshot."; + unavailableReason = "The selected packet is not present in the stream snapshot."; return false; } frame_data *frame = frame_data_sequence_find(provider->frames, frameMatch->second); @@ -1709,32 +1724,32 @@ struct TCPViewerWiresharkSession { epan_dissect_t *dissect = nullptr; auto *currentEpan = epan; - if (auto report = CatchWiresharkException("creating Wireshark TCP follow selector", selectedContext.packetIdentifier, [&] { + if (auto report = CatchWiresharkException("creating Wireshark follow selector", selectedContext.packetIdentifier, [&] { dissect = epan_dissect_new(currentEpan, true, true); })) { return failWithCriticalExceptionLocked(std::move(*report)); } if (dissect == nullptr) { - unavailableReason = "Wireshark could not allocate the TCP stream selector."; + unavailableReason = "Wireshark could not allocate the stream selector."; return false; } - bool selectedTCP = false; + bool selectedProtocol = false; char *followFilter = nullptr; uint32_t cumulativeBytesForPacket = frame->cum_bytes >= frame->pkt_len ? frame->cum_bytes - frame->pkt_len : 0; nstime_t elapsed = NSTIME_INIT_ZERO; const frame_data *reference = nullptr; wtap_block_t block = record.get()->block != nullptr ? wtap_block_ref(record.get()->block) : nullptr; - if (auto report = CatchWiresharkException("selecting Wireshark TCP stream", selectedContext.packetIdentifier, [&] { + if (auto report = CatchWiresharkException("selecting Wireshark stream", selectedContext.packetIdentifier, [&] { frame_data_set_before_dissect(frame, &elapsed, &reference, nullptr); epan_dissect_run(dissect, WTAP_FILE_TYPE_SUBTYPE_UNKNOWN, record.get(), frame, nullptr); frame_data_set_after_dissect(frame, &cumulativeBytesForPacket); const int protocolID = get_follow_proto_id(tcpFollower); - selectedTCP = proto_is_frame_protocol( + selectedProtocol = proto_is_frame_protocol( dissect->pi.layers, proto_get_protocol_filter_name(protocolID) ); - if (selectedTCP) { + if (selectedProtocol) { unsigned streamNumber = 0; unsigned substreamNumber = 0; followFilter = get_follow_conv_func(tcpFollower)( @@ -1750,28 +1765,28 @@ struct TCPViewerWiresharkSession { if (followFilter != nullptr) { g_free(followFilter); } - FreeEpanDissect(dissect, "freeing Wireshark TCP follow selector", selectedContext.packetIdentifier); + FreeEpanDissect(dissect, "freeing Wireshark follow selector", selectedContext.packetIdentifier); return failWithCriticalExceptionLocked(std::move(*report)); } record.get()->block = block; - if (auto cleanupReport = FreeEpanDissect(dissect, "freeing Wireshark TCP follow selector", selectedContext.packetIdentifier)) { + if (auto cleanupReport = FreeEpanDissect(dissect, "freeing Wireshark follow selector", selectedContext.packetIdentifier)) { if (followFilter != nullptr) { g_free(followFilter); } return failWithCriticalExceptionLocked(std::move(*cleanupReport)); } - if (!selectedTCP || followFilter == nullptr || followFilter[0] == '\0') { + if (!selectedProtocol || followFilter == nullptr || followFilter[0] == '\0') { if (followFilter != nullptr) { g_free(followFilter); } - unavailableReason = "Select a TCP packet to follow its stream."; + unavailableReason = "The selected packet does not contain this protocol stream."; return false; } followInfo = g_try_new0(follow_info_t, 1); if (followInfo == nullptr) { g_free(followFilter); - unavailableReason = "TCP stream follower state could not be allocated."; + unavailableReason = "Stream follower state could not be allocated."; return false; } followInfo->show_stream = BOTH_HOSTS; @@ -1782,7 +1797,7 @@ struct TCPViewerWiresharkSession { follow_info_free(followInfo); followInfo = nullptr; tcpFollower = nullptr; - unavailableReason = "TCP stream tap context could not be allocated."; + unavailableReason = "Stream tap context could not be allocated."; return false; } followTapContext->session = this; @@ -1798,7 +1813,7 @@ struct TCPViewerWiresharkSession { ); if (registrationError != nullptr) { unavailableReason = registrationError->str == nullptr || registrationError->str[0] == '\0' - ? "Wireshark could not register the TCP follow listener." + ? "Wireshark could not register the follow listener." : registrationError->str; g_string_free(registrationError, TRUE); g_free(followTapContext); @@ -1816,7 +1831,12 @@ struct TCPViewerWiresharkSession { followReferenceFrame = frame_data{}; nstime_set_zero(&followElapsedTime); followTruncated = false; + followUsesPerDirectionLimit = std::strcmp(protocolName, "TCP") != 0; followPayloadByteCount = 0; + followObservedByteCountByDirection[0] = 0; + followObservedByteCountByDirection[1] = 0; + followRetainedPayloadByDirection[0].clear(); + followRetainedPayloadByDirection[1].clear(); followNewestPayloadItem = nullptr; return true; } @@ -1892,15 +1912,51 @@ struct TCPViewerWiresharkSession { return TCPViewerWiresharkFollowPacketFailed; } - // Wireshark does not add released out-of-order fragments to bytes_written, so count new payload records directly. + // Wireshark prepends records, so drain each packet's new records from oldest to newest. + GList *oldestNewPayloadItem = nullptr; for (GList *item = followInfo->payload; item != followNewestPayloadItem; item = g_list_next(item)) { + oldestNewPayloadItem = item; + } + for (GList *item = oldestNewPayloadItem; item != nullptr;) { + GList *nextItem = g_list_previous(item); auto *record = static_cast(item->data); if (record != nullptr && record->data != nullptr) { - followPayloadByteCount += record->data->len; + const size_t byteCount = record->data->len; + followPayloadByteCount += byteCount; + if (followUsesPerDirectionLimit) { + const size_t direction = record->is_server ? 1 : 0; + followObservedByteCountByDirection[direction] += byteCount; + auto &payload = followRetainedPayloadByDirection[direction]; + const size_t retained = payload.size(); + const size_t remaining = retained >= maximumPayloadBytes ? 0 : maximumPayloadBytes - retained; + const size_t retainedByteCount = std::min(byteCount, remaining); + if (retainedByteCount > 0) { + try { + payload.insert(payload.end(), record->data->data, record->data->data + retainedByteCount); + } catch (const std::bad_alloc &) { + unavailableReason = "Decrypted stream payload could not be allocated."; + return TCPViewerWiresharkFollowPacketFailed; + } + } + if (retainedByteCount < byteCount) { + followTruncated = true; + } + // Decrypted output needs only two directional byte streams, so release Wireshark's record immediately. + followInfo->payload = g_list_delete_link(followInfo->payload, item); + g_byte_array_free(record->data, true); + g_free(record); + } } + item = nextItem; } followNewestPayloadItem = followInfo->payload; - if (followPayloadByteCount > maximumPayloadBytes) { + if (followUsesPerDirectionLimit + && followRetainedPayloadByDirection[0].size() >= maximumPayloadBytes + && followRetainedPayloadByDirection[1].size() >= maximumPayloadBytes) { + followTruncated = true; + return TCPViewerWiresharkFollowPacketLimitReached; + } + if (!followUsesPerDirectionLimit && followPayloadByteCount > maximumPayloadBytes) { followTruncated = true; return TCPViewerWiresharkFollowPacketLimitReached; } @@ -1963,12 +2019,19 @@ struct TCPViewerWiresharkSession { result->clientByteCount += record->data->len; } } + if (followUsesPerDirectionLimit) { + result->clientByteCount = followObservedByteCountByDirection[0]; + result->serverByteCount = followObservedByteCountByDirection[1]; + } - const size_t availableRecordCount = static_cast(g_list_length(followInfo->payload)); + const size_t availableRecordCount = followUsesPerDirectionLimit + ? static_cast(!followRetainedPayloadByDirection[0].empty()) + + static_cast(!followRetainedPayloadByDirection[1].empty()) + : static_cast(g_list_length(followInfo->payload)); const size_t allocatedRecordCount = std::min(availableRecordCount, maximumRecordCount); result->recordCount = allocatedRecordCount; result->isTruncated = followTruncated - || followPayloadByteCount > maximumPayloadBytes + || (!followUsesPerDirectionLimit && followPayloadByteCount > maximumPayloadBytes) || availableRecordCount > maximumRecordCount; if (allocatedRecordCount > 0) { result->records = static_cast( @@ -1983,37 +2046,57 @@ struct TCPViewerWiresharkSession { } size_t outputIndex = 0; - size_t remainingPayloadBytes = maximumPayloadBytes; - for (GList *item = g_list_last(followInfo->payload); - item != nullptr && outputIndex < allocatedRecordCount && remainingPayloadBytes > 0; - item = g_list_previous(item)) { - auto *source = static_cast(item->data); - if (source == nullptr || source->data == nullptr || source->data->len == 0) { - continue; - } - auto &destination = result->records[outputIndex]; - destination.isServer = source->is_server; - destination.packetIdentifier = source->packet_num < packetIdentifierByFrameNumber.size() - ? packetIdentifierByFrameNumber[source->packet_num] - : static_cast(source->packet_num); - destination.sequenceNumber = source->seq; - destination.timestampSeconds = source->abs_ts.secs; - destination.timestampNanoseconds = source->abs_ts.nsecs; - destination.byteCount = std::min(static_cast(source->data->len), remainingPayloadBytes); - if (destination.byteCount > 0) { + if (followUsesPerDirectionLimit) { + for (size_t direction = 0; direction < 2 && outputIndex < allocatedRecordCount; direction += 1) { + const auto &source = followRetainedPayloadByDirection[direction]; + if (source.empty()) { + continue; + } + auto &destination = result->records[outputIndex]; + destination.isServer = direction == 1; + destination.byteCount = source.size(); destination.bytes = static_cast(std::malloc(destination.byteCount)); if (destination.bytes == nullptr) { result->errorMessage = CopyCString("TCP stream payload could not be allocated.", false); cancelFollowLocked(); return result; } - std::memcpy(destination.bytes, source->data->data, destination.byteCount); + std::memcpy(destination.bytes, source.data(), destination.byteCount); + outputIndex += 1; } - if (destination.byteCount < source->data->len) { - result->isTruncated = true; + } else { + size_t remainingPayloadBytes = maximumPayloadBytes; + for (GList *item = g_list_last(followInfo->payload); + item != nullptr && outputIndex < allocatedRecordCount && remainingPayloadBytes > 0; + item = g_list_previous(item)) { + auto *source = static_cast(item->data); + if (source == nullptr || source->data == nullptr || source->data->len == 0) { + continue; + } + auto &destination = result->records[outputIndex]; + destination.isServer = source->is_server; + destination.packetIdentifier = source->packet_num < packetIdentifierByFrameNumber.size() + ? packetIdentifierByFrameNumber[source->packet_num] + : static_cast(source->packet_num); + destination.sequenceNumber = source->seq; + destination.timestampSeconds = source->abs_ts.secs; + destination.timestampNanoseconds = source->abs_ts.nsecs; + destination.byteCount = std::min(static_cast(source->data->len), remainingPayloadBytes); + if (destination.byteCount > 0) { + destination.bytes = static_cast(std::malloc(destination.byteCount)); + if (destination.bytes == nullptr) { + result->errorMessage = CopyCString("TCP stream payload could not be allocated.", false); + cancelFollowLocked(); + return result; + } + std::memcpy(destination.bytes, source->data->data, destination.byteCount); + } + if (destination.byteCount < source->data->len) { + result->isTruncated = true; + } + remainingPayloadBytes -= destination.byteCount; + outputIndex += 1; } - remainingPayloadBytes -= destination.byteCount; - outputIndex += 1; } result->recordCount = outputIndex; @@ -2026,7 +2109,12 @@ struct TCPViewerWiresharkSession { followReferenceFrame = frame_data{}; nstime_set_zero(&followElapsedTime); followTruncated = false; + followUsesPerDirectionLimit = false; followPayloadByteCount = 0; + followObservedByteCountByDirection[0] = 0; + followObservedByteCountByDirection[1] = 0; + followRetainedPayloadByDirection[0].clear(); + followRetainedPayloadByDirection[1].clear(); followNewestPayloadItem = nullptr; return result; } @@ -2171,6 +2259,65 @@ struct TCPViewerWiresharkSession { } }; +bool TCPViewerWiresharkConfigureTLSKeyLog( + const char *filePath, + const char *personalConfigurationDirectory, + char **errorMessage +) { + if (errorMessage != nullptr) { + *errorMessage = nullptr; + } + if (personalConfigurationDirectory == nullptr || personalConfigurationDirectory[0] == '\0') { + if (errorMessage != nullptr) { + *errorMessage = CopyCString("Wireshark configuration is unavailable.", false); + } + return false; + } + + auto &runtime = WiresharkRuntime::shared(personalConfigurationDirectory); + if (!runtime.isAvailable()) { + if (errorMessage != nullptr) { + *errorMessage = CopyCString(runtime.unavailableReason(), false); + } + return false; + } + + std::lock_guard apiLock(WiresharkAPIMutex()); + module_t *tlsModule = prefs_find_module("tls"); + pref_t *keyLogPreference = tlsModule == nullptr ? nullptr : prefs_find_preference(tlsModule, "keylog_file"); + if (keyLogPreference == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = CopyCString("This Wireshark build does not expose the TLS key-log preference.", false); + } + return false; + } + + const std::string nextPath = filePath == nullptr ? std::string() : std::string(filePath); + if (auto report = CatchWiresharkException("applying the TLS key-log preference", std::nullopt, [&] { + prefs_set_string_value(keyLogPreference, nextPath.c_str(), pref_current); + prefs_apply(tlsModule); + })) { + if (errorMessage != nullptr) { + *errorMessage = CopyCString("Wireshark could not apply the TLS key-log preference.", false); + } + return false; + } + + TLSKeyLogPath() = nextPath; + return true; +} + +bool TCPViewerWiresharkHasTLSKeyLog(void) +{ + std::lock_guard apiLock(WiresharkAPIMutex()); + return !TLSKeyLogPath().empty(); +} + +void TCPViewerWiresharkCStringDestroy(char *value) +{ + std::free(value); +} + TCPViewerWiresharkSession *TCPViewerWiresharkSessionCreate(bool disabled, bool livePriority, const char *personalConfigurationDirectory) { return new TCPViewerWiresharkSession(disabled, livePriority, personalConfigurationDirectory); @@ -2425,13 +2572,21 @@ TCPViewerWiresharkInspectionResult *TCPViewerWiresharkSessionInspectPacket(TCPVi bool TCPViewerWiresharkSessionBeginFollowTCPStream(TCPViewerWiresharkSession *session, const TCPViewerWiresharkPacketContext *selectedContext) { - if (session == nullptr || selectedContext == nullptr) { + return TCPViewerWiresharkSessionBeginFollowStream(session, selectedContext, "TCP"); +} + +bool TCPViewerWiresharkSessionBeginFollowStream( + TCPViewerWiresharkSession *session, + const TCPViewerWiresharkPacketContext *selectedContext, + const char *protocolName +) { + if (session == nullptr || selectedContext == nullptr || protocolName == nullptr || protocolName[0] == '\0') { return false; } std::lock_guard apiLock(WiresharkAPIMutex()); std::lock_guard sessionLock(session->mutex); session->clearCriticalExceptionsLocked(); - return session->beginTCPFollowLocked(ContextViewFromC(selectedContext)); + return session->beginFollowLocked(ContextViewFromC(selectedContext), protocolName); } TCPViewerWiresharkFollowPacketStatus TCPViewerWiresharkSessionProcessFollowPacket( diff --git a/PcapPlusPlusCore/Dissection/WiresharkEpanShim.h b/PcapPlusPlusCore/Dissection/WiresharkEpanShim.h index a554bc7..7f72921 100644 --- a/PcapPlusPlusCore/Dissection/WiresharkEpanShim.h +++ b/PcapPlusPlusCore/Dissection/WiresharkEpanShim.h @@ -120,6 +120,14 @@ typedef enum TCPViewerWiresharkFollowPacketStatus { TCPViewerWiresharkFollowPacketLimitReached = 1, } TCPViewerWiresharkFollowPacketStatus; +bool TCPViewerWiresharkConfigureTLSKeyLog( + const char *filePath, + const char *personalConfigurationDirectory, + char **errorMessage +); +bool TCPViewerWiresharkHasTLSKeyLog(void); +void TCPViewerWiresharkCStringDestroy(char *value); + typedef struct TCPViewerWiresharkExceptionReport { bool isCriticalException; unsigned long exceptionGroup; @@ -158,6 +166,11 @@ bool TCPViewerWiresharkSessionTCPStreamIdentifier( TCPViewerWiresharkSummaryResult *TCPViewerWiresharkSessionSummarizePacket(TCPViewerWiresharkSession *session, const TCPViewerWiresharkPacketContext *context); TCPViewerWiresharkInspectionResult *TCPViewerWiresharkSessionInspectPacket(TCPViewerWiresharkSession *session, const TCPViewerWiresharkPacketContext *context); bool TCPViewerWiresharkSessionBeginFollowTCPStream(TCPViewerWiresharkSession *session, const TCPViewerWiresharkPacketContext *selectedContext); +bool TCPViewerWiresharkSessionBeginFollowStream( + TCPViewerWiresharkSession *session, + const TCPViewerWiresharkPacketContext *selectedContext, + const char *protocolName +); TCPViewerWiresharkFollowPacketStatus TCPViewerWiresharkSessionProcessFollowPacket( TCPViewerWiresharkSession *session, const TCPViewerWiresharkPacketContext *context, diff --git a/PcapPlusPlusCore/Models/CoreProtocols.swift b/PcapPlusPlusCore/Models/CoreProtocols.swift index 16e4e00..a4b98f5 100644 --- a/PcapPlusPlusCore/Models/CoreProtocols.swift +++ b/PcapPlusPlusCore/Models/CoreProtocols.swift @@ -56,7 +56,7 @@ public protocol CaptureFilterValidating { func validateCaptureFilter(_ expression: String, completion: @escaping (CaptureFilterValidation) -> Void) } -public protocol LiveCaptureSessionProviding: TCPStreamFollowing { +public protocol LiveCaptureSessionProviding: TCPStreamFollowing, DecryptedStreamLoading { var eventHandler: PacketIngestEventHandler? { get set } func start(completion: @escaping TCPViewerVoidCompletion) @@ -96,7 +96,7 @@ public extension LiveCaptureSessionProviding { } #endif -public protocol OfflineCaptureDocumentProviding: TCPStreamFollowing { +public protocol OfflineCaptureDocumentProviding: TCPStreamFollowing, DecryptedStreamLoading { var eventHandler: PacketIngestEventHandler? { get set } func open(completion: @escaping TCPViewerCompletion<[PacketSummary]>) diff --git a/PcapPlusPlusCore/Models/DecryptedStream.swift b/PcapPlusPlusCore/Models/DecryptedStream.swift new file mode 100644 index 0000000..5eb60c1 --- /dev/null +++ b/PcapPlusPlusCore/Models/DecryptedStream.swift @@ -0,0 +1,88 @@ +// +// DecryptedStream.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import Foundation + +public enum DecryptedStreamProtocol: String, Sendable, Codable, Hashable { + case tls = "TLS" + case dtls = "DTLS" + case quic = "QUIC" +} + +public struct DecryptedStreamPayload: Sendable, Codable, Hashable { + public let data: Data + public let observedByteCount: Int + public let isTruncated: Bool + + public init(data: Data, observedByteCount: Int, isTruncated: Bool) { + self.data = data + self.observedByteCount = observedByteCount + self.isTruncated = isTruncated + } +} + +public struct DecryptedStreamResult: Sendable, Codable, Hashable { + public let protocolName: DecryptedStreamProtocol + public let client: PacketEndpoint + public let server: PacketEndpoint + public let request: DecryptedStreamPayload + public let response: DecryptedStreamPayload + + public init( + protocolName: DecryptedStreamProtocol, + client: PacketEndpoint, + server: PacketEndpoint, + request: DecryptedStreamPayload, + response: DecryptedStreamPayload + ) { + self.protocolName = protocolName + self.client = client + self.server = server + self.request = request + self.response = response + } +} + +public struct DecryptedStreamLimits: Sendable, Equatable, Hashable { + public let maximumBytesPerDirection: Int + public let maximumRecordCount: Int + + public init( + maximumBytesPerDirection: Int = 8 * 1_024 * 1_024, + maximumRecordCount: Int = 100_000 + ) { + self.maximumBytesPerDirection = max(maximumBytesPerDirection, 1) + self.maximumRecordCount = max(maximumRecordCount, 1) + } + + public static let `default` = DecryptedStreamLimits() +} + +public protocol DecryptedStreamLoading: AnyObject { + func loadDecryptedStream( + containing packetID: PacketSummary.ID, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + completion: @escaping TCPViewerCompletion + ) +} + +public extension DecryptedStreamLoading { + func loadDecryptedStream( + containing packetID: PacketSummary.ID, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + completion: @escaping TCPViewerCompletion + ) { + completion(.failure(TCPViewerCoreError( + code: .unavailableFeature, + message: "TLS stream decryption is unavailable for this capture source." + ))) + } +} diff --git a/PcapPlusPlusCore/Models/PacketModels.swift b/PcapPlusPlusCore/Models/PacketModels.swift index c7fa663..db6774c 100644 --- a/PcapPlusPlusCore/Models/PacketModels.swift +++ b/PcapPlusPlusCore/Models/PacketModels.swift @@ -17,7 +17,11 @@ public enum TransportProtocolHint: String, Sendable, Codable { case udp case dns case http1 + case http2 + case http3 case tls + case dtls + case quic case websocket case payload case unknown diff --git a/PcapPlusPlusCore/Models/TLSKeyLog.swift b/PcapPlusPlusCore/Models/TLSKeyLog.swift new file mode 100644 index 0000000..ef2166d --- /dev/null +++ b/PcapPlusPlusCore/Models/TLSKeyLog.swift @@ -0,0 +1,41 @@ +// +// TLSKeyLog.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import Foundation + +public struct TLSKeyLogValidation: Sendable, Equatable { + public let validRecordCount: Int + public let warningCount: Int + public let scannedLineCount: Int + public let reachedScanLimit: Bool + + public init(validRecordCount: Int, warningCount: Int, scannedLineCount: Int, reachedScanLimit: Bool) { + self.validRecordCount = validRecordCount + self.warningCount = warningCount + self.scannedLineCount = scannedLineCount + self.reachedScanLimit = reachedScanLimit + } +} + +public struct TLSKeyLogState: Sendable, Equatable { + public let fileURL: URL? + public let validation: TLSKeyLogValidation? + + public init(fileURL: URL?, validation: TLSKeyLogValidation?) { + self.fileURL = fileURL + self.validation = validation + } + + public static let empty = TLSKeyLogState(fileURL: nil, validation: nil) +} + +public protocol TLSKeyLogManaging: AnyObject { + func validate(fileURL: URL, completion: @escaping TCPViewerCompletion) + func apply(fileURL: URL, completion: @escaping TCPViewerCompletion) + func remove(completion: @escaping TCPViewerCompletion) + func currentState(completion: @escaping (TLSKeyLogState) -> Void) +} diff --git a/PcapPlusPlusCore/NativeBridge/NativeBridgeSupport.swift b/PcapPlusPlusCore/NativeBridge/NativeBridgeSupport.swift index 26c2d38..d7d7307 100644 --- a/PcapPlusPlusCore/NativeBridge/NativeBridgeSupport.swift +++ b/PcapPlusPlusCore/NativeBridge/NativeBridgeSupport.swift @@ -112,6 +112,14 @@ enum NativeBridgeMapper { .payload case 12: .unknown + case 13: + .http2 + case 14: + .http3 + case 15: + .dtls + case 16: + .quic default: .unknown } diff --git a/PcapPlusPlusCore/NativeBridge/NativeBridgeTypes.swift b/PcapPlusPlusCore/NativeBridge/NativeBridgeTypes.swift index 1e10240..6890d70 100644 --- a/PcapPlusPlusCore/NativeBridge/NativeBridgeTypes.swift +++ b/PcapPlusPlusCore/NativeBridge/NativeBridgeTypes.swift @@ -42,6 +42,10 @@ enum PCPPNativeTransportHint: Int { case websocket = 10 case payload = 11 case unknown = 12 + case http2 = 13 + case http3 = 14 + case dtls = 15 + case quic = 16 } enum PCPPNativeDecodeStatusKind: Int { diff --git a/PcapPlusPlusCore/Services/Core/NativeTLSKeyLogManager.swift b/PcapPlusPlusCore/Services/Core/NativeTLSKeyLogManager.swift new file mode 100644 index 0000000..cfe874f --- /dev/null +++ b/PcapPlusPlusCore/Services/Core/NativeTLSKeyLogManager.swift @@ -0,0 +1,238 @@ +// +// NativeTLSKeyLogManager.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import Foundation +@_implementationOnly import TCPViewerWiresharkEpanShim + +public final class NativeTLSKeyLogManager: TLSKeyLogManaging, @unchecked Sendable { + private enum Limits { + static let maximumBytes = 4 * 1_024 * 1_024 + static let maximumLineCount = 20_000 + static let readChunkSize = 64 * 1_024 + } + + private let queue: DispatchQueue + private let runtimeConfiguration: WiresharkRuntimeConfiguration + private var state = TLSKeyLogState.empty + + public init() { + self.queue = DispatchQueue(label: "com.proxyman.tcpviewer.PcapPlusPlusCore.TLSKeyLog", qos: .userInitiated) + self.runtimeConfiguration = WiresharkRuntimeConfiguration() + } + + init(queue: DispatchQueue, runtimeConfiguration: WiresharkRuntimeConfiguration) { + self.queue = queue + self.runtimeConfiguration = runtimeConfiguration + } + + public func validate(fileURL: URL, completion: @escaping TCPViewerCompletion) { + queue.async { + completion(Result { try Self.validateFile(at: fileURL) }) + } + } + + public func apply(fileURL: URL, completion: @escaping TCPViewerCompletion) { + queue.async { + completion(Result { + let validation = try Self.validateFile(at: fileURL) + try self.configureWireshark(filePath: fileURL.path) + let nextState = TLSKeyLogState(fileURL: fileURL, validation: validation) + self.state = nextState + return nextState + }) + } + } + + public func remove(completion: @escaping TCPViewerCompletion) { + queue.async { + completion(Result { + try self.configureWireshark(filePath: nil) + let nextState = TLSKeyLogState.empty + self.state = nextState + return nextState + }) + } + } + + public func currentState(completion: @escaping (TLSKeyLogState) -> Void) { + queue.async { + completion(self.state) + } + } + + // Scan complete lines only because key-log producers can be appending the final record. + static func validateFile(at fileURL: URL) throws -> TLSKeyLogValidation { + let values: URLResourceValues + do { + values = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .isReadableKey]) + } catch { + throw invalidFile("TCP Viewer cannot access the selected TLS key-log file.") + } + guard values.isRegularFile == true else { + throw invalidFile("Choose a regular TLS key-log file, not a directory.") + } + guard values.isReadable != false else { + throw invalidFile("TCP Viewer cannot read the selected TLS key-log file.") + } + + let handle: FileHandle + do { + handle = try FileHandle(forReadingFrom: fileURL) + } catch { + throw invalidFile("TCP Viewer cannot read the selected TLS key-log file.") + } + defer { try? handle.close() } + + var pending = Data() + var scannedBytes = 0 + var scannedLines = 0 + var validRecords = 0 + var warnings = 0 + var reachedLimit = false + + while scannedBytes < Limits.maximumBytes && scannedLines < Limits.maximumLineCount { + let requestedCount = min(Limits.readChunkSize, Limits.maximumBytes - scannedBytes) + let chunk: Data + do { + chunk = try handle.read(upToCount: requestedCount) ?? Data() + } catch { + throw invalidFile("TCP Viewer could not finish reading the selected TLS key-log file.") + } + guard !chunk.isEmpty else { + break + } + scannedBytes += chunk.count + pending.append(chunk) + + while scannedLines < Limits.maximumLineCount, + let newlineIndex = pending.firstIndex(of: 0x0A) { + var line = pending[.. 0 else { + throw invalidFile("No key records recognized by this Wireshark build were found. Syntax validation cannot prove that keys match a capture.") + } + return TLSKeyLogValidation( + validRecordCount: validRecords, + warningCount: warnings, + scannedLineCount: scannedLines, + reachedScanLimit: reachedLimit + ) + } + + private enum LineClassification { + case ignored + case valid + case warning + } + + private static func classify(line: Data.SubSequence) -> LineClassification { + guard let value = String(data: Data(line), encoding: .utf8) else { + return .warning + } + let trimmed = value.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { + return .ignored + } + + if trimmed.hasPrefix("RSA Session-ID:") { + return validateRSASessionLine(trimmed) ? .valid : .warning + } + let fields = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) + guard fields.count == 3 else { + return .warning + } + let label = fields[0] + let identifier = fields[1] + let secret = fields[2] + guard isEvenHex(identifier), isEvenHex(secret) else { + return .warning + } + + switch label { + case "PMS_CLIENT_RANDOM": + return identifier.count == 64 ? .valid : .warning + case "RSA": + return identifier.count == 16 ? .valid : .warning + case "CLIENT_RANDOM": + return identifier.count == 64 && secret.count == 96 ? .valid : .warning + case "CLIENT_EARLY_TRAFFIC_SECRET", "CLIENT_HANDSHAKE_TRAFFIC_SECRET", + "SERVER_HANDSHAKE_TRAFFIC_SECRET", "CLIENT_TRAFFIC_SECRET_0", + "SERVER_TRAFFIC_SECRET_0", "EARLY_EXPORTER_SECRET", "EXPORTER_SECRET": + return identifier.count == 64 ? .valid : .warning + case "ECH_SECRET": + return (64...128).contains(identifier.count) ? .valid : .warning + case "ECH_CONFIG": + return identifier.count >= 44 ? .valid : .warning + default: + return .warning + } + } + + private static func validateRSASessionLine(_ value: String) -> Bool { + let prefix = "RSA Session-ID:" + let separator = " Master-Key:" + guard let separatorRange = value.range(of: separator) else { + return false + } + let sessionID = String(value[value.index(value.startIndex, offsetBy: prefix.count).. Bool { + !value.isEmpty && value.count.isMultiple(of: 2) && value.unicodeScalars.allSatisfy { + (48...57).contains($0.value) || (65...70).contains($0.value) || (97...102).contains($0.value) + } + } + + private func configureWireshark(filePath: String?) throws { + let directory: URL + do { + directory = try runtimeConfiguration.createPersonalConfigurationDirectoryIfNeeded() + } catch { + throw Self.invalidFile("TCP Viewer could not prepare its Wireshark runtime.") + } + + var errorPointer: UnsafeMutablePointer? + let succeeded = directory.path.withCString { directoryPath in + guard let filePath else { + return TCPViewerWiresharkConfigureTLSKeyLog(nil, directoryPath, &errorPointer) + } + return filePath.withCString { path in + TCPViewerWiresharkConfigureTLSKeyLog(path, directoryPath, &errorPointer) + } + } + defer { TCPViewerWiresharkCStringDestroy(errorPointer) } + guard succeeded else { + let message = errorPointer.map { String(cString: $0) } + ?? "Wireshark could not apply the TLS key-log file." + throw Self.invalidFile(message) + } + } + + private static func invalidFile(_ message: String) -> TCPViewerCoreError { + TCPViewerCoreError(code: .unavailableFeature, message: message) + } +} diff --git a/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift b/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift index 69cd90f..8a60b81 100644 --- a/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift +++ b/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift @@ -299,6 +299,50 @@ final class PCPPNativeOfflineDocument { ) } + // Explicit inspector loading may replay the capture once; live ingestion never calls this path. + func loadDecryptedStream( + containing identifier: UInt64, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck? + ) throws -> DecryptedStreamResult { + let snapshot = try state.read { state -> (NativePacketRecord, [NativePacketRecord], WiresharkEpanSession) in + guard let selected = state.file.records.first(where: { $0.identifier == identifier }) else { + throw NativeNSError(.fileReadFailed, "Packet \(identifier) is not available in the backing store.") + } + guard let session = state.dissectionSession else { + throw NativeNSError(.unavailableFeature, "Wireshark TLS stream decryption is unavailable for this capture.") + } + return (selected, state.file.records, session) + } + let identifiers = snapshot.1.map(\.identifier) + let fields: WiresharkDecryptedFollowFields + if snapshot.2.canFollowObservedPackets(withIdentifiers: identifiers) { + fields = try snapshot.2.followObservedDecryptedStream( + containing: snapshot.0, + records: snapshot.1, + limits: limits, + progress: progress, + shouldCancel: shouldCancel + ) + } else { + fields = try WiresharkEpanSession.followDecryptedStreamInTemporarySession( + containing: snapshot.0, + records: snapshot.1, + limits: limits, + progress: progress, + shouldCancel: shouldCancel + ) + } + return DecryptedStreamResult( + protocolName: fields.protocolName, + client: fields.client, + server: fields.server, + request: fields.request, + response: fields.response + ) + } + func save() throws { let snapshot = state.read { ($0.file, $0.currentURL) } try NativeCaptureFile.write(records: snapshot.0.records, to: snapshot.1, format: snapshot.0.format) @@ -865,6 +909,43 @@ final class PCPPNativeLiveSession { ) } + func loadDecryptedStream( + containing identifier: UInt64, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck? + ) throws -> DecryptedStreamResult { + let snapshot = try state.read { state -> NativeLivePacketDiskSnapshot in + guard state.phase == .stopped else { + throw NativeNSError(.unavailableFeature, "Stop the live capture to load the complete decrypted stream.") + } + guard state.hadWorkingDissectionSession else { + throw NativeNSError(.unavailableFeature, "Wireshark TLS stream decryption is unavailable for this capture.") + } + return try state.packetStore.snapshotAll( + shouldCancel: shouldCancel + ) + } + let selected = try snapshot.record(withIdentifier: identifier) + let fields = try WiresharkEpanSession.followDecryptedStreamInTemporarySession( + containing: selected, + recordCount: snapshot.packetCount, + replay: { consume in + try snapshot.replayRecords(shouldCancel: shouldCancel, consume) + }, + limits: limits, + progress: progress, + shouldCancel: shouldCancel + ) + return DecryptedStreamResult( + protocolName: fields.protocolName, + client: fields.client, + server: fields.server, + request: fields.request, + response: fields.response + ) + } + func reanalyzePacketSummaries() throws -> [PCPPNativePacketSummaryDescriptor] { try reanalyzePacketSummaries(withIdentifiers: nil) } @@ -1150,6 +1231,18 @@ private func transportHint(analyzed: AnalyzedPacket, wireshark: WiresharkPacketS // Wireshark has conversation/reassembly state that the metadata analyzer intentionally does not keep. // Let epan's decoded protocol win for app-level hints when it has stronger evidence. + if protocolSummary.contains("http3") || protocolSummary.contains("http/3") { + return .http3 + } + if protocolSummary.contains("http2") || protocolSummary.contains("http/2") { + return .http2 + } + if protocolSummary.contains("quic") { + return .quic + } + if protocolSummary.contains("dtls") { + return .dtls + } if wireshark.sniDomainName?.isEmpty == false || protocolSummary.contains("tls") || infoSummary.contains("client hello") diff --git a/PcapPlusPlusCore/Services/LiveCapture/NativeLiveCaptureSession.swift b/PcapPlusPlusCore/Services/LiveCapture/NativeLiveCaptureSession.swift index 82dff00..e209f1f 100644 --- a/PcapPlusPlusCore/Services/LiveCapture/NativeLiveCaptureSession.swift +++ b/PcapPlusPlusCore/Services/LiveCapture/NativeLiveCaptureSession.swift @@ -65,6 +65,22 @@ public final class NativeLiveCaptureSession: LiveCaptureSessionProviding, @unche ) } + public func loadDecryptedStream( + containing packetID: PacketSummary.ID, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + completion: @escaping TCPViewerCompletion + ) { + state.loadDecryptedStream( + containing: packetID, + limits: limits, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + } + public func exportPackets( withIDs identifiers: [PacketSummary.ID], to url: URL, @@ -502,6 +518,37 @@ private final class NativeLiveCaptureSessionState: @unchecked Sendable { } } + func loadDecryptedStream( + containing packetID: PacketSummary.ID, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + completion: @escaping TCPViewerCompletion + ) { + guard followOperationCoordinator.beginFollow() else { + completion(.failure(TCPViewerCoreError(code: .operationCancelled, message: "TLS stream decryption was cancelled for a capture lifecycle change."))) + return + } + followQueue.async { + let result = Result { + do { + return try self.nativeSession.loadDecryptedStream( + containing: packetID, + limits: limits, + progress: progress, + shouldCancel: { + self.followOperationCoordinator.shouldCancel || shouldCancel?() == true + } + ) + } catch { + throw NativeBridgeMapper.coreError(error, defaultCode: .unavailableFeature) + } + } + self.followOperationCoordinator.finishFollow() + completion(result) + } + } + func exportPackets( withIDs identifiers: [PacketSummary.ID], to url: URL, diff --git a/PcapPlusPlusCore/Services/LiveCapture/NativeLivePacketDiskStore.swift b/PcapPlusPlusCore/Services/LiveCapture/NativeLivePacketDiskStore.swift index 2b3c814..5f2c3f5 100644 --- a/PcapPlusPlusCore/Services/LiveCapture/NativeLivePacketDiskStore.swift +++ b/PcapPlusPlusCore/Services/LiveCapture/NativeLivePacketDiskStore.swift @@ -43,6 +43,32 @@ final class NativeLivePacketDiskSnapshot: @unchecked Sendable { Darwin.close(fileDescriptor) } + var packetCount: Int { + entries.count + } + + func record(withIdentifier identifier: UInt64) throws -> NativePacketRecord { + guard let entry = entries.first(where: { $0.identifier == identifier }) else { + throw NativeNSError(.fileReadFailed, "Packet \(identifier) is not available in the live snapshot.") + } + return try record(for: entry) + } + + // Rehydrate one packet at a time so a full stopped capture stays disk-backed during replay. + func replayRecords( + shouldCancel: TCPFollowCancellationCheck? = nil, + _ consume: (NativePacketRecord) throws -> Bool + ) throws { + for entry in entries { + if shouldCancel?() == true { + throw NativeNSError(.operationCancelled, "TLS stream decryption was cancelled.") + } + if try !consume(record(for: entry)) { + break + } + } + } + // Rehydrate a bounded immutable snapshot without holding the live capture lock. func records( maximumBytes: Int, @@ -59,27 +85,30 @@ final class NativeLivePacketDiskSnapshot: @unchecked Sendable { throw NativeNSError(.unavailableFeature, "The TCP stream snapshot exceeds the \(maximumBytes)-byte input limit.") } remainingBytes -= entry.capturedLength - let bytes = try readBytes(for: entry) - records.append(NativePacketRecord( - identifier: entry.identifier, - packetNumber: entry.packetNumber, - timestamp: entry.timestamp, - rawBytes: bytes, - originalLength: entry.originalLength, - linkLayerType: entry.linkLayerType, - interfaceIdentifier: entry.interfaceIdentifier, - interfaceName: entry.interfaceName, - packetComment: entry.packetComment, - interfaceID: entry.interfaceID, - sectionNumber: entry.sectionNumber, - pcapNGTimestampResolution: entry.pcapNGTimestampResolution, - pcapNGTimestampOffsetSeconds: entry.pcapNGTimestampOffsetSeconds, - pcapNGTimestampRawValue: entry.pcapNGTimestampRawValue - )) + records.append(try record(for: entry)) } return records } + private func record(for entry: NativeLivePacketDiskEntry) throws -> NativePacketRecord { + NativePacketRecord( + identifier: entry.identifier, + packetNumber: entry.packetNumber, + timestamp: entry.timestamp, + rawBytes: try readBytes(for: entry), + originalLength: entry.originalLength, + linkLayerType: entry.linkLayerType, + interfaceIdentifier: entry.interfaceIdentifier, + interfaceName: entry.interfaceName, + packetComment: entry.packetComment, + interfaceID: entry.interfaceID, + sectionNumber: entry.sectionNumber, + pcapNGTimestampResolution: entry.pcapNGTimestampResolution, + pcapNGTimestampOffsetSeconds: entry.pcapNGTimestampOffsetSeconds, + pcapNGTimestampRawValue: entry.pcapNGTimestampRawValue + ) + } + private func readBytes(for entry: NativeLivePacketDiskEntry) throws -> Data { var bytes = Data(count: entry.capturedLength) let bytesRead = bytes.withUnsafeMutableBytes { buffer -> Int in @@ -253,6 +282,28 @@ final class NativeLivePacketDiskStore { ) } + // Duplicate the anonymous store so stopped-capture TLS replay never holds the writer lock. + func snapshotAll( + shouldCancel: TCPFollowCancellationCheck? = nil + ) throws -> NativeLivePacketDiskSnapshot { + if shouldCancel?() == true { + throw NativeNSError(.operationCancelled, "TLS stream decryption was cancelled.") + } + try openHandlesIfNeeded() + guard let reader else { + throw NativeNSError(.fileReadFailed, "The live packet backing store could not be opened for reading.") + } + let descriptor = Darwin.dup(reader.fileDescriptor) + guard descriptor >= 0 else { + throw NativeNSError(.fileReadFailed, "The live packet backing store could not create a stable snapshot.") + } + return NativeLivePacketDiskSnapshot( + fileDescriptor: descriptor, + entries: entries, + capturedThroughPacketID: entries.last?.identifier ?? 0 + ) + } + // Rehydrate only the requested packet bytes from disk. func record(withIdentifier identifier: UInt64) throws -> NativePacketRecord { guard let index = entryIndexByID[identifier] else { diff --git a/PcapPlusPlusCore/Services/OfflineCapture/NativeOfflineCaptureDocument.swift b/PcapPlusPlusCore/Services/OfflineCapture/NativeOfflineCaptureDocument.swift index e8b7902..bfe354c 100644 --- a/PcapPlusPlusCore/Services/OfflineCapture/NativeOfflineCaptureDocument.swift +++ b/PcapPlusPlusCore/Services/OfflineCapture/NativeOfflineCaptureDocument.swift @@ -52,6 +52,22 @@ public final class NativeOfflineCaptureDocument: OfflineCaptureDocumentProviding ) } + public func loadDecryptedStream( + containing packetID: PacketSummary.ID, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + completion: @escaping TCPViewerCompletion + ) { + state.loadDecryptedStream( + containing: packetID, + limits: limits, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + } + public func save(completion: @escaping TCPViewerVoidCompletion) { state.save(completion: completion) } @@ -292,6 +308,33 @@ private final class NativeOfflineCaptureDocumentState: @unchecked Sendable { } } + func loadDecryptedStream( + containing packetID: PacketSummary.ID, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + completion: @escaping TCPViewerCompletion + ) { + let packets = packetCache.get() + followQueue.async { + completion(Result { + guard packets.contains(where: { $0.id == packetID }) else { + throw TCPViewerCoreError(code: .offlineFileOpenFailed, message: "Packet \(packetID) is not available.") + } + do { + return try self.nativeDocument.loadDecryptedStream( + containing: packetID, + limits: limits, + progress: progress, + shouldCancel: shouldCancel + ) + } catch { + throw NativeBridgeMapper.coreError(error, defaultCode: .unavailableFeature) + } + }) + } + } + func save(completion: @escaping TCPViewerVoidCompletion) { stateQueue.async { completion(Result { diff --git a/PcapPlusPlusCoreTests/Dissection/WiresharkTLSDecryptionTests.swift b/PcapPlusPlusCoreTests/Dissection/WiresharkTLSDecryptionTests.swift new file mode 100644 index 0000000..5231f3b --- /dev/null +++ b/PcapPlusPlusCoreTests/Dissection/WiresharkTLSDecryptionTests.swift @@ -0,0 +1,184 @@ +// +// WiresharkTLSDecryptionTests.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import Foundation +import Testing +@testable import PcapPlusPlusCore + +@Suite(.serialized) +struct WiresharkTLSDecryptionTests { + @Test func decryptsRFC8446TLS13IntoDirectionalStreams() throws { + let root = repositoryRoot() + let captureURL = root.appendingPathComponent("Vendor/Wireshark/test/captures/tls13-rfc8446.pcap") + let keyURL = root.appendingPathComponent("Vendor/Wireshark/test/keys/tls13-rfc8446.keys") + let manager = NativeTLSKeyLogManager() + _ = try apply(manager: manager, fileURL: keyURL) + defer { remove(manager: manager) } + + let records = try NativeCaptureFile.load(from: captureURL).records + let selected = try #require(records.first(where: { $0.identifier == 5 })) + let result = try WiresharkEpanSession.followDecryptedStreamInTemporarySession( + containing: selected, + records: records, + limits: .default, + progress: nil, + shouldCancel: nil + ) + + #expect(result.protocolName == .tls) + #expect(String(data: result.request.data, encoding: .utf8)?.contains("/first") == true) + #expect(!result.response.data.isEmpty) + } + + @Test func mismatchedKeysDoNotExposePlaintextOrCrash() throws { + let root = repositoryRoot() + let captureURL = root.appendingPathComponent("Vendor/Wireshark/test/captures/tls13-rfc8446.pcap") + let keyURL = root.appendingPathComponent("Vendor/Wireshark/test/keys/tls12-chacha20poly1305.keys") + let manager = NativeTLSKeyLogManager() + _ = try apply(manager: manager, fileURL: keyURL) + defer { remove(manager: manager) } + + let records = try NativeCaptureFile.load(from: captureURL).records + let selected = try #require(records.first(where: { $0.identifier == 5 })) + let result = try WiresharkEpanSession.followDecryptedStreamInTemporarySession( + containing: selected, + records: records, + limits: .default, + progress: nil, + shouldCancel: nil + ) + + #expect(result.request.data.isEmpty) + #expect(result.response.data.isEmpty) + } + + @Test func decryptsTLS12ChaCha20Poly1305Fixture() throws { + let root = repositoryRoot() + let captureURL = root.appendingPathComponent("Vendor/Wireshark/test/captures/tls12-chacha20poly1305.pcap") + let keyURL = root.appendingPathComponent("Vendor/Wireshark/test/keys/tls12-chacha20poly1305.keys") + let manager = NativeTLSKeyLogManager() + _ = try apply(manager: manager, fileURL: keyURL) + defer { remove(manager: manager) } + + let records = try NativeCaptureFile.load(from: captureURL).records + let session = try WiresharkEpanSession(purpose: .follow) + for record in records { + try session.observe(record) + } + try session.finishFirstPass() + let selected = try #require(records.first { record in + (try? session.summarize(record).protocolSummary?.lowercased().contains("tls")) == true + }) + let result = try session.followObservedDecryptedStream( + containing: selected, + records: records, + limits: .default, + progress: nil, + shouldCancel: nil + ) + let plaintext = result.request.data + result.response.data + + #expect(String(data: plaintext, encoding: .utf8)?.contains("Cipher is") == true) + } + + @Test func capsEachDirectionAndReportsObservedBytes() throws { + let root = repositoryRoot() + let captureURL = root.appendingPathComponent("Vendor/Wireshark/test/captures/tls13-rfc8446.pcap") + let keyURL = root.appendingPathComponent("Vendor/Wireshark/test/keys/tls13-rfc8446.keys") + let manager = NativeTLSKeyLogManager() + _ = try apply(manager: manager, fileURL: keyURL) + defer { remove(manager: manager) } + let records = try NativeCaptureFile.load(from: captureURL).records + let selected = try #require(records.first(where: { $0.identifier == 5 })) + + let result = try WiresharkEpanSession.followDecryptedStreamInTemporarySession( + containing: selected, + records: records, + limits: DecryptedStreamLimits(maximumBytesPerDirection: 4), + progress: nil, + shouldCancel: nil + ) + + #expect(result.request.data.count == 4) + #expect(result.response.data.count == 4) + #expect(result.request.observedByteCount > result.request.data.count) + #expect(result.response.observedByteCount > result.response.data.count) + #expect(result.request.isTruncated) + #expect(result.response.isTruncated) + } + + @Test func detectsSecretsAppendedToSelectedFileWithoutReapplyingPreference() throws { + let root = repositoryRoot() + let captureURL = root.appendingPathComponent("Vendor/Wireshark/test/captures/tls13-rfc8446.pcap") + let sourceKeyURL = root.appendingPathComponent("Vendor/Wireshark/test/keys/tls13-rfc8446.keys") + let keyData = try Data(contentsOf: sourceKeyURL) + let newline = try #require(keyData.firstIndex(of: 0x0A)) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let selectedKeyURL = directory.appendingPathComponent("growing.keys") + try keyData[...newline].write(to: selectedKeyURL) + let manager = NativeTLSKeyLogManager() + _ = try apply(manager: manager, fileURL: selectedKeyURL) + defer { remove(manager: manager) } + let records = try NativeCaptureFile.load(from: captureURL).records + let selected = try #require(records.first(where: { $0.identifier == 5 })) + + let beforeAppend = try WiresharkEpanSession.followDecryptedStreamInTemporarySession( + containing: selected, + records: records, + limits: .default, + progress: nil, + shouldCancel: nil + ) + let handle = try FileHandle(forWritingTo: selectedKeyURL) + try handle.seekToEnd() + try handle.write(contentsOf: keyData[keyData.index(after: newline)...]) + try handle.close() + let afterAppend = try WiresharkEpanSession.followDecryptedStreamInTemporarySession( + containing: selected, + records: records, + limits: .default, + progress: nil, + shouldCancel: nil + ) + + #expect(beforeAppend.request.data.isEmpty) + #expect(beforeAppend.response.data.isEmpty) + #expect(!afterAppend.request.data.isEmpty) + #expect(!afterAppend.response.data.isEmpty) + } + + private func repositoryRoot() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + private func apply(manager: NativeTLSKeyLogManager, fileURL: URL) throws -> TLSKeyLogState { + let semaphore = DispatchSemaphore(value: 0) + let lock = NSLock() + var storedResult: Result? + manager.apply(fileURL: fileURL) { result in + lock.lock() + storedResult = result + lock.unlock() + semaphore.signal() + } + semaphore.wait() + lock.lock() + defer { lock.unlock() } + return try #require(storedResult).get() + } + + private func remove(manager: NativeTLSKeyLogManager) { + let semaphore = DispatchSemaphore(value: 0) + manager.remove { _ in semaphore.signal() } + semaphore.wait() + } +} diff --git a/PcapPlusPlusCoreTests/Services/Core/NativeTLSKeyLogManagerTests.swift b/PcapPlusPlusCoreTests/Services/Core/NativeTLSKeyLogManagerTests.swift new file mode 100644 index 0000000..c3b265f --- /dev/null +++ b/PcapPlusPlusCoreTests/Services/Core/NativeTLSKeyLogManagerTests.swift @@ -0,0 +1,150 @@ +// +// NativeTLSKeyLogManagerTests.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import Foundation +import Testing +@testable import PcapPlusPlusCore + +@Suite(.serialized) +struct NativeTLSKeyLogManagerTests { + @Test func acceptsEveryFormatRecognizedByPinnedWireshark() throws { + let url = try temporaryFile(contents: [ + "PMS_CLIENT_RANDOM \(hex(bytes: 32)) aa", + "RSA \(hex(bytes: 8)) \(hex(bytes: 48))", + "RSA Session-ID:aa Master-Key:\(hex(bytes: 48))", + "CLIENT_RANDOM \(hex(bytes: 32)) \(hex(bytes: 48))", + "CLIENT_EARLY_TRAFFIC_SECRET \(hex(bytes: 32)) aa", + "CLIENT_HANDSHAKE_TRAFFIC_SECRET \(hex(bytes: 32)) aa", + "SERVER_HANDSHAKE_TRAFFIC_SECRET \(hex(bytes: 32)) aa", + "CLIENT_TRAFFIC_SECRET_0 \(hex(bytes: 32)) aa", + "SERVER_TRAFFIC_SECRET_0 \(hex(bytes: 32)) aa", + "EARLY_EXPORTER_SECRET \(hex(bytes: 32)) aa", + "EXPORTER_SECRET \(hex(bytes: 32)) aa", + "ECH_SECRET \(hex(bytes: 32)) aa", + "ECH_CONFIG \(hex(bytes: 22)) aa", + ].joined(separator: "\n") + "\n") + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let result = try NativeTLSKeyLogManager.validateFile(at: url) + + #expect(result.validRecordCount == 13) + #expect(result.warningCount == 0) + } + + @Test func acceptsCommentsCRLFLowercaseAndIgnoresIncompleteFinalLine() throws { + let valid = "client_random \(hex(bytes: 32)) \(hex(bytes: 48))" + .replacingOccurrences(of: "client_random", with: "CLIENT_RANDOM") + let url = try temporaryFile(contents: "# generated\r\n\r\n\(valid)\r\nBROKEN value\r\nCLIENT_RANDOM aa") + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let result = try NativeTLSKeyLogManager.validateFile(at: url) + + #expect(result.validRecordCount == 1) + #expect(result.warningCount == 1) + #expect(result.scannedLineCount == 4) + } + + @Test func rejectsDirectoriesAndFilesWithoutRecognizedRecords() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let invalidURL = directory.appendingPathComponent("invalid.keys") + try Data("CLIENT_RANDOM aa abc\n".utf8).write(to: invalidURL) + + #expect(throws: TCPViewerCoreError.self) { + try NativeTLSKeyLogManager.validateFile(at: directory) + } + #expect(throws: TCPViewerCoreError.self) { + try NativeTLSKeyLogManager.validateFile(at: invalidURL) + } + } + + @Test func missingFileErrorDoesNotExposeItsPath() throws { + let missingURL = FileManager.default.temporaryDirectory + .appendingPathComponent("SECRET-PATH-(UUID().uuidString)") + + do { + _ = try NativeTLSKeyLogManager.validateFile(at: missingURL) + Issue.record("Expected the missing file to be rejected.") + } catch let error as TCPViewerCoreError { + #expect(error.message == "TCP Viewer cannot access the selected TLS key-log file.") + #expect(!error.message.contains(missingURL.path)) + } + } + + @Test func stopsAtCompleteLineLimit() throws { + var lines = ["CLIENT_RANDOM \(hex(bytes: 32)) \(hex(bytes: 48))"] + lines.append(contentsOf: repeatElement("# comment", count: 20_100)) + let url = try temporaryFile(contents: lines.joined(separator: "\n") + "\n") + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let result = try NativeTLSKeyLogManager.validateFile(at: url) + + #expect(result.validRecordCount == 1) + #expect(result.scannedLineCount == 20_000) + #expect(result.reachedScanLimit) + } + + @Test func replacementAndRemovalUpdateState() throws { + let firstURL = try temporaryFile(contents: "CLIENT_RANDOM \(hex(bytes: 32)) \(hex(bytes: 48))\n") + let directory = firstURL.deletingLastPathComponent() + let secondURL = directory.appendingPathComponent("replacement.log") + try Data("CLIENT_RANDOM \(String(repeating: "cd", count: 32)) \(hex(bytes: 48))\n".utf8).write(to: secondURL) + defer { try? FileManager.default.removeItem(at: directory) } + let manager = NativeTLSKeyLogManager() + + let first = try apply(manager, fileURL: firstURL) + let replacement = try apply(manager, fileURL: secondURL) + let removed = try remove(manager) + + #expect(first.fileURL == firstURL) + #expect(replacement.fileURL == secondURL) + #expect(removed.fileURL == nil) + } + + private func hex(bytes: Int) -> String { + String(repeating: "ab", count: bytes) + } + + private func temporaryFile(contents: String) throws -> URL { + let directory = try temporaryDirectory() + let url = directory.appendingPathComponent("test key ü.keys") + try Data(contents.utf8).write(to: url) + return url + } + + private func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func apply(_ manager: NativeTLSKeyLogManager, fileURL: URL) throws -> TLSKeyLogState { + try waitForResult { manager.apply(fileURL: fileURL, completion: $0) } + } + + private func remove(_ manager: NativeTLSKeyLogManager) throws -> TLSKeyLogState { + try waitForResult(manager.remove) + } + + private func waitForResult( + _ operation: (@escaping TCPViewerCompletion) -> Void + ) throws -> Value { + let semaphore = DispatchSemaphore(value: 0) + let lock = NSLock() + var storedResult: Result? + operation { result in + lock.lock() + storedResult = result + lock.unlock() + semaphore.signal() + } + semaphore.wait() + lock.lock() + defer { lock.unlock() } + return try #require(storedResult).get() + } +} diff --git a/PcapPlusPlusCoreTests/Services/LiveCapture/NativeLivePacketDiskSnapshotTests.swift b/PcapPlusPlusCoreTests/Services/LiveCapture/NativeLivePacketDiskSnapshotTests.swift index 43e4767..7eb1857 100644 --- a/PcapPlusPlusCoreTests/Services/LiveCapture/NativeLivePacketDiskSnapshotTests.swift +++ b/PcapPlusPlusCoreTests/Services/LiveCapture/NativeLivePacketDiskSnapshotTests.swift @@ -78,6 +78,28 @@ struct NativeLivePacketDiskSnapshotTests { #expect(try snapshot.records(maximumBytes: 3).map(\.identifier) == [1, 2, 3]) } + @Test func fullSnapshotReplaysFromDiskWithoutBuildingARecordArray() throws { + let store = NativeLivePacketDiskStore() + try store.append(makeRecord(identifier: 1, byte: 0x11)) + try store.append(makeRecord(identifier: 2, byte: 0x22)) + try store.append(makeRecord(identifier: 3, byte: 0x33)) + + let snapshot = try store.snapshotAll() + store.reset() + var identifiers: [UInt64] = [] + try snapshot.replayRecords { record in + identifiers.append(record.identifier) + return identifiers.count < 2 + } + + #expect(snapshot.packetCount == 3) + #expect(try snapshot.record(withIdentifier: 3).rawBytes == Data([0x33])) + #expect(identifiers == [1, 2]) + #expect(throws: NSError.self) { + try snapshot.replayRecords(shouldCancel: { true }) { _ in true } + } + } + private func makeRecord(identifier: UInt64, byte: UInt8) -> NativePacketRecord { NativePacketRecord( identifier: identifier, diff --git a/README.md b/README.md index 4d08694..3be0890 100644 --- a/README.md +++ b/README.md @@ -191,14 +191,19 @@ In Xcode: 3. Choose `My Mac`. 4. Press Run. +Signing: + +- Set `TCPVIEWER_DEVELOPMENT_TEAM` in `Config/TCPViewer.local.xcconfig`. +- Use the same development team for every target. +- `TCPViewer`, `PcapPlusPlusCore`, and `TCPViewerHelperTool` must use the same team. +- If dyld reports different Team IDs, clean the build folder and build again. + Command-line build: ```bash xcodebuild -project TCPViewer.xcodeproj -scheme TCPViewer build ``` -If Xcode asks for signing, select a development team for `TCPViewer` and `PcapPlusPlusCore`. - ## Test ```bash diff --git a/TCPViewer/App/AppDelegate.swift b/TCPViewer/App/AppDelegate.swift index 1c2c50e..4e621fc 100644 --- a/TCPViewer/App/AppDelegate.swift +++ b/TCPViewer/App/AppDelegate.swift @@ -16,6 +16,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var aboutWindowController: TCPViewerAboutWindowController? private var settingsWindowController: NSWindowController? + private var tlsKeyLogWindowController: TLSKeyLogWindowController? private var licenseWindowController: TCPViewerLicenseWindowController? private var updaterController: SPUStandardUpdaterController? private let sparkleUpdaterDelegate = TCPViewerSparkleUpdaterDelegate() @@ -24,8 +25,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { private weak var licenseMenuItem: NSMenuItem? private var licenseStatusObserver: NSObjectProtocol? private var configurationObserver: NSObjectProtocol? + private var liveCaptureReleaseObserver: NSObjectProtocol? private lazy var sentryService = TCPViewerSentryService(configuration: appConfiguration) private lazy var factoryResetService = TCPViewerFactoryResetService(helperToolManager: networkHelperToolManager) + private let tlsKeyLogManager = NativeTLSKeyLogManager() private var isHandlingTermination = false private var skipsNextQuitConfirmation = false private var isShowingRenewalRequiredAlert = false @@ -33,6 +36,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var didCheckForUpdatesAtLaunch = false private var availableUpdateCount = 0 private var isTerminatingAfterFactoryReset = false + private var hasPendingTLSKeyLogReload = false + private var isReloadingTLSKeyLogCaptures = false #if DEBUG private var shouldOpenUntitledDocumentAfterIgnoringDebugLaunchFiles = false #endif @@ -43,12 +48,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { appConfiguration.applyAppearance() observeLicenseStatusChanges() observeConfigurationChanges() + observeLiveCaptureRelease() wireAboutMenu() wirePreferencesMenu() wireUpdatesMenu() checkForAvailableUpdatesAtLaunch() wireClearAllPacketsMenu() wireFilterMenu() + wireToolsMenu() wireHelpMenu() verifyLicenseAtLaunch() updateMCPServerAvailability() @@ -90,6 +97,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { if let configurationObserver { NotificationCenter.default.removeObserver(configurationObserver) } + if let liveCaptureReleaseObserver { + NotificationCenter.default.removeObserver(liveCaptureReleaseObserver) + } } func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { @@ -116,6 +126,23 @@ class AppDelegate: NSObject, NSApplicationDelegate { presentCaptureOpenPanel() } + @objc private func showTLSKeyLog(_ sender: Any?) { + if let tlsKeyLogWindowController { + tlsKeyLogWindowController.showWindow(sender) + tlsKeyLogWindowController.window?.makeKeyAndOrderFront(sender) + return + } + + let controller = TLSKeyLogWindowController(manager: tlsKeyLogManager) + controller.configurationDidChange = { [weak self] in + self?.handleTLSKeyLogConfigurationChange() + } + tlsKeyLogWindowController = controller + controller.showWindow(sender) + controller.window?.center() + controller.window?.makeKeyAndOrderFront(sender) + } + private func prepareForTermination(_ sender: NSApplication) -> NSApplication.TerminateReply { isHandlingTermination = true TCPViewerWorkspaceController.prepareAllForApplicationTermination { [weak self] shouldTerminate in @@ -617,6 +644,92 @@ class AppDelegate: NSObject, NSApplicationDelegate { editMenu.insertItem(item, at: insertionIndex) } + // Insert one idempotent app-level Tools menu immediately before Window. + func wireToolsMenu() { + guard let mainMenu = NSApp.mainMenu else { + return + } + let toolsItem: NSMenuItem + if let existing = mainMenu.items.first(where: { $0.title == "Tools" }) { + toolsItem = existing + } else { + toolsItem = NSMenuItem(title: "Tools", action: nil, keyEquivalent: "") + let windowIndex = mainMenu.items.firstIndex(where: { $0.title == "Window" }) ?? mainMenu.items.count + mainMenu.insertItem(toolsItem, at: windowIndex) + } + + let toolsMenu = toolsItem.submenu ?? NSMenu(title: "Tools") + toolsItem.submenu = toolsMenu + if let existing = toolsMenu.items.first(where: { $0.action == #selector(showTLSKeyLog(_:)) }) { + existing.title = "TLS Key Log…" + existing.target = self + return + } + let keyLogItem = NSMenuItem(title: "TLS Key Log…", action: #selector(showTLSKeyLog(_:)), keyEquivalent: "") + keyLogItem.target = self + toolsMenu.addItem(keyLogItem) + } + + private func handleTLSKeyLogConfigurationChange() { + let controllers = workspaceWindowControllers() + controllers.forEach { + $0.rootViewController.viewModel.invalidateInspectionAfterTLSKeyLogChange() + } + hasPendingTLSKeyLogReload = true + reloadPendingTLSKeyLogCapturesIfPossible() + } + + private func observeLiveCaptureRelease() { + liveCaptureReleaseObserver = NotificationCenter.default.addObserver( + forName: TCPViewerWorkspaceController.liveCaptureDidReleaseWiresharkNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.reloadPendingTLSKeyLogCapturesIfPossible() + } + } + + private func reloadPendingTLSKeyLogCapturesIfPossible() { + guard hasPendingTLSKeyLogReload, !isReloadingTLSKeyLogCaptures else { + return + } + let controllers = workspaceWindowControllers() + guard !controllers.contains(where: { $0.rootViewController.viewModel.snapshot.base.sessionState.canStop }) else { + return + } + hasPendingTLSKeyLogReload = false + let offlineControllers = controllers.filter { + $0.rootViewController.viewModel.snapshot.base.packetIngestState.source == .offline + } + guard !offlineControllers.isEmpty else { + return + } + isReloadingTLSKeyLogCaptures = true + reloadOfflineCaptures(offlineControllers, index: 0) { [weak self] in + self?.isReloadingTLSKeyLogCaptures = false + self?.reloadPendingTLSKeyLogCapturesIfPossible() + } + } + + // Reopen offline windows one at a time because Wireshark has one process-wide dissection session. + private func reloadOfflineCaptures( + _ controllers: [TCPViewerWindowController], + index: Int, + completion: @escaping () -> Void + ) { + guard index < controllers.count else { + completion() + return + } + controllers[index].rootViewController.viewModel.reloadAfterTLSKeyLogChange { [weak self] in + self?.reloadOfflineCaptures(controllers, index: index + 1, completion: completion) + } + } + + private func workspaceWindowControllers() -> [TCPViewerWindowController] { + NSApp.windows.compactMap { $0.windowController as? TCPViewerWindowController } + } + private func configureClearAllPacketsMenuItem(_ item: NSMenuItem) { item.title = "Clear All Packets" item.target = nil diff --git a/TCPViewer/App/TLSKeyLogWindowController.swift b/TCPViewer/App/TLSKeyLogWindowController.swift new file mode 100644 index 0000000..38ec12c --- /dev/null +++ b/TCPViewer/App/TLSKeyLogWindowController.swift @@ -0,0 +1,177 @@ +// +// TLSKeyLogWindowController.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import AppKit +import PcapPlusPlusCore +import UniformTypeIdentifiers + +final class TLSKeyLogWindowController: NSWindowController { + var configurationDidChange: (() -> Void)? + + private let manager: any TLSKeyLogManaging + private let fileLabel = NSTextField(labelWithString: "No key-log file selected") + private let pathLabel = NSTextField(labelWithString: "") + private let statusLabel = NSTextField(wrappingLabelWithString: "Choose an NSS/SSL key-log file to enable Wireshark decryption.") + private let chooseButton = NSButton(title: "Choose File…", target: nil, action: nil) + private let reloadButton = NSButton(title: "Reload", target: nil, action: nil) + private let removeButton = NSButton(title: "Remove", target: nil, action: nil) + private let progressIndicator = NSProgressIndicator() + private var selectedURL: URL? + + init(manager: any TLSKeyLogManaging) { + self.manager = manager + let contentController = NSViewController() + let window = NSWindow(contentViewController: contentController) + window.title = "TLS Key Log" + window.styleMask = [.titled, .closable, .miniaturizable] + window.setContentSize(NSSize(width: 560, height: 330)) + window.isReleasedWhenClosed = false + super.init(window: window) + setupView(contentController.view) + refreshState() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView(_ contentView: NSView) { + fileLabel.font = .systemFont(ofSize: 15, weight: .semibold) + fileLabel.lineBreakMode = .byTruncatingMiddle + pathLabel.textColor = .secondaryLabelColor + pathLabel.font = .monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular) + pathLabel.lineBreakMode = .byTruncatingMiddle + pathLabel.isSelectable = true + statusLabel.textColor = .secondaryLabelColor + + chooseButton.target = self + chooseButton.action = #selector(chooseFile(_:)) + reloadButton.target = self + reloadButton.action = #selector(reloadFile(_:)) + removeButton.target = self + removeButton.action = #selector(removeFile(_:)) + + progressIndicator.style = .spinning + progressIndicator.controlSize = .small + progressIndicator.isDisplayedWhenStopped = false + + let warning = NSTextField(wrappingLabelWithString: "TLS key logs expose encrypted session contents. Keep the file private. TCP Viewer uses the original file by reference, does not copy it, and forgets the selection when the app quits.") + warning.textColor = .systemOrange + + let buttonRow = NSStackView(views: [chooseButton, reloadButton, removeButton, progressIndicator]) + buttonRow.orientation = .horizontal + buttonRow.alignment = .centerY + buttonRow.spacing = 8 + + let stack = NSStackView(views: [fileLabel, pathLabel, statusLabel, warning, buttonRow]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 12 + stack.edgeInsets = NSEdgeInsets(top: 24, left: 24, bottom: 24, right: 24) + stack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + stack.topAnchor.constraint(equalTo: contentView.topAnchor), + stack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor), + fileLabel.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -48), + pathLabel.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -48), + statusLabel.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -48), + warning.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -48), + ]) + } + + private func refreshState() { + manager.currentState { [weak self] state in + DispatchQueue.main.async { + self?.render(state) + } + } + } + + private func render(_ state: TLSKeyLogState) { + selectedURL = state.fileURL + fileLabel.stringValue = state.fileURL?.lastPathComponent ?? "No key-log file selected" + pathLabel.stringValue = state.fileURL?.path ?? "" + chooseButton.title = state.fileURL == nil ? "Choose File…" : "Replace…" + reloadButton.isEnabled = state.fileURL != nil + removeButton.isEnabled = state.fileURL != nil + if let validation = state.validation { + var message = "Valid records: \(validation.validRecordCount). Warnings: \(validation.warningCount)." + if validation.reachedScanLimit { + message += " Validation stopped at the safe scan limit." + } + message += " Syntax validation cannot prove that these secrets match the capture." + statusLabel.stringValue = message + } else { + statusLabel.stringValue = "Choose an NSS/SSL key-log file to enable Wireshark decryption." + } + } + + private func setLoading(_ loading: Bool, message: String) { + statusLabel.stringValue = message + chooseButton.isEnabled = !loading + reloadButton.isEnabled = !loading && selectedURL != nil + removeButton.isEnabled = !loading && selectedURL != nil + loading ? progressIndicator.startAnimation(nil) : progressIndicator.stopAnimation(nil) + } + + @objc private func chooseFile(_ sender: Any?) { + let panel = NSOpenPanel() + panel.title = "Choose TLS Key Log" + panel.message = "Choose a .txt, .log, .keys, or extensionless NSS/SSL key-log file." + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [.plainText, .data] + guard panel.runModal() == .OK, let url = panel.url else { + return + } + apply(url) + } + + @objc private func reloadFile(_ sender: Any?) { + guard let selectedURL else { + return + } + apply(selectedURL) + } + + @objc private func removeFile(_ sender: Any?) { + setLoading(true, message: "Removing TLS key log…") + manager.remove { [weak self] result in + DispatchQueue.main.async { + self?.finish(result) + } + } + } + + private func apply(_ url: URL) { + setLoading(true, message: "Validating TLS key log…") + manager.apply(fileURL: url) { [weak self] result in + DispatchQueue.main.async { + self?.finish(result) + } + } + } + + private func finish(_ result: Result) { + progressIndicator.stopAnimation(nil) + chooseButton.isEnabled = true + switch result { + case .success(let state): + render(state) + configurationDidChange?() + case .failure(let error): + reloadButton.isEnabled = selectedURL != nil + removeButton.isEnabled = selectedURL != nil + statusLabel.stringValue = (error as? TCPViewerCoreError)?.message ?? error.localizedDescription + } + } +} diff --git a/TCPViewer/Core/WorkspaceFoundation.swift b/TCPViewer/Core/WorkspaceFoundation.swift index 72c4d4e..3ab88fe 100644 --- a/TCPViewer/Core/WorkspaceFoundation.swift +++ b/TCPViewer/Core/WorkspaceFoundation.swift @@ -1071,6 +1071,10 @@ struct TCPViewerWorkspaceMemoryDebugSnapshot: Equatable { #endif final class TCPViewerWorkspaceController { + static let liveCaptureDidReleaseWiresharkNotification = Notification.Name( + "TCPViewerWorkspaceControllerLiveCaptureDidReleaseWireshark" + ) + private struct ImportedSessionCaptureExportGroup { let fileID: ImportedCaptureFileID var originalPacketIDs: [PacketSummary.ID] @@ -1967,6 +1971,16 @@ final class TCPViewerWorkspaceController { } } + // Reopen one document directly or rebuild a merged offline workspace from its original files. + func reloadOfflineCapturesAfterTLSKeyLogChange(completion: (() -> Void)? = nil) { + let importedURLs = snapshot.packetIngestState.importedFiles.map(\.url) + if importedURLs.count > 1 { + openDocuments(at: importedURLs, replacingCurrent: true, completion: completion) + return + } + reopenDocument(completion: completion) + } + func saveDocument(completion: (() -> Void)? = nil) { guard let document else { completion?() @@ -2995,6 +3009,28 @@ final class TCPViewerWorkspaceController { ) } + func loadDecryptedStream( + containing identifier: PacketSummary.ID, + progress: TCPFollowProgressHandler? = nil, + shouldCancel: TCPFollowCancellationCheck? = nil, + completion: @escaping TCPViewerCompletion + ) { + guard let packet = snapshot.packetIngestState.packet(withID: identifier) else { + completion(.failure(TCPViewerCoreError( + code: .offlineFileOpenFailed, + message: "Packet \(identifier) is no longer available." + ))) + return + } + loadDecryptedStream( + packet, + identifier: identifier, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + } + func cancelBackgroundWork() { cancelControllerTasks() @@ -3285,11 +3321,18 @@ final class TCPViewerWorkspaceController { private func applyPacketIngestEvent(_ event: PacketIngestEvent) { switch event { case .liveStateChanged(let phase, let message): + let previouslyOwnedWireshark = snapshot.sessionState.canStop snapshot.sessionState.phase = mappedPhase(phase) snapshot.sessionState.statusMessage = message if mappedPhase(phase) != .failed { snapshot.sessionState.lastError = nil } + if previouslyOwnedWireshark && !snapshot.sessionState.canStop { + NotificationCenter.default.post( + name: Self.liveCaptureDidReleaseWiresharkNotification, + object: self + ) + } case .documentStateChanged(let phase, let message): snapshot.documentState.phase = mappedPhase(phase) snapshot.documentState.statusMessage = message @@ -4050,6 +4093,54 @@ final class TCPViewerWorkspaceController { } } + private func loadDecryptedStream( + _ packet: PacketSummary, + identifier: PacketSummary.ID, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + completion: @escaping TCPViewerCompletion + ) { + switch packet.source { + case .live: + guard let liveSession else { + completion(.failure(TCPViewerCoreError(code: .offlineFileOpenFailed, message: "Live packet \(identifier) is no longer available."))) + return + } + liveSession.loadDecryptedStream( + containing: identifier, + limits: .default, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + case .offline: + if let reference = snapshot.packetIngestState.importedPacketReference(for: identifier), + let importedDocument = importedDocumentsByFileID[reference.fileID] { + importedDocument.loadDecryptedStream( + containing: reference.originalPacketID, + limits: .default, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + } else { + guard let document else { + completion(.failure(TCPViewerCoreError(code: .offlineFileOpenFailed, message: "Packet \(identifier) is no longer available."))) + return + } + document.loadDecryptedStream( + containing: identifier, + limits: .default, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + } + @unknown default: + completion(.failure(TCPViewerCoreError(code: .unavailableFeature, message: "Packet \(identifier) cannot be decrypted."))) + } + } + private func detailNode(with identifier: String?) -> PacketDetailNode? { guard let identifier, let inspection = snapshot.inspectionState.inspection else { diff --git a/TCPViewer/Features/NetworkInspector/Models/DecryptedStreamTextFormatter.swift b/TCPViewer/Features/NetworkInspector/Models/DecryptedStreamTextFormatter.swift new file mode 100644 index 0000000..c7e2015 --- /dev/null +++ b/TCPViewer/Features/NetworkInspector/Models/DecryptedStreamTextFormatter.swift @@ -0,0 +1,42 @@ +// +// DecryptedStreamTextFormatter.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import Foundation + +enum DecryptedStreamTextFormatter { + // Text mode is intentionally strict so binary HTTP/2 and QUIC payloads remain inspectable. + static func string(for data: Data) -> String { + if let text = String(data: data, encoding: .utf8), text.unicodeScalars.allSatisfy(isReadable) { + return text + } + return hexDump(data) + } + + private static func isReadable(_ scalar: UnicodeScalar) -> Bool { + scalar.value == 0x09 || scalar.value == 0x0A || scalar.value == 0x0D || + (scalar.value >= 0x20 && scalar.value != 0x7F && !(0x80...0x9F).contains(scalar.value)) + } + + private static func hexDump(_ data: Data) -> String { + guard !data.isEmpty else { + return "" + } + let bytes = [UInt8](data) + var lines: [String] = [] + lines.reserveCapacity((bytes.count + 15) / 16) + for offset in stride(from: 0, to: bytes.count, by: 16) { + let line = Array(bytes[offset..= 0x20 && byte <= 0x7E ? String(UnicodeScalar(byte)) : "." + }.joined() + lines.append(String(format: "%08x %@%@ |%@|", offset, hex, padding, ascii)) + } + return lines.joined(separator: "\n") + } +} diff --git a/TCPViewer/Features/NetworkInspector/ViewModels/NetworkInspectorViewModel.swift b/TCPViewer/Features/NetworkInspector/ViewModels/NetworkInspectorViewModel.swift index cd160f4..5b1196a 100644 --- a/TCPViewer/Features/NetworkInspector/ViewModels/NetworkInspectorViewModel.swift +++ b/TCPViewer/Features/NetworkInspector/ViewModels/NetworkInspectorViewModel.swift @@ -1835,6 +1835,23 @@ final class NetworkInspectorViewModel { } } + func invalidateInspectionAfterTLSKeyLogChange() { + controller.selectPacket(nil) + rebuildSnapshot() + } + + // Reopen all active offline files so Wireshark rebuilds summaries with the current TLS keys. + func reloadAfterTLSKeyLogChange(completion: (() -> Void)? = nil) { + guard snapshot.base.packetIngestState.source == .offline else { + completion?() + return + } + controller.reloadOfflineCapturesAfterTLSKeyLogChange { [weak self] in + self?.rebuildSnapshot() + completion?() + } + } + func importDocuments(at fileURLs: [URL], completion: (() -> Void)? = nil) { let hasSessionFile = fileURLs .map(TCPViewerCaptureFileImportPolicy.standardizedFileURL) @@ -2178,6 +2195,20 @@ final class NetworkInspectorViewModel { ) } + func loadDecryptedStream( + containing identifier: PacketSummary.ID, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + completion: @escaping TCPViewerCompletion + ) { + controller.loadDecryptedStream( + containing: identifier, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + } + func selectInspectorTab(_ tab: PacketInspectorTab) { inspectorTab = tab rebuildSnapshot() diff --git a/TCPViewer/Features/NetworkInspector/Views/PacketInspectorViewController.swift b/TCPViewer/Features/NetworkInspector/Views/PacketInspectorViewController.swift index e082f07..57d7040 100644 --- a/TCPViewer/Features/NetworkInspector/Views/PacketInspectorViewController.swift +++ b/TCPViewer/Features/NetworkInspector/Views/PacketInspectorViewController.swift @@ -11,6 +11,25 @@ import PcapPlusPlusCore protocol PacketInspectorViewControllerDelegate: AnyObject { func packetInspectorViewController(_ controller: PacketInspectorViewController, didSelectDetailNode identifier: String?) func packetInspectorViewController(_ controller: PacketInspectorViewController, didRequestCreateCustomColumn request: PacketCustomColumnRequest) + func packetInspectorViewController( + _ controller: PacketInspectorViewController, + loadDecryptedStreamFor packetID: PacketSummary.ID, + progress: @escaping TCPFollowProgressHandler, + shouldCancel: @escaping TCPFollowCancellationCheck, + completion: @escaping TCPViewerCompletion + ) +} + +extension PacketInspectorViewControllerDelegate { + func packetInspectorViewController( + _ controller: PacketInspectorViewController, + loadDecryptedStreamFor packetID: PacketSummary.ID, + progress: @escaping TCPFollowProgressHandler, + shouldCancel: @escaping TCPFollowCancellationCheck, + completion: @escaping TCPViewerCompletion + ) { + completion(.failure(TCPViewerCoreError(code: .unavailableFeature, message: "TLS stream decryption is unavailable."))) + } } enum PacketInspectorTreeItemKind: Equatable { @@ -667,11 +686,18 @@ private final class PacketInspectorSectionRowView: NSTableRowView { } final class PacketInspectorViewController: NSViewController { + private enum InspectorPage: Int { + case packet + case request + case response + } + private enum Metrics { static let rowHeight: CGFloat = 20 static let cellIdentifier = NSUserInterfaceItemIdentifier("PacketInspectorCell") static let minimumHexPanelHeight: CGFloat = 120 static let filterBarHeight: CGFloat = 34 + static let tabBarHeight: CGFloat = 34 static let summaryPaneFraction: CGFloat = 0.70 static let hexPaneFraction: CGFloat = 0.30 } @@ -686,12 +712,19 @@ final class PacketInspectorViewController: NSViewController { private let detailSplitViewController = NSSplitViewController() private let outlineViewController = NSViewController() private let stackView = NSStackView() + private let pageContainerView = NSView() private let detailContainerView = NSView() + private let tabBarView = TCPViewerDynamicBackgroundView(backgroundColor: .controlBackgroundColor) + private let tabControl = NSSegmentedControl(labels: ["Packet", "Request", "Response"], trackingMode: .selectOne, target: nil, action: nil) private let filterBarView = TCPViewerDynamicBackgroundView(backgroundColor: .controlBackgroundColor) private let filterSearchField = NSSearchField() private let scrollView = NSScrollView() private let outlineView = PacketInspectorOutlineView() private let detailColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("detail")) + private let decryptedContainerView = NSView() + private let decryptedStatusLabel = NSTextField(wrappingLabelWithString: "") + private let decryptedScrollView = NSScrollView() + private let decryptedTextView = NSTextView() private var outlineItem: NSSplitViewItem? private var hexItem: NSSplitViewItem? private var emptyStateView: NSView? @@ -702,6 +735,12 @@ final class PacketInspectorViewController: NSViewController { private var isShowingPacketDetail = false private var isApplyingSelection = false private var isApplyingExpansionState = false + private var selectedPage: InspectorPage = .packet + private var decryptedStream: DecryptedStreamResult? + private var decryptedPacketID: PacketSummary.ID? + private var decryptedLoadGeneration = 0 + private var isLoadingDecryptedStream = false + private var decryptedCancellationFlag: TCPFollowCancellationFlag? init(configuration: AppConfiguration) { self.configuration = configuration @@ -714,10 +753,15 @@ final class PacketInspectorViewController: NSViewController { fatalError("init(coder:) has not been implemented") } + deinit { + decryptedCancellationFlag?.cancel() + } + override func loadView() { view = TCPViewerDynamicBackgroundView(backgroundColor: .controlBackgroundColor) setupFilterBar() setupOutlineView() + setupDecryptedView() setupLayout() } @@ -729,6 +773,7 @@ final class PacketInspectorViewController: NSViewController { // Render the current packet inspection tree as a single Wireshark-style outline. func render(snapshot: NetworkInspectorSnapshot) { let inspectionState = snapshot.base.inspectionState + updateDecryptedSelection(for: inspectionState) latestInspectionState = inspectionState let didRevealPacketDetail = updateContentVisibility(for: inspectionState) applyPlacement( @@ -740,6 +785,7 @@ final class PacketInspectorViewController: NSViewController { hexViewController.render(inspectionState: inspectionState) applyTreeRenderChange(renderChange, inspectionState: inspectionState) + renderSelectedPage() } // Forward a Follow TCP record to the Hex pane after its packet inspection finishes loading. @@ -838,7 +884,7 @@ final class PacketInspectorViewController: NSViewController { private func hasSettledDetailSplitLayout() -> Bool { let detailFrame = detailSplitViewController.view.convert(detailSplitViewController.view.bounds, to: stackView) - let expectedDetailHeight = stackView.bounds.height - filterBarView.bounds.height + let expectedDetailHeight = stackView.bounds.height - tabBarView.bounds.height - filterBarView.bounds.height guard stackView.bounds.width > 0, expectedDetailHeight > 0 else { return false @@ -892,6 +938,50 @@ final class PacketInspectorViewController: NSViewController { ]) } + private func setupDecryptedView() { + tabControl.selectedSegment = InspectorPage.packet.rawValue + tabControl.target = self + tabControl.action = #selector(selectInspectorPage(_:)) + tabControl.translatesAutoresizingMaskIntoConstraints = false + tabBarView.translatesAutoresizingMaskIntoConstraints = false + tabBarView.addSubview(tabControl) + + decryptedStatusLabel.textColor = .secondaryLabelColor + decryptedStatusLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + decryptedStatusLabel.translatesAutoresizingMaskIntoConstraints = false + + decryptedTextView.isEditable = false + decryptedTextView.isSelectable = true + decryptedTextView.isRichText = false + decryptedTextView.font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + decryptedTextView.textContainerInset = NSSize(width: 8, height: 8) + decryptedTextView.usesFindBar = true + decryptedTextView.autoresizingMask = [.width] + decryptedScrollView.borderType = .noBorder + decryptedScrollView.hasVerticalScroller = true + decryptedScrollView.hasHorizontalScroller = true + decryptedScrollView.autohidesScrollers = true + decryptedScrollView.documentView = decryptedTextView + decryptedScrollView.translatesAutoresizingMaskIntoConstraints = false + + decryptedContainerView.translatesAutoresizingMaskIntoConstraints = false + decryptedContainerView.addSubview(decryptedStatusLabel) + decryptedContainerView.addSubview(decryptedScrollView) + NSLayoutConstraint.activate([ + tabBarView.heightAnchor.constraint(equalToConstant: Metrics.tabBarHeight), + tabControl.leadingAnchor.constraint(equalTo: tabBarView.leadingAnchor, constant: 8), + tabControl.trailingAnchor.constraint(lessThanOrEqualTo: tabBarView.trailingAnchor, constant: -8), + tabControl.centerYAnchor.constraint(equalTo: tabBarView.centerYAnchor), + decryptedStatusLabel.leadingAnchor.constraint(equalTo: decryptedContainerView.leadingAnchor, constant: 10), + decryptedStatusLabel.trailingAnchor.constraint(equalTo: decryptedContainerView.trailingAnchor, constant: -10), + decryptedStatusLabel.topAnchor.constraint(equalTo: decryptedContainerView.topAnchor, constant: 8), + decryptedScrollView.leadingAnchor.constraint(equalTo: decryptedContainerView.leadingAnchor), + decryptedScrollView.trailingAnchor.constraint(equalTo: decryptedContainerView.trailingAnchor), + decryptedScrollView.topAnchor.constraint(equalTo: decryptedStatusLabel.bottomAnchor, constant: 6), + decryptedScrollView.bottomAnchor.constraint(equalTo: decryptedContainerView.bottomAnchor), + ]) + } + private func setupOutlineView() { detailColumn.minWidth = 160 detailColumn.width = 320 @@ -939,22 +1029,39 @@ final class PacketInspectorViewController: NSViewController { stackView.orientation = .vertical stackView.alignment = .width + stackView.distribution = .fill stackView.spacing = 0 stackView.edgeInsets = NSEdgeInsetsZero stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(tabBarView) stackView.addArrangedSubview(filterBarView) - stackView.addArrangedSubview(detailContainerView) + stackView.addArrangedSubview(pageContainerView) + stackView.setVisibilityPriority(.mustHold, for: tabBarView) + stackView.setVisibilityPriority(.mustHold, for: filterBarView) + stackView.setVisibilityPriority(.mustHold, for: pageContainerView) + tabBarView.setContentHuggingPriority(.required, for: .vertical) + tabBarView.setContentCompressionResistancePriority(.required, for: .vertical) filterBarView.setContentHuggingPriority(.required, for: .vertical) filterBarView.setContentCompressionResistancePriority(.required, for: .vertical) - detailContainerView.setContentHuggingPriority(.defaultLow, for: .vertical) - detailContainerView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + pageContainerView.setContentHuggingPriority(.defaultLow, for: .vertical) + pageContainerView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) applyPlacement(.trailing, resetsDefaultDivider: false, forcesDefaultDivider: false) + pageContainerView.translatesAutoresizingMaskIntoConstraints = false + pageContainerView.addSubview(detailContainerView) + pageContainerView.addSubview(decryptedContainerView) view.addSubview(stackView) NSLayoutConstraint.activate([ filterBarView.widthAnchor.constraint(equalTo: stackView.widthAnchor), - detailContainerView.widthAnchor.constraint(equalTo: stackView.widthAnchor), - detailContainerView.heightAnchor.constraint(equalTo: stackView.heightAnchor, constant: -Metrics.filterBarHeight), + pageContainerView.widthAnchor.constraint(equalTo: stackView.widthAnchor), + detailContainerView.leadingAnchor.constraint(equalTo: pageContainerView.leadingAnchor), + detailContainerView.trailingAnchor.constraint(equalTo: pageContainerView.trailingAnchor), + detailContainerView.topAnchor.constraint(equalTo: pageContainerView.topAnchor), + detailContainerView.bottomAnchor.constraint(equalTo: pageContainerView.bottomAnchor), + decryptedContainerView.leadingAnchor.constraint(equalTo: pageContainerView.leadingAnchor), + decryptedContainerView.trailingAnchor.constraint(equalTo: pageContainerView.trailingAnchor), + decryptedContainerView.topAnchor.constraint(equalTo: pageContainerView.topAnchor), + decryptedContainerView.bottomAnchor.constraint(equalTo: pageContainerView.bottomAnchor), detailSplitViewController.view.leadingAnchor.constraint(equalTo: detailContainerView.leadingAnchor), detailSplitViewController.view.trailingAnchor.constraint(equalTo: detailContainerView.trailingAnchor), detailSplitViewController.view.topAnchor.constraint(equalTo: detailContainerView.topAnchor), @@ -964,6 +1071,132 @@ final class PacketInspectorViewController: NSViewController { stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + decryptedContainerView.isHidden = true + } + + private func updateDecryptedSelection(for inspectionState: PacketInspectionState) { + let packetID = inspectionState.selectedPacketID + guard packetID != decryptedPacketID else { + return + } + decryptedCancellationFlag?.cancel() + decryptedCancellationFlag = nil + decryptedLoadGeneration += 1 + decryptedPacketID = packetID + decryptedStream = nil + isLoadingDecryptedStream = false + decryptedTextView.string = "" + } + + private func renderSelectedPage() { + let showsPacket = selectedPage == .packet + stackView.setVisibilityPriority(showsPacket ? .mustHold : .notVisible, for: filterBarView) + filterBarView.isHidden = !showsPacket + detailContainerView.isHidden = !showsPacket + decryptedContainerView.isHidden = showsPacket + guard !showsPacket else { + return + } + guard let packetID = decryptedPacketID else { + showDecryptedMessage("Select a TLS, DTLS, or QUIC packet to inspect its decrypted stream.") + return + } + if let stream = decryptedStream { + render(stream: stream, page: selectedPage) + } else if !isLoadingDecryptedStream { + loadDecryptedStream(packetID: packetID) + } + } + + private func loadDecryptedStream(packetID: PacketSummary.ID) { + decryptedCancellationFlag?.cancel() + let cancellationFlag = TCPFollowCancellationFlag() + decryptedCancellationFlag = cancellationFlag + isLoadingDecryptedStream = true + decryptedLoadGeneration += 1 + let generation = decryptedLoadGeneration + showDecryptedMessage("Loading the complete decrypted stream…") + delegate?.packetInspectorViewController( + self, + loadDecryptedStreamFor: packetID, + progress: { [weak self] progress in + DispatchQueue.main.async { + guard let self, + self.decryptedLoadGeneration == generation, + self.decryptedPacketID == packetID else { + return + } + let percent = Int((progress.fractionCompleted * 100).rounded()) + self.showDecryptedMessage("Loading the complete decrypted stream… \(percent)% (\(progress.processedPacketCount)/\(progress.totalPacketCount) packets)") + } + }, + shouldCancel: { + cancellationFlag.isCancelled + } + ) { [weak self] result in + DispatchQueue.main.async { + guard let self else { + return + } + if self.decryptedCancellationFlag === cancellationFlag { + self.decryptedCancellationFlag = nil + } + guard self.decryptedLoadGeneration == generation, self.decryptedPacketID == packetID else { + return + } + self.isLoadingDecryptedStream = false + switch result { + case .success(let stream): + self.decryptedStream = stream + self.renderSelectedPage() + case .failure(let error): + self.showDecryptedMessage(self.decryptedErrorMessage(error)) + } + } + } + } + + private func render(stream: DecryptedStreamResult, page: InspectorPage) { + let payload = page == .request ? stream.request : stream.response + let source = page == .request ? stream.client : stream.server + let destination = page == .request ? stream.server : stream.client + var status = "\(stream.protocolName.rawValue) \(endpointText(source)) → \(endpointText(destination)) • \(payload.data.count) bytes retained" + if payload.isTruncated { + status += " • Truncated after at least \(payload.observedByteCount) observed bytes" + } + if payload.data.isEmpty { + showDecryptedMessage("\(status)\nNo application data exists in this direction, or the key does not match the capture.") + return + } + decryptedStatusLabel.stringValue = status + decryptedTextView.string = DecryptedStreamTextFormatter.string(for: payload.data) + decryptedScrollView.isHidden = false + } + + private func showDecryptedMessage(_ message: String) { + decryptedStatusLabel.stringValue = message + decryptedTextView.string = "" + decryptedScrollView.isHidden = true + } + + private func endpointText(_ endpoint: PacketEndpoint) -> String { + let address = endpoint.address ?? "unknown" + guard let port = endpoint.port, port != 0 else { + return address + } + return "\(address):\(port)" + } + + private func decryptedErrorMessage(_ error: Error) -> String { + if let coreError = error as? TCPViewerCoreError { + return coreError.message + } + return error.localizedDescription + } + + @objc private func selectInspectorPage(_ sender: NSSegmentedControl) { + selectedPage = InspectorPage(rawValue: sender.selectedSegment) ?? .packet + renderSelectedPage() } // Swap between the launch empty state and packet-detail controls. diff --git a/TCPViewer/Features/NetworkInspector/Views/TCPViewerRootViewController.swift b/TCPViewer/Features/NetworkInspector/Views/TCPViewerRootViewController.swift index 505378d..a1f8b61 100644 --- a/TCPViewer/Features/NetworkInspector/Views/TCPViewerRootViewController.swift +++ b/TCPViewer/Features/NetworkInspector/Views/TCPViewerRootViewController.swift @@ -1538,6 +1538,21 @@ extension TCPViewerRootViewController: PacketInspectorViewControllerDelegate { ) { workspaceViewController.createCustomColumn(from: request) } + + func packetInspectorViewController( + _ controller: PacketInspectorViewController, + loadDecryptedStreamFor packetID: PacketSummary.ID, + progress: @escaping TCPFollowProgressHandler, + shouldCancel: @escaping TCPFollowCancellationCheck, + completion: @escaping TCPViewerCompletion + ) { + viewModel.loadDecryptedStream( + containing: packetID, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + } } extension TCPViewerRootViewController: StatusStripViewControllerDelegate { diff --git a/TCPViewerTests/App/TLSKeyLogMenuTests.swift b/TCPViewerTests/App/TLSKeyLogMenuTests.swift new file mode 100644 index 0000000..27ef692 --- /dev/null +++ b/TCPViewerTests/App/TLSKeyLogMenuTests.swift @@ -0,0 +1,37 @@ +// +// TLSKeyLogMenuTests.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import AppKit +import Testing +@testable import TCPViewer + +@MainActor +struct TLSKeyLogMenuTests { + @Test func toolsMenuIsInsertedBeforeWindowAndWiredOnlyOnce() throws { + let previousMenu = NSApp.mainMenu + let menu = NSMenu() + for title in ["TCP Viewer", "File", "Edit", "Window", "Help"] { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.submenu = NSMenu(title: title) + menu.addItem(item) + } + NSApp.mainMenu = menu + defer { NSApp.mainMenu = previousMenu } + let delegate = AppDelegate() + + delegate.wireToolsMenu() + delegate.wireToolsMenu() + + let toolsItems = menu.items.filter { $0.title == "Tools" } + let toolsItem = try #require(toolsItems.first) + #expect(toolsItems.count == 1) + #expect(menu.index(of: toolsItem) < menu.items.firstIndex(where: { $0.title == "Window" })!) + #expect(toolsItem.submenu?.items.count == 1) + #expect(toolsItem.submenu?.items.first?.title == "TLS Key Log…") + #expect(toolsItem.submenu?.items.first?.target === delegate) + } +} diff --git a/TCPViewerTests/App/WorkspaceControllerTests.swift b/TCPViewerTests/App/WorkspaceControllerTests.swift index d743d65..5cc2ba1 100644 --- a/TCPViewerTests/App/WorkspaceControllerTests.swift +++ b/TCPViewerTests/App/WorkspaceControllerTests.swift @@ -311,6 +311,15 @@ struct WindowControllerTests { let controller = TCPViewerWorkspaceController( services: TCPViewerServiceRegistry(core: fakeCore) ) + var releaseNotificationCount = 0 + let releaseObserver = NotificationCenter.default.addObserver( + forName: TCPViewerWorkspaceController.liveCaptureDidReleaseWiresharkNotification, + object: controller, + queue: .main + ) { _ in + releaseNotificationCount += 1 + } + defer { NotificationCenter.default.removeObserver(releaseObserver) } await controller.refreshInterfaces() await controller.startLiveCapture() @@ -362,6 +371,7 @@ struct WindowControllerTests { controller.snapshot.sessionState.phase == .stopped } #expect(liveSession.stopCount == 1) + #expect(releaseNotificationCount == 1) await tearDown(controller) } diff --git a/TCPViewerTests/Features/NetworkInspector/DecryptedStreamTextFormatterTests.swift b/TCPViewerTests/Features/NetworkInspector/DecryptedStreamTextFormatterTests.swift new file mode 100644 index 0000000..aa8c867 --- /dev/null +++ b/TCPViewerTests/Features/NetworkInspector/DecryptedStreamTextFormatterTests.swift @@ -0,0 +1,28 @@ +// +// DecryptedStreamTextFormatterTests.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import Foundation +import Testing +@testable import TCPViewer + +struct DecryptedStreamTextFormatterTests { + @Test func rendersReadableUTF8AsText() { + let text = "GET / HTTP/1.1\r\nHost: example.test\r\n\r\n" + + #expect(DecryptedStreamTextFormatter.string(for: Data(text.utf8)) == text) + } + + @Test func rendersControlsAndInvalidUTF8AsHexAndASCII() { + let output = DecryptedStreamTextFormatter.string(for: Data([0x48, 0x00, 0xFF])) + let deleteOutput = DecryptedStreamTextFormatter.string(for: Data([0x48, 0x7F])) + + #expect(output.contains("00000000")) + #expect(output.contains("48 00 ff")) + #expect(output.contains("|H..|")) + #expect(deleteOutput.contains("48 7f")) + } +} diff --git a/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift b/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift index 70e4f89..e68b018 100644 --- a/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift +++ b/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift @@ -71,6 +71,77 @@ struct PacketInspectorTreeViewModelTests { #expect(!textFieldValues(in: controller.view).contains("No Packet Selected")) } + @MainActor + @Test func requestAndResponseTabsShareOneDecryptedStreamLoad() async throws { + let packet = makePacket() + let controller = PacketInspectorViewController(configuration: AppConfiguration(defaults: isolatedDefaults())) + let delegate = PacketInspectorDelegateSpy() + delegate.decryptedStreamResult = .success(DecryptedStreamResult( + protocolName: .tls, + client: PacketEndpoint(address: "10.0.0.1", port: 1234), + server: PacketEndpoint(address: "10.0.0.2", port: 443), + request: DecryptedStreamPayload(data: Data("GET / HTTP/1.1\r\n\r\n".utf8), observedByteCount: 18, isTruncated: false), + response: DecryptedStreamPayload(data: Data("HTTP/1.1 200 OK\r\n\r\n".utf8), observedByteCount: 19, isTruncated: false) + )) + controller.delegate = delegate + controller.loadViewIfNeeded() + controller.render(snapshot: makeSnapshot( + packet: packet, + inspectionState: loadedInspectionState(packet: packet, inspection: makeFrameInspection(for: packet)) + )) + let tabs = try #require(firstSubview(ofType: NSSegmentedControl.self, in: controller.view)) + let textView = try #require(allSubviews(ofType: NSTextView.self, in: controller.view).first { $0.usesFindBar }) + + tabs.selectedSegment = 1 + tabs.sendAction(tabs.action, to: tabs.target) + await drainMainQueue() + + #expect(delegate.decryptedStreamLoadCount == 1) + #expect(textView.string == "GET / HTTP/1.1\r\n\r\n") + + tabs.selectedSegment = 2 + tabs.sendAction(tabs.action, to: tabs.target) + await drainMainQueue() + + #expect(delegate.decryptedStreamLoadCount == 1) + #expect(textView.string == "HTTP/1.1 200 OK\r\n\r\n") + } + + @MainActor + @Test func packetChangeRejectsStaleDecryptedStreamCompletion() async throws { + let firstPacket = makePacket(packetNumber: 1) + let secondPacket = makePacket(packetNumber: 2) + let controller = PacketInspectorViewController(configuration: AppConfiguration(defaults: isolatedDefaults())) + let delegate = PacketInspectorDelegateSpy() + delegate.defersDecryptedStreamCompletions = true + controller.delegate = delegate + controller.loadViewIfNeeded() + controller.render(snapshot: makeSnapshot( + packet: firstPacket, + inspectionState: loadedInspectionState(packet: firstPacket, inspection: makeFrameInspection(for: firstPacket)) + )) + let tabs = try #require(firstSubview(ofType: NSSegmentedControl.self, in: controller.view)) + let textView = try #require(allSubviews(ofType: NSTextView.self, in: controller.view).first { $0.usesFindBar }) + tabs.selectedSegment = 1 + tabs.sendAction(tabs.action, to: tabs.target) + + controller.render(snapshot: makeSnapshot( + packet: secondPacket, + inspectionState: loadedInspectionState(packet: secondPacket, inspection: makeFrameInspection(for: secondPacket)) + )) + #expect(delegate.decryptedStreamLoadCount == 2) + #expect(delegate.decryptedStreamCancellationChecks[0]()) + #expect(!delegate.decryptedStreamCancellationChecks[1]()) + + delegate.completeDecryptedStream(at: 0, with: .success(makeDecryptedResult(packet: firstPacket, request: "STALE"))) + await drainMainQueue() + #expect(textView.string != "STALE") + + delegate.completeDecryptedStream(at: 1, with: .success(makeDecryptedResult(packet: secondPacket, request: "CURRENT"))) + await drainMainQueue() + #expect(textView.string == "CURRENT") + } + @MainActor @Test func inspectorFilterIsAlwaysVisibleAndCommandShiftFIsReservedForSidebarMenu() throws { let packet = makePacket() @@ -132,7 +203,7 @@ struct PacketInspectorTreeViewModelTests { #expect(!splitView.isVertical) #expect(frame(outlineFrame, isVisuallyAbove: hexFrame, in: splitView)) #expect(abs(splitFrame.width - controller.view.bounds.width) <= 1) - #expect(abs(splitFrame.height - (controller.view.bounds.height - 34)) <= 1) + #expect(abs(splitFrame.height - (controller.view.bounds.height - 68)) <= 1) #expect(abs(outlineFrame.height - availableHeight * 0.70) <= 2) #expect(abs(hexFrame.height - availableHeight * 0.30) <= 2) } @@ -921,6 +992,14 @@ struct PacketInspectorTreeViewModelTests { return defaults } + private func drainMainQueue() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + continuation.resume() + } + } + } + private func firstSubview(ofType type: T.Type, in view: NSView) -> T? { if let view = view as? T { return view @@ -1139,11 +1218,26 @@ struct PacketInspectorTreeViewModelTests { captureMetadata: PacketCaptureMetadata(linkType: .ethernet, isTruncated: false) ) } + + private func makeDecryptedResult(packet: PacketSummary, request: String) -> DecryptedStreamResult { + DecryptedStreamResult( + protocolName: .tls, + client: PacketEndpoint(address: "10.0.0.1", port: 1234), + server: PacketEndpoint(address: "10.0.0.2", port: 443), + request: DecryptedStreamPayload(data: Data(request.utf8), observedByteCount: request.utf8.count, isTruncated: false), + response: DecryptedStreamPayload(data: Data(), observedByteCount: 0, isTruncated: false) + ) + } } private final class PacketInspectorDelegateSpy: PacketInspectorViewControllerDelegate { var selectedDetailNodeID: String? var customColumnRequest: PacketCustomColumnRequest? + var decryptedStreamResult: Result? + var decryptedStreamLoadCount = 0 + var decryptedStreamCancellationChecks: [TCPFollowCancellationCheck] = [] + var defersDecryptedStreamCompletions = false + private var decryptedStreamCompletions: [TCPViewerCompletion] = [] func packetInspectorViewController(_ controller: PacketInspectorViewController, didSelectDetailNode identifier: String?) { selectedDetailNodeID = identifier @@ -1155,4 +1249,27 @@ private final class PacketInspectorDelegateSpy: PacketInspectorViewControllerDel ) { customColumnRequest = request } + + func packetInspectorViewController( + _ controller: PacketInspectorViewController, + loadDecryptedStreamFor packetID: PacketSummary.ID, + progress: @escaping TCPFollowProgressHandler, + shouldCancel: @escaping TCPFollowCancellationCheck, + completion: @escaping TCPViewerCompletion + ) { + decryptedStreamLoadCount += 1 + decryptedStreamCancellationChecks.append(shouldCancel) + if defersDecryptedStreamCompletions { + decryptedStreamCompletions.append(completion) + return + } + completion(decryptedStreamResult ?? .failure(TCPViewerCoreError( + code: .unavailableFeature, + message: "No decrypted stream result was configured." + ))) + } + + func completeDecryptedStream(at index: Int, with result: Result) { + decryptedStreamCompletions[index](result) + } }