@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.
- package/CHANGELOG.md +60 -0
- package/README.md +1169 -175
- package/bin/7h3.ts +22 -1
- package/docs/assets/banner-github.png +0 -0
- package/docs/assets/banner.svg +123 -0
- package/package.json +55 -13
- package/sdk/browser/package.json +1 -1
- package/sdk/go/cbor.go +551 -0
- package/sdk/go/cbor_test.go +232 -0
- package/sdk/go/encryption.go +280 -0
- package/sdk/go/encryption_test.go +318 -0
- package/sdk/go/go.mod +5 -1
- package/sdk/go/go.sum +4 -0
- package/sdk/go/replay.go +121 -0
- package/sdk/go/replay_test.go +149 -0
- package/sdk/pq/package-lock.json +1358 -0
- package/sdk/pq/package.json +42 -0
- package/sdk/pq/src/index.test.ts +143 -0
- package/sdk/pq/src/index.ts +166 -0
- package/sdk/pq/tsconfig.json +14 -0
- package/sdk/pq/vitest.config.ts +7 -0
- package/sdk/python/protocol_7h3/encryption.py +252 -0
- package/sdk/python/protocol_7h3/pq.py +244 -0
- package/sdk/python/protocol_7h3/replay.py +98 -0
- package/sdk/python/pyproject.toml +1 -1
- package/sdk/python/tests/test_encryption.py +206 -0
- package/sdk/rust/Cargo.lock +1 -1
- package/sdk/rust/Cargo.toml +1 -1
- package/sdk/threshold/index.d.ts +68 -0
- package/sdk/threshold/index.d.ts.map +1 -0
- package/sdk/threshold/index.js +254 -0
- package/sdk/threshold/package-lock.json +1361 -0
- package/sdk/threshold/package.json +39 -0
- package/sdk/threshold/src/index.d.ts +68 -0
- package/sdk/threshold/src/index.d.ts.map +1 -0
- package/sdk/threshold/src/index.js +254 -0
- package/sdk/threshold/src/index.test.ts +238 -0
- package/sdk/threshold/src/index.ts +355 -0
- package/sdk/threshold/tsconfig.json +19 -0
- package/sdk/threshold/vitest.config.ts +12 -0
- package/src/capability.test.ts +504 -0
- package/src/capability.ts +380 -0
- package/src/cborCodec.test.ts +263 -0
- package/src/cborCodec.ts +339 -0
- package/src/encryption.test.ts +206 -0
- package/src/encryption.ts +245 -0
- package/src/envelopeCbor.ts +140 -0
- package/src/gateway.ts +75 -0
- package/src/httpBinding.ts +37 -11
- package/src/index.ts +7 -0
- package/src/otel.ts +136 -0
- package/src/protocol.d.ts +67 -0
- package/src/protocol.d.ts.map +1 -0
- package/src/protocol.js +294 -0
- package/src/protocol.ts +1 -0
- package/src/replayStores.test.ts +133 -1
- package/src/replayStores.ts +136 -3
- package/src/stream.test.ts +254 -0
- package/src/stream.ts +417 -0
- package/src/telemetry.test.ts +251 -0
- package/src/telemetry.ts +299 -0
- package/src/wsBinding.ts +100 -0
- package/vitest.config.ts +11 -0
package/src/replayStores.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
|