commit acf58a7085519f5cb10a077d8144600c9005ee2b Author: Rodrigo Verdiani Date: Wed Apr 8 16:00:56 2026 -0300 add initial implementation of GCNet packet decryptor with pcapng support diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7f98a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# ===================== +# Java / Maven +# ===================== +target/ +*.class +*.jar +*.war +*.ear +*.log +*.tmp + +# Maven +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties + +# ===================== +# IDE - JetBrains (IntelliJ, etc.) +# ===================== +.idea/ +*.iws +*.iml +*.ipr +out/ + +# Eclipse +.classpath +.project +.settings/ +bin/ + +# VS Code +.vscode/ + +# NetBeans +nbproject/ +nbbuild/ +dist/ +nbdist/ +.nb-gradle/ + +# ===================== +# OS Files +# ===================== +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# ===================== +# Test / Capture Files +# ===================== +*.pcapng +*.pcap +*.cap +*.dump + +# ===================== +# Miscellaneous +# ===================== +*.swp +*.swo +*~ +.recommenders diff --git a/README.md b/README.md new file mode 100644 index 0000000..59a685b --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# GCNet Packet Decryptor + +A Java tool to decrypt and analyze GCNet (Grand Chase) packets from pcapng capture files. + +## Overview + +This tool reads pcapng files containing network captures of Grand Chase game traffic, filters TCP packets on a specified port (default: 8501), and decrypts them using the GCNet security protocol. It automatically: + +1. Parses pcapng file format +2. Extracts TCP segments and filters by port +3. Detects the initial key exchange packet (opcode 1) to obtain session keys +4. Decrypts all subsequent packets using DES-CBC +5. Validates packet integrity using MD5-HMAC ICV +6. Decompresses zlib-compressed payloads +7. Displays decrypted packet contents in human-readable format + +## Building + +```bash +mvn clean package +``` + +This creates two JAR files in `target/`: +- `gcnet-decryptor-1.0.0.jar` - Standalone JAR (requires dependencies) +- `gcnet-decryptor-1.0.0-jar-with-dependencies.jar` - Fat JAR with all dependencies (recommended) + +## Usage + +```bash +java -jar target/gcnet-decryptor-1.0.0-jar-with-dependencies.jar [port] +``` + +**Parameters:** +- ``: Path to the pcapng capture file (required) +- `[port]`: TCP port to filter on (default: 8501) + +**Examples:** + +```bash +# Decrypt packets on default port 8501 +java -jar target/gcnet-decryptor-1.0.0-jar-with-dependencies.jar capture.pcapng + +# Decrypt packets on custom port +java -jar target/gcnet-decryptor-1.0.0-jar-with-dependencies.jar capture.pcapng 9001 +``` + +## How It Works + +### GCNet Protocol Structure + +The GCNet protocol has two main layers: + +#### 1. Security Layer +- **Size** (2 bytes): Total security layer size +- **SPI** (2 bytes): Security Parameters Index +- **Sequence Number** (4 bytes): Packet counter +- **IV** (8 bytes): DES initialization vector +- **Encrypted Payload** (variable): DES-CBC encrypted data +- **ICV** (10 bytes): Integrity check value (MD5-HMAC truncated) + +#### 2. Payload Layer +- **Opcode** (2 bytes): Packet type identifier +- **Content Size** (4 bytes): Size of content +- **Compression Flag** (1 byte): Whether content is zlib-compressed +- **Content** (variable): Actual data (possibly compressed) +- **Null Padding** (4 bytes): End padding + +### Key Exchange + +The first packet (opcode 1) contains the session keys: +- Sent by server using default keys +- Contains new SPI, authentication key, and encryption key +- All subsequent packets use these new keys + +**Default Keys:** +- Encryption Key: `C7 D8 C4 BF B5 E9 C0 FD` +- Authentication Key: `C0 D3 BD C3 B7 CE B8 B8` + +### Encryption + +- **Algorithm**: DES in CBC mode +- **Padding**: Custom GCNet padding scheme (incrementing bytes) +- **Integrity**: MD5-HMAC truncated to 10 bytes + +### Compression + +- **Algorithm**: zlib +- **Header**: `78 01` +- **Structure**: First 4 bytes indicate decompressed size (little-endian) + +## Output Format + +For each packet, the tool displays: +- Source/destination IP and port +- TCP sequence number +- SPI and IV values +- ICV validation status +- Opcode and content size +- Hex dump of decrypted content +- Extracted readable strings + +## Project Structure + +``` +gcnet-decryptor/ +├── pom.xml +└── src/main/java/com/gcnet/decryptor/ + ├── GCNetDecryptor.java # Main application + ├── pcapng/ + │ ├── PcapngParser.java # pcapng file parser (wraps pcapngdecoder) + │ └── TcpPacketParser.java # TCP segment extractor + ├── security/ + │ └── GCNetSecurityAssociation.java # Decryption & ICV validation + └── payload/ + └── GCNetPayloadParser.java # Payload parser & decompression +``` + +## Dependencies + +- **[pcapng-decoder](https://github.com/bertrandmartel/pcapng-decoder)** by Bertrand Martel (MIT License) - Pure Java pcapng file parser + +## References + +Based on the [GCNet](https://github.com/frihet/GCNet) library by Gabriel F. (Frihet Dev), licensed under AGPL-3.0. + +Protocol documentation: +- [The Security Layer](../GCNet/docs/en/The%20Security%20Layer.md) +- [The Cryptography](../GCNet/docs/en/The%20Cryptography.md) +- [The Payload Layer](../GCNet/docs/en/The%20Payload%20Layer.md) +- [The Security Protocol Setup](../GCNet/docs/en/The%20Security%20Protocol%20Setup.md) + +## License + +This project is provided as-is for educational and analysis purposes. diff --git a/docs/ADDING_PACKET_PARSERS.md b/docs/ADDING_PACKET_PARSERS.md new file mode 100644 index 0000000..1badce0 --- /dev/null +++ b/docs/ADDING_PACKET_PARSERS.md @@ -0,0 +1,213 @@ +# Adding New Packet Parsers + +This guide explains how to implement parsers for new GCNet packet types. + +## Architecture Overview + +The packet parsing system uses several design patterns: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ GCNetPacketProcessor │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ PacketParserFactory (Factory) ││ +│ │ ┌────────────┐ ┌────────────┐ ┌──────────────────────────┐ ││ +│ │ │ PingParser │ │ PongParser │ │ VerifyAccountRequestParser│ ││ +│ │ └────────────┘ └────────────┘ └──────────────────────────┘ ││ +│ │ Strategy Pattern ││ +│ └─────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ PacketContext │ +│ • decrypted payload bytes │ +│ • TCP segment (src/dst IP:port) │ +│ • server port for direction detection │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Design Patterns Used + +| Pattern | Class | Purpose | +|---------|-------|---------| +| **Strategy** | `PacketParser` interface | Each opcode has its own parsing strategy | +| **Factory** | `PacketParserFactory` | Creates the correct parser for each opcode | +| **Context** | `PacketContext` | Immutable context with payload + connection metadata | +| **Registry** | `GCNetOpcode` enum | Maps opcode numbers to metadata and direction hints | +| **Null Object** | `GenericPayloadParser` | Fallback for unimplemented opcodes | + +## Step-by-Step: Adding a New Parser + +### 1. Define the Opcode + +Add the opcode to `GCNetOpcode.java`: + +```java +// In packets.com.gcpp.GCNetOpcode + +/** + * Example: Player join request. + * Structure: Player name (string), Character class (int), Level (short) + */ +ENU_PLAYER_JOIN_REQ(50, "ENU_PLAYER_JOIN_REQ", + "Player join request", Direction.CLIENT_TO_SERVER), +``` + +The `Direction` enum indicates expected traffic flow: +- `CLIENT_TO_SERVER` — Sent from client **to** server port (dstPort == serverPort) +- `SERVER_TO_CLIENT` — Sent from server **from** server port (srcPort == serverPort) +- `BIDIRECTIONAL` — Valid both ways (e.g., PING/PONG) + +### 2. Create the Parser + +Create a new class in `packets/parsers/`: + +```java +package com.gcnet.decryptor.packets.parsers; + +import packets.com.gcemu.gcpp.GCNetOpcode; +import packets.com.gcemu.gcpp.PacketContext; +import packets.com.gcemu.gcpp.PacketParser; + +import java.nio.ByteBuffer; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Parser for ENU_PLAYER_JOIN_REQ packets (opcode 50). + * + *

