1
+ package com.credman.cmwallet
2
+
3
+ import java.nio.ByteBuffer
4
+ import java.security.GeneralSecurityException
5
+ import java.security.KeyFactory
6
+ import java.security.KeyPairGenerator
7
+ import java.security.PrivateKey
8
+ import java.security.PublicKey
9
+ import java.security.spec.ECGenParameterSpec
10
+ import java.security.spec.X509EncodedKeySpec
11
+ import javax.crypto.Cipher
12
+ import javax.crypto.KeyAgreement
13
+ import javax.crypto.Mac
14
+ import javax.crypto.spec.GCMParameterSpec
15
+ import javax.crypto.spec.SecretKeySpec
16
+
17
+ /* *
18
+ * A Kotlin implementation of Hybrid Public Key Encryption (HPKE) as specified in RFC 9180.
19
+ *
20
+ * This implementation focuses on the base encryption mode and supports the ciphersuite
21
+ * using DHKEM(X25519), HKDF-SHA256, and AES-128-GCM.
22
+ *
23
+ * @property suite The HPKE ciphersuite to be used for all operations.
24
+ */
25
+ class Hpke (val suite : HpkeCipherSuite ) {
26
+
27
+ /* *
28
+ * Data class to hold a public/private key pair.
29
+ */
30
+ data class HpkeKeyPair (val privateKey : PrivateKey , val publicKey : PublicKey )
31
+
32
+ /* *
33
+ * Data class to hold the result of an HPKE seal (encryption) operation.
34
+ * @param enc The encapsulated ephemeral public key.
35
+ * @param ciphertext The encrypted message, including the authentication tag.
36
+ */
37
+ data class HpkeSealedData (val enc : ByteArray , val ciphertext : ByteArray )
38
+
39
+ /* *
40
+ * Custom exception for HPKE-specific failures.
41
+ */
42
+ class HpkeException (message : String , cause : Throwable ? = null ) : GeneralSecurityException(message, cause)
43
+
44
+ /* *
45
+ * Represents the supported HPKE ciphersuites.
46
+ * Each suite defines the KEM, KDF, and AEAD algorithms.
47
+ * Values are taken from RFC 9180, Section 7.
48
+ *
49
+ * @param kemId The IANA-registered value for the KEM.
50
+ * @param kdfId The IANA-registered value for the KDF.
51
+ * @param aeadId The IANA-registered value for the AEAD.
52
+ * @param keyBitLength (`Nk`) The length of the AEAD key in bits.
53
+ * @param nonceBitLength (`Nn`) The length of the AEAD nonce in bits.
54
+ * @param hashBitLength (`Nh`) The length of the KDF hash output in bits.
55
+ * @param dhKeyBitLength (`Npk`) The length of the public key in bits.
56
+ * @param keyAlgorithm The JCA algorithm name for the KEM.
57
+ * @param kdfAlgorithm The JCA algorithm name for the KDF MAC.
58
+ * @param aeadAlgorithm The JCA algorithm name for the AEAD cipher.
59
+ */
60
+ enum class HpkeCipherSuite (
61
+ val kemId : Short ,
62
+ val kdfId : Short ,
63
+ val aeadId : Short ,
64
+ val keyBitLength : Int ,
65
+ val nonceBitLength : Int ,
66
+ val hashBitLength : Int ,
67
+ val dhKeyBitLength : Int ,
68
+ val keyAlgorithm : String ,
69
+ val kemAlgorithm : String ,
70
+ val kdfAlgorithm : String ,
71
+ val aeadAlgorithm : String ,
72
+ ) {
73
+ DHKEM_P256_HKDF_SHA256_AES_128_GCM (
74
+ kemId = 0x0010 ,
75
+ kdfId = 0x0001 ,
76
+ aeadId = 0x0001 ,
77
+ keyBitLength = 128 ,
78
+ nonceBitLength = 96 ,
79
+ hashBitLength = 256 ,
80
+ dhKeyBitLength = 256 ,
81
+ keyAlgorithm = " EC" ,
82
+ kemAlgorithm = " ECDH" ,
83
+ kdfAlgorithm = " HmacSHA256" ,
84
+ aeadAlgorithm = " AES/GCM/NoPadding"
85
+ );
86
+
87
+ val keyByteLength: Int get() = keyBitLength / 8
88
+ val nonceByteLength: Int get() = nonceBitLength / 8
89
+ val hashByteLength: Int get() = hashBitLength / 8
90
+ val dhKeyByteLength: Int get() = dhKeyBitLength / 8
91
+ }
92
+
93
+ // Internal container for the derived secrets from the key schedule.
94
+ private data class HpkeContext (val key : ByteArray , val baseNonce : ByteArray , val exporterSecret : ByteArray )
95
+
96
+ private val suiteId: ByteArray = " HPKE" .toByteArray(Charsets .UTF_8 ) + ByteBuffer .allocate(6 )
97
+ .putShort(suite.kemId)
98
+ .putShort(suite.kdfId)
99
+ .putShort(suite.aeadId)
100
+ .array()
101
+
102
+ companion object {
103
+ private const val HPKE_VERSION_LABEL = " HPKE-v1"
104
+ private const val HPKE = " HPKE"
105
+ private const val KEM = " KEM"
106
+
107
+ fun generateKeyPair (suite : HpkeCipherSuite ): HpkeKeyPair {
108
+ val kpg = KeyPairGenerator .getInstance(suite.keyAlgorithm)
109
+ val spec = when (suite) {
110
+ HpkeCipherSuite .DHKEM_P256_HKDF_SHA256_AES_128_GCM -> ECGenParameterSpec (" secp256r1" )
111
+ }
112
+ kpg.initialize(spec)
113
+ val kp = kpg.generateKeyPair()
114
+ return HpkeKeyPair (kp.private, kp.public)
115
+ }
116
+ }
117
+
118
+ /* *
119
+ * Encrypts a message for a recipient. This is the `Seal` operation.
120
+ * See RFC 9180, Section 5.1.1.
121
+ *
122
+ * @param recipientPublicKey The public key of the recipient.
123
+ * @param info Application-specific information (can be empty).
124
+ * @param aad Additional Associated Data to be authenticated (can be empty).
125
+ * @param plaintext The message to encrypt.
126
+ * @return An HpkeSealedData object containing the encapsulated key and the ciphertext.
127
+ */
128
+ fun seal (recipientPublicKey : PublicKey , info : ByteArray , aad : ByteArray , plaintext : ByteArray ): HpkeSealedData {
129
+ // Step 1: Ephemeral key generation and DH
130
+ val ephemeralKeyPair = generateKeyPair(this .suite)
131
+ val sharedSecret = dh(ephemeralKeyPair.privateKey, recipientPublicKey)
132
+ val enc = ephemeralKeyPair.publicKey.encoded
133
+
134
+ // Step 2: Derive HPKE context
135
+ // This combines SetupS from RFC 9180, Section 5.1.1
136
+ val context = keySchedule(sharedSecret, info)
137
+
138
+ // Step 3: Encrypt the plaintext
139
+ // This is the AeadSeal operation from RFC 9180, Section 5.2
140
+ val ciphertext = aeadSeal(context.key, context.baseNonce, aad, plaintext)
141
+
142
+ return HpkeSealedData (enc, ciphertext)
143
+ }
144
+
145
+ /* *
146
+ * Decrypts a message. This is the `Open` operation.
147
+ * See RFC 9180, Section 5.1.1.
148
+ *
149
+ * @param sealedData The object containing the encapsulated key and ciphertext.
150
+ * @param recipientKeyPair The key pair of the recipient.
151
+ * @param info Application-specific information (must match the info used for sealing).
152
+ * @param aad Additional Associated Data (must match the aad used for sealing).
153
+ * @return The decrypted plaintext, or throws an exception if decryption fails.
154
+ */
155
+ fun open (sealedData : HpkeSealedData , recipientKeyPair : PrivateKey , info : ByteArray , aad : ByteArray ): ByteArray {
156
+ val (enc, ciphertext) = sealedData
157
+
158
+ // Step 1: Perform DH with the encapsulated ephemeral public key
159
+ val ephemeralPublicKey = KeyFactory .getInstance(suite.keyAlgorithm).generatePublic(X509EncodedKeySpec (enc))
160
+ val sharedSecret = dh(recipientKeyPair, ephemeralPublicKey)
161
+
162
+ // Step 2: Derive HPKE context
163
+ // This combines SetupR from RFC 9180, Section 5.1.1
164
+ val context = keySchedule(sharedSecret, info)
165
+
166
+ // Step 3: Decrypt the ciphertext
167
+ // This is the AeadOpen operation from RFC 9180, Section 5.2
168
+ return aeadOpen(context.key, context.baseNonce, aad, ciphertext)
169
+ }
170
+
171
+ /* *
172
+ * Performs the Diffie-Hellman key exchange.
173
+ * Corresponds to `DH(sk, pk)` in the RFC.
174
+ */
175
+ private fun dh (privateKey : PrivateKey , publicKey : PublicKey ): ByteArray {
176
+ val ka = KeyAgreement .getInstance(suite.kemAlgorithm)
177
+ ka.init (privateKey)
178
+ ka.doPhase(publicKey, true )
179
+ return ka.generateSecret()
180
+ }
181
+
182
+ /* *
183
+ * Implements the HPKE Key Schedule for base mode.
184
+ * See RFC 9180, Section 5.1.
185
+ *
186
+ * @param sharedSecret The result of the Diffie-Hellman exchange.
187
+ * @param info Application-specific information.
188
+ * @return An HpkeContext containing the derived key, nonce, and exporter secret.
189
+ */
190
+ private fun keySchedule (sharedSecret : ByteArray , info : ByteArray ): HpkeContext {
191
+ // In "base" mode, psk and psk_id are empty.
192
+ val emptySalt = ByteArray (suite.hashByteLength)
193
+
194
+ // secret = LabeledExtract("", "secret", shared_secret)
195
+ val secret = labeledExtract(emptySalt, " secret" , sharedSecret)
196
+
197
+ // key = LabeledExpand(secret, "key", info, Nk)
198
+ val key = labeledExpand(secret, " key" , info, suite.keyByteLength)
199
+
200
+ // base_nonce = LabeledExpand(secret, "base_nonce", info, Nn)
201
+ val baseNonce = labeledExpand(secret, " base_nonce" , info, suite.nonceByteLength)
202
+
203
+ // exporter_secret = LabeledExpand(secret, "exp", info, Nh)
204
+ val exporterSecret = labeledExpand(secret, " exp" , info, suite.hashByteLength)
205
+
206
+ return HpkeContext (key, baseNonce, exporterSecret)
207
+ }
208
+
209
+ /* *
210
+ * AEAD Encryption (AES-GCM). Corresponds to `AeadSeal(key, nonce, aad, pt)`.
211
+ * See RFC 9180, Section 5.2.
212
+ * The sequence number is always 0 for single-shot encryption.
213
+ */
214
+ private fun aeadSeal (key : ByteArray , nonce : ByteArray , aad : ByteArray , pt : ByteArray ): ByteArray {
215
+ val cipher = Cipher .getInstance(suite.aeadAlgorithm)
216
+ val keySpec = SecretKeySpec (key, " AES" )
217
+ // Tag length for AES-GCM is typically 128 bits (16 bytes).
218
+ val gcmSpec = GCMParameterSpec (128 , nonce)
219
+ cipher.init (Cipher .ENCRYPT_MODE , keySpec, gcmSpec)
220
+ cipher.updateAAD(aad)
221
+ return cipher.doFinal(pt)
222
+ }
223
+
224
+ /* *
225
+ * AEAD Decryption (AES-GCM). Corresponds to `AeadOpen(key, nonce, aad, ct)`.
226
+ * See RFC 9180, Section 5.2.
227
+ */
228
+ private fun aeadOpen (key : ByteArray , nonce : ByteArray , aad : ByteArray , ct : ByteArray ): ByteArray {
229
+ try {
230
+ val cipher = Cipher .getInstance(suite.aeadAlgorithm)
231
+ val keySpec = SecretKeySpec (key, " AES" )
232
+ val gcmSpec = GCMParameterSpec (128 , nonce)
233
+ cipher.init (Cipher .DECRYPT_MODE , keySpec, gcmSpec)
234
+ cipher.updateAAD(aad)
235
+ return cipher.doFinal(ct)
236
+ } catch (e: GeneralSecurityException ) {
237
+ // Catches AEADBadTagException and others, indicating a decryption failure.
238
+ throw HpkeException (" AEAD decryption failed, likely due to invalid authentication tag." , e)
239
+ }
240
+ }
241
+
242
+ /* *
243
+ * HKDF-Extract operation.
244
+ * See RFC 5869.
245
+ */
246
+ private fun hkdfExtract (salt : ByteArray , ikm : ByteArray ): ByteArray {
247
+ val mac = Mac .getInstance(suite.kdfAlgorithm)
248
+ mac.init (SecretKeySpec (salt, suite.kdfAlgorithm))
249
+ return mac.doFinal(ikm)
250
+ }
251
+
252
+ /* *
253
+ * HKDF-Expand operation.
254
+ * See RFC 5869.
255
+ */
256
+ private fun hkdfExpand (prk : ByteArray , info : ByteArray , length : Int ): ByteArray {
257
+ val mac = Mac .getInstance(suite.kdfAlgorithm)
258
+ mac.init (SecretKeySpec (prk, suite.kdfAlgorithm))
259
+
260
+ val result = ByteArray (length)
261
+ var currentInfo = info
262
+ var t = ByteArray (0 )
263
+ var i: Byte = 1
264
+ var bytesCopied = 0
265
+
266
+ while (bytesCopied < length) {
267
+ mac.update(t)
268
+ mac.update(currentInfo)
269
+ mac.update(i)
270
+ t = mac.doFinal()
271
+
272
+ val toCopy = minOf(length - bytesCopied, t.size)
273
+ System .arraycopy(t, 0 , result, bytesCopied, toCopy)
274
+ bytesCopied + = toCopy
275
+
276
+ currentInfo = ByteArray (0 ) // Subsequent rounds use only the previous T value
277
+ i++
278
+ }
279
+ return result
280
+ }
281
+
282
+ /* *
283
+ * Implements the `LabeledExtract` function from RFC 9180, Section 4.
284
+ */
285
+ private fun labeledExtract (salt : ByteArray , label : String , ikm : ByteArray ): ByteArray {
286
+ val labeledIkm = HPKE_VERSION_LABEL .toByteArray() + suiteId + label.toByteArray() + ikm
287
+ return hkdfExtract(salt, labeledIkm)
288
+ }
289
+
290
+ /* *
291
+ * Implements the `LabeledExpand` function from RFC 9180, Section 4.
292
+ */
293
+ private fun labeledExpand (prk : ByteArray , label : String , info : ByteArray , length : Int ): ByteArray {
294
+ val lengthBytes = ByteBuffer .allocate(2 ).putShort(length.toShort()).array()
295
+ val labeledInfo = lengthBytes + HPKE_VERSION_LABEL .toByteArray() + suiteId + label.toByteArray() + info
296
+ return hkdfExpand(prk, labeledInfo, length)
297
+ }
298
+ }
0 commit comments