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..80dd9ed 100644 --- a/PcapPlusPlusCore/Dissection/WiresharkEpanSession.swift +++ b/PcapPlusPlusCore/Dissection/WiresharkEpanSession.swift @@ -29,6 +29,14 @@ struct WiresharkTCPFollowFields { let isTruncated: Bool } +struct WiresharkDecryptedFollowFields { + let protocolName: DecryptedStreamProtocol + let client: PacketEndpoint + let server: PacketEndpoint + let request: DecryptedStreamPayload + let response: DecryptedStreamPayload +} + struct WiresharkTCPStreamIndexEntry: Sendable, Equatable { let packetIdentifier: UInt64 let streamIdentifier: UInt32 @@ -261,10 +269,11 @@ final class WiresharkEpanSession { ) } try session.finishFirstPass() - return try session.followObservedTCPStream( + return try session.followObservedStream( containing: selectedRecord, records: records, limits: limits, + protocolName: "TCP", progressOffset: records.count, progressTotal: totalWorkCount, progress: progress, @@ -280,10 +289,11 @@ final class WiresharkEpanSession { progress: TCPFollowProgressHandler?, shouldCancel: TCPFollowCancellationCheck? ) throws -> WiresharkTCPFollowFields { - try followObservedTCPStream( + try followObservedStream( containing: selectedRecord, records: records, limits: limits, + protocolName: "TCP", progressOffset: 0, progressTotal: records.count, progress: progress, @@ -291,17 +301,46 @@ final class WiresharkEpanSession { ) } - private func followObservedTCPStream( + private func followObservedStream( containing selectedRecord: NativePacketRecord, records: [NativePacketRecord], limits: TCPFollowLimits, + protocolName: String, progressOffset: Int, progressTotal: Int, progress: TCPFollowProgressHandler?, shouldCancel: TCPFollowCancellationCheck? ) throws -> WiresharkTCPFollowFields { try Self.validateFollowRequest(selectedRecord: selectedRecord, records: records, limits: limits) + return try followObservedStream( + containing: selectedRecord, + limits: limits, + protocolName: protocolName, + progressOffset: progressOffset, + progressTotal: progressTotal, + progress: progress, + shouldCancel: shouldCancel, + replay: { consume in + for record in records { + if try !consume(record) { + break + } + } + } + ) + } + // Consume replayed packets one at a time so stopped live captures never load all packet bytes into memory. + private func followObservedStream( + containing selectedRecord: NativePacketRecord, + limits: TCPFollowLimits, + protocolName: String, + progressOffset: Int, + progressTotal: Int, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + replay: (_ consume: (NativePacketRecord) throws -> Bool) throws -> Void + ) throws -> WiresharkTCPFollowFields { var followIsActive = false defer { if followIsActive { @@ -309,7 +348,10 @@ final class WiresharkEpanSession { } } try withContext(for: selectedRecord) { context in - guard TCPViewerWiresharkSessionBeginFollowTCPStream(handle, context) else { + let didBegin = protocolName.withCString { name in + TCPViewerWiresharkSessionBeginFollowStream(handle, context, name) + } + guard didBegin else { if let criticalError = criticalExceptionErrorIfNeeded() { throw criticalError } @@ -318,7 +360,8 @@ final class WiresharkEpanSession { } followIsActive = true - for (index, record) in records.enumerated() { + var processedPacketCount = 0 + try replay { record in if shouldCancel?() == true { throw NativeNSError(.operationCancelled, "TCP stream reassembly was cancelled.") } @@ -335,14 +378,13 @@ final class WiresharkEpanSession { } throw unavailableError() } + processedPacketCount += 1 Self.reportFollowProgress( - processedPacketCount: progressOffset + index + 1, + processedPacketCount: progressOffset + processedPacketCount, totalPacketCount: progressTotal, handler: progress ) - if status == TCPViewerWiresharkFollowPacketLimitReached { - break - } + return status != TCPViewerWiresharkFollowPacketLimitReached } guard let resultPointer = TCPViewerWiresharkSessionFinishFollowTCPStream( @@ -378,6 +420,174 @@ final class WiresharkEpanSession { ) } + // Build a temporary first pass, then let Wireshark choose TLS, DTLS, or QUIC follow semantics. + static func followDecryptedStreamInTemporarySession( + containing selectedRecord: NativePacketRecord, + records: [NativePacketRecord], + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck? + ) throws -> WiresharkDecryptedFollowFields { + guard records.contains(where: { $0.identifier == selectedRecord.identifier }) else { + throw NativeNSError(.fileReadFailed, "The selected packet is not available in the stream snapshot.") + } + return try followDecryptedStreamInTemporarySession( + containing: selectedRecord, + recordCount: records.count, + replay: { consume in + for record in records { + if try !consume(record) { + break + } + } + }, + limits: limits, + progress: progress, + shouldCancel: shouldCancel + ) + } + + // Build a temporary first pass while reading each retained packet only when Wireshark needs it. + static func followDecryptedStreamInTemporarySession( + containing selectedRecord: NativePacketRecord, + recordCount: Int, + replay: (_ consume: (NativePacketRecord) throws -> Bool) throws -> Void, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck? + ) throws -> WiresharkDecryptedFollowFields { + guard TCPViewerWiresharkHasTLSKeyLog() else { + throw NativeNSError(.unavailableFeature, "No TLS key-log file is selected. Choose one in Decrypted or open Tools → TLS Decryption… first.") + } + let session = try WiresharkEpanSession(purpose: .follow) + let totalWorkCount = recordCount > Int.max / 2 ? Int.max : recordCount * 2 + var processedPacketCount = 0 + try replay { record in + if shouldCancel?() == true { + throw NativeNSError(.operationCancelled, "TLS stream decryption was cancelled.") + } + try session.observe(record) + processedPacketCount += 1 + reportFollowProgress( + processedPacketCount: processedPacketCount, + totalPacketCount: totalWorkCount, + handler: progress + ) + return true + } + try session.finishFirstPass() + return try session.followObservedDecryptedStream( + containing: selectedRecord, + replay: replay, + limits: limits, + progressOffset: recordCount, + progressTotal: totalWorkCount, + progress: progress, + shouldCancel: shouldCancel + ) + } + + func followObservedDecryptedStream( + containing selectedRecord: NativePacketRecord, + records: [NativePacketRecord], + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck? + ) throws -> WiresharkDecryptedFollowFields { + guard TCPViewerWiresharkHasTLSKeyLog() else { + throw NativeNSError(.unavailableFeature, "No TLS key-log file is selected. Choose one in Decrypted or open Tools → TLS Decryption… first.") + } + guard records.contains(where: { $0.identifier == selectedRecord.identifier }) else { + throw NativeNSError(.fileReadFailed, "The selected packet is not available in the stream snapshot.") + } + return try followObservedDecryptedStream( + containing: selectedRecord, + replay: { consume in + for record in records { + if try !consume(record) { + break + } + } + }, + limits: limits, + progressOffset: 0, + progressTotal: records.count, + progress: progress, + shouldCancel: shouldCancel + ) + } + + private func followObservedDecryptedStream( + containing selectedRecord: NativePacketRecord, + replay: (_ consume: (NativePacketRecord) throws -> Bool) throws -> Void, + limits: DecryptedStreamLimits, + progressOffset: Int, + progressTotal: Int, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck? + ) throws -> WiresharkDecryptedFollowFields { + let followLimits = TCPFollowLimits( + maximumPayloadBytes: limits.maximumBytesPerDirection, + maximumRecordCount: limits.maximumRecordCount + ) + var lastError: Error? + for protocolName in [DecryptedStreamProtocol.tls, .dtls, .quic] { + do { + let fields = try followObservedStream( + containing: selectedRecord, + limits: followLimits, + protocolName: protocolName.rawValue, + progressOffset: progressOffset, + progressTotal: progressTotal, + progress: progress, + shouldCancel: shouldCancel, + replay: replay + ) + return decryptedFields(protocolName: protocolName, fields: fields, limit: limits.maximumBytesPerDirection) + } catch { + if shouldCancel?() == true { + throw error + } + lastError = error + } + } + throw lastError ?? NativeNSError(.unavailableFeature, "Select a TLS, DTLS, or QUIC packet to decrypt its stream.") + } + + private func decryptedFields( + protocolName: DecryptedStreamProtocol, + fields: WiresharkTCPFollowFields, + limit: Int + ) -> WiresharkDecryptedFollowFields { + var request = Data() + var response = Data() + for record in fields.records { + switch record.direction { + case .clientToServer: + let remaining = max(limit - request.count, 0) + request.append(record.data.prefix(remaining)) + case .serverToClient: + let remaining = max(limit - response.count, 0) + response.append(record.data.prefix(remaining)) + } + } + return WiresharkDecryptedFollowFields( + protocolName: protocolName, + client: fields.client, + server: fields.server, + request: DecryptedStreamPayload( + data: request, + observedByteCount: fields.clientByteCount, + isTruncated: fields.isTruncated || fields.clientByteCount > request.count + ), + response: DecryptedStreamPayload( + data: response, + observedByteCount: fields.serverByteCount, + isTruncated: fields.isTruncated || fields.serverByteCount > response.count + ) + ) + } + private static func validateFollowRequest( selectedRecord: NativePacketRecord, records: [NativePacketRecord], diff --git a/PcapPlusPlusCore/Dissection/WiresharkEpanShim.cpp b/PcapPlusPlusCore/Dissection/WiresharkEpanShim.cpp index 1387e1e..6e1d837 100644 --- a/PcapPlusPlusCore/Dissection/WiresharkEpanShim.cpp +++ b/PcapPlusPlusCore/Dissection/WiresharkEpanShim.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -1160,6 +1161,12 @@ class WiresharkRuntime { WiresharkCriticalExceptionReports criticalExceptionReports_; }; +std::string &TLSKeyLogPath() +{ + static std::string path; + return path; +} + } // namespace struct TCPViewerWiresharkSession { @@ -1196,7 +1203,10 @@ struct TCPViewerWiresharkSession { bool tcpIndexTapRegistered = false; bool collectingTCPStreamIndex = false; bool followTruncated = false; + bool followUsesPerDirectionLimit = false; uint64_t followPayloadByteCount = 0; + uint64_t followObservedByteCountByDirection[2] = {}; + std::vector followRetainedPayloadByDirection[2]; GList *followNewestPayloadItem = nullptr; std::string personalConfigurationDirectory; bool disabled = false; @@ -1668,11 +1678,16 @@ struct TCPViewerWiresharkSession { followReferenceFrame = frame_data{}; nstime_set_zero(&followElapsedTime); followTruncated = false; + followUsesPerDirectionLimit = false; followPayloadByteCount = 0; + followObservedByteCountByDirection[0] = 0; + followObservedByteCountByDirection[1] = 0; + followRetainedPayloadByDirection[0].clear(); + followRetainedPayloadByDirection[1].clear(); followNewestPayloadItem = nullptr; } - bool beginTCPFollowLocked(const PacketContextView &selectedContext) + bool beginFollowLocked(const PacketContextView &selectedContext, const char *protocolName) { cancelFollowLocked(); if (!hasSession() || activeSession() != this) { @@ -1680,19 +1695,19 @@ struct TCPViewerWiresharkSession { return false; } if (activeFollowSession() != nullptr && activeFollowSession() != this) { - unavailableReason = "Another TCP stream is already being reassembled."; + unavailableReason = "Another stream is already being reassembled."; return false; } // Followers are keyed by Wireshark's case-sensitive protocol short name. - tcpFollower = get_follow_by_name("TCP"); + tcpFollower = protocolName == nullptr ? nullptr : get_follow_by_name(protocolName); if (tcpFollower == nullptr) { - unavailableReason = "Wireshark TCP stream following is unavailable."; + unavailableReason = "Wireshark stream following is unavailable for this protocol."; return false; } const auto frameMatch = frameNumberByPacketIdentifier.find(selectedContext.packetIdentifier); if (frameMatch == frameNumberByPacketIdentifier.end()) { - unavailableReason = "The selected packet is not present in the TCP stream snapshot."; + unavailableReason = "The selected packet is not present in the stream snapshot."; return false; } frame_data *frame = frame_data_sequence_find(provider->frames, frameMatch->second); @@ -1709,32 +1724,32 @@ struct TCPViewerWiresharkSession { epan_dissect_t *dissect = nullptr; auto *currentEpan = epan; - if (auto report = CatchWiresharkException("creating Wireshark TCP follow selector", selectedContext.packetIdentifier, [&] { + if (auto report = CatchWiresharkException("creating Wireshark follow selector", selectedContext.packetIdentifier, [&] { dissect = epan_dissect_new(currentEpan, true, true); })) { return failWithCriticalExceptionLocked(std::move(*report)); } if (dissect == nullptr) { - unavailableReason = "Wireshark could not allocate the TCP stream selector."; + unavailableReason = "Wireshark could not allocate the stream selector."; return false; } - bool selectedTCP = false; + bool selectedProtocol = false; char *followFilter = nullptr; uint32_t cumulativeBytesForPacket = frame->cum_bytes >= frame->pkt_len ? frame->cum_bytes - frame->pkt_len : 0; nstime_t elapsed = NSTIME_INIT_ZERO; const frame_data *reference = nullptr; wtap_block_t block = record.get()->block != nullptr ? wtap_block_ref(record.get()->block) : nullptr; - if (auto report = CatchWiresharkException("selecting Wireshark TCP stream", selectedContext.packetIdentifier, [&] { + if (auto report = CatchWiresharkException("selecting Wireshark stream", selectedContext.packetIdentifier, [&] { frame_data_set_before_dissect(frame, &elapsed, &reference, nullptr); epan_dissect_run(dissect, WTAP_FILE_TYPE_SUBTYPE_UNKNOWN, record.get(), frame, nullptr); frame_data_set_after_dissect(frame, &cumulativeBytesForPacket); const int protocolID = get_follow_proto_id(tcpFollower); - selectedTCP = proto_is_frame_protocol( + selectedProtocol = proto_is_frame_protocol( dissect->pi.layers, proto_get_protocol_filter_name(protocolID) ); - if (selectedTCP) { + if (selectedProtocol) { unsigned streamNumber = 0; unsigned substreamNumber = 0; followFilter = get_follow_conv_func(tcpFollower)( @@ -1750,28 +1765,28 @@ struct TCPViewerWiresharkSession { if (followFilter != nullptr) { g_free(followFilter); } - FreeEpanDissect(dissect, "freeing Wireshark TCP follow selector", selectedContext.packetIdentifier); + FreeEpanDissect(dissect, "freeing Wireshark follow selector", selectedContext.packetIdentifier); return failWithCriticalExceptionLocked(std::move(*report)); } record.get()->block = block; - if (auto cleanupReport = FreeEpanDissect(dissect, "freeing Wireshark TCP follow selector", selectedContext.packetIdentifier)) { + if (auto cleanupReport = FreeEpanDissect(dissect, "freeing Wireshark follow selector", selectedContext.packetIdentifier)) { if (followFilter != nullptr) { g_free(followFilter); } return failWithCriticalExceptionLocked(std::move(*cleanupReport)); } - if (!selectedTCP || followFilter == nullptr || followFilter[0] == '\0') { + if (!selectedProtocol || followFilter == nullptr || followFilter[0] == '\0') { if (followFilter != nullptr) { g_free(followFilter); } - unavailableReason = "Select a TCP packet to follow its stream."; + unavailableReason = "The selected packet does not contain this protocol stream."; return false; } followInfo = g_try_new0(follow_info_t, 1); if (followInfo == nullptr) { g_free(followFilter); - unavailableReason = "TCP stream follower state could not be allocated."; + unavailableReason = "Stream follower state could not be allocated."; return false; } followInfo->show_stream = BOTH_HOSTS; @@ -1782,7 +1797,7 @@ struct TCPViewerWiresharkSession { follow_info_free(followInfo); followInfo = nullptr; tcpFollower = nullptr; - unavailableReason = "TCP stream tap context could not be allocated."; + unavailableReason = "Stream tap context could not be allocated."; return false; } followTapContext->session = this; @@ -1798,7 +1813,7 @@ struct TCPViewerWiresharkSession { ); if (registrationError != nullptr) { unavailableReason = registrationError->str == nullptr || registrationError->str[0] == '\0' - ? "Wireshark could not register the TCP follow listener." + ? "Wireshark could not register the follow listener." : registrationError->str; g_string_free(registrationError, TRUE); g_free(followTapContext); @@ -1816,7 +1831,12 @@ struct TCPViewerWiresharkSession { followReferenceFrame = frame_data{}; nstime_set_zero(&followElapsedTime); followTruncated = false; + followUsesPerDirectionLimit = std::strcmp(protocolName, "TCP") != 0; followPayloadByteCount = 0; + followObservedByteCountByDirection[0] = 0; + followObservedByteCountByDirection[1] = 0; + followRetainedPayloadByDirection[0].clear(); + followRetainedPayloadByDirection[1].clear(); followNewestPayloadItem = nullptr; return true; } @@ -1892,15 +1912,51 @@ struct TCPViewerWiresharkSession { return TCPViewerWiresharkFollowPacketFailed; } - // Wireshark does not add released out-of-order fragments to bytes_written, so count new payload records directly. + // Wireshark prepends records, so drain each packet's new records from oldest to newest. + GList *oldestNewPayloadItem = nullptr; for (GList *item = followInfo->payload; item != followNewestPayloadItem; item = g_list_next(item)) { + oldestNewPayloadItem = item; + } + for (GList *item = oldestNewPayloadItem; item != nullptr;) { + GList *nextItem = g_list_previous(item); auto *record = static_cast(item->data); if (record != nullptr && record->data != nullptr) { - followPayloadByteCount += record->data->len; + const size_t byteCount = record->data->len; + followPayloadByteCount += byteCount; + if (followUsesPerDirectionLimit) { + const size_t direction = record->is_server ? 1 : 0; + followObservedByteCountByDirection[direction] += byteCount; + auto &payload = followRetainedPayloadByDirection[direction]; + const size_t retained = payload.size(); + const size_t remaining = retained >= maximumPayloadBytes ? 0 : maximumPayloadBytes - retained; + const size_t retainedByteCount = std::min(byteCount, remaining); + if (retainedByteCount > 0) { + try { + payload.insert(payload.end(), record->data->data, record->data->data + retainedByteCount); + } catch (const std::bad_alloc &) { + unavailableReason = "Decrypted stream payload could not be allocated."; + return TCPViewerWiresharkFollowPacketFailed; + } + } + if (retainedByteCount < byteCount) { + followTruncated = true; + } + // Decrypted output needs only two directional byte streams, so release Wireshark's record immediately. + followInfo->payload = g_list_delete_link(followInfo->payload, item); + g_byte_array_free(record->data, true); + g_free(record); + } } + item = nextItem; } followNewestPayloadItem = followInfo->payload; - if (followPayloadByteCount > maximumPayloadBytes) { + if (followUsesPerDirectionLimit + && followRetainedPayloadByDirection[0].size() >= maximumPayloadBytes + && followRetainedPayloadByDirection[1].size() >= maximumPayloadBytes) { + followTruncated = true; + return TCPViewerWiresharkFollowPacketLimitReached; + } + if (!followUsesPerDirectionLimit && followPayloadByteCount > maximumPayloadBytes) { followTruncated = true; return TCPViewerWiresharkFollowPacketLimitReached; } @@ -1963,12 +2019,19 @@ struct TCPViewerWiresharkSession { result->clientByteCount += record->data->len; } } + if (followUsesPerDirectionLimit) { + result->clientByteCount = followObservedByteCountByDirection[0]; + result->serverByteCount = followObservedByteCountByDirection[1]; + } - const size_t availableRecordCount = static_cast(g_list_length(followInfo->payload)); + const size_t availableRecordCount = followUsesPerDirectionLimit + ? static_cast(!followRetainedPayloadByDirection[0].empty()) + + static_cast(!followRetainedPayloadByDirection[1].empty()) + : static_cast(g_list_length(followInfo->payload)); const size_t allocatedRecordCount = std::min(availableRecordCount, maximumRecordCount); result->recordCount = allocatedRecordCount; result->isTruncated = followTruncated - || followPayloadByteCount > maximumPayloadBytes + || (!followUsesPerDirectionLimit && followPayloadByteCount > maximumPayloadBytes) || availableRecordCount > maximumRecordCount; if (allocatedRecordCount > 0) { result->records = static_cast( @@ -1983,37 +2046,57 @@ struct TCPViewerWiresharkSession { } size_t outputIndex = 0; - size_t remainingPayloadBytes = maximumPayloadBytes; - for (GList *item = g_list_last(followInfo->payload); - item != nullptr && outputIndex < allocatedRecordCount && remainingPayloadBytes > 0; - item = g_list_previous(item)) { - auto *source = static_cast(item->data); - if (source == nullptr || source->data == nullptr || source->data->len == 0) { - continue; - } - auto &destination = result->records[outputIndex]; - destination.isServer = source->is_server; - destination.packetIdentifier = source->packet_num < packetIdentifierByFrameNumber.size() - ? packetIdentifierByFrameNumber[source->packet_num] - : static_cast(source->packet_num); - destination.sequenceNumber = source->seq; - destination.timestampSeconds = source->abs_ts.secs; - destination.timestampNanoseconds = source->abs_ts.nsecs; - destination.byteCount = std::min(static_cast(source->data->len), remainingPayloadBytes); - if (destination.byteCount > 0) { + if (followUsesPerDirectionLimit) { + for (size_t direction = 0; direction < 2 && outputIndex < allocatedRecordCount; direction += 1) { + const auto &source = followRetainedPayloadByDirection[direction]; + if (source.empty()) { + continue; + } + auto &destination = result->records[outputIndex]; + destination.isServer = direction == 1; + destination.byteCount = source.size(); destination.bytes = static_cast(std::malloc(destination.byteCount)); if (destination.bytes == nullptr) { result->errorMessage = CopyCString("TCP stream payload could not be allocated.", false); cancelFollowLocked(); return result; } - std::memcpy(destination.bytes, source->data->data, destination.byteCount); + std::memcpy(destination.bytes, source.data(), destination.byteCount); + outputIndex += 1; } - if (destination.byteCount < source->data->len) { - result->isTruncated = true; + } else { + size_t remainingPayloadBytes = maximumPayloadBytes; + for (GList *item = g_list_last(followInfo->payload); + item != nullptr && outputIndex < allocatedRecordCount && remainingPayloadBytes > 0; + item = g_list_previous(item)) { + auto *source = static_cast(item->data); + if (source == nullptr || source->data == nullptr || source->data->len == 0) { + continue; + } + auto &destination = result->records[outputIndex]; + destination.isServer = source->is_server; + destination.packetIdentifier = source->packet_num < packetIdentifierByFrameNumber.size() + ? packetIdentifierByFrameNumber[source->packet_num] + : static_cast(source->packet_num); + destination.sequenceNumber = source->seq; + destination.timestampSeconds = source->abs_ts.secs; + destination.timestampNanoseconds = source->abs_ts.nsecs; + destination.byteCount = std::min(static_cast(source->data->len), remainingPayloadBytes); + if (destination.byteCount > 0) { + destination.bytes = static_cast(std::malloc(destination.byteCount)); + if (destination.bytes == nullptr) { + result->errorMessage = CopyCString("TCP stream payload could not be allocated.", false); + cancelFollowLocked(); + return result; + } + std::memcpy(destination.bytes, source->data->data, destination.byteCount); + } + if (destination.byteCount < source->data->len) { + result->isTruncated = true; + } + remainingPayloadBytes -= destination.byteCount; + outputIndex += 1; } - remainingPayloadBytes -= destination.byteCount; - outputIndex += 1; } result->recordCount = outputIndex; @@ -2026,7 +2109,12 @@ struct TCPViewerWiresharkSession { followReferenceFrame = frame_data{}; nstime_set_zero(&followElapsedTime); followTruncated = false; + followUsesPerDirectionLimit = false; followPayloadByteCount = 0; + followObservedByteCountByDirection[0] = 0; + followObservedByteCountByDirection[1] = 0; + followRetainedPayloadByDirection[0].clear(); + followRetainedPayloadByDirection[1].clear(); followNewestPayloadItem = nullptr; return result; } @@ -2171,6 +2259,65 @@ struct TCPViewerWiresharkSession { } }; +bool TCPViewerWiresharkConfigureTLSKeyLog( + const char *filePath, + const char *personalConfigurationDirectory, + char **errorMessage +) { + if (errorMessage != nullptr) { + *errorMessage = nullptr; + } + if (personalConfigurationDirectory == nullptr || personalConfigurationDirectory[0] == '\0') { + if (errorMessage != nullptr) { + *errorMessage = CopyCString("Wireshark configuration is unavailable.", false); + } + return false; + } + + auto &runtime = WiresharkRuntime::shared(personalConfigurationDirectory); + if (!runtime.isAvailable()) { + if (errorMessage != nullptr) { + *errorMessage = CopyCString(runtime.unavailableReason(), false); + } + return false; + } + + std::lock_guard apiLock(WiresharkAPIMutex()); + module_t *tlsModule = prefs_find_module("tls"); + pref_t *keyLogPreference = tlsModule == nullptr ? nullptr : prefs_find_preference(tlsModule, "keylog_file"); + if (keyLogPreference == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = CopyCString("This Wireshark build does not expose the TLS key-log preference.", false); + } + return false; + } + + const std::string nextPath = filePath == nullptr ? std::string() : std::string(filePath); + if (auto report = CatchWiresharkException("applying the TLS key-log preference", std::nullopt, [&] { + prefs_set_string_value(keyLogPreference, nextPath.c_str(), pref_current); + prefs_apply(tlsModule); + })) { + if (errorMessage != nullptr) { + *errorMessage = CopyCString("Wireshark could not apply the TLS key-log preference.", false); + } + return false; + } + + TLSKeyLogPath() = nextPath; + return true; +} + +bool TCPViewerWiresharkHasTLSKeyLog(void) +{ + std::lock_guard apiLock(WiresharkAPIMutex()); + return !TLSKeyLogPath().empty(); +} + +void TCPViewerWiresharkCStringDestroy(char *value) +{ + std::free(value); +} + TCPViewerWiresharkSession *TCPViewerWiresharkSessionCreate(bool disabled, bool livePriority, const char *personalConfigurationDirectory) { return new TCPViewerWiresharkSession(disabled, livePriority, personalConfigurationDirectory); @@ -2425,13 +2572,21 @@ TCPViewerWiresharkInspectionResult *TCPViewerWiresharkSessionInspectPacket(TCPVi bool TCPViewerWiresharkSessionBeginFollowTCPStream(TCPViewerWiresharkSession *session, const TCPViewerWiresharkPacketContext *selectedContext) { - if (session == nullptr || selectedContext == nullptr) { + return TCPViewerWiresharkSessionBeginFollowStream(session, selectedContext, "TCP"); +} + +bool TCPViewerWiresharkSessionBeginFollowStream( + TCPViewerWiresharkSession *session, + const TCPViewerWiresharkPacketContext *selectedContext, + const char *protocolName +) { + if (session == nullptr || selectedContext == nullptr || protocolName == nullptr || protocolName[0] == '\0') { return false; } std::lock_guard apiLock(WiresharkAPIMutex()); std::lock_guard sessionLock(session->mutex); session->clearCriticalExceptionsLocked(); - return session->beginTCPFollowLocked(ContextViewFromC(selectedContext)); + return session->beginFollowLocked(ContextViewFromC(selectedContext), protocolName); } TCPViewerWiresharkFollowPacketStatus TCPViewerWiresharkSessionProcessFollowPacket( diff --git a/PcapPlusPlusCore/Dissection/WiresharkEpanShim.h b/PcapPlusPlusCore/Dissection/WiresharkEpanShim.h index a554bc7..7f72921 100644 --- a/PcapPlusPlusCore/Dissection/WiresharkEpanShim.h +++ b/PcapPlusPlusCore/Dissection/WiresharkEpanShim.h @@ -120,6 +120,14 @@ typedef enum TCPViewerWiresharkFollowPacketStatus { TCPViewerWiresharkFollowPacketLimitReached = 1, } TCPViewerWiresharkFollowPacketStatus; +bool TCPViewerWiresharkConfigureTLSKeyLog( + const char *filePath, + const char *personalConfigurationDirectory, + char **errorMessage +); +bool TCPViewerWiresharkHasTLSKeyLog(void); +void TCPViewerWiresharkCStringDestroy(char *value); + typedef struct TCPViewerWiresharkExceptionReport { bool isCriticalException; unsigned long exceptionGroup; @@ -158,6 +166,11 @@ bool TCPViewerWiresharkSessionTCPStreamIdentifier( TCPViewerWiresharkSummaryResult *TCPViewerWiresharkSessionSummarizePacket(TCPViewerWiresharkSession *session, const TCPViewerWiresharkPacketContext *context); TCPViewerWiresharkInspectionResult *TCPViewerWiresharkSessionInspectPacket(TCPViewerWiresharkSession *session, const TCPViewerWiresharkPacketContext *context); bool TCPViewerWiresharkSessionBeginFollowTCPStream(TCPViewerWiresharkSession *session, const TCPViewerWiresharkPacketContext *selectedContext); +bool TCPViewerWiresharkSessionBeginFollowStream( + TCPViewerWiresharkSession *session, + const TCPViewerWiresharkPacketContext *selectedContext, + const char *protocolName +); TCPViewerWiresharkFollowPacketStatus TCPViewerWiresharkSessionProcessFollowPacket( TCPViewerWiresharkSession *session, const TCPViewerWiresharkPacketContext *context, diff --git a/PcapPlusPlusCore/Models/CoreProtocols.swift b/PcapPlusPlusCore/Models/CoreProtocols.swift index 16e4e00..a4b98f5 100644 --- a/PcapPlusPlusCore/Models/CoreProtocols.swift +++ b/PcapPlusPlusCore/Models/CoreProtocols.swift @@ -56,7 +56,7 @@ public protocol CaptureFilterValidating { func validateCaptureFilter(_ expression: String, completion: @escaping (CaptureFilterValidation) -> Void) } -public protocol LiveCaptureSessionProviding: TCPStreamFollowing { +public protocol LiveCaptureSessionProviding: TCPStreamFollowing, DecryptedStreamLoading { var eventHandler: PacketIngestEventHandler? { get set } func start(completion: @escaping TCPViewerVoidCompletion) @@ -96,7 +96,7 @@ public extension LiveCaptureSessionProviding { } #endif -public protocol OfflineCaptureDocumentProviding: TCPStreamFollowing { +public protocol OfflineCaptureDocumentProviding: TCPStreamFollowing, DecryptedStreamLoading { var eventHandler: PacketIngestEventHandler? { get set } func open(completion: @escaping TCPViewerCompletion<[PacketSummary]>) diff --git a/PcapPlusPlusCore/Models/DecryptedStream.swift b/PcapPlusPlusCore/Models/DecryptedStream.swift new file mode 100644 index 0000000..5eb60c1 --- /dev/null +++ b/PcapPlusPlusCore/Models/DecryptedStream.swift @@ -0,0 +1,88 @@ +// +// DecryptedStream.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import Foundation + +public enum DecryptedStreamProtocol: String, Sendable, Codable, Hashable { + case tls = "TLS" + case dtls = "DTLS" + case quic = "QUIC" +} + +public struct DecryptedStreamPayload: Sendable, Codable, Hashable { + public let data: Data + public let observedByteCount: Int + public let isTruncated: Bool + + public init(data: Data, observedByteCount: Int, isTruncated: Bool) { + self.data = data + self.observedByteCount = observedByteCount + self.isTruncated = isTruncated + } +} + +public struct DecryptedStreamResult: Sendable, Codable, Hashable { + public let protocolName: DecryptedStreamProtocol + public let client: PacketEndpoint + public let server: PacketEndpoint + public let request: DecryptedStreamPayload + public let response: DecryptedStreamPayload + + public init( + protocolName: DecryptedStreamProtocol, + client: PacketEndpoint, + server: PacketEndpoint, + request: DecryptedStreamPayload, + response: DecryptedStreamPayload + ) { + self.protocolName = protocolName + self.client = client + self.server = server + self.request = request + self.response = response + } +} + +public struct DecryptedStreamLimits: Sendable, Equatable, Hashable { + public let maximumBytesPerDirection: Int + public let maximumRecordCount: Int + + public init( + maximumBytesPerDirection: Int = 8 * 1_024 * 1_024, + maximumRecordCount: Int = 100_000 + ) { + self.maximumBytesPerDirection = max(maximumBytesPerDirection, 1) + self.maximumRecordCount = max(maximumRecordCount, 1) + } + + public static let `default` = DecryptedStreamLimits() +} + +public protocol DecryptedStreamLoading: AnyObject { + func loadDecryptedStream( + containing packetID: PacketSummary.ID, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + completion: @escaping TCPViewerCompletion + ) +} + +public extension DecryptedStreamLoading { + func loadDecryptedStream( + containing packetID: PacketSummary.ID, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + completion: @escaping TCPViewerCompletion + ) { + completion(.failure(TCPViewerCoreError( + code: .unavailableFeature, + message: "TLS stream decryption is unavailable for this capture source." + ))) + } +} diff --git a/PcapPlusPlusCore/Models/PacketModels.swift b/PcapPlusPlusCore/Models/PacketModels.swift index c7fa663..db6774c 100644 --- a/PcapPlusPlusCore/Models/PacketModels.swift +++ b/PcapPlusPlusCore/Models/PacketModels.swift @@ -17,7 +17,11 @@ public enum TransportProtocolHint: String, Sendable, Codable { case udp case dns case http1 + case http2 + case http3 case tls + case dtls + case quic case websocket case payload case unknown diff --git a/PcapPlusPlusCore/Models/TLSKeyLog.swift b/PcapPlusPlusCore/Models/TLSKeyLog.swift new file mode 100644 index 0000000..ef2166d --- /dev/null +++ b/PcapPlusPlusCore/Models/TLSKeyLog.swift @@ -0,0 +1,41 @@ +// +// TLSKeyLog.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import Foundation + +public struct TLSKeyLogValidation: Sendable, Equatable { + public let validRecordCount: Int + public let warningCount: Int + public let scannedLineCount: Int + public let reachedScanLimit: Bool + + public init(validRecordCount: Int, warningCount: Int, scannedLineCount: Int, reachedScanLimit: Bool) { + self.validRecordCount = validRecordCount + self.warningCount = warningCount + self.scannedLineCount = scannedLineCount + self.reachedScanLimit = reachedScanLimit + } +} + +public struct TLSKeyLogState: Sendable, Equatable { + public let fileURL: URL? + public let validation: TLSKeyLogValidation? + + public init(fileURL: URL?, validation: TLSKeyLogValidation?) { + self.fileURL = fileURL + self.validation = validation + } + + public static let empty = TLSKeyLogState(fileURL: nil, validation: nil) +} + +public protocol TLSKeyLogManaging: AnyObject { + func validate(fileURL: URL, completion: @escaping TCPViewerCompletion) + func apply(fileURL: URL, completion: @escaping TCPViewerCompletion) + func remove(completion: @escaping TCPViewerCompletion) + func currentState(completion: @escaping (TLSKeyLogState) -> Void) +} diff --git a/PcapPlusPlusCore/NativeBridge/NativeBridgeSupport.swift b/PcapPlusPlusCore/NativeBridge/NativeBridgeSupport.swift index 26c2d38..d7d7307 100644 --- a/PcapPlusPlusCore/NativeBridge/NativeBridgeSupport.swift +++ b/PcapPlusPlusCore/NativeBridge/NativeBridgeSupport.swift @@ -112,6 +112,14 @@ enum NativeBridgeMapper { .payload case 12: .unknown + case 13: + .http2 + case 14: + .http3 + case 15: + .dtls + case 16: + .quic default: .unknown } diff --git a/PcapPlusPlusCore/NativeBridge/NativeBridgeTypes.swift b/PcapPlusPlusCore/NativeBridge/NativeBridgeTypes.swift index 1e10240..6890d70 100644 --- a/PcapPlusPlusCore/NativeBridge/NativeBridgeTypes.swift +++ b/PcapPlusPlusCore/NativeBridge/NativeBridgeTypes.swift @@ -42,6 +42,10 @@ enum PCPPNativeTransportHint: Int { case websocket = 10 case payload = 11 case unknown = 12 + case http2 = 13 + case http3 = 14 + case dtls = 15 + case quic = 16 } enum PCPPNativeDecodeStatusKind: Int { diff --git a/PcapPlusPlusCore/Services/Core/NativeTLSKeyLogManager.swift b/PcapPlusPlusCore/Services/Core/NativeTLSKeyLogManager.swift new file mode 100644 index 0000000..cfe874f --- /dev/null +++ b/PcapPlusPlusCore/Services/Core/NativeTLSKeyLogManager.swift @@ -0,0 +1,238 @@ +// +// NativeTLSKeyLogManager.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import Foundation +@_implementationOnly import TCPViewerWiresharkEpanShim + +public final class NativeTLSKeyLogManager: TLSKeyLogManaging, @unchecked Sendable { + private enum Limits { + static let maximumBytes = 4 * 1_024 * 1_024 + static let maximumLineCount = 20_000 + static let readChunkSize = 64 * 1_024 + } + + private let queue: DispatchQueue + private let runtimeConfiguration: WiresharkRuntimeConfiguration + private var state = TLSKeyLogState.empty + + public init() { + self.queue = DispatchQueue(label: "com.proxyman.tcpviewer.PcapPlusPlusCore.TLSKeyLog", qos: .userInitiated) + self.runtimeConfiguration = WiresharkRuntimeConfiguration() + } + + init(queue: DispatchQueue, runtimeConfiguration: WiresharkRuntimeConfiguration) { + self.queue = queue + self.runtimeConfiguration = runtimeConfiguration + } + + public func validate(fileURL: URL, completion: @escaping TCPViewerCompletion) { + queue.async { + completion(Result { try Self.validateFile(at: fileURL) }) + } + } + + public func apply(fileURL: URL, completion: @escaping TCPViewerCompletion) { + queue.async { + completion(Result { + let validation = try Self.validateFile(at: fileURL) + try self.configureWireshark(filePath: fileURL.path) + let nextState = TLSKeyLogState(fileURL: fileURL, validation: validation) + self.state = nextState + return nextState + }) + } + } + + public func remove(completion: @escaping TCPViewerCompletion) { + queue.async { + completion(Result { + try self.configureWireshark(filePath: nil) + let nextState = TLSKeyLogState.empty + self.state = nextState + return nextState + }) + } + } + + public func currentState(completion: @escaping (TLSKeyLogState) -> Void) { + queue.async { + completion(self.state) + } + } + + // Scan complete lines only because key-log producers can be appending the final record. + static func validateFile(at fileURL: URL) throws -> TLSKeyLogValidation { + let values: URLResourceValues + do { + values = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .isReadableKey]) + } catch { + throw invalidFile("TCP Viewer cannot access the selected TLS key-log file.") + } + guard values.isRegularFile == true else { + throw invalidFile("Choose a regular TLS key-log file, not a directory.") + } + guard values.isReadable != false else { + throw invalidFile("TCP Viewer cannot read the selected TLS key-log file.") + } + + let handle: FileHandle + do { + handle = try FileHandle(forReadingFrom: fileURL) + } catch { + throw invalidFile("TCP Viewer cannot read the selected TLS key-log file.") + } + defer { try? handle.close() } + + var pending = Data() + var scannedBytes = 0 + var scannedLines = 0 + var validRecords = 0 + var warnings = 0 + var reachedLimit = false + + while scannedBytes < Limits.maximumBytes && scannedLines < Limits.maximumLineCount { + let requestedCount = min(Limits.readChunkSize, Limits.maximumBytes - scannedBytes) + let chunk: Data + do { + chunk = try handle.read(upToCount: requestedCount) ?? Data() + } catch { + throw invalidFile("TCP Viewer could not finish reading the selected TLS key-log file.") + } + guard !chunk.isEmpty else { + break + } + scannedBytes += chunk.count + pending.append(chunk) + + while scannedLines < Limits.maximumLineCount, + let newlineIndex = pending.firstIndex(of: 0x0A) { + var line = pending[.. 0 else { + throw invalidFile("No key records recognized by this Wireshark build were found. Syntax validation cannot prove that keys match a capture.") + } + return TLSKeyLogValidation( + validRecordCount: validRecords, + warningCount: warnings, + scannedLineCount: scannedLines, + reachedScanLimit: reachedLimit + ) + } + + private enum LineClassification { + case ignored + case valid + case warning + } + + private static func classify(line: Data.SubSequence) -> LineClassification { + guard let value = String(data: Data(line), encoding: .utf8) else { + return .warning + } + let trimmed = value.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { + return .ignored + } + + if trimmed.hasPrefix("RSA Session-ID:") { + return validateRSASessionLine(trimmed) ? .valid : .warning + } + let fields = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) + guard fields.count == 3 else { + return .warning + } + let label = fields[0] + let identifier = fields[1] + let secret = fields[2] + guard isEvenHex(identifier), isEvenHex(secret) else { + return .warning + } + + switch label { + case "PMS_CLIENT_RANDOM": + return identifier.count == 64 ? .valid : .warning + case "RSA": + return identifier.count == 16 ? .valid : .warning + case "CLIENT_RANDOM": + return identifier.count == 64 && secret.count == 96 ? .valid : .warning + case "CLIENT_EARLY_TRAFFIC_SECRET", "CLIENT_HANDSHAKE_TRAFFIC_SECRET", + "SERVER_HANDSHAKE_TRAFFIC_SECRET", "CLIENT_TRAFFIC_SECRET_0", + "SERVER_TRAFFIC_SECRET_0", "EARLY_EXPORTER_SECRET", "EXPORTER_SECRET": + return identifier.count == 64 ? .valid : .warning + case "ECH_SECRET": + return (64...128).contains(identifier.count) ? .valid : .warning + case "ECH_CONFIG": + return identifier.count >= 44 ? .valid : .warning + default: + return .warning + } + } + + private static func validateRSASessionLine(_ value: String) -> Bool { + let prefix = "RSA Session-ID:" + let separator = " Master-Key:" + guard let separatorRange = value.range(of: separator) else { + return false + } + let sessionID = String(value[value.index(value.startIndex, offsetBy: prefix.count).. Bool { + !value.isEmpty && value.count.isMultiple(of: 2) && value.unicodeScalars.allSatisfy { + (48...57).contains($0.value) || (65...70).contains($0.value) || (97...102).contains($0.value) + } + } + + private func configureWireshark(filePath: String?) throws { + let directory: URL + do { + directory = try runtimeConfiguration.createPersonalConfigurationDirectoryIfNeeded() + } catch { + throw Self.invalidFile("TCP Viewer could not prepare its Wireshark runtime.") + } + + var errorPointer: UnsafeMutablePointer? + let succeeded = directory.path.withCString { directoryPath in + guard let filePath else { + return TCPViewerWiresharkConfigureTLSKeyLog(nil, directoryPath, &errorPointer) + } + return filePath.withCString { path in + TCPViewerWiresharkConfigureTLSKeyLog(path, directoryPath, &errorPointer) + } + } + defer { TCPViewerWiresharkCStringDestroy(errorPointer) } + guard succeeded else { + let message = errorPointer.map { String(cString: $0) } + ?? "Wireshark could not apply the TLS key-log file." + throw Self.invalidFile(message) + } + } + + private static func invalidFile(_ message: String) -> TCPViewerCoreError { + TCPViewerCoreError(code: .unavailableFeature, message: message) + } +} diff --git a/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift b/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift index 69cd90f..8a60b81 100644 --- a/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift +++ b/PcapPlusPlusCore/Services/Core/SwiftNativeCore.swift @@ -299,6 +299,50 @@ final class PCPPNativeOfflineDocument { ) } + // Explicit inspector loading may replay the capture once; live ingestion never calls this path. + func loadDecryptedStream( + containing identifier: UInt64, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck? + ) throws -> DecryptedStreamResult { + let snapshot = try state.read { state -> (NativePacketRecord, [NativePacketRecord], WiresharkEpanSession) in + guard let selected = state.file.records.first(where: { $0.identifier == identifier }) else { + throw NativeNSError(.fileReadFailed, "Packet \(identifier) is not available in the backing store.") + } + guard let session = state.dissectionSession else { + throw NativeNSError(.unavailableFeature, "Wireshark TLS stream decryption is unavailable for this capture.") + } + return (selected, state.file.records, session) + } + let identifiers = snapshot.1.map(\.identifier) + let fields: WiresharkDecryptedFollowFields + if snapshot.2.canFollowObservedPackets(withIdentifiers: identifiers) { + fields = try snapshot.2.followObservedDecryptedStream( + containing: snapshot.0, + records: snapshot.1, + limits: limits, + progress: progress, + shouldCancel: shouldCancel + ) + } else { + fields = try WiresharkEpanSession.followDecryptedStreamInTemporarySession( + containing: snapshot.0, + records: snapshot.1, + limits: limits, + progress: progress, + shouldCancel: shouldCancel + ) + } + return DecryptedStreamResult( + protocolName: fields.protocolName, + client: fields.client, + server: fields.server, + request: fields.request, + response: fields.response + ) + } + func save() throws { let snapshot = state.read { ($0.file, $0.currentURL) } try NativeCaptureFile.write(records: snapshot.0.records, to: snapshot.1, format: snapshot.0.format) @@ -865,6 +909,43 @@ final class PCPPNativeLiveSession { ) } + func loadDecryptedStream( + containing identifier: UInt64, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck? + ) throws -> DecryptedStreamResult { + let snapshot = try state.read { state -> NativeLivePacketDiskSnapshot in + guard state.phase == .stopped else { + throw NativeNSError(.unavailableFeature, "Stop the live capture to load the complete decrypted stream.") + } + guard state.hadWorkingDissectionSession else { + throw NativeNSError(.unavailableFeature, "Wireshark TLS stream decryption is unavailable for this capture.") + } + return try state.packetStore.snapshotAll( + shouldCancel: shouldCancel + ) + } + let selected = try snapshot.record(withIdentifier: identifier) + let fields = try WiresharkEpanSession.followDecryptedStreamInTemporarySession( + containing: selected, + recordCount: snapshot.packetCount, + replay: { consume in + try snapshot.replayRecords(shouldCancel: shouldCancel, consume) + }, + limits: limits, + progress: progress, + shouldCancel: shouldCancel + ) + return DecryptedStreamResult( + protocolName: fields.protocolName, + client: fields.client, + server: fields.server, + request: fields.request, + response: fields.response + ) + } + func reanalyzePacketSummaries() throws -> [PCPPNativePacketSummaryDescriptor] { try reanalyzePacketSummaries(withIdentifiers: nil) } @@ -1150,6 +1231,18 @@ private func transportHint(analyzed: AnalyzedPacket, wireshark: WiresharkPacketS // Wireshark has conversation/reassembly state that the metadata analyzer intentionally does not keep. // Let epan's decoded protocol win for app-level hints when it has stronger evidence. + if protocolSummary.contains("http3") || protocolSummary.contains("http/3") { + return .http3 + } + if protocolSummary.contains("http2") || protocolSummary.contains("http/2") { + return .http2 + } + if protocolSummary.contains("quic") { + return .quic + } + if protocolSummary.contains("dtls") { + return .dtls + } if wireshark.sniDomainName?.isEmpty == false || protocolSummary.contains("tls") || infoSummary.contains("client hello") diff --git a/PcapPlusPlusCore/Services/LiveCapture/NativeLiveCaptureSession.swift b/PcapPlusPlusCore/Services/LiveCapture/NativeLiveCaptureSession.swift index 82dff00..e209f1f 100644 --- a/PcapPlusPlusCore/Services/LiveCapture/NativeLiveCaptureSession.swift +++ b/PcapPlusPlusCore/Services/LiveCapture/NativeLiveCaptureSession.swift @@ -65,6 +65,22 @@ public final class NativeLiveCaptureSession: LiveCaptureSessionProviding, @unche ) } + public func loadDecryptedStream( + containing packetID: PacketSummary.ID, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + completion: @escaping TCPViewerCompletion + ) { + state.loadDecryptedStream( + containing: packetID, + limits: limits, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + } + public func exportPackets( withIDs identifiers: [PacketSummary.ID], to url: URL, @@ -502,6 +518,37 @@ private final class NativeLiveCaptureSessionState: @unchecked Sendable { } } + func loadDecryptedStream( + containing packetID: PacketSummary.ID, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + completion: @escaping TCPViewerCompletion + ) { + guard followOperationCoordinator.beginFollow() else { + completion(.failure(TCPViewerCoreError(code: .operationCancelled, message: "TLS stream decryption was cancelled for a capture lifecycle change."))) + return + } + followQueue.async { + let result = Result { + do { + return try self.nativeSession.loadDecryptedStream( + containing: packetID, + limits: limits, + progress: progress, + shouldCancel: { + self.followOperationCoordinator.shouldCancel || shouldCancel?() == true + } + ) + } catch { + throw NativeBridgeMapper.coreError(error, defaultCode: .unavailableFeature) + } + } + self.followOperationCoordinator.finishFollow() + completion(result) + } + } + func exportPackets( withIDs identifiers: [PacketSummary.ID], to url: URL, diff --git a/PcapPlusPlusCore/Services/LiveCapture/NativeLivePacketDiskStore.swift b/PcapPlusPlusCore/Services/LiveCapture/NativeLivePacketDiskStore.swift index 2b3c814..5f2c3f5 100644 --- a/PcapPlusPlusCore/Services/LiveCapture/NativeLivePacketDiskStore.swift +++ b/PcapPlusPlusCore/Services/LiveCapture/NativeLivePacketDiskStore.swift @@ -43,6 +43,32 @@ final class NativeLivePacketDiskSnapshot: @unchecked Sendable { Darwin.close(fileDescriptor) } + var packetCount: Int { + entries.count + } + + func record(withIdentifier identifier: UInt64) throws -> NativePacketRecord { + guard let entry = entries.first(where: { $0.identifier == identifier }) else { + throw NativeNSError(.fileReadFailed, "Packet \(identifier) is not available in the live snapshot.") + } + return try record(for: entry) + } + + // Rehydrate one packet at a time so a full stopped capture stays disk-backed during replay. + func replayRecords( + shouldCancel: TCPFollowCancellationCheck? = nil, + _ consume: (NativePacketRecord) throws -> Bool + ) throws { + for entry in entries { + if shouldCancel?() == true { + throw NativeNSError(.operationCancelled, "TLS stream decryption was cancelled.") + } + if try !consume(record(for: entry)) { + break + } + } + } + // Rehydrate a bounded immutable snapshot without holding the live capture lock. func records( maximumBytes: Int, @@ -59,27 +85,30 @@ final class NativeLivePacketDiskSnapshot: @unchecked Sendable { throw NativeNSError(.unavailableFeature, "The TCP stream snapshot exceeds the \(maximumBytes)-byte input limit.") } remainingBytes -= entry.capturedLength - let bytes = try readBytes(for: entry) - records.append(NativePacketRecord( - identifier: entry.identifier, - packetNumber: entry.packetNumber, - timestamp: entry.timestamp, - rawBytes: bytes, - originalLength: entry.originalLength, - linkLayerType: entry.linkLayerType, - interfaceIdentifier: entry.interfaceIdentifier, - interfaceName: entry.interfaceName, - packetComment: entry.packetComment, - interfaceID: entry.interfaceID, - sectionNumber: entry.sectionNumber, - pcapNGTimestampResolution: entry.pcapNGTimestampResolution, - pcapNGTimestampOffsetSeconds: entry.pcapNGTimestampOffsetSeconds, - pcapNGTimestampRawValue: entry.pcapNGTimestampRawValue - )) + records.append(try record(for: entry)) } return records } + private func record(for entry: NativeLivePacketDiskEntry) throws -> NativePacketRecord { + NativePacketRecord( + identifier: entry.identifier, + packetNumber: entry.packetNumber, + timestamp: entry.timestamp, + rawBytes: try readBytes(for: entry), + originalLength: entry.originalLength, + linkLayerType: entry.linkLayerType, + interfaceIdentifier: entry.interfaceIdentifier, + interfaceName: entry.interfaceName, + packetComment: entry.packetComment, + interfaceID: entry.interfaceID, + sectionNumber: entry.sectionNumber, + pcapNGTimestampResolution: entry.pcapNGTimestampResolution, + pcapNGTimestampOffsetSeconds: entry.pcapNGTimestampOffsetSeconds, + pcapNGTimestampRawValue: entry.pcapNGTimestampRawValue + ) + } + private func readBytes(for entry: NativeLivePacketDiskEntry) throws -> Data { var bytes = Data(count: entry.capturedLength) let bytesRead = bytes.withUnsafeMutableBytes { buffer -> Int in @@ -253,6 +282,28 @@ final class NativeLivePacketDiskStore { ) } + // Duplicate the anonymous store so stopped-capture TLS replay never holds the writer lock. + func snapshotAll( + shouldCancel: TCPFollowCancellationCheck? = nil + ) throws -> NativeLivePacketDiskSnapshot { + if shouldCancel?() == true { + throw NativeNSError(.operationCancelled, "TLS stream decryption was cancelled.") + } + try openHandlesIfNeeded() + guard let reader else { + throw NativeNSError(.fileReadFailed, "The live packet backing store could not be opened for reading.") + } + let descriptor = Darwin.dup(reader.fileDescriptor) + guard descriptor >= 0 else { + throw NativeNSError(.fileReadFailed, "The live packet backing store could not create a stable snapshot.") + } + return NativeLivePacketDiskSnapshot( + fileDescriptor: descriptor, + entries: entries, + capturedThroughPacketID: entries.last?.identifier ?? 0 + ) + } + // Rehydrate only the requested packet bytes from disk. func record(withIdentifier identifier: UInt64) throws -> NativePacketRecord { guard let index = entryIndexByID[identifier] else { diff --git a/PcapPlusPlusCore/Services/OfflineCapture/NativeOfflineCaptureDocument.swift b/PcapPlusPlusCore/Services/OfflineCapture/NativeOfflineCaptureDocument.swift index e8b7902..bfe354c 100644 --- a/PcapPlusPlusCore/Services/OfflineCapture/NativeOfflineCaptureDocument.swift +++ b/PcapPlusPlusCore/Services/OfflineCapture/NativeOfflineCaptureDocument.swift @@ -52,6 +52,22 @@ public final class NativeOfflineCaptureDocument: OfflineCaptureDocumentProviding ) } + public func loadDecryptedStream( + containing packetID: PacketSummary.ID, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + completion: @escaping TCPViewerCompletion + ) { + state.loadDecryptedStream( + containing: packetID, + limits: limits, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + } + public func save(completion: @escaping TCPViewerVoidCompletion) { state.save(completion: completion) } @@ -292,6 +308,33 @@ private final class NativeOfflineCaptureDocumentState: @unchecked Sendable { } } + func loadDecryptedStream( + containing packetID: PacketSummary.ID, + limits: DecryptedStreamLimits, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + completion: @escaping TCPViewerCompletion + ) { + let packets = packetCache.get() + followQueue.async { + completion(Result { + guard packets.contains(where: { $0.id == packetID }) else { + throw TCPViewerCoreError(code: .offlineFileOpenFailed, message: "Packet \(packetID) is not available.") + } + do { + return try self.nativeDocument.loadDecryptedStream( + containing: packetID, + limits: limits, + progress: progress, + shouldCancel: shouldCancel + ) + } catch { + throw NativeBridgeMapper.coreError(error, defaultCode: .unavailableFeature) + } + }) + } + } + func save(completion: @escaping TCPViewerVoidCompletion) { stateQueue.async { completion(Result { diff --git a/PcapPlusPlusCoreTests/Dissection/WiresharkTLSDecryptionTests.swift b/PcapPlusPlusCoreTests/Dissection/WiresharkTLSDecryptionTests.swift new file mode 100644 index 0000000..5231f3b --- /dev/null +++ b/PcapPlusPlusCoreTests/Dissection/WiresharkTLSDecryptionTests.swift @@ -0,0 +1,184 @@ +// +// WiresharkTLSDecryptionTests.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import Foundation +import Testing +@testable import PcapPlusPlusCore + +@Suite(.serialized) +struct WiresharkTLSDecryptionTests { + @Test func decryptsRFC8446TLS13IntoDirectionalStreams() throws { + let root = repositoryRoot() + let captureURL = root.appendingPathComponent("Vendor/Wireshark/test/captures/tls13-rfc8446.pcap") + let keyURL = root.appendingPathComponent("Vendor/Wireshark/test/keys/tls13-rfc8446.keys") + let manager = NativeTLSKeyLogManager() + _ = try apply(manager: manager, fileURL: keyURL) + defer { remove(manager: manager) } + + let records = try NativeCaptureFile.load(from: captureURL).records + let selected = try #require(records.first(where: { $0.identifier == 5 })) + let result = try WiresharkEpanSession.followDecryptedStreamInTemporarySession( + containing: selected, + records: records, + limits: .default, + progress: nil, + shouldCancel: nil + ) + + #expect(result.protocolName == .tls) + #expect(String(data: result.request.data, encoding: .utf8)?.contains("/first") == true) + #expect(!result.response.data.isEmpty) + } + + @Test func mismatchedKeysDoNotExposePlaintextOrCrash() throws { + let root = repositoryRoot() + let captureURL = root.appendingPathComponent("Vendor/Wireshark/test/captures/tls13-rfc8446.pcap") + let keyURL = root.appendingPathComponent("Vendor/Wireshark/test/keys/tls12-chacha20poly1305.keys") + let manager = NativeTLSKeyLogManager() + _ = try apply(manager: manager, fileURL: keyURL) + defer { remove(manager: manager) } + + let records = try NativeCaptureFile.load(from: captureURL).records + let selected = try #require(records.first(where: { $0.identifier == 5 })) + let result = try WiresharkEpanSession.followDecryptedStreamInTemporarySession( + containing: selected, + records: records, + limits: .default, + progress: nil, + shouldCancel: nil + ) + + #expect(result.request.data.isEmpty) + #expect(result.response.data.isEmpty) + } + + @Test func decryptsTLS12ChaCha20Poly1305Fixture() throws { + let root = repositoryRoot() + let captureURL = root.appendingPathComponent("Vendor/Wireshark/test/captures/tls12-chacha20poly1305.pcap") + let keyURL = root.appendingPathComponent("Vendor/Wireshark/test/keys/tls12-chacha20poly1305.keys") + let manager = NativeTLSKeyLogManager() + _ = try apply(manager: manager, fileURL: keyURL) + defer { remove(manager: manager) } + + let records = try NativeCaptureFile.load(from: captureURL).records + let session = try WiresharkEpanSession(purpose: .follow) + for record in records { + try session.observe(record) + } + try session.finishFirstPass() + let selected = try #require(records.first { record in + (try? session.summarize(record).protocolSummary?.lowercased().contains("tls")) == true + }) + let result = try session.followObservedDecryptedStream( + containing: selected, + records: records, + limits: .default, + progress: nil, + shouldCancel: nil + ) + let plaintext = result.request.data + result.response.data + + #expect(String(data: plaintext, encoding: .utf8)?.contains("Cipher is") == true) + } + + @Test func capsEachDirectionAndReportsObservedBytes() throws { + let root = repositoryRoot() + let captureURL = root.appendingPathComponent("Vendor/Wireshark/test/captures/tls13-rfc8446.pcap") + let keyURL = root.appendingPathComponent("Vendor/Wireshark/test/keys/tls13-rfc8446.keys") + let manager = NativeTLSKeyLogManager() + _ = try apply(manager: manager, fileURL: keyURL) + defer { remove(manager: manager) } + let records = try NativeCaptureFile.load(from: captureURL).records + let selected = try #require(records.first(where: { $0.identifier == 5 })) + + let result = try WiresharkEpanSession.followDecryptedStreamInTemporarySession( + containing: selected, + records: records, + limits: DecryptedStreamLimits(maximumBytesPerDirection: 4), + progress: nil, + shouldCancel: nil + ) + + #expect(result.request.data.count == 4) + #expect(result.response.data.count == 4) + #expect(result.request.observedByteCount > result.request.data.count) + #expect(result.response.observedByteCount > result.response.data.count) + #expect(result.request.isTruncated) + #expect(result.response.isTruncated) + } + + @Test func detectsSecretsAppendedToSelectedFileWithoutReapplyingPreference() throws { + let root = repositoryRoot() + let captureURL = root.appendingPathComponent("Vendor/Wireshark/test/captures/tls13-rfc8446.pcap") + let sourceKeyURL = root.appendingPathComponent("Vendor/Wireshark/test/keys/tls13-rfc8446.keys") + let keyData = try Data(contentsOf: sourceKeyURL) + let newline = try #require(keyData.firstIndex(of: 0x0A)) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let selectedKeyURL = directory.appendingPathComponent("growing.keys") + try keyData[...newline].write(to: selectedKeyURL) + let manager = NativeTLSKeyLogManager() + _ = try apply(manager: manager, fileURL: selectedKeyURL) + defer { remove(manager: manager) } + let records = try NativeCaptureFile.load(from: captureURL).records + let selected = try #require(records.first(where: { $0.identifier == 5 })) + + let beforeAppend = try WiresharkEpanSession.followDecryptedStreamInTemporarySession( + containing: selected, + records: records, + limits: .default, + progress: nil, + shouldCancel: nil + ) + let handle = try FileHandle(forWritingTo: selectedKeyURL) + try handle.seekToEnd() + try handle.write(contentsOf: keyData[keyData.index(after: newline)...]) + try handle.close() + let afterAppend = try WiresharkEpanSession.followDecryptedStreamInTemporarySession( + containing: selected, + records: records, + limits: .default, + progress: nil, + shouldCancel: nil + ) + + #expect(beforeAppend.request.data.isEmpty) + #expect(beforeAppend.response.data.isEmpty) + #expect(!afterAppend.request.data.isEmpty) + #expect(!afterAppend.response.data.isEmpty) + } + + private func repositoryRoot() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + private func apply(manager: NativeTLSKeyLogManager, fileURL: URL) throws -> TLSKeyLogState { + let semaphore = DispatchSemaphore(value: 0) + let lock = NSLock() + var storedResult: Result? + manager.apply(fileURL: fileURL) { result in + lock.lock() + storedResult = result + lock.unlock() + semaphore.signal() + } + semaphore.wait() + lock.lock() + defer { lock.unlock() } + return try #require(storedResult).get() + } + + private func remove(manager: NativeTLSKeyLogManager) { + let semaphore = DispatchSemaphore(value: 0) + manager.remove { _ in semaphore.signal() } + semaphore.wait() + } +} diff --git a/PcapPlusPlusCoreTests/Services/Core/NativeTLSKeyLogManagerTests.swift b/PcapPlusPlusCoreTests/Services/Core/NativeTLSKeyLogManagerTests.swift new file mode 100644 index 0000000..c3b265f --- /dev/null +++ b/PcapPlusPlusCoreTests/Services/Core/NativeTLSKeyLogManagerTests.swift @@ -0,0 +1,150 @@ +// +// NativeTLSKeyLogManagerTests.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import Foundation +import Testing +@testable import PcapPlusPlusCore + +@Suite(.serialized) +struct NativeTLSKeyLogManagerTests { + @Test func acceptsEveryFormatRecognizedByPinnedWireshark() throws { + let url = try temporaryFile(contents: [ + "PMS_CLIENT_RANDOM \(hex(bytes: 32)) aa", + "RSA \(hex(bytes: 8)) \(hex(bytes: 48))", + "RSA Session-ID:aa Master-Key:\(hex(bytes: 48))", + "CLIENT_RANDOM \(hex(bytes: 32)) \(hex(bytes: 48))", + "CLIENT_EARLY_TRAFFIC_SECRET \(hex(bytes: 32)) aa", + "CLIENT_HANDSHAKE_TRAFFIC_SECRET \(hex(bytes: 32)) aa", + "SERVER_HANDSHAKE_TRAFFIC_SECRET \(hex(bytes: 32)) aa", + "CLIENT_TRAFFIC_SECRET_0 \(hex(bytes: 32)) aa", + "SERVER_TRAFFIC_SECRET_0 \(hex(bytes: 32)) aa", + "EARLY_EXPORTER_SECRET \(hex(bytes: 32)) aa", + "EXPORTER_SECRET \(hex(bytes: 32)) aa", + "ECH_SECRET \(hex(bytes: 32)) aa", + "ECH_CONFIG \(hex(bytes: 22)) aa", + ].joined(separator: "\n") + "\n") + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let result = try NativeTLSKeyLogManager.validateFile(at: url) + + #expect(result.validRecordCount == 13) + #expect(result.warningCount == 0) + } + + @Test func acceptsCommentsCRLFLowercaseAndIgnoresIncompleteFinalLine() throws { + let valid = "client_random \(hex(bytes: 32)) \(hex(bytes: 48))" + .replacingOccurrences(of: "client_random", with: "CLIENT_RANDOM") + let url = try temporaryFile(contents: "# generated\r\n\r\n\(valid)\r\nBROKEN value\r\nCLIENT_RANDOM aa") + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let result = try NativeTLSKeyLogManager.validateFile(at: url) + + #expect(result.validRecordCount == 1) + #expect(result.warningCount == 1) + #expect(result.scannedLineCount == 4) + } + + @Test func rejectsDirectoriesAndFilesWithoutRecognizedRecords() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let invalidURL = directory.appendingPathComponent("invalid.keys") + try Data("CLIENT_RANDOM aa abc\n".utf8).write(to: invalidURL) + + #expect(throws: TCPViewerCoreError.self) { + try NativeTLSKeyLogManager.validateFile(at: directory) + } + #expect(throws: TCPViewerCoreError.self) { + try NativeTLSKeyLogManager.validateFile(at: invalidURL) + } + } + + @Test func missingFileErrorDoesNotExposeItsPath() throws { + let missingURL = FileManager.default.temporaryDirectory + .appendingPathComponent("SECRET-PATH-(UUID().uuidString)") + + do { + _ = try NativeTLSKeyLogManager.validateFile(at: missingURL) + Issue.record("Expected the missing file to be rejected.") + } catch let error as TCPViewerCoreError { + #expect(error.message == "TCP Viewer cannot access the selected TLS key-log file.") + #expect(!error.message.contains(missingURL.path)) + } + } + + @Test func stopsAtCompleteLineLimit() throws { + var lines = ["CLIENT_RANDOM \(hex(bytes: 32)) \(hex(bytes: 48))"] + lines.append(contentsOf: repeatElement("# comment", count: 20_100)) + let url = try temporaryFile(contents: lines.joined(separator: "\n") + "\n") + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let result = try NativeTLSKeyLogManager.validateFile(at: url) + + #expect(result.validRecordCount == 1) + #expect(result.scannedLineCount == 20_000) + #expect(result.reachedScanLimit) + } + + @Test func replacementAndRemovalUpdateState() throws { + let firstURL = try temporaryFile(contents: "CLIENT_RANDOM \(hex(bytes: 32)) \(hex(bytes: 48))\n") + let directory = firstURL.deletingLastPathComponent() + let secondURL = directory.appendingPathComponent("replacement.log") + try Data("CLIENT_RANDOM \(String(repeating: "cd", count: 32)) \(hex(bytes: 48))\n".utf8).write(to: secondURL) + defer { try? FileManager.default.removeItem(at: directory) } + let manager = NativeTLSKeyLogManager() + + let first = try apply(manager, fileURL: firstURL) + let replacement = try apply(manager, fileURL: secondURL) + let removed = try remove(manager) + + #expect(first.fileURL == firstURL) + #expect(replacement.fileURL == secondURL) + #expect(removed.fileURL == nil) + } + + private func hex(bytes: Int) -> String { + String(repeating: "ab", count: bytes) + } + + private func temporaryFile(contents: String) throws -> URL { + let directory = try temporaryDirectory() + let url = directory.appendingPathComponent("test key ü.keys") + try Data(contents.utf8).write(to: url) + return url + } + + private func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func apply(_ manager: NativeTLSKeyLogManager, fileURL: URL) throws -> TLSKeyLogState { + try waitForResult { manager.apply(fileURL: fileURL, completion: $0) } + } + + private func remove(_ manager: NativeTLSKeyLogManager) throws -> TLSKeyLogState { + try waitForResult(manager.remove) + } + + private func waitForResult( + _ operation: (@escaping TCPViewerCompletion) -> Void + ) throws -> Value { + let semaphore = DispatchSemaphore(value: 0) + let lock = NSLock() + var storedResult: Result? + operation { result in + lock.lock() + storedResult = result + lock.unlock() + semaphore.signal() + } + semaphore.wait() + lock.lock() + defer { lock.unlock() } + return try #require(storedResult).get() + } +} diff --git a/PcapPlusPlusCoreTests/Services/LiveCapture/NativeLivePacketDiskSnapshotTests.swift b/PcapPlusPlusCoreTests/Services/LiveCapture/NativeLivePacketDiskSnapshotTests.swift index 43e4767..7eb1857 100644 --- a/PcapPlusPlusCoreTests/Services/LiveCapture/NativeLivePacketDiskSnapshotTests.swift +++ b/PcapPlusPlusCoreTests/Services/LiveCapture/NativeLivePacketDiskSnapshotTests.swift @@ -78,6 +78,28 @@ struct NativeLivePacketDiskSnapshotTests { #expect(try snapshot.records(maximumBytes: 3).map(\.identifier) == [1, 2, 3]) } + @Test func fullSnapshotReplaysFromDiskWithoutBuildingARecordArray() throws { + let store = NativeLivePacketDiskStore() + try store.append(makeRecord(identifier: 1, byte: 0x11)) + try store.append(makeRecord(identifier: 2, byte: 0x22)) + try store.append(makeRecord(identifier: 3, byte: 0x33)) + + let snapshot = try store.snapshotAll() + store.reset() + var identifiers: [UInt64] = [] + try snapshot.replayRecords { record in + identifiers.append(record.identifier) + return identifiers.count < 2 + } + + #expect(snapshot.packetCount == 3) + #expect(try snapshot.record(withIdentifier: 3).rawBytes == Data([0x33])) + #expect(identifiers == [1, 2]) + #expect(throws: NSError.self) { + try snapshot.replayRecords(shouldCancel: { true }) { _ in true } + } + } + private func makeRecord(identifier: UInt64, byte: UInt8) -> NativePacketRecord { NativePacketRecord( identifier: identifier, diff --git a/README.md b/README.md index eb93ada..e3e0b5f 100644 --- a/README.md +++ b/README.md @@ -191,14 +191,19 @@ In Xcode: 3. Choose `My Mac`. 4. Press Run. +Signing: + +- Set `TCPVIEWER_DEVELOPMENT_TEAM` in `Config/TCPViewer.local.xcconfig`. +- Use the same development team for every target. +- `TCPViewer`, `PcapPlusPlusCore`, and `TCPViewerHelperTool` must use the same team. +- If dyld reports different Team IDs, clean the build folder and build again. + Command-line build: ```bash xcodebuild -project TCPViewer.xcodeproj -scheme TCPViewer build ``` -If Xcode asks for signing, select a development team for `TCPViewer` and `PcapPlusPlusCore`. - ## Test ```bash diff --git a/TCPViewer/App/AppDelegate.swift b/TCPViewer/App/AppDelegate.swift index 1c2c50e..3f1c0ce 100644 --- a/TCPViewer/App/AppDelegate.swift +++ b/TCPViewer/App/AppDelegate.swift @@ -16,6 +16,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var aboutWindowController: TCPViewerAboutWindowController? private var settingsWindowController: NSWindowController? + private var tlsKeyLogWindowController: TLSKeyLogWindowController? private var licenseWindowController: TCPViewerLicenseWindowController? private var updaterController: SPUStandardUpdaterController? private let sparkleUpdaterDelegate = TCPViewerSparkleUpdaterDelegate() @@ -24,8 +25,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { private weak var licenseMenuItem: NSMenuItem? private var licenseStatusObserver: NSObjectProtocol? private var configurationObserver: NSObjectProtocol? + private var liveCaptureReleaseObserver: NSObjectProtocol? private lazy var sentryService = TCPViewerSentryService(configuration: appConfiguration) private lazy var factoryResetService = TCPViewerFactoryResetService(helperToolManager: networkHelperToolManager) + private let tlsKeyLogManager = NativeTLSKeyLogManager() private var isHandlingTermination = false private var skipsNextQuitConfirmation = false private var isShowingRenewalRequiredAlert = false @@ -33,6 +36,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var didCheckForUpdatesAtLaunch = false private var availableUpdateCount = 0 private var isTerminatingAfterFactoryReset = false + private var hasPendingTLSKeyLogReload = false + private var isReloadingTLSKeyLogCaptures = false + private var pendingTLSKeyLogSelectionByController: [ObjectIdentifier: PacketSummary.ID] = [:] #if DEBUG private var shouldOpenUntitledDocumentAfterIgnoringDebugLaunchFiles = false #endif @@ -43,12 +49,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { appConfiguration.applyAppearance() observeLicenseStatusChanges() observeConfigurationChanges() + observeLiveCaptureRelease() wireAboutMenu() wirePreferencesMenu() wireUpdatesMenu() checkForAvailableUpdatesAtLaunch() wireClearAllPacketsMenu() wireFilterMenu() + wireToolsMenu() wireHelpMenu() verifyLicenseAtLaunch() updateMCPServerAvailability() @@ -90,6 +98,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { if let configurationObserver { NotificationCenter.default.removeObserver(configurationObserver) } + if let liveCaptureReleaseObserver { + NotificationCenter.default.removeObserver(liveCaptureReleaseObserver) + } } func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { @@ -116,6 +127,62 @@ 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) + } + + // Apply keys from the selected connection so users do not have to leave the inspector first. + func chooseTLSKeyLog( + for window: NSWindow, + completion: @escaping (TLSKeyLogSelectionOutcome) -> Void + ) { + let panel = TLSKeyLogOpenPanel.make() + panel.beginSheetModal(for: window) { [weak self] response in + guard response == .OK, let url = panel.url else { + completion(.cancelled) + return + } + guard let self else { + completion(.failed(TCPViewerCoreError( + code: .unavailableFeature, + message: "TLS key-log selection is unavailable." + ))) + return + } + self.tlsKeyLogManager.apply(fileURL: url) { [weak self] result in + DispatchQueue.main.async { + guard let self else { + completion(.failed(TCPViewerCoreError( + code: .unavailableFeature, + message: "TLS key-log selection is unavailable." + ))) + return + } + switch result { + case .success(let state): + self.handleTLSKeyLogConfigurationChange() + completion(.applied(state)) + case .failure(let error): + completion(.failed(error)) + } + } + } + } + } + private func prepareForTermination(_ sender: NSApplication) -> NSApplication.TerminateReply { isHandlingTermination = true TCPViewerWorkspaceController.prepareAllForApplicationTermination { [weak self] shouldTerminate in @@ -617,6 +684,120 @@ 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 Decryption…" + existing.target = self + return + } + let keyLogItem = NSMenuItem(title: "TLS Decryption…", action: #selector(showTLSKeyLog(_:)), keyEquivalent: "") + keyLogItem.target = self + toolsMenu.addItem(keyLogItem) + } + + private func handleTLSKeyLogConfigurationChange() { + let controllers = workspaceWindowControllers() + controllers.forEach { + let viewModel = $0.rootViewController.viewModel + let selectedPacketID = viewModel.invalidateInspectionAfterTLSKeyLogChange() + if let selectedPacketID { + pendingTLSKeyLogSelectionByController[ObjectIdentifier($0)] = selectedPacketID + } + if viewModel.snapshot.base.packetIngestState.source != .offline, + let selectedPacketID { + viewModel.selectPacket(selectedPacketID) + pendingTLSKeyLogSelectionByController.removeValue(forKey: ObjectIdentifier($0)) + } + } + hasPendingTLSKeyLogReload = true + reloadPendingTLSKeyLogCapturesIfPossible() + } + + private func observeLiveCaptureRelease() { + liveCaptureReleaseObserver = NotificationCenter.default.addObserver( + forName: TCPViewerWorkspaceController.liveCaptureDidReleaseWiresharkNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.reloadPendingTLSKeyLogCapturesIfPossible() + } + } + + private func reloadPendingTLSKeyLogCapturesIfPossible() { + guard hasPendingTLSKeyLogReload, !isReloadingTLSKeyLogCaptures else { + return + } + let controllers = workspaceWindowControllers() + guard !controllers.contains(where: { $0.rootViewController.viewModel.snapshot.base.sessionState.canStop }) else { + return + } + hasPendingTLSKeyLogReload = false + let offlineControllers = controllers.filter { + $0.rootViewController.viewModel.snapshot.base.packetIngestState.source == .offline + } + guard !offlineControllers.isEmpty else { + restorePendingTLSKeyLogSelections(in: controllers) + return + } + isReloadingTLSKeyLogCaptures = true + reloadOfflineCaptures(offlineControllers, index: 0) { [weak self] in + self?.isReloadingTLSKeyLogCaptures = false + self?.reloadPendingTLSKeyLogCapturesIfPossible() + } + } + + // Reopen offline windows one at a time because Wireshark has one process-wide dissection session. + private func reloadOfflineCaptures( + _ controllers: [TCPViewerWindowController], + index: Int, + completion: @escaping () -> Void + ) { + guard index < controllers.count else { + completion() + return + } + controllers[index].rootViewController.viewModel.reloadAfterTLSKeyLogChange { [weak self] in + guard let self else { + completion() + return + } + self.restorePendingTLSKeyLogSelection(in: controllers[index]) + self.reloadOfflineCaptures(controllers, index: index + 1, completion: completion) + } + } + + private func restorePendingTLSKeyLogSelections(in controllers: [TCPViewerWindowController]) { + controllers.forEach(restorePendingTLSKeyLogSelection) + } + + private func restorePendingTLSKeyLogSelection(in controller: TCPViewerWindowController) { + let key = ObjectIdentifier(controller) + guard let selectedPacketID = pendingTLSKeyLogSelectionByController.removeValue(forKey: key), + controller.rootViewController.viewModel.snapshot.base.packetIngestState.packet(withID: selectedPacketID) != nil else { + return + } + controller.rootViewController.viewModel.selectPacket(selectedPacketID) + } + + private func workspaceWindowControllers() -> [TCPViewerWindowController] { + NSApp.windows.compactMap { $0.windowController as? TCPViewerWindowController } + } + private func configureClearAllPacketsMenuItem(_ item: NSMenuItem) { item.title = "Clear All Packets" item.target = nil diff --git a/TCPViewer/App/TLSKeyLogWindowController.swift b/TCPViewer/App/TLSKeyLogWindowController.swift new file mode 100644 index 0000000..fbb370c --- /dev/null +++ b/TCPViewer/App/TLSKeyLogWindowController.swift @@ -0,0 +1,176 @@ +// +// TLSKeyLogWindowController.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import AppKit +import PcapPlusPlusCore +import UniformTypeIdentifiers + +enum TLSKeyLogSelectionOutcome { + case cancelled + case applied(TLSKeyLogState) + case failed(Error) +} + +enum TLSKeyLogOpenPanel { + static func make() -> NSOpenPanel { + let panel = NSOpenPanel() + panel.title = "Choose TLS Key Log" + panel.message = "Choose a TLS key log created for the same captured connections." + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [.plainText, .data] + return panel + } +} + +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 a TLS key log to decrypt matching connections.") + private let chooseButton = NSButton(title: "Choose File…", 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 Decryption" + 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(_:)) + removeButton.target = self + removeButton.action = #selector(removeFile(_:)) + + progressIndicator.style = .spinning + progressIndicator.controlSize = .small + progressIndicator.isDisplayedWhenStopped = false + + let warning = NSTextField(wrappingLabelWithString: "Keep this file private. TCP Viewer uses it in place and forgets it when the app quits.") + warning.textColor = .systemOrange + + let buttonRow = NSStackView(views: [chooseButton, 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…" + removeButton.isEnabled = state.fileURL != nil + if let validation = state.validation { + var message = "TLS keys loaded. TCP Viewer will decrypt matching connections in open captures. \(validation.validRecordCount) recognized records, \(validation.warningCount) warnings." + if validation.reachedScanLimit { + message += " Validation stopped at the scan limit." + } + statusLabel.stringValue = message + } else { + statusLabel.stringValue = "Choose a TLS key log to decrypt matching connections." + } + } + + private func setLoading(_ loading: Bool, message: String) { + statusLabel.stringValue = message + chooseButton.isEnabled = !loading + removeButton.isEnabled = !loading && selectedURL != nil + loading ? progressIndicator.startAnimation(nil) : progressIndicator.stopAnimation(nil) + } + + @objc private func chooseFile(_ sender: Any?) { + let panel = TLSKeyLogOpenPanel.make() + guard panel.runModal() == .OK, let url = panel.url else { + return + } + apply(url) + } + + @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): + removeButton.isEnabled = selectedURL != nil + statusLabel.stringValue = (error as? TCPViewerCoreError)?.message ?? error.localizedDescription + } + } +} diff --git a/TCPViewer/App/Windowing/TCPViewerWindowController.swift b/TCPViewer/App/Windowing/TCPViewerWindowController.swift index 32f679f..2cf0051 100644 --- a/TCPViewer/App/Windowing/TCPViewerWindowController.swift +++ b/TCPViewer/App/Windowing/TCPViewerWindowController.swift @@ -227,6 +227,21 @@ extension TCPViewerWindowController: TCPViewerRootViewControllerDelegate { func tcpviewerRootViewControllerDidRequestPaywall(_ controller: TCPViewerRootViewController) { (NSApp.delegate as? AppDelegate)?.showPaywall(self) } + + func tcpviewerRootViewController( + _ controller: TCPViewerRootViewController, + didRequestChooseTLSKeyLog completion: @escaping (TLSKeyLogSelectionOutcome) -> Void + ) { + guard let window, + let appDelegate = NSApp.delegate as? AppDelegate else { + completion(.failed(TCPViewerCoreError( + code: .unavailableFeature, + message: "TLS key-log selection is unavailable." + ))) + return + } + appDelegate.chooseTLSKeyLog(for: window, completion: completion) + } } extension TCPViewerWindowController: TCPViewerToolbarDataSourceDelegate { diff --git a/TCPViewer/Core/WorkspaceFoundation.swift b/TCPViewer/Core/WorkspaceFoundation.swift index 72c4d4e..3ab88fe 100644 --- a/TCPViewer/Core/WorkspaceFoundation.swift +++ b/TCPViewer/Core/WorkspaceFoundation.swift @@ -1071,6 +1071,10 @@ struct TCPViewerWorkspaceMemoryDebugSnapshot: Equatable { #endif final class TCPViewerWorkspaceController { + static let liveCaptureDidReleaseWiresharkNotification = Notification.Name( + "TCPViewerWorkspaceControllerLiveCaptureDidReleaseWireshark" + ) + private struct ImportedSessionCaptureExportGroup { let fileID: ImportedCaptureFileID var originalPacketIDs: [PacketSummary.ID] @@ -1967,6 +1971,16 @@ final class TCPViewerWorkspaceController { } } + // Reopen one document directly or rebuild a merged offline workspace from its original files. + func reloadOfflineCapturesAfterTLSKeyLogChange(completion: (() -> Void)? = nil) { + let importedURLs = snapshot.packetIngestState.importedFiles.map(\.url) + if importedURLs.count > 1 { + openDocuments(at: importedURLs, replacingCurrent: true, completion: completion) + return + } + reopenDocument(completion: completion) + } + func saveDocument(completion: (() -> Void)? = nil) { guard let document else { completion?() @@ -2995,6 +3009,28 @@ final class TCPViewerWorkspaceController { ) } + func loadDecryptedStream( + containing identifier: PacketSummary.ID, + progress: TCPFollowProgressHandler? = nil, + shouldCancel: TCPFollowCancellationCheck? = nil, + completion: @escaping TCPViewerCompletion + ) { + guard let packet = snapshot.packetIngestState.packet(withID: identifier) else { + completion(.failure(TCPViewerCoreError( + code: .offlineFileOpenFailed, + message: "Packet \(identifier) is no longer available." + ))) + return + } + loadDecryptedStream( + packet, + identifier: identifier, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + } + func cancelBackgroundWork() { cancelControllerTasks() @@ -3285,11 +3321,18 @@ final class TCPViewerWorkspaceController { private func applyPacketIngestEvent(_ event: PacketIngestEvent) { switch event { case .liveStateChanged(let phase, let message): + let previouslyOwnedWireshark = snapshot.sessionState.canStop snapshot.sessionState.phase = mappedPhase(phase) snapshot.sessionState.statusMessage = message if mappedPhase(phase) != .failed { snapshot.sessionState.lastError = nil } + if previouslyOwnedWireshark && !snapshot.sessionState.canStop { + NotificationCenter.default.post( + name: Self.liveCaptureDidReleaseWiresharkNotification, + object: self + ) + } case .documentStateChanged(let phase, let message): snapshot.documentState.phase = mappedPhase(phase) snapshot.documentState.statusMessage = message @@ -4050,6 +4093,54 @@ final class TCPViewerWorkspaceController { } } + private func loadDecryptedStream( + _ packet: PacketSummary, + identifier: PacketSummary.ID, + progress: TCPFollowProgressHandler?, + shouldCancel: TCPFollowCancellationCheck?, + completion: @escaping TCPViewerCompletion + ) { + switch packet.source { + case .live: + guard let liveSession else { + completion(.failure(TCPViewerCoreError(code: .offlineFileOpenFailed, message: "Live packet \(identifier) is no longer available."))) + return + } + liveSession.loadDecryptedStream( + containing: identifier, + limits: .default, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + case .offline: + if let reference = snapshot.packetIngestState.importedPacketReference(for: identifier), + let importedDocument = importedDocumentsByFileID[reference.fileID] { + importedDocument.loadDecryptedStream( + containing: reference.originalPacketID, + limits: .default, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + } else { + guard let document else { + completion(.failure(TCPViewerCoreError(code: .offlineFileOpenFailed, message: "Packet \(identifier) is no longer available."))) + return + } + document.loadDecryptedStream( + containing: identifier, + limits: .default, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + } + @unknown default: + completion(.failure(TCPViewerCoreError(code: .unavailableFeature, message: "Packet \(identifier) cannot be decrypted."))) + } + } + private func detailNode(with identifier: String?) -> PacketDetailNode? { guard let identifier, let inspection = snapshot.inspectionState.inspection else { diff --git a/TCPViewer/Features/NetworkInspector/Models/DecryptedStreamTextFormatter.swift b/TCPViewer/Features/NetworkInspector/Models/DecryptedStreamTextFormatter.swift new file mode 100644 index 0000000..c7e2015 --- /dev/null +++ b/TCPViewer/Features/NetworkInspector/Models/DecryptedStreamTextFormatter.swift @@ -0,0 +1,42 @@ +// +// DecryptedStreamTextFormatter.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import Foundation + +enum DecryptedStreamTextFormatter { + // Text mode is intentionally strict so binary HTTP/2 and QUIC payloads remain inspectable. + static func string(for data: Data) -> String { + if let text = String(data: data, encoding: .utf8), text.unicodeScalars.allSatisfy(isReadable) { + return text + } + return hexDump(data) + } + + private static func isReadable(_ scalar: UnicodeScalar) -> Bool { + scalar.value == 0x09 || scalar.value == 0x0A || scalar.value == 0x0D || + (scalar.value >= 0x20 && scalar.value != 0x7F && !(0x80...0x9F).contains(scalar.value)) + } + + private static func hexDump(_ data: Data) -> String { + guard !data.isEmpty else { + return "" + } + let bytes = [UInt8](data) + var lines: [String] = [] + lines.reserveCapacity((bytes.count + 15) / 16) + for offset in stride(from: 0, to: bytes.count, by: 16) { + let line = Array(bytes[offset..= 0x20 && byte <= 0x7E ? String(UnicodeScalar(byte)) : "." + }.joined() + lines.append(String(format: "%08x %@%@ |%@|", offset, hex, padding, ascii)) + } + return lines.joined(separator: "\n") + } +} diff --git a/TCPViewer/Features/NetworkInspector/ViewModels/NetworkInspectorViewModel.swift b/TCPViewer/Features/NetworkInspector/ViewModels/NetworkInspectorViewModel.swift index cd160f4..f515aaf 100644 --- a/TCPViewer/Features/NetworkInspector/ViewModels/NetworkInspectorViewModel.swift +++ b/TCPViewer/Features/NetworkInspector/ViewModels/NetworkInspectorViewModel.swift @@ -1835,6 +1835,26 @@ final class NetworkInspectorViewModel { } } + @discardableResult + func invalidateInspectionAfterTLSKeyLogChange() -> PacketSummary.ID? { + let selectedPacketID = snapshot.selectedPacketID + controller.selectPacket(nil) + rebuildSnapshot() + return selectedPacketID + } + + // 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 +2198,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..3a252a0 100644 --- a/TCPViewer/Features/NetworkInspector/Views/PacketInspectorViewController.swift +++ b/TCPViewer/Features/NetworkInspector/Views/PacketInspectorViewController.swift @@ -11,6 +11,50 @@ import PcapPlusPlusCore protocol PacketInspectorViewControllerDelegate: AnyObject { func packetInspectorViewController(_ controller: PacketInspectorViewController, didSelectDetailNode identifier: String?) func packetInspectorViewController(_ controller: PacketInspectorViewController, didRequestCreateCustomColumn request: PacketCustomColumnRequest) + func packetInspectorViewController( + _ controller: PacketInspectorViewController, + didRequestChooseTLSKeyLog completion: @escaping (TLSKeyLogSelectionOutcome) -> Void + ) + func packetInspectorViewControllerDidRequestStopAndDecrypt( + _ controller: PacketInspectorViewController, + completion: @escaping () -> Void + ) + func packetInspectorViewController( + _ controller: PacketInspectorViewController, + loadDecryptedStreamFor packetID: PacketSummary.ID, + progress: @escaping TCPFollowProgressHandler, + shouldCancel: @escaping TCPFollowCancellationCheck, + completion: @escaping TCPViewerCompletion + ) +} + +extension PacketInspectorViewControllerDelegate { + func packetInspectorViewController( + _ controller: PacketInspectorViewController, + didRequestChooseTLSKeyLog completion: @escaping (TLSKeyLogSelectionOutcome) -> Void + ) { + completion(.failed(TCPViewerCoreError( + code: .unavailableFeature, + message: "TLS key-log selection is unavailable." + ))) + } + + func packetInspectorViewControllerDidRequestStopAndDecrypt( + _ controller: PacketInspectorViewController, + completion: @escaping () -> Void + ) { + completion() + } + + 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 +711,27 @@ private final class PacketInspectorSectionRowView: NSTableRowView { } final class PacketInspectorViewController: NSViewController { + private enum InspectorPage: Int { + case packet + case decrypted + } + + private enum DecryptedDirection: Int { + case clientToServer + case serverToClient + } + + private enum DecryptedAction { + case chooseTLSKeyLog + case stopAndDecrypt + } + 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 +746,28 @@ 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", "Decrypted"], 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 decryptedHeaderStack = NSStackView() + private let decryptedTitleLabel = NSTextField(wrappingLabelWithString: "") + private let decryptedStatusLabel = NSTextField(wrappingLabelWithString: "") + private let decryptedDirectionControl = NSSegmentedControl( + labels: ["Client → Server", "Server → Client"], + trackingMode: .selectOne, + target: nil, + action: nil + ) + private let decryptedActionButton = NSButton(title: "", target: nil, action: nil) + private let decryptedScrollView = NSScrollView() + private let decryptedTextView = NSTextView() private var outlineItem: NSSplitViewItem? private var hexItem: NSSplitViewItem? private var emptyStateView: NSView? @@ -702,6 +778,15 @@ final class PacketInspectorViewController: NSViewController { private var isShowingPacketDetail = false private var isApplyingSelection = false private var isApplyingExpansionState = false + private var selectedPage: InspectorPage = .packet + private var selectedDecryptedDirection: DecryptedDirection = .clientToServer + private var decryptedAction: DecryptedAction? + private var selectedDomainName: String? + private var decryptedStream: DecryptedStreamResult? + private var decryptedPacketID: PacketSummary.ID? + private var decryptedLoadGeneration = 0 + private var isLoadingDecryptedStream = false + private var decryptedCancellationFlag: TCPFollowCancellationFlag? init(configuration: AppConfiguration) { self.configuration = configuration @@ -714,10 +799,15 @@ final class PacketInspectorViewController: NSViewController { fatalError("init(coder:) has not been implemented") } + deinit { + decryptedCancellationFlag?.cancel() + } + override func loadView() { view = TCPViewerDynamicBackgroundView(backgroundColor: .controlBackgroundColor) setupFilterBar() setupOutlineView() + setupDecryptedView() setupLayout() } @@ -729,6 +819,8 @@ 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 + selectedDomainName = snapshot.selectedPacket?.domainName + updateDecryptedSelection(for: inspectionState) latestInspectionState = inspectionState let didRevealPacketDetail = updateContentVisibility(for: inspectionState) applyPlacement( @@ -740,6 +832,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 +931,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 +985,73 @@ 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) + + decryptedTitleLabel.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold) + decryptedTitleLabel.textColor = .labelColor + + decryptedStatusLabel.textColor = .secondaryLabelColor + decryptedStatusLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + + decryptedDirectionControl.selectedSegment = DecryptedDirection.clientToServer.rawValue + decryptedDirectionControl.target = self + decryptedDirectionControl.action = #selector(selectDecryptedDirection(_:)) + + decryptedActionButton.bezelStyle = .rounded + decryptedActionButton.target = self + decryptedActionButton.action = #selector(performDecryptedAction(_:)) + + decryptedHeaderStack.orientation = .vertical + decryptedHeaderStack.alignment = .leading + decryptedHeaderStack.spacing = 6 + decryptedHeaderStack.addArrangedSubview(decryptedTitleLabel) + decryptedHeaderStack.addArrangedSubview(decryptedStatusLabel) + decryptedHeaderStack.addArrangedSubview(decryptedDirectionControl) + decryptedHeaderStack.addArrangedSubview(decryptedActionButton) + decryptedHeaderStack.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(decryptedHeaderStack) + 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), + decryptedHeaderStack.leadingAnchor.constraint(equalTo: decryptedContainerView.leadingAnchor, constant: 10), + decryptedHeaderStack.trailingAnchor.constraint(equalTo: decryptedContainerView.trailingAnchor, constant: -10), + decryptedHeaderStack.topAnchor.constraint(equalTo: decryptedContainerView.topAnchor, constant: 8), + decryptedTitleLabel.widthAnchor.constraint(equalTo: decryptedHeaderStack.widthAnchor), + decryptedStatusLabel.widthAnchor.constraint(equalTo: decryptedHeaderStack.widthAnchor), + decryptedScrollView.leadingAnchor.constraint(equalTo: decryptedContainerView.leadingAnchor), + decryptedScrollView.trailingAnchor.constraint(equalTo: decryptedContainerView.trailingAnchor), + decryptedScrollView.topAnchor.constraint(equalTo: decryptedHeaderStack.bottomAnchor, constant: 6), + decryptedScrollView.bottomAnchor.constraint(equalTo: decryptedContainerView.bottomAnchor), + ]) + decryptedDirectionControl.isHidden = true + decryptedActionButton.isHidden = true + } + private func setupOutlineView() { detailColumn.minWidth = 160 detailColumn.width = 320 @@ -939,22 +1099,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 +1141,281 @@ final class PacketInspectorViewController: NSViewController { stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + decryptedContainerView.isHidden = true + } + + private func updateDecryptedSelection(for inspectionState: PacketInspectionState) { + let packetID = inspectionState.selectedPacketID + guard packetID != decryptedPacketID else { + return + } + decryptedCancellationFlag?.cancel() + decryptedCancellationFlag = nil + decryptedLoadGeneration += 1 + decryptedPacketID = packetID + decryptedStream = nil + isLoadingDecryptedStream = false + decryptedTextView.string = "" + decryptedAction = nil + } + + 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( + title: "No encrypted connection selected", + message: "Select a TLS, DTLS, or QUIC packet to inspect its decrypted stream." + ) + return + } + if let stream = decryptedStream { + render(stream: stream) + } else if !isLoadingDecryptedStream { + loadDecryptedStream(packetID: packetID) + } + } + + private func loadDecryptedStream(packetID: PacketSummary.ID) { + decryptedCancellationFlag?.cancel() + let cancellationFlag = TCPFollowCancellationFlag() + decryptedCancellationFlag = cancellationFlag + isLoadingDecryptedStream = true + decryptedLoadGeneration += 1 + let generation = decryptedLoadGeneration + showDecryptedMessage( + title: selectedDomainName ?? "Decrypting connection", + message: "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( + title: self.selectedDomainName ?? "Decrypting connection", + message: "Loading the complete decrypted stream… \(percent)% (\(progress.processedPacketCount)/\(progress.totalPacketCount) packets)" + ) + } + }, + shouldCancel: { + cancellationFlag.isCancelled + } + ) { [weak self] result in + DispatchQueue.main.async { + guard let self else { + return + } + if self.decryptedCancellationFlag === cancellationFlag { + self.decryptedCancellationFlag = nil + } + guard self.decryptedLoadGeneration == generation, self.decryptedPacketID == packetID else { + return + } + self.isLoadingDecryptedStream = false + switch result { + case .success(let stream): + self.decryptedStream = stream + self.renderSelectedPage() + case .failure(let error): + self.showDecryptedError(error) + } + } + } + } + + private func render(stream: DecryptedStreamResult) { + if stream.request.data.isEmpty, stream.response.data.isEmpty { + showDecryptedMessage( + title: "No decrypted data found", + message: "The key log may not match this capture, the TLS handshake may be missing, or this connection may contain no application data.", + action: .chooseTLSKeyLog, + actionTitle: "Choose Different Key Log…" + ) + return + } + let showsClientToServer = selectedDecryptedDirection == .clientToServer + let payload = showsClientToServer ? stream.request : stream.response + let source = showsClientToServer ? stream.client : stream.server + let destination = showsClientToServer ? 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( + title: selectedDomainName ?? "Decrypted connection", + message: "\(status)\nNo application data exists in this direction. Try the other direction." + ) + decryptedDirectionControl.isHidden = false + return + } + decryptedTitleLabel.stringValue = selectedDomainName ?? "Decrypted connection" + decryptedStatusLabel.stringValue = status + decryptedAction = nil + decryptedActionButton.isHidden = true + decryptedDirectionControl.isHidden = false + decryptedTextView.string = DecryptedStreamTextFormatter.string(for: payload.data) + decryptedScrollView.isHidden = false + } + + private func showDecryptedMessage( + title: String, + message: String, + action: DecryptedAction? = nil, + actionTitle: String? = nil + ) { + decryptedTitleLabel.stringValue = title + decryptedStatusLabel.stringValue = message + decryptedDirectionControl.isHidden = true + decryptedAction = action + decryptedActionButton.title = actionTitle ?? "" + decryptedActionButton.isHidden = action == nil + decryptedTextView.string = "" + decryptedScrollView.isHidden = true + } + + private func showDecryptedError(_ error: Error) { + let message = decryptedErrorMessage(error) + if message.localizedCaseInsensitiveContains("No TLS key-log file") { + showDecryptedMessage( + title: "Encrypted connection", + message: "Add a TLS key log from the same capture session to decrypt this connection.", + action: .chooseTLSKeyLog, + actionTitle: "Choose TLS Key Log…" + ) + return + } + if message.localizedCaseInsensitiveContains("Stop the live capture") { + showDecryptedMessage( + title: "Stop capture to decrypt", + message: "TCP Viewer needs the completed capture to rebuild the full decrypted stream.", + action: .stopAndDecrypt, + actionTitle: "Stop and Decrypt" + ) + return + } + if message.localizedCaseInsensitiveContains("Select a TLS") { + showDecryptedMessage( + title: "No encrypted connection selected", + message: message + ) + return + } + showDecryptedMessage( + title: "Could not decrypt this connection", + message: "\(message) The key log may not match this capture, or the TLS handshake may be missing.", + action: .chooseTLSKeyLog, + actionTitle: "Choose Different Key Log…" + ) + } + + 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() + } + + @objc private func selectDecryptedDirection(_ sender: NSSegmentedControl) { + selectedDecryptedDirection = DecryptedDirection(rawValue: sender.selectedSegment) ?? .clientToServer + if let decryptedStream { + render(stream: decryptedStream) + } + } + + @objc private func performDecryptedAction(_ sender: NSButton) { + switch decryptedAction { + case .chooseTLSKeyLog: + chooseTLSKeyLog() + case .stopAndDecrypt: + stopAndDecrypt() + case nil: + break + } + } + + private func chooseTLSKeyLog() { + showDecryptedMessage( + title: "Choose TLS key log", + message: "Waiting for a TLS key-log file…" + ) + delegate?.packetInspectorViewController(self, didRequestChooseTLSKeyLog: { [weak self] outcome in + DispatchQueue.main.async { + guard let self else { + return + } + switch outcome { + case .cancelled: + self.showDecryptedMessage( + title: "Encrypted connection", + message: "Add a TLS key log from the same capture session to decrypt this connection.", + action: .chooseTLSKeyLog, + actionTitle: "Choose TLS Key Log…" + ) + case .applied(let state): + let fileName = state.fileURL?.lastPathComponent ?? "the selected key log" + self.decryptedStream = nil + if let packetID = self.decryptedPacketID, !self.isLoadingDecryptedStream { + self.loadDecryptedStream(packetID: packetID) + } else { + self.showDecryptedMessage( + title: "TLS keys loaded", + message: "Updating the capture with \(fileName)…" + ) + } + case .failed(let error): + self.showDecryptedMessage( + title: "Could not load TLS keys", + message: self.decryptedErrorMessage(error), + action: .chooseTLSKeyLog, + actionTitle: "Choose Different Key Log…" + ) + } + } + }) + } + + private func stopAndDecrypt() { + showDecryptedMessage( + title: "Stopping capture", + message: "TCP Viewer will decrypt this connection when the retained capture is ready." + ) + delegate?.packetInspectorViewControllerDidRequestStopAndDecrypt(self) { [weak self] in + DispatchQueue.main.async { + guard let self, let packetID = self.decryptedPacketID else { + return + } + self.decryptedStream = nil + self.isLoadingDecryptedStream = false + self.loadDecryptedStream(packetID: packetID) + } + } } // 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..8e2fdc4 100644 --- a/TCPViewer/Features/NetworkInspector/Views/TCPViewerRootViewController.swift +++ b/TCPViewer/Features/NetworkInspector/Views/TCPViewerRootViewController.swift @@ -12,6 +12,10 @@ protocol TCPViewerRootViewControllerDelegate: AnyObject { func tcpviewerRootViewControllerDidChangeToolbarState(_ controller: TCPViewerRootViewController) func tcpviewerRootViewController(_ controller: TCPViewerRootViewController, didRequestHelperOnboarding snapshot: TCPViewerNetworkHelperToolSnapshot) func tcpviewerRootViewControllerDidRequestPaywall(_ controller: TCPViewerRootViewController) + func tcpviewerRootViewController( + _ controller: TCPViewerRootViewController, + didRequestChooseTLSKeyLog completion: @escaping (TLSKeyLogSelectionOutcome) -> Void + ) } private final class TCPViewerInspectorSplitViewController: NSSplitViewController { @@ -1538,6 +1542,35 @@ 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 + ) + } + + func packetInspectorViewController( + _ controller: PacketInspectorViewController, + didRequestChooseTLSKeyLog completion: @escaping (TLSKeyLogSelectionOutcome) -> Void + ) { + delegate?.tcpviewerRootViewController(self, didRequestChooseTLSKeyLog: completion) + } + + func packetInspectorViewControllerDidRequestStopAndDecrypt( + _ controller: PacketInspectorViewController, + completion: @escaping () -> Void + ) { + viewModel.stopLiveCapture(completion: completion) + } } extension TCPViewerRootViewController: StatusStripViewControllerDelegate { diff --git a/TCPViewerTests/App/TLSKeyLogMenuTests.swift b/TCPViewerTests/App/TLSKeyLogMenuTests.swift new file mode 100644 index 0000000..2ded538 --- /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 Decryption…") + #expect(toolsItem.submenu?.items.first?.target === delegate) + } +} diff --git a/TCPViewerTests/App/WorkspaceControllerTests.swift b/TCPViewerTests/App/WorkspaceControllerTests.swift index d743d65..5cc2ba1 100644 --- a/TCPViewerTests/App/WorkspaceControllerTests.swift +++ b/TCPViewerTests/App/WorkspaceControllerTests.swift @@ -311,6 +311,15 @@ struct WindowControllerTests { let controller = TCPViewerWorkspaceController( services: TCPViewerServiceRegistry(core: fakeCore) ) + var releaseNotificationCount = 0 + let releaseObserver = NotificationCenter.default.addObserver( + forName: TCPViewerWorkspaceController.liveCaptureDidReleaseWiresharkNotification, + object: controller, + queue: .main + ) { _ in + releaseNotificationCount += 1 + } + defer { NotificationCenter.default.removeObserver(releaseObserver) } await controller.refreshInterfaces() await controller.startLiveCapture() @@ -362,6 +371,7 @@ struct WindowControllerTests { controller.snapshot.sessionState.phase == .stopped } #expect(liveSession.stopCount == 1) + #expect(releaseNotificationCount == 1) await tearDown(controller) } diff --git a/TCPViewerTests/Features/NetworkInspector/DecryptedStreamTextFormatterTests.swift b/TCPViewerTests/Features/NetworkInspector/DecryptedStreamTextFormatterTests.swift new file mode 100644 index 0000000..aa8c867 --- /dev/null +++ b/TCPViewerTests/Features/NetworkInspector/DecryptedStreamTextFormatterTests.swift @@ -0,0 +1,28 @@ +// +// DecryptedStreamTextFormatterTests.swift +// TCPViewer +// +// Created by Proxyman LLC on 19/8/26. +// + +import Foundation +import Testing +@testable import TCPViewer + +struct DecryptedStreamTextFormatterTests { + @Test func rendersReadableUTF8AsText() { + let text = "GET / HTTP/1.1\r\nHost: example.test\r\n\r\n" + + #expect(DecryptedStreamTextFormatter.string(for: Data(text.utf8)) == text) + } + + @Test func rendersControlsAndInvalidUTF8AsHexAndASCII() { + let output = DecryptedStreamTextFormatter.string(for: Data([0x48, 0x00, 0xFF])) + let deleteOutput = DecryptedStreamTextFormatter.string(for: Data([0x48, 0x7F])) + + #expect(output.contains("00000000")) + #expect(output.contains("48 00 ff")) + #expect(output.contains("|H..|")) + #expect(deleteOutput.contains("48 7f")) + } +} diff --git a/TCPViewerTests/Features/NetworkInspector/NetworkInspectorViewModelTests.swift b/TCPViewerTests/Features/NetworkInspector/NetworkInspectorViewModelTests.swift index 7ff3a1e..7848c2e 100644 --- a/TCPViewerTests/Features/NetworkInspector/NetworkInspectorViewModelTests.swift +++ b/TCPViewerTests/Features/NetworkInspector/NetworkInspectorViewModelTests.swift @@ -65,6 +65,32 @@ struct NetworkInspectorViewModelTests { #expect(viewModel.snapshot.visiblePacketCount == 0) } + @Test func tlsKeyLogInvalidationReturnsSelectedPacketForRestoration() async { + let packet = makePacket(packetNumber: 1, source: .live, transportHint: .tcp) + let liveSession = InspectorFakeLiveSession() + liveSession.inspections[packet.id] = makeInspection(for: packet) + let viewModel = NetworkInspectorViewModel( + services: TCPViewerServiceRegistry(core: InspectorFakeCore( + interfaces: [makeInterface(id: "en0", displayName: "Wi-Fi")], + liveSession: liveSession + )), + userDefaults: isolatedDefaults() + ) + + await viewModel.performInitialLoadIfNeeded() + await viewModel.toggleLiveCapture() + liveSession.send(.liveStateChanged(phase: .running, message: "Capture running.")) + liveSession.send(.packetBatch([packet], disposition: .append)) + await waitUntil { viewModel.snapshot.packetRows.count == 1 } + viewModel.selectPacket(packet.id) + await waitUntil { viewModel.snapshot.selectedPacket?.id == packet.id } + + let selectedPacketID = viewModel.invalidateInspectionAfterTLSKeyLogChange() + + #expect(selectedPacketID == packet.id) + #expect(viewModel.snapshot.selectedPacket == nil) + } + @Test func mcpCaptureControlsCompleteAfterAppliedPhaseEvents() async { let liveSession = InspectorFakeLiveSession() let viewModel = NetworkInspectorViewModel( diff --git a/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift b/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift index 70e4f89..3c78bd3 100644 --- a/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift +++ b/TCPViewerTests/Features/NetworkInspector/PacketInspectorTreeViewModelTests.swift @@ -71,6 +71,180 @@ struct PacketInspectorTreeViewModelTests { #expect(!textFieldValues(in: controller.view).contains("No Packet Selected")) } + @MainActor + @Test func decryptedDirectionsShareOneStreamLoad() async throws { + let packet = makePacket(sniDomainName: "api.example.com") + let controller = PacketInspectorViewController(configuration: AppConfiguration(defaults: isolatedDefaults())) + let delegate = PacketInspectorDelegateSpy() + delegate.decryptedStreamResult = .success(DecryptedStreamResult( + protocolName: .tls, + client: PacketEndpoint(address: "10.0.0.1", port: 1234), + server: PacketEndpoint(address: "10.0.0.2", port: 443), + request: DecryptedStreamPayload(data: Data("GET / HTTP/1.1\r\n\r\n".utf8), observedByteCount: 18, isTruncated: false), + response: DecryptedStreamPayload(data: Data("HTTP/1.1 200 OK\r\n\r\n".utf8), observedByteCount: 19, isTruncated: false) + )) + controller.delegate = delegate + controller.loadViewIfNeeded() + controller.render(snapshot: makeSnapshot( + packet: packet, + inspectionState: loadedInspectionState(packet: packet, inspection: makeFrameInspection(for: packet)) + )) + let tabs = try #require(segmentedControl(labels: ["Packet", "Decrypted"], in: controller.view)) + let directions = try #require(segmentedControl(labels: ["Client → Server", "Server → Client"], 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") + #expect(textFieldValues(in: controller.view).contains("api.example.com")) + + directions.selectedSegment = 1 + directions.sendAction(directions.action, to: directions.target) + await drainMainQueue() + + #expect(delegate.decryptedStreamLoadCount == 1) + #expect(textView.string == "HTTP/1.1 200 OK\r\n\r\n") + } + + @MainActor + @Test func missingKeyLogOffersChooserInsideDecryptedView() async throws { + let packet = makePacket() + let controller = PacketInspectorViewController(configuration: AppConfiguration(defaults: isolatedDefaults())) + let delegate = PacketInspectorDelegateSpy() + delegate.decryptedStreamResult = .failure(TCPViewerCoreError( + code: .unavailableFeature, + message: "No TLS key-log file is selected. Choose one in Decrypted or open Tools → TLS Decryption… first." + )) + controller.delegate = delegate + controller.loadViewIfNeeded() + controller.render(snapshot: makeSnapshot( + packet: packet, + inspectionState: loadedInspectionState(packet: packet, inspection: makeFrameInspection(for: packet)) + )) + let tabs = try #require(segmentedControl(labels: ["Packet", "Decrypted"], in: controller.view)) + + tabs.selectedSegment = 1 + tabs.sendAction(tabs.action, to: tabs.target) + await drainMainQueue() + + let chooseButton = try #require(allSubviews(ofType: NSButton.self, in: controller.view).first { + $0.title == "Choose TLS Key Log…" + }) + #expect(!isEffectivelyHidden(chooseButton)) + + chooseButton.sendAction(chooseButton.action, to: chooseButton.target) + await drainMainQueue() + + #expect(delegate.tlsKeyLogSelectionCount == 1) + #expect(!isEffectivelyHidden(chooseButton)) + } + + @MainActor + @Test func emptyDecryptedStreamOffersDifferentKeyLog() async throws { + let packet = makePacket() + let controller = PacketInspectorViewController(configuration: AppConfiguration(defaults: isolatedDefaults())) + let delegate = PacketInspectorDelegateSpy() + delegate.decryptedStreamResult = .success(DecryptedStreamResult( + protocolName: .tls, + client: packet.endpoints.source, + server: packet.endpoints.destination, + request: DecryptedStreamPayload(data: Data(), observedByteCount: 0, isTruncated: false), + response: DecryptedStreamPayload(data: Data(), observedByteCount: 0, 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(segmentedControl(labels: ["Packet", "Decrypted"], in: controller.view)) + + tabs.selectedSegment = 1 + tabs.sendAction(tabs.action, to: tabs.target) + await drainMainQueue() + + let chooseButton = try #require(allSubviews(ofType: NSButton.self, in: controller.view).first { + $0.title == "Choose Different Key Log…" + }) + #expect(!isEffectivelyHidden(chooseButton)) + #expect(textFieldValues(in: controller.view).contains("No decrypted data found")) + } + + @MainActor + @Test func runningCaptureOffersStopAndRetriesDecryption() async throws { + let packet = makePacket() + let controller = PacketInspectorViewController(configuration: AppConfiguration(defaults: isolatedDefaults())) + let delegate = PacketInspectorDelegateSpy() + delegate.decryptedStreamResult = .failure(TCPViewerCoreError( + code: .unavailableFeature, + message: "Stop the live capture to load the complete decrypted stream." + )) + delegate.stopAndDecryptHandler = { + delegate.decryptedStreamResult = .success(self.makeDecryptedResult(packet: packet, request: "GET /after-stop")) + } + controller.delegate = delegate + controller.loadViewIfNeeded() + controller.render(snapshot: makeSnapshot( + packet: packet, + inspectionState: loadedInspectionState(packet: packet, inspection: makeFrameInspection(for: packet)) + )) + let tabs = try #require(segmentedControl(labels: ["Packet", "Decrypted"], 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() + + let stopButton = try #require(allSubviews(ofType: NSButton.self, in: controller.view).first { + $0.title == "Stop and Decrypt" + }) + stopButton.sendAction(stopButton.action, to: stopButton.target) + await drainMainQueue() + await drainMainQueue() + + #expect(delegate.stopAndDecryptCount == 1) + #expect(delegate.decryptedStreamLoadCount == 2) + #expect(textView.string == "GET /after-stop") + } + + @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(segmentedControl(labels: ["Packet", "Decrypted"], in: controller.view)) + let textView = try #require(allSubviews(ofType: NSTextView.self, in: controller.view).first { $0.usesFindBar }) + tabs.selectedSegment = 1 + tabs.sendAction(tabs.action, to: tabs.target) + + controller.render(snapshot: makeSnapshot( + packet: secondPacket, + inspectionState: loadedInspectionState(packet: secondPacket, inspection: makeFrameInspection(for: secondPacket)) + )) + #expect(delegate.decryptedStreamLoadCount == 2) + #expect(delegate.decryptedStreamCancellationChecks[0]()) + #expect(!delegate.decryptedStreamCancellationChecks[1]()) + + delegate.completeDecryptedStream(at: 0, with: .success(makeDecryptedResult(packet: firstPacket, request: "STALE"))) + await drainMainQueue() + #expect(textView.string != "STALE") + + delegate.completeDecryptedStream(at: 1, with: .success(makeDecryptedResult(packet: secondPacket, request: "CURRENT"))) + await drainMainQueue() + #expect(textView.string == "CURRENT") + } + @MainActor @Test func inspectorFilterIsAlwaysVisibleAndCommandShiftFIsReservedForSidebarMenu() throws { let packet = makePacket() @@ -132,7 +306,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 +1095,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 @@ -953,6 +1135,13 @@ struct PacketInspectorTreeViewModelTests { } } + private func segmentedControl(labels: [String], in view: NSView) -> NSSegmentedControl? { + allSubviews(ofType: NSSegmentedControl.self, in: view).first { control in + control.segmentCount == labels.count && + labels.indices.allSatisfy { control.label(forSegment: $0) == labels[$0] } + } + } + private func frame(_ upperFrame: NSRect, isVisuallyAbove lowerFrame: NSRect, in view: NSView) -> Bool { let tolerance: CGFloat = 1 if view.isFlipped { @@ -1121,7 +1310,7 @@ struct PacketInspectorTreeViewModelTests { ) } - private func makePacket(packetNumber: UInt64 = 1) -> PacketSummary { + private func makePacket(packetNumber: UInt64 = 1, sniDomainName: String? = nil) -> PacketSummary { PacketSummary( packetNumber: packetNumber, timestamp: Date(timeIntervalSince1970: 0), @@ -1136,7 +1325,18 @@ struct PacketInspectorTreeViewModelTests { infoSummary: "TCP packet", layers: [PacketLayer(name: "TCP")], decodeStatus: PacketDecodeStatus(kind: .complete), - captureMetadata: PacketCaptureMetadata(linkType: .ethernet, isTruncated: false) + captureMetadata: PacketCaptureMetadata(linkType: .ethernet, isTruncated: false), + sniDomainName: sniDomainName + ) + } + + private func makeDecryptedResult(packet: PacketSummary, request: String) -> DecryptedStreamResult { + DecryptedStreamResult( + protocolName: .tls, + client: PacketEndpoint(address: "10.0.0.1", port: 1234), + server: PacketEndpoint(address: "10.0.0.2", port: 443), + request: DecryptedStreamPayload(data: Data(request.utf8), observedByteCount: request.utf8.count, isTruncated: false), + response: DecryptedStreamPayload(data: Data(), observedByteCount: 0, isTruncated: false) ) } } @@ -1144,6 +1344,15 @@ struct PacketInspectorTreeViewModelTests { private final class PacketInspectorDelegateSpy: PacketInspectorViewControllerDelegate { var selectedDetailNodeID: String? var customColumnRequest: PacketCustomColumnRequest? + var decryptedStreamResult: Result? + var decryptedStreamLoadCount = 0 + var decryptedStreamCancellationChecks: [TCPFollowCancellationCheck] = [] + var defersDecryptedStreamCompletions = false + var tlsKeyLogSelectionCount = 0 + var tlsKeyLogSelectionOutcome: TLSKeyLogSelectionOutcome = .cancelled + var stopAndDecryptCount = 0 + var stopAndDecryptHandler: (() -> Void)? + private var decryptedStreamCompletions: [TCPViewerCompletion] = [] func packetInspectorViewController(_ controller: PacketInspectorViewController, didSelectDetailNode identifier: String?) { selectedDetailNodeID = identifier @@ -1155,4 +1364,44 @@ private final class PacketInspectorDelegateSpy: PacketInspectorViewControllerDel ) { customColumnRequest = request } + + func packetInspectorViewController( + _ controller: PacketInspectorViewController, + didRequestChooseTLSKeyLog completion: @escaping (TLSKeyLogSelectionOutcome) -> Void + ) { + tlsKeyLogSelectionCount += 1 + completion(tlsKeyLogSelectionOutcome) + } + + func packetInspectorViewControllerDidRequestStopAndDecrypt( + _ controller: PacketInspectorViewController, + completion: @escaping () -> Void + ) { + stopAndDecryptCount += 1 + stopAndDecryptHandler?() + completion() + } + + func packetInspectorViewController( + _ controller: PacketInspectorViewController, + loadDecryptedStreamFor packetID: PacketSummary.ID, + progress: @escaping TCPFollowProgressHandler, + shouldCancel: @escaping TCPFollowCancellationCheck, + completion: @escaping TCPViewerCompletion + ) { + decryptedStreamLoadCount += 1 + decryptedStreamCancellationChecks.append(shouldCancel) + if defersDecryptedStreamCompletions { + decryptedStreamCompletions.append(completion) + return + } + completion(decryptedStreamResult ?? .failure(TCPViewerCoreError( + code: .unavailableFeature, + message: "No decrypted stream result was configured." + ))) + } + + func completeDecryptedStream(at index: Int, with result: Result) { + decryptedStreamCompletions[index](result) + } }