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.
Files changed (56) hide show
  1. package/bin/create.js +26 -8
  2. package/package.json +1 -1
  3. package/template/.env.example +20 -3
  4. package/template/configs/database.json +173 -10
  5. package/template/configs/jwt.json +1 -0
  6. package/template/configs/oauth.json +37 -0
  7. package/template/configs/push.json +26 -0
  8. package/template/entities/Account/account_audit.json +4 -5
  9. package/template/entities/README.md +4 -4
  10. package/template/entities/{Auth → System/Auth}/account.json +0 -14
  11. package/template/entities/System/system_audit_log.json +14 -8
  12. package/template/samples/README.md +43 -21
  13. package/template/samples/browser/entity-server-client.js +453 -0
  14. package/template/samples/browser/example.html +498 -0
  15. package/template/samples/entities/01_basic_fields.json +39 -0
  16. package/template/samples/entities/02_types_and_defaults.json +67 -0
  17. package/template/samples/entities/03_hash_and_unique.json +33 -0
  18. package/template/samples/entities/04_fk_and_composite_unique.json +29 -0
  19. package/template/samples/entities/05_cache.json +55 -0
  20. package/template/samples/entities/06_history_and_hard_delete.json +60 -0
  21. package/template/samples/entities/07_license_scope.json +52 -0
  22. package/template/samples/entities/08_hook_sql.json +52 -0
  23. package/template/samples/entities/09_hook_entity.json +65 -0
  24. package/template/samples/entities/10_hook_submit_delete.json +78 -0
  25. package/template/samples/entities/11_hook_webhook.json +84 -0
  26. package/template/samples/entities/12_hook_push.json +73 -0
  27. package/template/samples/entities/13_read_only.json +54 -0
  28. package/template/samples/entities/14_optimistic_lock.json +29 -0
  29. package/template/samples/entities/15_reset_defaults.json +94 -0
  30. package/template/samples/entities/16_isolated_license.json +62 -0
  31. package/template/samples/entities/README.md +91 -0
  32. package/template/samples/flutter/lib/entity_server_client.dart +261 -48
  33. package/template/samples/java/EntityServerClient.java +325 -61
  34. package/template/samples/java/EntityServerExample.java +4 -3
  35. package/template/samples/kotlin/EntityServerClient.kt +261 -45
  36. package/template/samples/node/src/EntityServerClient.js +348 -59
  37. package/template/samples/node/src/example.js +9 -9
  38. package/template/samples/php/ci4/Config/EntityServer.php +14 -0
  39. package/template/samples/php/ci4/Controllers/EntityController.php +202 -0
  40. package/template/samples/php/ci4/Controllers/ProductController.php +16 -76
  41. package/template/samples/php/ci4/Libraries/EntityServer.php +352 -60
  42. package/template/samples/php/laravel/Services/EntityServerService.php +245 -40
  43. package/template/samples/python/entity_server.py +287 -68
  44. package/template/samples/python/example.py +7 -6
  45. package/template/samples/react/src/example.tsx +41 -25
  46. package/template/samples/swift/EntityServerClient.swift +248 -37
  47. package/template/scripts/normalize-entities.sh +10 -10
  48. package/template/scripts/run.ps1 +12 -3
  49. package/template/scripts/run.sh +120 -37
  50. package/template/scripts/update-server.ps1 +160 -4
  51. package/template/scripts/update-server.sh +132 -4
  52. package/template/samples/react/src/api/entityServerClient.ts +0 -290
  53. package/template/samples/react/src/hooks/useEntity.ts +0 -105
  54. /package/template/entities/{Auth → System/Auth}/api_keys.json +0 -0
  55. /package/template/entities/{Auth → System/Auth}/license.json +0 -0
  56. /package/template/entities/{Auth → System/Auth}/rbac_roles.json +0 -0
@@ -1,8 +1,11 @@
1
1
  package com.example.entityserver;
2
2
 
3
+ import org.bouncycastle.crypto.generators.HKDFBytesGenerator;
3
4
  import org.bouncycastle.crypto.modes.ChaCha20Poly1305;
