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
@@ -2,6 +2,8 @@
2
2
 
3
3
  namespace App\Libraries;
4
4
 
5
+ use Config\EntityServer as EntityServerConfig;
6
+
5
7
  /**
6
8
  * Entity Server 클라이언트 라이브러리 (CodeIgniter 4)
7
9
  *
@@ -9,11 +11,11 @@ namespace App\Libraries;
9
11
  *
10
12
  * 설치: app/Libraries/EntityServer.php 에 배치
11
13
  *
12
- * 설정: app/Config/EntityServer.php 또는 .env 에서
14
+ * 설정: app/Config/EntityServer.php 우선 (필요시 .env fallback)
13
15
  * ENTITY_SERVER_URL=http://localhost:47200
14
16
  * ENTITY_SERVER_API_KEY=your-api-key
15
17
  * ENTITY_SERVER_HMAC_SECRET=your-hmac-secret
16
- * ENTITY_PACKET_MAGIC_LEN=4
18
+
17
19
  *
18
20
  * 컨트롤러 사용법:
19
21
  * $es = new \App\Libraries\EntityServer();
@@ -26,55 +28,158 @@ class EntityServer
26
28
  private string $baseUrl;
27
29
  private string $apiKey;
28
30
  private string $hmacSecret;
31
+ private string $token = '';
29
32
  private int $timeout;
30
- private int $magicLen;
33
+ private bool $requireEncryptedRequest;
34
+ private bool $encryptRequests;
35
+ private bool $packetEncryption = false;
31
36
  private ?string $activeTxId = null;
32
37
 
33
38
  public function __construct(
34
- string $baseUrl = '',
35
- string $apiKey = '',
36
- string $hmacSecret = '',
37
- int $timeout = 10,
38
- int $magicLen = 4
39
+ ?string $baseUrl = null,
40
+ ?string $apiKey = null,
41
+ ?string $hmacSecret = null,
42
+ ?string $token = null,
43
+ ?int $timeout = null,
44
+ ?bool $requireEncryptedRequest = null,
45
+ ?bool $encryptRequests = null
39
46
  ) {
40
- $this->baseUrl = rtrim($baseUrl ?: env('ENTITY_SERVER_URL', 'http://localhost:47200'), '/');
41
- $this->apiKey = $apiKey ?: env('ENTITY_SERVER_API_KEY', '');
42
- $this->hmacSecret = $hmacSecret ?: env('ENTITY_SERVER_HMAC_SECRET', '');
43
- $this->timeout = $timeout;
44
- $this->magicLen = (int) ($magicLen ?: env('ENTITY_PACKET_MAGIC_LEN', 4));
47
+ $config = class_exists(EntityServerConfig::class) ? new EntityServerConfig() : null;
48
+
49
+ $configBaseUrl = $config?->baseUrl ?? env('ENTITY_SERVER_URL', 'http://localhost:47200');
50
+ $configApiKey = $config?->apiKey ?? env('ENTITY_SERVER_API_KEY', '');
51
+ $configHmacSecret = $config?->hmacSecret ?? env('ENTITY_SERVER_HMAC_SECRET', '');
52
+ $configToken = $config?->token ?? env('ENTITY_SERVER_TOKEN', '');
53
+ $configTimeout = $config?->timeout ?? (int) env('ENTITY_SERVER_TIMEOUT', 10);
54
+ $configRequireEncrypted = $config?->requireEncryptedRequest ?? true;
55
+ $configEncryptRequests = $config?->encryptRequests ?? false;
56
+
57
+ $this->baseUrl = rtrim($baseUrl ?? (string) $configBaseUrl, '/');
58
+ $this->apiKey = (string) ($apiKey ?? $configApiKey);
59
+ $this->hmacSecret = (string) ($hmacSecret ?? $configHmacSecret);
60
+ $this->token = (string) ($token ?? $configToken);
61
+ $this->timeout = (int) ($timeout ?? $configTimeout);
62
+ $this->requireEncryptedRequest = (bool) ($requireEncryptedRequest ?? $configRequireEncrypted);
63
+ $this->encryptRequests = (bool) ($encryptRequests ?? $configEncryptRequests);
64
+ }
65
+
66
+ /** JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용합니다. */
67
+ public function setToken(string $token): void
68
+ {
69
+ $this->token = $token;
45
70
  }
