@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.
- package/.dockerignore +19 -0
- package/.github/dependabot.yml +32 -0
- package/.github/workflows/ci.yml +31 -0
- package/.github/workflows/publish.yml +59 -0
- package/.github/workflows/scorecard.yml +37 -0
- package/7h3.example.yaml +125 -0
- package/CHANGELOG.md +152 -0
- package/CONTRIBUTING.md +82 -0
- package/Dockerfile +73 -0
- package/GOVERNANCE.md +62 -0
- package/README.md +1317 -6
- package/SECURITY.md +70 -0
- package/bench-results/replay-cache-full-1777891033256.json +10 -0
- package/bench-results/replay-cache-full-1777896317488.json +10 -0
- package/bench-results/replay-cache-full-1777900993184.json +10 -0
- package/bench-results/replay-cache-full-1777901019285.json +10 -0
- package/bench-results/replay-cache-quick-1777870170126.json +10 -0
- package/bench-results/signature-profiles-quick-1775875160079.json +85 -0
- package/bench-results/signature-profiles-quick-1775983539716.json +85 -0
- package/bench-results/signature-profiles-quick-1776237913190.json +85 -0
- package/bench-results/wire-codecs-full-1777891019803.json +93 -0
- package/bench-results/wire-codecs-full-1777896260964.json +93 -0
- package/bench-results/wire-codecs-full-1777901004247.json +93 -0
- package/bench-results/wire-codecs-quick-1775972879056.json +93 -0
- package/bench-results/wire-codecs-quick-1775983541111.json +93 -0
- package/bench-results/wire-codecs-quick-1776237914299.json +93 -0
- package/bench-results/wire-codecs-quick-1777841285236.json +93 -0
- package/bench-results/wire-codecs-quick-1777841321772.json +93 -0
- package/bench-results/wire-codecs-quick-1777841330408.json +93 -0
- package/bench-results/wire-codecs-quick-1777852886082.json +93 -0
- package/bench-results/wire-codecs-quick-1777852988773.json +93 -0
- package/bench-results/wire-codecs-quick-1777870188095.json +93 -0
- package/bench-results/wire-codecs-quick-1777870263918.json +93 -0
- package/bench-results/wire-codecs-quick-1777870455034.json +93 -0
- package/bench-results/wire-codecs-quick-1778816163081.json +93 -0
- package/bench-results/wire-codecs-quick-1778843936130.json +93 -0
- package/bin/7h3.ts +406 -0
- package/conformance/7h3_v0_1.json +77 -0
- package/conformance/7h3_v0_1_binary.json +20 -0
- package/conformance/aip_v0_1_binary.json +20 -0
- package/docker-compose.yaml +77 -0
- package/docs/ADOPTION_PLAN.md +120 -0
- package/docs/AGENTS.md +77 -0
- package/docs/AIP_RFC_v0.1.md +97 -0
- package/docs/AI_DECISION_CARD.md +122 -0
- package/docs/AI_RUNTIME_POLICY.json +126 -0
- package/docs/AI_RUNTIME_POLICY.yaml +110 -0
- package/docs/BACKPRESSURE_TUNING.md +65 -0
- package/docs/BENCHMARK_CLAIM_MATRIX.md +42 -0
- package/docs/BENCHMARK_REPORT_TEMPLATE.md +169 -0
- package/docs/BINARY_CODEC_BENCH.md +23 -0
- package/docs/CLEAN_CLONE_RUNBOOK.md +36 -0
- package/docs/CLOCK_SKEW_POLICY.md +30 -0
- package/docs/DISTRIBUTED_REPLAY.md +142 -0
- package/docs/FUZZ_CAMPAIGN.md +121 -0
- package/docs/GATEWAY.md +195 -0
- package/docs/KEY_MANAGEMENT_POLICY.md +53 -0
- package/docs/KEY_REVOCATION.md +69 -0
- package/docs/MCP_WRAPPER.md +159 -0
- package/docs/MIGRATION_GUIDE.md +40 -0
- package/docs/OPERATORS.md +184 -0
- package/docs/PERF_REGRESSION_POLICY.md +34 -0
- package/docs/PROJECT_EXAMINATION_2026-05-31.md +219 -0
- package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +135 -0
- package/docs/RELEASE_GATE.md +25 -0
- package/docs/RELEASE_NOTES_v0.1.0.md +54 -0
- package/docs/SECURITY_REVIEW_2026-06-05.md +165 -0
- package/docs/TELEMETRY.md +41 -0
- package/docs/THREAT_MODEL.md +89 -0
- package/docs/VERSIONING_POLICY.md +30 -0
- package/docs/assets/banner-github.png +0 -0
- package/docs/assets/banner.png +0 -0
- package/docs/assets/banner.svg +123 -0
- package/eslint.config.js +15 -0
- package/fuzz/ts/harness-decode.ts +136 -0
- package/fuzz/ts/harness-verify.ts +121 -0
- package/fuzz/ts/run.ts +35 -0
- package/mcp-server/README.md +38 -0
- package/mcp-server/package-lock.json +1187 -0
- package/mcp-server/package.json +35 -0
- package/mcp-server/src/index.ts +236 -0
- package/mcp-server/tsconfig.json +14 -0
- package/package.json +121 -13
- package/scripts/aip-framework-quickstart.ts +110 -0
- package/scripts/aip-mcp-gateway.ts +38 -0
- package/scripts/aip-mcp-wrap-demo.ts +72 -0
- package/scripts/aip-quickstart.ts +60 -0
- package/scripts/bench-diff.ts +118 -0
- package/scripts/bench-protocol-e2e.ts +937 -0
- package/scripts/bench-protocol-openloop.ts +1397 -0
- package/scripts/bench-replay-cache.ts +76 -0
- package/scripts/bench-signature-profiles.ts +180 -0
- package/scripts/bench-wire-codecs.ts +161 -0
- package/scripts/build-binary-conformance.ts +36 -0
- package/scripts/build-release-dashboard.ts +175 -0
- package/scripts/canary-rollout.ts +38 -0
- package/scripts/mcpGatewayCli.test.ts +116 -0
- package/scripts/prepare-aip-package.ts +88 -0
- package/scripts/regen-conformance-sigs.ts +18 -0
- package/scripts/release-gate.ts +19 -0
- package/scripts/validate-runtime-policy.ts +18 -0
- package/sdk/browser/index.test.ts +162 -0
- package/sdk/browser/index.ts +257 -0
- package/sdk/browser/package.json +13 -0
- 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 +7 -0
- package/sdk/go/go.sum +4 -0
- package/sdk/go/http.go +135 -0
- package/sdk/go/protocol.go +324 -0
- package/sdk/go/protocol_test.go +334 -0
- package/sdk/go/replay.go +121 -0
- package/sdk/go/replay_test.go +149 -0
- package/sdk/go/webhook.go +136 -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/README.md +18 -0
- package/sdk/python/protocol_7h3/__init__.py +46 -0
- package/sdk/python/protocol_7h3/encryption.py +252 -0
- package/sdk/python/protocol_7h3/http.py +212 -0
- package/sdk/python/protocol_7h3/keys.py +149 -0
- package/sdk/python/protocol_7h3/pq.py +244 -0
- package/sdk/python/protocol_7h3/protocol.py +525 -0
- package/sdk/python/protocol_7h3/queue.py +118 -0
- package/sdk/python/protocol_7h3/replay.py +98 -0
- package/sdk/python/protocol_7h3/webhook.py +116 -0
- package/sdk/python/pyproject.toml +40 -0
- package/sdk/python/tests/test_conformance.py +110 -0
- package/sdk/python/tests/test_encryption.py +206 -0
- package/sdk/python/tests/test_http.py +305 -0
- package/sdk/python/tests/test_keys.py +417 -0
- package/sdk/python/tests/test_queue.py +120 -0
- package/sdk/python/tests/test_webhook.py +345 -0
- package/sdk/rust/Cargo.lock +371 -0
- package/sdk/rust/Cargo.toml +25 -0
- package/sdk/rust/README.md +31 -0
- package/sdk/rust/fuzz/Cargo.toml +29 -0
- package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +46 -0
- package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +11 -0
- package/sdk/rust/src/bin/aip_mcp_gateway.rs +59 -0
- package/sdk/rust/src/http.rs +145 -0
- package/sdk/rust/src/keys.rs +161 -0
- package/sdk/rust/src/lib.rs +688 -0
- package/sdk/rust/src/queue.rs +79 -0
- package/sdk/rust/src/webhook.rs +86 -0
- package/sdk/rust/tests/conformance.rs +148 -0
- package/sdk/rust/tests/gateway.rs +130 -0
- package/sdk/rust/tests/http_webhook_queue.rs +201 -0
- package/sdk/rust/tests/keys.rs +189 -0
- 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/agentAdapter.test.ts +48 -0
- package/src/agentAdapter.ts +56 -0
- package/src/auditLog.test.ts +145 -0
- package/src/auditLog.ts +147 -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/conformance.test.ts +136 -0
- package/src/conformanceVectors.ts +99 -0
- package/src/encryption.test.ts +206 -0
- package/src/encryption.ts +245 -0
- package/src/envelopeCbor.ts +140 -0
- package/src/frameworkAdapters.test.ts +290 -0
- package/src/frameworkAdapters.ts +261 -0
- package/src/gateway.test.ts +343 -0
- package/src/gateway.ts +246 -0
- package/src/grpcBinding.test.ts +211 -0
- package/src/grpcBinding.ts +103 -0
- package/src/httpBinding.test.ts +376 -0
- package/src/httpBinding.ts +189 -0
- package/src/index.ts +39 -0
- package/src/keyInfra.test.ts +278 -0
- package/src/keyInfra.ts +228 -0
- package/src/keyRegistry.ts +59 -0
- package/src/keyRotation.test.ts +78 -0
- package/src/keyRotation.ts +72 -0
- package/src/mcpGateway.test.ts +129 -0
- package/src/mcpGateway.ts +250 -0
- package/src/mcpTransports.test.ts +92 -0
- package/src/mcpTransports.ts +169 -0
- package/src/mcpWrapper.test.ts +179 -0
- package/src/mcpWrapper.ts +206 -0
- package/src/otel.ts +136 -0
- package/src/policyEnforcer.test.ts +99 -0
- package/src/policyEnforcer.ts +169 -0
- package/src/policyTelemetryFeedback.test.ts +25 -0
- package/src/policyTelemetryFeedback.ts +38 -0
- package/src/protocol.bench.ts +37 -0
- package/{protocol.d.ts → src/protocol.d.ts} +2 -1
- package/src/protocol.d.ts.map +1 -0
- package/src/protocol.js +294 -0
- package/src/protocol.test.ts +155 -0
- package/src/protocol.ts +414 -0
- package/src/protocolAgent.test.ts +105 -0
- package/src/protocolAgent.ts +169 -0
- package/src/protocolBinary.test.ts +165 -0
- package/src/protocolBinary.ts +312 -0
- package/src/protocolCapabilities.ts +70 -0
- package/src/protocolFuzz.advanced.test.ts +235 -0
- package/src/protocolFuzz.test.ts +111 -0
- package/src/protocolNegative.test.ts +97 -0
- package/src/protocolReplay.test.ts +71 -0
- package/src/protocolReplay.ts +194 -0
- package/src/protocolTransport.test.ts +556 -0
- package/src/protocolTransport.ts +483 -0
- package/src/queueBinding.test.ts +130 -0
- package/src/queueBinding.ts +102 -0
- package/src/rateLimiter.test.ts +96 -0
- package/src/rateLimiter.ts +46 -0
- package/src/redisClient.ts +140 -0
- package/src/redisIntegration.test.ts +134 -0
- package/src/replayStores.test.ts +273 -0
- package/src/replayStores.ts +215 -0
- package/src/revocation.test.ts +98 -0
- package/src/revocation.ts +0 -0
- package/src/routePolicy.test.ts +87 -0
- package/src/routePolicy.ts +72 -0
- package/src/runtimePolicy.test.ts +49 -0
- package/src/runtimePolicy.ts +81 -0
- package/src/runtimePolicyManager.test.ts +29 -0
- package/src/runtimePolicyManager.ts +50 -0
- package/src/runtimePolicyPresets.ts +43 -0
- package/src/signedResponse.test.ts +111 -0
- package/src/signedResponse.ts +83 -0
- 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/webhookBinding.test.ts +144 -0
- package/src/webhookBinding.ts +115 -0
- package/src/wsBinding.test.ts +221 -0
- package/src/wsBinding.ts +200 -0
- package/tsconfig.json +15 -0
- package/tsconfig.lib.json +23 -0
- package/vite.lib.config.ts +16 -0
- package/vitest.config.ts +11 -0
- package/agentAdapter.d.ts +0 -26
- package/conformanceVectors.d.ts +0 -20
- package/frameworkAdapters.d.ts +0 -72
- package/index.d.ts +0 -20
- package/index.js +0 -1702
- package/keyRotation.d.ts +0 -20
- package/mcpGateway.d.ts +0 -37
- package/mcpTransports.d.ts +0 -62
- package/mcpWrapper.d.ts +0 -83
- package/policyEnforcer.d.ts +0 -50
- package/policyTelemetryFeedback.d.ts +0 -11
- package/protocolAgent.d.ts +0 -58
- package/protocolBinary.d.ts +0 -8
- package/protocolCapabilities.d.ts +0 -24
- package/protocolReplay.d.ts +0 -35
- package/protocolTransport.d.ts +0 -73
- package/redisClient.d.ts +0 -49
- package/replayStores.d.ts +0 -32
- package/revocation.d.ts +0 -71
- package/runtimePolicy.d.ts +0 -24
- package/runtimePolicyManager.d.ts +0 -15
- 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
|
+
}
|