From 9fdeafbbd73389540cf9773a74b41e994adb462e Mon Sep 17 00:00:00 2001 From: NikitolProject Date: Fri, 27 Feb 2026 02:11:20 +0300 Subject: [PATCH] feat(kotlin): add --key, --key-file, --password support to ArchiveDecoder Remove hardcoded KEY constant and accept key via CLI arguments. Add Argon2id KDF (Bouncy Castle) with parameters matching Rust impl, salt reading for password-derived archives, and hex/key-file parsing. --- kotlin/ArchiveDecoder.kt | 186 +++++++++++++++++++++++++++++++++------ 1 file changed, 161 insertions(+), 25 deletions(-) diff --git a/kotlin/ArchiveDecoder.kt b/kotlin/ArchiveDecoder.kt index 04ad7f3..985db48 100644 --- a/kotlin/ArchiveDecoder.kt +++ b/kotlin/ArchiveDecoder.kt @@ -9,6 +9,11 @@ import javax.crypto.Cipher import javax.crypto.Mac import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec +// Bouncy Castle — required only for --password (Argon2id KDF). +// Download: https://www.bouncycastle.org/download/bouncy-castle-java/#latest +// Run: java -cp bcprov-jdk18on-1.79.jar:ArchiveDecoder.jar ArchiveDecoderKt ... +import org.bouncycastle.crypto.generators.Argon2BytesGenerator +import org.bouncycastle.crypto.params.Argon2Parameters // --------------------------------------------------------------------------- // Constants (matching FORMAT.md Section 4 and src/key.rs) @@ -20,18 +25,6 @@ val MAGIC = byteArrayOf(0x00, 0xEA.toByte(), 0x72, 0x63) /** Fixed header size in bytes (FORMAT.md Section 4). */ const val HEADER_SIZE = 40 -/** - * Hardcoded 32-byte AES-256 key. - * Same key is used for AES-256-CBC encryption and HMAC-SHA-256 authentication (v1). - * Matches src/key.rs exactly. - */ -val KEY = byteArrayOf( - 0x7A, 0x35, 0xC1.toByte(), 0xD9.toByte(), 0x4F, 0xE8.toByte(), 0x2B, 0x6A, - 0x91.toByte(), 0x0D, 0xF3.toByte(), 0x58, 0xBC.toByte(), 0x74, 0xA6.toByte(), 0x1E, - 0x42, 0x8F.toByte(), 0xD0.toByte(), 0x63, 0xE5.toByte(), 0x17, 0x9B.toByte(), 0x2C, - 0xFA.toByte(), 0x84.toByte(), 0x06, 0xCD.toByte(), 0x3E, 0x79, 0xB5.toByte(), 0x50, -) - /** * Fixed 8-byte XOR obfuscation key (FORMAT.md Section 9.1). * Applied cyclically across the 40-byte header for obfuscation/de-obfuscation. @@ -96,7 +89,7 @@ fun readLeU32(data: ByteArray, offset: Int): Long { /** * Parse the 40-byte archive header. * - * Verifies: magic bytes, version == 2 (v1.1 format), reserved flag bits 4-7 are zero. + * Verifies: magic bytes, version == 2 (v1.1 format), reserved flag bits 5-7 are zero. */ fun parseHeader(data: ByteArray): ArchiveHeader { require(data.size >= HEADER_SIZE) { "Header too short: ${data.size} bytes" } @@ -113,7 +106,7 @@ fun parseHeader(data: ByteArray): ArchiveHeader { // Flags validation val flags = data[5].toInt() and 0xFF - require(flags and 0xF0 == 0) { "Unknown flags set: 0x${flags.toString(16)} (bits 4-7 must be zero)" } + require(flags and 0xE0 == 0) { "Unknown flags set: 0x${flags.toString(16)} (bits 5-7 must be zero)" } // Read remaining fields val fileCount = readLeU16(data, 6) @@ -277,6 +270,97 @@ fun xorHeader(buf: ByteArray) { } } +// --------------------------------------------------------------------------- +// Key source types and resolution +// --------------------------------------------------------------------------- + +/** How the user supplies the decryption key. */ +sealed class KeySource { + data class Hex(val hex: String) : KeySource() + data class KeyFile(val path: String) : KeySource() + data class Password(val password: String) : KeySource() +} + +/** Size of the KDF salt appended after the 40-byte header (FORMAT.md Section 4). */ +const val SALT_SIZE = 16 + +/** + * Read the 16-byte KDF salt from offset 40 if the KDF flag (bit 4) is set. + * Returns null when the archive uses a raw key (no salt present). + */ +fun readSalt(raf: RandomAccessFile, header: ArchiveHeader): ByteArray? { + if (header.flags and 0x10 == 0) return null + raf.seek(HEADER_SIZE.toLong()) + val salt = ByteArray(SALT_SIZE) + raf.readFully(salt) + return salt +} + +/** + * Derive a 32-byte key from a password and salt using Argon2id. + * + * Parameters match the Rust implementation (src/kdf.rs) exactly: + * - Argon2id v19 + * - memory = 19456 KiB (19 MiB) + * - iterations = 2 + * - parallelism = 1 + * - output length = 32 bytes + * + * Requires Bouncy Castle on the classpath. + */ +fun deriveKeyFromPassword(password: String, salt: ByteArray): ByteArray { + val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withVersion(Argon2Parameters.ARGON2_VERSION_13) + .withMemoryAsKB(19456) + .withIterations(2) + .withParallelism(1) + .withSalt(salt) + .build() + + val generator = Argon2BytesGenerator() + generator.init(params) + + val key = ByteArray(32) + generator.generateBytes(password.toByteArray(Charsets.UTF_8), key) + return key +} + +/** + * Parse a hex string into a ByteArray. + * Accepts lowercase, uppercase, or mixed hex. Must be exactly 64 hex chars (32 bytes). + */ +fun hexToBytes(hex: String): ByteArray { + require(hex.length == 64) { "Hex key must be exactly 64 hex characters (32 bytes), got ${hex.length}" } + return ByteArray(32) { i -> + hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } +} + +/** + * Resolve a [KeySource] into a 32-byte key. + * + * @param source How the key was supplied (hex, file, or password). + * @param salt Optional 16-byte salt read from the archive (required for Password source). + * @return 32-byte key suitable for AES-256 and HMAC-SHA-256. + */ +fun resolveKey(source: KeySource, salt: ByteArray?): ByteArray { + return when (source) { + is KeySource.Hex -> hexToBytes(source.hex) + is KeySource.KeyFile -> { + val bytes = File(source.path).readBytes() + require(bytes.size == 32) { "Key file must be exactly 32 bytes, got ${bytes.size}" } + bytes + } + is KeySource.Password -> { + requireNotNull(salt) { + "Archive does not contain a KDF salt (flag bit 4 not set). " + + "This archive was not created with --password. Use --key or --key-file instead." + } + deriveKeyFromPassword(source.password, salt) + } + } +} + // --------------------------------------------------------------------------- // Permissions restoration (v1.1) // --------------------------------------------------------------------------- @@ -317,10 +401,12 @@ fun applyPermissions(file: File, permissions: Int) { * * Follows FORMAT.md Section 10 decode order: * 1. Read and parse 40-byte header - * 2. Seek to tocOffset, read and parse TOC entries - * 3. For each file: verify HMAC, decrypt, decompress, verify SHA-256, write + * 2. Read KDF salt if present (flag bit 4) + * 3. Resolve key from [keySource] (hex, file, or password+salt) + * 4. Seek to tocOffset, read and parse TOC entries + * 5. For each file: verify HMAC, decrypt, decompress, verify SHA-256, write */ -fun decode(archivePath: String, outputDir: String) { +fun decode(archivePath: String, outputDir: String, keySource: KeySource) { val raf = RandomAccessFile(archivePath, "r") // Read 40-byte header @@ -336,6 +422,12 @@ fun decode(archivePath: String, outputDir: String) { val header = parseHeader(headerBytes) + // Read KDF salt if present (flag bit 4) + val salt = readSalt(raf, header) + + // Resolve the key from the supplied source + val key = resolveKey(keySource, salt) + // Read TOC bytes -- decrypt if TOC encryption flag is set (bit 1) val entries: List if (header.flags and 0x02 != 0) { @@ -343,7 +435,7 @@ fun decode(archivePath: String, outputDir: String) { raf.seek(header.tocOffset) val encryptedToc = ByteArray(header.tocSize.toInt()) raf.readFully(encryptedToc) - val decryptedToc = decryptAesCbc(encryptedToc, header.tocIv, KEY) + val decryptedToc = decryptAesCbc(encryptedToc, header.tocIv, key) entries = parseToc(decryptedToc, header.fileCount) } else { // TOC is plaintext (backward compatibility) @@ -378,13 +470,13 @@ fun decode(archivePath: String, outputDir: String) { raf.readFully(ciphertext) // Step 2: Verify HMAC FIRST (Encrypt-then-MAC -- FORMAT.md Section 7) - if (!verifyHmac(entry.iv, ciphertext, KEY, entry.hmac)) { + if (!verifyHmac(entry.iv, ciphertext, key, entry.hmac)) { System.err.println("HMAC failed for ${entry.name}, skipping") continue } // Step 3: Decrypt (PKCS5Padding auto-removes PKCS7 padding) - val decrypted = decryptAesCbc(ciphertext, entry.iv, KEY) + val decrypted = decryptAesCbc(ciphertext, entry.iv, key) // Step 4: Decompress if compression_flag == 1 val original = if (entry.compressionFlag == 1) { @@ -416,13 +508,57 @@ fun decode(archivePath: String, outputDir: String) { // --------------------------------------------------------------------------- fun main(args: Array) { - if (args.size != 2) { - System.err.println("Usage: java -jar ArchiveDecoder.jar ") + val usage = """ + |Usage: java -jar ArchiveDecoder.jar [OPTIONS] + | + |Key options (exactly one required): + | --key 64-char hex key (32 bytes) + | --key-file Path to 32-byte raw key file + | --password Password (requires Bouncy Castle on classpath for Argon2id) + | + |For --password, run with Bouncy Castle: + | java -cp bcprov-jdk18on-1.79.jar:ArchiveDecoder.jar ArchiveDecoderKt --password + """.trimMargin() + + // Parse arguments + var keySource: KeySource? = null + val positional = mutableListOf() + var i = 0 + while (i < args.size) { + when (args[i]) { + "--key" -> { + require(i + 1 < args.size) { "--key requires a hex argument" } + keySource = KeySource.Hex(args[i + 1]) + i += 2 + } + "--key-file" -> { + require(i + 1 < args.size) { "--key-file requires a path argument" } + keySource = KeySource.KeyFile(args[i + 1]) + i += 2 + } + "--password" -> { + require(i + 1 < args.size) { "--password requires a password argument" } + keySource = KeySource.Password(args[i + 1]) + i += 2 + } + "--help", "-h" -> { + println(usage) + return + } + else -> { + positional.add(args[i]) + i++ + } + } + } + + if (keySource == null || positional.size != 2) { + System.err.println(usage) System.exit(1) } - val archivePath = args[0] - val outputDir = args[1] + val archivePath = positional[0] + val outputDir = positional[1] // Validate archive exists require(File(archivePath).exists()) { "Archive not found: $archivePath" } @@ -430,5 +566,5 @@ fun main(args: Array) { // Create output directory if needed File(outputDir).mkdirs() - decode(archivePath, outputDir) + decode(archivePath, outputDir, keySource!!) }