@7h3/protocol 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +1169 -175
  3. package/bin/7h3.ts +22 -1
  4. package/docs/assets/banner-github.png +0 -0
  5. package/docs/assets/banner.svg +123 -0
  6. package/package.json +55 -13
  7. package/sdk/browser/package.json +1 -1
  8. package/sdk/go/cbor.go +551 -0
  9. package/sdk/go/cbor_test.go +232 -0
  10. package/sdk/go/encryption.go +280 -0
  11. package/sdk/go/encryption_test.go +318 -0
  12. package/sdk/go/go.mod +5 -1
  13. package/sdk/go/go.sum +4 -0
  14. package/sdk/go/replay.go +121 -0
  15. package/sdk/go/replay_test.go +149 -0
  16. package/sdk/pq/package-lock.json +1358 -0
  17. package/sdk/pq/package.json +42 -0
  18. package/sdk/pq/src/index.test.ts +143 -0
  19. package/sdk/pq/src/index.ts +166 -0
  20. package/sdk/pq/tsconfig.json +14 -0
  21. package/sdk/pq/vitest.config.ts +7 -0
  22. package/sdk/python/protocol_7h3/encryption.py +252 -0
  23. package/sdk/python/protocol_7h3/pq.py +244 -0
  24. package/sdk/python/protocol_7h3/replay.py +98 -0
  25. package/sdk/python/pyproject.toml +1 -1
  26. package/sdk/python/tests/test_encryption.py +206 -0
  27. package/sdk/rust/Cargo.lock +1 -1
  28. package/sdk/rust/Cargo.toml +1 -1
  29. package/sdk/threshold/index.d.ts +68 -0
  30. package/sdk/threshold/index.d.ts.map +1 -0
  31. package/sdk/threshold/index.js +254 -0
  32. package/sdk/threshold/package-lock.json +1361 -0
  33. package/sdk/threshold/package.json +39 -0
  34. package/sdk/threshold/src/index.d.ts +68 -0
  35. package/sdk/threshold/src/index.d.ts.map +1 -0
  36. package/sdk/threshold/src/index.js +254 -0
  37. package/sdk/threshold/src/index.test.ts +238 -0
  38. package/sdk/threshold/src/index.ts +355 -0
  39. package/sdk/threshold/tsconfig.json +19 -0
  40. package/sdk/threshold/vitest.config.ts +12 -0
  41. package/src/capability.test.ts +504 -0
  42. package/src/capability.ts +380 -0
  43. package/src/cborCodec.test.ts +263 -0
  44. package/src/cborCodec.ts +339 -0
  45. package/src/encryption.test.ts +206 -0
  46. package/src/encryption.ts +245 -0
  47. package/src/envelopeCbor.ts +140 -0
  48. package/src/gateway.ts +75 -0
  49. package/src/httpBinding.ts +37 -11
  50. package/src/index.ts +7 -0
  51. package/src/otel.ts +136 -0
  52. package/src/protocol.d.ts +67 -0
  53. package/src/protocol.d.ts.map +1 -0
  54. package/src/protocol.js +294 -0
  55. package/src/protocol.ts +1 -0
  56. package/src/replayStores.test.ts +133 -1
  57. package/src/replayStores.ts +136 -3
  58. package/src/stream.test.ts +254 -0
  59. package/src/stream.ts +417 -0
  60. package/src/telemetry.test.ts +251 -0
  61. package/src/telemetry.ts +299 -0
  62. package/src/wsBinding.ts +100 -0
  63. package/vitest.config.ts +11 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * telemetry.ts — Prometheus metrics for 7h3 Protocol
