package com.peachpasswords.sync import com.peachpasswords.vault.VaultCrypto import org.bouncycastle.crypto.digests.Blake3Digest import org.bouncycastle.crypto.params.X25519PrivateKeyParameters import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters import org.bouncycastle.crypto.generators.HKDFBytesGenerator import org.bouncycastle.crypto.params.HKDFParameters data class SyncIdentityKeypair( val publicKey: ByteArray, val secretKey: ByteArray, ) data class SyncSigningKeypair( val privateKey: Ed25519PrivateKeyParameters, val publicKeyRaw: ByteArray, ) private val SYNC_IDENTITY_SALT = "peach-sync-identity-v1".encodeToByteArray() private val SYNC_SIGNING_SALT = "peach-sync-signing-v1".encodeToByteArray() private val VAULT_ID_INFO = "peach-vault-id".encodeToByteArray() fun deriveSyncIdentityKeypair(masterKey: ByteArray, deviceId: String): SyncIdentityKeypair { require(masterKey.isNotEmpty()) { "masterKey must not be empty" } require(deviceId.isNotEmpty()) { "deviceId must not be empty" } val info = deviceId.encodeToByteArray() val seed = hkdf(masterKey, SYNC_IDENTITY_SALT, info, 32) val priv = X25519PrivateKeyParameters(seed, 0) return SyncIdentityKeypair(publicKey = priv.generatePublicKey().encoded, secretKey = seed) } fun computeVaultId(masterKey: ByteArray): String { require(masterKey.isNotEmpty()) { "masterKey must not be empty" } val buf = masterKey + VAULT_ID_INFO val digest = Blake3Digest(256) digest.update(buf, 0, buf.size) val out = ByteArray(32) return try { digest.doFinal(out, 0) out.joinToString("") { "%02x".format(it) } } finally { VaultCrypto.zero(out) VaultCrypto.zero(buf) } } fun deriveSyncSigningKeypair(masterKey: ByteArray, deviceId: String): SyncSigningKeypair { require(masterKey.isNotEmpty()) { "masterKey must not be empty" } require(deviceId.isNotEmpty()) { "deviceId must not be empty" } val info = deviceId.encodeToByteArray() val seed = hkdf(masterKey, SYNC_SIGNING_SALT, info, 32) return try { val privateKey = Ed25519PrivateKeyParameters(seed, 0) SyncSigningKeypair(privateKey = privateKey, publicKeyRaw = privateKey.generatePublicKey().encoded) } finally { // Ed25519PrivateKeyParameters copies the seed into its own immutable // key object; the HKDF output itself is only constructor workspace. VaultCrypto.zero(seed) } } fun verifyEd25519(publicKeyRaw: ByteArray, message: ByteArray, signature: ByteArray): Boolean { return try { if (publicKeyRaw.size != 32 || signature.size != 64) return false val publicKey = org.bouncycastle.crypto.params.Ed25519PublicKeyParameters(publicKeyRaw, 0) val verifier = org.bouncycastle.crypto.signers.Ed25519Signer() verifier.init(false, publicKey) verifier.update(message, 0, message.size) verifier.verifySignature(signature) } catch (_: Exception) { false } } fun signEd25519(privateKey: Ed25519PrivateKeyParameters, message: ByteArray): ByteArray { val signer = org.bouncycastle.crypto.signers.Ed25519Signer() signer.init(true, privateKey) signer.update(message, 0, message.size) return signer.generateSignature() } fun pubkeyToHex(pk: ByteArray): String = pk.joinToString("") { "%02x".format(it) } fun hexToBytes(hex: String): ByteArray { require(hex.length % 2 == 0) { "hex length must be even" } val out = ByteArray(hex.length / 2) for (i in out.indices) { val byte = hex.substring(i * 2, i * 2 + 2).toInt(16) out[i] = byte.toByte() } return out } fun hexToPubkey(hex: String): ByteArray { require(hex.length == 64) { "public key must be exactly 32 bytes" } return hexToBytes(hex) } private fun hkdf(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray { val generator = HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest()) generator.init(HKDFParameters(ikm, salt, info)) val out = ByteArray(length) generator.generateBytes(out, 0, length) return out }