From a14ef37ab34b211373698c7dc5fb6e47d8721d43 Mon Sep 17 00:00:00 2001 From: Noah Tran Date: Wed, 19 Aug 2026 17:01:30 +0200 Subject: [PATCH 1/5] Add TLS key-log decryption --- .../Dissection/SwiftPacketDissector.swift | 8 + .../Dissection/WiresharkEpanSession.swift | 150 ++++++++++- .../Dissection/WiresharkEpanShim.cpp | 192 ++++++++++++-- .../Dissection/WiresharkEpanShim.h | 14 + PcapPlusPlusCore/Models/CoreProtocols.swift | 4 +- PcapPlusPlusCore/Models/DecryptedStream.swift | 108 ++++++++ PcapPlusPlusCore/Models/PacketModels.swift | 9 +- PcapPlusPlusCore/Models/TLSKeyLog.swift | 42 +++ .../NativeBridge/NativeBridgeSupport.swift | 39 ++- .../NativeBridge/NativeBridgeTypes.swift | 4 + .../Core/NativeTLSKeyLogManager.swift | 248 ++++++++++++++++++ .../Services/Core/SwiftNativeCore.swift | 99 +++++++ .../NativeLiveCaptureSession.swift | 47 ++++ .../NativeLivePacketDiskStore.swift | 26 ++ .../NativeOfflineCaptureDocument.swift | 43 +++ .../WiresharkTLSDecryptionTests.swift | 184 +++++++++++++ .../Core/NativeTLSKeyLogManagerTests.swift | 156 +++++++++++ TCPViewer/App/AppDelegate.swift | 121 +++++++++ TCPViewer/App/TLSKeyLogWindowController.swift | 177 +++++++++++++ TCPViewer/Core/WorkspaceFoundation.swift | 95 ++++++- .../Models/DecryptedStreamTextFormatter.swift | 42 +++ .../NetworkInspectorViewModel.swift | 31 +++ .../Views/PacketInspectorViewController.swift | 229 +++++++++++++++- .../Views/TCPViewerRootViewController.swift | 15 ++ TCPViewerTests/App/TLSKeyLogMenuTests.swift | 37 +++ .../DecryptedStreamTextFormatterTests.swift | 28 ++ .../PacketInspectorTreeViewModelTests.swift | 117 ++++++++- 27 files changed, 2220 insertions(+), 45 deletions(-) create mode 100644 PcapPlusPlusCore/Models/DecryptedStream.swift create mode 100644 PcapPlusPlusCore/Models/TLSKeyLog.swift create mode 100644 PcapPlusPlusCore/Services/Core/NativeTLSKeyLogManager.swift create mode 100644 PcapPlusPlusCoreTests/Dissection/WiresharkTLSDecryptionTests.swift create mode 100644 PcapPlusPlusCoreTests/Services/Core/NativeTLSKeyLogManagerTests.swift create mode 100644 TCPViewer/App/TLSKeyLogWindowController.swift create mode 100644 TCPViewer/Features/NetworkInspector/Models/DecryptedStreamTextFormatter.swift create mode 100644 TCPViewerTests/App/TLSKeyLogMenuTests.swift create mode 100644 TCPViewerTests/Features/NetworkInspector/DecryptedStreamTextFormatterTests.swift 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..7a1c528 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,10 +301,11 @@ final class WiresharkEpanSession { ) } - private func followObservedTCPStream( + private func followObservedStream( containing selectedRecord: NativePacketRecord, records: [NativePacketRecord], limits: TCPFollowLimits, + protocolName: String, progressOffset: Int, progressTotal: Int, progress: TCPFollowProgressHandler?, @@ -309,7 +320,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 } @@ -378,6 +392,134 @@ 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 TCPViewerWiresharkHasTLSKeyLog() else { + throw NativeNSError(.unavailableFeature, "No TLS key-log file is selected. Open Tools → TLS Key Log… first.") + } + let tcpLimits = TCPFollowLimits( + maximumCandidatePacketCount: limits.maximumCandidatePacketCount, + maximumPayloadBytes: limits.maximumBytesPerDirection, + maximumRecordCount: limits.maximumRecordCount + ) + try validateFollowRequest(selectedRecord: selectedRecord, records: records, limits: tcpLimits) + let session = try WiresharkEpanSession(purpose: .follow) + let totalWorkCount = records.count * 2 + for (index, record) in records.enumerated() { + if shouldCancel?() == true { + throw NativeNSError(.operationCancelled, "TLS stream decryption was cancelled.") + } + try session.observe(record) + reportFollowProgress(processedPacketCount: index + 1, totalPacketCount: totalWorkCount, handler: progress) + } + try session.finishFirstPass() + return try session.followObservedDecryptedStream( + containing: selectedRecord, + records: records, + limits: limits, + progressOffset: records.count, + 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.") + } + return try followObservedDecryptedStream( + containing: selectedRecord, + records: records, + limits: limits, + progressOffset: 0, + progressTotal: records.count, + progress: progress, + shouldCancel: shouldCancel + ) + } + + private func followObservedDecryptedStream( + containing selectedRecord: NativePacketRecord, + records: [NativePacketRecord], + limits: DecryptedStreamLimits, + progressOffset: Int, + progressTotal: Int, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck? + ) throws -> WiresharkDecryptedFollowFields { + let followLimits = TCPFollowLimits( + maximumCandidatePacketCount: limits.maximumCandidatePacketCount, + maximumPayloadBytes: limits.maximumBytesPerDirection, + maximumRecordCount: limits.maximumRecordCount + ) + var lastError: Error? + for protocolName in [DecryptedStreamProtocol.tls, .dtls, .quic] { + do { + let fields = try followObservedStream( + containing: selectedRecord, + records: records, + limits: followLimits, + protocolName: protocolName.rawValue, + progressOffset: progressOffset, + progressTotal: progressTotal, + progress: progress, + shouldCancel: shouldCancel + ) + return decryptedFields(protocolName: protocolName, fields: fields, limit: limits.maximumBytesPerDirection) + } catch { + 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..76c2744 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,18 @@ class WiresharkRuntime { WiresharkCriticalExceptionReports criticalExceptionReports_; }; +std::string &TLSKeyLogPath() +{ + static std::string path; + return path; +} + +uint64_t &TLSKeyLogConfigurationGeneration() +{ + static uint64_t generation = 0; + return generation; +} + } // namespace struct TCPViewerWiresharkSession { @@ -1195,8 +1208,12 @@ struct TCPViewerWiresharkSession { bool processingFollowPacket = false; bool tcpIndexTapRegistered = false; bool collectingTCPStreamIndex = false; + uint64_t tlsKeyLogConfigurationGeneration = 0; bool followTruncated = false; + bool followUsesPerDirectionLimit = false; uint64_t followPayloadByteCount = 0; + uint64_t followObservedByteCountByDirection[2] = {}; + uint64_t followRetainedByteCountByDirection[2] = {}; GList *followNewestPayloadItem = nullptr; std::string personalConfigurationDirectory; bool disabled = false; @@ -1505,6 +1522,7 @@ struct TCPViewerWiresharkSession { activeSession() = this; resetActiveFrameStateLocked(); firstPassFinished = false; + tlsKeyLogConfigurationGeneration = TLSKeyLogConfigurationGeneration(); return true; } @@ -1571,7 +1589,14 @@ struct TCPViewerWiresharkSession { return false; } if (hasSession() && activeSession() == this) { - return true; + if (tlsKeyLogConfigurationGeneration == TLSKeyLogConfigurationGeneration()) { + return true; + } + // A live session resumes from the next packet; offline documents are reopened by the app. + releaseWiresharkResourcesLocked("Wireshark TLS keys changed; reload this capture to refresh packet details.", false); + if (livePriority) { + firstPassFinished = false; + } } if (!initializeWiresharkResourcesLocked()) { return false; @@ -1668,11 +1693,16 @@ struct TCPViewerWiresharkSession { followReferenceFrame = frame_data{}; nstime_set_zero(&followElapsedTime); followTruncated = false; + followUsesPerDirectionLimit = false; followPayloadByteCount = 0; + followObservedByteCountByDirection[0] = 0; + followObservedByteCountByDirection[1] = 0; + followRetainedByteCountByDirection[0] = 0; + followRetainedByteCountByDirection[1] = 0; followNewestPayloadItem = nullptr; } - bool beginTCPFollowLocked(const PacketContextView &selectedContext) + bool beginFollowLocked(const PacketContextView &selectedContext, const char *protocolName) { cancelFollowLocked(); if (!hasSession() || activeSession() != this) { @@ -1680,19 +1710,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 +1739,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 +1780,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 +1812,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 +1828,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 +1846,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; + followRetainedByteCountByDirection[0] = 0; + followRetainedByteCountByDirection[1] = 0; followNewestPayloadItem = nullptr; return true; } @@ -1896,11 +1931,23 @@ struct TCPViewerWiresharkSession { for (GList *item = followInfo->payload; item != followNewestPayloadItem; item = g_list_next(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; + const size_t retained = static_cast(followRetainedByteCountByDirection[direction]); + const size_t remaining = retained >= maximumPayloadBytes ? 0 : maximumPayloadBytes - retained; + if (byteCount > remaining) { + g_byte_array_set_size(record->data, static_cast(remaining)); + followTruncated = true; + } + followRetainedByteCountByDirection[direction] += std::min(byteCount, remaining); + } } } followNewestPayloadItem = followInfo->payload; - if (followPayloadByteCount > maximumPayloadBytes) { + if (!followUsesPerDirectionLimit && followPayloadByteCount > maximumPayloadBytes) { followTruncated = true; return TCPViewerWiresharkFollowPacketLimitReached; } @@ -1963,12 +2010,16 @@ 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 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( @@ -1984,8 +2035,12 @@ struct TCPViewerWiresharkSession { size_t outputIndex = 0; size_t remainingPayloadBytes = maximumPayloadBytes; + size_t remainingPayloadBytesByDirection[2] = {maximumPayloadBytes, maximumPayloadBytes}; for (GList *item = g_list_last(followInfo->payload); - item != nullptr && outputIndex < allocatedRecordCount && remainingPayloadBytes > 0; + item != nullptr && outputIndex < allocatedRecordCount + && (followUsesPerDirectionLimit + ? remainingPayloadBytesByDirection[0] > 0 || remainingPayloadBytesByDirection[1] > 0 + : remainingPayloadBytes > 0); item = g_list_previous(item)) { auto *source = static_cast(item->data); if (source == nullptr || source->data == nullptr || source->data->len == 0) { @@ -1999,7 +2054,11 @@ struct TCPViewerWiresharkSession { 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); + const size_t direction = source->is_server ? 1 : 0; + size_t &remainingForRecord = followUsesPerDirectionLimit + ? remainingPayloadBytesByDirection[direction] + : remainingPayloadBytes; + destination.byteCount = std::min(static_cast(source->data->len), remainingForRecord); if (destination.byteCount > 0) { destination.bytes = static_cast(std::malloc(destination.byteCount)); if (destination.bytes == nullptr) { @@ -2012,7 +2071,7 @@ struct TCPViewerWiresharkSession { if (destination.byteCount < source->data->len) { result->isTruncated = true; } - remainingPayloadBytes -= destination.byteCount; + remainingForRecord -= destination.byteCount; outputIndex += 1; } result->recordCount = outputIndex; @@ -2026,7 +2085,12 @@ struct TCPViewerWiresharkSession { followReferenceFrame = frame_data{}; nstime_set_zero(&followElapsedTime); followTruncated = false; + followUsesPerDirectionLimit = false; followPayloadByteCount = 0; + followObservedByteCountByDirection[0] = 0; + followObservedByteCountByDirection[1] = 0; + followRetainedByteCountByDirection[0] = 0; + followRetainedByteCountByDirection[1] = 0; followNewestPayloadItem = nullptr; return result; } @@ -2171,6 +2235,72 @@ struct TCPViewerWiresharkSession { } }; +bool TCPViewerWiresharkConfigureTLSKeyLog( + const char *filePath, + const char *personalConfigurationDirectory, + uint64_t *configurationGeneration, + 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; + } + + if (TLSKeyLogPath() != nextPath) { + TLSKeyLogPath() = nextPath; + TLSKeyLogConfigurationGeneration() += 1; + } + if (configurationGeneration != nullptr) { + *configurationGeneration = TLSKeyLogConfigurationGeneration(); + } + 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 +2555,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..dbb8956 100644 --- a/PcapPlusPlusCore/Dissection/WiresharkEpanShim.h +++ b/PcapPlusPlusCore/Dissection/WiresharkEpanShim.h @@ -120,6 +120,15 @@ typedef enum TCPViewerWiresharkFollowPacketStatus { TCPViewerWiresharkFollowPacketLimitReached = 1, } TCPViewerWiresharkFollowPacketStatus; +bool TCPViewerWiresharkConfigureTLSKeyLog( + const char *filePath, + const char *personalConfigurationDirectory, + uint64_t *configurationGeneration, + char **errorMessage +); +bool TCPViewerWiresharkHasTLSKeyLog(void); +void TCPViewerWiresharkCStringDestroy(char *value); + typedef struct TCPViewerWiresharkExceptionReport { bool isCriticalException; unsigned long exceptionGroup; @@ -158,6 +167,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..492a1ec --- /dev/null +++ b/PcapPlusPlusCore/Models/DecryptedStream.swift @@ -0,0 +1,108 @@ +// +// 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 DecryptedStreamReference: Sendable, Codable, Hashable { + public let packetID: PacketSummary.ID + public let protocolName: DecryptedStreamProtocol? + + public init(packetID: PacketSummary.ID, protocolName: DecryptedStreamProtocol? = nil) { + self.packetID = packetID + self.protocolName = protocolName + } +} + +public enum DecryptedStreamSide: String, Sendable, Codable, Hashable { + case request + case response +} + +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 reference: DecryptedStreamReference + public let protocolName: DecryptedStreamProtocol + public let client: PacketEndpoint + public let server: PacketEndpoint + public let request: DecryptedStreamPayload + public let response: DecryptedStreamPayload + + public init( + reference: DecryptedStreamReference, + protocolName: DecryptedStreamProtocol, + client: PacketEndpoint, + server: PacketEndpoint, + request: DecryptedStreamPayload, + response: DecryptedStreamPayload + ) { + self.reference = reference + self.protocolName = protocolName + self.client = client + self.server = server + self.request = request + self.response = response + } +} + +public struct DecryptedStreamLimits: Sendable, Equatable, Hashable { + public let maximumCandidatePacketCount: Int + public let maximumBytesPerDirection: Int + public let maximumRecordCount: Int + + public init( + maximumCandidatePacketCount: Int = 250_000, + maximumBytesPerDirection: Int = 8 * 1_024 * 1_024, + maximumRecordCount: Int = 100_000 + ) { + self.maximumCandidatePacketCount = max(maximumCandidatePacketCount, 1) + 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..bce07d4 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 @@ -126,6 +130,7 @@ public struct PacketInspection: Sendable, Codable, Hashable { public let byteViews: [PacketByteView] public let detailNodes: [PacketDetailNode] public let decodeStatus: PacketDecodeStatus + public let decryptedStreamReference: DecryptedStreamReference? public init( packetID: UInt64, @@ -133,7 +138,8 @@ public struct PacketInspection: Sendable, Codable, Hashable { rawBytes: Data, byteViews: [PacketByteView]? = nil, detailNodes: [PacketDetailNode], - decodeStatus: PacketDecodeStatus + decodeStatus: PacketDecodeStatus, + decryptedStreamReference: DecryptedStreamReference? = nil ) { self.packetID = packetID self.packetNumber = packetNumber @@ -141,6 +147,7 @@ public struct PacketInspection: Sendable, Codable, Hashable { self.byteViews = byteViews ?? [PacketByteView(id: "frame", label: "Frame", bytes: rawBytes)] self.detailNodes = detailNodes self.decodeStatus = decodeStatus + self.decryptedStreamReference = decryptedStreamReference } } diff --git a/PcapPlusPlusCore/Models/TLSKeyLog.swift b/PcapPlusPlusCore/Models/TLSKeyLog.swift new file mode 100644 index 0000000..00a2d4b --- /dev/null +++ b/PcapPlusPlusCore/Models/TLSKeyLog.swift @@ -0,0 +1,42 @@ +// +// 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 let configurationGeneration: UInt64 + + public init(fileURL: URL?, validation: TLSKeyLogValidation?, configurationGeneration: UInt64) { + self.fileURL = fileURL + self.validation = validation + self.configurationGeneration = configurationGeneration + } + + public static let empty = TLSKeyLogState(fileURL: nil, validation: nil, configurationGeneration: 0) +} + +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..1cd9e2d 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 } @@ -358,16 +366,41 @@ enum NativeBridgeMapper { } static func packetInspection(_ descriptor: PCPPNativePacketInspectionDescriptor) -> PacketInspection { - PacketInspection( + let detailNodes = descriptor.detailNodes.map(packetDetailNode) + return PacketInspection( packetID: descriptor.packetIdentifier, packetNumber: descriptor.packetNumber, rawBytes: descriptor.rawBytes, byteViews: descriptor.byteViews.map(packetByteView), - detailNodes: descriptor.detailNodes.map(packetDetailNode), - decodeStatus: decodeStatus(descriptor.decodeStatus) + detailNodes: detailNodes, + decodeStatus: decodeStatus(descriptor.decodeStatus), + decryptedStreamReference: decryptedStreamReference(packetID: descriptor.packetIdentifier, nodes: detailNodes) ) } + private static func decryptedStreamReference( + packetID: PacketSummary.ID, + nodes: [PacketDetailNode] + ) -> DecryptedStreamReference? { + let fieldNames = recursiveFieldNames(nodes) + if fieldNames.contains(where: { $0.hasPrefix("tls.") }) { + return DecryptedStreamReference(packetID: packetID, protocolName: .tls) + } + if fieldNames.contains(where: { $0.hasPrefix("dtls.") }) { + return DecryptedStreamReference(packetID: packetID, protocolName: .dtls) + } + if fieldNames.contains(where: { $0.hasPrefix("quic.") }) { + return DecryptedStreamReference(packetID: packetID, protocolName: .quic) + } + return nil + } + + private static func recursiveFieldNames(_ nodes: [PacketDetailNode]) -> [String] { + nodes.flatMap { node in + [node.fieldName].compactMap(\.self) + recursiveFieldNames(node.children) + } + } + static func packetSummary( _ descriptor: PCPPNativePacketSummaryDescriptor, source: CaptureSource 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..3e57628 --- /dev/null +++ b/PcapPlusPlusCore/Services/Core/NativeTLSKeyLogManager.swift @@ -0,0 +1,248 @@ +// +// 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) + let generation = try self.configureWireshark(filePath: fileURL.path) + let nextState = TLSKeyLogState( + fileURL: fileURL, + validation: validation, + configurationGeneration: generation + ) + self.state = nextState + return nextState + }) + } + } + + public func remove(completion: @escaping TCPViewerCompletion) { + queue.async { + completion(Result { + let generation = try self.configureWireshark(filePath: nil) + let nextState = TLSKeyLogState( + fileURL: nil, + validation: nil, + configurationGeneration: generation + ) + 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 -> UInt64 { + let directory: URL + do { + directory = try runtimeConfiguration.createPersonalConfigurationDirectoryIfNeeded() + } catch { + throw Self.invalidFile("TCP Viewer could not prepare its Wireshark runtime.") + } + + var generation: UInt64 = 0 + var errorPointer: UnsafeMutablePointer? + let succeeded = directory.path.withCString { directoryPath in + guard let filePath else { + return TCPViewerWiresharkConfigureTLSKeyLog(nil, directoryPath, &generation, &errorPointer) + } + return filePath.withCString { path in + TCPViewerWiresharkConfigureTLSKeyLog(path, directoryPath, &generation, &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) + } + return generation + } + + 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..47d230e 100644 --- a/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift +++ b/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift @@ -299,6 +299,54 @@ 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 state.file.records.count <= limits.maximumCandidatePacketCount else { + throw NativeNSError(.unavailableFeature, "This capture has more than \(limits.maximumCandidatePacketCount) packets.") + } + 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( + reference: DecryptedStreamReference(packetID: identifier, protocolName: fields.protocolName), + 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 +913,45 @@ 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( + maximumPacketCount: limits.maximumCandidatePacketCount, + shouldCancel: shouldCancel + ) + } + let records = try snapshot.records(maximumBytes: 256 * 1_024 * 1_024, shouldCancel: shouldCancel) + guard let selected = records.first(where: { $0.identifier == identifier }) else { + throw NativeNSError(.fileReadFailed, "The selected packet is no longer in the live snapshot.") + } + let fields = try WiresharkEpanSession.followDecryptedStreamInTemporarySession( + containing: selected, + records: records, + limits: limits, + progress: progress, + shouldCancel: shouldCancel + ) + return DecryptedStreamResult( + reference: DecryptedStreamReference(packetID: identifier, protocolName: fields.protocolName), + 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 +1237,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..86d9058 100644 --- a/PcapPlusPlusCore/Services/LiveCapture/NativeLivePacketDiskStore.swift +++ b/PcapPlusPlusCore/Services/LiveCapture/NativeLivePacketDiskStore.swift @@ -253,6 +253,32 @@ final class NativeLivePacketDiskStore { ) } + // Duplicate the anonymous store so stopped-capture TLS replay never holds the writer lock. + func snapshotAll( + maximumPacketCount: Int, + shouldCancel: TCPFollowCancellationCheck? = nil + ) throws -> NativeLivePacketDiskSnapshot { + guard entries.count <= maximumPacketCount else { + throw NativeNSError(.unavailableFeature, "This capture has more than \(maximumPacketCount) packets.") + } + 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..d21f57b --- /dev/null +++ b/PcapPlusPlusCoreTests/Services/Core/NativeTLSKeyLogManagerTests.swift @@ -0,0 +1,156 @@ +// +// 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 replacementAndRemovalAdvanceGenerationButSameFileAppendDoesNot() 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 handle = try FileHandle(forWritingTo: firstURL) + try handle.seekToEnd() + try handle.write(contentsOf: Data("# appended\n".utf8)) + try handle.close() + let appended = try apply(manager, fileURL: firstURL) + let replacement = try apply(manager, fileURL: secondURL) + let removed = try remove(manager) + + #expect(appended.configurationGeneration == first.configurationGeneration) + #expect(replacement.configurationGeneration == first.configurationGeneration + 1) + #expect(removed.configurationGeneration == replacement.configurationGeneration + 1) + #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/TCPViewer/App/AppDelegate.swift b/TCPViewer/App/AppDelegate.swift index 1c2c50e..a6fc270 100644 --- a/TCPViewer/App/AppDelegate.swift +++ b/TCPViewer/App/AppDelegate.swift @@ -16,6 +16,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var aboutWindowController: TCPViewerAboutWindowController? private var settingsWindowController: NSWindowController? + private var tlsKeyLogWindowController: TLSKeyLogWindowController? + private var tlsKeyLogReloadTimer: Timer? + private var hasPendingTLSKeyLogReload = false + private var isReloadingTLSKeyLogCaptures = false private var licenseWindowController: TCPViewerLicenseWindowController? private var updaterController: SPUStandardUpdaterController? private let sparkleUpdaterDelegate = TCPViewerSparkleUpdaterDelegate() @@ -26,6 +30,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var configurationObserver: 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 @@ -49,6 +54,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { checkForAvailableUpdatesAtLaunch() wireClearAllPacketsMenu() wireFilterMenu() + wireToolsMenu() wireHelpMenu() verifyLicenseAtLaunch() updateMCPServerAvailability() @@ -116,6 +122,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 +640,93 @@ 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() { + workspaceWindowControllers().forEach { + $0.rootViewController.viewModel.invalidateInspectionAfterTLSKeyLogChange() + } + hasPendingTLSKeyLogReload = true + processPendingTLSKeyLogReload() + } + + // A live capture has EPAN priority, so wait until Stop before reopening offline captures. + private func processPendingTLSKeyLogReload() { + guard hasPendingTLSKeyLogReload, !isReloadingTLSKeyLogCaptures else { + return + } + guard !workspaceWindowControllers().contains(where: { $0.rootViewController.viewModel.snapshot.base.sessionState.phase.ownsWiresharkRuntime }) else { + scheduleTLSKeyLogReloadRetry() + return + } + + tlsKeyLogReloadTimer?.invalidate() + tlsKeyLogReloadTimer = nil + hasPendingTLSKeyLogReload = false + isReloadingTLSKeyLogCaptures = true + let offlineControllers = workspaceWindowControllers().filter { + $0.rootViewController.viewModel.snapshot.base.packetIngestState.source == .offline + } + reloadOfflineCaptures(offlineControllers, index: 0) { [weak self] in + guard let self else { + return + } + self.isReloadingTLSKeyLogCaptures = false + self.processPendingTLSKeyLogReload() + } + } + + 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 scheduleTLSKeyLogReloadRetry() { + guard tlsKeyLogReloadTimer == nil else { + return + } + tlsKeyLogReloadTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + self?.processPendingTLSKeyLogReload() + } + } + + private func workspaceWindowControllers() -> [TCPViewerWindowController] { + NSApp.windows.compactMap { $0.windowController as? TCPViewerWindowController } + } + private func configureClearAllPacketsMenuItem(_ item: NSMenuItem) { item.title = "Clear All Packets" item.target = nil @@ -916,6 +1026,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } +private extension CaptureSessionState.Phase { + var ownsWiresharkRuntime: Bool { + switch self { + case .starting, .running, .paused, .stopping: + true + case .idle, .ready, .stopped, .failed: + false + } + } +} + #if DEBUG private enum TCPViewerDebugLaunchArgumentFilter { private static let reproducerLaunchArgument = "--tcpviewer-run-selection-crash-reproducer" 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..0364e65 100644 --- a/TCPViewer/Core/WorkspaceFoundation.swift +++ b/TCPViewer/Core/WorkspaceFoundation.swift @@ -934,7 +934,10 @@ private extension PacketInspection { rawBytes: rawBytes, byteViews: byteViews, detailNodes: detailNodes, - decodeStatus: decodeStatus + decodeStatus: decodeStatus, + decryptedStreamReference: decryptedStreamReference.map { + DecryptedStreamReference(packetID: packetID, protocolName: $0.protocolName) + } ) } } @@ -1967,6 +1970,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 +3008,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() @@ -4050,6 +4085,64 @@ 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 + ) { result in + completion(result.map { value in + DecryptedStreamResult( + reference: DecryptedStreamReference(packetID: identifier, protocolName: value.protocolName), + protocolName: value.protocolName, + client: value.client, + server: value.server, + request: value.request, + response: value.response + ) + }) + } + } 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..455075a 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,11 @@ 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 init(configuration: AppConfiguration) { self.configuration = configuration @@ -718,6 +756,7 @@ final class PacketInspectorViewController: NSViewController { view = TCPViewerDynamicBackgroundView(backgroundColor: .controlBackgroundColor) setupFilterBar() setupOutlineView() + setupDecryptedView() setupLayout() } @@ -729,6 +768,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 +780,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 +879,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 +933,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 +1024,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 +1066,121 @@ 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 + } + 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) { + 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: { [weak self] in + self?.decryptedLoadGeneration != generation + } + ) { [weak self] result in + DispatchQueue.main.async { + guard let self, 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/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..8d0b0d8 100644 --- a/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift +++ b/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift @@ -71,6 +71,76 @@ 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( + reference: DecryptedStreamReference(packetID: packet.id, protocolName: .tls), + 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) + + 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 +202,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 +991,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 +1217,26 @@ struct PacketInspectorTreeViewModelTests { captureMetadata: PacketCaptureMetadata(linkType: .ethernet, isTruncated: false) ) } + + private func makeDecryptedResult(packet: PacketSummary, request: String) -> DecryptedStreamResult { + DecryptedStreamResult( + reference: DecryptedStreamReference(packetID: packet.id, protocolName: .tls), + 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 defersDecryptedStreamCompletions = false + private var decryptedStreamCompletions: [TCPViewerCompletion] = [] func packetInspectorViewController(_ controller: PacketInspectorViewController, didSelectDetailNode identifier: String?) { selectedDetailNodeID = identifier @@ -1155,4 +1248,26 @@ 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 + 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) + } } From 0bbdb17dea26dfbdc567ba09c741b45e554ccd23 Mon Sep 17 00:00:00 2001 From: Noah Tran Date: Wed, 19 Aug 2026 17:53:45 +0200 Subject: [PATCH 2/5] Simplify TLS key-log lifecycle --- .../Dissection/WiresharkEpanShim.cpp | 26 +------- .../Dissection/WiresharkEpanShim.h | 1 - PcapPlusPlusCore/Models/DecryptedStream.swift | 17 ------ PcapPlusPlusCore/Models/PacketModels.swift | 5 +- PcapPlusPlusCore/Models/TLSKeyLog.swift | 7 +-- .../NativeBridge/NativeBridgeSupport.swift | 31 +--------- .../Core/NativeTLSKeyLogManager.swift | 24 +++----- .../Services/Core/SwiftNativeCore.swift | 2 - .../Core/NativeTLSKeyLogManagerTests.swift | 12 +--- TCPViewer/App/AppDelegate.swift | 61 +++---------------- TCPViewer/Core/WorkspaceFoundation.swift | 21 ++----- .../PacketInspectorTreeViewModelTests.swift | 2 - 12 files changed, 32 insertions(+), 177 deletions(-) diff --git a/PcapPlusPlusCore/Dissection/WiresharkEpanShim.cpp b/PcapPlusPlusCore/Dissection/WiresharkEpanShim.cpp index 76c2744..55160e5 100644 --- a/PcapPlusPlusCore/Dissection/WiresharkEpanShim.cpp +++ b/PcapPlusPlusCore/Dissection/WiresharkEpanShim.cpp @@ -1167,12 +1167,6 @@ std::string &TLSKeyLogPath() return path; } -uint64_t &TLSKeyLogConfigurationGeneration() -{ - static uint64_t generation = 0; - return generation; -} - } // namespace struct TCPViewerWiresharkSession { @@ -1208,7 +1202,6 @@ struct TCPViewerWiresharkSession { bool processingFollowPacket = false; bool tcpIndexTapRegistered = false; bool collectingTCPStreamIndex = false; - uint64_t tlsKeyLogConfigurationGeneration = 0; bool followTruncated = false; bool followUsesPerDirectionLimit = false; uint64_t followPayloadByteCount = 0; @@ -1522,7 +1515,6 @@ struct TCPViewerWiresharkSession { activeSession() = this; resetActiveFrameStateLocked(); firstPassFinished = false; - tlsKeyLogConfigurationGeneration = TLSKeyLogConfigurationGeneration(); return true; } @@ -1589,14 +1581,7 @@ struct TCPViewerWiresharkSession { return false; } if (hasSession() && activeSession() == this) { - if (tlsKeyLogConfigurationGeneration == TLSKeyLogConfigurationGeneration()) { - return true; - } - // A live session resumes from the next packet; offline documents are reopened by the app. - releaseWiresharkResourcesLocked("Wireshark TLS keys changed; reload this capture to refresh packet details.", false); - if (livePriority) { - firstPassFinished = false; - } + return true; } if (!initializeWiresharkResourcesLocked()) { return false; @@ -2238,7 +2223,6 @@ struct TCPViewerWiresharkSession { bool TCPViewerWiresharkConfigureTLSKeyLog( const char *filePath, const char *personalConfigurationDirectory, - uint64_t *configurationGeneration, char **errorMessage ) { if (errorMessage != nullptr) { @@ -2280,13 +2264,7 @@ bool TCPViewerWiresharkConfigureTLSKeyLog( return false; } - if (TLSKeyLogPath() != nextPath) { - TLSKeyLogPath() = nextPath; - TLSKeyLogConfigurationGeneration() += 1; - } - if (configurationGeneration != nullptr) { - *configurationGeneration = TLSKeyLogConfigurationGeneration(); - } + TLSKeyLogPath() = nextPath; return true; } diff --git a/PcapPlusPlusCore/Dissection/WiresharkEpanShim.h b/PcapPlusPlusCore/Dissection/WiresharkEpanShim.h index dbb8956..7f72921 100644 --- a/PcapPlusPlusCore/Dissection/WiresharkEpanShim.h +++ b/PcapPlusPlusCore/Dissection/WiresharkEpanShim.h @@ -123,7 +123,6 @@ typedef enum TCPViewerWiresharkFollowPacketStatus { bool TCPViewerWiresharkConfigureTLSKeyLog( const char *filePath, const char *personalConfigurationDirectory, - uint64_t *configurationGeneration, char **errorMessage ); bool TCPViewerWiresharkHasTLSKeyLog(void); diff --git a/PcapPlusPlusCore/Models/DecryptedStream.swift b/PcapPlusPlusCore/Models/DecryptedStream.swift index 492a1ec..2c81690 100644 --- a/PcapPlusPlusCore/Models/DecryptedStream.swift +++ b/PcapPlusPlusCore/Models/DecryptedStream.swift @@ -12,20 +12,6 @@ public enum DecryptedStreamProtocol: String, Sendable, Codable, Hashable { case dtls = "DTLS" case quic = "QUIC" } -public struct DecryptedStreamReference: Sendable, Codable, Hashable { - public let packetID: PacketSummary.ID - public let protocolName: DecryptedStreamProtocol? - - public init(packetID: PacketSummary.ID, protocolName: DecryptedStreamProtocol? = nil) { - self.packetID = packetID - self.protocolName = protocolName - } -} - -public enum DecryptedStreamSide: String, Sendable, Codable, Hashable { - case request - case response -} public struct DecryptedStreamPayload: Sendable, Codable, Hashable { public let data: Data @@ -40,7 +26,6 @@ public struct DecryptedStreamPayload: Sendable, Codable, Hashable { } public struct DecryptedStreamResult: Sendable, Codable, Hashable { - public let reference: DecryptedStreamReference public let protocolName: DecryptedStreamProtocol public let client: PacketEndpoint public let server: PacketEndpoint @@ -48,14 +33,12 @@ public struct DecryptedStreamResult: Sendable, Codable, Hashable { public let response: DecryptedStreamPayload public init( - reference: DecryptedStreamReference, protocolName: DecryptedStreamProtocol, client: PacketEndpoint, server: PacketEndpoint, request: DecryptedStreamPayload, response: DecryptedStreamPayload ) { - self.reference = reference self.protocolName = protocolName self.client = client self.server = server diff --git a/PcapPlusPlusCore/Models/PacketModels.swift b/PcapPlusPlusCore/Models/PacketModels.swift index bce07d4..db6774c 100644 --- a/PcapPlusPlusCore/Models/PacketModels.swift +++ b/PcapPlusPlusCore/Models/PacketModels.swift @@ -130,7 +130,6 @@ public struct PacketInspection: Sendable, Codable, Hashable { public let byteViews: [PacketByteView] public let detailNodes: [PacketDetailNode] public let decodeStatus: PacketDecodeStatus - public let decryptedStreamReference: DecryptedStreamReference? public init( packetID: UInt64, @@ -138,8 +137,7 @@ public struct PacketInspection: Sendable, Codable, Hashable { rawBytes: Data, byteViews: [PacketByteView]? = nil, detailNodes: [PacketDetailNode], - decodeStatus: PacketDecodeStatus, - decryptedStreamReference: DecryptedStreamReference? = nil + decodeStatus: PacketDecodeStatus ) { self.packetID = packetID self.packetNumber = packetNumber @@ -147,7 +145,6 @@ public struct PacketInspection: Sendable, Codable, Hashable { self.byteViews = byteViews ?? [PacketByteView(id: "frame", label: "Frame", bytes: rawBytes)] self.detailNodes = detailNodes self.decodeStatus = decodeStatus - self.decryptedStreamReference = decryptedStreamReference } } diff --git a/PcapPlusPlusCore/Models/TLSKeyLog.swift b/PcapPlusPlusCore/Models/TLSKeyLog.swift index 00a2d4b..ef2166d 100644 --- a/PcapPlusPlusCore/Models/TLSKeyLog.swift +++ b/PcapPlusPlusCore/Models/TLSKeyLog.swift @@ -20,18 +20,17 @@ public struct TLSKeyLogValidation: Sendable, Equatable { self.reachedScanLimit = reachedScanLimit } } + public struct TLSKeyLogState: Sendable, Equatable { public let fileURL: URL? public let validation: TLSKeyLogValidation? - public let configurationGeneration: UInt64 - public init(fileURL: URL?, validation: TLSKeyLogValidation?, configurationGeneration: UInt64) { + public init(fileURL: URL?, validation: TLSKeyLogValidation?) { self.fileURL = fileURL self.validation = validation - self.configurationGeneration = configurationGeneration } - public static let empty = TLSKeyLogState(fileURL: nil, validation: nil, configurationGeneration: 0) + public static let empty = TLSKeyLogState(fileURL: nil, validation: nil) } public protocol TLSKeyLogManaging: AnyObject { diff --git a/PcapPlusPlusCore/NativeBridge/NativeBridgeSupport.swift b/PcapPlusPlusCore/NativeBridge/NativeBridgeSupport.swift index 1cd9e2d..d7d7307 100644 --- a/PcapPlusPlusCore/NativeBridge/NativeBridgeSupport.swift +++ b/PcapPlusPlusCore/NativeBridge/NativeBridgeSupport.swift @@ -366,41 +366,16 @@ enum NativeBridgeMapper { } static func packetInspection(_ descriptor: PCPPNativePacketInspectionDescriptor) -> PacketInspection { - let detailNodes = descriptor.detailNodes.map(packetDetailNode) - return PacketInspection( + PacketInspection( packetID: descriptor.packetIdentifier, packetNumber: descriptor.packetNumber, rawBytes: descriptor.rawBytes, byteViews: descriptor.byteViews.map(packetByteView), - detailNodes: detailNodes, - decodeStatus: decodeStatus(descriptor.decodeStatus), - decryptedStreamReference: decryptedStreamReference(packetID: descriptor.packetIdentifier, nodes: detailNodes) + detailNodes: descriptor.detailNodes.map(packetDetailNode), + decodeStatus: decodeStatus(descriptor.decodeStatus) ) } - private static func decryptedStreamReference( - packetID: PacketSummary.ID, - nodes: [PacketDetailNode] - ) -> DecryptedStreamReference? { - let fieldNames = recursiveFieldNames(nodes) - if fieldNames.contains(where: { $0.hasPrefix("tls.") }) { - return DecryptedStreamReference(packetID: packetID, protocolName: .tls) - } - if fieldNames.contains(where: { $0.hasPrefix("dtls.") }) { - return DecryptedStreamReference(packetID: packetID, protocolName: .dtls) - } - if fieldNames.contains(where: { $0.hasPrefix("quic.") }) { - return DecryptedStreamReference(packetID: packetID, protocolName: .quic) - } - return nil - } - - private static func recursiveFieldNames(_ nodes: [PacketDetailNode]) -> [String] { - nodes.flatMap { node in - [node.fieldName].compactMap(\.self) + recursiveFieldNames(node.children) - } - } - static func packetSummary( _ descriptor: PCPPNativePacketSummaryDescriptor, source: CaptureSource diff --git a/PcapPlusPlusCore/Services/Core/NativeTLSKeyLogManager.swift b/PcapPlusPlusCore/Services/Core/NativeTLSKeyLogManager.swift index 3e57628..cfe874f 100644 --- a/PcapPlusPlusCore/Services/Core/NativeTLSKeyLogManager.swift +++ b/PcapPlusPlusCore/Services/Core/NativeTLSKeyLogManager.swift @@ -39,12 +39,8 @@ public final class NativeTLSKeyLogManager: TLSKeyLogManaging, @unchecked Sendabl queue.async { completion(Result { let validation = try Self.validateFile(at: fileURL) - let generation = try self.configureWireshark(filePath: fileURL.path) - let nextState = TLSKeyLogState( - fileURL: fileURL, - validation: validation, - configurationGeneration: generation - ) + try self.configureWireshark(filePath: fileURL.path) + let nextState = TLSKeyLogState(fileURL: fileURL, validation: validation) self.state = nextState return nextState }) @@ -54,12 +50,8 @@ public final class NativeTLSKeyLogManager: TLSKeyLogManaging, @unchecked Sendabl public func remove(completion: @escaping TCPViewerCompletion) { queue.async { completion(Result { - let generation = try self.configureWireshark(filePath: nil) - let nextState = TLSKeyLogState( - fileURL: nil, - validation: nil, - configurationGeneration: generation - ) + try self.configureWireshark(filePath: nil) + let nextState = TLSKeyLogState.empty self.state = nextState return nextState }) @@ -215,7 +207,7 @@ public final class NativeTLSKeyLogManager: TLSKeyLogManaging, @unchecked Sendabl } } - private func configureWireshark(filePath: String?) throws -> UInt64 { + private func configureWireshark(filePath: String?) throws { let directory: URL do { directory = try runtimeConfiguration.createPersonalConfigurationDirectoryIfNeeded() @@ -223,14 +215,13 @@ public final class NativeTLSKeyLogManager: TLSKeyLogManaging, @unchecked Sendabl throw Self.invalidFile("TCP Viewer could not prepare its Wireshark runtime.") } - var generation: UInt64 = 0 var errorPointer: UnsafeMutablePointer? let succeeded = directory.path.withCString { directoryPath in guard let filePath else { - return TCPViewerWiresharkConfigureTLSKeyLog(nil, directoryPath, &generation, &errorPointer) + return TCPViewerWiresharkConfigureTLSKeyLog(nil, directoryPath, &errorPointer) } return filePath.withCString { path in - TCPViewerWiresharkConfigureTLSKeyLog(path, directoryPath, &generation, &errorPointer) + TCPViewerWiresharkConfigureTLSKeyLog(path, directoryPath, &errorPointer) } } defer { TCPViewerWiresharkCStringDestroy(errorPointer) } @@ -239,7 +230,6 @@ public final class NativeTLSKeyLogManager: TLSKeyLogManaging, @unchecked Sendabl ?? "Wireshark could not apply the TLS key-log file." throw Self.invalidFile(message) } - return generation } private static func invalidFile(_ message: String) -> TCPViewerCoreError { diff --git a/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift b/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift index 47d230e..bde48cc 100644 --- a/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift +++ b/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift @@ -338,7 +338,6 @@ final class PCPPNativeOfflineDocument { ) } return DecryptedStreamResult( - reference: DecryptedStreamReference(packetID: identifier, protocolName: fields.protocolName), protocolName: fields.protocolName, client: fields.client, server: fields.server, @@ -943,7 +942,6 @@ final class PCPPNativeLiveSession { shouldCancel: shouldCancel ) return DecryptedStreamResult( - reference: DecryptedStreamReference(packetID: identifier, protocolName: fields.protocolName), protocolName: fields.protocolName, client: fields.client, server: fields.server, diff --git a/PcapPlusPlusCoreTests/Services/Core/NativeTLSKeyLogManagerTests.swift b/PcapPlusPlusCoreTests/Services/Core/NativeTLSKeyLogManagerTests.swift index d21f57b..c3b265f 100644 --- a/PcapPlusPlusCoreTests/Services/Core/NativeTLSKeyLogManagerTests.swift +++ b/PcapPlusPlusCoreTests/Services/Core/NativeTLSKeyLogManagerTests.swift @@ -88,7 +88,7 @@ struct NativeTLSKeyLogManagerTests { #expect(result.reachedScanLimit) } - @Test func replacementAndRemovalAdvanceGenerationButSameFileAppendDoesNot() throws { + @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") @@ -97,17 +97,11 @@ struct NativeTLSKeyLogManagerTests { let manager = NativeTLSKeyLogManager() let first = try apply(manager, fileURL: firstURL) - let handle = try FileHandle(forWritingTo: firstURL) - try handle.seekToEnd() - try handle.write(contentsOf: Data("# appended\n".utf8)) - try handle.close() - let appended = try apply(manager, fileURL: firstURL) let replacement = try apply(manager, fileURL: secondURL) let removed = try remove(manager) - #expect(appended.configurationGeneration == first.configurationGeneration) - #expect(replacement.configurationGeneration == first.configurationGeneration + 1) - #expect(removed.configurationGeneration == replacement.configurationGeneration + 1) + #expect(first.fileURL == firstURL) + #expect(replacement.fileURL == secondURL) #expect(removed.fileURL == nil) } diff --git a/TCPViewer/App/AppDelegate.swift b/TCPViewer/App/AppDelegate.swift index a6fc270..6043706 100644 --- a/TCPViewer/App/AppDelegate.swift +++ b/TCPViewer/App/AppDelegate.swift @@ -17,9 +17,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var aboutWindowController: TCPViewerAboutWindowController? private var settingsWindowController: NSWindowController? private var tlsKeyLogWindowController: TLSKeyLogWindowController? - private var tlsKeyLogReloadTimer: Timer? - private var hasPendingTLSKeyLogReload = false - private var isReloadingTLSKeyLogCaptures = false private var licenseWindowController: TCPViewerLicenseWindowController? private var updaterController: SPUStandardUpdaterController? private let sparkleUpdaterDelegate = TCPViewerSparkleUpdaterDelegate() @@ -667,59 +664,30 @@ class AppDelegate: NSObject, NSApplicationDelegate { } private func handleTLSKeyLogConfigurationChange() { - workspaceWindowControllers().forEach { + let controllers = workspaceWindowControllers() + controllers.forEach { $0.rootViewController.viewModel.invalidateInspectionAfterTLSKeyLogChange() } - hasPendingTLSKeyLogReload = true - processPendingTLSKeyLogReload() - } - - // A live capture has EPAN priority, so wait until Stop before reopening offline captures. - private func processPendingTLSKeyLogReload() { - guard hasPendingTLSKeyLogReload, !isReloadingTLSKeyLogCaptures else { + // Keep an active live capture untouched; users can reload offline files after stopping it. + guard !controllers.contains(where: { $0.rootViewController.viewModel.snapshot.base.sessionState.canStop }) else { return } - guard !workspaceWindowControllers().contains(where: { $0.rootViewController.viewModel.snapshot.base.sessionState.phase.ownsWiresharkRuntime }) else { - scheduleTLSKeyLogReloadRetry() - return - } - - tlsKeyLogReloadTimer?.invalidate() - tlsKeyLogReloadTimer = nil - hasPendingTLSKeyLogReload = false - isReloadingTLSKeyLogCaptures = true - let offlineControllers = workspaceWindowControllers().filter { + let offlineControllers = controllers.filter { $0.rootViewController.viewModel.snapshot.base.packetIngestState.source == .offline } - reloadOfflineCaptures(offlineControllers, index: 0) { [weak self] in - guard let self else { - return - } - self.isReloadingTLSKeyLogCaptures = false - self.processPendingTLSKeyLogReload() - } + reloadOfflineCaptures(offlineControllers, index: 0) } + // 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 + index: Int ) { 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 scheduleTLSKeyLogReloadRetry() { - guard tlsKeyLogReloadTimer == nil else { - return - } - tlsKeyLogReloadTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in - self?.processPendingTLSKeyLogReload() + self?.reloadOfflineCaptures(controllers, index: index + 1) } } @@ -1026,17 +994,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } -private extension CaptureSessionState.Phase { - var ownsWiresharkRuntime: Bool { - switch self { - case .starting, .running, .paused, .stopping: - true - case .idle, .ready, .stopped, .failed: - false - } - } -} - #if DEBUG private enum TCPViewerDebugLaunchArgumentFilter { private static let reproducerLaunchArgument = "--tcpviewer-run-selection-crash-reproducer" diff --git a/TCPViewer/Core/WorkspaceFoundation.swift b/TCPViewer/Core/WorkspaceFoundation.swift index 0364e65..d22a7ec 100644 --- a/TCPViewer/Core/WorkspaceFoundation.swift +++ b/TCPViewer/Core/WorkspaceFoundation.swift @@ -934,10 +934,7 @@ private extension PacketInspection { rawBytes: rawBytes, byteViews: byteViews, detailNodes: detailNodes, - decodeStatus: decodeStatus, - decryptedStreamReference: decryptedStreamReference.map { - DecryptedStreamReference(packetID: packetID, protocolName: $0.protocolName) - } + decodeStatus: decodeStatus ) } } @@ -4112,19 +4109,9 @@ final class TCPViewerWorkspaceController { containing: reference.originalPacketID, limits: .default, progress: progress, - shouldCancel: shouldCancel - ) { result in - completion(result.map { value in - DecryptedStreamResult( - reference: DecryptedStreamReference(packetID: identifier, protocolName: value.protocolName), - protocolName: value.protocolName, - client: value.client, - server: value.server, - request: value.request, - response: value.response - ) - }) - } + shouldCancel: shouldCancel, + completion: completion + ) } else { guard let document else { completion(.failure(TCPViewerCoreError(code: .offlineFileOpenFailed, message: "Packet \(identifier) is no longer available."))) diff --git a/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift b/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift index 8d0b0d8..b1a5ec1 100644 --- a/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift +++ b/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift @@ -77,7 +77,6 @@ struct PacketInspectorTreeViewModelTests { let controller = PacketInspectorViewController(configuration: AppConfiguration(defaults: isolatedDefaults())) let delegate = PacketInspectorDelegateSpy() delegate.decryptedStreamResult = .success(DecryptedStreamResult( - reference: DecryptedStreamReference(packetID: packet.id, protocolName: .tls), protocolName: .tls, client: PacketEndpoint(address: "10.0.0.1", port: 1234), server: PacketEndpoint(address: "10.0.0.2", port: 443), @@ -1220,7 +1219,6 @@ struct PacketInspectorTreeViewModelTests { private func makeDecryptedResult(packet: PacketSummary, request: String) -> DecryptedStreamResult { DecryptedStreamResult( - reference: DecryptedStreamReference(packetID: packet.id, protocolName: .tls), protocolName: .tls, client: PacketEndpoint(address: "10.0.0.1", port: 1234), server: PacketEndpoint(address: "10.0.0.2", port: 443), From 5dc8dab9fed2cec1a570de4d7aab2805e919c824 Mon Sep 17 00:00:00 2001 From: Noah Tran Date: Wed, 19 Aug 2026 22:18:39 +0200 Subject: [PATCH 3/5] Fix TLS decryption replay bounds --- .../Dissection/WiresharkEpanSession.swift | 110 ++++++++++++--- .../Dissection/WiresharkEpanShim.cpp | 127 ++++++++++++------ PcapPlusPlusCore/Models/DecryptedStream.swift | 3 - .../Services/Core/SwiftNativeCore.swift | 14 +- .../NativeLivePacketDiskStore.swift | 67 ++++++--- .../NativeLivePacketDiskSnapshotTests.swift | 22 +++ TCPViewer/App/AppDelegate.swift | 43 +++++- TCPViewer/Core/WorkspaceFoundation.swift | 11 ++ .../Views/PacketInspectorViewController.swift | 22 ++- .../App/WorkspaceControllerTests.swift | 10 ++ .../PacketInspectorTreeViewModelTests.swift | 4 + 11 files changed, 328 insertions(+), 105 deletions(-) diff --git a/PcapPlusPlusCore/Dissection/WiresharkEpanSession.swift b/PcapPlusPlusCore/Dissection/WiresharkEpanSession.swift index 7a1c528..0012451 100644 --- a/PcapPlusPlusCore/Dissection/WiresharkEpanSession.swift +++ b/PcapPlusPlusCore/Dissection/WiresharkEpanSession.swift @@ -312,7 +312,35 @@ final class WiresharkEpanSession { 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 { @@ -332,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.") } @@ -349,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( @@ -399,31 +427,60 @@ final class WiresharkEpanSession { 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 tcpLimits = TCPFollowLimits( - maximumCandidatePacketCount: limits.maximumCandidatePacketCount, - maximumPayloadBytes: limits.maximumBytesPerDirection, - maximumRecordCount: limits.maximumRecordCount - ) - try validateFollowRequest(selectedRecord: selectedRecord, records: records, limits: tcpLimits) let session = try WiresharkEpanSession(purpose: .follow) - let totalWorkCount = records.count * 2 - for (index, record) in records.enumerated() { + 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) - reportFollowProgress(processedPacketCount: index + 1, totalPacketCount: totalWorkCount, handler: progress) + processedPacketCount += 1 + reportFollowProgress( + processedPacketCount: processedPacketCount, + totalPacketCount: totalWorkCount, + handler: progress + ) + return true } try session.finishFirstPass() return try session.followObservedDecryptedStream( containing: selectedRecord, - records: records, + replay: replay, limits: limits, - progressOffset: records.count, + progressOffset: recordCount, progressTotal: totalWorkCount, progress: progress, shouldCancel: shouldCancel @@ -440,9 +497,18 @@ final class WiresharkEpanSession { 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, - records: records, + replay: { consume in + for record in records { + if try !consume(record) { + break + } + } + }, limits: limits, progressOffset: 0, progressTotal: records.count, @@ -453,7 +519,7 @@ final class WiresharkEpanSession { private func followObservedDecryptedStream( containing selectedRecord: NativePacketRecord, - records: [NativePacketRecord], + replay: (_ consume: (NativePacketRecord) throws -> Bool) throws -> Void, limits: DecryptedStreamLimits, progressOffset: Int, progressTotal: Int, @@ -461,7 +527,6 @@ final class WiresharkEpanSession { shouldCancel: TCPFollowCancellationCheck? ) throws -> WiresharkDecryptedFollowFields { let followLimits = TCPFollowLimits( - maximumCandidatePacketCount: limits.maximumCandidatePacketCount, maximumPayloadBytes: limits.maximumBytesPerDirection, maximumRecordCount: limits.maximumRecordCount ) @@ -470,16 +535,19 @@ final class WiresharkEpanSession { do { let fields = try followObservedStream( containing: selectedRecord, - records: records, limits: followLimits, protocolName: protocolName.rawValue, progressOffset: progressOffset, progressTotal: progressTotal, progress: progress, - shouldCancel: shouldCancel + shouldCancel: shouldCancel, + replay: replay ) return decryptedFields(protocolName: protocolName, fields: fields, limit: limits.maximumBytesPerDirection) } catch { + if shouldCancel?() == true { + throw error + } lastError = error } } diff --git a/PcapPlusPlusCore/Dissection/WiresharkEpanShim.cpp b/PcapPlusPlusCore/Dissection/WiresharkEpanShim.cpp index 55160e5..6e1d837 100644 --- a/PcapPlusPlusCore/Dissection/WiresharkEpanShim.cpp +++ b/PcapPlusPlusCore/Dissection/WiresharkEpanShim.cpp @@ -1206,7 +1206,7 @@ struct TCPViewerWiresharkSession { bool followUsesPerDirectionLimit = false; uint64_t followPayloadByteCount = 0; uint64_t followObservedByteCountByDirection[2] = {}; - uint64_t followRetainedByteCountByDirection[2] = {}; + std::vector followRetainedPayloadByDirection[2]; GList *followNewestPayloadItem = nullptr; std::string personalConfigurationDirectory; bool disabled = false; @@ -1682,8 +1682,8 @@ struct TCPViewerWiresharkSession { followPayloadByteCount = 0; followObservedByteCountByDirection[0] = 0; followObservedByteCountByDirection[1] = 0; - followRetainedByteCountByDirection[0] = 0; - followRetainedByteCountByDirection[1] = 0; + followRetainedPayloadByDirection[0].clear(); + followRetainedPayloadByDirection[1].clear(); followNewestPayloadItem = nullptr; } @@ -1835,8 +1835,8 @@ struct TCPViewerWiresharkSession { followPayloadByteCount = 0; followObservedByteCountByDirection[0] = 0; followObservedByteCountByDirection[1] = 0; - followRetainedByteCountByDirection[0] = 0; - followRetainedByteCountByDirection[1] = 0; + followRetainedPayloadByDirection[0].clear(); + followRetainedPayloadByDirection[1].clear(); followNewestPayloadItem = nullptr; return true; } @@ -1912,8 +1912,13 @@ 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) { const size_t byteCount = record->data->len; @@ -1921,17 +1926,36 @@ struct TCPViewerWiresharkSession { if (followUsesPerDirectionLimit) { const size_t direction = record->is_server ? 1 : 0; followObservedByteCountByDirection[direction] += byteCount; - const size_t retained = static_cast(followRetainedByteCountByDirection[direction]); + auto &payload = followRetainedPayloadByDirection[direction]; + const size_t retained = payload.size(); const size_t remaining = retained >= maximumPayloadBytes ? 0 : maximumPayloadBytes - retained; - if (byteCount > remaining) { - g_byte_array_set_size(record->data, static_cast(remaining)); + 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; } - followRetainedByteCountByDirection[direction] += std::min(byteCount, remaining); + // 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 (followUsesPerDirectionLimit + && followRetainedPayloadByDirection[0].size() >= maximumPayloadBytes + && followRetainedPayloadByDirection[1].size() >= maximumPayloadBytes) { + followTruncated = true; + return TCPViewerWiresharkFollowPacketLimitReached; + } if (!followUsesPerDirectionLimit && followPayloadByteCount > maximumPayloadBytes) { followTruncated = true; return TCPViewerWiresharkFollowPacketLimitReached; @@ -2000,7 +2024,10 @@ struct TCPViewerWiresharkSession { 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 @@ -2019,45 +2046,57 @@ struct TCPViewerWiresharkSession { } size_t outputIndex = 0; - size_t remainingPayloadBytes = maximumPayloadBytes; - size_t remainingPayloadBytesByDirection[2] = {maximumPayloadBytes, maximumPayloadBytes}; - for (GList *item = g_list_last(followInfo->payload); - item != nullptr && outputIndex < allocatedRecordCount - && (followUsesPerDirectionLimit - ? remainingPayloadBytesByDirection[0] > 0 || remainingPayloadBytesByDirection[1] > 0 - : 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; - const size_t direction = source->is_server ? 1 : 0; - size_t &remainingForRecord = followUsesPerDirectionLimit - ? remainingPayloadBytesByDirection[direction] - : remainingPayloadBytes; - destination.byteCount = std::min(static_cast(source->data->len), remainingForRecord); - 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; } - remainingForRecord -= destination.byteCount; - outputIndex += 1; } result->recordCount = outputIndex; @@ -2074,8 +2113,8 @@ struct TCPViewerWiresharkSession { followPayloadByteCount = 0; followObservedByteCountByDirection[0] = 0; followObservedByteCountByDirection[1] = 0; - followRetainedByteCountByDirection[0] = 0; - followRetainedByteCountByDirection[1] = 0; + followRetainedPayloadByDirection[0].clear(); + followRetainedPayloadByDirection[1].clear(); followNewestPayloadItem = nullptr; return result; } diff --git a/PcapPlusPlusCore/Models/DecryptedStream.swift b/PcapPlusPlusCore/Models/DecryptedStream.swift index 2c81690..5eb60c1 100644 --- a/PcapPlusPlusCore/Models/DecryptedStream.swift +++ b/PcapPlusPlusCore/Models/DecryptedStream.swift @@ -48,16 +48,13 @@ public struct DecryptedStreamResult: Sendable, Codable, Hashable { } public struct DecryptedStreamLimits: Sendable, Equatable, Hashable { - public let maximumCandidatePacketCount: Int public let maximumBytesPerDirection: Int public let maximumRecordCount: Int public init( - maximumCandidatePacketCount: Int = 250_000, maximumBytesPerDirection: Int = 8 * 1_024 * 1_024, maximumRecordCount: Int = 100_000 ) { - self.maximumCandidatePacketCount = max(maximumCandidatePacketCount, 1) self.maximumBytesPerDirection = max(maximumBytesPerDirection, 1) self.maximumRecordCount = max(maximumRecordCount, 1) } diff --git a/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift b/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift index bde48cc..8a60b81 100644 --- a/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift +++ b/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift @@ -307,9 +307,6 @@ final class PCPPNativeOfflineDocument { shouldCancel: TCPFollowCancellationCheck? ) throws -> DecryptedStreamResult { let snapshot = try state.read { state -> (NativePacketRecord, [NativePacketRecord], WiresharkEpanSession) in - guard state.file.records.count <= limits.maximumCandidatePacketCount else { - throw NativeNSError(.unavailableFeature, "This capture has more than \(limits.maximumCandidatePacketCount) packets.") - } guard let selected = state.file.records.first(where: { $0.identifier == identifier }) else { throw NativeNSError(.fileReadFailed, "Packet \(identifier) is not available in the backing store.") } @@ -926,17 +923,16 @@ final class PCPPNativeLiveSession { throw NativeNSError(.unavailableFeature, "Wireshark TLS stream decryption is unavailable for this capture.") } return try state.packetStore.snapshotAll( - maximumPacketCount: limits.maximumCandidatePacketCount, shouldCancel: shouldCancel ) } - let records = try snapshot.records(maximumBytes: 256 * 1_024 * 1_024, shouldCancel: shouldCancel) - guard let selected = records.first(where: { $0.identifier == identifier }) else { - throw NativeNSError(.fileReadFailed, "The selected packet is no longer in the live snapshot.") - } + let selected = try snapshot.record(withIdentifier: identifier) let fields = try WiresharkEpanSession.followDecryptedStreamInTemporarySession( containing: selected, - records: records, + recordCount: snapshot.packetCount, + replay: { consume in + try snapshot.replayRecords(shouldCancel: shouldCancel, consume) + }, limits: limits, progress: progress, shouldCancel: shouldCancel diff --git a/PcapPlusPlusCore/Services/LiveCapture/NativeLivePacketDiskStore.swift b/PcapPlusPlusCore/Services/LiveCapture/NativeLivePacketDiskStore.swift index 86d9058..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 @@ -255,12 +284,8 @@ final class NativeLivePacketDiskStore { // Duplicate the anonymous store so stopped-capture TLS replay never holds the writer lock. func snapshotAll( - maximumPacketCount: Int, shouldCancel: TCPFollowCancellationCheck? = nil ) throws -> NativeLivePacketDiskSnapshot { - guard entries.count <= maximumPacketCount else { - throw NativeNSError(.unavailableFeature, "This capture has more than \(maximumPacketCount) packets.") - } if shouldCancel?() == true { throw NativeNSError(.operationCancelled, "TLS stream decryption was cancelled.") } 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/TCPViewer/App/AppDelegate.swift b/TCPViewer/App/AppDelegate.swift index 6043706..4e621fc 100644 --- a/TCPViewer/App/AppDelegate.swift +++ b/TCPViewer/App/AppDelegate.swift @@ -25,6 +25,7 @@ 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() @@ -35,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 @@ -45,6 +48,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { appConfiguration.applyAppearance() observeLicenseStatusChanges() observeConfigurationChanges() + observeLiveCaptureRelease() wireAboutMenu() wirePreferencesMenu() wireUpdatesMenu() @@ -93,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 { @@ -668,26 +675,54 @@ class AppDelegate: NSObject, NSApplicationDelegate { controllers.forEach { $0.rootViewController.viewModel.invalidateInspectionAfterTLSKeyLogChange() } - // Keep an active live capture untouched; users can reload offline files after stopping it. + 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 } - reloadOfflineCaptures(offlineControllers, index: 0) + 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 + 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) + self?.reloadOfflineCaptures(controllers, index: index + 1, completion: completion) } } diff --git a/TCPViewer/Core/WorkspaceFoundation.swift b/TCPViewer/Core/WorkspaceFoundation.swift index d22a7ec..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] @@ -3317,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 diff --git a/TCPViewer/Features/NetworkInspector/Views/PacketInspectorViewController.swift b/TCPViewer/Features/NetworkInspector/Views/PacketInspectorViewController.swift index 455075a..57d7040 100644 --- a/TCPViewer/Features/NetworkInspector/Views/PacketInspectorViewController.swift +++ b/TCPViewer/Features/NetworkInspector/Views/PacketInspectorViewController.swift @@ -740,6 +740,7 @@ final class PacketInspectorViewController: NSViewController { private var decryptedPacketID: PacketSummary.ID? private var decryptedLoadGeneration = 0 private var isLoadingDecryptedStream = false + private var decryptedCancellationFlag: TCPFollowCancellationFlag? init(configuration: AppConfiguration) { self.configuration = configuration @@ -752,6 +753,10 @@ final class PacketInspectorViewController: NSViewController { fatalError("init(coder:) has not been implemented") } + deinit { + decryptedCancellationFlag?.cancel() + } + override func loadView() { view = TCPViewerDynamicBackgroundView(backgroundColor: .controlBackgroundColor) setupFilterBar() @@ -1074,6 +1079,8 @@ final class PacketInspectorViewController: NSViewController { guard packetID != decryptedPacketID else { return } + decryptedCancellationFlag?.cancel() + decryptedCancellationFlag = nil decryptedLoadGeneration += 1 decryptedPacketID = packetID decryptedStream = nil @@ -1102,6 +1109,9 @@ final class PacketInspectorViewController: NSViewController { } private func loadDecryptedStream(packetID: PacketSummary.ID) { + decryptedCancellationFlag?.cancel() + let cancellationFlag = TCPFollowCancellationFlag() + decryptedCancellationFlag = cancellationFlag isLoadingDecryptedStream = true decryptedLoadGeneration += 1 let generation = decryptedLoadGeneration @@ -1120,12 +1130,18 @@ final class PacketInspectorViewController: NSViewController { self.showDecryptedMessage("Loading the complete decrypted stream… \(percent)% (\(progress.processedPacketCount)/\(progress.totalPacketCount) packets)") } }, - shouldCancel: { [weak self] in - self?.decryptedLoadGeneration != generation + shouldCancel: { + cancellationFlag.isCancelled } ) { [weak self] result in DispatchQueue.main.async { - guard let self, self.decryptedLoadGeneration == generation, self.decryptedPacketID == packetID else { + guard let self else { + return + } + if self.decryptedCancellationFlag === cancellationFlag { + self.decryptedCancellationFlag = nil + } + guard self.decryptedLoadGeneration == generation, self.decryptedPacketID == packetID else { return } self.isLoadingDecryptedStream = false 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/PacketInspectorTreeViewModelTests.swift b/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift index b1a5ec1..e68b018 100644 --- a/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift +++ b/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift @@ -130,6 +130,8 @@ struct PacketInspectorTreeViewModelTests { 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() @@ -1233,6 +1235,7 @@ private final class PacketInspectorDelegateSpy: PacketInspectorViewControllerDel var customColumnRequest: PacketCustomColumnRequest? var decryptedStreamResult: Result? var decryptedStreamLoadCount = 0 + var decryptedStreamCancellationChecks: [TCPFollowCancellationCheck] = [] var defersDecryptedStreamCompletions = false private var decryptedStreamCompletions: [TCPViewerCompletion] = [] @@ -1255,6 +1258,7 @@ private final class PacketInspectorDelegateSpy: PacketInspectorViewControllerDel completion: @escaping TCPViewerCompletion ) { decryptedStreamLoadCount += 1 + decryptedStreamCancellationChecks.append(shouldCancel) if defersDecryptedStreamCompletions { decryptedStreamCompletions.append(completion) return From 80c798c87027feabf1684df56cacfabe7aa69281 Mon Sep 17 00:00:00 2001 From: Noah Tran Date: Sun, 23 Aug 2026 09:04:09 +0200 Subject: [PATCH 4/5] Update project.pbxproj --- TCPViewer.xcodeproj/project.pbxproj | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/TCPViewer.xcodeproj/project.pbxproj b/TCPViewer.xcodeproj/project.pbxproj index f5766cc..23b26a0 100644 --- a/TCPViewer.xcodeproj/project.pbxproj +++ b/TCPViewer.xcodeproj/project.pbxproj @@ -932,10 +932,11 @@ baseConfigurationReference = BAE100012FA300000070BE17 /* TCPViewer.shared.xcconfig */; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 57SKMSUCY8; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1003,10 +1004,11 @@ baseConfigurationReference = BAE100012FA300000070BE17 /* TCPViewer.shared.xcconfig */; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 57SKMSUCY8; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1075,7 +1077,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 57SKMSUCY8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.proxyman.PcapPlusPlusCoreTests; @@ -1094,7 +1096,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 57SKMSUCY8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.proxyman.PcapPlusPlusCoreTests; @@ -1112,7 +1114,7 @@ buildSettings = { AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 3X57WP8E8V; ENABLE_HARDENED_RUNTIME = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; @@ -1144,7 +1146,7 @@ buildSettings = { AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 3X57WP8E8V; ENABLE_HARDENED_RUNTIME = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; @@ -1176,7 +1178,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 36; - DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 3X57WP8E8V; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -1222,7 +1224,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 36; - DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 3X57WP8E8V; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -1266,7 +1268,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 3X57WP8E8V; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Vendor/HexFiend", @@ -1292,7 +1294,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 3X57WP8E8V; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Vendor/HexFiend", @@ -1318,7 +1320,7 @@ CODE_SIGN_STYLE = Automatic; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 3X57WP8E8V; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "$(DERIVED_FILE_DIR)/TCPViewerHelperTool-SMJobBless-Info.plist"; @@ -1349,7 +1351,7 @@ CODE_SIGN_STYLE = Automatic; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 3X57WP8E8V; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "$(DERIVED_FILE_DIR)/TCPViewerHelperTool-SMJobBless-Info.plist"; From 41900244bd7a3779e56a0a9b3e68481cfad5b91d Mon Sep 17 00:00:00 2001 From: Noah Tran Date: Sun, 23 Aug 2026 09:07:49 +0200 Subject: [PATCH 5/5] Centralize development team signing configuration --- README.md | 9 +++++++-- TCPViewer.xcodeproj/project.pbxproj | 26 ++++++++++++-------------- 2 files changed, 19 insertions(+), 16 deletions(-) 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.xcodeproj/project.pbxproj b/TCPViewer.xcodeproj/project.pbxproj index 23b26a0..f5766cc 100644 --- a/TCPViewer.xcodeproj/project.pbxproj +++ b/TCPViewer.xcodeproj/project.pbxproj @@ -932,11 +932,10 @@ baseConfigurationReference = BAE100012FA300000070BE17 /* TCPViewer.shared.xcconfig */; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; - CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 57SKMSUCY8; + DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1004,11 +1003,10 @@ baseConfigurationReference = BAE100012FA300000070BE17 /* TCPViewer.shared.xcconfig */; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; - CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 57SKMSUCY8; + DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1077,7 +1075,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 57SKMSUCY8; + DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.proxyman.PcapPlusPlusCoreTests; @@ -1096,7 +1094,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 57SKMSUCY8; + DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.proxyman.PcapPlusPlusCoreTests; @@ -1114,7 +1112,7 @@ buildSettings = { AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 3X57WP8E8V; + DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; @@ -1146,7 +1144,7 @@ buildSettings = { AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 3X57WP8E8V; + DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; @@ -1178,7 +1176,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 36; - DEVELOPMENT_TEAM = 3X57WP8E8V; + DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -1224,7 +1222,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 36; - DEVELOPMENT_TEAM = 3X57WP8E8V; + DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -1268,7 +1266,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 3X57WP8E8V; + DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Vendor/HexFiend", @@ -1294,7 +1292,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 3X57WP8E8V; + DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Vendor/HexFiend", @@ -1320,7 +1318,7 @@ CODE_SIGN_STYLE = Automatic; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 3X57WP8E8V; + DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "$(DERIVED_FILE_DIR)/TCPViewerHelperTool-SMJobBless-Info.plist"; @@ -1351,7 +1349,7 @@ CODE_SIGN_STYLE = Automatic; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 3X57WP8E8V; + DEVELOPMENT_TEAM = "$(TCPVIEWER_DEVELOPMENT_TEAM)"; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "$(DERIVED_FILE_DIR)/TCPViewerHelperTool-SMJobBless-Info.plist";