4
5
  import org.bouncycastle.crypto.params.AEADParameters;
6
+ import org.bouncycastle.crypto.params.HKDFParameters;
5
7
  import org.bouncycastle.crypto.params.KeyParameter;
8
+ import org.bouncycastle.crypto.digests.SHA256Digest;
6
9
 
7
10
  import javax.crypto.Mac;
8
11
  import javax.crypto.spec.SecretKeySpec;
@@ -14,6 +17,7 @@ import java.net.HttpURLConnection;
14
17
  import java.net.URL;
15
18
  import java.nio.charset.StandardCharsets;
16
19
  import java.security.MessageDigest;
20
+ import java.security.SecureRandom;
17
21
  import java.util.Arrays;
18
22
  import java.util.Collections;
19
23
  import java.util.HashMap;
@@ -29,9 +33,9 @@ import java.util.UUID;
29
33
  *
30
34
  * 환경변수 또는 생성자로 설정:
31
35
  * ENTITY_SERVER_URL http://localhost:47200
32
- * ENTITY_SERVER_API_KEY your-api-key
33
- * ENTITY_SERVER_HMAC_SECRET your-hmac-secret
34
- * ENTITY_PACKET_MAGIC_LEN 4 (서버 packet_magic_len 과 동일)
36
+ * ENTITY_SERVER_API_KEY your-api-key (HMAC 모드)
37
+ * ENTITY_SERVER_HMAC_SECRET your-hmac-secret (HMAC 모드)
38
+ * ENTITY_SERVER_TOKEN your-jwt-token (JWT 모드)
35
39
  *
36
40
  * 사용 예:
37
41
  * EntityServerClient es = new EntityServerClient();
@@ -55,12 +59,15 @@ import java.util.UUID;
55
59
  */