3
+ *
4
+ * Zero runtime dependencies. Plain in-memory counters and histograms rendered
5
+ * to standard Prometheus text exposition format.
6
+ */
7
+
8
+ // ─── Types ───────────────────────────────────────────────────────────────────
9
+
10
+ export interface TelemetryConfig {
11
+ enabled?: boolean
12
+ prefix?: string // default '7h3'
13
+ }
14
+
15
+ export interface Counter {
16
+ increment(labels?: Record<string, string>): void
17
+ value(labels?: Record<string, string>): number
18
+ }
19
+
20
+ export interface HistogramSnapshot {
21
+ count: number
22
+ sum: number
23
+ buckets: Record<string, number> // upper bound (as string) → cumulative count
24
+ }
25
+
26
+ export interface Histogram {
27
+ observe(value: number, labels?: Record<string, string>): void
28
+ snapshot(labels?: Record<string, string>): HistogramSnapshot
29
+ }
30
+
31
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
32
+
33
+ function labelKey(labels?: Record<string, string>): string {
34
+ if (!labels || Object.keys(labels).length === 0) return '__default__'
35
+ return Object.entries(labels)
36
+ .sort(([a], [b]) => a.localeCompare(b))
37
+ .map(([k, v]) => `${k}=${v}`)
38
+ .join(',')
39
+ }
40
+
41
+ function renderLabels(labels?: Record<string, string>): string {
42
+ if (!labels || Object.keys(labels).length === 0) return ''
43
+ const parts = Object.entries(labels)
44
+ .sort(([a], [b]) => a.localeCompare(b))
45
+ .map(([k, v]) => `${k}="${v}"`)
46
+ return `{${parts.join(',')}}`
47
+ }
48
+
49
+ // ─── SimpleCounter ────────────────────────────────────────────────────────────
50
+
51
+ export class SimpleCounter implements Counter {
52
+ private counts = new Map<string, number>()
53
+ private labelSets = new Map<string, Record<string, string> | undefined>()
54
+
55
+ increment(labels?: Record<string, string>): void {
56
+ const key = labelKey(labels)
57
+ this.counts.set(key, (this.counts.get(key) ?? 0) + 1)
58
+ if (!this.labelSets.has(key)) {
59
+ this.labelSets.set(key, labels)
60
+ }
61
+ }
62
+
63
+ value(labels?: Record<string, string>): number {
64
+ return this.counts.get(labelKey(labels)) ?? 0
65
+ }
66
+
67
+ entries(): Array<{ labels?: Record<string, string>; value: number }> {
68
+ const result: Array<{ labels?: Record<string, string>; value: number }> = []
69
+ for (const [key, value] of this.counts) {
70
+ result.push({ labels: this.labelSets.get(key), value })
71
+ }
72
+ return result
73
+ }
74
+ }
75
+
76
+ // ─── SimpleHistogram ──────────────────────────────────────────────────────────
77
+
78
+ interface BucketState {
79
+ count: number
80
+ sum: number
81
+ // bucket upper bounds → cumulative count (populated on snapshot)
82
+ observations: number[]
83
+ }
84
+
85
+ export class SimpleHistogram implements Histogram {
86
+ private bucketBounds: number[]
87
+ private states = new Map<string, BucketState>()
88
+ private labelSets = new Map<string, Record<string, string> | undefined>()
89
+
90
+ constructor(buckets: number[] = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]) {
91
+ this.bucketBounds = [...buckets].sort((a, b) => a - b)
92
+ }
93
+
94
+ observe(value: number, labels?: Record<string, string>): void {
95
+ const key = labelKey(labels)
96
+ if (!this.states.has(key)) {
97
+ this.states.set(key, { count: 0, sum: 0, observations: [] })
98
+ this.labelSets.set(key, labels)
99
+ }
100
+ const state = this.states.get(key)!
101
+ state.count++
102
+ state.sum += value
103
+ state.observations.push(value)
104
+ }
105
+
106
+ snapshot(labels?: Record<string, string>): HistogramSnapshot {
107
+ const key = labelKey(labels)
108
+ const state = this.states.get(key) ?? { count: 0, sum: 0, observations: [] }
109
+
110
+ const buckets: Record<string, number> = {}
111
+ for (const bound of this.bucketBounds) {
112
+ buckets[String(bound)] = state.observations.filter(v => v <= bound).length
113
+ }
114
+ // +Inf bucket
115
+ buckets['+Inf'] = state.count
116
+
117
+ return { count: state.count, sum: state.sum, buckets }
118
+ }
119
+
120
+ entries(): Array<{ labels?: Record<string, string>; snapshot: HistogramSnapshot }> {
121
+ const result: Array<{ labels?: Record<string, string>; snapshot: HistogramSnapshot }> = []
122
+ for (const [key] of this.states) {
123
+ const labels = this.labelSets.get(key)
124
+ result.push({ labels, snapshot: this.snapshot(labels) })
125
+ }
126
+ return result
127
+ }
128
+
129
+ getBounds(): number[] {
130
+ return [...this.bucketBounds]
131
+ }
132
+ }
133
+
134
+ // ─── Protocol7h3Metrics ───────────────────────────────────────────────────────
135
+
136
+ export class Protocol7h3Metrics {
137
+ /** labels: result (ok|fail), alg (ED25519|HS256|none), transport (http|ws|grpc|queue|webhook) */
138
+ verifications_total = new SimpleCounter()
139
+
140
+ /** buckets in ms */
141
+ verification_duration_ms = new SimpleHistogram([0.1, 0.5, 1, 5, 10, 50, 100])
142
+
143
+ /** labels: sender, path */
144
+ rate_limit_hits_total = new SimpleCounter()
145
+
146
+ /** labels: sender, path */
147
+ sender_denials_total = new SimpleCounter()
148
+
149
+ /** labels: transport */
150
+ replay_detections_total = new SimpleCounter()
151
+
152
+ /** labels: type */
153
+ audit_entries_total = new SimpleCounter()
154
+
155
+ /** labels: transport — use for WS/gRPC connection tracking */
156
+ active_connections = new SimpleCounter()
157
+ }
158
+
159
+ // ─── Global instance ──────────────────────────────────────────────────────────
160
+
161
+ export const metrics = new Protocol7h3Metrics()
162
+
163
+ // ─── Prometheus text format renderer ─────────────────────────────────────────
164
+
165
+ export function renderPrometheusText(m: Protocol7h3Metrics, prefix = '7h3'): string {
166
+ const lines: string[] = []
167
+
168
+ // Helper: emit a counter
169
+ function emitCounter(name: string, help: string, counter: SimpleCounter): void {
170
+ const fullName = `${prefix}_${name}`
171
+ lines.push(`# HELP ${fullName} ${help}`)
172
+ lines.push(`# TYPE ${fullName} counter`)
173
+ const entries = counter.entries()
174
+ if (entries.length === 0) {
175
+ lines.push(`${fullName} 0`)
176
+ } else {
177
+ for (const { labels, value } of entries) {
178
+ if (labels && Object.keys(labels).length > 0) {
179
+ lines.push(`${fullName}${renderLabels(labels)} ${value}`)
180
+ } else {
181
+ lines.push(`${fullName} ${value}`)
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ // Helper: emit a histogram
188
+ function emitHistogram(name: string, help: string, histogram: SimpleHistogram): void {
189
+ const fullName = `${prefix}_${name}`
190
+ lines.push(`# HELP ${fullName} ${help}`)
191
+ lines.push(`# TYPE ${fullName} histogram`)
192
+ const entries = histogram.entries()
193
+ if (entries.length === 0) {
194
+ // Emit empty histogram with zero counts
195
+ for (const bound of histogram.getBounds()) {
196
+ lines.push(`${fullName}_bucket{le="${bound}"} 0`)
197
+ }
198
+ lines.push(`${fullName}_bucket{le="+Inf"} 0`)
199
+ lines.push(`${fullName}_sum 0`)
200
+ lines.push(`${fullName}_count 0`)
201
+ } else {
202
+ for (const { labels, snapshot } of entries) {
203
+ const labelStr = labels && Object.keys(labels).length > 0
204
+ ? renderLabels(labels)
205
+ : ''
206
+
207
+ for (const [bound, count] of Object.entries(snapshot.buckets)) {
208
+ if (bound === '+Inf') continue
209
+ const bucketLabels = labelStr
210
+ ? labelStr.slice(0, -1) + `,le="${bound}"}`
211
+ : `{le="${bound}"}`
212
+ lines.push(`${fullName}_bucket${bucketLabels} ${count}`)
213
+ }
214
+ // +Inf bucket
215
+ const infLabels = labelStr
216
+ ? labelStr.slice(0, -1) + `,le="+Inf"}`
217
+ : `{le="+Inf"}`
218
+ lines.push(`${fullName}_bucket${infLabels} ${snapshot.count}`)
219
+ lines.push(`${fullName}_sum${labelStr} ${snapshot.sum}`)
220
+ lines.push(`${fullName}_count${labelStr} ${snapshot.count}`)
221
+ }
222
+ }
223
+ }
224
+
225
+ emitCounter(
226
+ 'verifications_total',
227
+ 'Total number of AIP envelope verifications, by result, algorithm, and transport',
228
+ m.verifications_total,
229
+ )
230
+
231
+ emitHistogram(
232
+ 'verification_duration_ms',
233
+ 'Verification latency in milliseconds',
234
+ m.verification_duration_ms,
235
+ )
236
+
237
+ emitCounter(
238
+ 'rate_limit_hits_total',
239
+ 'Total number of rate-limit rejections, by sender and path',
240
+ m.rate_limit_hits_total,
241
+ )
242
+
243
+ emitCounter(
244
+ 'sender_denials_total',
245
+ 'Total number of sender-denied (403) rejections, by sender and path',
246
+ m.sender_denials_total,
247
+ )
248
+
249
+ emitCounter(
250
+ 'replay_detections_total',
251
+ 'Total number of replay-detected events, by transport',
252
+ m.replay_detections_total,
253
+ )
254
+
255
+ emitCounter(
256
+ 'audit_entries_total',
257
+ 'Total number of audit log entries written, by type',
258
+ m.audit_entries_total,
259
+ )
260
+
261
+ emitCounter(
262
+ 'active_connections',
263
+ 'Current active connections, by transport (WS/gRPC)',
264
+ m.active_connections,
265
+ )
266
+
267
+ return lines.join('\n') + '\n'
268
+ }
269
+
270
+ // ─── Node.js http-compatible metrics middleware ───────────────────────────────
271
+
272
+ type NodeReq = { url?: string; method?: string }
273
+ type NodeRes = {
274
+ writeHead(status: number, headers: Record<string, string>): void
275
+ end(body: string): void
276
+ }
277
+ type NextFn = () => void
278
+
279
+ /**
280
+ * Returns a Node.js http-compatible middleware that serves Prometheus metrics
281
+ * at the given path (default: /metrics).
282
+ */
283
+ export function createMetricsMiddleware(
284
+ path = '/metrics',
285
+ m: Protocol7h3Metrics = metrics,
286
+ prefix = '7h3',
287
+ ): (req: NodeReq, res: NodeRes, next: NextFn) => void {
288
+ return (req, res, next) => {
289
+ if (req.url === path && (req.method === 'GET' || req.method === undefined)) {
290
+ const body = renderPrometheusText(m, prefix)
291
+ res.writeHead(200, {
292
+ 'content-type': 'text/plain; version=0.0.4; charset=utf-8',
293
+ })
294
+ res.end(body)
295
+ } else {
296
+ next()
297
+ }
298
+ }
299
+ }
package/src/wsBinding.ts CHANGED
@@ -4,8 +4,19 @@ import {
4
4
  generateEd25519KeypairBase64Url, type ProtocolEnvelope
5
5
  } from './protocol'
6
6
  import type { KeyRegistry } from './keyRegistry'
7
+ import {
8
+ SignedStreamWriter,
9
+ verifyStream,
10
+ decodeStreamChunk,
11
+ encodeStreamChunk,
12
+ type StreamSignerOpts,
13
+ type StreamVerifierOpts,
14
+ type StreamVerifyResult,
15
+ type StreamChunk,
16
+ } from './stream'
7
17
 
8
18
  export { type KeyRegistry, generateEd25519KeypairBase64Url }
19
+ export type { StreamSignerOpts, StreamVerifierOpts, StreamVerifyResult, StreamChunk }
9
20
 
10
21
  // Minimal WebSocket interface (works with browser WebSocket, ws library, etc.)
11
22
  export interface WebSocketLike {
@@ -98,3 +109,92 @@ export function wrapWebSocket(ws: WebSocketLike, opts: WsBindingOptions): Protec
98
109
  onVerifyFail(handler) { failHandlers.push(handler) },
99
110
  }
100
111
  }
112
+
113
+ // ─────────────────── Stream signing over WebSocket ────────────────────────────
114
+
115
+ /**
116
+ * createSignedWebSocketStream
117
+ *
118
+ * Attaches a SignedStreamWriter to a WebSocket:
119
+ * - Each incoming WebSocket message is signed as a stream chunk and sent back.
120
+ * - On connection close, the final signed frame is sent.
121
+ *
122
+ * Returns the underlying SignedStreamWriter so callers can also call writeChunk
123
+ * directly (e.g. when driving the stream from application code rather than
124
+ * forwarding received messages).
125
+ */
126
+ export function createSignedWebSocketStream(
127
+ ws: WebSocketLike,
128
+ opts: StreamSignerOpts,
129
+ ): SignedStreamWriter {
130
+ const writer = new SignedStreamWriter(opts)
131
+
132
+ const messageListener = async (e: { data: string | Buffer }) => {
133
+ const data = typeof e.data === 'string' ? e.data : e.data.toString('utf8')
134
+ const chunk = await writer.writeChunk(data)
135
+ ws.send(encodeStreamChunk(chunk))
136
+ }
137
+
138
+ const closeListener = async () => {
139
+ ws.removeEventListener('message', messageListener as any)
140
+ ws.removeEventListener('close', closeListener as any)
141
+ const finalChunk = await writer.finalize()
142
+ ws.send(encodeStreamChunk(finalChunk))
143
+ }
144
+
145
+ ws.addEventListener('message', messageListener as any)
146
+ ws.addEventListener('close', closeListener as any)
147
+
148
+ return writer
149
+ }
150
+
151
+ /**
152
+ * receiveSignedWebSocketStream
153
+ *
154
+ * Listens for StreamChunk JSON frames on a WebSocket, accumulates them, and
155
+ * when the final frame (f=true) arrives calls verifyStream over the complete
156
+ * set of collected chunks.
157
+ *
158
+ * Resolves with StreamVerifyResult when the final frame is received.
159
+ * Rejects if a frame cannot be decoded.
160
+ */
161
+ export function receiveSignedWebSocketStream(
162
+ ws: WebSocketLike,
163
+ opts: StreamVerifierOpts,
164
+ ): Promise<StreamVerifyResult> {
165
+ return new Promise<StreamVerifyResult>((resolve, reject) => {
166
+ const collected: StreamChunk[] = []
167
+
168
+ const messageListener = async (e: { data: string | Buffer }) => {
169
+ const raw = typeof e.data === 'string' ? e.data : e.data.toString('utf8')
170
+ let chunk: StreamChunk
171
+ try {
172
+ chunk = decodeStreamChunk(raw)
173
+ } catch (err) {
174
+ cleanup()
175
+ reject(err)
176
+ return
177
+ }
178
+ collected.push(chunk)
179
+ if (chunk.f) {
180
+ cleanup()
181
+ const result = await verifyStream(collected, opts)
182
+ resolve(result)
183
+ }
184
+ }
185
+
186
+ const closeListener = () => {
187
+ // Connection closed before receiving final frame
188
+ cleanup()
189
+ resolve({ ok: false, reason: 'connection closed before final frame' })
190
+ }
191
+
192
+ function cleanup() {
193
+ ws.removeEventListener('message', messageListener as any)
194
+ ws.removeEventListener('close', closeListener as any)
195
+ }
196
+
197
+ ws.addEventListener('message', messageListener as any)
198
+ ws.addEventListener('close', closeListener as any)
199
+ })
200
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ exclude: [
6
+ 'sdk/pq/dist/**',
7
+ 'sdk/threshold/dist/**',
8
+ '**/node_modules/**',
9
+ ],
10
+ },
11
+ })