@7h3/protocol 0.5.0 → 0.5.2
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/README.md +6 -1317
- package/agentAdapter.d.ts +26 -0
- package/auditLog.d.ts +54 -0
- package/capability.d.ts +67 -0
- package/cborCodec.d.ts +34 -0
- package/conformanceVectors.d.ts +20 -0
- package/encryption.d.ts +85 -0
- package/envelopeCbor.d.ts +33 -0
- package/frameworkAdapters.d.ts +72 -0
- package/gateway.d.ts +53 -0
- package/grpcBinding.d.ts +29 -0
- package/httpBinding.d.ts +50 -0
- package/index.d.ts +39 -0
- package/index.js +3670 -0
- package/keyInfra.d.ts +64 -0
- package/keyRegistry.d.ts +9 -0
- package/keyRotation.d.ts +20 -0
- package/mcpGateway.d.ts +37 -0
- package/mcpTransports.d.ts +62 -0
- package/mcpWrapper.d.ts +83 -0
- package/otel.d.ts +65 -0
- package/package.json +13 -121
- package/policyEnforcer.d.ts +50 -0
- package/policyTelemetryFeedback.d.ts +11 -0
- package/{src/protocol.d.ts → protocol.d.ts} +6 -2
- package/protocolAgent.d.ts +58 -0
- package/protocolBinary.d.ts +8 -0
- package/protocolCapabilities.d.ts +24 -0
- package/protocolReplay.d.ts +35 -0
- package/protocolTransport.d.ts +73 -0
- package/queueBinding.d.ts +43 -0
- package/rateLimiter.d.ts +18 -0
- package/{src/redisClient.ts → redisClient.d.ts} +25 -75
- package/replayStores.d.ts +98 -0
- package/revocation.d.ts +71 -0
- package/routePolicy.d.ts +30 -0
- package/runtimePolicy.d.ts +24 -0
- package/runtimePolicyManager.d.ts +15 -0
- package/runtimePolicyPresets.d.ts +11 -0
- package/signedResponse.d.ts +21 -0
- package/stream.d.ts +59 -0
- package/telemetry.d.ts +79 -0
- package/webhookBinding.d.ts +30 -0
- package/wsBinding.d.ts +52 -0
- package/.dockerignore +0 -19
- package/.github/dependabot.yml +0 -32
- package/.github/workflows/ci.yml +0 -31
- package/.github/workflows/publish.yml +0 -59
- package/.github/workflows/scorecard.yml +0 -37
- package/7h3.example.yaml +0 -125
- package/CHANGELOG.md +0 -152
- package/CONTRIBUTING.md +0 -82
- package/Dockerfile +0 -73
- package/GOVERNANCE.md +0 -62
- package/SECURITY.md +0 -70
- package/bench-results/replay-cache-full-1777891033256.json +0 -10
- package/bench-results/replay-cache-full-1777896317488.json +0 -10
- package/bench-results/replay-cache-full-1777900993184.json +0 -10
- package/bench-results/replay-cache-full-1777901019285.json +0 -10
- package/bench-results/replay-cache-quick-1777870170126.json +0 -10
- package/bench-results/signature-profiles-quick-1775875160079.json +0 -85
- package/bench-results/signature-profiles-quick-1775983539716.json +0 -85
- package/bench-results/signature-profiles-quick-1776237913190.json +0 -85
- package/bench-results/wire-codecs-full-1777891019803.json +0 -93
- package/bench-results/wire-codecs-full-1777896260964.json +0 -93
- package/bench-results/wire-codecs-full-1777901004247.json +0 -93
- package/bench-results/wire-codecs-quick-1775972879056.json +0 -93
- package/bench-results/wire-codecs-quick-1775983541111.json +0 -93
- package/bench-results/wire-codecs-quick-1776237914299.json +0 -93
- package/bench-results/wire-codecs-quick-1777841285236.json +0 -93
- package/bench-results/wire-codecs-quick-1777841321772.json +0 -93
- package/bench-results/wire-codecs-quick-1777841330408.json +0 -93
- package/bench-results/wire-codecs-quick-1777852886082.json +0 -93
- package/bench-results/wire-codecs-quick-1777852988773.json +0 -93
- package/bench-results/wire-codecs-quick-1777870188095.json +0 -93
- package/bench-results/wire-codecs-quick-1777870263918.json +0 -93
- package/bench-results/wire-codecs-quick-1777870455034.json +0 -93
- package/bench-results/wire-codecs-quick-1778816163081.json +0 -93
- package/bench-results/wire-codecs-quick-1778843936130.json +0 -93
- package/bin/7h3.ts +0 -406
- package/conformance/7h3_v0_1.json +0 -77
- package/conformance/7h3_v0_1_binary.json +0 -20
- package/conformance/aip_v0_1_binary.json +0 -20
- package/docker-compose.yaml +0 -77
- package/docs/ADOPTION_PLAN.md +0 -120
- package/docs/AGENTS.md +0 -77
- package/docs/AIP_RFC_v0.1.md +0 -97
- package/docs/AI_DECISION_CARD.md +0 -122
- package/docs/AI_RUNTIME_POLICY.json +0 -126
- package/docs/AI_RUNTIME_POLICY.yaml +0 -110
- package/docs/BACKPRESSURE_TUNING.md +0 -65
- package/docs/BENCHMARK_CLAIM_MATRIX.md +0 -42
- package/docs/BENCHMARK_REPORT_TEMPLATE.md +0 -169
- package/docs/BINARY_CODEC_BENCH.md +0 -23
- package/docs/CLEAN_CLONE_RUNBOOK.md +0 -36
- package/docs/CLOCK_SKEW_POLICY.md +0 -30
- package/docs/DISTRIBUTED_REPLAY.md +0 -142
- package/docs/FUZZ_CAMPAIGN.md +0 -121
- package/docs/GATEWAY.md +0 -195
- package/docs/KEY_MANAGEMENT_POLICY.md +0 -53
- package/docs/KEY_REVOCATION.md +0 -69
- package/docs/MCP_WRAPPER.md +0 -159
- package/docs/MIGRATION_GUIDE.md +0 -40
- package/docs/OPERATORS.md +0 -184
- package/docs/PERF_REGRESSION_POLICY.md +0 -34
- package/docs/PROJECT_EXAMINATION_2026-05-31.md +0 -219
- package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +0 -135
- package/docs/RELEASE_GATE.md +0 -25
- package/docs/RELEASE_NOTES_v0.1.0.md +0 -54
- package/docs/SECURITY_REVIEW_2026-06-05.md +0 -165
- package/docs/TELEMETRY.md +0 -41
- package/docs/THREAT_MODEL.md +0 -89
- package/docs/VERSIONING_POLICY.md +0 -30
- package/docs/assets/banner-github.png +0 -0
- package/docs/assets/banner.png +0 -0
- package/docs/assets/banner.svg +0 -123
- package/eslint.config.js +0 -15
- package/fuzz/ts/harness-decode.ts +0 -136
- package/fuzz/ts/harness-verify.ts +0 -121
- package/fuzz/ts/run.ts +0 -35
- package/mcp-server/README.md +0 -38
- package/mcp-server/package-lock.json +0 -1187
- package/mcp-server/package.json +0 -35
- package/mcp-server/src/index.ts +0 -236
- package/mcp-server/tsconfig.json +0 -14
- package/scripts/aip-framework-quickstart.ts +0 -110
- package/scripts/aip-mcp-gateway.ts +0 -38
- package/scripts/aip-mcp-wrap-demo.ts +0 -72
- package/scripts/aip-quickstart.ts +0 -60
- package/scripts/bench-diff.ts +0 -118
- package/scripts/bench-protocol-e2e.ts +0 -937
- package/scripts/bench-protocol-openloop.ts +0 -1397
- package/scripts/bench-replay-cache.ts +0 -76
- package/scripts/bench-signature-profiles.ts +0 -180
- package/scripts/bench-wire-codecs.ts +0 -161
- package/scripts/build-binary-conformance.ts +0 -36
- package/scripts/build-release-dashboard.ts +0 -175
- package/scripts/canary-rollout.ts +0 -38
- package/scripts/mcpGatewayCli.test.ts +0 -116
- package/scripts/prepare-aip-package.ts +0 -88
- package/scripts/regen-conformance-sigs.ts +0 -18
- package/scripts/release-gate.ts +0 -19
- package/scripts/validate-runtime-policy.ts +0 -18
- package/sdk/browser/index.test.ts +0 -162
- package/sdk/browser/index.ts +0 -257
- package/sdk/browser/package.json +0 -13
- package/sdk/go/cbor.go +0 -551
- package/sdk/go/cbor_test.go +0 -232
- package/sdk/go/encryption.go +0 -280
- package/sdk/go/encryption_test.go +0 -318
- package/sdk/go/go.mod +0 -7
- package/sdk/go/go.sum +0 -4
- package/sdk/go/http.go +0 -135
- package/sdk/go/protocol.go +0 -324
- package/sdk/go/protocol_test.go +0 -334
- package/sdk/go/replay.go +0 -121
- package/sdk/go/replay_test.go +0 -149
- package/sdk/go/webhook.go +0 -136
- package/sdk/pq/package-lock.json +0 -1358
- package/sdk/pq/package.json +0 -42
- package/sdk/pq/src/index.test.ts +0 -143
- package/sdk/pq/src/index.ts +0 -166
- package/sdk/pq/tsconfig.json +0 -14
- package/sdk/pq/vitest.config.ts +0 -7
- package/sdk/python/README.md +0 -18
- package/sdk/python/protocol_7h3/__init__.py +0 -46
- package/sdk/python/protocol_7h3/encryption.py +0 -252
- package/sdk/python/protocol_7h3/http.py +0 -212
- package/sdk/python/protocol_7h3/keys.py +0 -149
- package/sdk/python/protocol_7h3/pq.py +0 -244
- package/sdk/python/protocol_7h3/protocol.py +0 -525
- package/sdk/python/protocol_7h3/queue.py +0 -118
- package/sdk/python/protocol_7h3/replay.py +0 -98
- package/sdk/python/protocol_7h3/webhook.py +0 -116
- package/sdk/python/pyproject.toml +0 -40
- package/sdk/python/tests/test_conformance.py +0 -110
- package/sdk/python/tests/test_encryption.py +0 -206
- package/sdk/python/tests/test_http.py +0 -305
- package/sdk/python/tests/test_keys.py +0 -417
- package/sdk/python/tests/test_queue.py +0 -120
- package/sdk/python/tests/test_webhook.py +0 -345
- package/sdk/rust/Cargo.lock +0 -371
- package/sdk/rust/Cargo.toml +0 -25
- package/sdk/rust/README.md +0 -31
- package/sdk/rust/fuzz/Cargo.toml +0 -29
- package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +0 -46
- package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +0 -11
- package/sdk/rust/src/bin/aip_mcp_gateway.rs +0 -59
- package/sdk/rust/src/http.rs +0 -145
- package/sdk/rust/src/keys.rs +0 -161
- package/sdk/rust/src/lib.rs +0 -688
- package/sdk/rust/src/queue.rs +0 -79
- package/sdk/rust/src/webhook.rs +0 -86
- package/sdk/rust/tests/conformance.rs +0 -148
- package/sdk/rust/tests/gateway.rs +0 -130
- package/sdk/rust/tests/http_webhook_queue.rs +0 -201
- package/sdk/rust/tests/keys.rs +0 -189
- package/sdk/threshold/index.d.ts +0 -68
- package/sdk/threshold/index.d.ts.map +0 -1
- package/sdk/threshold/index.js +0 -254
- package/sdk/threshold/package-lock.json +0 -1361
- package/sdk/threshold/package.json +0 -39
- package/sdk/threshold/src/index.d.ts +0 -68
- package/sdk/threshold/src/index.d.ts.map +0 -1
- package/sdk/threshold/src/index.js +0 -254
- package/sdk/threshold/src/index.test.ts +0 -238
- package/sdk/threshold/src/index.ts +0 -355
- package/sdk/threshold/tsconfig.json +0 -19
- package/sdk/threshold/vitest.config.ts +0 -12
- package/src/agentAdapter.test.ts +0 -48
- package/src/agentAdapter.ts +0 -56
- package/src/auditLog.test.ts +0 -145
- package/src/auditLog.ts +0 -147
- package/src/capability.test.ts +0 -504
- package/src/capability.ts +0 -380
- package/src/cborCodec.test.ts +0 -263
- package/src/cborCodec.ts +0 -339
- package/src/conformance.test.ts +0 -136
- package/src/conformanceVectors.ts +0 -99
- package/src/encryption.test.ts +0 -206
- package/src/encryption.ts +0 -245
- package/src/envelopeCbor.ts +0 -140
- package/src/frameworkAdapters.test.ts +0 -290
- package/src/frameworkAdapters.ts +0 -261
- package/src/gateway.test.ts +0 -343
- package/src/gateway.ts +0 -246
- package/src/grpcBinding.test.ts +0 -211
- package/src/grpcBinding.ts +0 -103
- package/src/httpBinding.test.ts +0 -376
- package/src/httpBinding.ts +0 -189
- package/src/index.ts +0 -39
- package/src/keyInfra.test.ts +0 -278
- package/src/keyInfra.ts +0 -228
- package/src/keyRegistry.ts +0 -59
- package/src/keyRotation.test.ts +0 -78
- package/src/keyRotation.ts +0 -72
- package/src/mcpGateway.test.ts +0 -129
- package/src/mcpGateway.ts +0 -250
- package/src/mcpTransports.test.ts +0 -92
- package/src/mcpTransports.ts +0 -169
- package/src/mcpWrapper.test.ts +0 -179
- package/src/mcpWrapper.ts +0 -206
- package/src/otel.ts +0 -136
- package/src/policyEnforcer.test.ts +0 -99
- package/src/policyEnforcer.ts +0 -169
- package/src/policyTelemetryFeedback.test.ts +0 -25
- package/src/policyTelemetryFeedback.ts +0 -38
- package/src/protocol.bench.ts +0 -37
- package/src/protocol.d.ts.map +0 -1
- package/src/protocol.js +0 -294
- package/src/protocol.test.ts +0 -155
- package/src/protocol.ts +0 -414
- package/src/protocolAgent.test.ts +0 -105
- package/src/protocolAgent.ts +0 -169
- package/src/protocolBinary.test.ts +0 -165
- package/src/protocolBinary.ts +0 -312
- package/src/protocolCapabilities.ts +0 -70
- package/src/protocolFuzz.advanced.test.ts +0 -235
- package/src/protocolFuzz.test.ts +0 -111
- package/src/protocolNegative.test.ts +0 -97
- package/src/protocolReplay.test.ts +0 -71
- package/src/protocolReplay.ts +0 -194
- package/src/protocolTransport.test.ts +0 -556
- package/src/protocolTransport.ts +0 -483
- package/src/queueBinding.test.ts +0 -130
- package/src/queueBinding.ts +0 -102
- package/src/rateLimiter.test.ts +0 -96
- package/src/rateLimiter.ts +0 -46
- package/src/redisIntegration.test.ts +0 -134
- package/src/replayStores.test.ts +0 -273
- package/src/replayStores.ts +0 -215
- package/src/revocation.test.ts +0 -98
- package/src/revocation.ts +0 -0
- package/src/routePolicy.test.ts +0 -87
- package/src/routePolicy.ts +0 -72
- package/src/runtimePolicy.test.ts +0 -49
- package/src/runtimePolicy.ts +0 -81
- package/src/runtimePolicyManager.test.ts +0 -29
- package/src/runtimePolicyManager.ts +0 -50
- package/src/runtimePolicyPresets.ts +0 -43
- package/src/signedResponse.test.ts +0 -111
- package/src/signedResponse.ts +0 -83
- package/src/stream.test.ts +0 -254
- package/src/stream.ts +0 -417
- package/src/telemetry.test.ts +0 -251
- package/src/telemetry.ts +0 -299
- package/src/webhookBinding.test.ts +0 -144
- package/src/webhookBinding.ts +0 -115
- package/src/wsBinding.test.ts +0 -221
- package/src/wsBinding.ts +0 -200
- package/tsconfig.json +0 -15
- package/tsconfig.lib.json +0 -23
- package/vite.lib.config.ts +0 -16
- package/vitest.config.ts +0 -11
package/src/stream.ts
DELETED
|
@@ -1,417 +0,0 @@
|
|
|
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
|
-
}
|
package/src/telemetry.test.ts
DELETED
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
-
SimpleCounter,
|
|
4
|
-
SimpleHistogram,
|
|
5
|
-
Protocol7h3Metrics,
|
|
6
|
-
renderPrometheusText,
|
|
7
|
-
createMetricsMiddleware,
|
|
8
|
-
} from './telemetry'
|
|
9
|
-
|
|
10
|
-
// ─── 1. Counter increments correctly with labels ──────────────────────────────
|
|
11
|
-
|
|
12
|
-
describe('SimpleCounter with labels', () => {
|
|
13
|
-
it('increments independently per label set', () => {
|
|
14
|
-
const counter = new SimpleCounter()
|
|
15
|
-
counter.increment({ result: 'ok', alg: 'ED25519' })
|
|
16
|
-
counter.increment({ result: 'ok', alg: 'ED25519' })
|
|
17
|
-
counter.increment({ result: 'fail', alg: 'HS256' })
|
|
18
|
-
|
|
19
|
-
expect(counter.value({ result: 'ok', alg: 'ED25519' })).toBe(2)
|
|
20
|
-
expect(counter.value({ result: 'fail', alg: 'HS256' })).toBe(1)
|
|
21
|
-
expect(counter.value({ result: 'ok', alg: 'HS256' })).toBe(0)
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('label order does not affect key identity', () => {
|
|
25
|
-
const counter = new SimpleCounter()
|
|
26
|
-
counter.increment({ a: '1', b: '2' })
|
|
27
|
-
counter.increment({ b: '2', a: '1' })
|
|
28
|
-
expect(counter.value({ a: '1', b: '2' })).toBe(2)
|
|
29
|
-
expect(counter.value({ b: '2', a: '1' })).toBe(2)
|
|
30
|
-
})
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
// ─── 2. Counter with no labels increments the default bucket ─────────────────
|
|
34
|
-
|
|
35
|
-
describe('SimpleCounter with no labels', () => {
|
|
36
|
-
it('starts at 0', () => {
|
|
37
|
-
const counter = new SimpleCounter()
|
|
38
|
-
expect(counter.value()).toBe(0)
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('increments the default bucket', () => {
|
|
42
|
-
const counter = new SimpleCounter()
|
|
43
|
-
counter.increment()
|
|
44
|
-
counter.increment()
|
|
45
|
-
counter.increment()
|
|
46
|
-
expect(counter.value()).toBe(3)
|
|
47
|
-
// labeled lookup returns 0 — separate bucket
|
|
48
|
-
expect(counter.value({ x: '1' })).toBe(0)
|
|
49
|
-
})
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
// ─── 3. Histogram records observations and buckets correctly ─────────────────
|
|
53
|
-
|
|
54
|
-
describe('SimpleHistogram', () => {
|
|
55
|
-
it('counts observations into correct buckets', () => {
|
|
56
|
-
const h = new SimpleHistogram([1, 5, 10, 50, 100])
|
|
57
|
-
h.observe(0.5)
|
|
58
|
-
h.observe(3)
|
|
59
|
-
h.observe(7)
|
|
60
|
-
h.observe(75)
|
|
61
|
-
h.observe(200)
|
|
62
|
-
|
|
63
|
-
const snap = h.snapshot()
|
|
64
|
-
// ≤1: 0.5 → 1
|
|
65
|
-
expect(snap.buckets['1']).toBe(1)
|
|
66
|
-
// ≤5: 0.5, 3 → 2
|
|
67
|
-
expect(snap.buckets['5']).toBe(2)
|
|
68
|
-
// ≤10: 0.5, 3, 7 → 3
|
|
69
|
-
expect(snap.buckets['10']).toBe(3)
|
|
70
|
-
// ≤50: 0.5, 3, 7 → 3
|
|
71
|
-
expect(snap.buckets['50']).toBe(3)
|
|
72
|
-
// ≤100: 0.5, 3, 7, 75 → 4
|
|
73
|
-
expect(snap.buckets['100']).toBe(4)
|
|
74
|
-
// +Inf: all 5
|
|
75
|
-
expect(snap.buckets['+Inf']).toBe(5)
|
|
76
|
-
expect(snap.count).toBe(5)
|
|
77
|
-
expect(snap.sum).toBeCloseTo(0.5 + 3 + 7 + 75 + 200)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('tracks per-label state independently', () => {
|
|
81
|
-
const h = new SimpleHistogram([10])
|
|
82
|
-
h.observe(5, { transport: 'http' })
|
|
83
|
-
h.observe(15, { transport: 'ws' })
|
|
84
|
-
|
|
85
|
-
const snapHttp = h.snapshot({ transport: 'http' })
|
|
86
|
-
expect(snapHttp.count).toBe(1)
|
|
87
|
-
expect(snapHttp.buckets['10']).toBe(1)
|
|
88
|
-
|
|
89
|
-
const snapWs = h.snapshot({ transport: 'ws' })
|
|
90
|
-
expect(snapWs.count).toBe(1)
|
|
91
|
-
expect(snapWs.buckets['10']).toBe(0)
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
it('returns zero snapshot for unseen labels', () => {
|
|
95
|
-
const h = new SimpleHistogram([1, 5])
|
|
96
|
-
const snap = h.snapshot({ transport: 'grpc' })
|
|
97
|
-
expect(snap.count).toBe(0)
|
|
98
|
-
expect(snap.sum).toBe(0)
|
|
99
|
-
expect(snap.buckets['+Inf']).toBe(0)
|
|
100
|
-
})
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
// ─── 4. renderPrometheusText produces valid Prometheus format ─────────────────
|
|
104
|
-
|
|
105
|
-
describe('renderPrometheusText', () => {
|
|
106
|
-
it('contains # HELP, # TYPE, and metric lines', () => {
|
|
107
|
-
const m = new Protocol7h3Metrics()
|
|
108
|
-
m.verifications_total.increment({ result: 'ok', alg: 'ED25519', transport: 'http' })
|
|
109
|
-
m.verifications_total.increment({ result: 'ok', alg: 'ED25519', transport: 'http' })
|
|
110
|
-
m.verifications_total.increment({ result: 'fail', alg: 'none', transport: 'http' })
|
|
111
|
-
m.verification_duration_ms.observe(2.5)
|
|
112
|
-
|
|
113
|
-
const text = renderPrometheusText(m)
|
|
114
|
-
|
|
115
|
-
expect(text).toMatch(/# HELP 7h3_verifications_total /)
|
|
116
|
-
expect(text).toMatch(/# TYPE 7h3_verifications_total counter/)
|
|
117
|
-
expect(text).toMatch(/7h3_verifications_total\{.*result="ok".*\} 2/)
|
|
118
|
-
expect(text).toMatch(/7h3_verifications_total\{.*result="fail".*\} 1/)
|
|
119
|
-
expect(text).toMatch(/# HELP 7h3_verification_duration_ms /)
|
|
120
|
-
expect(text).toMatch(/# TYPE 7h3_verification_duration_ms histogram/)
|
|
121
|
-
expect(text).toMatch(/7h3_verification_duration_ms_bucket\{.*le="\+Inf".*\} 1/)
|
|
122
|
-
expect(text).toMatch(/7h3_verification_duration_ms_count 1/)
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
it('respects a custom prefix', () => {
|
|
126
|
-
const m = new Protocol7h3Metrics()
|
|
127
|
-
m.rate_limit_hits_total.increment({ sender: 'agent-a', path: '/api' })
|
|
128
|
-
const text = renderPrometheusText(m, 'myapp')
|
|
129
|
-
expect(text).toMatch(/# HELP myapp_verifications_total/)
|
|
130
|
-
expect(text).toMatch(/myapp_rate_limit_hits_total/)
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
it('all expected metric names appear', () => {
|
|
134
|
-
const m = new Protocol7h3Metrics()
|
|
135
|
-
const text = renderPrometheusText(m)
|
|
136
|
-
const expectedNames = [
|
|
137
|
-
'7h3_verifications_total',
|
|
138
|
-
'7h3_verification_duration_ms',
|
|
139
|
-
'7h3_rate_limit_hits_total',
|
|
140
|
-
'7h3_sender_denials_total',
|
|
141
|
-
'7h3_replay_detections_total',
|
|
142
|
-
'7h3_audit_entries_total',
|
|
143
|
-
'7h3_active_connections',
|
|
144
|
-
]
|
|
145
|
-
for (const name of expectedNames) {
|
|
146
|
-
expect(text).toContain(`# HELP ${name}`)
|
|
147
|
-
}
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
it('ends with a newline', () => {
|
|
151
|
-
const m = new Protocol7h3Metrics()
|
|
152
|
-
const text = renderPrometheusText(m)
|
|
153
|
-
expect(text.endsWith('\n')).toBe(true)
|
|
154
|
-
})
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
// ─── 5. metrics.verifications_total increments on gateway verify calls ────────
|
|
158
|
-
|
|
159
|
-
describe('Protocol7h3Metrics integration via gateway verify', () => {
|
|
160
|
-
it('increments verifications_total on skip-verify path (allow policy)', async () => {
|
|
161
|
-
// Import gateway dynamically to get a fresh reference to globalMetrics
|
|
162
|
-
// We directly test Protocol7h3Metrics counting logic by simulating the verify flow
|
|
163
|
-
const m = new Protocol7h3Metrics()
|
|
164
|
-
|
|
165
|
-
// Simulate a successful skip-verify (allow-all gateway)
|
|
166
|
-
m.verifications_total.increment({ result: 'ok', alg: 'none', transport: 'http' })
|
|
167
|
-
|
|
168
|
-
expect(m.verifications_total.value({ result: 'ok', alg: 'none', transport: 'http' })).toBe(1)
|
|
169
|
-
expect(m.verifications_total.value({ result: 'fail', alg: 'none', transport: 'http' })).toBe(0)
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
it('increments verifications_total fail on rate-limit', async () => {
|
|
173
|
-
const m = new Protocol7h3Metrics()
|
|
174
|
-
|
|
175
|
-
// Simulate a rate-limit hit
|
|
176
|
-
m.verifications_total.increment({ result: 'fail', alg: 'ED25519', transport: 'http' })
|
|
177
|
-
m.rate_limit_hits_total.increment({ sender: 'agent-x', path: '/api/data' })
|
|
178
|
-
|
|
179
|
-
expect(m.verifications_total.value({ result: 'fail', alg: 'ED25519', transport: 'http' })).toBe(1)
|
|
180
|
-
expect(m.rate_limit_hits_total.value({ sender: 'agent-x', path: '/api/data' })).toBe(1)
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
it('increments sender_denials_total on 403', async () => {
|
|
184
|
-
const m = new Protocol7h3Metrics()
|
|
185
|
-
|
|
186
|
-
m.verifications_total.increment({ result: 'fail', alg: 'ED25519', transport: 'http' })
|
|
187
|
-
m.sender_denials_total.increment({ sender: 'rogue-agent', path: '/admin' })
|
|
188
|
-
|
|
189
|
-
expect(m.sender_denials_total.value({ sender: 'rogue-agent', path: '/admin' })).toBe(1)
|
|
190
|
-
expect(m.verifications_total.value({ result: 'fail', alg: 'ED25519', transport: 'http' })).toBe(1)
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
it('records duration in verification_duration_ms', () => {
|
|
194
|
-
const m = new Protocol7h3Metrics()
|
|
195
|
-
|
|
196
|
-
m.verification_duration_ms.observe(0.3)
|
|
197
|
-
m.verification_duration_ms.observe(2.0)
|
|
198
|
-
m.verification_duration_ms.observe(120)
|
|
199
|
-
|
|
200
|
-
const snap = m.verification_duration_ms.snapshot()
|
|
201
|
-
expect(snap.count).toBe(3)
|
|
202
|
-
expect(snap.sum).toBeCloseTo(0.3 + 2.0 + 120)
|
|
203
|
-
// 0.3 ≤ 0.5 → should appear in bucket 0.5
|
|
204
|
-
expect(snap.buckets['0.5']).toBe(1)
|
|
205
|
-
// +Inf must be count
|
|
206
|
-
expect(snap.buckets['+Inf']).toBe(3)
|
|
207
|
-
})
|
|
208
|
-
})
|
|
209
|
-
|
|
210
|
-
// ─── 6. createMetricsMiddleware serves metrics ────────────────────────────────
|
|
211
|
-
|
|
212
|
-
describe('createMetricsMiddleware', () => {
|
|
213
|
-
it('serves Prometheus text at /metrics', () => {
|
|
214
|
-
const m = new Protocol7h3Metrics()
|
|
215
|
-
m.verifications_total.increment({ result: 'ok', alg: 'ED25519', transport: 'http' })
|
|
216
|
-
const middleware = createMetricsMiddleware('/metrics', m)
|
|
217
|
-
|
|
218
|
-
let status = 0
|
|
219
|
-
let body = ''
|
|
220
|
-
let headers: Record<string, string> = {}
|
|
221
|
-
const req = { url: '/metrics', method: 'GET' }
|
|
222
|
-
const res = {
|
|
223
|
-
writeHead(s: number, h: Record<string, string>) { status = s; headers = h },
|
|
224
|
-
end(b: string) { body = b },
|
|
225
|
-
}
|
|
226
|
-
let nextCalled = false
|
|
227
|
-
const next = () => { nextCalled = true }
|
|
228
|
-
|
|
229
|
-
middleware(req, res, next)
|
|
230
|
-
|
|
231
|
-
expect(status).toBe(200)
|
|
232
|
-
expect(headers['content-type']).toMatch(/text\/plain/)
|
|
233
|
-
expect(body).toContain('# HELP 7h3_verifications_total')
|
|
234
|
-
expect(nextCalled).toBe(false)
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
it('calls next() for non-metrics paths', () => {
|
|
238
|
-
const m = new Protocol7h3Metrics()
|
|
239
|
-
const middleware = createMetricsMiddleware('/metrics', m)
|
|
240
|
-
|
|
241
|
-
let nextCalled = false
|
|
242
|
-
const req = { url: '/api/data', method: 'GET' }
|
|
243
|
-
const res = {
|
|
244
|
-
writeHead() {},
|
|
245
|
-
end() {},
|
|
246
|
-
}
|
|
247
|
-
middleware(req, res, () => { nextCalled = true })
|
|
248
|
-
|
|
249
|
-
expect(nextCalled).toBe(true)
|
|
250
|
-
})
|
|
251
|
-
})
|