Packet Structure:

+ * + * + * + * + * + * + * + * + * + *
OffsetSizeTypeDescription
02ushort (BE)Opcode (50)
24int (BE)Content size
61boolCompression flag
74int (LE)Player name byte length
11varstring (UTF-16LE)Player name
var4int (LE)Character class ID
var2short (LE)Character level
+ * + *

Direction: {@link GCNetOpcode.Direction#CLIENT_TO_SERVER}

+ */ +@PacketParser.PacketParserFor(opcode = 50) +public class PlayerJoinRequestParser implements PacketParser { + + private static final int CONTENT_OFFSET = 7; + + @Override + public ParseResult parse(PacketContext context) { + byte[] payload = context.decryptedPayload(); + + if (payload.length < CONTENT_OFFSET + 10) { + return ParseResult.empty(GCNetOpcode.ENU_PLAYER_JOIN_REQ, "Client → Server"); + } + + int offset = CONTENT_OFFSET; + Map fields = new LinkedHashMap<>(); + + // Parse player name + ByteBuffer bb = ByteBuffer.wrap(payload, offset, 4) + .order(java.nio.ByteOrder.LITTLE_ENDIAN); + int nameLen = bb.getInt(); + offset += 4; + + if (offset + nameLen <= payload.length) { + String playerName = new String(payload, offset, nameLen, java.nio.charset.StandardCharsets.UTF_16LE); + fields.put("Player Name", playerName); + offset += nameLen; + } + + // Parse character class + bb = ByteBuffer.wrap(payload, offset, 4) + .order(java.nio.ByteOrder.LITTLE_ENDIAN); + int charClass = bb.getInt(); + fields.put("Character Class", String.valueOf(charClass)); + offset += 4; + + // Parse level + bb = ByteBuffer.wrap(payload, offset, 2) + .order(java.nio.ByteOrder.LITTLE_ENDIAN); + short level = bb.getShort(); + fields.put("Level", String.valueOf(level)); + + return ParseResult.parsed( + GCNetOpcode.ENU_PLAYER_JOIN_REQ, + "Client → Server", + "ENU_PLAYER_JOIN_REQ { name: string_utf16_le, class_id: int32, level: int16 }", + fields, + formatContentHex(payload), + "player=\"" + fields.getOrDefault("Player Name", "?") + "\"" + ); + } + + private String formatContentHex(byte[] data) { + var sb = new StringBuilder(); + for (int i = 0; i < data.length; i++) { + if (i % 16 == 0 && i > 0) sb.append("\n"); + sb.append(String.format("%02X ", data[i])); + } + return sb.toString().trim(); + } +} +``` + +### 3. Register the Parser + +Add registration in `PacketParserFactory.registerBuiltInParsers()`: + +```java +private void registerBuiltInParsers() { + // ... existing registrations ... + + // Player Management + registerParser(50, new PlayerJoinRequestParser()); + registerParser(51, new PlayerJoinAckParser()); +} +``` + +### 4. Direction Detection + +The `PacketContext` automatically determines direction: + +```java +public boolean isClientToServer() { + return segment.dstPort() == serverPort; // Destination is the server +} + +public boolean isServerToClient() { + return segment.srcPort() == serverPort; // Source is the server +} +``` + +For a server on port 9501: +- `10.0.1.10:51094 → 10.0.1.10:9501` → **Client → Server** (dstPort == 9501) +- `10.0.1.10:9501 → 10.0.1.10:51094` → **Server → Client** (srcPort == 9501) + +## File Structure + +``` +src/main/java/com/gcnet/decryptor/packets/ +├── GCNetOpcode.java ← Opcode registry (add new opcodes here) +├── PacketContext.java ← Immutable context object +├── PacketParser.java ← Strategy interface + annotation +├── PacketParserFactory.java ← Factory (register new parsers here) +└── parsers/ + ├── GenericPayloadParser.java ← Fallback for unknown opcodes + ├── KeyExchangeParser.java ← Opcode 1 + ├── PingParser.java ← Opcode 39 + ├── PongParser.java ← Opcode 40 + ├── VerifyAccountRequestParser.java ← Opcode 34 + └── VerifyAccountAckParser.java ← Opcode 35 +``` + +## Testing + +Run the decryptor with your capture file: + +```bash +java -jar target/gcnet-decryptor-1.0.0-jar-with-dependencies.jar capture.pcapng 9501 +``` + +New parsers will automatically be invoked for their registered opcodes, producing structured field output instead of raw hex dumps. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..92310ef --- /dev/null +++ b/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + com.gcemu + gcpp + 1.0.0 + jar + + + 25 + 25 + UTF-8 + + + + + org.projectlombok + lombok + 1.18.44 + provided + + + fr.bmartel + pcapngdecoder + 1.2 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + 1.18.44 + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + com.gcemu.gcpp.GCPacketParser + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.6.0 + + + + com.gcemu.gcpp.GCPacketParser + + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + + + + diff --git a/src/main/java/com/gcemu/gcpp/CliArguments.java b/src/main/java/com/gcemu/gcpp/CliArguments.java new file mode 100644 index 0000000..c5448d1 --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/CliArguments.java @@ -0,0 +1,37 @@ +package com.gcemu.gcpp; + +import lombok.Getter; + +import java.io.File; + +@Getter +public class CliArguments { + private static final int DEFAULT_PORT = 9501; + + private final File pcapngFile; + private final int targetPort; + + public CliArguments(String[] args) { + if (args.length < 1) { + printUsage(); + System.exit(1); + } + + this.pcapngFile = new File(args[0]); + this.targetPort = args.length > 1 ? Integer.parseInt(args[1]) : DEFAULT_PORT; + } + + public void validate() { + if (!pcapngFile.exists() || !pcapngFile.isFile()) { + System.err.println("Error: File not found or not accessible: " + pcapngFile.getPath()); + System.exit(1); + } + } + + private void printUsage() { + System.out.println("Usage: java -jar gcnet-decryptor.jar [port]"); + System.out.println(" : Path to the pcapng capture file"); + System.out.println(" [port]: TCP port to filter on (default: " + DEFAULT_PORT + ")"); + System.exit(1); + } +} diff --git a/src/main/java/com/gcemu/gcpp/GCPacketParser.java b/src/main/java/com/gcemu/gcpp/GCPacketParser.java new file mode 100644 index 0000000..8ef1941 --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/GCPacketParser.java @@ -0,0 +1,86 @@ +package com.gcemu.gcpp; + +import com.gcemu.gcpp.pcapng.TcpPacketParser; + +public class GCPacketParser { + private final PacketExtractor extractor = new PacketExtractor(); + private final OutputFormatter formatter = new OutputFormatter(); + + private final CliArguments cliArgs; + private final PacketProcessor processor; + + public GCPacketParser(String[] args) { + this.cliArgs = new CliArguments(args); + this.processor = new PacketProcessor(cliArgs.getTargetPort()); + } + + public void run() { + cliArgs.validate(); + + formatter.printHeader(cliArgs.getPcapngFile().getName(), cliArgs.getTargetPort()); + + try { + var extractionResult = extractor.extract(cliArgs.getPcapngFile(), cliArgs.getTargetPort()); + formatter.printParsingPhase(extractionResult.totalCaptured()); + formatter.printExtractionPhase(extractionResult.tcpSegmentsFound(), extractionResult.filteredSegments().size()); + + if (extractionResult.filteredSegments().isEmpty()) { + formatter.printNoSegmentsFound(); + return; + } + + formatter.printDecryptionPhase(); + processPackets(extractionResult.filteredSegments()); + + } catch (Exception e) { + System.err.println("Error processing file: " + e.getMessage()); + System.exit(1); + } + } + + private void processPackets(java.util.List segments) { + var packetCount = 0; + var decryptedCount = 0; + var failedCount = 0; + + for (TcpPacketParser.TcpSegment segment : segments) { + packetCount++; + formatter.printPacketHeader(packetCount, segment, segment.payload().length); + + var result = processor.process(segment); + + if (result.isTooSmall()) { + formatter.printSkippedPacket(); + continue; + } + + if (!result.isSuccess()) { + formatter.printDecryptionError(result.errorMessage()); + failedCount++; + System.out.println(); + continue; + } + + formatter.printDecryptionResult(result.decryptionResult()); + + if (result.isKeyExchangeDetected()) { + formatter.printKeyExchangeDetected(); + if (processor.isKeyExchangeProcessed()) { + formatter.printKeyExchangeSuccess(); + } else { + formatter.printKeyExchangeFailure(); + } + } + + formatter.printParsedPacket(result.parseResult()); + decryptedCount++; + System.out.println(); + } + + formatter.printSummary(packetCount, decryptedCount, failedCount, processor.isKeyExchangeProcessed()); + } + + static void main(String[] args) { + new GCPacketParser(args).run(); + } +} diff --git a/src/main/java/com/gcemu/gcpp/OutputFormatter.java b/src/main/java/com/gcemu/gcpp/OutputFormatter.java new file mode 100644 index 0000000..36daf2d --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/OutputFormatter.java @@ -0,0 +1,148 @@ +package com.gcemu.gcpp; + +import com.gcemu.gcpp.packets.PacketParser; +import com.gcemu.gcpp.pcapng.TcpPacketParser; +import com.gcemu.gcpp.security.SecurityAssociation; + +/** + * Handles formatted console output for the parser. + */ +public class OutputFormatter { + public void printHeader(String fileName, int targetPort) { + System.out.println("========================================"); + System.out.println("GCEmu Packet Parser"); + System.out.println("========================================"); + System.out.println("Input file: " + fileName); + System.out.println("Target port: " + targetPort); + System.out.println(); + } + + public void printParsingPhase(int totalPackets) { + System.out.println("[1/4] Parsing pcapng file..."); + System.out.println(" Total packets captured: " + totalPackets); + } + + public void printExtractionPhase(int tcpFound, int filteredCount) { + System.out.println("[2/4] Extracting TCP segments..."); + System.out.println(" TCP segments found: " + tcpFound); + System.out.println(" Segments on target port: " + filteredCount); + System.out.println(); + } + + public void printNoSegmentsFound() { + System.out.println("No TCP segments found on target port"); + } + + public void printDecryptionPhase() { + System.out.println("[3/4] Parsing packets..."); + System.out.println("========================================"); + System.out.println(); + } + + public void printPacketHeader(int packetNum, TcpPacketParser.TcpSegment segment, int payloadSize) { + System.out.println("────────────────────────────────────"); + System.out.println("Packet #" + packetNum); + System.out.println("────────────────────────────────────"); + System.out.println(segment); + System.out.println("TCP Payload Size: " + payloadSize + " bytes"); + } + + public void printSkippedPacket() { + System.out.println(" [SKIP] Too small to be a GCNet packet"); + System.out.println(); + } + + public void printDecryptionResult(SecurityAssociation.DecryptionResult result) { + System.out.println(" SPI: 0x" + String.format("%04X", result.spi())); + System.out.println(" IV: " + bytesToHex(result.iv())); + System.out.println(" ICV Valid: " + result.icvValid()); + System.out.println(" Decrypted Payload Size: " + result.decryptedPayload().length + " bytes"); + } + + public void printKeyExchangeDetected() { + System.out.println(); + System.out.println(" *** KEY EXCHANGE PACKET DETECTED (Opcode 1) ***"); + System.out.println(" Extracting session keys for subsequent packets..."); + System.out.println(); + } + + public void printKeyExchangeSuccess() { + System.out.println(" [OK] Session keys extracted - all following packets will use these"); + } + + public void printKeyExchangeFailure() { + System.out.println(" [WARN] Failed to parse key exchange, using default keys"); + } + + public void printParsedPacket(PacketParser.ParseResult parseResult) { + System.out.println(); + System.out.println(" ┌─────────────────────────────────────────────────"); + System.out.println(" │ Packet: " + parseResult.opcodeName()); + System.out.println(" │ Opcode: 0x" + String.format("%04X", parseResult.opcode().getOpcode()) + + " (" + parseResult.opcode().getOpcode() + ")"); + System.out.println(" │ Direction: " + parseResult.direction()); + System.out.println(" │ Structure: " + parseResult.structure()); + System.out.println(" ├─────────────────────────────────────────────────"); + + if (!parseResult.fields().isEmpty()) { + System.out.println(" │ Fields:"); + for (var entry : parseResult.fields().entrySet()) { + var value = entry.getValue(); + + // Truncate long values like hex strings + if (value.length() > 64) { + value = value.substring(0, 60) + "..."; + } + + System.out.println(" │ " + entry.getKey() + ": " + value); + } + } + + if (!parseResult.readableText().isEmpty()) { + System.out.println(" ├─────────────────────────────────────────────────"); + System.out.println(" │ Extracted: " + parseResult.readableText()); + } + + if (!parseResult.rawHex().isEmpty()) { + System.out.println(" ├─────────────────────────────────────────────────"); + System.out.println(" │ Raw Hex:"); + + var lines = parseResult.rawHex().split("\n"); + for (var line : lines) { + System.out.println(" │ " + line); + } + } + + System.out.println(" └─────────────────────────────────────────────────"); + } + + public void printDecryptionError(String error) { + System.out.println(" [ERROR] Parsing failed: " + error); + } + + public void printSummary(int total, int decrypted, int failed, boolean keyExchangeProcessed) { + System.out.println("========================================"); + System.out.println("[4/4] Summary"); + System.out.println("========================================"); + System.out.println("Total packets processed: " + total); + System.out.println("Successfully parsed: " + decrypted); + System.out.println("Failed: " + failed); + System.out.println("Key exchange processed: " + keyExchangeProcessed); + System.out.println("========================================"); + } + + public String bytesToHex(byte[] bytes) { + return bytesToHex(bytes, bytes.length); + } + + public String bytesToHex(byte[] bytes, int length) { + var sb = new StringBuilder(); + var end = Math.min(length, bytes.length); + + for (var i = 0; i < end; i++) { + sb.append(String.format("%02X ", bytes[i])); + } + + return sb.toString().trim(); + } +} diff --git a/src/main/java/com/gcemu/gcpp/PacketExtractor.java b/src/main/java/com/gcemu/gcpp/PacketExtractor.java new file mode 100644 index 0000000..4c79210 --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/PacketExtractor.java @@ -0,0 +1,43 @@ +package com.gcemu.gcpp; + +import com.gcemu.gcpp.pcapng.PcapngParser; +import com.gcemu.gcpp.pcapng.TcpPacketParser; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Extracts TCP segments from pcapng files filtered by port. + */ +public class PacketExtractor { + private final PcapngParser pcapngParser = new PcapngParser(); + private final TcpPacketParser tcpParser = new TcpPacketParser(); + + public ExtractionResult extract(File file, int targetPort) throws Exception { + var rawPackets = pcapngParser.parseFile(file); + + var packetsByLinkLayer = rawPackets.stream() + .collect(Collectors.groupingBy(PcapngParser.Packet::linkLayerType)); + + List allTcpSegments = new ArrayList<>(); + for (var entry : packetsByLinkLayer.entrySet()) { + var packets = entry.getValue(); + var segments = packets.stream() + .map(packet -> tcpParser.parsePacket(packet.packetData())) + .filter(Objects::nonNull) + .toList(); + allTcpSegments.addAll(segments); + } + + var filteredSegments = TcpPacketParser.filterByPort(allTcpSegments, targetPort); + + return new ExtractionResult(rawPackets.size(), allTcpSegments.size(), filteredSegments); + } + + public record ExtractionResult(int totalCaptured, int tcpSegmentsFound, + List filteredSegments) { + } +} diff --git a/src/main/java/com/gcemu/gcpp/PacketProcessor.java b/src/main/java/com/gcemu/gcpp/PacketProcessor.java new file mode 100644 index 0000000..51cce57 --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/PacketProcessor.java @@ -0,0 +1,80 @@ +package com.gcemu.gcpp; + +import com.gcemu.gcpp.packets.PacketContext; +import com.gcemu.gcpp.packets.PacketParser; +import com.gcemu.gcpp.packets.PacketParserFactory; +import com.gcemu.gcpp.pcapng.TcpPacketParser; +import com.gcemu.gcpp.security.SecurityAssociation; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import static java.util.Objects.nonNull; + +@RequiredArgsConstructor +public class PacketProcessor { + private static final int MIN_GCNET_PACKET_SIZE = 28; + + private final PacketParserFactory parserFactory = new PacketParserFactory(); + + private SecurityAssociation securityAssociation = new SecurityAssociation(); + private final int serverPort; + + @Getter + private boolean keyExchangeProcessed = false; + + public ProcessingResult process(TcpPacketParser.TcpSegment segment) { + var tcpPayload = segment.payload(); + + if (tcpPayload.length < MIN_GCNET_PACKET_SIZE) { + return ProcessingResult.tooSmall(); + } + + try { + var result = securityAssociation.decryptPacket(tcpPayload); + var newKeyExchangeDetected = false; + + // Check for key exchange packet (only process once) + if (!keyExchangeProcessed && SecurityAssociation.isInitialKeyExchange(result.decryptedPayload())) { + var newAssociation = SecurityAssociation.parseInitialKeyExchange(result.decryptedPayload()); + + if (nonNull(newAssociation)) { + securityAssociation = newAssociation; + keyExchangeProcessed = true; + newKeyExchangeDetected = true; + } + } + + // Create context and parse with appropriate parser + var context = new PacketContext(result.decryptedPayload(), segment, serverPort); + var opcode = context.getOpcode(); + var parser = parserFactory.getParser(opcode.getOpcode()); + var parseResult = parser.parse(context); + + return ProcessingResult.success(result, parseResult, newKeyExchangeDetected); + } catch (Exception e) { + return ProcessingResult.error(e.getMessage()); + } + } + + public record ProcessingResult( + boolean isSuccess, + boolean isTooSmall, + boolean isKeyExchangeDetected, + SecurityAssociation.DecryptionResult decryptionResult, + PacketParser.ParseResult parseResult, + String errorMessage + ) { + public static ProcessingResult tooSmall() { + return new ProcessingResult(false, true, false, null, null, null); + } + + public static ProcessingResult success(SecurityAssociation.DecryptionResult decryption, + PacketParser.ParseResult parseResult, boolean keyExchangeDetected) { + return new ProcessingResult(true, false, keyExchangeDetected, decryption, parseResult, null); + } + + public static ProcessingResult error(String message) { + return new ProcessingResult(false, false, false, null, null, message); + } + } +} diff --git a/src/main/java/com/gcemu/gcpp/packets/Opcode.java b/src/main/java/com/gcemu/gcpp/packets/Opcode.java new file mode 100644 index 0000000..0b36b9b --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/packets/Opcode.java @@ -0,0 +1,38 @@ +package com.gcemu.gcpp.packets; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.HashMap; +import java.util.Map; + +@Getter +@RequiredArgsConstructor +public enum Opcode { + KEY_EXCHANGE(1, "KEY_EXCHANGE", "Initial security key exchange", Direction.SERVER_TO_CLIENT), + + UNKNOWN(-1, "UNKNOWN", "Unknown opcode", Direction.BIDIRECTIONAL); + + private final int opcode; + private final String name; + private final String description; + private final Direction direction; + + private static final Map OPCODE_MAP = new HashMap<>(); + + static { + for (var opcode : values()) { + OPCODE_MAP.put(opcode.opcode, opcode); + } + } + + public static Opcode valueOf(int opcode) { + return OPCODE_MAP.getOrDefault(opcode, UNKNOWN); + } + public enum Direction { + CLIENT_TO_SERVER, + SERVER_TO_CLIENT, + BIDIRECTIONAL + } +} diff --git a/src/main/java/com/gcemu/gcpp/packets/PacketContext.java b/src/main/java/com/gcemu/gcpp/packets/PacketContext.java new file mode 100644 index 0000000..6359b56 --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/packets/PacketContext.java @@ -0,0 +1,38 @@ +package com.gcemu.gcpp.packets; + +import com.gcemu.gcpp.pcapng.TcpPacketParser; + +public record PacketContext( + byte[] decryptedPayload, + + TcpPacketParser.TcpSegment segment, + + int serverPort +) { + public boolean isClientToServer() { + return segment.dstPort() == serverPort; + } + + public boolean isServerToClient() { + return segment.srcPort() == serverPort; + } + + public Opcode getOpcode() { + if (decryptedPayload.length < 2) { + return Opcode.UNKNOWN; + } + + var opcode = ((decryptedPayload[0] & 0xFF) << 8) | (decryptedPayload[1] & 0xFF); + return Opcode.valueOf(opcode); + } + + @Override + public String toString() { + return String.format("%s [%d bytes] %s:%d -> %s:%d", + getOpcode().getName(), + decryptedPayload.length, + segment.srcIp(), segment.srcPort(), + segment.dstIp(), segment.dstPort() + ); + } +} diff --git a/src/main/java/com/gcemu/gcpp/packets/PacketParser.java b/src/main/java/com/gcemu/gcpp/packets/PacketParser.java new file mode 100644 index 0000000..2a3478b --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/packets/PacketParser.java @@ -0,0 +1,46 @@ +package com.gcemu.gcpp.packets; + +import java.util.Collections; +import java.util.Map; + +public interface PacketParser { + ParseResult parse(PacketContext context); + + record ParseResult( + Opcode opcode, + String opcodeName, + String direction, + String structure, + Map fields, + String rawHex, + String readableText + ) { + + public static ParseResult empty(Opcode opcode, String direction) { + return new ParseResult( + opcode, + opcode.getName(), + direction, + "", + Collections.emptyMap(), + "", + "" + ); + } + + public static ParseResult parsed(Opcode opcode, String direction, + String structure, Map fields, + String rawHex, String readableText) { + return new ParseResult( + opcode, opcode.getName(), direction, structure, + fields, rawHex, readableText + ); + } + } + + @java.lang.annotation.Target(java.lang.annotation.ElementType.TYPE) + @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.RUNTIME) + @interface PacketParserFor { + int opcode(); + } +} diff --git a/src/main/java/com/gcemu/gcpp/packets/PacketParserFactory.java b/src/main/java/com/gcemu/gcpp/packets/PacketParserFactory.java new file mode 100644 index 0000000..643da65 --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/packets/PacketParserFactory.java @@ -0,0 +1,28 @@ +package com.gcemu.gcpp.packets; + +import com.gcemu.gcpp.packets.parsers.*; + +import java.util.HashMap; +import java.util.Map; + +public class PacketParserFactory { + private final Map parsers = new HashMap<>(); + private final PacketParser defaultParser = new GenericPayloadParser(); + + public PacketParserFactory() { + registerBuiltInParsers(); + } + + public PacketParser getParser(int opcode) { + return parsers.getOrDefault(opcode, defaultParser); + } + + public void registerParser(int opcode, PacketParser parser) { + parsers.put(opcode, parser); + } + + private void registerBuiltInParsers() { + // Security Protocol + registerParser(1, new KeyExchangeParser()); + } +} diff --git a/src/main/java/com/gcemu/gcpp/packets/parsers/GenericPayloadParser.java b/src/main/java/com/gcemu/gcpp/packets/parsers/GenericPayloadParser.java new file mode 100644 index 0000000..94524bd --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/packets/parsers/GenericPayloadParser.java @@ -0,0 +1,38 @@ +package com.gcemu.gcpp.packets.parsers; + +import com.gcemu.gcpp.packets.PacketContext; +import com.gcemu.gcpp.packets.PacketParser; +import com.gcemu.gcpp.packets.PacketParserFactory; + +/** + * Generic fallback parser for unrecognized or unimplemented opcodes. + * + *

This parser provides a basic hex dump and string extraction for any + * packet, even if no specific parser is registered for its opcode. It + * follows the Null Object Pattern, providing sensible + * defaults instead of null or error states.

+ * + *

When adding support for a new opcode, replace this parser in + * {@link PacketParserFactory} with a specific implementation.

+ */ +public class GenericPayloadParser implements PacketParser { + @Override + public ParseResult parse(PacketContext context) { + var opcode = context.getOpcode(); + var direction = resolveDirection(context); + + return ParseResult.empty(opcode, direction); + } + + private String resolveDirection(PacketContext context) { + if (context.isClientToServer()) { + return "Client → Server"; + } + + if (context.isServerToClient()) { + return "Server → Client"; + } + + return "Unknown"; + } +} diff --git a/src/main/java/com/gcemu/gcpp/packets/parsers/KeyExchangeParser.java b/src/main/java/com/gcemu/gcpp/packets/parsers/KeyExchangeParser.java new file mode 100644 index 0000000..df4092f --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/packets/parsers/KeyExchangeParser.java @@ -0,0 +1,141 @@ +package com.gcemu.gcpp.packets.parsers; + +import com.gcemu.gcpp.packets.Opcode; +import com.gcemu.gcpp.packets.PacketContext; +import com.gcemu.gcpp.packets.PacketParser; + +import java.nio.ByteBuffer; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Parser for KEY_EXCHANGE packets (opcode 1). + * + *

Packet Structure:

+ * + * + * + * + * + * + * + * + * + * + * + * + * + *
OffsetSizeTypeDescription
02ushort (BE)Opcode (1)
24int (BE)Content size (38)
61boolCompression flag (false)
72ushort (BE)New SPI for session
94int (BE)Auth key length
13varbyte[]Authentication key
var4int (BE)Crypto key length
varvarbyte[]Encryption key
var4int (BE)Sequence number (initial)
var4int (BE)Last sequence number
var4int (BE)Replay window mask
+ * + *

This is the first packet in every session, sent by the server using + * default encryption keys. It establishes the session-specific SPI, + * authentication key, and encryption key for all subsequent packets.

+ * + *

Direction: {@link Opcode.Direction#SERVER_TO_CLIENT}

+ */ +@PacketParser.PacketParserFor(opcode = 1) +public class KeyExchangeParser implements PacketParser { + private static final int CONTENT_OFFSET = 7; + + @Override + public ParseResult parse(PacketContext context) { + var payload = context.decryptedPayload(); + var direction = "Server → Client"; + + if (payload.length < CONTENT_OFFSET + 38) { + return ParseResult.empty(Opcode.KEY_EXCHANGE, direction); + } + + try { + var content = parseContent(payload); + return ParseResult.parsed( + Opcode.KEY_EXCHANGE, + direction, + "KEY_EXCHANGE { spi: ushort, auth_key: bytes, crypto_key: bytes, seq_num: uint, last_seq: uint, replay_mask: uint }", + content.fields(), + formatContentHex(payload), + content.readableText() + ); + } catch (Exception e) { + return ParseResult.empty(Opcode.KEY_EXCHANGE, direction); + } + } + + private record ParsedContent(Map fields, String readableText) {} + + private ParsedContent parseContent(byte[] payload) { + Map fields = new LinkedHashMap<>(); + var readable = new StringBuilder(); + var offset = CONTENT_OFFSET; + + // Parse SPI + var bb = ByteBuffer.wrap(payload, offset, 2) + .order(java.nio.ByteOrder.BIG_ENDIAN); + var spi = bb.getShort(); + offset += 2; + fields.put("SPI", String.format("0x%04X", spi)); + + // Parse auth key (with length prefix) + bb = ByteBuffer.wrap(payload, offset, 4).order(java.nio.ByteOrder.BIG_ENDIAN); + var authKeyLen = bb.getInt(); + offset += 4; + + if (offset + authKeyLen <= payload.length) { + var authKey = new byte[authKeyLen]; + System.arraycopy(payload, offset, authKey, 0, authKeyLen); + fields.put("Auth Key", bytesToHex(authKey)); + offset += authKeyLen; + } + + // Parse crypto key (with length prefix) + bb = ByteBuffer.wrap(payload, offset, 4).order(java.nio.ByteOrder.BIG_ENDIAN); + var cryptoKeyLen = bb.getInt(); + offset += 4; + + if (offset + cryptoKeyLen <= payload.length) { + var cryptoKey = new byte[cryptoKeyLen]; + System.arraycopy(payload, offset, cryptoKey, 0, cryptoKeyLen); + fields.put("Crypto Key", bytesToHex(cryptoKey)); + offset += cryptoKeyLen; + } + + // Parse sequence numbers + if (offset + 12 <= payload.length) { + bb = ByteBuffer.wrap(payload, offset, 12).order(java.nio.ByteOrder.BIG_ENDIAN); + var seqNum = bb.getInt(); + var lastSeq = bb.getInt(); + var replayMask = bb.getInt(); + + fields.put("Seq Num", String.valueOf(seqNum)); + fields.put("Last Seq Num", String.valueOf(lastSeq)); + fields.put("Replay Window Mask", String.format("0x%08X", replayMask)); + } + + readable.append("spi=0x").append(String.format("%04X", spi)); + return new ParsedContent(fields, readable.toString()); + } + + private String bytesToHex(byte[] bytes) { + var sb = new StringBuilder(); + + for (var b : bytes) { + sb.append(String.format("%02X", b)); + } + + return sb.toString(); + } + + private String formatContentHex(byte[] data) { + var sb = new StringBuilder(); + + for (var i = 0; i < data.length; i++) { + if (i % 16 == 0 && i > 0) { + sb.append("\n"); + } + + sb.append(String.format("%02X ", data[i])); + } + + return sb.toString().trim(); + } +} diff --git a/src/main/java/com/gcemu/gcpp/pcapng/PcapngParser.java b/src/main/java/com/gcemu/gcpp/pcapng/PcapngParser.java new file mode 100644 index 0000000..fb7b81f --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/pcapng/PcapngParser.java @@ -0,0 +1,37 @@ +package com.gcemu.gcpp.pcapng; + +import fr.bmartel.pcapdecoder.PcapDecoder; +import fr.bmartel.pcapdecoder.structure.types.IPcapngType; +import fr.bmartel.pcapdecoder.structure.types.inter.IEnhancedPacketBLock; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +public class PcapngParser { + public static final int LINK_TYPE_ETHERNET = 1; + + public List parseFile(File file) throws Exception { + List packets = new ArrayList<>(); + + var decoder = new PcapDecoder(file.getAbsolutePath()); + decoder.decode(); + + var sectionList = decoder.getSectionList(); + + for (IPcapngType block : sectionList) { + if (block instanceof IEnhancedPacketBLock epb) { + var data = epb.getPacketData(); + packets.add(new Packet( + data, + LINK_TYPE_ETHERNET + )); + } + } + + return packets; + } + + public record Packet(byte[] packetData, int linkLayerType) { + } +} diff --git a/src/main/java/com/gcemu/gcpp/pcapng/TcpPacketParser.java b/src/main/java/com/gcemu/gcpp/pcapng/TcpPacketParser.java new file mode 100644 index 0000000..e3e4d49 --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/pcapng/TcpPacketParser.java @@ -0,0 +1,173 @@ +package com.gcemu.gcpp.pcapng; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; + +import static java.util.Objects.isNull; + +public class TcpPacketParser { + private static final int ETHERNET_HEADER_SIZE = 14; + private static final short ETHERNET_TYPE_IPV4 = 0x0800; + private static final byte IP_PROTOCOL_TCP = 6; + + public TcpSegment parsePacket(byte[] packetData) { + int ipOffset; + + // Auto-detect packet format by examining the data + // This handles cases where the pcapng library reports wrong link type + ipOffset = detectIpOffset(packetData); + + if (ipOffset < 0) { + return null; // Failed to parse + } + + return parseIPv4AndTCP(packetData, ipOffset); + } + + /** + * Auto-detect where the IP header starts in the packet data. + * Handles various link layer types and raw captures. + */ + private int detectIpOffset(byte[] packetData) { + if (isNull(packetData) || packetData.length < 20) { + return -1; + } + + // Try to detect IPv4 header: first byte should be 0x45 (version 4, IHL 5) + for (var offset = 0; offset <= Math.min(40, packetData.length - 20); offset++) { + var versionAndIhl = packetData[offset]; + var version = (versionAndIhl >> 4) & 0x0F; + var ihl = versionAndIhl & 0x0F; + + if (version == 4 && ihl >= 5) { + // Looks like IPv4 header, verify protocol field is TCP + var headerLength = ihl * 4; + if (offset + headerLength + 13 <= packetData.length) { + var protocol = packetData[offset + 9]; + if (protocol == IP_PROTOCOL_TCP) { + return offset; + } + } + } + } + + // Try standard Ethernet header + if (packetData.length >= ETHERNET_HEADER_SIZE + 20) { + var bb = ByteBuffer.wrap(packetData).order(ByteOrder.BIG_ENDIAN); + bb.position(12); + var etherType = bb.getShort(); + + if (etherType == ETHERNET_TYPE_IPV4) { + return ETHERNET_HEADER_SIZE; + } + } + + // Try Linux SLL header (16 bytes) + if (packetData.length >= 16 + 20) { + var bb = ByteBuffer.wrap(packetData, 14, 2).order(ByteOrder.BIG_ENDIAN); + var protocolType = bb.getShort(); + + if (protocolType == ETHERNET_TYPE_IPV4) { + return 16; + } + } + + return -1; + } + + private TcpSegment parseIPv4AndTCP(byte[] packetData, int ipHeaderStart) { + if (packetData.length < ipHeaderStart + 20) { + System.err.println(" [DEBUG] IPv4: packet too small for IP header (" + packetData.length + " bytes, need " + (ipHeaderStart + 20) + ")"); + return null; + } + + var ipBb = ByteBuffer.wrap(packetData, ipHeaderStart, packetData.length - ipHeaderStart) + .order(ByteOrder.BIG_ENDIAN); + + var versionAndIhl = ipBb.get(); + var ipHeaderLength = (versionAndIhl & 0x0F) * 4; + ipBb.get(); // DSCP/ECN (TOS) + ipBb.getShort(); + ipBb.getShort(); // Identification + ipBb.getShort(); // Flags + Fragment Offset + ipBb.get(); // TTL + var protocol = ipBb.get(); + ipBb.getShort(); // Header checksum + + var srcIp = ipBb.getInt(); + var dstIp = ipBb.getInt(); + + if (protocol != IP_PROTOCOL_TCP) { + return null; // Not TCP + } + + // Parse TCP header + var tcpHeaderStart = ipHeaderStart + ipHeaderLength; + if (packetData.length < tcpHeaderStart + 20) { + return null; + } + + var tcpBb = ByteBuffer.wrap(packetData, tcpHeaderStart, packetData.length - tcpHeaderStart) + .order(ByteOrder.BIG_ENDIAN); + + var srcPort = tcpBb.getShort() & 0xFFFF; + var dstPort = tcpBb.getShort() & 0xFFFF; + var seqNum = tcpBb.getInt(); + tcpBb.getInt(); + + var dataOffsetAndFlags = tcpBb.get(); + var tcpHeaderLength = ((dataOffsetAndFlags >> 4) & 0x0F) * 4; + + if (tcpHeaderLength < 20 || tcpHeaderStart + tcpHeaderLength > packetData.length) { + return null; // Invalid TCP header + } + + var payloadStart = tcpHeaderStart + tcpHeaderLength; + var payloadLength = packetData.length - payloadStart; + + if (payloadLength <= 0) { + return null; // No payload + } + + var payload = new byte[payloadLength]; + System.arraycopy(packetData, payloadStart, payload, 0, payloadLength); + + return new TcpSegment( + intToIp(srcIp), + srcPort, + intToIp(dstIp), + dstPort, + seqNum, + payload + ); + } + + public static List filterByPort(List segments, int targetPort) { + List filtered = new ArrayList<>(); + for (var segment : segments) { + if (segment.srcPort() == targetPort || segment.dstPort() == targetPort) { + filtered.add(segment); + } + } + + return filtered; + } + + private String intToIp(int ip) { + return String.format("%d.%d.%d.%d", + (ip >> 24) & 0xFF, + (ip >> 16) & 0xFF, + (ip >> 8) & 0xFF, + ip & 0xFF); + } + + public record TcpSegment(String srcIp, int srcPort, String dstIp, int dstPort, int sequenceNumber, byte[] payload) { + @Override + public String toString() { + return String.format("%s:%d -> %s:%d [SEQ=%d] %d bytes", + srcIp, srcPort, dstIp, dstPort, sequenceNumber, payload.length); + } + } +} diff --git a/src/main/java/com/gcemu/gcpp/security/SecurityAssociation.java b/src/main/java/com/gcemu/gcpp/security/SecurityAssociation.java new file mode 100644 index 0000000..e68effb --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/security/SecurityAssociation.java @@ -0,0 +1,200 @@ +package com.gcemu.gcpp.security; + +import lombok.RequiredArgsConstructor; + +import javax.crypto.Cipher; +import javax.crypto.spec.DESKeySpec; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.SecretKeyFactory; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.MessageDigest; +import java.util.Arrays; + +@RequiredArgsConstructor +public class SecurityAssociation { + private static final byte[] DEFAULT_CRYPTO_KEY = { + (byte) 0xC7, (byte) 0xD8, (byte) 0xC4, (byte) 0xBF, + (byte) 0xB5, (byte) 0xE9, (byte) 0xC0, (byte) 0xFD + }; + private static final byte[] DEFAULT_AUTH_KEY = { + (byte) 0xC0, (byte) 0xD3, (byte) 0xBD, (byte) 0xC3, + (byte) 0xB7, (byte) 0xCE, (byte) 0xB8, (byte) 0xB8 + }; + + private final byte[] cryptoKey; + private final byte[] authKey; + private final short spi; + + /** + * Create a security association with default keys (for initial packet). + */ + public SecurityAssociation() { + this(DEFAULT_CRYPTO_KEY, DEFAULT_AUTH_KEY, (short) 0); + } + + public DecryptionResult decryptPacket(byte[] secureBuffer) { + return decryptPacket(secureBuffer, 0); + } + + public DecryptionResult decryptPacket(byte[] secureBuffer, int startIndex) { + var length = readShort(secureBuffer, startIndex); + var spiIndex = startIndex + 2; + var ivIndex = startIndex + 8; + var payloadIndex = startIndex + 16; + var icvIndex = startIndex + length - 10; + + var packetSpi = readShort(secureBuffer, spiIndex); + var iv = Arrays.copyOfRange(secureBuffer, ivIndex, ivIndex + 8); + + var payloadLength = length - 16 - 10; // Total - header - ICV + var encryptedPayload = Arrays.copyOfRange(secureBuffer, payloadIndex, payloadIndex + payloadLength); + + var storedIcv = Arrays.copyOfRange(secureBuffer, icvIndex, icvIndex + 10); + + var authData = Arrays.copyOfRange(secureBuffer, spiIndex, icvIndex); + var icvValid = validateIcv(authData, storedIcv); + + var decryptedPayload = decryptPayload(encryptedPayload, iv); + + return new DecryptionResult(packetSpi, iv, decryptedPayload, icvValid); + } + + private boolean validateIcv(byte[] authData, byte[] storedIcv) { + try { + var calculatedIcv = calculateIcv(authData); + return Arrays.equals(calculatedIcv, storedIcv); + } catch (Exception e) { + System.err.println("ICV validation error: " + e.getMessage()); + return false; + } + } + + private byte[] calculateIcv(byte[] authData) { + try { + var md = MessageDigest.getInstance("MD5"); + + var key = new byte[64]; + System.arraycopy(authKey, 0, key, 0, Math.min(authKey.length, 64)); + + var ipad = new byte[64]; + var opad = new byte[64]; + + for (var i = 0; i < 64; i++) { + ipad[i] = (byte) (key[i] ^ 0x36); + opad[i] = (byte) (key[i] ^ 0x5C); + } + + md.update(ipad); + var innerHash = md.digest(authData); + + var md2 = MessageDigest.getInstance("MD5"); + md2.update(opad); + var fullHmac = md2.digest(innerHash); + + return Arrays.copyOf(fullHmac, 10); + } catch (Exception e) { + throw new RuntimeException("Failed to calculate ICV", e); + } + } + + private byte[] decryptPayload(byte[] encryptedPayload, byte[] iv) { + try { + var keySpec = new DESKeySpec(cryptoKey); + var keyFactory = SecretKeyFactory.getInstance("DES"); + var key = keyFactory.generateSecret(keySpec); + + var cipher = Cipher.getInstance("DES/CBC/NoPadding"); + var ivSpec = new IvParameterSpec(iv); + cipher.init(Cipher.DECRYPT_MODE, key, ivSpec); + + var decrypted = cipher.doFinal(encryptedPayload); + + // Remove padding + var paddingLength = decrypted[decrypted.length - 1] + 1; + + if (paddingLength > 0 && paddingLength <= decrypted.length) { + return Arrays.copyOf(decrypted, decrypted.length - paddingLength); + } + + return decrypted; + } catch (Exception e) { + throw new RuntimeException("Failed to decrypt payload", e); + } + } + + private short readShort(byte[] buffer, int offset) { + // Little-endian + return (short) ((buffer[offset] & 0xFF) | ((buffer[offset + 1] & 0xFF) << 8)); + } + + /** + * Parse the initial key exchange packet (opcode 1) to extract new security parameters. + *

+ * Initial packet content structure (big-endian): + * - New SPI (2 bytes) + * - Authentication Key (8 bytes) + * - Encryption Key (8 bytes) + * - Sequence Number (4 bytes) + * - Last Sequence Number (4 bytes) + * - Replay Window Mask (4 bytes) + */ + public static SecurityAssociation parseInitialKeyExchange(byte[] decryptedPayload) { + try { + // Payload header: opcode (2 bytes), content size (4 bytes), compression flag (1 byte) + int offset = 7; + + // Parse content (big-endian) + var bb = ByteBuffer.wrap(decryptedPayload, offset, decryptedPayload.length - offset); + bb.order(ByteOrder.BIG_ENDIAN); + + var newSpi = bb.getShort(); + + var authKeyLen = bb.getInt(); + var newAuthKey = new byte[authKeyLen]; + bb.get(newAuthKey); + + var cryptoKeyLen = bb.getInt(); + var newCryptoKey = new byte[cryptoKeyLen]; + bb.get(newCryptoKey); + + var seqNum = bb.getInt(); + bb.getInt(); + bb.getInt(); + + System.out.println("Extracted security parameters from key exchange:"); + System.out.printf(" SPI: 0x%04X%n", newSpi); + System.out.printf(" Auth Key (%d bytes): %s%n", authKeyLen, bytesToHex(newAuthKey)); + System.out.printf(" Crypto Key (%d bytes): %s%n", cryptoKeyLen, bytesToHex(newCryptoKey)); + System.out.printf(" Seq Num: %d%n", seqNum); + + return new SecurityAssociation(newCryptoKey, newAuthKey, newSpi); + } catch (Exception e) { + System.err.println("Failed to parse initial key exchange: " + e.getMessage()); + return null; + } + } + + public static boolean isInitialKeyExchange(byte[] decryptedPayload) { + if (decryptedPayload.length < 2) { + return false; + } + + // Big-endian opcode + short opcode = (short) (((decryptedPayload[0] & 0xFF) << 8) | (decryptedPayload[1] & 0xFF)); + return opcode == 1; + } + + private static String bytesToHex(byte[] bytes) { + var sb = new StringBuilder(); + + for (var b : bytes) { + sb.append(String.format("%02X ", b)); + } + + return sb.toString().trim(); + } + + public record DecryptionResult(short spi, byte[] iv, byte[] decryptedPayload, boolean icvValid) { + } +}