@h-ai/serv 1.0.0-beta.14 → 1.0.0-beta.15

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
@@ -223,7 +223,7 @@ const app = serv.createApp({
223
223
 
224
224
  > **Security**: with `transport` enabled, business requests need valid `X-Client-Id` except `excludePaths` and key exchange. Missing/unknown clientId returns 400. Unencryptable bodies, non-JSON responses, or encryption size limits return errors rather than forwarding plaintext business data.
225
225
 
226
- 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.
226
+ 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. By default, requests expire after five minutes and client timestamps may be up to 30 seconds ahead. Set `maxRequestAgeSeconds` (1–600) and `maxFutureSkewSeconds` (0–300) on the server to adjust these limits in whole seconds. The shared registry atomically consumes (clientId, requestId) and retains the nonce until issuedAt plus 11 minutes, covering the longest configurable window and server clock differences. 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.
227
227
 
228
228
  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. Serv/Kit hosts explicitly allow plaintext through `encryption: disabled` path rules or host `excludePaths`. Callers must select ordinary requests; API Client/Kit wrappers use their own `excludePaths`. CORS must allow and expose X-Hai-Transport. TLS authenticates handshake keys; this protocol does not replace IAM authorization.
229
229
 
@@ -231,7 +231,7 @@ Set `transport.maxPlaintextBytes` for both request and response JSON plaintext,
231
231
 
232
232
  Ordinary routes retain `transport.maxPlaintextBytes` (auto/omitted: 1 MiB). Declare Feedback report intake, evidence, attachments and ZIP download capacity separately using `routePlaintextBytes` and `feedback.budget.maxTransportBytes`; `*` matches one complete path segment and explicit host route values take precedence. Handshakes, adapter-node and proxies use the maximum route capacity. HTTP adapters admit at most 2 explicitly declared material requests above 1 MiB and a separate 16 ordinary business requests, releasing admission after the entire response or failure. Busy admission returns 503 / `hai:crypto:072`; business writes are not automatically replayed.
233
233
 
234
- 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.
234
+ Serv/Kit return an encrypted HTTP 400 for an out-of-window request after validating request binding, signature and MAC. The client throws `TransportTimeError` (`hai:crypto:071`), displays the server’s UTC time at validation, and asks the user to enable automatic synchronization or set the device time manually before refreshing. `serverTime` is Unix milliseconds. Clients still show synchronization guidance when an older server omits it. `api-client` maps the error to `VALIDATION_FAILED` and preserves `cause.transportCode` and `cause.serverTime`.
235
235
 
236
236
  #### Token authentication (`iam` / `refreshCookie`)
237
237
 
package/README.ja.md CHANGED
@@ -223,7 +223,7 @@ const app = serv.createApp({
223
223
 
224
224
  > **安全方針**:`transport` 有効時は `excludePaths` と鍵交換以外で有効な `X-Client-Id` が必要です。不足・未知の clientId は 400、暗号化不能・非 JSON 応答・上限超過はエラーになり、平文の業務データは渡しません。
225
225
 
226
- 認証済み 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 を保持してください。業務の再試行には別の冪等キーが必要です。
226
+ 認証済み 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 秒先まで許容されます。サーバー側の `maxRequestAgeSeconds`(1~600)と `maxFutureSkewSeconds`(0~300)で整数秒単位に調整できます。共有レジストリは (clientId, requestId) をアトミックに消費し、設定可能な最長の有効期間とノード間の時刻差をカバーするため、nonce を issuedAt の 11 分後まで保持します。ストレージ障害時は要求を拒否します。時計同期と Redis の noeviction/永続化により有効な nonce を保持してください。業務の再試行には別の冪等キーが必要です。
227
227
 
228
228
  空の POST も空平文の認証封装を送ります。GET/HEAD は X-Hai-Transport ヘッダー、204/304/HEAD など本文なし応答も同じヘッダーを使います。他の要求・応答は JSON 本文と X-Encrypted: true を使います。クライアントは元の要求コンテキストと HTTP status を照合し、マーカー欠落、旧形式、認証失敗では reject します。平文 endpoint は Serv/Kit の `encryption: disabled` ルールまたはホストの `excludePaths` で明示的に許可し、呼出側も通常の要求を選びます。API Client/Kit のラッパーは自身の `excludePaths` を使用します。CORS では X-Hai-Transport を許可・公開してください。鍵交換の公開鍵認証は TLS に依存し、IAM の業務認可は別に必要です。
229
229
 
@@ -231,7 +231,7 @@ const app = serv.createApp({
231
231
 
232
232
  通常業務は `transport.maxPlaintextBytes`(auto/省略は 1 MiB)を維持します。Feedback の受理・証拠・添付・ZIP ダウンロードは `routePlaintextBytes` と `feedback.budget.maxTransportBytes` で別途宣言します。`*` は完全な1パス区間のみ一致し、ホストの明示値が優先です。ハンドシェイク、adapter-node、プロキシは経路の最大値を使用します。HTTP アダプターは個別宣言された 1 MiB 超の資料経路を最大2件、通常業務を別枠で16件まで許可し、応答完了・失敗時に解放します。混雑は503 / `hai:crypto:072` を返し、業務書込は自動再送しません。
233
233
 
234
- serv/kit は要求の結合、署名、MAC を検証した後、時間窓を超えた報文に暗号化した HTTP 400 時刻エラーを返します。クライアントは `TransportTimeError`(`hai:crypto:071`)で端末とサーバーの時刻同期・再読み込みを案内します。`api-client` は `VALIDATION_FAILED` に変換し `cause.transportCode` に原因を保持します。自動再送やリプレイ時間窓の緩和は行わず、改ざん・未認証の要求は従来の認証エラーです。
234
+ Serv/Kit は要求の結合、署名、MAC を検証した後、時刻の許容範囲を超えた要求に暗号化した HTTP 400 を返します。クライアントは `TransportTimeError`(`hai:crypto:071`)をスローし、検証時のサーバー UTC 時刻を表示して、自動時刻同期を有効にするか手動で時刻を設定してから再読み込みするよう案内します。`serverTime` は Unix ミリ秒です。旧サーバーが値を返さない場合も時刻調整の案内を表示します。`api-client` は `VALIDATION_FAILED` に変換し、`cause.transportCode` と `cause.serverTime` を保持します。
235
235
 
236
236
  #### Token 認証(`iam` / `refreshCookie`)
237
237
 
package/README.md CHANGED
@@ -223,7 +223,7 @@ const app = serv.createApp({
223
223
 
224
224
  > **安全策略**:启用 `transport` 后,除 `excludePaths` 与密钥协商端点外,业务请求必须携带有效 `X-Client-Id`。缺失或未知 clientId 会返回 400;响应体无法加密、非 JSON 业务响应或超过单次加密上限时返回错误,不会明文透传业务数据。
225
225
 
226
- 认证协议仅支持 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/可靠持久化策略;各节点时钟须同步。此能力防止同一传输消息重放,业务重试仍需自己的幂等键。
226
+ 认证协议仅支持 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 秒。服务端可用 `maxRequestAgeSeconds`(1~600)和 `maxFutureSkewSeconds`(0~300)调整,单位为整数秒。共享 registry 原子消费 (clientId, requestId),nonce 保留到 issuedAt + 11 分钟,覆盖最长可配窗口及节点时钟差,存储故障拒绝请求。Redis 必须保留有效 nonce,使用 noeviction/可靠持久化策略;各节点时钟须同步。此能力防止同一传输消息重放,业务重试仍需自己的幂等键。
227
227
 
228
228
  空 POST 也发送空明文的认证封装;GET/HEAD 将封装放在 X-Hai-Transport 头,204/304/HEAD 等无正文响应也使用同名头承载认证封装。其它请求/响应在 JSON 正文承载封装并带 X-Encrypted: true。客户端必须关联原请求上下文和 HTTP status,缺标记、旧格式或认证失败一律 reject;明文接口由 Serv/Kit 路径规则 `encryption: disabled` 或宿主 `excludePaths` 显式允许;调用方同步选择普通请求,API Client/Kit 包装器使用自己的 `excludePaths`。CORS 须允许并暴露 X-Hai-Transport。握手公钥的真实性依赖 TLS,不替代 IAM 业务授权。
229
229
 
@@ -231,7 +231,7 @@ const app = serv.createApp({
231
231
 
232
232
  普通业务仍使用 `transport.maxPlaintextBytes`(auto/省略为 1 MiB);Feedback 的报告受理、证据、附件及 ZIP 下载路由通过 `routePlaintextBytes` 单独声明 `feedback.budget.maxTransportBytes`。路径中的 `*` 仅匹配一个完整路径段;宿主显式路由值优先。握手、adapter-node 与代理使用各路由最大容量,普通业务不会随材料预算扩大。HTTP 适配器为单独声明且大于 1 MiB 的材料路由保留最多 2 个槽,普通业务另有 16 个槽,完整响应/失败后释放;忙碌返回 503 / `hai:crypto:072`,不自动重放业务写入。
233
233
 
234
- serv/kit 对通过请求绑定、签名和 MAC 校验但超出时间窗口的报文返回加密的 HTTP 400 时间错误;客户端抛 `TransportTimeError`(`hai:crypto:071`),提示同步设备和服务器时间后刷新重试。`api-client` 映射为 `VALIDATION_FAILED`,在 `cause.transportCode` 保留该原因。不会自动重放请求或放宽防重放窗口;篡改或未认证报文仍返回通用认证失败。
234
+ Serv/Kit 对通过请求绑定、签名和 MAC 校验但超出时间窗口的请求返回加密的 HTTP 400 错误。客户端抛出 `TransportTimeError`(`hai:crypto:071`),显示校验时的服务器 UTC 时间,提示开启设备的自动校时或手工修改时间后刷新。`serverTime` 为 Unix 毫秒,旧服务端未提供该值时仍显示校时提示。`api-client` 映射为 `VALIDATION_FAILED`,在 `cause.transportCode` 和 `cause.serverTime` 保留诊断信息。
235
235
 
236
236
  #### Token 认证(`iam` / `refreshCookie`)
237
237
 
package/dist/index.d.ts CHANGED
@@ -232,6 +232,10 @@ interface ServHealthHttpConfig {
232
232
  interface ServTransportRuntimeConfig {
233
233
  /** 双向明文 UTF-8 字节上限;省略使用 1 MiB,范围 1 KiB~64 MiB。 */
234
234
  readonly maxPlaintextBytes?: number;
235
+ /** 请求时间允许领先服务器的秒数,默认 30,整数范围 0~300。 */
236
+ readonly maxFutureSkewSeconds?: number;
237
+ /** 请求从签发起的有效秒数,默认 300,整数范围 1~600。 */
238
+ readonly maxRequestAgeSeconds?: number;
235
239
  /** 按完整路径段匹配的明文容量;* 匹配单段。 */
236
240
  readonly routePlaintextBytes?: Readonly<Record<string, number>>;
237
241
  readonly keyExchangePath: `/${string}`;
@@ -322,6 +326,8 @@ type ServCorsRuntimeConfigInput = Partial<ServCorsRuntimeConfig>;
322
326
  */
323
327
  type ServTransportRuntimeConfigInput = Partial<{
324
328
  readonly maxPlaintextBytes: number | 'auto';
329
+ readonly maxFutureSkewSeconds: number;
330
+ readonly maxRequestAgeSeconds: number;
325
331
  readonly routePlaintextBytes: Readonly<Record<string, number>>;
326
332
  readonly keyExchangePath: `/${string}`;
327
333
  readonly excludePaths: readonly `/${string}`[];
@@ -449,6 +455,8 @@ declare const ServConfigSchema: z.ZodPipe<z.ZodObject<{
449
455
  }, z.core.$strip>>;
450
456
  transport: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
451
457
  maxPlaintextBytes: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodLiteral<"auto">]>, z.ZodTransform<number | undefined, number | "auto">>>;
458
+ maxFutureSkewSeconds: z.ZodOptional<z.ZodNumber>;
459
+ maxRequestAgeSeconds: z.ZodOptional<z.ZodNumber>;
452
460
  routePlaintextBytes: z.ZodOptional<z.ZodRecord<z.ZodCustom<`/${string}`, `/${string}`>, z.ZodNumber>>;
453
461
  keyExchangePath: z.ZodDefault<z.ZodCustom<`/${string}`, `/${string}`>>;
454
462
  excludePaths: z.ZodDefault<z.ZodArray<z.ZodCustom<`/${string}`, `/${string}`>>>;
@@ -517,6 +525,8 @@ declare const ServConfigSchema: z.ZodPipe<z.ZodObject<{
517
525
  keyExchangePath: `/${string}`;
518
526
  excludePaths: `/${string}`[];
519
527
  maxPlaintextBytes?: number | undefined;
528
+ maxFutureSkewSeconds?: number | undefined;
529
+ maxRequestAgeSeconds?: number | undefined;
520
530
  routePlaintextBytes?: Record<`/${string}`, number> | undefined;
521
531
  } | undefined;
522
532
  }>>;
@@ -541,6 +551,10 @@ interface ServTransportConfig {
541
551
  /** 受管服务端密钥环,仅运行时从私有 Secret 注入。 */
542
552
  /** 双向业务明文 UTF-8 字节上限;默认 1 MiB,整数范围 1 KiB~64 MiB。 */
543
553
  readonly maxPlaintextBytes?: number;
554
+ /** 请求时间允许领先服务器的秒数,默认 30,整数范围 0~300。 */
555
+ readonly maxFutureSkewSeconds?: number;
556
+ /** 请求从签发起的有效秒数,默认 300,整数范围 1~600。 */
557
+ readonly maxRequestAgeSeconds?: number;
544
558
  /** 路由明文容量;* 匹配一个完整路径段。 */
545
559
  routePlaintextBytes?: Readonly<Record<string, number>>;
546
560
  readonly serverKeyRing: TransportServerKeyRing;
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { posix } from 'path';
6
6
  import { apiContract, AUDIO_WS_PATH, AudioWsClientMessageSchema } from '@h-ai/api-contract';
7
7
  import { audit } from '@h-ai/audit';
8
8
  import { core, RequestAccessConfigSchema, TrustedProxiesSchema, HaiCommonError, err, ok } from '@h-ai/core';
9
- import { TRANSPORT_PLAINTEXT_RANGE, createTransportBodyPolicy, TRANSPORT_PROTOCOL, authenticateTransportRequest, TransportTimeError, createTransportErrorResponse, TransportBodyTooLargeError, readTransportBody, TRANSPORT_BODY_LIMITS, isValidSM2PublicKey } from '@h-ai/crypto';
9
+ import { TRANSPORT_PLAINTEXT_RANGE, TRANSPORT_TIME_LIMITS, TRANSPORT_PROTOCOL, createTransportBodyPolicy, authenticateTransportRequest, TransportTimeError, createTransportErrorResponse, TransportBodyTooLargeError, readTransportBody, TRANSPORT_BODY_LIMITS, isValidSM2PublicKey } from '@h-ai/crypto';
10
10
  import { OpenAPIHandler } from '@orpc/openapi/fetch';
11
11
  import { Hono } from 'hono';
12
12
  import { WebSocketServer } from 'ws';
@@ -593,6 +593,8 @@ var TransportRuntimeConfigInputSchema = z.union([
593
593
  z.literal(false),
594
594
  z.object({
595
595
  maxPlaintextBytes: z.union([z.number().int().min(TRANSPORT_PLAINTEXT_RANGE.min).max(TRANSPORT_PLAINTEXT_RANGE.max), z.literal("auto")]).transform((value) => value === "auto" ? void 0 : value).optional().describe("\u666E\u901A\u4E1A\u52A1\u53CC\u5411\u660E\u6587\u4E0A\u9650\uFF1Bauto \u4E3A 1 MiB\uFF1B\u5927\u6750\u6599\u4F7F\u7528 routePlaintextBytes \u5355\u72EC\u58F0\u660E").meta({ default: "auto" }),
596
+ maxFutureSkewSeconds: z.number().int().min(0).max(TRANSPORT_TIME_LIMITS.maxFutureSkewSeconds).optional().describe("\u5141\u8BB8\u5BA2\u6237\u7AEF\u8BF7\u6C42\u65F6\u95F4\u9886\u5148\u670D\u52A1\u5668\u7684\u79D2\u6570").meta({ default: TRANSPORT_PROTOCOL.CLOCK_SKEW_MS / 1e3 }),
597
+ maxRequestAgeSeconds: z.number().int().min(TRANSPORT_TIME_LIMITS.minRequestAgeSeconds).max(TRANSPORT_TIME_LIMITS.maxRequestAgeSeconds).optional().describe("\u8BF7\u6C42\u4ECE\u7B7E\u53D1\u8D77\u7684\u6709\u6548\u79D2\u6570").meta({ default: TRANSPORT_PROTOCOL.REQUEST_WINDOW_MS / 1e3 }),
596
598
  routePlaintextBytes: z.record(AbsolutePathSchema, z.number().int().min(TRANSPORT_PLAINTEXT_RANGE.min).max(TRANSPORT_PLAINTEXT_RANGE.max)).optional().describe("\u5B8C\u6574\u8DEF\u5F84\u6BB5\u5230\u53CC\u5411\u660E\u6587\u5B57\u8282\u4E0A\u9650\u7684\u6620\u5C04\uFF1B\u661F\u53F7\u5339\u914D\u5355\u6BB5\uFF0C\u4F9B\u9644\u4EF6\u548C\u5F52\u6863\u72EC\u7ACB\u5BB9\u91CF"),
597
599
  keyExchangePath: AbsolutePathSchema.default("/_hai/key-exchange"),
598
600
  excludePaths: z.array(AbsolutePathSchema).default([])
@@ -1292,7 +1294,9 @@ function createApp(options) {
1292
1294
  const mgr = options.transport.crypto.transport.createServer({
1293
1295
  serverKeyRing: options.transport.serverKeyRing,
1294
1296
  keyStore: options.transport.keyStore,
1295
- maxPlaintextBytes: policy.maximum.plaintextBytes
1297
+ maxPlaintextBytes: policy.maximum.plaintextBytes,
1298
+ maxFutureSkewSeconds: options.transport.maxFutureSkewSeconds,
1299
+ maxRequestAgeSeconds: options.transport.maxRequestAgeSeconds
1296
1300
  });
1297
1301
  if (!mgr.success) {
1298
1302
  throw core.error.buildHaiErrorInst(