@7h3/protocol 0.4.0 → 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 (63) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +1169 -175
  3. package/bin/7h3.ts +22 -1
  4. package/docs/assets/banner-github.png +0 -0
  5. package/docs/assets/banner.svg +123 -0
  6. package/package.json +55 -13
  7. package/sdk/browser/package.json +1 -1
  8. package/sdk/go/cbor.go +551 -0
  9. package/sdk/go/cbor_test.go +232 -0
  10. package/sdk/go/encryption.go +280 -0
  11. package/sdk/go/encryption_test.go +318 -0
  12. package/sdk/go/go.mod +5 -1
  13. package/sdk/go/go.sum +4 -0
  14. package/sdk/go/replay.go +121 -0
  15. package/sdk/go/replay_test.go +149 -0
  16. package/sdk/pq/package-lock.json +1358 -0
  17. package/sdk/pq/package.json +42 -0
  18. package/sdk/pq/src/index.test.ts +143 -0
  19. package/sdk/pq/src/index.ts +166 -0
  20. package/sdk/pq/tsconfig.json +14 -0
  21. package/sdk/pq/vitest.config.ts +7 -0
  22. package/sdk/python/protocol_7h3/encryption.py +252 -0
  23. package/sdk/python/protocol_7h3/pq.py +244 -0
  24. package/sdk/python/protocol_7h3/replay.py +98 -0
  25. package/sdk/python/pyproject.toml +1 -1
  26. package/sdk/python/tests/test_encryption.py +206 -0
  27. package/sdk/rust/Cargo.lock +1 -1
  28. package/sdk/rust/Cargo.toml +1 -1
  29. package/sdk/threshold/index.d.ts +68 -0
  30. package/sdk/threshold/index.d.ts.map +1 -0
  31. package/sdk/threshold/index.js +254 -0
  32. package/sdk/threshold/package-lock.json +1361 -0
  33. package/sdk/threshold/package.json +39 -0
  34. package/sdk/threshold/src/index.d.ts +68 -0
  35. package/sdk/threshold/src/index.d.ts.map +1 -0
  36. package/sdk/threshold/src/index.js +254 -0
  37. package/sdk/threshold/src/index.test.ts +238 -0
  38. package/sdk/threshold/src/index.ts +355 -0
  39. package/sdk/threshold/tsconfig.json +19 -0
  40. package/sdk/threshold/vitest.config.ts +12 -0
  41. package/src/capability.test.ts +504 -0
  42. package/src/capability.ts +380 -0
  43. package/src/cborCodec.test.ts +263 -0
  44. package/src/cborCodec.ts +339 -0
  45. package/src/encryption.test.ts +206 -0
  46. package/src/encryption.ts +245 -0
  47. package/src/envelopeCbor.ts +140 -0
  48. package/src/gateway.ts +75 -0
  49. package/src/httpBinding.ts +37 -11
  50. package/src/index.ts +7 -0
  51. package/src/otel.ts +136 -0
  52. package/src/protocol.d.ts +67 -0
  53. package/src/protocol.d.ts.map +1 -0
  54. package/src/protocol.js +294 -0
  55. package/src/protocol.ts +1 -0
  56. package/src/replayStores.test.ts +133 -1
  57. package/src/replayStores.ts +136 -3
  58. package/src/stream.test.ts +254 -0
  59. package/src/stream.ts +417 -0
  60. package/src/telemetry.test.ts +251 -0
  61. package/src/telemetry.ts +299 -0
  62. package/src/wsBinding.ts +100 -0
  63. package/vitest.config.ts +11 -0
@@ -1,6 +1,137 @@
1
1
  import type { DistributedReplayStore } from './protocolReplay'
2
2
  import { InMemoryRedisLikeClient, type RedisLikeClient } from './redisClient'
3
3
 
