VaultCrypto.kt

4.5 KB · raw

1package com.peachpasswords.vault23import org.bouncycastle.crypto.generators.Argon2BytesGenerator4import org.bouncycastle.crypto.params.Argon2Parameters5import java.security.SecureRandom6import javax.crypto.Cipher7import javax.crypto.spec.GCMParameterSpec8import javax.crypto.spec.SecretKeySpec910object VaultCrypto {11    const val ARGON2_MEMORY_KB = 6553612    const val ARGON2_ITERATIONS = 313    const val ARGON2_PARALLELISM = 414    const val ARGON2_HASH_LENGTH = 3215    const val SALT_SIZE = 1616    const val NONCE_SIZE = 1217    const val TAG_LENGTH_BITS = 12818    const val KEY_SIZE = 3219    const val PAD_BLOCK = 102420    const val DURESS_SLOT_SIZE = 262_1442122    val PADDING_BUCKETS = intArrayOf(1024, 4096, 16384, 65536, 262144)2324    const val AAD_SLOT_A = "slot_a_v1"25    const val AAD_SLOT_B = "slot_b_v1"26    const val AAD_ROOT_KEY = "pw_root_v1"2728    /**29     * Distinct from [AAD_SLOT_A] so a recovery-escrow blob can never be swapped in30     * as a slot-A blob (GCM auth covers the AAD). Used by Android's whole-payload31     * offline-restore escrow.32     */33    const val AAD_RECOVERY = "recovery_escrow_v1"3435    /**36     * AAD for the browser-byte-compatible root-key escrow. The browser wraps the37     * 32-byte root key under AAD = the single byte 0x01 (`vault.ts:298`); matching38     * it byte-for-byte is what lets the same 12 words unwrap the same root key on39     * either platform. Do NOT change — it is a cross-platform wire constant.40     */41    val AAD_RECOVERY_ROOTKEY = byteArrayOf(1)4243    private val secureRandom = SecureRandom()44    private var _lastTimestamp = 0L4546    fun deriveKey(password: String, salt: ByteArray): ByteArray {47        val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)48            .withSalt(salt)49            .withParallelism(ARGON2_PARALLELISM)50            .withMemoryAsKB(ARGON2_MEMORY_KB)51            .withIterations(ARGON2_ITERATIONS)52            .build()53        val generator = Argon2BytesGenerator().apply { init(params) }54        val hash = ByteArray(ARGON2_HASH_LENGTH)55        generator.generateBytes(password.toCharArray(), hash)56        return hash57    }5859    fun encrypt(plaintext: ByteArray, key: ByteArray, aad: String): ByteArray =60        encrypt(plaintext, key, aad.toByteArray(Charsets.UTF_8))6162    fun decrypt(data: ByteArray, key: ByteArray, aad: String): ByteArray =63        decrypt(data, key, aad.toByteArray(Charsets.UTF_8))6465    /**66     * Raw-AAD encrypt. Output is `nonce(12) || ciphertext || tag(16)`. The67     * [aad] bytes are authenticated, not encrypted. Used for the root-key68     * escrow whose AAD is the browser's single 0x01 byte ([AAD_RECOVERY_ROOTKEY]).69     */70    fun encrypt(plaintext: ByteArray, key: ByteArray, aad: ByteArray): ByteArray {71        val nonce = ByteArray(NONCE_SIZE).also(secureRandom::nextBytes)72        val cipher = Cipher.getInstance("AES/GCM/NoPadding")73        cipher.init(74            Cipher.ENCRYPT_MODE,75            SecretKeySpec(key, "AES"),76            GCMParameterSpec(TAG_LENGTH_BITS, nonce),77        )78        cipher.updateAAD(aad)79        return nonce + cipher.doFinal(plaintext)80    }8182    fun decrypt(data: ByteArray, key: ByteArray, aad: ByteArray): ByteArray {83        require(data.size >= NONCE_SIZE) { "Data too short" }84        val nonce = data.copyOfRange(0, NONCE_SIZE)85        val ciphertext = data.copyOfRange(NONCE_SIZE, data.size)86        val cipher = Cipher.getInstance("AES/GCM/NoPadding")87        cipher.init(88            Cipher.DECRYPT_MODE,89            SecretKeySpec(key, "AES"),90            GCMParameterSpec(TAG_LENGTH_BITS, nonce),91        )92        cipher.updateAAD(aad)93        return cipher.doFinal(ciphertext)94    }9596    fun generateSalt(): ByteArray = ByteArray(SALT_SIZE).also(secureRandom::nextBytes)97    fun generateRootKey(): ByteArray = ByteArray(KEY_SIZE).also(secureRandom::nextBytes)98    fun generateNoise(): ByteArray = ByteArray(DURESS_SLOT_SIZE).also(secureRandom::nextBytes)99100    fun padToBucket(data: ByteArray): ByteArray {101        val target = PADDING_BUCKETS.firstOrNull { it >= data.size } ?: data.size102        return data.copyOf(target)103    }104105    fun padToBlock(data: ByteArray): ByteArray {106        val remainder = data.size % PAD_BLOCK107        if (remainder == 0) return data108        return data.copyOf(data.size + (PAD_BLOCK - remainder))109    }110111    @Synchronized112    fun monotonicEntryTimestamp(currentModifiedAt: Long? = null): Long {113        val base = maxOf(System.currentTimeMillis(), (currentModifiedAt ?: 0L) + 1L, _lastTimestamp + 1L)114        _lastTimestamp = base115        return base116    }117118    fun zero(bytes: ByteArray) {119        bytes.fill(0)120    }121}