@h-ai/crypto 0.1.0-alpha.67 → 0.1.0-alpha.69

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/README.en.md CHANGED
@@ -22,7 +22,7 @@ A cryptography module for asymmetric encryption, hashing, symmetric encryption,
22
22
  ### Security notice
23
23
 
24
24
  - **SM4 encryption returns a structured payload by default**: `crypto.symmetric.encrypt(data, key)` uses CBC + a random IV by default and returns `{ mode, ciphertext, iv, encoding }`. Pass that object to `crypto.symmetric.decrypt(payload, key)` to decrypt.
25
- - **SM4 `deriveKey(password, salt)` is not a KDF**: it performs a single SM3 hash and offers no resistance to password cracking. **Never use it for password storage**. Use `crypto.password.hash(password)` for password hashing and PBKDF2 / scrypt / Argon2 for password-based key derivation.
25
+ - **Use random SM4 keys**: generate keys with `crypto.symmetric.generateKey()`. This module does not provide a KDF for password-based encryption keys. Store passwords with the server-only `crypto.password.hash(password)`; do not use passwords or password hashes directly as SM4 keys.
26
26
  - **IVs must be unique**: never reuse an IV with the same key. Prefer default CBC or `encryptWithIV()` to generate one for each call. Transmit and store the IV with the ciphertext; the IV can be public.
27
27
 
28
28
  ### Quick start