46
71
 
47
72
  // ─── CRUD ────────────────────────────────────────────────────────────────
48
73
 
49
- /** 단건 조회 */
50
- public function get(string $entity, int $seq): array
74
+ /** * 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
75
+ * 서버가 packet_encryption: true 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다.
76
+ */
77
+ public function checkHealth(): array
51
78
  {
52
- return $this->request('GET', "/v1/entity/{$entity}/{$seq}");
79
+ $ch = curl_init($this->baseUrl . '/v1/health');
80
+ curl_setopt_array($ch, [
81
+ CURLOPT_RETURNTRANSFER => true,
82
+ CURLOPT_TIMEOUT => $this->timeout,
83
+ ]);
84
+ $response = curl_exec($ch);
85
+ curl_close($ch);
86
+ $decoded = json_decode($response, true) ?? [];
87
+ if (!empty($decoded['packet_encryption'])) {
88
+ $this->packetEncryption = true;
89
+ }
90
+ return $decoded;
53
91
  }
54
92
 
55
- /** 목록 조회 */
56
- public function list(string $entity, array $params = []): array
93
+ /** * 단건 조회
94
+ *
95
+ * @param bool $skipHooks true 이면 after_get 훅 미실행
96
+ */
97
+ public function get(string $entity, int $seq, bool $skipHooks = false): array
57
98
  {
58
- $query = http_build_query(array_merge(['page' => 1, 'limit' => 20], $params));
59
- return $this->request('GET', "/v1/entity/{$entity}/list?{$query}");
99
+ $q = $skipHooks ? '?skipHooks=true' : '';
100
+ return $this->request('GET', "/v1/entity/{$entity}/{$seq}{$q}");
60
101
  }
61
102
 
62
- /** 건수 조회 */
63
- public function count(string $entity): array
103
+ /**
104
+ * 조건으로 단건 조회 (POST + conditions body)
105
+ *
106
+ * @param array $conditions 필터 조건. index/hash/unique 필드만 사용 가능.
107
+ * 예: ['email' => 'user@example.com']
108
+ * @param bool $skipHooks true 이면 after_find 훅 미실행
109
+ */
110
+ public function find(string $entity, array $conditions, bool $skipHooks = false): array
64
111
  {
65
- return $this->request('GET', "/v1/entity/{$entity}/count");
112
+ $q = $skipHooks ? '?skipHooks=true' : '';
113
+ return $this->request('POST', "/v1/entity/{$entity}/find{$q}", $conditions);
66
114
  }
67
115
 
68
116
  /**
69
- * 필터 검색
117
+ * 목록 조회
70
118
  *
71
- * @param array $filter 예: [['field' => 'status', 'op' => 'eq', 'value' => 'active']]
72
- * @param array $params 예: ['page' => 1, 'limit' => 20, 'order_by' => 'name']
119
+ * @param array $params 페이지/정렬 파라미터 (page, limit, orderBy, orderDir, fields)
120
+ * @param array $conditions 필터 조건 POST body. index/hash/unique 필드만 사용 가능.
121
+ * 예: ['status' => 'active']
122
+ * fields 예: ['*'] 시 전체 필드 반환, 미지정 시 인덱스 필드만 반환 (기본, 가장 빠름)
123
+ * fields 예: ['name','email'] 또는 미지정
73
124
  */
