@7h3/protocol 0.5.0 → 0.5.2

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 (294) hide show
  1. package/README.md +6 -1317
  2. package/agentAdapter.d.ts +26 -0
  3. package/auditLog.d.ts +54 -0
  4. package/capability.d.ts +67 -0
  5. package/cborCodec.d.ts +34 -0
  6. package/conformanceVectors.d.ts +20 -0
  7. package/encryption.d.ts +85 -0
  8. package/envelopeCbor.d.ts +33 -0
  9. package/frameworkAdapters.d.ts +72 -0
  10. package/gateway.d.ts +53 -0
  11. package/grpcBinding.d.ts +29 -0
  12. package/httpBinding.d.ts +50 -0
  13. package/index.d.ts +39 -0
  14. package/index.js +3670 -0
  15. package/keyInfra.d.ts +64 -0
  16. package/keyRegistry.d.ts +9 -0
  17. package/keyRotation.d.ts +20 -0
  18. package/mcpGateway.d.ts +37 -0
  19. package/mcpTransports.d.ts +62 -0
  20. package/mcpWrapper.d.ts +83 -0
  21. package/otel.d.ts +65 -0
  22. package/package.json +13 -121
  23. package/policyEnforcer.d.ts +50 -0
  24. package/policyTelemetryFeedback.d.ts +11 -0
  25. package/{src/protocol.d.ts → protocol.d.ts} +6 -2
  26. package/protocolAgent.d.ts +58 -0
  27. package/protocolBinary.d.ts +8 -0
  28. package/protocolCapabilities.d.ts +24 -0
  29. package/protocolReplay.d.ts +35 -0
  30. package/protocolTransport.d.ts +73 -0
  31. package/queueBinding.d.ts +43 -0
  32. package/rateLimiter.d.ts +18 -0
  33. package/{src/redisClient.ts → redisClient.d.ts} +25 -75
  34. package/replayStores.d.ts +98 -0
  35. package/revocation.d.ts +71 -0
  36. package/routePolicy.d.ts +30 -0
  37. package/runtimePolicy.d.ts +24 -0
  38. package/runtimePolicyManager.d.ts +15 -0
  39. package/runtimePolicyPresets.d.ts +11 -0
  40. package/signedResponse.d.ts +21 -0
  41. package/stream.d.ts +59 -0
  42. package/telemetry.d.ts +79 -0
  43. package/webhookBinding.d.ts +30 -0
  44. package/wsBinding.d.ts +52 -0
  45. package/.dockerignore +0 -19
  46. package/.github/dependabot.yml +0 -32
  47. package/.github/workflows/ci.yml +0 -31
  48. package/.github/workflows/publish.yml +0 -59
  49. package/.github/workflows/scorecard.yml +0 -37
  50. package/7h3.example.yaml +0 -125
  51. package/CHANGELOG.md +0 -152
  52. package/CONTRIBUTING.md +0 -82
  53. package/Dockerfile +0 -73
  54. package/GOVERNANCE.md +0 -62
  55. package/SECURITY.md +0 -70
  56. package/bench-results/replay-cache-full-1777891033256.json +0 -10
  57. package/bench-results/replay-cache-full-1777896317488.json +0 -10
  58. package/bench-results/replay-cache-full-1777900993184.json +0 -10
  59. package/bench-results/replay-cache-full-1777901019285.json +0 -10
  60. package/bench-results/replay-cache-quick-1777870170126.json +0 -10
  61. package/bench-results/signature-profiles-quick-1775875160079.json +0 -85
  62. package/bench-results/signature-profiles-quick-1775983539716.json +0 -85
  63. package/bench-results/signature-profiles-quick-1776237913190.json +0 -85
  64. package/bench-results/wire-codecs-full-1777891019803.json +0 -93
  65. package/bench-results/wire-codecs-full-1777896260964.json +0 -93
  66. package/bench-results/wire-codecs-full-1777901004247.json +0 -93
  67. package/bench-results/wire-codecs-quick-1775972879056.json +0 -93
  68. package/bench-results/wire-codecs-quick-1775983541111.json +0 -93
  69. package/bench-results/wire-codecs-quick-1776237914299.json +0 -93
  70. package/bench-results/wire-codecs-quick-1777841285236.json +0 -93
  71. package/bench-results/wire-codecs-quick-1777841321772.json +0 -93
  72. package/bench-results/wire-codecs-quick-1777841330408.json +0 -93
  73. package/bench-results/wire-codecs-quick-1777852886082.json +0 -93
  74. package/bench-results/wire-codecs-quick-1777852988773.json +0 -93
  75. package/bench-results/wire-codecs-quick-1777870188095.json +0 -93
  76. package/bench-results/wire-codecs-quick-1777870263918.json +0 -93
  77. package/bench-results/wire-codecs-quick-1777870455034.json +0 -93
  78. package/bench-results/wire-codecs-quick-1778816163081.json +0 -93
  79. package/bench-results/wire-codecs-quick-1778843936130.json +0 -93
  80. package/bin/7h3.ts +0 -406
  81. package/conformance/7h3_v0_1.json +0 -77
  82. package/conformance/7h3_v0_1_binary.json +0 -20
  83. package/conformance/aip_v0_1_binary.json +0 -20
  84. package/docker-compose.yaml +0 -77
  85. package/docs/ADOPTION_PLAN.md +0 -120
  86. package/docs/AGENTS.md +0 -77
  87. package/docs/AIP_RFC_v0.1.md +0 -97
  88. package/docs/AI_DECISION_CARD.md +0 -122
  89. package/docs/AI_RUNTIME_POLICY.json +0 -126
  90. package/docs/AI_RUNTIME_POLICY.yaml +0 -110
  91. package/docs/BACKPRESSURE_TUNING.md +0 -65
  92. package/docs/BENCHMARK_CLAIM_MATRIX.md +0 -42
  93. package/docs/BENCHMARK_REPORT_TEMPLATE.md +0 -169
  94. package/docs/BINARY_CODEC_BENCH.md +0 -23
  95. package/docs/CLEAN_CLONE_RUNBOOK.md +0 -36
  96. package/docs/CLOCK_SKEW_POLICY.md +0 -30
  97. package/docs/DISTRIBUTED_REPLAY.md +0 -142
  98. package/docs/FUZZ_CAMPAIGN.md +0 -121
  99. package/docs/GATEWAY.md +0 -195
  100. package/docs/KEY_MANAGEMENT_POLICY.md +0 -53
  101. package/docs/KEY_REVOCATION.md +0 -69
  102. package/docs/MCP_WRAPPER.md +0 -159
  103. package/docs/MIGRATION_GUIDE.md +0 -40
  104. package/docs/OPERATORS.md +0 -184
  105. package/docs/PERF_REGRESSION_POLICY.md +0 -34
  106. package/docs/PROJECT_EXAMINATION_2026-05-31.md +0 -219
  107. package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +0 -135
  108. package/docs/RELEASE_GATE.md +0 -25
  109. package/docs/RELEASE_NOTES_v0.1.0.md +0 -54
  110. package/docs/SECURITY_REVIEW_2026-06-05.md +0 -165
  111. package/docs/TELEMETRY.md +0 -41
  112. package/docs/THREAT_MODEL.md +0 -89
  113. package/docs/VERSIONING_POLICY.md +0 -30
  114. package/docs/assets/banner-github.png +0 -0
  115. package/docs/assets/banner.png +0 -0
  116. package/docs/assets/banner.svg +0 -123
  117. package/eslint.config.js +0 -15
  118. package/fuzz/ts/harness-decode.ts +0 -136
  119. package/fuzz/ts/harness-verify.ts +0 -121
  120. package/fuzz/ts/run.ts +0 -35
  121. package/mcp-server/README.md +0 -38
  122. package/mcp-server/package-lock.json +0 -1187
  123. package/mcp-server/package.json +0 -35
  124. package/mcp-server/src/index.ts +0 -236
  125. package/mcp-server/tsconfig.json +0 -14
  126. package/scripts/aip-framework-quickstart.ts +0 -110
  127. package/scripts/aip-mcp-gateway.ts +0 -38
  128. package/scripts/aip-mcp-wrap-demo.ts +0 -72
  129. package/scripts/aip-quickstart.ts +0 -60
  130. package/scripts/bench-diff.ts +0 -118
  131. package/scripts/bench-protocol-e2e.ts +0 -937
  132. package/scripts/bench-protocol-openloop.ts +0 -1397
  133. package/scripts/bench-replay-cache.ts +0 -76
  134. package/scripts/bench-signature-profiles.ts +0 -180
  135. package/scripts/bench-wire-codecs.ts +0 -161
  136. package/scripts/build-binary-conformance.ts +0 -36
  137. package/scripts/build-release-dashboard.ts +0 -175
  138. package/scripts/canary-rollout.ts +0 -38
  139. package/scripts/mcpGatewayCli.test.ts +0 -116
  140. package/scripts/prepare-aip-package.ts +0 -88
  141. package/scripts/regen-conformance-sigs.ts +0 -18
  142. package/scripts/release-gate.ts +0 -19
  143. package/scripts/validate-runtime-policy.ts +0 -18
  144. package/sdk/browser/index.test.ts +0 -162
  145. package/sdk/browser/index.ts +0 -257
  146. package/sdk/browser/package.json +0 -13
  147. package/sdk/go/cbor.go +0 -551
  148. package/sdk/go/cbor_test.go +0 -232
  149. package/sdk/go/encryption.go +0 -280
  150. package/sdk/go/encryption_test.go +0 -318
  151. package/sdk/go/go.mod +0 -7
  152. package/sdk/go/go.sum +0 -4
  153. package/sdk/go/http.go +0 -135
  154. package/sdk/go/protocol.go +0 -324
  155. package/sdk/go/protocol_test.go +0 -334
  156. package/sdk/go/replay.go +0 -121
  157. package/sdk/go/replay_test.go +0 -149
  158. package/sdk/go/webhook.go +0 -136
  159. package/sdk/pq/package-lock.json +0 -1358
  160. package/sdk/pq/package.json +0 -42
  161. package/sdk/pq/src/index.test.ts +0 -143
  162. package/sdk/pq/src/index.ts +0 -166
  163. package/sdk/pq/tsconfig.json +0 -14
  164. package/sdk/pq/vitest.config.ts +0 -7
  165. package/sdk/python/README.md +0 -18
  166. package/sdk/python/protocol_7h3/__init__.py +0 -46
  167. package/sdk/python/protocol_7h3/encryption.py +0 -252
  168. package/sdk/python/protocol_7h3/http.py +0 -212
  169. package/sdk/python/protocol_7h3/keys.py +0 -149
  170. package/sdk/python/protocol_7h3/pq.py +0 -244
  171. package/sdk/python/protocol_7h3/protocol.py +0 -525
  172. package/sdk/python/protocol_7h3/queue.py +0 -118
  173. package/sdk/python/protocol_7h3/replay.py +0 -98
  174. package/sdk/python/protocol_7h3/webhook.py +0 -116
  175. package/sdk/python/pyproject.toml +0 -40
  176. package/sdk/python/tests/test_conformance.py +0 -110
  177. package/sdk/python/tests/test_encryption.py +0 -206
  178. package/sdk/python/tests/test_http.py +0 -305
  179. package/sdk/python/tests/test_keys.py +0 -417
  180. package/sdk/python/tests/test_queue.py +0 -120
  181. package/sdk/python/tests/test_webhook.py +0 -345
  182. package/sdk/rust/Cargo.lock +0 -371
  183. package/sdk/rust/Cargo.toml +0 -25
  184. package/sdk/rust/README.md +0 -31
  185. package/sdk/rust/fuzz/Cargo.toml +0 -29
  186. package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +0 -46
  187. package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +0 -11
  188. package/sdk/rust/src/bin/aip_mcp_gateway.rs +0 -59
  189. package/sdk/rust/src/http.rs +0 -145
  190. package/sdk/rust/src/keys.rs +0 -161
  191. package/sdk/rust/src/lib.rs +0 -688
  192. package/sdk/rust/src/queue.rs +0 -79
  193. package/sdk/rust/src/webhook.rs +0 -86
  194. package/sdk/rust/tests/conformance.rs +0 -148
  195. package/sdk/rust/tests/gateway.rs +0 -130
  196. package/sdk/rust/tests/http_webhook_queue.rs +0 -201
  197. package/sdk/rust/tests/keys.rs +0 -189
  198. package/sdk/threshold/index.d.ts +0 -68
  199. package/sdk/threshold/index.d.ts.map +0 -1
  200. package/sdk/threshold/index.js +0 -254
  201. package/sdk/threshold/package-lock.json +0 -1361
  202. package/sdk/threshold/package.json +0 -39
  203. package/sdk/threshold/src/index.d.ts +0 -68
  204. package/sdk/threshold/src/index.d.ts.map +0 -1
  205. package/sdk/threshold/src/index.js +0 -254
  206. package/sdk/threshold/src/index.test.ts +0 -238
  207. package/sdk/threshold/src/index.ts +0 -355
  208. package/sdk/threshold/tsconfig.json +0 -19
  209. package/sdk/threshold/vitest.config.ts +0 -12
  210. package/src/agentAdapter.test.ts +0 -48
  211. package/src/agentAdapter.ts +0 -56
  212. package/src/auditLog.test.ts +0 -145
  213. package/src/auditLog.ts +0 -147
  214. package/src/capability.test.ts +0 -504
  215. package/src/capability.ts +0 -380
  216. package/src/cborCodec.test.ts +0 -263
  217. package/src/cborCodec.ts +0 -339
  218. package/src/conformance.test.ts +0 -136
  219. package/src/conformanceVectors.ts +0 -99
  220. package/src/encryption.test.ts +0 -206
  221. package/src/encryption.ts +0 -245
  222. package/src/envelopeCbor.ts +0 -140
  223. package/src/frameworkAdapters.test.ts +0 -290
  224. package/src/frameworkAdapters.ts +0 -261
  225. package/src/gateway.test.ts +0 -343
  226. package/src/gateway.ts +0 -246
  227. package/src/grpcBinding.test.ts +0 -211
  228. package/src/grpcBinding.ts +0 -103
  229. package/src/httpBinding.test.ts +0 -376
  230. package/src/httpBinding.ts +0 -189
  231. package/src/index.ts +0 -39
  232. package/src/keyInfra.test.ts +0 -278
  233. package/src/keyInfra.ts +0 -228
  234. package/src/keyRegistry.ts +0 -59
  235. package/src/keyRotation.test.ts +0 -78
  236. package/src/keyRotation.ts +0 -72
  237. package/src/mcpGateway.test.ts +0 -129
  238. package/src/mcpGateway.ts +0 -250
  239. package/src/mcpTransports.test.ts +0 -92
  240. package/src/mcpTransports.ts +0 -169
  241. package/src/mcpWrapper.test.ts +0 -179
  242. package/src/mcpWrapper.ts +0 -206
  243. package/src/otel.ts +0 -136
  244. package/src/policyEnforcer.test.ts +0 -99
  245. package/src/policyEnforcer.ts +0 -169
  246. package/src/policyTelemetryFeedback.test.ts +0 -25
  247. package/src/policyTelemetryFeedback.ts +0 -38
  248. package/src/protocol.bench.ts +0 -37
  249. package/src/protocol.d.ts.map +0 -1
  250. package/src/protocol.js +0 -294
  251. package/src/protocol.test.ts +0 -155
  252. package/src/protocol.ts +0 -414
  253. package/src/protocolAgent.test.ts +0 -105
  254. package/src/protocolAgent.ts +0 -169
  255. package/src/protocolBinary.test.ts +0 -165
  256. package/src/protocolBinary.ts +0 -312
  257. package/src/protocolCapabilities.ts +0 -70
  258. package/src/protocolFuzz.advanced.test.ts +0 -235
  259. package/src/protocolFuzz.test.ts +0 -111
  260. package/src/protocolNegative.test.ts +0 -97
  261. package/src/protocolReplay.test.ts +0 -71
  262. package/src/protocolReplay.ts +0 -194
  263. package/src/protocolTransport.test.ts +0 -556
  264. package/src/protocolTransport.ts +0 -483
  265. package/src/queueBinding.test.ts +0 -130
  266. package/src/queueBinding.ts +0 -102
  267. package/src/rateLimiter.test.ts +0 -96
  268. package/src/rateLimiter.ts +0 -46
  269. package/src/redisIntegration.test.ts +0 -134
  270. package/src/replayStores.test.ts +0 -273
  271. package/src/replayStores.ts +0 -215
  272. package/src/revocation.test.ts +0 -98
  273. package/src/revocation.ts +0 -0
  274. package/src/routePolicy.test.ts +0 -87
  275. package/src/routePolicy.ts +0 -72
  276. package/src/runtimePolicy.test.ts +0 -49
  277. package/src/runtimePolicy.ts +0 -81
  278. package/src/runtimePolicyManager.test.ts +0 -29
  279. package/src/runtimePolicyManager.ts +0 -50
  280. package/src/runtimePolicyPresets.ts +0 -43
  281. package/src/signedResponse.test.ts +0 -111
  282. package/src/signedResponse.ts +0 -83
  283. package/src/stream.test.ts +0 -254
  284. package/src/stream.ts +0 -417
  285. package/src/telemetry.test.ts +0 -251
  286. package/src/telemetry.ts +0 -299
  287. package/src/webhookBinding.test.ts +0 -144
  288. package/src/webhookBinding.ts +0 -115
  289. package/src/wsBinding.test.ts +0 -221
  290. package/src/wsBinding.ts +0 -200
  291. package/tsconfig.json +0 -15
  292. package/tsconfig.lib.json +0 -23
  293. package/vite.lib.config.ts +0 -16
  294. package/vitest.config.ts +0 -11
