feat(kotlin): add --key, --key-file, --password support to ArchiveDecoder
Some checks failed
CI / test (push) Failing after 40s
Some checks failed
CI / test (push) Failing after 40s
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.
This commit is contained in:
@@ -9,6 +9,11 @@ import javax.crypto.Cipher
|
|||||||
import javax.crypto.Mac
|
import javax.crypto.Mac
|
||||||
import javax.crypto.spec.IvParameterSpec
|
import javax.crypto.spec.IvParameterSpec
|
||||||
import javax.crypto.spec.SecretKeySpec
|
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)
|
// 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). */
|
/** Fixed header size in bytes (FORMAT.md Section 4). */
|
||||||
const val HEADER_SIZE = 40
|
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).
|
* Fixed 8-byte XOR obfuscation key (FORMAT.md Section 9.1).
|
||||||
* Applied cyclically across the 40-byte header for obfuscation/de-obfuscation.
|
* 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.
|
* 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 {
|
fun parseHeader(data: ByteArray): ArchiveHeader {
|
||||||
require(data.size >= HEADER_SIZE) { "Header too short: ${data.size} bytes" }
|
require(data.size >= HEADER_SIZE) { "Header too short: ${data.size} bytes" }
|
||||||
@@ -113,7 +106,7 @@ fun parseHeader(data: ByteArray): ArchiveHeader {
|
|||||||
|
|
||||||
// Flags validation
|
// Flags validation
|
||||||
val flags = data[5].toInt() and 0xFF
|
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
|
// Read remaining fields
|
||||||
val fileCount = readLeU16(data, 6)
|
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)
|
// Permissions restoration (v1.1)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -317,10 +401,12 @@ fun applyPermissions(file: File, permissions: Int) {
|
|||||||
*
|
*
|
||||||
* Follows FORMAT.md Section 10 decode order:
|
* Follows FORMAT.md Section 10 decode order:
|
||||||
* 1. Read and parse 40-byte header
|
* 1. Read and parse 40-byte header
|
||||||
* 2. Seek to tocOffset, read and parse TOC entries
|
* 2. Read KDF salt if present (flag bit 4)
|
||||||
* 3. For each file: verify HMAC, decrypt, decompress, verify SHA-256, write
|
* 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")
|
val raf = RandomAccessFile(archivePath, "r")
|
||||||
|
|
||||||
// Read 40-byte header
|
// Read 40-byte header
|
||||||
@@ -336,6 +422,12 @@ fun decode(archivePath: String, outputDir: String) {
|
|||||||
|
|
||||||
val header = parseHeader(headerBytes)
|
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)
|
// Read TOC bytes -- decrypt if TOC encryption flag is set (bit 1)
|
||||||
val entries: List<TocEntry>
|
val entries: List<TocEntry>
|
||||||
if (header.flags and 0x02 != 0) {
|
if (header.flags and 0x02 != 0) {
|
||||||
@@ -343,7 +435,7 @@ fun decode(archivePath: String, outputDir: String) {
|
|||||||
raf.seek(header.tocOffset)
|
raf.seek(header.tocOffset)
|
||||||
val encryptedToc = ByteArray(header.tocSize.toInt())
|
val encryptedToc = ByteArray(header.tocSize.toInt())
|
||||||
raf.readFully(encryptedToc)
|
raf.readFully(encryptedToc)
|
||||||
val decryptedToc = decryptAesCbc(encryptedToc, header.tocIv, KEY)
|
val decryptedToc = decryptAesCbc(encryptedToc, header.tocIv, key)
|
||||||
entries = parseToc(decryptedToc, header.fileCount)
|
entries = parseToc(decryptedToc, header.fileCount)
|
||||||
} else {
|
} else {
|
||||||
// TOC is plaintext (backward compatibility)
|
// TOC is plaintext (backward compatibility)
|
||||||
@@ -378,13 +470,13 @@ fun decode(archivePath: String, outputDir: String) {
|
|||||||
raf.readFully(ciphertext)
|
raf.readFully(ciphertext)
|
||||||
|
|
||||||
// Step 2: Verify HMAC FIRST (Encrypt-then-MAC -- FORMAT.md Section 7)
|
// 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")
|
System.err.println("HMAC failed for ${entry.name}, skipping")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Decrypt (PKCS5Padding auto-removes PKCS7 padding)
|
// 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
|
// Step 4: Decompress if compression_flag == 1
|
||||||
val original = if (entry.compressionFlag == 1) {
|
val original = if (entry.compressionFlag == 1) {
|
||||||
@@ -416,13 +508,57 @@ fun decode(archivePath: String, outputDir: String) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
if (args.size != 2) {
|
val usage = """
|
||||||
System.err.println("Usage: java -jar ArchiveDecoder.jar <archive> <output_dir>")
|
|Usage: java -jar ArchiveDecoder.jar [OPTIONS] <archive> <output_dir>
|
||||||
|
|
|
||||||
|
|Key options (exactly one required):
|
||||||
|
| --key <hex> 64-char hex key (32 bytes)
|
||||||
|
| --key-file <path> Path to 32-byte raw key file
|
||||||
|
| --password <pass> 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 <pass> <archive> <output_dir>
|
||||||
|
""".trimMargin()
|
||||||
|
|
||||||
|
// Parse arguments
|
||||||
|
var keySource: KeySource? = null
|
||||||
|
val positional = mutableListOf<String>()
|
||||||
|
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)
|
System.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
val archivePath = args[0]
|
val archivePath = positional[0]
|
||||||
val outputDir = args[1]
|
val outputDir = positional[1]
|
||||||
|
|
||||||
// Validate archive exists
|
// Validate archive exists
|
||||||
require(File(archivePath).exists()) { "Archive not found: $archivePath" }
|
require(File(archivePath).exists()) { "Archive not found: $archivePath" }
|
||||||
@@ -430,5 +566,5 @@ fun main(args: Array<String>) {
|
|||||||
// Create output directory if needed
|
// Create output directory if needed
|
||||||
File(outputDir).mkdirs()
|
File(outputDir).mkdirs()
|
||||||
|
|
||||||
decode(archivePath, outputDir)
|
decode(archivePath, outputDir, keySource!!)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user