1package com.peachpasswords.vault23import com.github.luben.zstd.Zstd4import org.bouncycastle.crypto.digests.Blake3Digest5import org.bouncycastle.crypto.generators.Argon2BytesGenerator6import org.bouncycastle.crypto.params.Argon2Parameters7import org.junit.Assert.assertEquals8import org.junit.Assert.assertThrows9import org.junit.Assert.assertTrue10import org.junit.Test11import javax.crypto.Cipher12import javax.crypto.spec.GCMParameterSpec13import javax.crypto.spec.SecretKeySpec1415161718192021class PeachScriptHardeningTest {2223 private val passphrase = "test-passphrase"2425 @Test fun oversized_envelope_rejected() {26 27 28 val env = ByteArray(50 * 1024 * 1024 + 1)29 val ex = assertThrows(PeachPayloadCodec.PeachScriptException::class.java) {30 PeachPayloadCodec.parsePayload(env, passphrase)31 }32 assertEquals("PAYLOAD_TOO_LARGE", ex.message)33 }3435 @Test fun declared_length_exceeds_actual_remaining_rejected() {36 37 38 39 val plaintext = ByteArray(64).apply {40 this[0] = 0x03 41 writeU32LE(this, 1, 1024L * 1024) 42 }43 val env = encryptPlaintext(plaintext, passphrase)44 val ex = assertThrows(PeachPayloadCodec.PeachScriptException::class.java) {45 PeachPayloadCodec.parsePayload(env, passphrase)46 }47 assertTrue(48 "expected PAYLOAD_TOO_SHORT or INNER_TOO_LARGE, got ${ex.message}",49 ex.message == "PAYLOAD_TOO_SHORT" || ex.message == "INNER_TOO_LARGE",50 )51 }5253 @Test fun decompression_bomb_rejected() {54 55 56 val body = ByteArray(8 + 9 + 16).apply {57 writeU32BE(this, 0, 0x50434853L) 58 this[4] = 0x01 59 this[5] = 0x01 60 this[8] = 0x01 61 writeU32LE(this, 9, 0xFFFFFFFFL) 62 writeU32LE(this, 13, 16L) 63 }64 val env = encryptPlaintext(v3Plaintext(body), passphrase)65 val ex = assertThrows(PeachPayloadCodec.PeachScriptException::class.java) {66 PeachPayloadCodec.parsePayload(env, passphrase)67 }68 assertEquals("SECTION_TOO_LARGE", ex.message)69 }7071 @Test fun entry_count_overflow_rejected() {72 73 74 val entriesRaw = ByteArray(4).apply { writeU32LE(this, 0, 0xFFFFFFFFL) }75 val compressed = Zstd.compress(entriesRaw, 19)76 val body = ByteArray(8 + 9 + compressed.size).apply {77 writeU32BE(this, 0, 0x50434853L)78 this[4] = 0x0179 this[5] = 0x0180 this[8] = 0x01 81 writeU32LE(this, 9, entriesRaw.size.toLong())82 writeU32LE(this, 13, compressed.size.toLong())83 System.arraycopy(compressed, 0, this, 17, compressed.size)84 }85 val env = encryptPlaintext(v3Plaintext(body), passphrase)86 val ex = assertThrows(PeachPayloadCodec.PeachScriptException::class.java) {87 PeachPayloadCodec.parsePayload(env, passphrase)88 }89 assertEquals("ENTRY_COUNT_OVERFLOW", ex.message)90 }9192 @Test fun oversize_passkey_blob_rejected() {93 94 95 96 97 val entry = ByteArray(33 + 4).apply {98 this[0] = 0x04 99 writeU32LE(this, 33, 0xFFFFFFFFL) 100 }101 val entriesRaw = ByteArray(6 + entry.size).apply {102 writeU32LE(this, 0, 1L) 103 writeU16LE(this, 4, 0) 104 System.arraycopy(entry, 0, this, 6, entry.size)105 }106 val compressed = Zstd.compress(entriesRaw, 19)107 val body = ByteArray(8 + 9 + compressed.size).apply {108 writeU32BE(this, 0, 0x50434853L)109 this[4] = 0x01110 this[5] = 0x01111 this[8] = 0x01112 writeU32LE(this, 9, entriesRaw.size.toLong())113 writeU32LE(this, 13, compressed.size.toLong())114 System.arraycopy(compressed, 0, this, 17, compressed.size)115 }116 val env = encryptPlaintext(v3Plaintext(body), passphrase)117 val ex = assertThrows(PeachPayloadCodec.PeachScriptException::class.java) {118 PeachPayloadCodec.parsePayload(env, passphrase)119 }120 assertEquals("PASSKEY_BLOB_TOO_LARGE", ex.message)121 }122123 124125 private fun v3Plaintext(body: ByteArray): ByteArray {126 val checksum = blake3Truncated(body)127 val inner = body + checksum128 val out = ByteArray(5 + inner.size)129 out[0] = 0x03 130 writeU32LE(out, 1, inner.size.toLong())131 System.arraycopy(inner, 0, out, 5, inner.size)132 return out133 }134135 private fun encryptPlaintext(plaintext: ByteArray, passphrase: String): ByteArray {136 val salt = ByteArray(16).also { java.security.SecureRandom().nextBytes(it) }137 val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }138 val key = deriveKey(passphrase, salt)139 val ctTag = try {140 val cipher = Cipher.getInstance("AES/GCM/NoPadding")141 cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))142 cipher.doFinal(plaintext)143 } finally { key.fill(0) }144 return salt + nonce + ctTag145 }146147 private fun deriveKey(passphrase: String, salt: ByteArray): ByteArray {148 val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)149 .withSalt(salt).withParallelism(4).withMemoryAsKB(65536).withIterations(3).build()150 val gen = Argon2BytesGenerator().apply { init(params) }151 val out = ByteArray(32)152 gen.generateBytes(passphrase.toByteArray(Charsets.UTF_8), out)153 return out154 }155156 private fun blake3Truncated(data: ByteArray): ByteArray {157 val digest = Blake3Digest(256)158 digest.update(data, 0, data.size)159 val full = ByteArray(32)160 digest.doFinal(full, 0)161 return full.copyOfRange(0, 4)162 }163164 private fun writeU32LE(buf: ByteArray, off: Int, v: Long) {165 buf[off] = (v and 0xff).toByte()166 buf[off + 1] = ((v ushr 8) and 0xff).toByte()167 buf[off + 2] = ((v ushr 16) and 0xff).toByte()168 buf[off + 3] = ((v ushr 24) and 0xff).toByte()169 }170171 private fun writeU32BE(buf: ByteArray, off: Int, v: Long) {172 buf[off] = ((v ushr 24) and 0xff).toByte()173 buf[off + 1] = ((v ushr 16) and 0xff).toByte()174 buf[off + 2] = ((v ushr 8) and 0xff).toByte()175 buf[off + 3] = (v and 0xff).toByte()176 }177178 private fun writeU16LE(buf: ByteArray, off: Int, v: Int) {179 buf[off] = (v and 0xff).toByte()180 buf[off + 1] = ((v ushr 8) and 0xff).toByte()181 }182}