74
- public function query(string $entity, array $filter = [], array $params = []): array
125
+ public function list(string $entity, array $params = [], array $conditions = []): array
75
126
  {
76
- $query = http_build_query(array_merge(['page' => 1, 'limit' => 20], $params));
77
- return $this->request('POST', "/v1/entity/{$entity}/query?{$query}", $filter);
127
+ $queryParams = array_merge(['page' => 1, 'limit' => 20], $params);
128
+
129
+ // orderBy + orderDir → orderBy 앞에 - 접두사 방식으로 변환
130
+ if (isset($queryParams['orderDir'])) {
131
+ $dir = strtoupper((string) $queryParams['orderDir']);
132
+ $orderBy = (string) ($queryParams['orderBy'] ?? '');
133
+ if ($dir === 'DESC' && $orderBy !== '') {
134
+ $queryParams['orderBy'] = '-' . ltrim($orderBy, '-');
135
+ }
136
+ unset($queryParams['orderDir']);
137
+ }
138
+
139
+ // fields 배열 → 쉼표 구분 문자열
140
+ if (isset($queryParams['fields']) && is_array($queryParams['fields'])) {
141
+ $queryParams['fields'] = implode(',', $queryParams['fields']);
142
+ }
143
+
144
+ $query = http_build_query($queryParams);
145
+ return $this->request('POST', "/v1/entity/{$entity}/list?{$query}", $conditions);
146
+ }
147
+
148
+ /**
149
+ * 건수 조회
150
+ *
151
+ * @param array $conditions 필터 조건 (list()와 동일 규칙)
152
+ */
153
+ public function count(string $entity, array $conditions = []): array
154
+ {
155
+ return $this->request('POST', "/v1/entity/{$entity}/count", $conditions);
156
+ }
157
+
158
+ /**
159
+ * 커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만)
160
+ *
161
+ * - SELECT 쿼리만 허용 (INSERT/UPDATE/DELETE 불가)
162
+ * - 인덱스 테이블(`entity_idx_*`)만 접근 가능. SELECT * 불가
163
+ * - JOIN 지원. 최대 반환 건수 1000
164
+ * - 사용자 입력은 반드시 params 로 바인딩 (SQL Injection 방지)
165
+ *
166
+ * @param string $entity URL 라우트용 기본 엔티티명
167
+ * @param string $sql SELECT SQL
168
+ * @param array $params ? 플레이스홀더 바인딩 값
169
+ * @param int|null $limit 최대 반환 건수 (최대 1000)
170
+ *
171
+ * 예:
172
+ * $es->query('order',
173
+ * 'SELECT o.seq, o.status, u.name FROM order o JOIN account u ON u.data_seq = o.account_seq WHERE o.status = ?',
174
+ * ['pending'], 100);
175
+ */
176
+ public function query(string $entity, string $sql, array $params = [], ?int $limit = null): array
177
+ {
178
+ $body = ['sql' => $sql, 'params' => $params];
179
+ if ($limit !== null) {
180
+ $body['limit'] = $limit;
181
+ }
182
+ return $this->request('POST', "/v1/entity/{$entity}/query", $body);
78
183
  }
79
184
 
80
185
  /**
@@ -121,26 +226,39 @@ class EntityServer
121
226
  * 생성 또는 수정
122
227
  * - body에 'seq' 포함 → 수정
123
228
  * - body에 'seq' 없음 → 생성 (seq 반환)
229
+ * - unique 필드 기준 중복 시 자동 UPDATE (upsert)
230
+ *
124
231
  * @param string|null $transactionId transStart() 로 얻은 ID (생략 시 활성 트랜잭션 자동 사용)
232
+ * @param bool $skipHooks true 이면 before/after_insert, before/after_update 훅 미실행
125
233
  */
126
- public function submit(string $entity, array $data, ?string $transactionId = null): array
234
+ public function submit(string $entity, array $data, ?string $transactionId = null, bool $skipHooks = false): array
127
235
  {
128
236
  $txId = $transactionId ?? $this->activeTxId;
129
237
  $extra = $txId ? ['X-Transaction-ID: ' . $txId] : [];
130
- return $this->request('POST', "/v1/entity/{$entity}/submit", $data, $extra);
238
+ $q = $skipHooks ? '?skipHooks=true' : '';
239
+ return $this->request('POST', "/v1/entity/{$entity}/submit{$q}", $data, $extra);
131
240
  }
132
241
 
133
242
  /**
134
- * 삭제
135
- * @param bool $hard true 이면 물리 삭제
243
+ * 삭제 (서버는 POST /delete/:seq 로만 처리)
244
+ *
245
+ * @param bool $hard true 이면 하드(물리) 삭제. false(기본) 이면 소프트 삭제 (rollback 으로 복원 가능)
136
246
  * @param string|null $transactionId transStart() 로 얻은 ID (생략 시 활성 트랜잭션 자동 사용)
247
+ * @param bool $skipHooks true 이면 before/after_delete 훅 미실행
137
248
  */