@@ -127,10 +127,6 @@ if (ecbEnc.success) {
127
127
  // 输出 base64 格式(ciphertext 字段为 base64,encoding 字段会标明)
128
128
  const b64Enc = crypto.symmetric.encrypt('data', key, { outputFormat: 'base64' })
129
129
 
130
- // ⚠️ 已弃用:从密码派生密钥(仅为单次 SM3 哈希,不是 KDF,禁止用于密码存储场景)
131
- // 密码散列请用 crypto.password.hash();如需从密码派生密钥,请在应用层自行实现 PBKDF2 / scrypt / Argon2
132
- const derivedKey = crypto.symmetric.deriveKey('my-password', 'random-salt')
133
-
134
130
  // 校验密钥/IV 格式
135
131
  crypto.symmetric.isValidKey(key) // true
136
132
  crypto.symmetric.isValidIV(iv) // true
@@ -160,37 +156,51 @@ if (hashed.success) {
160
156
 
161
157
  #### Transport encryption (crypto.transport)
162
158
 
159
+ See the [Transport v2 wire protocol](TRANSPORT-PROTOCOL.md) for canonical encoding, verification order and replay persistence.
160
+
163
161
  The `crypto.transport` namespace provides transport encryption, allowing `@h-ai/serv`, `@h-ai/kit`, and `@h-ai/api-client` to share protocol constants and payload formats.
164
162
 
165
163
  For typical applications, use the higher-level integrations:
166
164
 
167
- - Server: `serv.createApp({ transport: { crypto } })` or `kit.createHandle({ crypto: { crypto, transport: true } })`.
165
+ - Server: `serv.createApp({ transport: { crypto, serverKeyRing, keyStore } })` or `kit.createHandle({ crypto: { crypto, transport: { serverKeyRing, keyStore } } })`.
168
166
  - Client: `apiClient.init({ transport: { crypto } })` or `kit.client.create({ transport: { crypto } })`.
169
167
 
170
- Call `crypto.transport.createServer()` / `createClient()` directly only for custom runtimes, tests, or manually integrated HTTP key exchange endpoints.
168
+ Call `crypto.transport.createServer({ serverKeyRing, keyStore })` / `createClient()` directly only for custom runtimes, tests, or manually integrated HTTP key exchange endpoints.
171
169
 
172
170
  ##### Workflow
173
171
 
174
172
  1. Call `await crypto.init()` to initialize `crypto.asymmetric` and `crypto.symmetric`.
175
- 2. On the server, create a `manager` with `crypto.transport.createServer()`. It holds the server key pair and stores client public keys. Inject a shared `keyStore` for multi-node deployments.
176
- 3. Expose a POST key exchange endpoint: accept `{ clientPublicKey }`, register it with `manager.registerClientKey()`, and return `{ serverPublicKey: manager.getServerPublicKey(), clientId }`.
173
+ 2. On the server, create a `manager` with `crypto.transport.createServer({ serverKeyRing, keyStore })`. It holds the server key pair and stores client public keys. Inject a shared `keyStore` for multi-node deployments.
174
+ 3. Expose a POST key exchange endpoint: accept `{ clientPublicKey }`, register it with `manager.registerClientKey()`, and return `{ serverKeyId: manager.getServerKeyId(), serverPublicKey: manager.getServerPublicKey(), clientId }`.
177
175
  4. On the client, create a session with `crypto.transport.createClient({ keyExchangeUrl })`. The first `client.init()` or `client.encryptedFetch()` automatically performs key exchange.
178
- 5. After key exchange, requests include `X-Client-Id`. A request body is wrapped as `{ encryptedKey, ciphertext, iv }` with `X-Encrypted: true`. Bodyless requests include only `X-Client-Id` and do not create an encrypted body.
179
- 6. The server looks up the client public key by `clientId`, decrypts the body with `manager.decryptRequest()`, then runs the business logic.
180
- 7. Before returning JSON, encrypt it with `manager.encryptResponse(clientId, data)` and set `X-Encrypted: true`. The client decrypts the response automatically.
176
+ 5. Every request sends an authenticated envelope `{ context, direction, status, encryptedKey, ciphertext, iv, tag, signature }`, including empty POST and GET/HEAD.
177
+ 6. Custom adapters first await `authenticateTransportRequest(manager, request)`. Only successful `data.plaintext` reaches business code; retain `data.context`.
178
+ 7. Encrypt responses with `await manager.encryptResponse(context, data, response.status)`; clients verify context, status and authenticity before returning plaintext.
181
179
  8. Clear a client session with `client.destroy()`. Release server resources with `manager.close()` or module-level `await crypto.close()`.
182
180
 
183
- > The default `keyStore` is an in-process FIFO store; exceeding `maxClients` evicts the earliest registered client. Multi-node deployments need sticky sessions or a shared `TransportKeyStore` implementation for client public keys.
181
+ > Servers must explicitly inject the same managed `serverKeyRing` and a shared `keyStore` (Redis or a shared database). Sharing client public keys alone cannot enable cross-node decryption. There is no implicit process key or memory-registry fallback; development/tests may explicitly use `createInMemoryKeyStore(capacity)`. Reload the same ring after restart; sticky sessions are unnecessary.
184
182
 
185
183
  For shared storage, reuse the provider factories exported from the `@h-ai/crypto` root entry:
186
184
 
187
185
  - `createInMemoryKeyStore(maxClients)`: explicitly create the default memory store for tests or custom capacity.
188
186
  - `createRedisTransportKeyStore({ cache, ttlSeconds? })`: reuse `@h-ai/cache`, typically with Redis for sharing across nodes.
189
- - `createReldbTransportKeyStore({ reldb, ttlSeconds? })`: reuse `@h-ai/reldb`, automatically creating `hai_crypto_transport_client_keys`.
187
+ - `createReldbTransportKeyStore({ reldb, ttlSeconds?, maxClients? })`: reuse initialized `@h-ai/reldb`, create key, nonce and capacity tables, and return a `ReldbTransportKeyStore` with `pruneExpired()`.
188
+
189
+ Registration validates the encoding and SM2 curve point before writing. Only 128 hex characters or `04` followed by 128 hex characters are accepted; compressed, infinity and off-curve points are rejected. The pure `isValidSM2PublicKey(value)` helper requires no initialization. serv/kit reject invalid keys with HTTP 400 before accessing the registry.
190
+
191
+ Omitting Redis/DB `ttlSeconds` keeps keys indefinitely. Explicit values must be integer seconds from 1 to 2147483647; a finite TTL is recommended. Redis uses native per-key expiration and does not expose the DB `maxClients` limit; configure memory budgets without evicting valid nonces. DB `maxClients` is a positive safe integer, defaults to 10000, and must match the persistent setting on every node. Registration locks a shared capacity row inside its transaction before counting and inserting; a full registry rejects registration without evicting valid keys or nonces. Deleting a client does not delete its valid nonces.
192
+
193
+ DB expiry uses database epoch milliseconds. Synchronize both database and node clocks within the protocol limits. Failed initial table creation can be retried. Each registration prunes one batch; `await registry.pruneExpired()` deletes at most 100 expired keys and 100 expired nonces per call. Schedule it in the application for low-traffic deployments: the registry creates no background timers and does not close the injected reldb. Migrate old timestamp key columns offline to BIGINT `created_at_ms` / `expires_at_ms`. Capacity is persisted in `hai_crypto_transport_capacity`; coordinate changes across all nodes and that row, and never clear valid nonces to recreate a registry.
194
+
195
+ ```ts
196
+ const registry = createReldbTransportKeyStore({ reldb, ttlSeconds: 3600, maxClients: 10000 })
197
+ // 由应用的定期维护调度器调用;每批有界,故障后由调度器重试。
198
+ const removed = await registry.pruneExpired()
199
+ ```
190
200
 
191
201
  ```ts
192
- // 服务端:通常由 serv.createApp({ transport: { crypto } }) 或 kit.createHandle({ crypto }) 内部调用
193
- const server = crypto.transport.createServer({ maxClients: 10000 })
202
+ // 服务端:通常由 serv.createApp({ transport: { crypto, serverKeyRing, keyStore } }) 或 kit.createHandle({ crypto }) 内部调用
203
+ const server = crypto.transport.createServer({ serverKeyRing, keyStore })
194
204
  if (!server.success)
195
205
  throw new Error(server.error.message)
196
206
 
@@ -213,6 +223,7 @@ await crypto.init()
213
223
  await cache.init({ type: 'redis', host: '127.0.0.1', port: 6379 })
214
224
 
215
225
  const server = crypto.transport.createServer({
226
+ serverKeyRing,
216
227
  keyStore: createRedisTransportKeyStore({ cache, ttlSeconds: 3600 }),
217
228
  })
218
229
  if (!server.success)
@@ -223,6 +234,42 @@ if (!server.success)
223
234
 
224
235
  Access protocol constants through `crypto.transport.protocol` (or `TRANSPORT_PROTOCOL`): `X-Client-Id`, `X-Encrypted`, and the default `/_hai/key-exchange`.
225
236
 
237
+ Both `serverKeyRing` and `keyStore` are required by `createServer({ serverKeyRing, keyStore })`. A ring is `{ activeKeyId, keys: { [id]: { publicKey, privateKey } } }`; IDs contain 1–64 letters, digits, underscores or hyphens. Creation verifies key pairing and captures a snapshot; invalid/missing configuration returns a sanitized HaiResult error. In these examples, `keyStore` is a connected Redis/shared-database registry; memory is an explicit development/test choice.
238
+
239
+ ```ts
240
+ import { parseTransportServerKeyRing } from '@h-ai/crypto'
241
+
242
+ const ring = parseTransportServerKeyRing(process.env.HAI_CRYPTO_TRANSPORT_SERVERKEYRING)
243
+ if (!ring.success)
244
+ throw new Error(ring.error.message)
245
+ const serverKeyRing = ring.data
246
+ const server = crypto.transport.createServer({ serverKeyRing, keyStore })
247
+ ```
248
+
249
+ Key exchange returns `{ serverKeyId, serverPublicKey, clientId }`. Encrypted requests carry `serverKeyId`; the server selects that private key and rejects unknown/revoked IDs without trying other keys. Responses use the client's public key. Rotate by distributing both old and new versions to every node, then switching `activeKeyId` and rebuilding managers during rollout. Retain the old version through the transition, then remove it from every node. Old clients must call `destroy()` and negotiate again; do not automatically replay non-idempotent operations. Distribute the same ring through a Secret Manager or server-only private environment. Never put it in ordinary YAML, PUBLIC_/VITE_ variables, logs or public responses. CLI project creation provisions one independent development ring in ignored `.env.local`; runtime does not generate keys. Deploy with target-managed secrets.
250
+
251
+ Only authenticated protocol v2 is accepted. Independent random SM4-CBC and HMAC-SM3 keys are wrapped with SM2; verify the sender signature and MAC before CBC decryption. Clients sign requests and the selected server key version signs responses. Authentication covers version, direction, serverKeyId, clientId, a random 128-bit requestId acting as the nonce, issuedAt, HTTP method, the SM3 pathHash of pathname plus the original search, and response status. Requests have a five-minute lifetime and at most 30 seconds of future clock skew. The shared registry atomically consumes (clientId, requestId), retaining it until at least issuedAt plus six minutes to cover node skew; storage failures reject requests. Synchronize node clocks and use a Redis noeviction/durable policy that preserves live nonces. Transport replay prevention does not replace business idempotency keys.
252
+
253
+ Empty POSTs still send an authenticated envelope for empty plaintext. GET/HEAD carry it in X-Hai-Transport; bodyless responses such as 204/304/HEAD use the same header. Other requests/responses carry a JSON envelope and X-Encrypted: true. The client must match the original request context and HTTP status; missing markers, old formats and authentication failures reject. Exclude plaintext endpoints explicitly with excludePaths. CORS must allow and expose X-Hai-Transport. TLS authenticates handshake keys; this protocol does not replace IAM authorization.
254
+
255
+ Set `transport.maxPlaintextBytes` for both request and response JSON plaintext, measured in UTF-8 bytes: default 1 MiB, integer range 1 KiB–64 MiB. Clients learn the server limit during key exchange. `resolveTransportBodyLimits()` derives encrypted/proxy bytes as twice the plaintext limit plus 32 KiB. Handshake, public-key, header and replay constraints remain independent. Oversized streams are cancelled and readers released: requests return 413; oversized responses return an encrypted 500 capacity error, recognized as `TransportBodyTooLargeError` / `hai:crypto:070`. [容量配置、文件换算与升级](TRANSPORT-CAPACITY.md)。
256
+
257
+ serv/kit return an encrypted HTTP 400 time error for an out-of-window message only after request binding, signature and MAC verification. The client throws `TransportTimeError` (`hai:crypto:071`) with advice to synchronize device/server clocks and refresh. `api-client` maps it to `VALIDATION_FAILED` and preserves `cause.transportCode`. Requests are not replayed automatically and replay windows remain unchanged; tampered or unauthenticated messages retain the generic authentication failure.
258
+
259
+ Custom HTTP adapters check `error instanceof TransportTimeError && error.context`, then encrypt the rejection with `createTransportErrorResponse(error, { manager, context: error.context })`. Never classify an unverified message by its timestamp.
260
+
261
+ Custom HTTP adapters can import the pure `readTransportBody(stream, { maxBytes, signal? })` helper from the root entry without `crypto.init()`. Pass the original stream, rebuild Request/Response after reading, and clear stale Content-Length/Content-Encoding. The helper throws RangeError for byte limits, TypeError for invalid UTF-8, and preserves the original read/abort failure.
262
+
263
+ ```ts
264
+ import { readTransportBody, TRANSPORT_BODY_LIMITS } from '@h-ai/crypto'
265
+
266
+ const text = await readTransportBody(request.body, {
267
+ maxBytes: TRANSPORT_BODY_LIMITS.keyExchangeBytes,
268
+ signal: request.signal,
269
+ })
270
+ const payload: unknown = JSON.parse(text)
271
+ ```
272
+
226
273
  #### Close the module
227
274
 
228
275
  ```ts
@@ -245,6 +292,10 @@ if (!result.success) {
245
292
 
246
293
  The admin console's online encryption demo calls the Crypto SM3/SM4 APIs directly. SM4 uses a random temporary key and IV and verifies decryption before showing success; keys are not saved. Changing input clears previous results. Random digests or Base64 must not be presented as cryptographic results.
247
294
 
295
+ ## Production shared state
296
+
297
+ Production transport registries must declare stateScope=shared and actually use Redis or PostgreSQL/MySQL. Memory, Redis factories over memory, and RelDB factories over SQLite are rejected. All nodes load the same managed versioned key ring. Initialization checks apply when NODE_ENV or HAI_CORE_ENV is production; only explicit HAI_E2E=1/HAI_E2E_MOCK=1 tests are exempt. [State, concurrency and recovery (Chinese)](../../.agents/instructions/distributed-state.md)。
298
+
248
299
  ## Development and testing
249
300
 
250
301
  ```bash
package/README.ja.md CHANGED
@@ -22,7 +22,7 @@
22
22
  ### セキュリティに関する注意
23
23
 
24
24
  - **SM4 の既定の暗号化結果は構造化された暗号文**:`crypto.symmetric.encrypt(data, key)` は既定で CBC + ランダム IV を使用し、`{ mode, ciphertext, iv, encoding }` を返します。復号時はこのオブジェクトを `crypto.symmetric.decrypt(payload, key)` に渡します。
25
- - **SM4 の `deriveKey(password, salt)` は KDF ではありません**:SM3 を 1 回実行するだけで、パスワードの総当たり攻撃への耐性はありません。**パスワード保存には使用禁止**です。パスワードハッシュには `crypto.password.hash(password)`、パスワードからの鍵導出には PBKDF2 / scrypt / Argon2 を使用してください。
25
+ - **SM4 にはランダムな鍵を使用してください**:`crypto.symmetric.generateKey()` で鍵を生成します。このモジュールはパスワードから暗号鍵を導出する KDF を提供しません。パスワードの保存にはサーバー専用の `crypto.password.hash(password)` を使用し、パスワードやそのハッシュを直接 SM4 の鍵にしないでください。
26
26
  - **IV は毎回一意にしてください**:同じ鍵で IV を再利用してはいけません。既定の CBC または `encryptWithIV()` による毎回の自動生成を推奨します。IV は暗号文と一緒に送信・保存します(公開可能で、秘密にする必要はありません)。
27
27
 
28
28
  ### クイックスタート
@@ -127,10 +127,6 @@ if (ecbEnc.success) {
127
127
  // 输出 base64 格式(ciphertext 字段为 base64,encoding 字段会标明)
128
128
  const b64Enc = crypto.symmetric.encrypt('data', key, { outputFormat: 'base64' })
129
129
 
130
- // ⚠️ 已弃用:从密码派生密钥(仅为单次 SM3 哈希,不是 KDF,禁止用于密码存储场景)
131
- // 密码散列请用 crypto.password.hash();如需从密码派生密钥,请在应用层自行实现 PBKDF2 / scrypt / Argon2
132
- const derivedKey = crypto.symmetric.deriveKey('my-password', 'random-salt')
133
-
134
130
  // 校验密钥/IV 格式
135
131
  crypto.symmetric.isValidKey(key) // true
136
132
  crypto.symmetric.isValidIV(iv) // true
@@ -160,37 +156,51 @@ if (hashed.success) {
160
156
 
161
157
  #### 通信暗号化(crypto.transport)
162
158
 
159
+ 正規化、検証順序、nonce 保存は [Transport v2 プロトコル](TRANSPORT-PROTOCOL.md) を参照してください。
160
+
163
161
  通信暗号化は `crypto.transport` 名前空間で提供し、`@h-ai/serv`、`@h-ai/kit`、`@h-ai/api-client` で同じプロトコル定数とペイロード形式を再利用します。
164
162
 
165
163
  通常のアプリケーションでは、上位モジュールの統合機能を優先してください:
166
164
 
167
- - サーバー:`serv.createApp({ transport: { crypto } })` または `kit.createHandle({ crypto: { crypto, transport: true } })`。
165
+ - サーバー:`serv.createApp({ transport: { crypto, serverKeyRing, keyStore } })` または `kit.createHandle({ crypto: { crypto, transport: { serverKeyRing, keyStore } } })`。
168
166
  - クライアント:`apiClient.init({ transport: { crypto } })` または `kit.client.create({ transport: { crypto } })`。
169
167
 
170
- `crypto.transport.createServer()` / `createClient()` の直接呼び出しは、独自ランタイム、テスト、HTTP 鍵交換 endpoint を自分で接続する場合に限って推奨します。
168
+ `crypto.transport.createServer({ serverKeyRing, keyStore })` / `createClient()` の直接呼び出しは、独自ランタイム、テスト、HTTP 鍵交換 endpoint を自分で接続する場合に限って推奨します。
171
169
 
172
170
  ##### 使用手順
173
171
 
174
172
  1. `await crypto.init()` を呼び、`crypto.asymmetric` と `crypto.symmetric` を初期化します。
175
- 2. サーバーで `crypto.transport.createServer()` を呼び、`manager` を作成します。サーバー鍵ペアとクライアント公開鍵を管理します。複数ノードでは共有 `keyStore` を注入できます。
176
- 3. POST の鍵交換 endpoint を用意します。`{ clientPublicKey }` を受け取り、`manager.registerClientKey()` で登録し、`{ serverPublicKey: manager.getServerPublicKey(), clientId }` を返します。
173
+ 2. サーバーで `crypto.transport.createServer({ serverKeyRing, keyStore })` を呼び、`manager` を作成します。サーバー鍵ペアとクライアント公開鍵を管理します。複数ノードでは共有 `keyStore` を注入できます。
174
+ 3. POST の鍵交換 endpoint を用意します。`{ clientPublicKey }` を受け取り、`manager.registerClientKey()` で登録し、`{ serverKeyId: manager.getServerKeyId(), serverPublicKey: manager.getServerPublicKey(), clientId }` を返します。
177
175
  4. クライアントで `crypto.transport.createClient({ keyExchangeUrl })` によりセッションを作成します。最初の `client.init()` または `client.encryptedFetch()` で鍵交換が自動実行されます。
178
- 5. 鍵交換後、リクエストに `X-Client-Id` を付与します。body がある場合は `{ encryptedKey, ciphertext, iv }` に変換し、`X-Encrypted: true` を付けます。body のないリクエストでは `X-Client-Id` のみを付与し、暗号文 body は作成しません。
179
- 6. サーバーは `clientId` でクライアント公開鍵を取得し、`manager.decryptRequest()` でリクエスト本文を復号してから業務処理を実行します。
180
- 7. JSON 応答を返す前に `manager.encryptResponse(clientId, data)` で暗号化し、`X-Encrypted: true` を付けます。クライアントは応答を自動的に復号します。
176
+ 5. 空の POST と GET/HEAD を含め、すべての要求が認証封装 `{ context, direction, status, encryptedKey, ciphertext, iv, tag, signature }` を送ります。
177
+ 6. 独自アダプターは先に `await authenticateTransportRequest(manager, request)` を実行し、成功時のみ `data.plaintext` を業務へ渡し、`data.context` を保持します。
178
+ 7. 応答は `await manager.encryptResponse(context, data, response.status)` で元要求に関連付け、クライアントがコンテキスト・status・完全性を検証してから平文を返します。
181
179
  8. `client.destroy()` でクライアントセッションを消去します。サーバー側のリソースは `manager.close()` またはモジュール単位の `await crypto.close()` で解放します。
182
180
 
183
- > 既定の `keyStore` はプロセス内の FIFO メモリーストアです。`maxClients` を超えると最初に登録されたクライアントを削除します。複数ノードでは sticky session、またはクライアント公開鍵を共有する `TransportKeyStore` 実装が必要です。
181
+ > サーバーには同じ管理済み `serverKeyRing` と共有 `keyStore`(Redis または共有 DB)を明示的に渡します。クライアント公開鍵の共有だけではノード間で復号できません。暗黙のプロセス鍵・メモリー registry はなく、開発・テストでは `createInMemoryKeyStore(capacity)` を明示します。再起動後は同じ鍵環を読み込み、sticky session は不要です。
184
182
 
185
183
  共有ストレージには、`@h-ai/crypto` のルート入口からエクスポートされる Provider ファクトリーを再利用できます:
186
184
 
187
185
  - `createInMemoryKeyStore(maxClients)`:テストや容量指定用に、既定のメモリーストアを明示的に作成。
188
186
  - `createRedisTransportKeyStore({ cache, ttlSeconds? })`:`@h-ai/cache` を再利用。通常は Redis Provider と組み合わせ、複数ノードで共有。
189
- - `createReldbTransportKeyStore({ reldb, ttlSeconds? })`:`@h-ai/reldb` を再利用し、`hai_crypto_transport_client_keys` テーブルを自動作成。
187
+ - `createReldbTransportKeyStore({ reldb, ttlSeconds?, maxClients? })`:初期化済みの `@h-ai/reldb` を再利用し、公開鍵・nonce・容量テーブルを自動作成。`pruneExpired()` を備える `ReldbTransportKeyStore` を返します。
188
+
189
+ 登録前に公開鍵の形式と SM2 曲線上の点を検証します。128 文字の hex、または `04` + 128 文字の hex のみ受け付け、圧縮点・無限遠点・曲線外の点は拒否します。純関数 `isValidSM2PublicKey(value)` は初期化不要で、serv/kit は registry に書く前に不正な鍵を HTTP 400 で拒否します。
190
+
191
+ Redis/DB の `ttlSeconds` を省略すると無期限です。指定時は 1–2147483647 の整数秒とし、有限 TTL を推奨します。Redis はネイティブのキー TTL を使い、DB の `maxClients` 上限は提供しません。メモリー予算を設定し、有効な nonce を追い出さないでください。DB の `maxClients` は正の安全な整数、既定 10000 で、全ノードと永続容量レコードが一致する必要があります。登録はトランザクション内で共有容量行をロックし、件数確認後に挿入します。満杯では登録を拒否し、有効な公開鍵や nonce を削除しません。クライアント削除時も有効な nonce は保持します。
192
+
193
+ DB の期限は DB 時計の epoch ミリ秒で生成・判定します。DB と各ノードの時計をプロトコルの許容範囲内に同期してください。初回テーブル作成失敗後は再試行できます。登録時に 1 バッチ清掃し、`await registry.pruneExpired()` は期限切れ公開鍵と nonce を各 100 件まで削除します。低トラフィック時もアプリで定期実行してください。暗黙のタイマーは作らず、注入された reldb も閉じません。旧 timestamp 列は停止中に BIGINT の `created_at_ms` / `expires_at_ms` に移行します。容量は `hai_crypto_transport_capacity` に永続化されるため、変更時は全ノードと保存行を調整し、registry 再作成のために有効な nonce を消さないでください。
194
+
195
+ ```ts
196
+ const registry = createReldbTransportKeyStore({ reldb, ttlSeconds: 3600, maxClients: 10000 })
197
+ // アプリの定期メンテナンスから呼び出し、失敗時はスケジューラーで再試行する。
198
+ const removed = await registry.pruneExpired()
199
+ ```
190
200
 
191
201
  ```ts
192
- // 服务端:通常由 serv.createApp({ transport: { crypto } }) 或 kit.createHandle({ crypto }) 内部调用
193
- const server = crypto.transport.createServer({ maxClients: 10000 })
202
+ // 服务端:通常由 serv.createApp({ transport: { crypto, serverKeyRing, keyStore } }) 或 kit.createHandle({ crypto }) 内部调用
203
+ const server = crypto.transport.createServer({ serverKeyRing, keyStore })
194
204
  if (!server.success)
195
205
  throw new Error(server.error.message)
196
206
 
@@ -213,6 +223,7 @@ await crypto.init()
213
223
  await cache.init({ type: 'redis', host: '127.0.0.1', port: 6379 })
214
224
 
215
225
  const server = crypto.transport.createServer({
226
+ serverKeyRing,
216
227
  keyStore: createRedisTransportKeyStore({ cache, ttlSeconds: 3600 }),
217
228
  })
218
229
  if (!server.success)
@@ -223,6 +234,42 @@ if (!server.success)
223
234
 
224
235
  プロトコル定数は `crypto.transport.protocol`(または `TRANSPORT_PROTOCOL`)で取得できます:`X-Client-Id`、`X-Encrypted`、既定の `/_hai/key-exchange`。
225
236
 
237
+ `createServer({ serverKeyRing, keyStore })` は両方とも必須です。鍵環は `{ activeKeyId, keys: { [id]: { publicKey, privateKey } } }` で、ID は英数字・下線・ハイフンの 1–64 文字です。作成時に鍵の組を検証しスナップショットを保持します。不正・未設定の値は機密情報を含まない HaiResult エラーになります。例の `keyStore` は接続済み Redis/共有 DB の registry です。メモリーは開発・テストで明示的に使用します。
238
+
239
+ ```ts
240
+ import { parseTransportServerKeyRing } from '@h-ai/crypto'
241
+
242
+ const ring = parseTransportServerKeyRing(process.env.HAI_CRYPTO_TRANSPORT_SERVERKEYRING)
243
+ if (!ring.success)
244
+ throw new Error(ring.error.message)
245
+ const serverKeyRing = ring.data
246
+ const server = crypto.transport.createServer({ serverKeyRing, keyStore })
247
+ ```
248
+
249
+ 鍵交換は `{ serverKeyId, serverPublicKey, clientId }` を返し、暗号化リクエストは `serverKeyId` を送ります。サーバーは指定した秘密鍵だけを選び、不明・失効 ID は他の鍵を試さず拒否します。応答はクライアント公開鍵で暗号化します。更新時は全ノードに新旧の鍵を先に配布し、続いて `activeKeyId` を切り替えて manager を順次再構築します。移行期間後に古い ID を全ノードから削除します。旧クライアントは `destroy()` 後に再交渉し、非冪等処理を自動再送しません。同じ鍵環を Secret Manager またはサーバー専用の非公開環境から配布し、通常の YAML、PUBLIC_/VITE_、ログ、公開応答には含めないでください。CLI はプロジェクト作成時だけ独立した開発用鍵環を Git 対象外の `.env.local` に作成します。実行時には生成せず、本番には対象環境で管理する鍵を配布します。
250
+
251
+ 認証済み v2 のみを受け付けます。独立したランダム SM4-CBC 鍵と HMAC-SM3 鍵を SM2 で包み、送信者署名、MAC の順に検証してから CBC を復号します。要求はクライアント秘密鍵、応答は選択されたサーバー鍵バージョンで署名します。version、direction、serverKeyId、clientId、nonce を兼ねるランダム 128 ビット requestId、issuedAt、HTTP method、pathname と元の search の SM3 pathHash、応答 status を認証します。要求の有効期限は 5 分、未来方向の時計差は最大 30 秒です。共有 registry が (clientId, requestId) を原子的に消費し、ノード間の時計差を含め issuedAt + 6 分まで保持します。保存障害では拒否します。時計同期と Redis の noeviction/永続化により有効な nonce を保持してください。業務の再試行には別の冪等キーが必要です。
252
+
253
+ 空の POST も空平文の認証封装を送ります。GET/HEAD は X-Hai-Transport ヘッダー、204/304/HEAD など本文なし応答も同じヘッダーを使います。他の要求・応答は JSON 本文と X-Encrypted: true を使います。クライアントは元の要求コンテキストと HTTP status を照合し、マーカー欠落、旧形式、認証失敗では reject します。平文 endpoint は excludePaths で明示的に除外し、CORS では X-Hai-Transport を許可・公開してください。鍵交換の公開鍵認証は TLS に依存し、IAM の業務認可は別に必要です。
254
+
255
+ `transport.maxPlaintextBytes` でリクエストとレスポンスの JSON 平文容量を共通設定します。単位は UTF-8 バイト、既定値は 1 MiB、有効範囲は 1 KiB~64 MiB の整数です。クライアントは鍵交換で上限を取得し、`resolveTransportBodyLimits()` が暗号文とプロキシ容量(平文の 2 倍 + 32 KiB)を算出します。鍵交換、公開鍵、認証ヘッダー、防リプレイ制約は独立です。超過時は元ストリームを停止して reader を解放します。要求は 413、応答は暗号化された 500 容量エラーとなり、`TransportBodyTooLargeError` / `hai:crypto:070` で識別できます。 [容量配置、文件换算与升级](TRANSPORT-CAPACITY.md)。
256
+
257
+ serv/kit は要求の結合、署名、MAC を検証した後、時間窓を超えた報文に暗号化した HTTP 400 時刻エラーを返します。クライアントは `TransportTimeError`(`hai:crypto:071`)で端末とサーバーの時刻同期・再読み込みを案内します。`api-client` は `VALIDATION_FAILED` に変換し `cause.transportCode` に原因を保持します。自動再送やリプレイ時間窓の緩和は行わず、改ざん・未認証の要求は従来の認証エラーです。
258
+
259
+ 独自 HTTP アダプターでは `error instanceof TransportTimeError && error.context` を確認し、`createTransportErrorResponse(error, { manager, context: error.context })` で拒否応答を暗号化します。未認証の報文のタイムスタンプだけで時刻異常を診断しないでください。
260
+
261
+ 独自 HTTP アダプターではルート入口の純粋関数 `readTransportBody(stream, { maxBytes, signal? })` を `crypto.init()` なしで利用できます。元のストリームを渡し、読み取り後は Request/Response を再構築して古い Content-Length/Content-Encoding を削除してください。上限超過は RangeError、不正 UTF-8 は TypeError、読み取り例外・signal.reason は元の理由で拒否します。ストリームのキャンセル処理が失敗しても元の例外を維持します。
262
+
263
+ ```ts
264
+ import { readTransportBody, TRANSPORT_BODY_LIMITS } from '@h-ai/crypto'
265
+
266
+ const text = await readTransportBody(request.body, {
267
+ maxBytes: TRANSPORT_BODY_LIMITS.keyExchangeBytes,
268
+ signal: request.signal,
269
+ })
270
+ const payload: unknown = JSON.parse(text)
271
+ ```
272
+
226
273
  #### モジュールの終了
227
274
 
228
275
  ```ts
@@ -245,6 +292,10 @@ if (!result.success) {
245
292
 
246
293
  管理画面のオンライン暗号化デモは Crypto の SM3/SM4 API を直接呼びます。SM4 はランダムな一時鍵と IV を使い、成功表示前に復号を検証します。鍵は保存しません。入力が変わると古い結果を消去し、ランダムなダイジェストや Base64 を暗号処理の結果として表示してはいけません。
247
294
 
295
+ ## 本番の共有状態
296
+
297
+ 本番の伝送 registry は stateScope=shared と実際の Redis または PostgreSQL/MySQL が必須です。memory、および memory/SQLite を包む工場も拒否します。各ノードは同じ管理されたバージョン付き鍵セットを読み込みます。 NODE_ENV または HAI_CORE_ENV が production の場合、初期化成功前に検証します。明示的な HAI_E2E=1/HAI_E2E_MOCK=1 テストのみ除外します。 [状態・並行制御・復旧範囲(中国語)](../../.agents/instructions/distributed-state.md)。
298
+
248
299
  ## 開発とテスト
249
300
 
250
301
  ```bash
package/README.md CHANGED
@@ -22,7 +22,7 @@
22
22
  ### 安全声明
23
23
 
24
24
  - **SM4 默认加密返回结构化密文**:`crypto.symmetric.encrypt(data, key)` 默认使用 CBC + 随机 IV,返回 `{ mode, ciphertext, iv, encoding }`;解密时把该对象传给 `crypto.symmetric.decrypt(payload, key)`。
25
- - **SM4 `deriveKey(password, salt)` 不是 KDF**:仅为单次 SM3 哈希,不具备密码爆破抗性,**禁止用于密码存储**。如需密码哈希,使用 `crypto.password.hash(password)`;如需从密码派生密钥,请采用 PBKDF2 / scrypt / Argon2。
25
+ - **SM4 使用随机密钥**:通过 `crypto.symmetric.generateKey()` 生成密钥;本模块不提供从密码派生加密密钥的 KDF。密码存储使用服务端 `crypto.password.hash(password)`,不要将密码或密码哈希直接用作 SM4 密钥。
26
26
  - **IV 必须唯一**:相同密钥下禁止复用 IV;推荐使用默认 CBC 或 `encryptWithIV()` 每次自动生成。密文与 IV 需一同传输与存储(IV 可公开,不必保密)。
27
27
 
28
28
  ### 快速开始
@@ -127,10 +127,6 @@ if (ecbEnc.success) {
127
127
  // 输出 base64 格式(ciphertext 字段为 base64,encoding 字段会标明)
128
128
  const b64Enc = crypto.symmetric.encrypt('data', key, { outputFormat: 'base64' })
129
129
 
130
- // ⚠️ 已弃用:从密码派生密钥(仅为单次 SM3 哈希,不是 KDF,禁止用于密码存储场景)
131
- // 密码散列请用 crypto.password.hash();如需从密码派生密钥,请在应用层自行实现 PBKDF2 / scrypt / Argon2
132
- const derivedKey = crypto.symmetric.deriveKey('my-password', 'random-salt')
133
-
134
130
  // 校验密钥/IV 格式
135
131
  crypto.symmetric.isValidKey(key) // true
136
132
  crypto.symmetric.isValidIV(iv) // true
@@ -160,37 +156,51 @@ if (hashed.success) {
160
156
 
161
157
  #### 传输加密(crypto.transport)
162
158
 
159
+ 规范编码、验证顺序与 nonce 持久化要求见 [Transport v2 协议](TRANSPORT-PROTOCOL.md)。
160
+
163
161
  传输加密统一通过 `crypto.transport` 命名空间提供,供 `@h-ai/serv`、`@h-ai/kit` 与 `@h-ai/api-client` 复用同一套协议常量和载荷格式。
164
162
 
165
163
  常规应用优先让上层封装代接:
166
164
 
167
- - 服务端:`serv.createApp({ transport: { crypto } })` 或 `kit.createHandle({ crypto: { crypto, transport: true } })`
165
+ - 服务端:`serv.createApp({ transport: { crypto, serverKeyRing, keyStore } })` 或 `kit.createHandle({ crypto: { crypto, transport: { serverKeyRing, keyStore } } })`
168
166
  - 客户端:`apiClient.init({ transport: { crypto } })` 或 `kit.client.create({ transport: { crypto } })`
169
167
 
170
- 只有在自定义运行时、测试或你确实要自己接 HTTP 协商端点时,才建议直接调用 `crypto.transport.createServer()` / `createClient()`。
168
+ 只有在自定义运行时、测试或你确实要自己接 HTTP 协商端点时,才建议直接调用 `crypto.transport.createServer({ serverKeyRing, keyStore })` / `createClient()`。
171
169
 
172
170
  ##### 使用流程
173
171
 
174
172
  1. 先 `await crypto.init()`,确保 `crypto.asymmetric` 与 `crypto.symmetric` 已初始化。
175
- 2. 服务端调用 `crypto.transport.createServer()` 创建 `manager`;它持有服务端密钥对,并负责保存客户端公钥。多节点部署时可注入共享 `keyStore`。
176
- 3. 服务端提供一个 POST 密钥协商端点:接收 `{ clientPublicKey }`,调用 `manager.registerClientKey()` 注册客户端,再返回 `{ serverPublicKey: manager.getServerPublicKey(), clientId }`。
173
+ 2. 服务端调用 `crypto.transport.createServer({ serverKeyRing, keyStore })` 创建 `manager`;它持有服务端密钥对,并负责保存客户端公钥。多节点部署时可注入共享 `keyStore`。
174
+ 3. 服务端提供一个 POST 密钥协商端点:接收 `{ clientPublicKey }`,调用 `manager.registerClientKey()` 注册客户端,再返回 `{ serverKeyId: manager.getServerKeyId(), serverPublicKey: manager.getServerPublicKey(), clientId }`。
177
175
  4. 客户端调用 `crypto.transport.createClient({ keyExchangeUrl })` 创建会话;首次 `client.init()` 或 `client.encryptedFetch()` 会自动完成这次协商。
178
- 5. 协商完成后,客户端请求会附带 `X-Client-Id`;若请求有 body,则 body 会被包装成 `{ encryptedKey, ciphertext, iv }`,并带上 `X-Encrypted: true`。无 body 的请求只附带 `X-Client-Id`,不会额外生成密文 body。
179
- 6. 服务端根据 `clientId` 找到客户端公钥,先 `manager.decryptRequest()` 解密请求体,再执行业务逻辑。
180
- 7. 服务端返回 JSON 响应前,用 `manager.encryptResponse(clientId, data)` 重新加密,并设置 `X-Encrypted: true`;客户端收到后会自动解密。
176
+ 5. 每次请求都发送认证封装 `{ context, direction, status, encryptedKey, ciphertext, iv, tag, signature }`;空 POST 和 GET/HEAD 也必须认证。
177
+ 6. 自定义适配器先 `await authenticateTransportRequest(manager, request)`,成功后才将 `data.plaintext` 交给业务并保存 `data.context`。
178
+ 7. 响应用 `await manager.encryptResponse(context, data, response.status)` 关联原请求;客户端校验上下文、状态和完整性后才返回明文。
181
179
  8. 客户端调用 `client.destroy()` 可清空当前会话;服务端调用 `manager.close()`,或在模块级调用 `await crypto.close()`,用于释放资源。
182
180
 
183
- > 默认 `keyStore` 是进程内 FIFO 内存实现,超过 `maxClients` 会淘汰最早注册的客户端。多节点部署时,需要会话粘性(sticky session),或改用共享的 `TransportKeyStore` 实现来保存客户端公钥。
181
+ > 服务端必须显式注入相同的受管 `serverKeyRing` 和共享 `keyStore`(Redis 或共享数据库)。仅共享客户端公钥不足以跨节点解密。没有隐式进程私钥或内存 registry 回退;开发/测试显式用 `createInMemoryKeyStore(capacity)`。服务端重启后重新加载同一密钥环即可,无需 sticky session。
184
182
 
185
183
  共享存储可直接复用 `@h-ai/crypto` 根入口导出的 provider 工厂:
186
184
 
187
185
  - `createInMemoryKeyStore(maxClients)`:显式创建默认内存实现,常用于测试或自定义容量
188
186
  - `createRedisTransportKeyStore({ cache, ttlSeconds? })`:复用 `@h-ai/cache`,通常配合 Redis provider 获取跨节点共享
189
- - `createReldbTransportKeyStore({ reldb, ttlSeconds? })`:复用 `@h-ai/reldb`,自动建表 `hai_crypto_transport_client_keys`
187
+ - `createReldbTransportKeyStore({ reldb, ttlSeconds?, maxClients? })`:复用已初始化的 `@h-ai/reldb`,自动创建公钥、nonce 与容量表;返回带 `pruneExpired()` 的 `ReldbTransportKeyStore`
188
+
189
+ 公钥注册先验证编码和 SM2 曲线点;仅接受 128 字符 hex 或 `04` + 128 字符 hex,不接受压缩点、无穷远点和曲线外点。纯函数 `isValidSM2PublicKey(value)` 无需初始化,可用于自定义握手入口;serv/kit 在写 registry 前返回 400。
190
+
191
+ Redis/DB 的 `ttlSeconds` 省略表示不过期;显式值须为 1–2147483647 的整数秒,建议设置有限 TTL。Redis 使用原生逐键 TTL,不提供 DB 的 `maxClients` 容量限制,部署需设置内存预算且禁止驱逐有效 nonce。DB `maxClients` 为正安全整数,默认 10000,所有节点必须与持久容量记录一致;事务锁定共享容量行后计数和插入,容量满时拒绝注册,不驱逐有效公钥或 nonce。删除客户端不删除尚有效的 nonce。
192
+
193
+ DB 使用数据库时钟生成、判断 epoch 毫秒过期时间;数据库与节点均须满足协议的时钟同步要求。首次建表失败可重试。每次注册自动清理一批;`await registry.pruneExpired()` 每次最多删除 100 条过期公钥和 100 条过期 nonce,可由应用定期调度。没有隐式后台计时器,低流量部署仍需主动调度维护;registry 不关闭应用注入的 reldb。旧公钥表的 timestamp 字段需停机迁移为 `created_at_ms` / `expires_at_ms` BIGINT,容量在 `hai_crypto_transport_capacity` 保存;容量调整须协调所有节点及持久记录,不能清空有效 nonce 来重建 registry。
194
+
195
+ ```ts
196
+ const registry = createReldbTransportKeyStore({ reldb, ttlSeconds: 3600, maxClients: 10000 })
197
+ // 接入应用自己的定期维护调度;每批有界,失败后由调度器重试。
198
+ const removed = await registry.pruneExpired()
199
+ ```
190
200
 
191
201
  ```ts
192
- // 服务端:通常由 serv.createApp({ transport: { crypto } }) 或 kit.createHandle({ crypto }) 内部调用
193
- const server = crypto.transport.createServer({ maxClients: 10000 })
202
+ // 服务端:通常由 serv.createApp({ transport: { crypto, serverKeyRing, keyStore } }) 或 kit.createHandle({ crypto }) 内部调用
203
+ const server = crypto.transport.createServer({ serverKeyRing, keyStore })
194
204
  if (!server.success)
195
205
  throw new Error(server.error.message)
196
206
 
@@ -213,6 +223,7 @@ await crypto.init()
213
223
  await cache.init({ type: 'redis', host: '127.0.0.1', port: 6379 })
214
224
 
215
225
  const server = crypto.transport.createServer({
226
+ serverKeyRing,
216
227
  keyStore: createRedisTransportKeyStore({ cache, ttlSeconds: 3600 }),
217
228
  })
218
229
  if (!server.success)
@@ -223,6 +234,42 @@ if (!server.success)
223
234
 
224
235
  协议常量通过 `crypto.transport.protocol`(或 `TRANSPORT_PROTOCOL`)访问:`X-Client-Id`、`X-Encrypted`、默认 `/_hai/key-exchange`。
225
236
 
237
+ 服务端 `createServer({ serverKeyRing, keyStore })` 两项均必填。密钥环为 `{ activeKeyId, keys: { [id]: { publicKey, privateKey } } }`;ID 为 1–64 个字母、数字、下划线或连字符。创建时验证公私钥配对并捕获快照,缺失/无效配置返回脱敏的 HaiResult 错误。以下示例的 `keyStore` 为已连接的 Redis/共享数据库 registry;内存仅显式用于开发或测试。
238
+
239
+ ```ts
240
+ import { parseTransportServerKeyRing } from '@h-ai/crypto'
241
+
242
+ const ring = parseTransportServerKeyRing(process.env.HAI_CRYPTO_TRANSPORT_SERVERKEYRING)
243
+ if (!ring.success)
244
+ throw new Error(ring.error.message)
245
+ const serverKeyRing = ring.data
246
+ const server = crypto.transport.createServer({ serverKeyRing, keyStore })
247
+ ```
248
+
249
+ 握手返回 `{ serverKeyId, serverPublicKey, clientId }`;请求密文的 `context` 携带 `serverKeyId`,服务端按 ID 选择私钥,未知或撤销 ID 直接拒绝,不尝试其它密钥。响应仍用客户端公钥加密。轮换先向所有节点分发包含新旧版本的 ring,再切换 `activeKeyId` 并滚动重建 manager;过渡期保留旧版本,结束后删除旧 ID 并更新所有节点。旧客户端此后须 `destroy()` 后重新协商;不要自动重试非幂等业务。部署通过 Secret Manager 或服务端私有环境分发同一 ring,禁止写入普通 YAML、PUBLIC_/VITE_、日志或公开响应。CLI 只在创建服务端项目时向被忽略的 `.env.local` 生成一次独立开发密钥环,运行时不生成,部署应使用目标环境自己的受管密钥。
250
+
251
+ 认证协议仅支持 v2:独立随机 SM4-CBC 密钥和 HMAC-SM3 密钥由 SM2 封装,先验签、再验证 MAC,最后解密 CBC。请求由客户端私钥签名,响应由所选服务端版本签名。认证字段包括 version、direction、serverKeyId、clientId、128 位随机 requestId(兼 nonce)、issuedAt、HTTP method、pathname + 原始 search 的 SM3 pathHash,以及响应 status。请求有效期 5 分钟,未来时钟偏差最多 30 秒;共享 registry 原子消费 (clientId, requestId),nonce 至少保留到 issuedAt + 6 分钟以覆盖节点偏差,存储故障拒绝请求。Redis 必须保留有效 nonce,使用 noeviction/可靠持久化策略;各节点时钟须同步。此能力防止同一传输消息重放,业务重试仍需自己的幂等键。
252
+
253
+ 空 POST 也发送空明文的认证封装;GET/HEAD 将封装放在 X-Hai-Transport 头,204/304/HEAD 等无正文响应也使用同名头承载认证封装。其它请求/响应在 JSON 正文承载封装并带 X-Encrypted: true。客户端必须关联原请求上下文和 HTTP status,缺标记、旧格式或认证失败一律 reject;明文接口只能经 excludePaths 显式排除。CORS 须允许并暴露 X-Hai-Transport。握手公钥的真实性依赖 TLS,不替代 IAM 业务授权。
254
+
255
+ 业务容量通过 `transport.maxPlaintextBytes` 统一配置:默认 1 MiB,整数范围 1 KiB~64 MiB,请求和响应共用 UTF-8 明文字节预算。客户端从握手自动获取上限,密文与部署代理预算由 `resolveTransportBodyLimits()` 推导(两倍明文 + 32 KiB)。握手、公钥、认证头和防重放约束独立;超限取消原流并释放 reader,请求 413、响应为加密的 500 容量错误,客户端识别 `TransportBodyTooLargeError` / `hai:crypto:070`。 [容量配置、文件换算与升级](TRANSPORT-CAPACITY.md)。
256
+
257
+ serv/kit 对通过请求绑定、签名和 MAC 校验但超出时间窗口的报文返回加密的 HTTP 400 时间错误;客户端抛 `TransportTimeError`(`hai:crypto:071`),提示同步设备和服务器时间后刷新重试。`api-client` 映射为 `VALIDATION_FAILED`,在 `cause.transportCode` 保留该原因。不会自动重放请求或放宽防重放窗口;篡改或未认证报文仍返回通用认证失败。
258
+
259
+ 自定义 HTTP 适配器检查失败原因 `error instanceof TransportTimeError && error.context` 后,使用 `createTransportErrorResponse(error, { manager, context: error.context })` 加密拒绝响应;不能根据未认证报文的时间戳给出可信时间诊断。
260
+
261
+ 自定义 HTTP 适配器可从根入口使用纯工具 `readTransportBody(stream, { maxBytes, signal? })`,无需 `crypto.init()`。必须传入原始流;读取成功后重建 Request/Response,并清理旧 Content-Length/Content-Encoding。此工具对超限抛 RangeError,对无效 UTF-8 抛 TypeError,保留读取异常或 signal.reason;流取消清理失败不覆盖原异常。
262
+
263
+ ```ts
264
+ import { readTransportBody, TRANSPORT_BODY_LIMITS } from '@h-ai/crypto'
265
+
266
+ const text = await readTransportBody(request.body, {
267
+ maxBytes: TRANSPORT_BODY_LIMITS.keyExchangeBytes,
268
+ signal: request.signal,
269
+ })
270
+ const payload: unknown = JSON.parse(text)
271
+ ```
272
+
226
273
  #### 关闭模块
227
274
 
228
275
  ```ts
@@ -245,6 +292,10 @@ if (!result.success) {
245
292
 
246
293
  管理端在线加密演示直接调用 Crypto SM3/SM4 API,SM4 使用随机临时密钥和 IV,并在显示成功前解密校验;密钥不保存。输入改变即清空旧结果,禁止用随机摘要或 Base64 伪装算法结果。
247
294
 
295
+ ## 生产共享状态
296
+
297
+ 生产传输 registry 必须声明 stateScope=shared 并实际使用 Redis 或 PostgreSQL/MySQL。memory、包装 memory 的 Redis 工厂、包装 SQLite 的 RelDB 工厂会被拒绝;各节点加载相同受管版本化密钥环。 NODE_ENV 或 HAI_CORE_ENV 为 production 时在初始化发布成功前校验;仅 HAI_E2E=1/HAI_E2E_MOCK=1 明确测试可豁免。 [状态、并发和恢复边界](../../.agents/instructions/distributed-state.md)。
298
+
248
299
  ## 开发测试
249
300
 
250
301
  ```bash