@7h3/protocol 0.1.2 → 0.5.0

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 (276) hide show
  1. package/.dockerignore +19 -0
  2. package/.github/dependabot.yml +32 -0
  3. package/.github/workflows/ci.yml +31 -0
  4. package/.github/workflows/publish.yml +59 -0
  5. package/.github/workflows/scorecard.yml +37 -0
  6. package/7h3.example.yaml +125 -0
  7. package/CHANGELOG.md +152 -0
  8. package/CONTRIBUTING.md +82 -0
  9. package/Dockerfile +73 -0
  10. package/GOVERNANCE.md +62 -0
  11. package/README.md +1317 -6
  12. package/SECURITY.md +70 -0
  13. package/bench-results/replay-cache-full-1777891033256.json +10 -0
  14. package/bench-results/replay-cache-full-1777896317488.json +10 -0
  15. package/bench-results/replay-cache-full-1777900993184.json +10 -0
  16. package/bench-results/replay-cache-full-1777901019285.json +10 -0
  17. package/bench-results/replay-cache-quick-1777870170126.json +10 -0
  18. package/bench-results/signature-profiles-quick-1775875160079.json +85 -0
  19. package/bench-results/signature-profiles-quick-1775983539716.json +85 -0
  20. package/bench-results/signature-profiles-quick-1776237913190.json +85 -0
  21. package/bench-results/wire-codecs-full-1777891019803.json +93 -0
  22. package/bench-results/wire-codecs-full-1777896260964.json +93 -0
  23. package/bench-results/wire-codecs-full-1777901004247.json +93 -0
  24. package/bench-results/wire-codecs-quick-1775972879056.json +93 -0
  25. package/bench-results/wire-codecs-quick-1775983541111.json +93 -0
  26. package/bench-results/wire-codecs-quick-1776237914299.json +93 -0
  27. package/bench-results/wire-codecs-quick-1777841285236.json +93 -0
  28. package/bench-results/wire-codecs-quick-1777841321772.json +93 -0
  29. package/bench-results/wire-codecs-quick-1777841330408.json +93 -0
  30. package/bench-results/wire-codecs-quick-1777852886082.json +93 -0
  31. package/bench-results/wire-codecs-quick-1777852988773.json +93 -0
  32. package/bench-results/wire-codecs-quick-1777870188095.json +93 -0
  33. package/bench-results/wire-codecs-quick-1777870263918.json +93 -0
  34. package/bench-results/wire-codecs-quick-1777870455034.json +93 -0
  35. package/bench-results/wire-codecs-quick-1778816163081.json +93 -0
  36. package/bench-results/wire-codecs-quick-1778843936130.json +93 -0
  37. package/bin/7h3.ts +406 -0
  38. package/conformance/7h3_v0_1.json +77 -0
  39. package/conformance/7h3_v0_1_binary.json +20 -0
  40. package/conformance/aip_v0_1_binary.json +20 -0
  41. package/docker-compose.yaml +77 -0
  42. package/docs/ADOPTION_PLAN.md +120 -0
  43. package/docs/AGENTS.md +77 -0
  44. package/docs/AIP_RFC_v0.1.md +97 -0
  45. package/docs/AI_DECISION_CARD.md +122 -0
  46. package/docs/AI_RUNTIME_POLICY.json +126 -0
  47. package/docs/AI_RUNTIME_POLICY.yaml +110 -0
  48. package/docs/BACKPRESSURE_TUNING.md +65 -0
  49. package/docs/BENCHMARK_CLAIM_MATRIX.md +42 -0
  50. package/docs/BENCHMARK_REPORT_TEMPLATE.md +169 -0
  51. package/docs/BINARY_CODEC_BENCH.md +23 -0
  52. package/docs/CLEAN_CLONE_RUNBOOK.md +36 -0
  53. package/docs/CLOCK_SKEW_POLICY.md +30 -0
  54. package/docs/DISTRIBUTED_REPLAY.md +142 -0
  55. package/docs/FUZZ_CAMPAIGN.md +121 -0
  56. package/docs/GATEWAY.md +195 -0
  57. package/docs/KEY_MANAGEMENT_POLICY.md +53 -0
  58. package/docs/KEY_REVOCATION.md +69 -0
  59. package/docs/MCP_WRAPPER.md +159 -0
  60. package/docs/MIGRATION_GUIDE.md +40 -0
  61. package/docs/OPERATORS.md +184 -0
  62. package/docs/PERF_REGRESSION_POLICY.md +34 -0
  63. package/docs/PROJECT_EXAMINATION_2026-05-31.md +219 -0
  64. package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +135 -0
  65. package/docs/RELEASE_GATE.md +25 -0
  66. package/docs/RELEASE_NOTES_v0.1.0.md +54 -0
  67. package/docs/SECURITY_REVIEW_2026-06-05.md +165 -0
  68. package/docs/TELEMETRY.md +41 -0
  69. package/docs/THREAT_MODEL.md +89 -0
  70. package/docs/VERSIONING_POLICY.md +30 -0
  71. package/docs/assets/banner-github.png +0 -0
  72. package/docs/assets/banner.png +0 -0
  73. package/docs/assets/banner.svg +123 -0
  74. package/eslint.config.js +15 -0
  75. package/fuzz/ts/harness-decode.ts +136 -0
  76. package/fuzz/ts/harness-verify.ts +121 -0
  77. package/fuzz/ts/run.ts +35 -0
  78. package/mcp-server/README.md +38 -0
  79. package/mcp-server/package-lock.json +1187 -0
  80. package/mcp-server/package.json +35 -0
  81. package/mcp-server/src/index.ts +236 -0
  82. package/mcp-server/tsconfig.json +14 -0
  83. package/package.json +121 -13
  84. package/scripts/aip-framework-quickstart.ts +110 -0
  85. package/scripts/aip-mcp-gateway.ts +38 -0
  86. package/scripts/aip-mcp-wrap-demo.ts +72 -0
  87. package/scripts/aip-quickstart.ts +60 -0
  88. package/scripts/bench-diff.ts +118 -0
  89. package/scripts/bench-protocol-e2e.ts +937 -0
  90. package/scripts/bench-protocol-openloop.ts +1397 -0
  91. package/scripts/bench-replay-cache.ts +76 -0
  92. package/scripts/bench-signature-profiles.ts +180 -0
  93. package/scripts/bench-wire-codecs.ts +161 -0
  94. package/scripts/build-binary-conformance.ts +36 -0
  95. package/scripts/build-release-dashboard.ts +175 -0
  96. package/scripts/canary-rollout.ts +38 -0
  97. package/scripts/mcpGatewayCli.test.ts +116 -0
  98. package/scripts/prepare-aip-package.ts +88 -0
  99. package/scripts/regen-conformance-sigs.ts +18 -0
  100. package/scripts/release-gate.ts +19 -0
  101. package/scripts/validate-runtime-policy.ts +18 -0
  102. package/sdk/browser/index.test.ts +162 -0
  103. package/sdk/browser/index.ts +257 -0
  104. package/sdk/browser/package.json +13 -0
  105. package/sdk/go/cbor.go +551 -0
  106. package/sdk/go/cbor_test.go +232 -0
  107. package/sdk/go/encryption.go +280 -0
  108. package/sdk/go/encryption_test.go +318 -0
  109. package/sdk/go/go.mod +7 -0
  110. package/sdk/go/go.sum +4 -0
  111. package/sdk/go/http.go +135 -0
  112. package/sdk/go/protocol.go +324 -0
  113. package/sdk/go/protocol_test.go +334 -0
  114. package/sdk/go/replay.go +121 -0
  115. package/sdk/go/replay_test.go +149 -0
  116. package/sdk/go/webhook.go +136 -0
  117. package/sdk/pq/package-lock.json +1358 -0
  118. package/sdk/pq/package.json +42 -0
  119. package/sdk/pq/src/index.test.ts +143 -0
  120. package/sdk/pq/src/index.ts +166 -0
  121. package/sdk/pq/tsconfig.json +14 -0
  122. package/sdk/pq/vitest.config.ts +7 -0
  123. package/sdk/python/README.md +18 -0
  124. package/sdk/python/protocol_7h3/__init__.py +46 -0
  125. package/sdk/python/protocol_7h3/encryption.py +252 -0
  126. package/sdk/python/protocol_7h3/http.py +212 -0
  127. package/sdk/python/protocol_7h3/keys.py +149 -0
  128. package/sdk/python/protocol_7h3/pq.py +244 -0
  129. package/sdk/python/protocol_7h3/protocol.py +525 -0
  130. package/sdk/python/protocol_7h3/queue.py +118 -0
  131. package/sdk/python/protocol_7h3/replay.py +98 -0
  132. package/sdk/python/protocol_7h3/webhook.py +116 -0
  133. package/sdk/python/pyproject.toml +40 -0
  134. package/sdk/python/tests/test_conformance.py +110 -0
  135. package/sdk/python/tests/test_encryption.py +206 -0
  136. package/sdk/python/tests/test_http.py +305 -0
  137. package/sdk/python/tests/test_keys.py +417 -0
  138. package/sdk/python/tests/test_queue.py +120 -0
  139. package/sdk/python/tests/test_webhook.py +345 -0
  140. package/sdk/rust/Cargo.lock +371 -0
  141. package/sdk/rust/Cargo.toml +25 -0
  142. package/sdk/rust/README.md +31 -0
  143. package/sdk/rust/fuzz/Cargo.toml +29 -0
  144. package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +46 -0
  145. package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +11 -0
  146. package/sdk/rust/src/bin/aip_mcp_gateway.rs +59 -0
  147. package/sdk/rust/src/http.rs +145 -0
  148. package/sdk/rust/src/keys.rs +161 -0
  149. package/sdk/rust/src/lib.rs +688 -0
  150. package/sdk/rust/src/queue.rs +79 -0
  151. package/sdk/rust/src/webhook.rs +86 -0
  152. package/sdk/rust/tests/conformance.rs +148 -0
  153. package/sdk/rust/tests/gateway.rs +130 -0
  154. package/sdk/rust/tests/http_webhook_queue.rs +201 -0
  155. package/sdk/rust/tests/keys.rs +189 -0
  156. package/sdk/threshold/index.d.ts +68 -0
  157. package/sdk/threshold/index.d.ts.map +1 -0
  158. package/sdk/threshold/index.js +254 -0
  159. package/sdk/threshold/package-lock.json +1361 -0
  160. package/sdk/threshold/package.json +39 -0
  161. package/sdk/threshold/src/index.d.ts +68 -0
  162. package/sdk/threshold/src/index.d.ts.map +1 -0
  163. package/sdk/threshold/src/index.js +254 -0
  164. package/sdk/threshold/src/index.test.ts +238 -0
  165. package/sdk/threshold/src/index.ts +355 -0
  166. package/sdk/threshold/tsconfig.json +19 -0
  167. package/sdk/threshold/vitest.config.ts +12 -0
  168. package/src/agentAdapter.test.ts +48 -0
  169. package/src/agentAdapter.ts +56 -0
  170. package/src/auditLog.test.ts +145 -0
  171. package/src/auditLog.ts +147 -0
  172. package/src/capability.test.ts +504 -0
  173. package/src/capability.ts +380 -0
  174. package/src/cborCodec.test.ts +263 -0
  175. package/src/cborCodec.ts +339 -0
  176. package/src/conformance.test.ts +136 -0
  177. package/src/conformanceVectors.ts +99 -0
  178. package/src/encryption.test.ts +206 -0
  179. package/src/encryption.ts +245 -0
  180. package/src/envelopeCbor.ts +140 -0
  181. package/src/frameworkAdapters.test.ts +290 -0
  182. package/src/frameworkAdapters.ts +261 -0
  183. package/src/gateway.test.ts +343 -0
  184. package/src/gateway.ts +246 -0
  185. package/src/grpcBinding.test.ts +211 -0
  186. package/src/grpcBinding.ts +103 -0
  187. package/src/httpBinding.test.ts +376 -0
  188. package/src/httpBinding.ts +189 -0
  189. package/src/index.ts +39 -0
  190. package/src/keyInfra.test.ts +278 -0
  191. package/src/keyInfra.ts +228 -0
  192. package/src/keyRegistry.ts +59 -0
  193. package/src/keyRotation.test.ts +78 -0
  194. package/src/keyRotation.ts +72 -0
  195. package/src/mcpGateway.test.ts +129 -0
  196. package/src/mcpGateway.ts +250 -0
  197. package/src/mcpTransports.test.ts +92 -0
  198. package/src/mcpTransports.ts +169 -0
  199. package/src/mcpWrapper.test.ts +179 -0
  200. package/src/mcpWrapper.ts +206 -0
  201. package/src/otel.ts +136 -0
  202. package/src/policyEnforcer.test.ts +99 -0
  203. package/src/policyEnforcer.ts +169 -0
  204. package/src/policyTelemetryFeedback.test.ts +25 -0
  205. package/src/policyTelemetryFeedback.ts +38 -0
  206. package/src/protocol.bench.ts +37 -0
  207. package/{protocol.d.ts → src/protocol.d.ts} +2 -1
  208. package/src/protocol.d.ts.map +1 -0
  209. package/src/protocol.js +294 -0
  210. package/src/protocol.test.ts +155 -0
  211. package/src/protocol.ts +414 -0
  212. package/src/protocolAgent.test.ts +105 -0
  213. package/src/protocolAgent.ts +169 -0
  214. package/src/protocolBinary.test.ts +165 -0
  215. package/src/protocolBinary.ts +312 -0
  216. package/src/protocolCapabilities.ts +70 -0
  217. package/src/protocolFuzz.advanced.test.ts +235 -0
  218. package/src/protocolFuzz.test.ts +111 -0
  219. package/src/protocolNegative.test.ts +97 -0
  220. package/src/protocolReplay.test.ts +71 -0
  221. package/src/protocolReplay.ts +194 -0
  222. package/src/protocolTransport.test.ts +556 -0
  223. package/src/protocolTransport.ts +483 -0
  224. package/src/queueBinding.test.ts +130 -0
  225. package/src/queueBinding.ts +102 -0
  226. package/src/rateLimiter.test.ts +96 -0
  227. package/src/rateLimiter.ts +46 -0
  228. package/src/redisClient.ts +140 -0
  229. package/src/redisIntegration.test.ts +134 -0
  230. package/src/replayStores.test.ts +273 -0
  231. package/src/replayStores.ts +215 -0
  232. package/src/revocation.test.ts +98 -0
  233. package/src/revocation.ts +0 -0
  234. package/src/routePolicy.test.ts +87 -0
  235. package/src/routePolicy.ts +72 -0
  236. package/src/runtimePolicy.test.ts +49 -0
  237. package/src/runtimePolicy.ts +81 -0
  238. package/src/runtimePolicyManager.test.ts +29 -0
  239. package/src/runtimePolicyManager.ts +50 -0
  240. package/src/runtimePolicyPresets.ts +43 -0
  241. package/src/signedResponse.test.ts +111 -0
  242. package/src/signedResponse.ts +83 -0
  243. package/src/stream.test.ts +254 -0
  244. package/src/stream.ts +417 -0
  245. package/src/telemetry.test.ts +251 -0
  246. package/src/telemetry.ts +299 -0
  247. package/src/webhookBinding.test.ts +144 -0
  248. package/src/webhookBinding.ts +115 -0
  249. package/src/wsBinding.test.ts +221 -0
  250. package/src/wsBinding.ts +200 -0
  251. package/tsconfig.json +15 -0
  252. package/tsconfig.lib.json +23 -0
  253. package/vite.lib.config.ts +16 -0
  254. package/vitest.config.ts +11 -0
  255. package/agentAdapter.d.ts +0 -26
  256. package/conformanceVectors.d.ts +0 -20
  257. package/frameworkAdapters.d.ts +0 -72
  258. package/index.d.ts +0 -20
  259. package/index.js +0 -1702
  260. package/keyRotation.d.ts +0 -20
  261. package/mcpGateway.d.ts +0 -37
  262. package/mcpTransports.d.ts +0 -62
  263. package/mcpWrapper.d.ts +0 -83
  264. package/policyEnforcer.d.ts +0 -50
  265. package/policyTelemetryFeedback.d.ts +0 -11
  266. package/protocolAgent.d.ts +0 -58
  267. package/protocolBinary.d.ts +0 -8
  268. package/protocolCapabilities.d.ts +0 -24
  269. package/protocolReplay.d.ts +0 -35
  270. package/protocolTransport.d.ts +0 -73
  271. package/redisClient.d.ts +0 -49
  272. package/replayStores.d.ts +0 -32
  273. package/revocation.d.ts +0 -71
  274. package/runtimePolicy.d.ts +0 -24
  275. package/runtimePolicyManager.d.ts +0 -15
  276. package/runtimePolicyPresets.d.ts +0 -11
