VaultCrypto.kt

6.5 KB · raw

1package com.peachpasswords.vault23import org.bouncycastle.crypto.generators.Argon2BytesGenerator4import org.bouncycastle.crypto.params.Argon2Parameters5import java.security.SecureRandom6import java.text.Normalizer7import javax.crypto.Cipher8import javax.crypto.spec.GCMParameterSpec9import javax.crypto.spec.SecretKeySpec1011object VaultCrypto {12    const val ARGON2_MEMORY_KB = 6553613    const val ARGON2_ITERATIONS = 314    const val ARGON2_PARALLELISM = 415    const val ARGON2_HASH_LENGTH = 3216    const val SALT_SIZE = 1617    const val NONCE_SIZE = 1218    const val TAG_LENGTH_BITS = 12819    const val KEY_SIZE = 3220    const val PAD_BLOCK = 102421    const val DURESS_SLOT_SIZE = 262_1442223    val PADDING_BUCKETS = intArrayOf(1024, 4096, 16384, 65536, 262144)2425    const val AAD_SLOT_A = "slot_a_v1"26    const val AAD_SLOT_B = "slot_b_v1"27    const val AAD_ROOT_KEY = "pw_root_v1"2829    /**30     * Distinct from [AAD_SLOT_A] so a recovery-escrow blob can never be swapped in31     * as a slot-A blob (GCM auth covers the AAD). Used by Android's whole-payload32     * offline-restore escrow.33     */34    const val AAD_RECOVERY = "recovery_escrow_v1"3536    /**37     * AAD for the browser-byte-compatible root-key escrow. The browser wraps the38     * 32-byte root key under AAD = the single byte 0x01 (`vault.ts:298`); matching39     * it byte-for-byte is what lets the same 12 words unwrap the same root key on40     * either platform. Do NOT change — it is a cross-platform wire constant.41     */42    val AAD_RECOVERY_ROOTKEY = byteArrayOf(1)4344    private val secureRandom = SecureRandom()45    private var _lastTimestamp = 0L4647    /**48     * MF-4 / LV-1: NFC-normalize the password, then hash explicit UTF-8 bytes —49     * byte-for-byte identical to the browser's `password.normalize("NFC")` fed50     * to Argon2id (`argon2.ts` deriveKey). NFC normalization makes a visually51     * identical password typed in a different normalization form (NFD on macOS52     * dead-keys, some IMEs) derive the same key on every platform. UTF-8 (not53     * BouncyCastle's per-UTF-16-unit char[] path) keeps non-BMP characters54     * (emoji, surrogate pairs) consistent across platforms.55     */56    fun deriveKey(password: String, salt: ByteArray): ByteArray =57        deriveKeyFromBytes(58            Normalizer.normalize(password, Normalizer.Form.NFC).toByteArray(Charsets.UTF_8),59            salt,60        )6162    /**63     * LV-1 legacy fallback ONLY: derive WITHOUT NFC normalization (raw UTF-864     * bytes), for unlocking a vault sealed before normalization existed with a65     * non-NFC password. Every normal path uses [deriveKey].66     */67    fun deriveKeyRaw(password: String, salt: ByteArray): ByteArray =68        deriveKeyFromBytes(password.toByteArray(Charsets.UTF_8), salt)6970    private fun deriveKeyFromBytes(passwordBytes: ByteArray, salt: ByteArray): ByteArray {71        val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)72            .withSalt(salt)73            .withParallelism(ARGON2_PARALLELISM)74            .withMemoryAsKB(ARGON2_MEMORY_KB)75            .withIterations(ARGON2_ITERATIONS)76            .build()77        val generator = Argon2BytesGenerator().apply { init(params) }78        val hash = ByteArray(ARGON2_HASH_LENGTH)79        try {80            generator.generateBytes(passwordBytes, hash)81        } finally {82            // Zero the transient password bytes immediately after use.83            passwordBytes.fill(0)84        }85        return hash86    }8788    fun encrypt(plaintext: ByteArray, key: ByteArray, aad: String): ByteArray =89        encrypt(plaintext, key, aad.toByteArray(Charsets.UTF_8))9091    fun decrypt(data: ByteArray, key: ByteArray, aad: String): ByteArray =92        decrypt(data, key, aad.toByteArray(Charsets.UTF_8))9394    /**95     * Raw-AAD encrypt. Output is `nonce(12) || ciphertext || tag(16)`. The96     * [aad] bytes are authenticated, not encrypted. Used for the root-key97     * escrow whose AAD is the browser's single 0x01 byte ([AAD_RECOVERY_ROOTKEY]).98     */99    fun encrypt(plaintext: ByteArray, key: ByteArray, aad: ByteArray): ByteArray {100        val nonce = ByteArray(NONCE_SIZE).also(secureRandom::nextBytes)101        val cipher = Cipher.getInstance("AES/GCM/NoPadding")102        cipher.init(103            Cipher.ENCRYPT_MODE,104            SecretKeySpec(key, "AES"),105            GCMParameterSpec(TAG_LENGTH_BITS, nonce),106        )107        cipher.updateAAD(aad)108        return nonce + cipher.doFinal(plaintext)109    }110111    fun decrypt(data: ByteArray, key: ByteArray, aad: ByteArray): ByteArray {112        require(data.size >= NONCE_SIZE) { "Data too short" }113        val nonce = data.copyOfRange(0, NONCE_SIZE)114        val ciphertext = data.copyOfRange(NONCE_SIZE, data.size)115        val cipher = Cipher.getInstance("AES/GCM/NoPadding")116        cipher.init(117            Cipher.DECRYPT_MODE,118            SecretKeySpec(key, "AES"),119            GCMParameterSpec(TAG_LENGTH_BITS, nonce),120        )121        cipher.updateAAD(aad)122        return cipher.doFinal(ciphertext)123    }124125    fun generateSalt(): ByteArray = ByteArray(SALT_SIZE).also(secureRandom::nextBytes)126    fun generateRootKey(): ByteArray = ByteArray(KEY_SIZE).also(secureRandom::nextBytes)127    fun generateNoise(): ByteArray = ByteArray(DURESS_SLOT_SIZE).also(secureRandom::nextBytes)128129    fun padToBucket(data: ByteArray): ByteArray {130        val target = PeachPayloadCodec.paddedBucketSize(data.size)131        require(target <= Int.MAX_VALUE) { "padded plaintext is too large" }132        return data.copyOf(target)133    }134135    fun padToBlock(data: ByteArray): ByteArray {136        val remainder = data.size % PAD_BLOCK137        if (remainder == 0) return data138        return data.copyOf(data.size + (PAD_BLOCK - remainder))139    }140141    @Synchronized142    fun monotonicEntryTimestamp(currentModifiedAt: Long? = null): Long {143        val base = maxOf(System.currentTimeMillis(), (currentModifiedAt ?: 0L) + 1L, _lastTimestamp + 1L)144        _lastTimestamp = base145        return base146    }147148    /**149     * Reset the process-global monotonic entry clock. TEST ONLY.150     *151     * [monotonicEntryTimestamp] never moves backwards, so any test that feeds152     * it a far-future baseline leaves every later test in the same JVM issuing153     * far-future timestamps. That is invisible until something compares against154     * a clock-skew bound — [SyncLimits.clampSyncTimestamp] does, and a local155     * entry stamped beyond the skew window silently strands remote tombstones.156     */157    fun resetMonotonicClockForTest() {158        _lastTimestamp = 0L159    }160161    fun zero(bytes: ByteArray) {162        bytes.fill(0)163    }164}