4
+ // ---------------------------------------------------------------------------
5
+ // ReplayStore — high-level check() interface (returns true = REPLAY)
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /**
9
+ * Minimal Redis client surface required by RedisReplayStore.
10
+ *
11
+ * Distinct from the internal RedisLikeClient: this interface uses a `quit()`
12
+ * lifecycle method and does not require pipeline support, making it easy to
13
+ * adapt any Redis client (ioredis, node-redis, Upstash, etc.) without coupling
14
+ * to the internal pipeline abstraction.
15
+ */
16
+ export interface RedisClientLike {
17
+ set(key: string, value: string, opts?: { nx?: boolean; px?: number }): Promise<string | null>
18
+ get(key: string): Promise<string | null>
19
+ del(key: string): Promise<number | any>
20
+ quit(): Promise<any>
21
+ }
22
+
23
+ /**
24
+ * Gateway-facing replay store interface.
25
+ *
26
+ * `check()` atomically marks a key as seen and returns whether it was already
27
+ * present: `false` = fresh (first time seen), `true` = replay (already seen).
28
+ */
29
+ export interface ReplayStore {
30
+ check(key: string, ttlMs: number): Promise<boolean>
31
+ }
32
+
33
+ /**
34
+ * Redis-backed implementation of {@link ReplayStore}.
35
+ *
36
+ * Uses SET … NX PX for an atomic "set if not exists with TTL" operation.
37
+ * The operation is single-round-trip and safe under concurrent access from
38
+ * multiple gateway instances.
39
+ */
40
+ export class RedisReplayStore implements ReplayStore {
41
+ private readonly keyPrefix: string
42
+ private readonly client: RedisClientLike
43
+
44
+ constructor(opts: { redisUrl?: string; keyPrefix?: string; client?: RedisClientLike } = {}) {
45
+ this.keyPrefix = opts.keyPrefix ?? '7h3:nonce:'
46
+ if (opts.client) {
47
+ this.client = opts.client
48
+ } else {
49
+ // Lazily adapt InMemoryRedisLikeClient to RedisClientLike for zero-dep default
50
+ const inner = new InMemoryRedisLikeClient()
51
+ this.client = {
52
+ set: (key, value, setOpts) =>
53
+ inner.set(key, value, { nx: setOpts?.nx, pxMs: setOpts?.px }),
54
+ get: (key) => inner.get ? inner.get(key) : Promise.resolve(null),
55
+ del: (key) => inner.del ? inner.del(key) : Promise.resolve(0),
56
+ quit: () => Promise.resolve(),
57
+ }
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Returns `false` if the key is fresh (first time seen — key was set in Redis),
63
+ * or `true` if the key is a replay (key already existed — SET NX returned null).
64
+ */
65
+ async check(key: string, ttlMs: number): Promise<boolean> {
66
+ const redisKey = `${this.keyPrefix}${key}`
67
+ const result = await this.client.set(redisKey, '1', { nx: true, px: Math.max(1, ttlMs) })
68
+ // SET NX returns 'OK' when the key was newly set (fresh), null when already present (replay)
69
+ return result === null
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Cluster-aware replay store that wraps multiple {@link RedisReplayStore}
75
+ * instances — one per Redis shard / node.
76
+ *
77
+ * A message is treated as a replay if ANY node has already seen the nonce.
78
+ * All nodes are checked in parallel via Promise.all.
79
+ */
80
+ export class ClusterRedisReplayStore implements ReplayStore {
81
+ private readonly nodes: RedisReplayStore[]
82
+
83
+ constructor(nodes: RedisReplayStore[]) {
84
+ this.nodes = nodes
85
+ }
86
+
87
+ async check(key: string, ttlMs: number): Promise<boolean> {
88
+ const results = await Promise.all(this.nodes.map((node) => node.check(key, ttlMs)))
89
+ // If any node reports replay (true), the request is a replay
90
+ return results.some((seen) => seen)
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Convenience factory that creates a {@link RedisReplayStore} from a URL
96
+ * or options object.
97
+ */
98
+ export function createRedisReplayStore(
99
+ clientOrOpts: RedisLikeClient | { redisUrl?: string; keyPrefix?: string; client?: RedisClientLike },
100
+ options?: RedisReplayStoreOptions,
101
+ ): DistributedReplayStore
102
+
103
+ export function createRedisReplayStore(
104
+ clientOrOpts: RedisLikeClient | { redisUrl?: string; keyPrefix?: string; client?: RedisClientLike },
105
+ options: RedisReplayStoreOptions = {},
106
+ ): DistributedReplayStore | RedisReplayStore {
107
+ // Detect old (client-first) vs new (opts-object) call style
108
+ if (isRedisLikeClient(clientOrOpts)) {
109
+ return _createDistributedReplayStore(clientOrOpts, options)
110
+ }
111
+ // New style: return a RedisReplayStore instance
112
+ return new RedisReplayStore(clientOrOpts as { redisUrl?: string; keyPrefix?: string; client?: RedisClientLike })
113
+ }
114
+
115
+ function isRedisLikeClient(val: unknown): val is RedisLikeClient {
116
+ return (
117
+ val !== null &&
118
+ typeof val === 'object' &&
119
+ typeof (val as any).set === 'function' &&
120
+ // RedisLikeClient does NOT have a `quit` method — RedisClientLike does
121
+ // Pipeline presence distinguishes internal RedisLikeClient
122
+ (typeof (val as any).pipeline === 'function' || !('quit' in val))
123
+ )
124
+ }
125
+
126
+ /**
127
+ * Creates a {@link ClusterRedisReplayStore} from an array of Redis URLs.
128
+ * Each URL is used to create an independent {@link RedisReplayStore} node.
129
+ */
130
+ export function createClusterReplayStore(redisUrls: string[]): ClusterRedisReplayStore {
131
+ const nodes = redisUrls.map((url) => new RedisReplayStore({ redisUrl: url }))
132
+ return new ClusterRedisReplayStore(nodes)
133
+ }
134
+
4
135
  /**
5
136
  * What to do when the Redis client throws (network blip, server down).
6
137
  *
@@ -24,12 +155,14 @@ export interface RedisReplayStoreOptions {
24
155
  }
25
156
 
26
157
  /**
27
- * Creates a {@link DistributedReplayStore} backed by Redis-style `SET NX PX`.
158
+ * Internal factory — creates a {@link DistributedReplayStore} backed by
159
+ * Redis-style `SET NX PX`. Used by the overloaded `createRedisReplayStore`
160
+ * when a `RedisLikeClient` is passed as the first argument.
28
161
  *
29
162
  * Drop the returned store into `new DistributedReplayCache(store)` (or pass a
30
163
  * `DistributedReplayCache` as the transport `replayCache`).
31
164
  */
32
- export function createRedisReplayStore(
165
+ function _createDistributedReplayStore(
33
166
  client: RedisLikeClient,
34
167
  options: RedisReplayStoreOptions = {},
35
168
  ): DistributedReplayStore {
@@ -41,7 +174,7 @@ export function createRedisReplayStore(
41
174
  (errorBehavior === 'fallback'
42
175
  ? // The local fallback runs over a non-throwing in-memory client, so it
43
176
  // needs no fallback of its own — `reject` terminates the chain.
44
- createRedisReplayStore(new InMemoryRedisLikeClient(), { keyPrefix, errorBehavior: 'reject' })
177
+ _createDistributedReplayStore(new InMemoryRedisLikeClient(), { keyPrefix, errorBehavior: 'reject' })
45
178
  : undefined)
46
179
 
47
180
  function degrade(error: unknown, key: string, expiresAtMs: number, nowMs: number): Promise<boolean> | boolean {
@@ -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
+