56
60
  public class EntityServerClient {
57
61
 
58
- private final String baseUrl;
59
- private final String apiKey;
60
- private final String hmacSecret;
61
- private final int timeoutMs;
62
- private final int magicLen;
63
- private String activeTxId = null;
62
+ private final String baseUrl;
63
+ private final String apiKey;
64
+ private final String hmacSecret;
65
+ private final int timeoutMs;
66
+ /** true 이면 POST 요청 바디를 XChaCha20-Poly1305로 암호화합니다. */
67
+ private boolean encryptRequests;
68
+ private boolean packetEncryption = false;
69
+ private String token = "";
70
+ private String activeTxId = null;
64
71
 
65
72
  public EntityServerClient() {
66
73
  this(
@@ -68,43 +75,100 @@ public class EntityServerClient {
68
75
  getEnv("ENTITY_SERVER_API_KEY", ""),
69
76
  getEnv("ENTITY_SERVER_HMAC_SECRET", ""),
70
77
  10_000,
71
- Integer.parseInt(getEnv("ENTITY_PACKET_MAGIC_LEN", "4"))
78
+ false
72
79
  );
80
+ this.token = getEnv("ENTITY_SERVER_TOKEN", "");
73
81
  }
74
82
 
75
- public EntityServerClient(String baseUrl, String apiKey, String hmacSecret, int timeoutMs, int magicLen) {
76
- this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
77
- this.apiKey = apiKey;
78
- this.hmacSecret = hmacSecret;
79
- this.timeoutMs = timeoutMs;
80
- this.magicLen = magicLen;
83
+ /** JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용합니다. */
84
+ public void setToken(String token) { this.token = token; }
85
+
86
+ public EntityServerClient(String baseUrl, String apiKey, String hmacSecret, int timeoutMs) {
87
+ this(baseUrl, apiKey, hmacSecret, timeoutMs, false);
81
88
  }
82
89
 
83
- // ─── CRUD ────────────────────────────────────────────────────────────────
90
+ public EntityServerClient(String baseUrl, String apiKey, String hmacSecret, int timeoutMs, boolean encryptRequests) {
91
+ this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
92
+ this.apiKey = apiKey;
93
+ this.hmacSecret = hmacSecret;
94
+ this.timeoutMs = timeoutMs;
95
+ this.encryptRequests = encryptRequests;
96
+ }
84
97
 
98
+ // ─── CRUD ────────────────────────────────────────────────────────────────
99
+ /**
100
+ * 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
101
+ * 서버가 packet_encryption: true 를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다.
102
+ * @return JSON 문자열 (예: {"ok":true} 또는 {"ok":true,"packet_encryption":true})
103
+ */
104
+ public String checkHealth() throws IOException {
105
+ String json = request("GET", "/v1/health", null);
106
+ if (json != null && json.contains("\"packet_encryption\":true")) {
107
+ packetEncryption = true;
108
+ }
109
+ return json;
110
+ }
85
111
  /** 단건 조회 */
86
112
  public String get(String entity, long seq) throws IOException {
87
- return request("GET", "/v1/entity/" + entity + "/" + seq, null);
113
+ return get(entity, seq, false);
88
114
  }
89
115
 
90
- /** 목록 조회 */
91
- public String list(String entity, int page, int limit, String orderBy) throws IOException {
116
+ /** 단건 조회 (skipHooks 지원) */
117
+ public String get(String entity, long seq, boolean skipHooks) throws IOException {
118
+ String q = skipHooks ? "?skipHooks=true" : "";
119
+ return request("GET", "/v1/entity/" + entity + "/" + seq + q, null);
120
+ }
121
+
122
+ /** 조건으로 단건 조회 — conditions 에 맞는 행 1건 반환, 없으면 404 */
123
+ public String find(String entity, String conditionsJson) throws IOException {
124
+ return find(entity, conditionsJson, false);
125
+ }
126
+
127
+ /** 조건으로 단건 조회 (skipHooks 지원) */
128
+ public String find(String entity, String conditionsJson, boolean skipHooks) throws IOException {
129
+ String q = skipHooks ? "?skipHooks=true" : "";
130
+ return request("POST", "/v1/entity/" + entity + "/find" + q,
131
+ conditionsJson != null ? conditionsJson : "{}");
132
+ }
133
+
134
+ /**
135
+ * 목록 조회 (POST + conditions body)
136
+ * @param orderBy 정렬 기준 필드명. null 이면 기본 정렬. - 접두사로 내림차순
137
+ * @param conditionsJson 필터 조건 JSON 객체. index/hash/unique 필드만 사용 가능. null 이면 전체
138
+ */
139
+ public String list(String entity, int page, int limit, String orderBy, String conditionsJson) throws IOException {
92
140
  String query = "?page=" + page + "&limit=" + limit + (orderBy != null ? "&order_by=" + orderBy : "");
93
- return request("GET", "/v1/entity/" + entity + "/list" + query, null);
141
+ return request("POST", "/v1/entity/" + entity + "/list" + query, conditionsJson != null ? conditionsJson : "{}");
142
+ }
143
+
144
+ /** 목록 조회 (조건 없음) */
145
+ public String list(String entity, int page, int limit, String orderBy) throws IOException {
146
+ return list(entity, page, limit, orderBy, null);
94
147
  }
95
148
 
96
149
  /** 건수 조회 */
97
150
  public String count(String entity) throws IOException {
98
- return request("GET", "/v1/entity/" + entity + "/count", null);
151
+ return count(entity, null);
152
+ }
153
+
154
+ /** 건수 조회 (conditions 지원) */
155
+ public String count(String entity, String conditionsJson) throws IOException {
156
+ return request("POST", "/v1/entity/" + entity + "/count", conditionsJson != null ? conditionsJson : "{}");
99
157
  }
100
158
 
101
159
  /**
102
- * 필터 검색
103
- * @param filterJson JSON 배열 문자열. 예: [{"field":"status","op":"eq","value":"active"}]
160
+ * 커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만, JOIN 지원)
161
+ *
162
+ * @param sql SELECT SQL문. 사용자 입력은 반드시 ? 로 바인딩 (SQL Injection 방지)
163
+ * @param paramsJson 바인딩 파라미터 JSON 배열. 예: "[\"pending\"]" (null 이면 빈 배열)
164
+ * @param limit 최대 반환 건수 (최대 1000. 0 이하이면 서버 기본값 적용)
104
165
  */
105
- public String query(String entity, String filterJson, int page, int limit) throws IOException {
106
- String query = "?page=" + page + "&limit=" + limit;
107
- return request("POST", "/v1/entity/" + entity + "/query" + query, filterJson);
166
+ public String query(String entity, String sql, String paramsJson, int limit) throws IOException {
167
+ StringBuilder body = new StringBuilder("{\"sql\":").append(jsonString(sql));
168
+ body.append(",\"params\":").append(paramsJson != null ? paramsJson : "[]");
169
+ if (limit > 0) body.append(",\"limit\":").append(limit);
170
+ body.append("}");
171
+ return request("POST", "/v1/entity/" + entity + "/query", body.toString());
108
172
  }
109
173
 
110
174
  /**
@@ -159,29 +223,42 @@ public class EntityServerClient {
159
223
  * @param dataJson JSON 객체 문자열. seq 포함 시 수정, 없으면 생성.
160
224
  */
161
225
  public String submit(String entity, String dataJson) throws IOException {
162
- return submit(entity, dataJson, null);
226
+ return submit(entity, dataJson, null, false);
163
227
  }
164
228
 
165
229
  /** 생성 또는 수정 (트랜잭션 지원) */
166
230
  public String submit(String entity, String dataJson, String transactionId) throws IOException {
231
+ return submit(entity, dataJson, transactionId, false);
232
+ }
233
+
234
+ /** 생성 또는 수정 (skipHooks 지원) */
235
+ public String submit(String entity, String dataJson, String transactionId, boolean skipHooks) throws IOException {
167
236
  Map<String, String> extra = new HashMap<>();
168
237
  String txId = transactionId != null ? transactionId : activeTxId;
169
238
  if (txId != null) extra.put("X-Transaction-ID", txId);
170
- return request("POST", "/v1/entity/" + entity + "/submit", dataJson, extra);
239
+ String q = skipHooks ? "?skipHooks=true" : "";
240
+ return request("POST", "/v1/entity/" + entity + "/submit" + q, dataJson, extra);
171
241
  }
172
242
 
173
243
  /** 삭제 */
174
244
  public String delete(String entity, long seq) throws IOException {
175
- return delete(entity, seq, null, false);
245
+ return delete(entity, seq, null, false, false);
176
246
  }
177
247
 
178
- /** 삭제 (트랜잭션 지원) */
248
+ /** 삭제 (트랜잭션/하드 삭제 지원) */
179
249
  public String delete(String entity, long seq, String transactionId, boolean hard) throws IOException {
180
- String q = hard ? "?hard=true" : "";
250
+ return delete(entity, seq, transactionId, hard, false);
251
+ }
252
+
253
+ /** 삭제 (skipHooks 지원). 서버는 POST /delete/:seq 로만 처리합니다. */
254
+ public String delete(String entity, long seq, String transactionId, boolean hard, boolean skipHooks) throws IOException {
255
+ StringBuilder qb = new StringBuilder();
256
+ if (hard) { qb.append(qb.length() == 0 ? "?" : "&").append("hard=true"); }
257
+ if (skipHooks) { qb.append(qb.length() == 0 ? "?" : "&").append("skipHooks=true"); }
181
258
  Map<String, String> extra = new HashMap<>();
182
259
  String txId = transactionId != null ? transactionId : activeTxId;
183
260
  if (txId != null) extra.put("X-Transaction-ID", txId);
184
- return request("DELETE", "/v1/entity/" + entity + "/delete/" + seq + q, null, extra);
261
+ return request("POST", "/v1/entity/" + entity + "/delete/" + seq + qb.toString(), null, extra);
185
262
  }
186
263
 
187
264
  /** 변경 이력 조회 */
@@ -195,6 +272,110 @@ public class EntityServerClient {
195
272
  return request("POST", "/v1/entity/" + entity + "/rollback/" + historySeq, null);
196
273
  }
197
274
 
275
+ /** 푸시 발송 트리거 엔티티에 submit합니다. */
276
+ public String push(String pushEntity, String payloadJson) throws IOException {
277
+ return push(pushEntity, payloadJson, null);
278
+ }
279
+
280
+ /** 푸시 발송 트리거 엔티티에 submit합니다. (트랜잭션 지원) */
281
+ public String push(String pushEntity, String payloadJson, String transactionId) throws IOException {
282
+ return submit(pushEntity, payloadJson, transactionId);
283
+ }
284
+
285
+ /** push_log 목록 조회 헬퍼 */
286
+ public String pushLogList() throws IOException {
287
+ return pushLogList(1, 20, null);
288
+ }
289
+
290
+ /** push_log 목록 조회 헬퍼 */
291
+ public String pushLogList(int page, int limit, String orderBy) throws IOException {
292
+ return list("push_log", page, limit, orderBy);
293
+ }
294
+
295
+ /** account_device 디바이스 등록/갱신 헬퍼 (push_token 단일 필드) */
296
+ public String registerPushDevice(
297
+ long accountSeq,
298
+ String deviceId,
299
+ String pushToken,
300
+ String platform,
301
+ String deviceType,
302
+ boolean pushEnabled,
303
+ String transactionId
304
+ ) throws IOException {
305
+ StringBuilder payload = new StringBuilder("{");
306
+ payload.append("\"id\":").append(jsonString(deviceId));
307
+ payload.append(",\"account_seq\":").append(accountSeq);
308
+ payload.append(",\"push_token\":").append(jsonString(pushToken));
309
+ payload.append(",\"push_enabled\":").append(pushEnabled);
310
+ if (platform != null && !platform.isBlank()) {
311
+ payload.append(",\"platform\":").append(jsonString(platform));
312
+ }
313
+ if (deviceType != null && !deviceType.isBlank()) {
314
+ payload.append(",\"device_type\":").append(jsonString(deviceType));
315
+ }
316
+ payload.append("}");
317
+
318
+ return submit("account_device", payload.toString(), transactionId);
319
+ }
320
+
321
+ /** account_device.seq 기준 push_token 갱신 헬퍼 */
322
+ public String updatePushDeviceToken(
323
+ long deviceSeq,
324
+ String pushToken,
325
+ boolean pushEnabled,
326
+ String transactionId
327
+ ) throws IOException {
328
+ String payload = "{" +
329
+ "\"seq\":" + deviceSeq +
330
+ ",\"push_token\":" + jsonString(pushToken) +
331
+ ",\"push_enabled\":" + pushEnabled +
332
+ "}";
333
+ return submit("account_device", payload, transactionId);
334
+ }
335
+
336
+ /** account_device.seq 기준 푸시 수신 비활성화 헬퍼 */
337
+ public String disablePushDevice(long deviceSeq, String transactionId) throws IOException {
338
+ String payload = "{" +
339
+ "\"seq\":" + deviceSeq +
340
+ ",\"push_enabled\":false" +
341
+ "}";
342
+ return submit("account_device", payload, transactionId);
343
+ }
344
+
345
+ /**
346
+ * 요청 본문을 읽어 JSON 문자열로 반환합니다.
347
+ * - application/octet-stream: 암호 패킷 복호화
348
+ * - 그 외: 평문 JSON 문자열 반환
349
+ */
350
+ public String readRequestBody(byte[] rawBody, String contentType, boolean requireEncrypted) throws IOException {
351
+ String lowered = contentType == null ? "" : contentType.toLowerCase();
352
+ boolean isEncrypted = lowered.contains("application/octet-stream");
353
+
354
+ if (requireEncrypted && !isEncrypted) {
355
+ throw new IOException("Encrypted request required: Content-Type must be application/octet-stream");
356
+ }
357
+
358
+ if (isEncrypted) {
359
+ if (rawBody == null || rawBody.length == 0) {
360
+ throw new IOException("Encrypted request body is empty");
361
+ }
362
+ try {
363
+ return decryptPacket(rawBody);
364
+ } catch (Exception e) {
365
+ throw new IOException("Packet decryption failed: " + e.getMessage(), e);
366
+ }
367
+ }
368
+
369
+ if (rawBody == null || rawBody.length == 0) {
370
+ return "{}";
371
+ }
372
+ return new String(rawBody, StandardCharsets.UTF_8);
373
+ }
374
+
375
+ public String readRequestBody(byte[] rawBody, String contentType) throws IOException {
376
+ return readRequestBody(rawBody, contentType, false);
377
+ }
378
+
198
379
  // ─── 내부 ─────────────────────────────────────────────────────────────────
199
380
 
200
381
  private String request(String method, String path, String body) throws IOException {
@@ -202,40 +383,59 @@ public class EntityServerClient {
202
383
  }
203
384
 
204
385
  private String request(String method, String path, String body, Map<String, String> extraHeaders) throws IOException {
205
- String bodyStr = body != null ? body : "";
206
- String timestamp = String.valueOf(System.currentTimeMillis() / 1000L);
207
- String nonce = UUID.randomUUID().toString();
208
- String signature = sign(method, path, timestamp, nonce, bodyStr);
386
+ // 요청 바디 결정: encryptRequests POST 바디를 암호화
387
+ byte[] bodyBytes;
388
+ String contentType;
389
+ if ((encryptRequests || packetEncryption) && body != null && !body.isEmpty()) {
390
+ try {
391
+ bodyBytes = encryptPacket(body.getBytes(StandardCharsets.UTF_8));
392
+ } catch (Exception e) {
393
+ throw new IOException("Packet encryption failed: " + e.getMessage(), e);
394
+ }
395
+ contentType = "application/octet-stream";
396
+ } else {
397
+ bodyBytes = body != null ? body.getBytes(StandardCharsets.UTF_8) : new byte[0];
398
+ contentType = "application/json";
399
+ }
400
+
401
+ boolean isHmacMode = !apiKey.isEmpty() && !hmacSecret.isEmpty();
209
402
 
210
403
  URL url = new URL(baseUrl + path);
211
404
  HttpURLConnection conn = (HttpURLConnection) url.openConnection();
212
405
  conn.setRequestMethod(method);
213
406
  conn.setConnectTimeout(timeoutMs);
214
407
  conn.setReadTimeout(timeoutMs);
215
- conn.setRequestProperty("Content-Type", "application/json");
216
- conn.setRequestProperty("X-API-Key", apiKey);
217
- conn.setRequestProperty("X-Timestamp", timestamp);
218
- conn.setRequestProperty("X-Nonce", nonce);
219
- conn.setRequestProperty("X-Signature", signature);
408
+ conn.setRequestProperty("Content-Type", contentType);
409
+ if (isHmacMode) {
410
+ String timestamp = String.valueOf(System.currentTimeMillis() / 1000L);
411
+ String nonce = UUID.randomUUID().toString();
412
+ String signature = sign(method, path, timestamp, nonce, bodyBytes);
413
+ conn.setRequestProperty("X-API-Key", apiKey);
414
+ conn.setRequestProperty("X-Timestamp", timestamp);
415
+ conn.setRequestProperty("X-Nonce", nonce);
416
+ conn.setRequestProperty("X-Signature", signature);
417
+ } else if (token != null && !token.isEmpty()) {
418
+ conn.setRequestProperty("Authorization", "Bearer " + token);
419
+ }
220
420
  for (Map.Entry<String, String> h : extraHeaders.entrySet()) {
221
421
  conn.setRequestProperty(h.getKey(), h.getValue());
222
422
  }
223
423
 
224
- if (!bodyStr.isEmpty()) {
424
+ if (bodyBytes.length > 0) {
225
425
  conn.setDoOutput(true);
226
426
  try (OutputStream os = conn.getOutputStream()) {
227
- os.write(bodyStr.getBytes(StandardCharsets.UTF_8));
427
+ os.write(bodyBytes);
228
428
  }
229
429
  }
230
430
 
231
431
  int status = conn.getResponseCode();
232
- String contentType = conn.getContentType();
432
+ String respCT = conn.getContentType();
233
433
  InputStream stream = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
234
434
  byte[] rawBytes = readAllBytes(stream);
235
435
 
236
436
  // 패킷 암호화 응답: application/octet-stream → 복호화
237
437
  String response;
238
- if (contentType != null && contentType.contains("application/octet-stream")) {
438
+ if (respCT != null && respCT.contains("application/octet-stream")) {
239
439
  try {
240
440
  response = decryptPacket(rawBytes);
241
441
  } catch (Exception e) {
@@ -251,17 +451,68 @@ public class EntityServerClient {
251
451
  return response;
252
452
  }
253
453
 
454
+ /**
455
+ * 패킷 암호화 키를 유도합니다.
456
+ * - HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
457
+ * - JWT 모드: SHA256(token)
458
+ */
459
+ private byte[] derivePacketKey() {
460
+ if (token != null && !token.isEmpty() && hmacSecret.isEmpty()) {
461
+ try {
462
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
463
+ return digest.digest(token.getBytes(StandardCharsets.UTF_8));
464
+ } catch (Exception e) {
465
+ throw new RuntimeException("SHA-256 failed", e);
466
+ }
467
+ }
468
+ HKDFBytesGenerator gen = new HKDFBytesGenerator(new SHA256Digest());
469
+ gen.init(new HKDFParameters(
470
+ hmacSecret.getBytes(StandardCharsets.UTF_8),
471
+ "entity-server:hkdf:v1".getBytes(StandardCharsets.UTF_8),
472
+ "entity-server:packet-encryption".getBytes(StandardCharsets.UTF_8)
473
+ ));
474
+ byte[] key = new byte[32];
475
+ gen.generateBytes(key, 0, 32);
476
+ return key;
477
+ }
478
+
479
+ /**
480
+ * XChaCha20-Poly1305 패킷 암호화
481
+ * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
482
+ * magicLen: 2 + (key[31] & 0xFF) % 14
483
+ */
484
+ private byte[] encryptPacket(byte[] plaintext) throws Exception {
485
+ byte[] key = derivePacketKey();
486
+ int magicLen = 2 + (key[31] & 0xFF) % 14;
487
+ byte[] magic = new byte[magicLen];
488
+ byte[] nonce = new byte[24];
489
+ SecureRandom rng = new SecureRandom();
490
+ rng.nextBytes(magic);
491
+ rng.nextBytes(nonce);
492
+
493
+ ChaCha20Poly1305 aead = new ChaCha20Poly1305();
494
+ aead.init(true, new AEADParameters(new KeyParameter(key), 128, nonce));
495
+ byte[] ciphertext = new byte[aead.getOutputSize(plaintext.length)];
496
+ int len = aead.processBytes(plaintext, 0, plaintext.length, ciphertext, 0);
497
+ aead.doFinal(ciphertext, len);
498
+
499
+ byte[] result = new byte[magic.length + nonce.length + ciphertext.length];
500
+ System.arraycopy(magic, 0, result, 0, magic.length);
501
+ System.arraycopy(nonce, 0, result, magic.length, nonce.length);
502
+ System.arraycopy(ciphertext, 0, result, magic.length + nonce.length, ciphertext.length);
503
+ return result;
504
+ }
505
+
254
506
  /**
255
507
  * XChaCha20-Poly1305 패킷 복호화
256
508
  * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
257
- * 키: sha256(hmac_secret)
258
- *
259
- * Bouncy Castle ChaCha20Poly1305 사용 (nonce 24바이트 → 자동으로 XChaCha20 선택)
509
+ * 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
260
510
  */
261
511
  private String decryptPacket(byte[] data) throws Exception {
262
- byte[] key = sha256(hmacSecret.getBytes(StandardCharsets.UTF_8));
263
- byte[] nonce = Arrays.copyOfRange(data, magicLen, magicLen + 24);
264
- byte[] ctext = Arrays.copyOfRange(data, magicLen + 24, data.length);
512
+ byte[] key = derivePacketKey();
513
+ int magicLen = 2 + (key[31] & 0xFF) % 14;
514
+ byte[] nonce = Arrays.copyOfRange(data, magicLen, magicLen + 24);
515
+ byte[] ctext = Arrays.copyOfRange(data, magicLen + 24, data.length);
265
516
 
266
517
  ChaCha20Poly1305 aead = new ChaCha20Poly1305();
267
518
  aead.init(false, new AEADParameters(new KeyParameter(key), 128, nonce));
@@ -272,23 +523,23 @@ public class EntityServerClient {
272
523
  return new String(plaintext, StandardCharsets.UTF_8);
273
524
  }
274
525
 
275
- /** HMAC-SHA256 서명 */
276
- private String sign(String method, String path, String timestamp, String nonce, String body) {
526
+ /**
527
+ * HMAC-SHA256 서명. bodyBytes JSON 또는 암호화된 바이너리 모두 지원합니다.
528
+ */
529
+ private String sign(String method, String path, String timestamp, String nonce, byte[] bodyBytes) {
277
530
  try {
278
- String payload = String.join("|", method, path, timestamp, nonce, body);
279
531
  Mac mac = Mac.getInstance("HmacSHA256");
280
532
  mac.init(new SecretKeySpec(hmacSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
281
- byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
282
- return HexFormat.of().formatHex(hash); // Java 17+
533
+ String prefix = method + "|" + path + "|" + timestamp + "|" + nonce + "|";
534
+ mac.update(prefix.getBytes(StandardCharsets.UTF_8));
535
+ if (bodyBytes != null && bodyBytes.length > 0) mac.update(bodyBytes);
536
+ byte[] hash = mac.doFinal();
537
+ return HexFormat.of().formatHex(hash);
283
538
  } catch (Exception e) {
284
539
  throw new RuntimeException("HMAC signing failed", e);
285
540
  }
286
541
  }
287
542
 
288
- private static byte[] sha256(byte[] input) throws Exception {
289
- return MessageDigest.getInstance("SHA-256").digest(input);
290
- }
291
-
292
543
  private static byte[] readAllBytes(InputStream in) throws IOException {
293
544
  ByteArrayOutputStream buf = new ByteArrayOutputStream();
294
545
  byte[] chunk = new byte[8192];
@@ -301,4 +552,17 @@ public class EntityServerClient {
301
552
  String v = System.getenv(key);
302
553
  return (v != null && !v.isBlank()) ? v : defaultValue;
303
554
  }
555
+
556
+ private static String jsonString(String value) {
557
+ if (value == null) {
558
+ return "null";
559
+ }
560
+ String escaped = value
561
+ .replace("\\", "\\\\")
562
+ .replace("\"", "\\\"")
563
+ .replace("\n", "\\n")
564
+ .replace("\r", "\\r")
565
+ .replace("\t", "\\t");
566
+ return "\"" + escaped + "\"";
567
+ }
304
568
  }
@@ -31,10 +31,11 @@ public class EntityServerExample {
31
31
  String updated = es.submit("product", "{\"seq\":1,\"name\":\"게이밍 노트북\",\"price\":2000000}");
32
32
  System.out.println("Updated: " + updated);
33
33
 
34
- // 필터 검색
34
+ // 커스텀 SQL 검색
35
35
  String results = es.query("product",
36
- "[{\"field\":\"category\",\"op\":\"eq\",\"value\":\"electronics\"}]",
37
- 1, 10
36
+ "SELECT seq, name, category FROM product WHERE category = ?",
37
+ "[\"electronics\"]",
38
+ 10
38
39
  );
39
40
  System.out.println("Query: " + results);
40
41