138
- public function delete(string $entity, int $seq, ?string $transactionId = null, bool $hard = false): array
249
+ public function delete(string $entity, int $seq, ?string $transactionId = null, bool $hard = false, bool $skipHooks = false): array
139
250
  {
140
- $q = $hard ? '?hard=true' : '';
251
+ $queryParams = [];
252
+ if ($hard) {
253
+ $queryParams[] = 'hard=true';
254
+ }
255
+ if ($skipHooks) {
256
+ $queryParams[] = 'skipHooks=true';
257
+ }
258
+ $q = $queryParams ? '?' . implode('&', $queryParams) : '';
141
259
  $txId = $transactionId ?? $this->activeTxId;
142
260
  $extra = $txId ? ['X-Transaction-ID: ' . $txId] : [];
143
- return $this->request('DELETE', "/v1/entity/{$entity}/delete/{$seq}{$q}", [], $extra);
261
+ return $this->request('POST', "/v1/entity/{$entity}/delete/{$seq}{$q}", [], $extra);
144
262
  }
145
263
 
146
264
  /** 변경 이력 조회 */
@@ -155,22 +273,161 @@ class EntityServer
155
273
  return $this->request('POST', "/v1/entity/{$entity}/rollback/{$historySeq}");
156
274
  }
157
275
 
276
+ /**
277
+ * 푸시 발송 트리거 엔티티에 submit합니다.
278
+ */
279
+ public function push(string $pushEntity, array $payload, ?string $transactionId = null): array
280
+ {
281
+ return $this->submit($pushEntity, $payload, $transactionId);
282
+ }
283
+
284
+ /**
285
+ * push_log 목록 조회 헬퍼
286
+ */
287
+ public function pushLogList(array $params = []): array
288
+ {
289
+ return $this->list('push_log', $params);
290
+ }
291
+
292
+ /**
293
+ * 디바이스 등록/갱신 헬퍼 (push_token 단일 필드)
294
+ *
295
+ * - 기본 대상 엔티티: account_device
296
+ * - 신규 등록: seq 미전달
297
+ * - 기존 레코드 갱신: options['seq'] 전달
298
+ *
299
+ * @param int $accountSeq 계정 seq
300
+ * @param string $deviceId 디바이스 고유 ID (account_device.id)
301
+ * @param string $pushToken 푸시 디바이스 토큰
302
+ * @param array $options 추가 필드 (예: platform, device_type, browser, push_enabled, seq)
303
+ * @param string|null $transactionId transStart()로 얻은 트랜잭션 ID
304
+ */
305
+ public function registerPushDevice(
306
+ int $accountSeq,
307
+ string $deviceId,
308
+ string $pushToken,
309
+ array $options = [],
310
+ ?string $transactionId = null
311
+ ): array {
312
+ $payload = array_merge([
313
+ 'id' => $deviceId,
314
+ 'account_seq' => $accountSeq,
315
+ 'push_token' => $pushToken,
316
+ 'push_enabled' => true,
317
+ ], $options);
318
+
319
+ return $this->submit('account_device', $payload, $transactionId);
320
+ }
321
+
322
+ /**
323
+ * account_device.seq 기준으로 push_token 갱신
324
+ */
325
+ public function updatePushDeviceToken(
326
+ int $deviceSeq,
327
+ string $pushToken,
328
+ bool $pushEnabled = true,
329
+ ?string $transactionId = null
330
+ ): array {
331
+ return $this->submit('account_device', [
332
+ 'seq' => $deviceSeq,
333
+ 'push_token' => $pushToken,
334
+ 'push_enabled' => $pushEnabled,
335
+ ], $transactionId);
336
+ }
337
+
338
+ /**
339
+ * account_device.seq 기준으로 푸시 수신 비활성화
340
+ */
341
+ public function disablePushDevice(
342
+ int $deviceSeq,
343
+ ?string $transactionId = null
344
+ ): array {
345
+ return $this->submit('account_device', [
346
+ 'seq' => $deviceSeq,
347
+ 'push_enabled' => false,
348
+ ], $transactionId);
349
+ }
350
+
351
+ /**
352
+ * CI4 IncomingRequest에서 암호화 패킷을 읽어 JSON 배열로 복호화합니다.
353
+ *
354
+ * @param object $request CodeIgniter\HTTP\IncomingRequest
355
+ * @param bool $requireEncrypted true면 평문 JSON 요청을 거부합니다.
356
+ */
357
+ public function readRequestBody(object $request, ?bool $requireEncrypted = null): array
358
+ {
359
+ $requireEncrypted = $requireEncrypted ?? $this->requireEncryptedRequest;
360
+
361
+ $contentType = '';
362
+ if (method_exists($request, 'getHeaderLine')) {
363
+ $contentType = strtolower((string) $request->getHeaderLine('Content-Type'));
364
+ }
365
+
366
+ $rawBody = '';
367
+ if (method_exists($request, 'getBody')) {
368
+ $rawBody = (string) $request->getBody();
369
+ }
370
+
371
+ $isEncryptedPacket = str_contains($contentType, 'application/octet-stream');
372
+
373
+ if ($requireEncrypted && !$isEncryptedPacket) {
374
+ throw new \RuntimeException('Encrypted request required: Content-Type must be application/octet-stream');
375
+ }
376
+
377
+ if ($isEncryptedPacket) {
378
+ if ($rawBody === '') {
379
+ throw new \RuntimeException('Encrypted request body is empty');
380
+ }
381
+
382
+ $jsonStr = $this->decryptPacket($rawBody);
383
+ $decoded = json_decode($jsonStr, true);
384
+ if (!is_array($decoded)) {
385
+ throw new \RuntimeException('Invalid encrypted JSON payload');
386
+ }
387
+ return $decoded;
388
+ }
389
+
390
+ // 선택적으로 평문 허용할 때만 fallback
391
+ if ($rawBody === '') {
392
+ return [];
393
+ }
394
+ $decoded = json_decode($rawBody, true);
395
+ if (!is_array($decoded)) {
396
+ throw new \RuntimeException('Invalid JSON payload');
397
+ }
398
+ return $decoded;
399
+ }
400
+
158
401
  // ─── 내부 ─────────────────────────────────────────────────────────────────