package/src/stream.ts DELETED
@@ -1,417 +0,0 @@
1
- // 7h3 Protocol — Streaming message signing (Feature 4)
2
- //
3
- // Wire format per chunk:
4
- // { i, d, h, f } — non-final frame
5
- // { i, d, h, f:true, sig, kid } — final frame
6
- //
7
- // Security model:
8
- // Writer derives an HMAC-SHA256 key via HKDF(privateKeyBytes, salt=nonce, info='7h3-stream/1')
9
- // and produces per-chunk HMACs. Only the writer can verify these (private key material).
10
- //
11
- // Reader verification is anchored on the Ed25519 final signature which covers
12
- // SHA-256( ordered concat of "i:d" per chunk ) — so any mid-stream tamper, reorder,
13
- // or cross-stream splice is caught at finalize time.
14
-
15
- // ────────────────────────────────── types ─────────────────────────────────────
16
-
17
- export interface StreamChunk {
18
- i: number // sequence index (0-based)
19
- d: string // data (string)
20
- h: string // per-chunk HMAC-SHA256 (base64url) — writer-side only
21
- f: boolean // true = final frame
22
- sig?: string // Ed25519 sig over content hash (only on final frame)
23
- kid?: string // keyId (only on final frame)
24
- }
25
-
26
- export interface StreamSignerOpts {
27
- privateKey: string // Ed25519 PKCS8 base64url
28
- sender: string
29
- nonce?: string // auto-generated when omitted
30
- keyId?: string
31
- }
32
-
33
- export interface StreamVerifierOpts {
34
- publicKey: string // Ed25519 SPKI base64url
35
- maxChunks?: number // default 10000
36
- }
37
-
38
- export type StreamVerifyResult =
39
- | { ok: true; totalBytes: number; chunkCount: number }
40
- | { ok: false; reason: string; chunkIndex?: number }
41
-
42
- // ─────────────────────────── constants ────────────────────────────────────────
43
-
44
- export const STREAM_HEADER = 'x-7h3-stream'
45
-
46
- // ─────────────────────────── internal helpers ─────────────────────────────────
47
-
48
- const textEncoder = new TextEncoder()
49
-
50
- function requireCryptoSubtle(): SubtleCrypto {
51
- if (typeof crypto === 'undefined' || !crypto.subtle) {
52
- throw new Error('Web Crypto API is not available in this runtime')
53
- }
54
- return crypto.subtle
55
- }
56
-
57
- function toBase64Url(bytes: Uint8Array): string {
58
- const bufferLike = (globalThis as any).Buffer
59
- const base64 = bufferLike
60
- ? bufferLike.from(bytes).toString('base64')
61
- : btoa(String.fromCharCode(...bytes))
62
- return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
63
- }
64
-
65
- function fromBase64Url(value: string): Uint8Array {
66
- const padded = value
67
- .replace(/-/g, '+')
68
- .replace(/_/g, '/')
69
- .padEnd(Math.ceil(value.length / 4) * 4, '=')
70
- const bufferLike = (globalThis as any).Buffer
71
- if (bufferLike) return new Uint8Array(bufferLike.from(padded, 'base64'))
72
- const binary = atob(padded)
73
- const bytes = new Uint8Array(binary.length)
74
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
75
- return bytes
76
- }
77
-
78
- function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
79
- return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
80
- }
81
-
82
- function generateNonce(): string {
83
- const arr = new Uint8Array(16)
84
- crypto.getRandomValues(arr)
85
- return toBase64Url(arr)
86
- }
87
-
88
- // ─────────────────── HMAC key derivation (HKDF from private key) ──────────────
89
- //
90
- // IKM = raw PKCS8 DER bytes of the Ed25519 private key
91
- // salt = nonce (UTF-8)
92
- // info = '7h3-stream/1' (UTF-8)
93
- // output = 32-byte HMAC-SHA256 key
94
- //
95
- // The derived key is cached per (privateKey, nonce) pair so 1000 chunks only
96
- // pay one HKDF derivation.
97
-
98
- const derivedHmacKeyCache = new Map<string, Promise<CryptoKey>>()
99
- const HMAC_KEY_CACHE_LIMIT = 128
100
-
101
- function getDerivedHmacKey(privateKeyBase64Url: string, nonce: string): Promise<CryptoKey> {
102
- const cacheKey = `${privateKeyBase64Url}:${nonce}`
103
- const cached = derivedHmacKeyCache.get(cacheKey)
104
- if (cached) return cached
105
-
106
- if (derivedHmacKeyCache.size >= HMAC_KEY_CACHE_LIMIT) derivedHmacKeyCache.clear()
107
-
108
- const subtle = requireCryptoSubtle()
109
- const pkcs8Bytes = fromBase64Url(privateKeyBase64Url)
110
-
111
- const promise = (async () => {
112
- // Import the raw PKCS8 bytes as HKDF input keying material
113
- const hkdfKey = await subtle.importKey(
114
- 'raw',
115
- toArrayBuffer(pkcs8Bytes),
116
- 'HKDF',
117
- false,
118
- ['deriveBits'],
119
- )
120
- // Derive 256 bits
121
- const bits = await subtle.deriveBits(
122
- {
123
- name: 'HKDF',
124
- hash: 'SHA-256',
125
- salt: textEncoder.encode(nonce),
126
- info: textEncoder.encode('7h3-stream/1'),
127
- },
128
- hkdfKey,
129
- 256,
130
- )
131
- // Wrap as HMAC-SHA256 signing key
132
- return subtle.importKey(
133
- 'raw',
134
- bits,
135
- { name: 'HMAC', hash: 'SHA-256' },
136
- false,
137
- ['sign'],
138
- )
139
- })()
140
-
141
- promise.catch(() => derivedHmacKeyCache.delete(cacheKey))
142
- derivedHmacKeyCache.set(cacheKey, promise)
143
- return promise
144
- }
145
-
146
- // ─────────────────── Ed25519 private key cache ────────────────────────────────
147
-
148
- const ed25519PrivCache = new Map<string, Promise<CryptoKey>>()
149
- const ED25519_PRIV_CACHE_LIMIT = 64
150
-
151
- function getCachedEd25519PrivateKey(privateKeyBase64Url: string): Promise<CryptoKey> {
152
- const cached = ed25519PrivCache.get(privateKeyBase64Url)
153
- if (cached) return cached
154
- if (ed25519PrivCache.size >= ED25519_PRIV_CACHE_LIMIT) ed25519PrivCache.clear()
155
- const subtle = requireCryptoSubtle()
156
- const promise = subtle
157
- .importKey('pkcs8', toArrayBuffer(fromBase64Url(privateKeyBase64Url)), { name: 'Ed25519' }, false, ['sign'])
158
- .catch((err: unknown) => { ed25519PrivCache.delete(privateKeyBase64Url); throw err })
159
- ed25519PrivCache.set(privateKeyBase64Url, promise)
160
- return promise
161
- }
162
-
163
- const ed25519PubCache = new Map<string, Promise<CryptoKey>>()
164
- const ED25519_PUB_CACHE_LIMIT = 64
165
-
166
- function getCachedEd25519PublicKey(publicKeyBase64Url: string): Promise<CryptoKey> {
167
- const cached = ed25519PubCache.get(publicKeyBase64Url)
168
- if (cached) return cached
169
- if (ed25519PubCache.size >= ED25519_PUB_CACHE_LIMIT) ed25519PubCache.clear()
170
- const subtle = requireCryptoSubtle()
171
- const promise = subtle
172
- .importKey('spki', toArrayBuffer(fromBase64Url(publicKeyBase64Url)), { name: 'Ed25519' }, false, ['verify'])
173
- .catch((err: unknown) => { ed25519PubCache.delete(publicKeyBase64Url); throw err })
174
- ed25519PubCache.set(publicKeyBase64Url, promise)
175
- return promise
176
- }
177
-
178
- // ─────────────────── content-hash helpers ─────────────────────────────────────
179
- //
180
- // Content hash = SHA-256 over UTF-8 join of "i:d" per chunk, in order.
181
- // For the empty stream the message is the empty string "".
182
- // The join separator is "\n" to keep chunks from blending across boundaries.
183
-
184
- function buildContentMessage(chunks: Array<{ i: number; d: string }>): Uint8Array {
185
- const parts = chunks.map(c => `${c.i}:${c.d}`)
186
- return textEncoder.encode(parts.join('\n'))
187
- }
188
-
189
- async function sha256(data: Uint8Array): Promise<Uint8Array> {
190
- const subtle = requireCryptoSubtle()
191
- const digest = await subtle.digest('SHA-256', toArrayBuffer(data))
192
- return new Uint8Array(digest)
193
- }
194
-
195
- // ─────────────────── SignedStreamWriter ───────────────────────────────────────
196
-
197
- export class SignedStreamWriter {
198
- private readonly opts: Required<StreamSignerOpts>
199
- private seq = 0
200
- private readonly accum: Array<{ i: number; d: string }> = []
201
- // Lazily-derived HMAC key (cached after first use)
202
- private hmacKeyPromise: Promise<CryptoKey> | null = null
203
-
204
- constructor(opts: StreamSignerOpts) {
205
- this.opts = {
206
- nonce: opts.nonce ?? generateNonce(),
207
- keyId: opts.keyId ?? 'stream-key',
208
- ...opts,
209
- }
210
- }
211
-
212
- get nonce(): string { return this.opts.nonce }
213
-
214
- private getHmacKey(): Promise<CryptoKey> {
215
- if (!this.hmacKeyPromise) {
216
- this.hmacKeyPromise = getDerivedHmacKey(this.opts.privateKey, this.opts.nonce)
217
- }
218
- return this.hmacKeyPromise
219
- }
220
-
221
- private async computeChunkHmac(index: number, data: string): Promise<string> {
222
- const subtle = requireCryptoSubtle()
223
- const key = await this.getHmacKey()
224
- // payload = nonce ":" index ":" data
225
- const payload = `${this.opts.nonce}:${index}:${data}`
226
- const sig = await subtle.sign('HMAC', key, textEncoder.encode(payload))
227
- return toBase64Url(new Uint8Array(sig))
228
- }
229
-
230
- async writeChunk(data: string): Promise<StreamChunk> {
231
- const index = this.seq++
232
- const h = await this.computeChunkHmac(index, data)
233
- this.accum.push({ i: index, d: data })
234
- return { i: index, d: data, h, f: false }
235
- }
236
-
237
- async finalize(): Promise<StreamChunk> {
238
- const subtle = requireCryptoSubtle()
239
- const privKey = await getCachedEd25519PrivateKey(this.opts.privateKey)
240
-
241
- // Sign over SHA-256 of ordered chunk content
242
- const contentMsg = buildContentMessage(this.accum)
243
- const digest = await sha256(contentMsg)
244
- // Ed25519 signs the digest bytes directly (WebCrypto Ed25519 takes raw message)
245
- const sigBytes = await subtle.sign('Ed25519', privKey, toArrayBuffer(digest))
246
- const sig = toBase64Url(new Uint8Array(sigBytes))
247
-
248
- // Final frame HMAC is over the content hash (hex) so it binds the full stream
249
- const index = this.seq
250
- const finalData = ''
251
- const h = await this.computeChunkHmac(index, toBase64Url(digest))
252
-
253
- return { i: index, d: finalData, h, f: true, sig, kid: this.opts.keyId }
254
- }
255
- }
256
-
257
- // ─────────────────── SignedStreamReader ───────────────────────────────────────
258
-
259
- export class SignedStreamReader {
260
- private readonly opts: StreamVerifierOpts
261
- private expectedSeq = 0
262
- private readonly accum: Array<{ i: number; d: string }> = []
263
- private totalBytes = 0
264
- private done = false
265
-
266
- constructor(opts: StreamVerifierOpts) {
267
- this.opts = opts
268
- }
269
-
270
- async receiveChunk(chunk: StreamChunk): Promise<{ ok: boolean; reason?: string }> {
271
- if (this.done) return { ok: false, reason: 'stream already finalized' }
272
-
273
- const maxChunks = this.opts.maxChunks ?? 10_000
274
- if (this.accum.length >= maxChunks) {
275
- return { ok: false, reason: `exceeded maxChunks (${maxChunks})` }
276
- }
277
-
278
- // Sequence check — no gaps, no reorder
279
- if (chunk.i !== this.expectedSeq) {
280
- return { ok: false, reason: `sequence error: expected ${this.expectedSeq}, got ${chunk.i}` }
281
- }
282
- this.expectedSeq++
283
-
284
- // Accumulate data
285
- this.accum.push({ i: chunk.i, d: chunk.d })
286
- this.totalBytes += textEncoder.encode(chunk.d).length
287
-
288
- // Per-chunk HMAC is writer-side only (requires private key); we skip it here.
289
- // The Ed25519 final signature covers all chunks and is the reader's guarantee.
290
-
291
- return { ok: true }
292
- }
293
-
294
- async finalize(finalChunk: StreamChunk): Promise<StreamVerifyResult> {
295
- if (this.done) return { ok: false, reason: 'stream already finalized' }
296
- this.done = true
297
-
298
- if (!finalChunk.sig) {
299
- return { ok: false, reason: 'missing signature on final chunk' }
300
- }
301
-
302
- const subtle = requireCryptoSubtle()
303
- let pubKey: CryptoKey
304
- try {
305
- pubKey = await getCachedEd25519PublicKey(this.opts.publicKey)
306
- } catch {
307
- return { ok: false, reason: 'invalid public key' }
308
- }
309
-
310
- // Recompute the content hash (same as writer)
311
- const contentMsg = buildContentMessage(this.accum)
312
- const digest = await sha256(contentMsg)
313
-
314
- const sigBytes = fromBase64Url(finalChunk.sig)
315
- const valid = await subtle.verify('Ed25519', pubKey, toArrayBuffer(sigBytes), toArrayBuffer(digest))
316
-
317
- if (!valid) {
318
- return { ok: false, reason: 'signature verification failed' }
319
- }
320
-
321
- return { ok: true, totalBytes: this.totalBytes, chunkCount: this.accum.length }
322
- }
323
- }
324
-
325
- // ─────────────────── factory helpers ──────────────────────────────────────────
326
-
327
- export function createSignedStream(opts: StreamSignerOpts): SignedStreamWriter {
328
- return new SignedStreamWriter(opts)
329
- }
330
-
331
- export function createStreamVerifier(opts: StreamVerifierOpts): SignedStreamReader {
332
- return new SignedStreamReader(opts)
333
- }
334
-
335
- // ─────────────────── convenience functions ────────────────────────────────────
336
-
337
- export async function signStream(
338
- chunks: AsyncIterable<string> | string[],
339
- opts: StreamSignerOpts,
340
- ): Promise<StreamChunk[]> {
341
- const writer = new SignedStreamWriter(opts)
342
- const result: StreamChunk[] = []
343
- if (Array.isArray(chunks)) {
344
- for (const data of chunks) {
345
- result.push(await writer.writeChunk(data))
346
- }
347
- } else {
348
- for await (const data of chunks) {
349
- result.push(await writer.writeChunk(data))
350
- }
351
- }
352
- result.push(await writer.finalize())
353
- return result
354
- }
355
-
356
- export async function verifyStream(
357
- chunks: StreamChunk[],
358
- opts: StreamVerifierOpts,
359
- ): Promise<StreamVerifyResult> {
360
- const reader = new SignedStreamReader(opts)
361
- // All frames except the last
362
- const nonFinal = chunks.slice(0, -1)
363
- const finalFrame = chunks[chunks.length - 1]
364
-
365
- if (chunks.length === 0) {
366
- return { ok: false, reason: 'empty chunk array — missing final frame' }
367
- }
368
-
369
- for (const chunk of nonFinal) {
370
- const res = await reader.receiveChunk(chunk)
371
- if (!res.ok) return { ok: false, reason: res.reason!, chunkIndex: chunk.i }
372
- }
373
-
374
- if (!finalFrame.f) {
375
- // The last chunk in the array is not marked final — still try to read it as non-final
376
- // so sequence errors are reported, but finalize will fail due to missing sig
377
- const res = await reader.receiveChunk(finalFrame)
378
- if (!res.ok) return { ok: false, reason: res.reason!, chunkIndex: finalFrame.i }
379
- return { ok: false, reason: 'missing final frame' }
380
- }
381
-
382
- return reader.finalize(finalFrame)
383
- }
384
-
385
- // ─────────────────── encode / decode helpers ──────────────────────────────────
386
-
387
- export function encodeStreamChunk(chunk: StreamChunk): string {
388
- return JSON.stringify(chunk)
389
- }
390
-
391
- export function decodeStreamChunk(raw: string): StreamChunk {
392
- let parsed: unknown
393
- try {
394
- parsed = JSON.parse(raw)
395
- } catch {
396
- throw new Error('decodeStreamChunk: invalid JSON')
397
- }
398
- const obj = parsed as Record<string, unknown>
399
- if (
400
- typeof obj.i !== 'number' ||
401
- typeof obj.d !== 'string' ||
402
- typeof obj.h !== 'string' ||
403
- typeof obj.f !== 'boolean'
404
- ) {
405
- throw new Error('decodeStreamChunk: missing required fields (i, d, h, f)')
406
- }
407
- const chunk: StreamChunk = { i: obj.i, d: obj.d, h: obj.h, f: obj.f }
408
- if (obj.sig !== undefined) {
409
- if (typeof obj.sig !== 'string') throw new Error('decodeStreamChunk: sig must be string')
410
- chunk.sig = obj.sig
411
- }
412
- if (obj.kid !== undefined) {
413
- if (typeof obj.kid !== 'string') throw new Error('decodeStreamChunk: kid must be string')
414
- chunk.kid = obj.kid
415
- }
416
- return chunk
417
- }
@@ -1,251 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest'
2
- import {
3
- SimpleCounter,
4
- SimpleHistogram,
5
- Protocol7h3Metrics,
6
- renderPrometheusText,
7
- createMetricsMiddleware,
8
- } from './telemetry'
9
-
10
- // ─── 1. Counter increments correctly with labels ──────────────────────────────
11
-
12
- describe('SimpleCounter with labels', () => {
13
- it('increments independently per label set', () => {
14
- const counter = new SimpleCounter()
15
- counter.increment({ result: 'ok', alg: 'ED25519' })
16
- counter.increment({ result: 'ok', alg: 'ED25519' })
17
- counter.increment({ result: 'fail', alg: 'HS256' })
18
-
19
- expect(counter.value({ result: 'ok', alg: 'ED25519' })).toBe(2)
20
- expect(counter.value({ result: 'fail', alg: 'HS256' })).toBe(1)
21
- expect(counter.value({ result: 'ok', alg: 'HS256' })).toBe(0)
22
- })
23
-
24
- it('label order does not affect key identity', () => {
25
- const counter = new SimpleCounter()
26
- counter.increment({ a: '1', b: '2' })
27
- counter.increment({ b: '2', a: '1' })
28
- expect(counter.value({ a: '1', b: '2' })).toBe(2)
29
- expect(counter.value({ b: '2', a: '1' })).toBe(2)
30
- })
31
- })
32
-
33
- // ─── 2. Counter with no labels increments the default bucket ─────────────────
34
-
35
- describe('SimpleCounter with no labels', () => {
36
- it('starts at 0', () => {
37
- const counter = new SimpleCounter()
38
- expect(counter.value()).toBe(0)
39
- })
40
-
41
- it('increments the default bucket', () => {
42
- const counter = new SimpleCounter()
43
- counter.increment()
44
- counter.increment()
45
- counter.increment()
46
- expect(counter.value()).toBe(3)
47
- // labeled lookup returns 0 — separate bucket
48
- expect(counter.value({ x: '1' })).toBe(0)
49
- })
50
- })
51
-
52
- // ─── 3. Histogram records observations and buckets correctly ─────────────────
53
-
54
- describe('SimpleHistogram', () => {
55
- it('counts observations into correct buckets', () => {
56
- const h = new SimpleHistogram([1, 5, 10, 50, 100])
57
- h.observe(0.5)
58
- h.observe(3)
59
- h.observe(7)
60
- h.observe(75)
61
- h.observe(200)
62
-
63
- const snap = h.snapshot()
64
- // ≤1: 0.5 → 1
65
- expect(snap.buckets['1']).toBe(1)
66
- // ≤5: 0.5, 3 → 2
67
- expect(snap.buckets['5']).toBe(2)
68
- // ≤10: 0.5, 3, 7 → 3
69
- expect(snap.buckets['10']).toBe(3)
70
- // ≤50: 0.5, 3, 7 → 3
71
- expect(snap.buckets['50']).toBe(3)
72
- // ≤100: 0.5, 3, 7, 75 → 4
73
- expect(snap.buckets['100']).toBe(4)
74
- // +Inf: all 5
75
- expect(snap.buckets['+Inf']).toBe(5)
76
- expect(snap.count).toBe(5)
77
- expect(snap.sum).toBeCloseTo(0.5 + 3 + 7 + 75 + 200)
78
- })
79
-
80
- it('tracks per-label state independently', () => {
81
- const h = new SimpleHistogram([10])
82
- h.observe(5, { transport: 'http' })
83
- h.observe(15, { transport: 'ws' })
84
-
85
- const snapHttp = h.snapshot({ transport: 'http' })
86
- expect(snapHttp.count).toBe(1)
87
- expect(snapHttp.buckets['10']).toBe(1)
88
-
89
- const snapWs = h.snapshot({ transport: 'ws' })
90
- expect(snapWs.count).toBe(1)
91
- expect(snapWs.buckets['10']).toBe(0)
92
- })
93
-
94
- it('returns zero snapshot for unseen labels', () => {
95
- const h = new SimpleHistogram([1, 5])
96
- const snap = h.snapshot({ transport: 'grpc' })
97
- expect(snap.count).toBe(0)
98
- expect(snap.sum).toBe(0)
99
- expect(snap.buckets['+Inf']).toBe(0)
100
- })
101
- })
102
-
103
- // ─── 4. renderPrometheusText produces valid Prometheus format ─────────────────
104
-
105
- describe('renderPrometheusText', () => {
106
- it('contains # HELP, # TYPE, and metric lines', () => {
107
- const m = new Protocol7h3Metrics()
108
- m.verifications_total.increment({ result: 'ok', alg: 'ED25519', transport: 'http' })
109
- m.verifications_total.increment({ result: 'ok', alg: 'ED25519', transport: 'http' })
110
- m.verifications_total.increment({ result: 'fail', alg: 'none', transport: 'http' })
111
- m.verification_duration_ms.observe(2.5)
112
-
113
- const text = renderPrometheusText(m)
114
-
115
- expect(text).toMatch(/# HELP 7h3_verifications_total /)
116
- expect(text).toMatch(/# TYPE 7h3_verifications_total counter/)
117
- expect(text).toMatch(/7h3_verifications_total\{.*result="ok".*\} 2/)
118
- expect(text).toMatch(/7h3_verifications_total\{.*result="fail".*\} 1/)
119
- expect(text).toMatch(/# HELP 7h3_verification_duration_ms /)
120
- expect(text).toMatch(/# TYPE 7h3_verification_duration_ms histogram/)
121
- expect(text).toMatch(/7h3_verification_duration_ms_bucket\{.*le="\+Inf".*\} 1/)
122
- expect(text).toMatch(/7h3_verification_duration_ms_count 1/)
123
- })
124
-
125
- it('respects a custom prefix', () => {
126
- const m = new Protocol7h3Metrics()
127
- m.rate_limit_hits_total.increment({ sender: 'agent-a', path: '/api' })
128
- const text = renderPrometheusText(m, 'myapp')
129
- expect(text).toMatch(/# HELP myapp_verifications_total/)
130
- expect(text).toMatch(/myapp_rate_limit_hits_total/)
131
- })
132
-
133
- it('all expected metric names appear', () => {
134
- const m = new Protocol7h3Metrics()
135
- const text = renderPrometheusText(m)
136
- const expectedNames = [
137
- '7h3_verifications_total',
138
- '7h3_verification_duration_ms',
139
- '7h3_rate_limit_hits_total',
140
- '7h3_sender_denials_total',
141
- '7h3_replay_detections_total',
142
- '7h3_audit_entries_total',
143
- '7h3_active_connections',
144
- ]
145
- for (const name of expectedNames) {
146
- expect(text).toContain(`# HELP ${name}`)
147
- }
148
- })
149
-
150
- it('ends with a newline', () => {
151
- const m = new Protocol7h3Metrics()
152
- const text = renderPrometheusText(m)
153
- expect(text.endsWith('\n')).toBe(true)
154
- })
155
- })
156
-
157
- // ─── 5. metrics.verifications_total increments on gateway verify calls ────────
158
-
159
- describe('Protocol7h3Metrics integration via gateway verify', () => {
160
- it('increments verifications_total on skip-verify path (allow policy)', async () => {
161
- // Import gateway dynamically to get a fresh reference to globalMetrics
162
- // We directly test Protocol7h3Metrics counting logic by simulating the verify flow
163
- const m = new Protocol7h3Metrics()
164
-
165
- // Simulate a successful skip-verify (allow-all gateway)
166
- m.verifications_total.increment({ result: 'ok', alg: 'none', transport: 'http' })
167
-
168
- expect(m.verifications_total.value({ result: 'ok', alg: 'none', transport: 'http' })).toBe(1)
169
- expect(m.verifications_total.value({ result: 'fail', alg: 'none', transport: 'http' })).toBe(0)
170
- })
171
-
172
- it('increments verifications_total fail on rate-limit', async () => {
173
- const m = new Protocol7h3Metrics()
174
-
175
- // Simulate a rate-limit hit
176
- m.verifications_total.increment({ result: 'fail', alg: 'ED25519', transport: 'http' })
177
- m.rate_limit_hits_total.increment({ sender: 'agent-x', path: '/api/data' })
178
-
179
- expect(m.verifications_total.value({ result: 'fail', alg: 'ED25519', transport: 'http' })).toBe(1)
180
- expect(m.rate_limit_hits_total.value({ sender: 'agent-x', path: '/api/data' })).toBe(1)
181
- })
182
-
183
- it('increments sender_denials_total on 403', async () => {
184
- const m = new Protocol7h3Metrics()
185
-
186
- m.verifications_total.increment({ result: 'fail', alg: 'ED25519', transport: 'http' })
187
- m.sender_denials_total.increment({ sender: 'rogue-agent', path: '/admin' })
188
-
189
- expect(m.sender_denials_total.value({ sender: 'rogue-agent', path: '/admin' })).toBe(1)
190
- expect(m.verifications_total.value({ result: 'fail', alg: 'ED25519', transport: 'http' })).toBe(1)
191
- })
192
-
193
- it('records duration in verification_duration_ms', () => {
194
- const m = new Protocol7h3Metrics()
195
-
196
- m.verification_duration_ms.observe(0.3)
197
- m.verification_duration_ms.observe(2.0)
198
- m.verification_duration_ms.observe(120)
199
-
200
- const snap = m.verification_duration_ms.snapshot()
201
- expect(snap.count).toBe(3)
202
- expect(snap.sum).toBeCloseTo(0.3 + 2.0 + 120)
203
- // 0.3 ≤ 0.5 → should appear in bucket 0.5
204
- expect(snap.buckets['0.5']).toBe(1)
205
- // +Inf must be count
206
- expect(snap.buckets['+Inf']).toBe(3)
207
- })
208
- })
209
-
210
- // ─── 6. createMetricsMiddleware serves metrics ────────────────────────────────
211
-
212
- describe('createMetricsMiddleware', () => {
213
- it('serves Prometheus text at /metrics', () => {
214
- const m = new Protocol7h3Metrics()
215
- m.verifications_total.increment({ result: 'ok', alg: 'ED25519', transport: 'http' })
216
- const middleware = createMetricsMiddleware('/metrics', m)
217
-
218
- let status = 0
219
- let body = ''
220
- let headers: Record<string, string> = {}
221
- const req = { url: '/metrics', method: 'GET' }
222
- const res = {
223
- writeHead(s: number, h: Record<string, string>) { status = s; headers = h },
224
- end(b: string) { body = b },
225
- }
226
- let nextCalled = false
227
- const next = () => { nextCalled = true }
228
-
229
- middleware(req, res, next)
230
-
231
- expect(status).toBe(200)
232
- expect(headers['content-type']).toMatch(/text\/plain/)
233
- expect(body).toContain('# HELP 7h3_verifications_total')
234
- expect(nextCalled).toBe(false)
235
- })
236
-
237
- it('calls next() for non-metrics paths', () => {
238
- const m = new Protocol7h3Metrics()
239
- const middleware = createMetricsMiddleware('/metrics', m)
240
-
241
- let nextCalled = false
242
- const req = { url: '/api/data', method: 'GET' }
243
- const res = {
244
- writeHead() {},
245
- end() {},
246
- }
247
- middleware(req, res, () => { nextCalled = true })
248
-
249
- expect(nextCalled).toBe(true)
250
- })
251
- })