@@ -0,0 +1,254 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ SignedStreamWriter,
4
+ SignedStreamReader,
5
+ createSignedStream,
6
+ createStreamVerifier,
7
+ signStream,
8
+ verifyStream,
9
+ encodeStreamChunk,
10
+ decodeStreamChunk,
11
+ type StreamChunk,
12
+ type StreamSignerOpts,
13
+ type StreamVerifierOpts,
14
+ } from './stream'
15
+ import { generateEd25519KeypairBase64Url } from './protocol'
16
+
17
+ // ─────────────────── helpers ───────────────────────────────────────────────────
18
+
19
+ async function makeKeyPair() {
20
+ return generateEd25519KeypairBase64Url()
21
+ }
22
+
23
+ function signerOpts(privateKey: string, nonce?: string): StreamSignerOpts {
24
+ return { privateKey, sender: 'test-agent', nonce: nonce ?? 'fixed-nonce-for-tests', keyId: 'test-key' }
25
+ }
26
+
27
+ function verifierOpts(publicKey: string): StreamVerifierOpts {
28
+ return { publicKey }
29
+ }
30
+
31
+ // ─────────────────── tests ────────────────────────────────────────────────────
32
+
33
+ describe('SignedStreamWriter + SignedStreamReader', () => {
34
+ it('test 1: 5-chunk round trip verifies', async () => {
35
+ const kp = await makeKeyPair()
36
+ const writer = new SignedStreamWriter(signerOpts(kp.privateKey))
37
+ const reader = new SignedStreamReader(verifierOpts(kp.publicKey))
38
+
39
+ const dataChunks = ['Hello ', 'World', ' from', ' 7h3', ' Protocol']
40
+ const frames: StreamChunk[] = []
41
+
42
+ for (const d of dataChunks) {
43
+ const frame = await writer.writeChunk(d)
44
+ expect(frame.f).toBe(false)
45
+ expect(frame.i).toBe(frames.length)
46
+ expect(typeof frame.h).toBe('string')
47
+ frames.push(frame)
48
+ }
49
+
50
+ // Feed non-final chunks to reader
51
+ for (const frame of frames) {
52
+ const res = await reader.receiveChunk(frame)
53
+ expect(res.ok).toBe(true)
54
+ }
55
+
56
+ const finalFrame = await writer.finalize()
57
+ expect(finalFrame.f).toBe(true)
58
+ expect(typeof finalFrame.sig).toBe('string')
59
+ expect(finalFrame.kid).toBe('test-key')
60
+
61
+ const result = await reader.finalize(finalFrame)
62
+ expect(result.ok).toBe(true)
63
+ if (result.ok) {
64
+ expect(result.chunkCount).toBe(5)
65
+ // totalBytes = sum of UTF-8 bytes of each data chunk
66
+ const expected = dataChunks.reduce((s, d) => s + new TextEncoder().encode(d).length, 0)
67
+ expect(result.totalBytes).toBe(expected)
68
+ }
69
+ })
70
+
71
+ it('test 2: single chunk round trip', async () => {
72
+ const kp = await makeKeyPair()
73
+ const writer = new SignedStreamWriter(signerOpts(kp.privateKey))
74
+ const reader = new SignedStreamReader(verifierOpts(kp.publicKey))
75
+
76
+ const frame = await writer.writeChunk('only one chunk')
77
+ const res = await reader.receiveChunk(frame)
78
+ expect(res.ok).toBe(true)
79
+
80
+ const finalFrame = await writer.finalize()
81
+ const result = await reader.finalize(finalFrame)
82
+ expect(result.ok).toBe(true)
83
+ if (result.ok) {
84
+ expect(result.chunkCount).toBe(1)
85
+ }
86
+ })
87
+
88
+ it('test 3: empty stream (just finalize) verifies', async () => {
89
+ const kp = await makeKeyPair()
90
+ const writer = new SignedStreamWriter(signerOpts(kp.privateKey))
91
+ const reader = new SignedStreamReader(verifierOpts(kp.publicKey))
92
+
93
+ const finalFrame = await writer.finalize()
94
+ expect(finalFrame.f).toBe(true)
95
+ expect(typeof finalFrame.sig).toBe('string')
96
+
97
+ const result = await reader.finalize(finalFrame)
98
+ expect(result.ok).toBe(true)
99
+ if (result.ok) {
100
+ expect(result.chunkCount).toBe(0)
101
+ expect(result.totalBytes).toBe(0)
102
+ }
103
+ })
104
+
105
+ it('test 4: tampered chunk data detected (HMAC fails on that chunk, Ed25519 fails at finalize)', async () => {
106
+ const kp = await makeKeyPair()
107
+ const writer = new SignedStreamWriter(signerOpts(kp.privateKey))
108
+ const reader = new SignedStreamReader(verifierOpts(kp.publicKey))
109
+
110
+ const frame0 = await writer.writeChunk('legit data')
111
+ const frame1 = await writer.writeChunk('also legit')
112
+ const finalFrame = await writer.finalize()
113
+
114
+ // Tamper with frame0's data
115
+ const tampered: StreamChunk = { ...frame0, d: 'TAMPERED' }
116
+
117
+ // Reader accepts the chunk (sequence is fine; reader can't verify HMAC without private key)
118
+ await reader.receiveChunk(tampered)
119
+ await reader.receiveChunk(frame1)
120
+
121
+ // But the final Ed25519 signature will fail because the content hash is different
122
+ const result = await reader.finalize(finalFrame)
123
+ expect(result.ok).toBe(false)
124
+ if (!result.ok) {
125
+ expect(result.reason).toMatch(/signature verification failed/i)
126
+ }
127
+ })
128
+
129
+ it('test 5: wrong public key: final Ed25519 fails', async () => {
130
+ const kp = await makeKeyPair()
131
+ const wrongKp = await makeKeyPair()
132
+
133
+ const writer = new SignedStreamWriter(signerOpts(kp.privateKey))
134
+ // Reader uses a DIFFERENT public key
135
+ const reader = new SignedStreamReader(verifierOpts(wrongKp.publicKey))
136
+
137
+ const frame = await writer.writeChunk('data')
138
+ await reader.receiveChunk(frame)
139
+
140
+ const finalFrame = await writer.finalize()
141
+ const result = await reader.finalize(finalFrame)
142
+ expect(result.ok).toBe(false)
143
+ if (!result.ok) {
144
+ expect(result.reason).toMatch(/signature verification failed/i)
145
+ }
146
+ })
147
+
148
+ it('test 6: out-of-order chunk: sequence error', async () => {
149
+ const kp = await makeKeyPair()
150
+ const writer = new SignedStreamWriter(signerOpts(kp.privateKey))
151
+ const reader = new SignedStreamReader(verifierOpts(kp.publicKey))
152
+
153
+ const frame0 = await writer.writeChunk('first')
154
+ const frame1 = await writer.writeChunk('second')
155
+
156
+ // Feed frame1 before frame0 (out of order — frame1 has i=1, reader expects i=0)
157
+ const resOoo = await reader.receiveChunk(frame1)
158
+ expect(resOoo.ok).toBe(false)
159
+ if (!resOoo.ok) {
160
+ expect(resOoo.reason).toMatch(/sequence error/i)
161
+ }
162
+
163
+ // Feed in yet another out-of-order delivery: feed frame0, then frame1 again — frame1 will be seen
164
+ // as seq=1 but reader has only accepted 0 so far (via the successful frame0), so now inject a
165
+ // duplicate of frame1 which would be at seq=1 yet again — that should be fine.
166
+ // More importantly: verify that the FIRST out-of-order was caught.
167
+ // The reader did NOT increment on failure, so frame0 (i=0) is now OK.
168
+ const resAfter = await reader.receiveChunk(frame0)
169
+ expect(resAfter.ok).toBe(true) // seq=0 is correct now (reader expected 0 after ooo failure)
170
+ })
171
+
172
+ it('test 7: signStream(array) + verifyStream round trip', async () => {
173
+ const kp = await makeKeyPair()
174
+ const data = ['chunk A', 'chunk B', 'chunk C']
175
+ const chunks = await signStream(data, signerOpts(kp.privateKey))
176
+
177
+ // signStream returns n+1 frames (n data + 1 final)
178
+ expect(chunks).toHaveLength(data.length + 1)
179
+ expect(chunks[chunks.length - 1].f).toBe(true)
180
+
181
+ const result = await verifyStream(chunks, verifierOpts(kp.publicKey))
182
+ expect(result.ok).toBe(true)
183
+ })
184
+
185
+ it('test 8: replay — same StreamChunk in a different stream fails (nonce in HMAC)', async () => {
186
+ const kp = await makeKeyPair()
187
+
188
+ // Stream A
189
+ const writerA = new SignedStreamWriter({ ...signerOpts(kp.privateKey), nonce: 'nonce-A' })
190
+ const chunkA0 = await writerA.writeChunk('data A')
191
+ const finalA = await writerA.finalize()
192
+
193
+ // Stream B — different nonce
194
+ const writerB = new SignedStreamWriter({ ...signerOpts(kp.privateKey), nonce: 'nonce-B' })
195
+ // Don't write any chunk to B, then produce its final frame
196
+ const finalB = await writerB.finalize()
197
+
198
+ // Reader for stream B expects B's content — inject A's chunk into B's reader
199
+ const readerB = new SignedStreamReader(verifierOpts(kp.publicKey))
200
+ // Feed chunk from stream A (which has content 'data A') and use B's finalFrame
201
+ await readerB.receiveChunk(chunkA0)
202
+ // finalB was computed over empty content — now we have chunkA0's data accumulated
203
+ const result = await readerB.finalize(finalB)
204
+ expect(result.ok).toBe(false)
205
+ if (!result.ok) {
206
+ expect(result.reason).toMatch(/signature verification failed/i)
207
+ }
208
+
209
+ // Stream A itself verifies fine
210
+ const readerA = new SignedStreamReader(verifierOpts(kp.publicKey))
211
+ await readerA.receiveChunk(chunkA0)
212
+ const resultA = await readerA.finalize(finalA)
213
+ expect(resultA.ok).toBe(true)
214
+ })
215
+
216
+ it('test 9: large stream — 1000 chunks verify in <500ms', async () => {
217
+ const kp = await makeKeyPair()
218
+ const chunks: string[] = Array.from({ length: 1000 }, (_, i) => `token-${i}`)
219
+
220
+ const start = Date.now()
221
+ const frames = await signStream(chunks, signerOpts(kp.privateKey))
222
+ const result = await verifyStream(frames, verifierOpts(kp.publicKey))
223
+ const elapsed = Date.now() - start
224
+
225
+ expect(result.ok).toBe(true)
226
+ expect(elapsed).toBeLessThan(500)
227
+ if (result.ok) {
228
+ expect(result.chunkCount).toBe(1000)
229
+ }
230
+ })
231
+ })
232
+
233
+ describe('encodeStreamChunk / decodeStreamChunk', () => {
234
+ it('round-trips a non-final chunk', () => {
235
+ const chunk: StreamChunk = { i: 3, d: 'hello', h: 'abc-def', f: false }
236
+ const encoded = encodeStreamChunk(chunk)
237
+ const decoded = decodeStreamChunk(encoded)
238
+ expect(decoded).toEqual(chunk)
239
+ })
240
+
241
+ it('round-trips a final chunk', () => {
242
+ const chunk: StreamChunk = { i: 10, d: '', h: 'xxx', f: true, sig: 'yyy', kid: 'mykey' }
243
+ expect(decodeStreamChunk(encodeStreamChunk(chunk))).toEqual(chunk)
244
+ })
245
+
246
+ it('throws on invalid JSON', () => {
247
+ expect(() => decodeStreamChunk('not json')).toThrow(/invalid JSON/i)
248
+ })
249
+
250
+ it('throws on missing required fields', () => {
251
+ expect(() => decodeStreamChunk('{"i":0,"d":"x"}')).toThrow(/missing required fields/i)
252
+ })
253
+ })
254
+
package/src/stream.ts ADDED
@@ -0,0 +1,417 @@
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
+ }