159
402
 
160
403
  private function request(string $method, string $path, array $body = [], array $extraHeaders = []): array
161
404
  {
162
- $bodyJson = empty($body) ? '' : json_encode($body, JSON_UNESCAPED_UNICODE);
163
- $timestamp = (string) time();
164
- $nonce = $this->generateNonce();
165
- $signature = $this->sign($method, $path, $timestamp, $nonce, $bodyJson);
166
-
167
- $headers = array_merge([
168
- 'Content-Type: application/json',
169
- 'X-API-Key: ' . $this->apiKey,
170
- 'X-Timestamp: ' . $timestamp,
171
- 'X-Nonce: ' . $nonce,
172
- 'X-Signature: ' . $signature,
173
- ], $extraHeaders);
405
+ // 요청 바디 결정: encryptRequests POST 바디를 암호화
406
+ $bodyJson = empty($body) ? '' : json_encode($body, JSON_UNESCAPED_UNICODE);
407
+ $bodyData = $bodyJson;
408
+ $contentType = 'application/json';
409
+ if (($this->encryptRequests || $this->packetEncryption) && $bodyJson !== '') {
410
+ $bodyData = $this->encryptPacket($bodyJson);
411
+ $contentType = 'application/octet-stream';
412
+ }
413
+
414
+ $isHmacMode = $this->apiKey !== '' && $this->hmacSecret !== '';
415
+
416
+ $headers = ["Content-Type: {$contentType}"];
417
+ if ($isHmacMode) {
418
+ $timestamp = (string) time();
419
+ $nonce = $this->generateNonce();
420
+ $signature = $this->sign($method, $path, $timestamp, $nonce, $bodyData);
421
+ $headers = array_merge($headers, [
422
+ 'X-API-Key: ' . $this->apiKey,
423
+ 'X-Timestamp: ' . $timestamp,
424
+ 'X-Nonce: ' . $nonce,
425
+ 'X-Signature: ' . $signature,
426
+ ]);
427
+ } elseif ($this->token !== '') {
428
+ $headers[] = 'Authorization: Bearer ' . $this->token;
429
+ }
430
+ $headers = array_merge($headers, $extraHeaders);
174
431
 
175
432
  $url = $this->baseUrl . $path;
176
433
  $ch = curl_init($url);
@@ -182,13 +439,13 @@ class EntityServer
182
439
  CURLOPT_TIMEOUT => $this->timeout,
183
440
  ]);
184
441
 
185
- if ($bodyJson !== '') {
186
- curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyJson);
442
+ if ($bodyData !== '') {
443
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyData);
187
444
  }
