@7h3/protocol 0.1.2 → 0.4.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 +92 -0
- package/CONTRIBUTING.md +82 -0
- package/Dockerfile +73 -0
- package/GOVERNANCE.md +62 -0
- package/README.md +323 -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 +385 -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.png +0 -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 +79 -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/go.mod +3 -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/webhook.go +136 -0
- package/sdk/python/README.md +18 -0
- package/sdk/python/protocol_7h3/__init__.py +46 -0
- package/sdk/python/protocol_7h3/http.py +212 -0
- package/sdk/python/protocol_7h3/keys.py +149 -0
- package/sdk/python/protocol_7h3/protocol.py +525 -0
- package/sdk/python/protocol_7h3/queue.py +118 -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_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/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/conformance.test.ts +136 -0
- package/src/conformanceVectors.ts +99 -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 +171 -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 +163 -0
- package/src/index.ts +32 -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/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/src/protocol.test.ts +155 -0
- package/src/protocol.ts +413 -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 +141 -0
- package/src/replayStores.ts +82 -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/webhookBinding.test.ts +144 -0
- package/src/webhookBinding.ts +115 -0
- package/src/wsBinding.test.ts +221 -0
- package/src/wsBinding.ts +100 -0
- package/tsconfig.json +15 -0
- package/tsconfig.lib.json +23 -0
- package/vite.lib.config.ts +16 -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/protocol.d.ts +0 -66
- 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,483 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEnvelope,
|
|
3
|
+
signEnvelopeHmac,
|
|
4
|
+
validateEnvelope,
|
|
5
|
+
canonicalizeEnvelope,
|
|
6
|
+
verifyCanonicalPayloadSignature,
|
|
7
|
+
type SignatureVerificationMaterial,
|
|
8
|
+
type IntentKind,
|
|
9
|
+
type ProtocolDiagnostic,
|
|
10
|
+
type ProtocolEnvelope,
|
|
11
|
+
} from './protocol'
|
|
12
|
+
import { decodeEnvelopeBinary, encodeEnvelopeBinary } from './protocolBinary'
|
|
13
|
+
import { InMemoryReplayCache, type ReplayCache } from './protocolReplay'
|
|
14
|
+
|
|
15
|
+
export type WireFormat = 'json' | 'compact' | 'binary'
|
|
16
|
+
export type BatchWireFormat = Exclude<WireFormat, 'binary'>
|
|
17
|
+
|
|
18
|
+
interface CompactSignature {
|
|
19
|
+
a?: 'HS256' | 'ED25519'
|
|
20
|
+
k: string
|
|
21
|
+
v: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CompactEnvelope {
|
|
25
|
+
v: '7h3/0.1'
|
|
26
|
+
mid: string
|
|
27
|
+
ts: number
|
|
28
|
+
ttl: number
|
|
29
|
+
s: string
|
|
30
|
+
n: string
|
|
31
|
+
i: IntentKind
|
|
32
|
+
c: string
|
|
33
|
+
r?: string
|
|
34
|
+
cap?: string
|
|
35
|
+
cid?: string
|
|
36
|
+
sig?: CompactSignature
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ReceiveEnvelopeOptions {
|
|
40
|
+
nowMs?: number
|
|
41
|
+
requireSignature?: boolean
|
|
42
|
+
maxClockSkewMs?: number
|
|
43
|
+
replayCache?: ReplayCache
|
|
44
|
+
verificationMaterialCache?: VerificationMaterialCache
|
|
45
|
+
secretResolver?: (keyId: string, sender: string) => string | undefined | Promise<string | undefined>
|
|
46
|
+
signatureResolver?: (
|
|
47
|
+
signature: NonNullable<ProtocolEnvelope['signature']>,
|
|
48
|
+
sender: string,
|
|
49
|
+
) => SignatureVerificationMaterial | undefined | Promise<SignatureVerificationMaterial | undefined>
|
|
50
|
+
telemetry?: (event: TransportTelemetryEvent) => void | Promise<void>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ReceiveEnvelopeBatchOptions extends ReceiveEnvelopeOptions {
|
|
54
|
+
batchConcurrency?: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface TransportTelemetryEvent {
|
|
58
|
+
phase:
|
|
59
|
+
| 'decoded'
|
|
60
|
+
| 'rejected_clock_skew'
|
|
61
|
+
| 'rejected_validation'
|
|
62
|
+
| 'rejected_replay'
|
|
63
|
+
| 'rejected_missing_signature'
|
|
64
|
+
| 'rejected_missing_material'
|
|
65
|
+
| 'rejected_bad_signature'
|
|
66
|
+
| 'batch_summary'
|
|
67
|
+
| 'accepted'
|
|
68
|
+
nowMs: number
|
|
69
|
+
sender?: string
|
|
70
|
+
messageId?: string
|
|
71
|
+
reason?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ReceiveEnvelopeResult {
|
|
75
|
+
ok: boolean
|
|
76
|
+
diagnostics: ProtocolDiagnostic[]
|
|
77
|
+
envelope: ProtocolEnvelope | null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface VerificationMaterialCache {
|
|
81
|
+
get(key: string, nowMs?: number): SignatureVerificationMaterial | undefined
|
|
82
|
+
set(key: string, material: SignatureVerificationMaterial, expiresAtMs: number): void
|
|
83
|
+
delete?(key: string): void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class InMemoryVerificationMaterialCache implements VerificationMaterialCache {
|
|
87
|
+
private readonly entries = new Map<string, { expiresAtMs: number; material: SignatureVerificationMaterial }>()
|
|
88
|
+
|
|
89
|
+
private readonly maxEntries: number
|
|
90
|
+
|
|
91
|
+
constructor(maxEntries = 512) {
|
|
92
|
+
this.maxEntries = maxEntries
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
get(key: string, nowMs = Date.now()): SignatureVerificationMaterial | undefined {
|
|
96
|
+
const entry = this.entries.get(key)
|
|
97
|
+
if (!entry) return undefined
|
|
98
|
+
if (entry.expiresAtMs <= nowMs) {
|
|
99
|
+
this.entries.delete(key)
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
return entry.material
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
set(key: string, material: SignatureVerificationMaterial, expiresAtMs: number): void {
|
|
106
|
+
if (this.entries.size >= this.maxEntries) {
|
|
107
|
+
const oldest = this.entries.keys().next().value as string | undefined
|
|
108
|
+
if (oldest) this.entries.delete(oldest)
|
|
109
|
+
}
|
|
110
|
+
this.entries.set(key, { expiresAtMs, material })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
delete(key: string): void {
|
|
114
|
+
this.entries.delete(key)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function verificationMaterialCacheKey(signature: NonNullable<ProtocolEnvelope['signature']>, sender: string): string {
|
|
119
|
+
return `${sender}|${signature.alg}|${signature.keyId}`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isProtocolEnvelope(value: unknown): value is ProtocolEnvelope {
|
|
123
|
+
if (!value || typeof value !== 'object') return false
|
|
124
|
+
const candidate = value as Partial<ProtocolEnvelope>
|
|
125
|
+
return Boolean(candidate.header && candidate.body)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isCompactEnvelope(value: unknown): value is CompactEnvelope {
|
|
129
|
+
if (!value || typeof value !== 'object') return false
|
|
130
|
+
const candidate = value as Partial<CompactEnvelope>
|
|
131
|
+
return candidate.v === '7h3/0.1' && typeof candidate.mid === 'string' && typeof candidate.i === 'string'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function compactToEnvelope(compact: CompactEnvelope): ProtocolEnvelope {
|
|
135
|
+
return {
|
|
136
|
+
header: {
|
|
137
|
+
version: compact.v,
|
|
138
|
+
messageId: compact.mid,
|
|
139
|
+
timestampMs: compact.ts,
|
|
140
|
+
ttlMs: compact.ttl,
|
|
141
|
+
sender: compact.s,
|
|
142
|
+
recipient: compact.r,
|
|
143
|
+
nonce: compact.n,
|
|
144
|
+
},
|
|
145
|
+
body: {
|
|
146
|
+
intent: compact.i,
|
|
147
|
+
content: compact.c,
|
|
148
|
+
capability: compact.cap,
|
|
149
|
+
correlationId: compact.cid,
|
|
150
|
+
},
|
|
151
|
+
signature: compact.sig
|
|
152
|
+
? {
|
|
153
|
+
alg: compact.sig.a ?? 'HS256',
|
|
154
|
+
keyId: compact.sig.k,
|
|
155
|
+
value: compact.sig.v,
|
|
156
|
+
}
|
|
157
|
+
: undefined,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function envelopeToCompact(envelope: ProtocolEnvelope): CompactEnvelope {
|
|
162
|
+
return {
|
|
163
|
+
v: envelope.header.version,
|
|
164
|
+
mid: envelope.header.messageId,
|
|
165
|
+
ts: envelope.header.timestampMs,
|
|
166
|
+
ttl: envelope.header.ttlMs,
|
|
167
|
+
s: envelope.header.sender,
|
|
168
|
+
r: envelope.header.recipient,
|
|
169
|
+
n: envelope.header.nonce,
|
|
170
|
+
i: envelope.body.intent,
|
|
171
|
+
c: envelope.body.content,
|
|
172
|
+
cap: envelope.body.capability,
|
|
173
|
+
cid: envelope.body.correlationId,
|
|
174
|
+
sig: envelope.signature
|
|
175
|
+
? {
|
|
176
|
+
a: envelope.signature.alg,
|
|
177
|
+
k: envelope.signature.keyId,
|
|
178
|
+
v: envelope.signature.value,
|
|
179
|
+
}
|
|
180
|
+
: undefined,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export type WireEnvelope = string | Uint8Array
|
|
185
|
+
|
|
186
|
+
export function decodeEnvelope(raw: WireEnvelope): ReceiveEnvelopeResult {
|
|
187
|
+
if (raw instanceof Uint8Array) {
|
|
188
|
+
return decodeEnvelopeBinary(raw)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const parsed = JSON.parse(raw) as unknown
|
|
193
|
+
if (isCompactEnvelope(parsed)) {
|
|
194
|
+
return { ok: true, diagnostics: [], envelope: compactToEnvelope(parsed) }
|
|
195
|
+
}
|
|
196
|
+
if (isProtocolEnvelope(parsed)) {
|
|
197
|
+
return { ok: true, diagnostics: [], envelope: parsed }
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
diagnostics: [{ level: 'error', message: 'Envelope JSON shape is not recognized' }],
|
|
202
|
+
envelope: null,
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
diagnostics: [{ level: 'error', message: 'Invalid JSON envelope' }],
|
|
208
|
+
envelope: null,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function encodeEnvelope(envelope: ProtocolEnvelope, format: 'binary'): Uint8Array
|
|
214
|
+
export function encodeEnvelope(envelope: ProtocolEnvelope, format?: 'json' | 'compact'): string
|
|
215
|
+
export function encodeEnvelope(envelope: ProtocolEnvelope, format: WireFormat): WireEnvelope
|
|
216
|
+
export function encodeEnvelope(envelope: ProtocolEnvelope, format: WireFormat = 'json'): WireEnvelope {
|
|
217
|
+
if (format === 'binary') {
|
|
218
|
+
return encodeEnvelopeBinary(envelope)
|
|
219
|
+
}
|
|
220
|
+
if (format === 'compact') {
|
|
221
|
+
return JSON.stringify(envelopeToCompact(envelope))
|
|
222
|
+
}
|
|
223
|
+
return JSON.stringify(envelope)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function encodeEnvelopeBatch(envelopes: ProtocolEnvelope[], format: BatchWireFormat = 'compact'): string {
|
|
227
|
+
if (format === 'compact') {
|
|
228
|
+
return JSON.stringify(envelopes.map(envelopeToCompact))
|
|
229
|
+
}
|
|
230
|
+
return JSON.stringify(envelopes)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function decodeEnvelopeBatch(raw: string): ReceiveEnvelopeResult[] {
|
|
234
|
+
try {
|
|
235
|
+
const parsed = JSON.parse(raw) as unknown
|
|
236
|
+
if (!Array.isArray(parsed)) {
|
|
237
|
+
return [
|
|
238
|
+
{
|
|
239
|
+
ok: false,
|
|
240
|
+
diagnostics: [{ level: 'error', message: 'Batch payload must be a JSON array' }],
|
|
241
|
+
envelope: null,
|
|
242
|
+
},
|
|
243
|
+
]
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return parsed.map((entry): ReceiveEnvelopeResult => {
|
|
247
|
+
if (isCompactEnvelope(entry)) {
|
|
248
|
+
return { ok: true, diagnostics: [], envelope: compactToEnvelope(entry) }
|
|
249
|
+
}
|
|
250
|
+
if (isProtocolEnvelope(entry)) {
|
|
251
|
+
return { ok: true, diagnostics: [], envelope: entry }
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
diagnostics: [{ level: 'error', message: 'Batch item has unrecognized envelope shape' }],
|
|
256
|
+
envelope: null,
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
} catch {
|
|
260
|
+
return [
|
|
261
|
+
{
|
|
262
|
+
ok: false,
|
|
263
|
+
diagnostics: [{ level: 'error', message: 'Invalid JSON batch payload' }],
|
|
264
|
+
envelope: null,
|
|
265
|
+
},
|
|
266
|
+
]
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function receiveEnvelope(
|
|
271
|
+
input: WireEnvelope | ProtocolEnvelope,
|
|
272
|
+
options: ReceiveEnvelopeOptions = {},
|
|
273
|
+
): Promise<ReceiveEnvelopeResult> {
|
|
274
|
+
const nowMs = options.nowMs ?? Date.now()
|
|
275
|
+
const requireSignature = options.requireSignature ?? true
|
|
276
|
+
const maxClockSkewMs = options.maxClockSkewMs ?? 30_000
|
|
277
|
+
|
|
278
|
+
async function emitTelemetry(event: Omit<TransportTelemetryEvent, 'nowMs'>): Promise<void> {
|
|
279
|
+
if (!options.telemetry) return
|
|
280
|
+
await options.telemetry({
|
|
281
|
+
nowMs,
|
|
282
|
+
...event,
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const decoded = typeof input === 'string' || input instanceof Uint8Array ? decodeEnvelope(input) : { ok: true, diagnostics: [], envelope: input }
|
|
287
|
+
if (!decoded.ok || !decoded.envelope) return decoded
|
|
288
|
+
|
|
289
|
+
await emitTelemetry({
|
|
290
|
+
phase: 'decoded',
|
|
291
|
+
sender: decoded.envelope.header.sender,
|
|
292
|
+
messageId: decoded.envelope.header.messageId,
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
if (decoded.envelope.header.timestampMs > nowMs + maxClockSkewMs) {
|
|
296
|
+
const message = 'Message timestamp exceeds allowed clock skew'
|
|
297
|
+
const diagnostics: ProtocolDiagnostic[] = [{ level: 'error', message }]
|
|
298
|
+
await emitTelemetry({
|
|
299
|
+
phase: 'rejected_clock_skew',
|
|
300
|
+
sender: decoded.envelope.header.sender,
|
|
301
|
+
messageId: decoded.envelope.header.messageId,
|
|
302
|
+
reason: message,
|
|
303
|
+
})
|
|
304
|
+
return { ok: false, diagnostics, envelope: decoded.envelope }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const diagnostics: ProtocolDiagnostic[] = [...validateEnvelope(decoded.envelope, nowMs)]
|
|
308
|
+
if (diagnostics.some((d) => d.level === 'error')) {
|
|
309
|
+
await emitTelemetry({
|
|
310
|
+
phase: 'rejected_validation',
|
|
311
|
+
sender: decoded.envelope.header.sender,
|
|
312
|
+
messageId: decoded.envelope.header.messageId,
|
|
313
|
+
reason: diagnostics.find((d) => d.level === 'error')?.message,
|
|
314
|
+
})
|
|
315
|
+
return { ok: false, diagnostics, envelope: decoded.envelope }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (!decoded.envelope.signature) {
|
|
319
|
+
if (requireSignature) {
|
|
320
|
+
diagnostics.push({ level: 'error', message: 'Missing signature' })
|
|
321
|
+
await emitTelemetry({
|
|
322
|
+
phase: 'rejected_missing_signature',
|
|
323
|
+
sender: decoded.envelope.header.sender,
|
|
324
|
+
messageId: decoded.envelope.header.messageId,
|
|
325
|
+
reason: 'Missing signature',
|
|
326
|
+
})
|
|
327
|
+
return { ok: false, diagnostics, envelope: decoded.envelope }
|
|
328
|
+
}
|
|
329
|
+
await emitTelemetry({
|
|
330
|
+
phase: 'accepted',
|
|
331
|
+
sender: decoded.envelope.header.sender,
|
|
332
|
+
messageId: decoded.envelope.header.messageId,
|
|
333
|
+
})
|
|
334
|
+
return { ok: true, diagnostics, envelope: decoded.envelope }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let verificationMaterial: SignatureVerificationMaterial | undefined
|
|
338
|
+
const signature = decoded.envelope.signature
|
|
339
|
+
const cacheKey = verificationMaterialCacheKey(signature, decoded.envelope.header.sender)
|
|
340
|
+
if (options.verificationMaterialCache) {
|
|
341
|
+
verificationMaterial = options.verificationMaterialCache.get(cacheKey, nowMs)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (options.signatureResolver) {
|
|
345
|
+
verificationMaterial =
|
|
346
|
+
verificationMaterial ?? (await options.signatureResolver(signature, decoded.envelope.header.sender))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!verificationMaterial && signature.alg === 'HS256' && options.secretResolver) {
|
|
350
|
+
const secret = await options.secretResolver(signature.keyId, decoded.envelope.header.sender)
|
|
351
|
+
if (secret) {
|
|
352
|
+
verificationMaterial = { alg: 'HS256', secret }
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (verificationMaterial && options.verificationMaterialCache) {
|
|
357
|
+
const expiresAtMs = Math.max(nowMs + 1, decoded.envelope.header.timestampMs + decoded.envelope.header.ttlMs)
|
|
358
|
+
options.verificationMaterialCache.set(cacheKey, verificationMaterial, expiresAtMs)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (!verificationMaterial) {
|
|
362
|
+
diagnostics.push({ level: 'error', message: 'No signature verification material found for keyId/sender' })
|
|
363
|
+
await emitTelemetry({
|
|
364
|
+
phase: 'rejected_missing_material',
|
|
365
|
+
sender: decoded.envelope.header.sender,
|
|
366
|
+
messageId: decoded.envelope.header.messageId,
|
|
367
|
+
reason: 'No signature verification material found for keyId/sender',
|
|
368
|
+
})
|
|
369
|
+
return { ok: false, diagnostics, envelope: decoded.envelope }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const canonicalPayload = canonicalizeEnvelope({ header: decoded.envelope.header, body: decoded.envelope.body })
|
|
373
|
+
const isValid = await verifyCanonicalPayloadSignature(canonicalPayload, decoded.envelope.signature, verificationMaterial)
|
|
374
|
+
if (!isValid) {
|
|
375
|
+
diagnostics.push({ level: 'error', message: 'Signature verification failed' })
|
|
376
|
+
await emitTelemetry({
|
|
377
|
+
phase: 'rejected_bad_signature',
|
|
378
|
+
sender: decoded.envelope.header.sender,
|
|
379
|
+
messageId: decoded.envelope.header.messageId,
|
|
380
|
+
reason: 'Signature verification failed',
|
|
381
|
+
})
|
|
382
|
+
return { ok: false, diagnostics, envelope: decoded.envelope }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (options.replayCache) {
|
|
386
|
+
const replay = await options.replayCache.consume(decoded.envelope, nowMs)
|
|
387
|
+
if (!replay.ok) {
|
|
388
|
+
diagnostics.push({ level: 'error', message: replay.reason ?? 'Replay detected' })
|
|
389
|
+
await emitTelemetry({
|
|
390
|
+
phase: 'rejected_replay',
|
|
391
|
+
sender: decoded.envelope.header.sender,
|
|
392
|
+
messageId: decoded.envelope.header.messageId,
|
|
393
|
+
reason: replay.reason,
|
|
394
|
+
})
|
|
395
|
+
return { ok: false, diagnostics, envelope: decoded.envelope }
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
await emitTelemetry({
|
|
400
|
+
phase: 'accepted',
|
|
401
|
+
sender: decoded.envelope.header.sender,
|
|
402
|
+
messageId: decoded.envelope.header.messageId,
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
return { ok: true, diagnostics, envelope: decoded.envelope }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export async function receiveEnvelopeBatch(
|
|
409
|
+
input: string | Array<WireEnvelope | ProtocolEnvelope>,
|
|
410
|
+
options: ReceiveEnvelopeBatchOptions = {},
|
|
411
|
+
): Promise<ReceiveEnvelopeResult[]> {
|
|
412
|
+
const concurrency = Math.max(1, Math.floor(options.batchConcurrency ?? Number.POSITIVE_INFINITY))
|
|
413
|
+
const receiveItems = async (items: Array<WireEnvelope | ProtocolEnvelope | ReceiveEnvelopeResult>): Promise<ReceiveEnvelopeResult[]> => {
|
|
414
|
+
const results: ReceiveEnvelopeResult[] = new Array(items.length)
|
|
415
|
+
let nextIndex = 0
|
|
416
|
+
const workerCount = Number.isFinite(concurrency) ? Math.min(concurrency, items.length) : items.length
|
|
417
|
+
await Promise.all(
|
|
418
|
+
Array.from({ length: workerCount }, async () => {
|
|
419
|
+
while (nextIndex < items.length) {
|
|
420
|
+
const index = nextIndex
|
|
421
|
+
nextIndex += 1
|
|
422
|
+
const item = items[index]
|
|
423
|
+
if (item && typeof item === 'object' && 'ok' in item && 'diagnostics' in item && 'envelope' in item) {
|
|
424
|
+
results[index] = item
|
|
425
|
+
} else {
|
|
426
|
+
results[index] = await receiveEnvelope(item as WireEnvelope | ProtocolEnvelope, options)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}),
|
|
430
|
+
)
|
|
431
|
+
await options.telemetry?.({
|
|
432
|
+
phase: 'batch_summary',
|
|
433
|
+
nowMs: options.nowMs ?? Date.now(),
|
|
434
|
+
reason: `items=${items.length};accepted=${results.filter((result) => result?.ok).length}`,
|
|
435
|
+
})
|
|
436
|
+
return results
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (typeof input === 'string') {
|
|
440
|
+
const decoded = decodeEnvelopeBatch(input)
|
|
441
|
+
return receiveItems(decoded.map((item) => (item.ok && item.envelope ? item.envelope : item)))
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return receiveItems(input)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export class SessionTransport {
|
|
448
|
+
private readonly options: ReceiveEnvelopeOptions
|
|
449
|
+
|
|
450
|
+
constructor(options: Omit<ReceiveEnvelopeOptions, 'replayCache'> & { replayCache?: ReplayCache } = {}) {
|
|
451
|
+
this.options = {
|
|
452
|
+
...options,
|
|
453
|
+
replayCache: options.replayCache ?? new InMemoryReplayCache(),
|
|
454
|
+
verificationMaterialCache: options.verificationMaterialCache ?? new InMemoryVerificationMaterialCache(),
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
receive(input: WireEnvelope | ProtocolEnvelope, nowMs = Date.now()): Promise<ReceiveEnvelopeResult> {
|
|
459
|
+
return receiveEnvelope(input, { ...this.options, nowMs })
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
receiveBatch(input: string | Array<WireEnvelope | ProtocolEnvelope>, nowMs = Date.now()): Promise<ReceiveEnvelopeResult[]> {
|
|
463
|
+
return receiveEnvelopeBatch(input, { ...this.options, nowMs })
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export async function createSignedMessage(input: {
|
|
468
|
+
sender: string
|
|
469
|
+
recipient?: string
|
|
470
|
+
intent: IntentKind
|
|
471
|
+
content: string
|
|
472
|
+
capability?: string
|
|
473
|
+
correlationId?: string
|
|
474
|
+
ttlMs?: number
|
|
475
|
+
messageId?: string
|
|
476
|
+
nonce?: string
|
|
477
|
+
nowMs?: number
|
|
478
|
+
secret: string
|
|
479
|
+
keyId?: string
|
|
480
|
+
}): Promise<ProtocolEnvelope> {
|
|
481
|
+
const unsigned = createEnvelope(input)
|
|
482
|
+
return signEnvelopeHmac(unsigned, input.secret, input.keyId)
|
|
483
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { generateEd25519KeypairBase64Url } from './protocol'
|
|
3
|
+
import { signQueueMessage, verifyQueueMessage, verifyQueueBatch } from './queueBinding'
|
|
4
|
+
|
|
5
|
+
let publicKey: string
|
|
6
|
+
let privateKey: string
|
|
7
|
+
let altPublicKey: string
|
|
8
|
+
let altPrivateKey: string
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
const kp = await generateEd25519KeypairBase64Url()
|
|
12
|
+
publicKey = kp.publicKey
|
|
13
|
+
privateKey = kp.privateKey
|
|
14
|
+
|
|
15
|
+
const alt = await generateEd25519KeypairBase64Url()
|
|
16
|
+
altPublicKey = alt.publicKey
|
|
17
|
+
altPrivateKey = alt.privateKey
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('signQueueMessage + verifyQueueMessage', () => {
|
|
21
|
+
it('round trip with string payload', async () => {
|
|
22
|
+
const payload = 'hello queue'
|
|
23
|
+
const msg = await signQueueMessage(payload, { privateKey, sender: 'agent-a' })
|
|
24
|
+
const result = await verifyQueueMessage<string>(msg, { publicKey })
|
|
25
|
+
expect(result.payload).toBe(payload)
|
|
26
|
+
expect(result.envelope.header.sender).toBe('agent-a')
|
|
27
|
+
expect(result.envelope.body.intent).toBe('TASK')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('round trip with object payload', async () => {
|
|
31
|
+
const payload = { task: 'do-something', priority: 5 }
|
|
32
|
+
const msg = await signQueueMessage(payload, { privateKey, sender: 'agent-b', recipient: 'agent-c' })
|
|
33
|
+
const result = await verifyQueueMessage<typeof payload>(msg, { publicKey })
|
|
34
|
+
expect(result.payload).toEqual(payload)
|
|
35
|
+
expect(result.envelope.header.recipient).toBe('agent-c')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('uses default 1 hour TTL', async () => {
|
|
39
|
+
const msg = await signQueueMessage('ttl-test', { privateKey, sender: 'agent-a' })
|
|
40
|
+
const parsed = JSON.parse(msg)
|
|
41
|
+
expect(parsed.envelope.header.ttlMs).toBe(3_600_000)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('respects custom ttlMs', async () => {
|
|
45
|
+
const msg = await signQueueMessage('ttl-custom', { privateKey, sender: 'agent-a', ttlMs: 7200000 })
|
|
46
|
+
const parsed = JSON.parse(msg)
|
|
47
|
+
expect(parsed.envelope.header.ttlMs).toBe(7200000)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('throws on tampered message (modified payload)', async () => {
|
|
51
|
+
const msg = await signQueueMessage({ x: 1 }, { privateKey, sender: 'agent-a' })
|
|
52
|
+
const parsed = JSON.parse(msg)
|
|
53
|
+
// tamper with the envelope content
|
|
54
|
+
parsed.envelope.body.content = '{"x":999}'
|
|
55
|
+
const tampered = JSON.stringify(parsed)
|
|
56
|
+
await expect(verifyQueueMessage(tampered, { publicKey })).rejects.toThrow('signature verification failed')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('throws on wrong public key', async () => {
|
|
60
|
+
const msg = await signQueueMessage('secure', { privateKey, sender: 'agent-a' })
|
|
61
|
+
await expect(verifyQueueMessage(msg, { publicKey: altPublicKey })).rejects.toThrow('signature verification failed')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('throws on invalid JSON', async () => {
|
|
65
|
+
await expect(verifyQueueMessage('not-json', { publicKey })).rejects.toThrow('not valid JSON')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('throws on missing envelope', async () => {
|
|
69
|
+
await expect(verifyQueueMessage(JSON.stringify({ payload: 'x' }), { publicKey })).rejects.toThrow(
|
|
70
|
+
'missing envelope',
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('verifyQueueBatch', () => {
|
|
76
|
+
it('mixed valid/invalid returns correct ok/false results', async () => {
|
|
77
|
+
const valid1 = await signQueueMessage('msg1', { privateKey, sender: 'a' })
|
|
78
|
+
const valid2 = await signQueueMessage({ n: 2 }, { privateKey, sender: 'b' })
|
|
79
|
+
const invalid = 'garbage-json'
|
|
80
|
+
|
|
81
|
+
const results = await verifyQueueBatch<unknown>([valid1, invalid, valid2], { publicKey })
|
|
82
|
+
expect(results).toHaveLength(3)
|
|
83
|
+
expect(results[0].ok).toBe(true)
|
|
84
|
+
expect(results[1].ok).toBe(false)
|
|
85
|
+
expect(results[2].ok).toBe(true)
|
|
86
|
+
|
|
87
|
+
const failed = results[1]
|
|
88
|
+
if (!failed.ok) {
|
|
89
|
+
expect(failed.raw).toBe(invalid)
|
|
90
|
+
expect(failed.error).toBeTruthy()
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('all valid batch', async () => {
|
|
95
|
+
const messages = await Promise.all([
|
|
96
|
+
signQueueMessage('a', { privateKey, sender: 'x' }),
|
|
97
|
+
signQueueMessage('b', { privateKey, sender: 'y' }),
|
|
98
|
+
signQueueMessage('c', { privateKey, sender: 'z' }),
|
|
99
|
+
])
|
|
100
|
+
const results = await verifyQueueBatch<string>(messages, { publicKey })
|
|
101
|
+
expect(results.every((r) => r.ok)).toBe(true)
|
|
102
|
+
const payloads = results.filter((r) => r.ok).map((r) => (r.ok ? r.payload : null))
|
|
103
|
+
expect(payloads).toEqual(['a', 'b', 'c'])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('all invalid batch', async () => {
|
|
107
|
+
const messages = ['bad1', 'bad2', JSON.stringify({ payload: 'no-envelope' })]
|
|
108
|
+
const results = await verifyQueueBatch<string>(messages, { publicKey })
|
|
109
|
+
expect(results.every((r) => !r.ok)).toBe(true)
|
|
110
|
+
results.forEach((r, i) => {
|
|
111
|
+
if (!r.ok) {
|
|
112
|
+
expect(r.raw).toBe(messages[i])
|
|
113
|
+
expect(typeof r.error).toBe('string')
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('wrong key causes all-invalid for valid messages', async () => {
|
|
119
|
+
const messages = await Promise.all([
|
|
120
|
+
signQueueMessage('p', { privateKey, sender: 'a' }),
|
|
121
|
+
signQueueMessage('q', { privateKey, sender: 'b' }),
|
|
122
|
+
])
|
|
123
|
+
const results = await verifyQueueBatch<string>(messages, { publicKey: altPublicKey })
|
|
124
|
+
expect(results.every((r) => !r.ok)).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('never throws even if all messages are invalid', async () => {
|
|
128
|
+
await expect(verifyQueueBatch(['{}', 'nope', '{"envelope":null,"payload":1}'], { publicKey })).resolves.toBeDefined()
|
|
129
|
+
})
|
|
130
|
+
})
|