create-entity-server 0.0.9 → 0.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create.js +26 -8
- package/package.json +1 -1
- package/template/.env.example +20 -3
- package/template/configs/database.json +173 -10
- package/template/configs/jwt.json +1 -0
- package/template/configs/oauth.json +37 -0
- package/template/configs/push.json +26 -0
- package/template/entities/Account/account_audit.json +4 -5
- package/template/entities/README.md +4 -4
- package/template/entities/{Auth → System/Auth}/account.json +0 -14
- package/template/entities/System/system_audit_log.json +14 -8
- package/template/samples/README.md +43 -21
- package/template/samples/browser/entity-server-client.js +453 -0
- package/template/samples/browser/example.html +498 -0
- package/template/samples/entities/01_basic_fields.json +39 -0
- package/template/samples/entities/02_types_and_defaults.json +67 -0
- package/template/samples/entities/03_hash_and_unique.json +33 -0
- package/template/samples/entities/04_fk_and_composite_unique.json +29 -0
- package/template/samples/entities/05_cache.json +55 -0
- package/template/samples/entities/06_history_and_hard_delete.json +60 -0
- package/template/samples/entities/07_license_scope.json +52 -0
- package/template/samples/entities/08_hook_sql.json +52 -0
- package/template/samples/entities/09_hook_entity.json +65 -0
- package/template/samples/entities/10_hook_submit_delete.json +78 -0
- package/template/samples/entities/11_hook_webhook.json +84 -0
- package/template/samples/entities/12_hook_push.json +73 -0
- package/template/samples/entities/13_read_only.json +54 -0
- package/template/samples/entities/14_optimistic_lock.json +29 -0
- package/template/samples/entities/15_reset_defaults.json +94 -0
- package/template/samples/entities/16_isolated_license.json +62 -0
- package/template/samples/entities/README.md +91 -0
- package/template/samples/flutter/lib/entity_server_client.dart +261 -48
- package/template/samples/java/EntityServerClient.java +325 -61
- package/template/samples/java/EntityServerExample.java +4 -3
- package/template/samples/kotlin/EntityServerClient.kt +261 -45
- package/template/samples/node/src/EntityServerClient.js +348 -59
- package/template/samples/node/src/example.js +9 -9
- package/template/samples/php/ci4/Config/EntityServer.php +14 -0
- package/template/samples/php/ci4/Controllers/EntityController.php +202 -0
- package/template/samples/php/ci4/Controllers/ProductController.php +16 -76
- package/template/samples/php/ci4/Libraries/EntityServer.php +352 -60
- package/template/samples/php/laravel/Services/EntityServerService.php +245 -40
- package/template/samples/python/entity_server.py +287 -68
- package/template/samples/python/example.py +7 -6
- package/template/samples/react/src/example.tsx +41 -25
- package/template/samples/swift/EntityServerClient.swift +248 -37
- package/template/scripts/normalize-entities.sh +10 -10
- package/template/scripts/run.ps1 +12 -3
- package/template/scripts/run.sh +120 -37
- package/template/scripts/update-server.ps1 +160 -4
- package/template/scripts/update-server.sh +132 -4
- package/template/samples/react/src/api/entityServerClient.ts +0 -290
- package/template/samples/react/src/hooks/useEntity.ts +0 -105
- /package/template/entities/{Auth → System/Auth}/api_keys.json +0 -0
- /package/template/entities/{Auth → System/Auth}/license.json +0 -0
- /package/template/entities/{Auth → System/Auth}/rbac_roles.json +0 -0
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
* val client = EntityServerClient(
|
|
9
9
|
* baseUrl = "http://your-server:47200",
|
|
10
10
|
* apiKey = BuildConfig.ENTITY_API_KEY,
|
|
11
|
-
* hmacSecret = BuildConfig.ENTITY_HMAC_SECRET
|
|
12
|
-
* magicLen = 4 // 서버 packet_magic_len 과 동일
|
|
11
|
+
* hmacSecret = BuildConfig.ENTITY_HMAC_SECRET
|
|
13
12
|
* )
|
|
14
13
|
*
|
|
15
14
|
* 트랜잭션 사용 예:
|
|
@@ -34,9 +33,13 @@ import org.bouncycastle.crypto.engines.ChaCha7539Engine
|
|
|
34
33
|
import org.bouncycastle.crypto.modes.ChaChaEngine
|
|
35
34
|
import org.bouncycastle.crypto.params.KeyParameter
|
|
36
35
|
import org.bouncycastle.crypto.params.ParametersWithIV
|
|
36
|
+
import org.bouncycastle.crypto.generators.HKDFBytesGenerator
|
|
37
|
+
import org.bouncycastle.crypto.params.HKDFParameters
|
|
38
|
+
import org.bouncycastle.crypto.digests.SHA256Digest
|
|
37
39
|
import org.json.JSONArray
|
|
38
40
|
import org.json.JSONObject
|
|
39
41
|
import java.security.MessageDigest
|
|
42
|
+
import java.security.SecureRandom
|
|
40
43
|
import java.util.UUID
|
|
41
44
|
import javax.crypto.Mac
|
|
42
45
|
import javax.crypto.spec.SecretKeySpec
|
|
@@ -45,24 +48,87 @@ class EntityServerClient(
|
|
|
45
48
|
private val baseUrl: String = "http://localhost:47200",
|
|
46
49
|
private val apiKey: String = "",
|
|
47
50
|
private val hmacSecret: String = "",
|
|
48
|
-
private
|
|
51
|
+
private var token: String = "",
|
|
52
|
+
/** true 이면 POST 요청 바디를 XChaCha20-Poly1305로 암호화합니다. */
|
|
53
|
+
private var encryptRequests: Boolean = false,
|
|
49
54
|
) {
|
|
50
55
|
private val http = OkHttpClient()
|
|
51
|
-
private var activeTxId: String? = null
|
|
56
|
+
private var activeTxId: String? = null private var packetEncryption: Boolean = false
|
|
52
57
|
|
|
58
|
+
/** JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용합니다. */
|
|
59
|
+
fun setToken(newToken: String) { token = newToken }
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
|
|
63
|
+
* 서버가 packet_encryption: true 를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다.
|
|
64
|
+
*/
|
|
65
|
+
fun checkHealth(): JSONObject {
|
|
66
|
+
val req = Request.Builder()
|
|
67
|
+
.url(baseUrl.trimEnd('/') + "/v1/health")
|
|
68
|
+
.get()
|
|
69
|
+
.build()
|
|
70
|
+
val res = http.newCall(req).execute()
|
|
71
|
+
val body = JSONObject(res.body?.string() ?: "{}")
|
|
72
|
+
if (body.optBoolean("packet_encryption", false)) {
|
|
73
|
+
packetEncryption = true
|
|
74
|
+
}
|
|
75
|
+
return body
|
|
76
|
+
}
|
|
53
77
|
// ─── CRUD ─────────────────────────────────────────────────────────
|
|
54
78
|
|
|
55
|
-
fun get(entity: String, seq: Long): JSONObject
|
|
56
|
-
|
|
79
|
+
fun get(entity: String, seq: Long, skipHooks: Boolean = false): JSONObject {
|
|
80
|
+
val q = if (skipHooks) "?skipHooks=true" else ""
|
|
81
|
+
return request("GET", "/v1/entity/$entity/$seq$q")
|
|
82
|
+
}
|
|
57
83
|
|
|
58
|
-
|
|
59
|
-
|
|
84
|
+
/**
|
|
85
|
+
* 조건으로 단건 조회 (POST + conditions body)
|
|
86
|
+
*
|
|
87
|
+
* @param conditions 필터 조건. index/hash/unique 필드만 사용 가능
|
|
88
|
+
* @param skipHooks after_find 훅 미실행 여부
|
|
89
|
+
*/
|
|
90
|
+
fun find(entity: String, conditions: JSONObject, skipHooks: Boolean = false): JSONObject {
|
|
91
|
+
val q = if (skipHooks) "?skipHooks=true" else ""
|
|
92
|
+
return request("POST", "/v1/entity/$entity/find$q", conditions.toString())
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 목록 조회 (POST + conditions body)
|
|
97
|
+
* @param orderBy 정렬 기준 필드명. null 이면 기본 정렬. - 접두사로 내림차순
|
|
98
|
+
* @param fields 반환 필드 목록. 미지정 시 인덱스 필드만 반환 (기본, 가장 빠름). listOf("*") 지정 시 전체 필드 반환
|
|
99
|
+
* @param conditions 필터 조건. index/hash/unique 필드만 사용 가능
|
|
100
|
+
*/
|
|
101
|
+
fun list(
|
|
102
|
+
entity: String,
|
|
103
|
+
page: Int = 1,
|
|
104
|
+
limit: Int = 20,
|
|
105
|
+
orderBy: String? = null,
|
|
106
|
+
fields: List<String>? = null,
|
|
107
|
+
conditions: JSONObject? = null,
|
|
108
|
+
): JSONObject {
|
|
109
|
+
val qParts = mutableListOf("page=$page", "limit=$limit")
|
|
110
|
+
if (orderBy != null) qParts += "order_by=$orderBy"
|
|
111
|
+
if (!fields.isNullOrEmpty()) qParts += "fields=${fields.joinToString(",")}"
|
|
112
|
+
return request("POST", "/v1/entity/$entity/list?${qParts.joinToString("&")}", conditions?.toString() ?: "{}")
|
|
113
|
+
}
|
|
60
114
|
|
|
61
|
-
|
|
62
|
-
|
|
115
|
+
/** 건수 조회 */
|
|
116
|
+
fun count(entity: String, conditions: JSONObject? = null): JSONObject =
|
|
117
|
+
request("POST", "/v1/entity/$entity/count", conditions?.toString() ?: "{}")
|
|
63
118
|
|
|
64
|
-
|
|
65
|
-
|
|
119
|
+
/**
|
|
120
|
+
* 커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만, JOIN 지원)
|
|
121
|
+
* @param sql SELECT SQL문. 사용자 입력은 반드시 ? 로 바인딩 (SQL Injection 방지)
|
|
122
|
+
* @param params 바인딩 파라미터 JSON 배열
|
|
123
|
+
* @param limit 최대 반환 건수 (최대 1000. 0 이하이면 서버 기본값)
|
|
124
|
+
*/
|
|
125
|
+
fun query(entity: String, sql: String, params: JSONArray? = null, limit: Int = 0): JSONObject {
|
|
126
|
+
val body = JSONObject()
|
|
127
|
+
body.put("sql", sql)
|
|
128
|
+
body.put("params", params ?: JSONArray())
|
|
129
|
+
if (limit > 0) body.put("limit", limit)
|
|
130
|
+
return request("POST", "/v1/entity/$entity/query", body.toString())
|
|
131
|
+
}
|
|
66
132
|
|
|
67
133
|
/**
|
|
68
134
|
* 트랜잭션 시작 — 서버에 큐를 등록하고 txId 를 저장합니다.
|
|
@@ -98,18 +164,23 @@ class EntityServerClient(
|
|
|
98
164
|
}
|
|
99
165
|
|
|
100
166
|
/** 생성 또는 수정 (seq 포함시 수정, 없으면 생성) */
|
|
101
|
-
fun submit(entity: String, data: JSONObject, transactionId: String? = null): JSONObject {
|
|
167
|
+
fun submit(entity: String, data: JSONObject, transactionId: String? = null, skipHooks: Boolean = false): JSONObject {
|
|
102
168
|
val txId = transactionId ?: activeTxId
|
|
103
169
|
val extra = if (txId != null) mapOf("X-Transaction-ID" to txId) else emptyMap()
|
|
104
|
-
|
|
170
|
+
val q = if (skipHooks) "?skipHooks=true" else ""
|
|
171
|
+
return request("POST", "/v1/entity/$entity/submit$q", data.toString(), extra)
|
|
105
172
|
}
|
|
106
173
|
|
|
107
174
|
/** 삭제 */
|
|
108
|
-
|
|
109
|
-
|
|
175
|
+
/** 삭제. 서버는 POST /delete/:seq 로만 처리합니다. */
|
|
176
|
+
fun delete(entity: String, seq: Long, transactionId: String? = null, hard: Boolean = false, skipHooks: Boolean = false): JSONObject {
|
|
177
|
+
val qParts = mutableListOf<String>()
|
|
178
|
+
if (hard) qParts += "hard=true"
|
|
179
|
+
if (skipHooks) qParts += "skipHooks=true"
|
|
180
|
+
val q = if (qParts.isNotEmpty()) "?" + qParts.joinToString("&") else ""
|
|
110
181
|
val txId = transactionId ?: activeTxId
|
|
111
182
|
val extra = if (txId != null) mapOf("X-Transaction-ID" to txId) else emptyMap()
|
|
112
|
-
return request("
|
|
183
|
+
return request("POST", "/v1/entity/$entity/delete/$seq$q", extraHeaders = extra)
|
|
113
184
|
}
|
|
114
185
|
|
|
115
186
|
fun history(entity: String, seq: Long, page: Int = 1, limit: Int = 50): JSONObject =
|
|
@@ -118,58 +189,202 @@ class EntityServerClient(
|
|
|
118
189
|
fun rollback(entity: String, historySeq: Long): JSONObject =
|
|
119
190
|
request("POST", "/v1/entity/$entity/rollback/$historySeq")
|
|
120
191
|
|
|
192
|
+
/** 푸시 발송 트리거 엔티티에 submit합니다. */
|
|
193
|
+
fun push(pushEntity: String, payload: JSONObject, transactionId: String? = null): JSONObject =
|
|
194
|
+
submit(pushEntity, payload, transactionId)
|
|
195
|
+
|
|
196
|
+
/** push_log 목록 조회 헬퍼 */
|
|
197
|
+
fun pushLogList(page: Int = 1, limit: Int = 20): JSONObject =
|
|
198
|
+
list("push_log", page, limit)
|
|
199
|
+
|
|
200
|
+
/** account_device 디바이스 등록/갱신 헬퍼 (push_token 단일 필드) */
|
|
201
|
+
fun registerPushDevice(
|
|
202
|
+
accountSeq: Long,
|
|
203
|
+
deviceId: String,
|
|
204
|
+
pushToken: String,
|
|
205
|
+
platform: String? = null,
|
|
206
|
+
deviceType: String? = null,
|
|
207
|
+
pushEnabled: Boolean = true,
|
|
208
|
+
transactionId: String? = null,
|
|
209
|
+
): JSONObject {
|
|
210
|
+
val payload = JSONObject().apply {
|
|
211
|
+
put("id", deviceId)
|
|
212
|
+
put("account_seq", accountSeq)
|
|
213
|
+
put("push_token", pushToken)
|
|
214
|
+
put("push_enabled", pushEnabled)
|
|
215
|
+
if (!platform.isNullOrBlank()) put("platform", platform)
|
|
216
|
+
if (!deviceType.isNullOrBlank()) put("device_type", deviceType)
|
|
217
|
+
}
|
|
218
|
+
return submit("account_device", payload, transactionId)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** account_device.seq 기준 push_token 갱신 헬퍼 */
|
|
222
|
+
fun updatePushDeviceToken(
|
|
223
|
+
deviceSeq: Long,
|
|
224
|
+
pushToken: String,
|
|
225
|
+
pushEnabled: Boolean = true,
|
|
226
|
+
transactionId: String? = null,
|
|
227
|
+
): JSONObject =
|
|
228
|
+
submit(
|
|
229
|
+
"account_device",
|
|
230
|
+
JSONObject().apply {
|
|
231
|
+
put("seq", deviceSeq)
|
|
232
|
+
put("push_token", pushToken)
|
|
233
|
+
put("push_enabled", pushEnabled)
|
|
234
|
+
},
|
|
235
|
+
transactionId,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
/** account_device.seq 기준 푸시 수신 비활성화 헬퍼 */
|
|
239
|
+
fun disablePushDevice(
|
|
240
|
+
deviceSeq: Long,
|
|
241
|
+
transactionId: String? = null,
|
|
242
|
+
): JSONObject =
|
|
243
|
+
submit(
|
|
244
|
+
"account_device",
|
|
245
|
+
JSONObject().apply {
|
|
246
|
+
put("seq", deviceSeq)
|
|
247
|
+
put("push_enabled", false)
|
|
248
|
+
},
|
|
249
|
+
transactionId,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 요청 본문을 읽어 JSON으로 반환합니다.
|
|
254
|
+
* - application/octet-stream: 암호 패킷 복호화
|
|
255
|
+
* - 그 외: 평문 JSON 파싱
|
|
256
|
+
*/
|
|
257
|
+
fun readRequestBody(
|
|
258
|
+
rawBody: ByteArray,
|
|
259
|
+
contentType: String = "application/json",
|
|
260
|
+
requireEncrypted: Boolean = false,
|
|
261
|
+
): JSONObject {
|
|
262
|
+
val lowered = contentType.lowercase()
|
|
263
|
+
val isEncrypted = lowered.contains("application/octet-stream")
|
|
264
|
+
|
|
265
|
+
if (requireEncrypted && !isEncrypted) {
|
|
266
|
+
error("Encrypted request required: Content-Type must be application/octet-stream")
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (isEncrypted) {
|
|
270
|
+
if (rawBody.isEmpty()) error("Encrypted request body is empty")
|
|
271
|
+
return JSONObject(decryptPacket(rawBody))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (rawBody.isEmpty()) return JSONObject()
|
|
275
|
+
return JSONObject(String(rawBody, Charsets.UTF_8))
|
|
276
|
+
}
|
|
277
|
+
|
|
121
278
|
// ─── 내부 ─────────────────────────────────────────────────────────
|
|
122
279
|
|
|
123
280
|
private fun request(method: String, path: String, bodyStr: String = "", extraHeaders: Map<String, String> = emptyMap()): JSONObject {
|
|
281
|
+
// 요청 바디 결정: encryptRequests 시 POST 바디를 암호화
|
|
282
|
+
val bodyBytes: ByteArray
|
|
283
|
+
val contentType: String
|
|
284
|
+
if ((encryptRequests || packetEncryption) && bodyStr.isNotEmpty()) {
|
|
285
|
+
bodyBytes = encryptPacket(bodyStr.toByteArray(Charsets.UTF_8))
|
|
286
|
+
contentType = "application/octet-stream"
|
|
287
|
+
} else {
|
|
288
|
+
bodyBytes = bodyStr.toByteArray(Charsets.UTF_8)
|
|
289
|
+
contentType = "application/json"
|
|
290
|
+
}
|
|
291
|
+
|
|
124
292
|
val timestamp = (System.currentTimeMillis() / 1000).toString()
|
|
125
293
|
val nonce = UUID.randomUUID().toString()
|
|
126
|
-
val signature = sign(method, path, timestamp, nonce,
|
|
294
|
+
val signature = sign(method, path, timestamp, nonce, bodyBytes)
|
|
295
|
+
|
|
296
|
+
val isHmacMode = apiKey.isNotEmpty() && hmacSecret.isNotEmpty()
|
|
127
297
|
|
|
128
298
|
val requestBuilder = Request.Builder()
|
|
129
299
|
.url(baseUrl.trimEnd('/') + path)
|
|
130
|
-
.addHeader("Content-Type",
|
|
131
|
-
.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
300
|
+
.addHeader("Content-Type", contentType)
|
|
301
|
+
.apply {
|
|
302
|
+
if (isHmacMode) {
|
|
303
|
+
addHeader("X-API-Key", apiKey)
|
|
304
|
+
addHeader("X-Timestamp", timestamp)
|
|
305
|
+
addHeader("X-Nonce", nonce)
|
|
306
|
+
addHeader("X-Signature", signature)
|
|
307
|
+
} else if (token.isNotEmpty()) {
|
|
308
|
+
addHeader("Authorization", "Bearer $token")
|
|
309
|
+
}
|
|
310
|
+
extraHeaders.forEach { (k, v) -> addHeader(k, v) }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
val reqBody = if (bodyBytes.isNotEmpty())
|
|
314
|
+
bodyBytes.toRequestBody(contentType.toMediaType())
|
|
139
315
|
else null
|
|
140
316
|
|
|
141
317
|
val req = when (method.uppercase()) {
|
|
142
318
|
"GET" -> requestBuilder.get().build()
|
|
143
|
-
"DELETE" -> requestBuilder.delete(
|
|
144
|
-
else -> requestBuilder.method(method.uppercase(),
|
|
319
|
+
"DELETE" -> requestBuilder.delete(reqBody).build()
|
|
320
|
+
else -> requestBuilder.method(method.uppercase(), reqBody).build()
|
|
145
321
|
}
|
|
146
322
|
|
|
147
323
|
val res = http.newCall(req).execute()
|
|
148
|
-
val
|
|
324
|
+
val resContentType = res.header("Content-Type") ?: ""
|
|
149
325
|
val rawBytes = res.body?.bytes() ?: byteArrayOf()
|
|
150
326
|
|
|
151
327
|
// 패킷 암호화 응답: application/octet-stream → 복호화
|
|
152
|
-
return if (
|
|
328
|
+
return if (resContentType.contains("application/octet-stream")) {
|
|
153
329
|
JSONObject(decryptPacket(rawBytes))
|
|
154
330
|
} else {
|
|
155
331
|
JSONObject(String(rawBytes, Charsets.UTF_8))
|
|
156
332
|
}
|
|
157
333
|
}
|
|
158
334
|
|
|
335
|
+
/**
|
|
336
|
+
* 패킷 암호화 키를 유도합니다.
|
|
337
|
+
* - HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
338
|
+
* - JWT 모드: SHA256(token)
|
|
339
|
+
*/
|
|
340
|
+
private fun derivePacketKey(): ByteArray {
|
|
341
|
+
if (token.isNotEmpty() && hmacSecret.isEmpty()) {
|
|
342
|
+
val digest = java.security.MessageDigest.getInstance("SHA-256")
|
|
343
|
+
return digest.digest(token.toByteArray(Charsets.UTF_8))
|
|
344
|
+
}
|
|
345
|
+
val gen = HKDFBytesGenerator(SHA256Digest())
|
|
346
|
+
gen.init(HKDFParameters(
|
|
347
|
+
hmacSecret.toByteArray(Charsets.UTF_8),
|
|
348
|
+
"entity-server:hkdf:v1".toByteArray(Charsets.UTF_8),
|
|
349
|
+
"entity-server:packet-encryption".toByteArray(Charsets.UTF_8),
|
|
350
|
+
))
|
|
351
|
+
val key = ByteArray(32)
|
|
352
|
+
gen.generateBytes(key, 0, 32)
|
|
353
|
+
return key
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* XChaCha20-Poly1305 패킷 암호화
|
|
358
|
+
* 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
|
|
359
|
+
* magicLen: 2 + key[31] % 14
|
|
360
|
+
*/
|
|
361
|
+
private fun encryptPacket(plaintext: ByteArray): ByteArray {
|
|
362
|
+
val key = derivePacketKey()
|
|
363
|
+
val magicLen = 2 + (key[31].toInt() and 0xFF) % 14
|
|
364
|
+
val magic = ByteArray(magicLen).also { SecureRandom().nextBytes(it) }
|
|
365
|
+
val nonce = ByteArray(24).also { SecureRandom().nextBytes(it) }
|
|
366
|
+
|
|
367
|
+
val aead = org.bouncycastle.crypto.modes.ChaCha20Poly1305()
|
|
368
|
+
aead.init(true, org.bouncycastle.crypto.params.AEADParameters(
|
|
369
|
+
KeyParameter(key), 128, nonce
|
|
370
|
+
))
|
|
371
|
+
val ciphertext = ByteArray(aead.getOutputSize(plaintext.size))
|
|
372
|
+
val len = aead.processBytes(plaintext, 0, plaintext.size, ciphertext, 0)
|
|
373
|
+
aead.doFinal(ciphertext, len)
|
|
374
|
+
return magic + nonce + ciphertext
|
|
375
|
+
}
|
|
376
|
+
|
|
159
377
|
/**
|
|
160
378
|
* XChaCha20-Poly1305 패킷 복호화
|
|
161
379
|
* 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
|
|
162
|
-
* 키:
|
|
163
|
-
*
|
|
164
|
-
* Bouncy Castle XChaCha20-Poly1305 사용
|
|
380
|
+
* 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
165
381
|
*/
|
|
166
382
|
private fun decryptPacket(data: ByteArray): String {
|
|
167
|
-
val key =
|
|
168
|
-
|
|
383
|
+
val key = derivePacketKey()
|
|
384
|
+
val magicLen = 2 + (key[31].toInt() and 0xFF) % 14
|
|
169
385
|
val nonce = data.copyOfRange(magicLen, magicLen + 24)
|
|
170
386
|
val ciphertext = data.copyOfRange(magicLen + 24, data.size)
|
|
171
387
|
|
|
172
|
-
// Bouncy Castle: XChaCha20-Poly1305 (AEAD)
|
|
173
388
|
val aead = org.bouncycastle.crypto.modes.ChaCha20Poly1305()
|
|
174
389
|
aead.init(false, org.bouncycastle.crypto.params.AEADParameters(
|
|
175
390
|
KeyParameter(key), 128, nonce
|
|
@@ -181,14 +396,15 @@ class EntityServerClient(
|
|
|
181
396
|
return plaintext.toString(Charsets.UTF_8)
|
|
182
397
|
}
|
|
183
398
|
|
|
184
|
-
/**
|
|
185
|
-
|
|
186
|
-
|
|
399
|
+
/**
|
|
400
|
+
* HMAC-SHA256 서명
|
|
401
|
+
* bodyBytes 는 JSON 바이트 또는 암호화된 바리두 모두 지원합니다.
|
|
402
|
+
*/
|
|
403
|
+
private fun sign(method: String, path: String, timestamp: String, nonce: String, bodyBytes: ByteArray): String {
|
|
187
404
|
val mac = Mac.getInstance("HmacSHA256")
|
|
188
405
|
mac.init(SecretKeySpec(hmacSecret.toByteArray(Charsets.UTF_8), "HmacSHA256"))
|
|
189
|
-
|
|
406
|
+
val prefix = "$method|$path|$timestamp|$nonce|".toByteArray(Charsets.UTF_8)
|
|
407
|
+
mac.update(prefix)
|
|
408
|
+
if (bodyBytes.isNotEmpty()) mac.update(bodyBytes)
|
|
409
|
+
return mac.doFinal().joinToString("") { "%02x".format(it) }
|
|
190
410
|
}
|
|
191
|
-
|
|
192
|
-
private fun sha256(input: ByteArray): ByteArray =
|
|
193
|
-
MessageDigest.getInstance("SHA-256").digest(input)
|
|
194
|
-
}
|