188
445
 
189
446
  $response = curl_exec($ch);
190
447
  $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
191
- $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? '';
448
+ $respContentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? '';
192
449
  $error = curl_error($ch);
193
450
  curl_close($ch);
194
451
 
@@ -197,7 +454,7 @@ class EntityServer
197
454
  }
198
455
 
199
456
  // 패킷 암호화 응답: application/octet-stream → 복호화
200
- if (str_contains($contentType, 'application/octet-stream')) {
457
+ if (str_contains($respContentType, 'application/octet-stream')) {
201
458
  $jsonStr = $this->decryptPacket($response);
202
459
  $decoded = json_decode($jsonStr, true);
203
460
  } else {
@@ -215,18 +472,51 @@ class EntityServer
215
472
  return $decoded;
216
473
  }
217
474
 
475
+ /**
476
+ * 패킷 암호화 키를 유도합니다.
477
+ * - HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
478
+ * - JWT 모드: SHA256(token)
479
+ */
480
+ private function derivePacketKey(): string
481
+ {
482
+ if ($this->token !== '' && $this->hmacSecret === '') {
483
+ return hash('sha256', $this->token, true);
484
+ }
485
+ $salt = 'entity-server:hkdf:v1';
486
+ $info = 'entity-server:packet-encryption';
487
+ // HKDF-Extract: PRK = HMAC-SHA256(salt, IKM)
488
+ $prk = hash_hmac('sha256', $this->hmacSecret, $salt, true);
489
+ // HKDF-Expand(PRK, info, 32): T(1) = HMAC-SHA256(PRK, info || 0x01)
490
+ return substr(hash_hmac('sha256', $info . chr(1), $prk, true), 0, 32);
491
+ }
492
+
493
+ /**
494
+ * XChaCha20-Poly1305 패킷 암호화
495
+ * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
496
+ */
497
+ private function encryptPacket(string $plaintext): string
498
+ {
499
+ $key = $this->derivePacketKey();
500
+ $magicLen = 2 + (ord($key[31]) % 14);
501
+ $magic = random_bytes($magicLen);
502
+ $nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); // 24
503
+ $ct = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, '', $nonce, $key);
504
+ return $magic . $nonce . $ct;
505
+ }
506
+
218
507
  /**
219
508
  * XChaCha20-Poly1305 패킷 복호화
220
509
  * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
221
- * 키: sha256(hmac_secret)
510
+ * 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
222
511
  *
223
512
  * ext-sodium 사용 (PHP 7.2+ 내장)
224
513
  */
225
514
  private function decryptPacket(string $data): string
226
515
  {
227
- $key = hash('sha256', $this->hmacSecret, true);
228
- $nonce = substr($data, $this->magicLen, 24);
229
- $ciphertext = substr($data, $this->magicLen + 24);
516
+ $key = $this->derivePacketKey();
517
+ $magicLen = 2 + (ord($key[31]) % 14);
518
+ $nonce = substr($data, $magicLen, 24);
519
+ $ciphertext = substr($data, $magicLen + 24);
230
520
 
231
521
  $plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ciphertext, '', $nonce, $key);
232
522
  if ($plaintext === false) {
@@ -235,12 +525,14 @@ class EntityServer
235
525
  return $plaintext;
236
526
  }
237
527
 
238
- /** HMAC-SHA256 서명 */
528
+ /**
529
+ * HMAC-SHA256 서명. $body 는 JSON 스트링 또는 바이너리 암호화 페이로드 모두 지원합니다.
530
+ * prefix = "METHOD|path|timestamp|nonce|" 뒤에 $body 를 바로 이어 붙여 서명합니다.
531
+ */
239
532
  private function sign(string $method, string $path, string $timestamp, string $nonce, string $body): string
240
533
  {
241
- // PATH는 쿼리스트링 포함한 전체 경로
242
- $payload = implode('|', [$method, $path, $timestamp, $nonce, $body]);
243
- return hash_hmac('sha256', $payload, $this->hmacSecret);
534
+ $prefix = implode('|', [$method, $path, $timestamp, $nonce]) . '|';
535
+ return hash_hmac('sha256', $prefix . $body, $this->hmacSecret);
244
536
  }
245
537
 
246
538
  private function generateNonce(): string