// SPDX-License-Identifier: MIT // Copyright (c) 2026 Thibault Ducray // // This file is part of MyPwdTool's open-source sync/encryption core — see // LICENSE-SYNC-CRYPTO.md at the repo root and https://tducray.fr/mypwdtool/open-source/. // The rest of this application is proprietary and NOT covered by this license. package fr.tducray.mypwdtool.sync import fr.tducray.mypwdtool.relay.Base64Url import java.security.MessageDigest import java.security.SecureRandom import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec /** * Port of `Sync/SyncCrypto.swift` — the SGK-based envelope encryption wrapping [SyncPayload] * JSON before it goes over the relay (separate from [fr.tducray.mypwdtool.crypto.CryptoManager], * which protects fields at rest in the local vault). */ object SyncCrypto { private const val VERSION_BYTE: Byte = 0x01 private const val NONCE_SIZE = 12 private const val TAG_BITS = 128 fun generateSGK(): ByteArray { val bytes = ByteArray(32) SecureRandom().nextBytes(bytes) return bytes } /** `[messageId]|[streamId]|[senderDeviceId]|hex(SHA256(sorted recipient inbox ids joined by ",")))|1` */ fun buildAAD( messageId: String, streamId: String, senderDeviceId: String, recipientInboxIds: List, ): ByteArray { val sortedJoined = recipientInboxIds.sorted().joinToString(",") val digest = MessageDigest.getInstance("SHA-256").digest(sortedJoined.toByteArray(Charsets.UTF_8)) val hex = digest.joinToString("") { "%02x".format(it) } return "$messageId|$streamId|$senderDeviceId|$hex|1".toByteArray(Charsets.UTF_8) } fun encrypt( plaintext: ByteArray, sgk: ByteArray, messageId: String, streamId: String, senderDeviceId: String, recipientInboxIds: List, ): String { val aad = buildAAD(messageId, streamId, senderDeviceId, recipientInboxIds) val nonce = ByteArray(NONCE_SIZE).also { SecureRandom().nextBytes(it) } val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(sgk, "AES"), GCMParameterSpec(TAG_BITS, nonce)) cipher.updateAAD(aad) val ciphertextAndTag = cipher.doFinal(plaintext) val combined = byteArrayOf(VERSION_BYTE) + nonce + ciphertextAndTag return Base64Url.encode(combined) } fun decrypt( base64UrlBlob: String, sgk: ByteArray, messageId: String, streamId: String, senderDeviceId: String, recipientInboxIds: List, ): ByteArray { val combined = Base64Url.decode(base64UrlBlob) ?: throw IllegalArgumentException("Invalid sync blob") require(combined.size > 1 + NONCE_SIZE && combined[0] == VERSION_BYTE) { "Invalid sync blob" } val nonce = combined.copyOfRange(1, 1 + NONCE_SIZE) val ciphertextAndTag = combined.copyOfRange(1 + NONCE_SIZE, combined.size) val aad = buildAAD(messageId, streamId, senderDeviceId, recipientInboxIds) val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sgk, "AES"), GCMParameterSpec(TAG_BITS, nonce)) cipher.updateAAD(aad) return cipher.doFinal(ciphertextAndTag) } }