@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,245 @@
1
+ /**
2
+ * E2E Encryption for 7h3 Protocol
3
+ *
4
+ * Uses X25519 Diffie-Hellman key exchange + ChaCha20-Poly1305 AEAD.
5
+ * All operations via Node.js built-in `node:crypto` — zero new dependencies.
6
+ *
7
+ * Architecture:
8
+ * EncryptedEnvelope = SignedEnvelope where body.content is a base64url-encoded
9
+ * EncryptedPayload, body.intent = 'ENCRYPTED', body.capability = 'x25519-chacha20poly1305'
10
+ */
11
+
12
+ import { createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from 'node:crypto'
13
+ import type { KeyObject } from 'node:crypto'
14
+ import {
15
+ signEnvelopeEd25519,
16
+ verifyEnvelopeEd25519,
17
+ type ProtocolBody,
18
+ type ProtocolEnvelope,
19
+ } from './protocol.js'
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Types
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface X25519KeyPair {
26
+ /** Raw 32-byte X25519 public key, base64url-encoded (no padding) */
27
+ publicKey: string
28
+ /** Raw 32-byte X25519 private key, base64url-encoded (no padding) */
29
+ privateKey: string
30
+ }
31
+
32
+ export interface EncryptedPayload {
33
+ /** Ephemeral X25519 public key (base64url, raw 32 bytes) */
34
+ ephemeralPublic: string
35
+ /** ChaCha20 nonce / HKDF salt (base64url, 12 bytes) */
36
+ nonce: string
37
+ /** Ciphertext without auth tag (base64url) */
38
+ ciphertext: string
39
+ /** 16-byte Poly1305 auth tag (base64url) */
40
+ tag: string
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Key helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ function toBase64Url(buf: Buffer | Uint8Array): string {
48
+ return Buffer.from(buf).toString('base64url')
49
+ }
50
+
51
+ function fromBase64Url(s: string): Buffer {
52
+ return Buffer.from(s, 'base64url')
53
+ }
54
+
55
+ /**
56
+ * X25519 PKCS8 DER header: 30 2e 02 01 00 30 05 06 03 2b 65 6e 04 22 04 20
57
+ * (RFC 5958 / RFC 8410 encoding for X25519, OID 1.3.101.110 = 2b 65 6e)
58
+ */
59
+ const X25519_PKCS8_HEADER = Buffer.from('302e020100300506032b656e04220420', 'hex')
60
+
61
+ /**
62
+ * X25519 SPKI DER header: 30 2a 30 05 06 03 2b 65 6e 03 21 00
63
+ * (RFC 5480 SubjectPublicKeyInfo for X25519)
64
+ */
65
+ const X25519_SPKI_HEADER = Buffer.from('302a300506032b656e032100', 'hex')
66
+
67
+ function importX25519Private(privRaw32Base64Url: string): KeyObject {
68
+ const rawBytes = fromBase64Url(privRaw32Base64Url)
69
+ // Build PKCS8 DER: fixed 16-byte header + 32-byte raw private key
70
+ const der = Buffer.concat([X25519_PKCS8_HEADER, rawBytes])
71
+ return createPrivateKey({ key: der, format: 'der', type: 'pkcs8' })
72
+ }
73
+
74
+ function importX25519Public(pubRaw32Base64Url: string): KeyObject {
75
+ const rawBytes = fromBase64Url(pubRaw32Base64Url)
76
+ // Build SPKI DER: fixed 12-byte header + 32-byte raw public key
77
+ const der = Buffer.concat([X25519_SPKI_HEADER, rawBytes])
78
+ return createPublicKey({ key: der, format: 'der', type: 'spki' })
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Public API
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Generate a fresh X25519 keypair.
87
+ * Both keys are raw 32-byte values encoded as base64url (no padding).
88
+ */
89
+ export function generateX25519KeyPair(): X25519KeyPair {
90
+ const { privateKey, publicKey } = generateKeyPairSync('x25519')
91
+ const privJwk = privateKey.export({ format: 'jwk' }) as { d: string }
92
+ const pubJwk = publicKey.export({ format: 'jwk' }) as { x: string }
93
+ return {
94
+ publicKey: pubJwk.x,
95
+ privateKey: privJwk.d,
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Perform X25519 DH + HKDF-SHA256 to derive a 32-byte ChaCha20-Poly1305 key.
101
+ *
102
+ * @param privateKeyBase64Url - raw 32-byte X25519 private key (base64url)
103
+ * @param peerPublicKeyBase64Url - raw 32-byte X25519 public key (base64url)
104
+ * @param nonce - raw 12-byte nonce (base64url); used as HKDF salt
105
+ * @returns 32-byte Buffer ready for use with createCipheriv/createDecipheriv
106
+ */
107
+ export function deriveEncryptionKey(
108
+ privateKeyBase64Url: string,
109
+ peerPublicKeyBase64Url: string,
110
+ nonce: string,
111
+ ): Buffer {
112
+ const privKey = importX25519Private(privateKeyBase64Url)
113
+ const pubKey = importX25519Public(peerPublicKeyBase64Url)
114
+
115
+ const sharedSecret = diffieHellman({ privateKey: privKey, publicKey: pubKey })
116
+ const salt = fromBase64Url(nonce)
117
+ const info = Buffer.from('7h3-enc/1', 'utf8')
118
+
119
+ const derived = hkdfSync('sha256', sharedSecret, salt, info, 32)
120
+ return Buffer.from(derived)
121
+ }
122
+
123
+ /**
124
+ * Encrypt a ProtocolBody with the recipient's X25519 public key.
125
+ *
126
+ * @returns encryptedContent (base64url-encoded EncryptedPayload JSON) and ephemeralPublic
127
+ */
128
+ export function encryptBody(
129
+ body: ProtocolBody,
130
+ recipientX25519PublicKey: string,
131
+ ): { encryptedContent: string; ephemeralPublic: string } {
132
+ // 1. Generate ephemeral X25519 keypair for forward secrecy
133
+ const ephemeral = generateX25519KeyPair()
134
+
135
+ // 2. Generate 12-byte ChaCha nonce (also used as HKDF salt)
136
+ const nonce12 = randomBytes(12)
137
+ const nonceBase64Url = toBase64Url(nonce12)
138
+
139
+ // 3. Derive encryption key
140
+ const key = deriveEncryptionKey(ephemeral.privateKey, recipientX25519PublicKey, nonceBase64Url)
141
+
142
+ // 4. Encrypt body as JSON
143
+ const plaintext = Buffer.from(JSON.stringify(body), 'utf8')
144
+ const cipher = createCipheriv('chacha20-poly1305', key, nonce12, { authTagLength: 16 })
145
+ const ciphertextBuf = Buffer.concat([cipher.update(plaintext), cipher.final()])
146
+ const tag = cipher.getAuthTag()
147
+
148
+ // 5. Build EncryptedPayload
149
+ const payload: EncryptedPayload = {
150
+ ephemeralPublic: ephemeral.publicKey,
151
+ nonce: nonceBase64Url,
152
+ ciphertext: toBase64Url(ciphertextBuf),
153
+ tag: toBase64Url(tag),
154
+ }
155
+
156
+ return {
157
+ encryptedContent: toBase64Url(Buffer.from(JSON.stringify(payload), 'utf8')),
158
+ ephemeralPublic: ephemeral.publicKey,
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Decrypt an encrypted ProtocolBody.
164
+ *
165
+ * @param encryptedContent - base64url-encoded EncryptedPayload
166
+ * @param recipientX25519PrivateKey - raw 32-byte X25519 private key (base64url)
167
+ * @returns decrypted ProtocolBody
168
+ * @throws if AEAD tag verification fails
169
+ */
170
+ export function decryptBody(encryptedContent: string, recipientX25519PrivateKey: string): ProtocolBody {
171
+ const payloadJson = fromBase64Url(encryptedContent).toString('utf8')
172
+ const payload = JSON.parse(payloadJson) as EncryptedPayload
173
+
174
+ const { ephemeralPublic, nonce, ciphertext, tag } = payload
175
+
176
+ // Derive the same key using recipient's private key + ephemeral public key
177
+ const key = deriveEncryptionKey(recipientX25519PrivateKey, ephemeralPublic, nonce)
178
+
179
+ const nonce12 = fromBase64Url(nonce)
180
+ const ciphertextBuf = fromBase64Url(ciphertext)
181
+ const tagBuf = fromBase64Url(tag)
182
+
183
+ const decipher = createDecipheriv('chacha20-poly1305', key, nonce12, { authTagLength: 16 })
184
+ decipher.setAuthTag(tagBuf)
185
+
186
+ const plaintext = Buffer.concat([decipher.update(ciphertextBuf), decipher.final()])
187
+ return JSON.parse(plaintext.toString('utf8')) as ProtocolBody
188
+ }
189
+
190
+ /**
191
+ * Encrypt and sign an envelope (full pipeline).
192
+ *
193
+ * The original body is encrypted; the envelope body is replaced with:
194
+ * { intent: 'ENCRYPTED', content: <encrypted-payload>, capability: 'x25519-chacha20poly1305' }
195
+ * The modified envelope is then signed with Ed25519.
196
+ */
197
+ export async function sealEnvelope(
198
+ envelope: Omit<ProtocolEnvelope, 'signature'>,
199
+ opts: {
200
+ recipientX25519PublicKey: string
201
+ senderEd25519PrivateKey: string
202
+ },
203
+ ): Promise<ProtocolEnvelope> {
204
+ const { encryptedContent } = encryptBody(envelope.body, opts.recipientX25519PublicKey)
205
+
206
+ const encryptedEnvelope: Omit<ProtocolEnvelope, 'signature'> = {
207
+ header: envelope.header,
208
+ body: {
209
+ intent: 'ENCRYPTED' as const,
210
+ content: encryptedContent,
211
+ capability: 'x25519-chacha20poly1305',
212
+ correlationId: envelope.body.correlationId,
213
+ },
214
+ }
215
+
216
+ // Sign the envelope containing the encrypted body
217
+ return signEnvelopeEd25519(encryptedEnvelope, opts.senderEd25519PrivateKey)
218
+ }
219
+
220
+ /**
221
+ * Verify signature and decrypt an envelope (full pipeline).
222
+ *
223
+ * Signature is verified FIRST; decryption only proceeds if valid.
224
+ *
225
+ * @returns The signed envelope (with encrypted body) and the decrypted original body
226
+ * @throws if Ed25519 signature invalid or AEAD tag fails
227
+ */
228
+ export async function openEnvelope(
229
+ envelope: ProtocolEnvelope,
230
+ opts: {
231
+ recipientX25519PrivateKey: string
232
+ senderEd25519PublicKey: string
233
+ },
234
+ ): Promise<{ envelope: ProtocolEnvelope; body: ProtocolBody }> {
235
+ // 1. Verify Ed25519 signature FIRST — reject before decrypting
236
+ const valid = await verifyEnvelopeEd25519(envelope, opts.senderEd25519PublicKey)
237
+ if (!valid) {
238
+ throw new Error('7h3/encryption: Ed25519 signature verification failed')
239
+ }
240
+
241
+ // 2. Decrypt body
242
+ const body = decryptBody(envelope.body.content, opts.recipientX25519PrivateKey)
243
+
244
+ return { envelope, body }
245
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Higher-level CBOR codec for ProtocolEnvelope.
3
+ * Uses numeric field keys for maximum compactness.
4
+ *
5
+ * Envelope map structure:
6
+ * 1 → header map
7
+ * 2 → body map
8
+ * 3 → signature map (omitted if no signature)
9
+ *
10
+ * Header map:
11
+ * 1 → version (string)
12
+ * 2 → messageId (string)
13
+ * 3 → timestampMs (int)
14
+ * 4 → ttlMs (int)
15
+ * 5 → sender (string)
16
+ * 6 → recipient (string, omit if absent)
17
+ * 7 → nonce (string)
18
+ *
19
+ * Body map:
20
+ * 1 → intent (string)
21
+ * 2 → content (string)
22
+ * 3 → capability (string, omit if absent)
23
+ * 4 → correlationId (string, omit if absent)
24
+ *
25
+ * Signature map:
26
+ * 1 → alg (string)
27
+ * 2 → keyId (string)
28
+ * 3 → value (string — base64url signature)
29
+ */
30
+
31
+ import type { ProtocolEnvelope, ProtocolSignature } from './protocol'
32
+ import { encodeCbor, decodeCbor } from './cborCodec'
33
+
34
+ export const CBOR_CONTENT_TYPE = 'application/7h3-cbor'
35
+
36
+ export function encodeEnvelopeCbor(env: ProtocolEnvelope): Uint8Array {
37
+ // Build header map
38
+ const headerMap = new Map<number, unknown>()
39
+ headerMap.set(1, env.header.version)
40
+ headerMap.set(2, env.header.messageId)
41
+ headerMap.set(3, env.header.timestampMs)
42
+ headerMap.set(4, env.header.ttlMs)
43
+ headerMap.set(5, env.header.sender)
44
+ if (env.header.recipient !== undefined) {
45
+ headerMap.set(6, env.header.recipient)
46
+ }
47
+ headerMap.set(7, env.header.nonce)
48
+
49
+ // Build body map
50
+ const bodyMap = new Map<number, unknown>()
51
+ bodyMap.set(1, env.body.intent)
52
+ bodyMap.set(2, env.body.content)
53
+ if (env.body.capability !== undefined) {
54
+ bodyMap.set(3, env.body.capability)
55
+ }
56
+ if (env.body.correlationId !== undefined) {
57
+ bodyMap.set(4, env.body.correlationId)
58
+ }
59
+
60
+ // Build top-level map
61
+ const topMap = new Map<number, unknown>()
62
+ topMap.set(1, headerMap)
63
+ topMap.set(2, bodyMap)
64
+
65
+ const sig = env.signature
66
+ if (sig) {
67
+ const sigMap = new Map<number, unknown>()
68
+ sigMap.set(1, sig.alg)
69
+ sigMap.set(2, sig.keyId)
70
+ sigMap.set(3, sig.value)
71
+ topMap.set(3, sigMap)
72
+ }
73
+
74
+ return encodeCbor(topMap)
75
+ }
76
+
77
+ export function decodeEnvelopeCbor(data: Uint8Array): ProtocolEnvelope {
78
+ const top = decodeCbor(data)
79
+
80
+ if (!top || typeof top !== 'object' || Array.isArray(top)) {
81
+ throw new Error('decodeEnvelopeCbor: expected a map at top level')
82
+ }
83
+
84
+ const topRecord = top as Record<string, unknown>
85
+
86
+ // Header
87
+ const headerRaw = topRecord['1']
88
+ if (!headerRaw || typeof headerRaw !== 'object' || Array.isArray(headerRaw)) {
89
+ throw new Error('decodeEnvelopeCbor: missing header map (key 1)')
90
+ }
91
+ const hr = headerRaw as Record<string, unknown>
92
+
93
+ // Body
94
+ const bodyRaw = topRecord['2']
95
+ if (!bodyRaw || typeof bodyRaw !== 'object' || Array.isArray(bodyRaw)) {
96
+ throw new Error('decodeEnvelopeCbor: missing body map (key 2)')
97
+ }
98
+ const br = bodyRaw as Record<string, unknown>
99
+
100
+ const env: ProtocolEnvelope = {
101
+ header: {
102
+ version: hr['1'] as ProtocolEnvelope['header']['version'],
103
+ messageId: hr['2'] as string,
104
+ timestampMs: hr['3'] as number,
105
+ ttlMs: hr['4'] as number,
106
+ sender: hr['5'] as string,
107
+ nonce: hr['7'] as string,
108
+ },
109
+ body: {
110
+ intent: br['1'] as ProtocolEnvelope['body']['intent'],
111
+ content: br['2'] as string,
112
+ },
113
+ }
114
+
115
+ if (hr['6'] !== undefined) {
116
+ env.header.recipient = hr['6'] as string
117
+ }
118
+ if (br['3'] !== undefined) {
119
+ env.body.capability = br['3'] as string
120
+ }
121
+ if (br['4'] !== undefined) {
122
+ env.body.correlationId = br['4'] as string
123
+ }
124
+
125
+ // Signature
126
+ const sigRaw = topRecord['3']
127
+ if (sigRaw !== undefined && sigRaw !== null) {
128
+ if (typeof sigRaw !== 'object' || Array.isArray(sigRaw)) {
129
+ throw new Error('decodeEnvelopeCbor: expected signature map at key 3')
130
+ }
131
+ const sr = sigRaw as Record<string, unknown>
132
+ env.signature = {
133
+ alg: sr['1'] as ProtocolSignature['alg'],
134
+ keyId: sr['2'] as string,
135
+ value: sr['3'] as string,
136
+ }
137
+ }
138
+
139
+ return env
140
+ }
package/src/gateway.ts CHANGED
@@ -3,6 +3,9 @@ import { type RoutePolicy, matchPolicy, isAllowedSender } from './routePolicy'
3
3
  import { SlidingWindowRateLimiter } from './rateLimiter'
4
4
  import { verifyHttpEnvelope } from './httpBinding'
5
5
  import { signResponse } from './signedResponse'
6
+ import { metrics as globalMetrics } from './telemetry'
7
+ import type { ReplayStore } from './replayStores'
8
+ import { CAP_HEADER, parseCapabilityChain, verifyCapabilityChain, tokenMatchesScope } from './capability'
6
9
 
7
10
  export type { KeyRegistry, RoutePolicy }
8
11
 
@@ -15,6 +18,11 @@ export interface GatewayConfig {
15
18
  signResponses?: boolean // default true when privateKey set
16
19
  defaultPolicy?: 'allow' | 'deny' // default 'allow'
17
20
  headerName?: string
21
+ metricsPath?: string
22
+ /** Optional distributed replay store — prevents nonce reuse across gateway instances. */
23
+ replayStore?: ReplayStore
24
+ /** Optional capability token registry for capability-based auth. */
25
+ capabilityRegistry?: { getPublicKey(id: string): Promise<string | null> }
18
26
  }
19
27
 
20
28
  export interface GatewayRequest {
@@ -45,6 +53,7 @@ class Protocol7h3Gateway {
45
53
  }
46
54
 
47
55
  async verify(req: GatewayRequest): Promise<GatewayVerifyOutcome> {
56
+ const startMs = performance.now()
48
57
  const policy = matchPolicy(this.config.policies ?? [], req.path)
49
58
 
50
59
  // Determine if we skip verification
@@ -53,11 +62,48 @@ class Protocol7h3Gateway {
53
62
  (!policy && (this.config.defaultPolicy ?? 'allow') === 'allow')
54
63
 
55
64
  if (skipVerify) {
65
+ const durationMs = performance.now() - startMs
66
+ globalMetrics.verifications_total.increment({ result: 'ok', alg: 'none', transport: 'http' })
67
+ globalMetrics.verification_duration_ms.observe(durationMs)
56
68
  return { ok: true, sender: '' }
57
69
  }
58
70
 
71
+ // Capability token path — alternative auth via x-7h3-capability header
72
+ if (this.config.capabilityRegistry) {
73
+ const rawCap = req.headers[CAP_HEADER]
74
+ const capHeader = Array.isArray(rawCap) ? rawCap[0] : rawCap
75
+ if (capHeader) {
76
+ try {
77
+ const chain = parseCapabilityChain(capHeader)
78
+ const result = await verifyCapabilityChain(chain, this.config.capabilityRegistry, {
79
+ requiredPathGlob: req.path,
80
+ requiredMethod: req.method,
81
+ })
82
+ if (result.ok && tokenMatchesScope(result.token, req.path, req.method)) {
83
+ const durationMs = performance.now() - startMs
84
+ globalMetrics.verifications_total.increment({ result: 'ok', alg: 'ED25519', transport: 'http' })
85
+ globalMetrics.verification_duration_ms.observe(durationMs)
86
+ return { ok: true, sender: result.token.subject }
87
+ }
88
+ const durationMs = performance.now() - startMs
89
+ globalMetrics.verifications_total.increment({ result: 'fail', alg: 'none', transport: 'http' })
90
+ globalMetrics.verification_duration_ms.observe(durationMs)
91
+ return { ok: false, status: 401, reason: result.ok ? 'capability-scope-mismatch' : (result as { ok: false; reason: string }).reason }
92
+ } catch (e) {
93
+ const durationMs = performance.now() - startMs
94
+ globalMetrics.verifications_total.increment({ result: 'fail', alg: 'none', transport: 'http' })
95
+ globalMetrics.verification_duration_ms.observe(durationMs)
96
+ return { ok: false, status: 401, reason: 'invalid-capability-chain' }
97
+ }
98
+ }
99
+ }
100
+
59
101
  // deny if no policy and defaultPolicy is 'deny'
60
102
  if (!policy && (this.config.defaultPolicy ?? 'allow') === 'deny') {
103
+ const durationMs = performance.now() - startMs
104
+ globalMetrics.verifications_total.increment({ result: 'fail', alg: 'none', transport: 'http' })
105
+ globalMetrics.verification_duration_ms.observe(durationMs)
106
+ globalMetrics.sender_denials_total.increment({ sender: '', path: req.path })
61
107
  return { ok: false, status: 403, reason: 'no-matching-policy' }
62
108
  }
63
109
 
@@ -68,12 +114,27 @@ class Protocol7h3Gateway {
68
114
  })
69
115
 
70
116
  if (!result.ok) {
117
+ const durationMs = performance.now() - startMs
118
+ globalMetrics.verifications_total.increment({ result: 'fail', alg: 'none', transport: 'http' })
119
+ globalMetrics.verification_duration_ms.observe(durationMs)
71
120
  return { ok: false, status: 401, reason: result.reason }
72
121
  }
73
122
 
74
123
  const envelope = result.envelope
75
124
  const sender = envelope.header.sender
76
125
  const envelopeId = envelope.header.messageId
126
+ const alg = (envelope.signature?.alg as string | undefined) ?? 'none'
127
+
128
+ // Check replay store — prevents nonce reuse across multiple gateway instances
129
+ if (this.config.replayStore) {
130
+ const replayed = await this.config.replayStore.check(
131
+ envelope.header.nonce,
132
+ envelope.header.ttlMs,
133
+ )
134
+ if (replayed) {
135
+ return { ok: false, status: 401, reason: 'replay-detected' }
136
+ }
137
+ }
77
138
 
78
139
  // Enforce algorithm requirement when policy specifies a specific alg
79
140
  if (policy && policy.require !== 'any') {
@@ -81,12 +142,19 @@ class Protocol7h3Gateway {
81
142
  const requiresEd25519 = policy.require === 'ed25519' && actualAlg !== 'ED25519'
82
143
  const requiresHmac = policy.require === 'hmac' && actualAlg !== 'HS256'
83
144
  if (requiresEd25519 || requiresHmac) {
145
+ const durationMs = performance.now() - startMs
146
+ globalMetrics.verifications_total.increment({ result: 'fail', alg, transport: 'http' })
147
+ globalMetrics.verification_duration_ms.observe(durationMs)
84
148
  return { ok: false, status: 401, reason: 'invalid-signature' }
85
149
  }
86
150
  }
87
151
 
88
152
  // Check allowedSenders
89
153
  if (policy && !isAllowedSender(policy, sender)) {
154
+ const durationMs = performance.now() - startMs
155
+ globalMetrics.verifications_total.increment({ result: 'fail', alg, transport: 'http' })
156
+ globalMetrics.verification_duration_ms.observe(durationMs)
157
+ globalMetrics.sender_denials_total.increment({ sender, path: req.path })
90
158
  return { ok: false, status: 403, reason: 'sender-denied' }
91
159
  }
92
160
 
@@ -94,10 +162,17 @@ class Protocol7h3Gateway {
94
162
  if (policy?.rateLimit) {
95
163
  const rl = this.rateLimiter.consume(sender, policy.rateLimit)
96
164
  if (!rl.allowed) {
165
+ const durationMs = performance.now() - startMs
166
+ globalMetrics.verifications_total.increment({ result: 'fail', alg, transport: 'http' })
167
+ globalMetrics.verification_duration_ms.observe(durationMs)
168
+ globalMetrics.rate_limit_hits_total.increment({ sender, path: req.path })
97
169
  return { ok: false, status: 429, reason: 'rate-limited' }
98
170
  }
99
171
  }
100
172
 
173
+ const durationMs = performance.now() - startMs
174
+ globalMetrics.verifications_total.increment({ result: 'ok', alg, transport: 'http' })
175
+ globalMetrics.verification_duration_ms.observe(durationMs)
101
176
  return { ok: true, sender, envelopeId }
102
177
  }
103
178
 
@@ -9,6 +9,9 @@ import {
9
9
  validateEnvelope,
10
10
  } from './protocol'
11
11
  import type { KeyRegistry } from './keyRegistry'
12
+ import { encodeEnvelopeCbor, decodeEnvelopeCbor, CBOR_CONTENT_TYPE as _CBOR_CONTENT_TYPE } from './envelopeCbor'
13
+
14
+ export { _CBOR_CONTENT_TYPE as CBOR_CONTENT_TYPE }
12
15
 
13
16
  export { type KeyRegistry }
14
17
 
@@ -32,21 +35,36 @@ export interface HttpBindingOptions {
32
35
  strictTtl?: boolean // default true - reject expired TTL
33
36
  }
34
37
 
35
- // Verify the 7h3 envelope from an incoming HTTP request's headers
38
+ // Verify the 7h3 envelope from an incoming HTTP request's headers or body
39
+ // If content-type includes '7h3-cbor', decodes body as CBOR instead of JSON
36
40
  export async function verifyHttpEnvelope(
37
41
  headers: Record<string, string | string[] | undefined>,
38
- opts: HttpBindingOptions
42
+ opts: HttpBindingOptions,
43
+ body?: Uint8Array
39
44
  ): Promise<VerifyHttpResult> {
40
45
  const headerName = opts.headerName ?? DEFAULT_HEADER
41
- const raw = headers[headerName]
42
- const rawStr = Array.isArray(raw) ? raw[0] : raw
43
- if (!rawStr) return { ok: false, reason: 'missing-header' }
46
+ const contentType = (Array.isArray(headers['content-type']) ? headers['content-type'][0] : headers['content-type']) ?? ''
44
47
 
45
48
  let envelope: ProtocolEnvelope
46
- try {
47
- envelope = JSON.parse(rawStr) as ProtocolEnvelope
48
- } catch {
49
- return { ok: false, reason: 'malformed-envelope', detail: 'JSON parse failed' }
49
+
50
+ // CBOR mode: content-type contains '7h3-cbor' and body is provided
51
+ if (contentType.includes('7h3-cbor') && body instanceof Uint8Array) {
52
+ try {
53
+ envelope = decodeEnvelopeCbor(body)
54
+ } catch (e: unknown) {
55
+ return { ok: false, reason: 'malformed-envelope', detail: e instanceof Error ? e.message : 'CBOR decode failed' }
56
+ }
57
+ } else {
58
+ // JSON mode: read from header
59
+ const raw = headers[headerName]
60
+ const rawStr = Array.isArray(raw) ? raw[0] : raw
61
+ if (!rawStr) return { ok: false, reason: 'missing-header' }
62
+
63
+ try {
64
+ envelope = JSON.parse(rawStr) as ProtocolEnvelope
65
+ } catch {
66
+ return { ok: false, reason: 'malformed-envelope', detail: 'JSON parse failed' }
67
+ }
50
68
  }
51
69
 
52
70
  if (!envelope?.signature || !envelope?.header) {
@@ -83,12 +101,20 @@ export async function verifyHttpEnvelope(
83
101
  }
84
102
 
85
103
  // Sign an outgoing HTTP request — returns headers to merge in
104
+ // When format is 'cbor', returns binary body + content-type header instead of JSON header
86
105
  export async function signHttpRequest(
87
106
  envelope: Omit<ProtocolEnvelope, 'signature'>,
88
107
  privateKey: string,
89
- opts?: { headerName?: string }
90
- ): Promise<{ headers: Record<string, string> }> {
108
+ opts?: { headerName?: string; format?: 'cbor' | 'json' }
109
+ ): Promise<{ headers: Record<string, string>; body?: Uint8Array }> {
91
110
  const signed = await signEnvelopeEd25519(envelope, privateKey)
111
+ if (opts?.format === 'cbor') {
112
+ const body = encodeEnvelopeCbor(signed)
113
+ return {
114
+ headers: { 'content-type': _CBOR_CONTENT_TYPE },
115
+ body,
116
+ }
117
+ }
92
118
  return {
93
119
  headers: { [opts?.headerName ?? DEFAULT_HEADER]: JSON.stringify(signed) },
94
120
  }
package/src/index.ts CHANGED
@@ -30,3 +30,10 @@ export * from './routePolicy'
30
30
  export * from './gateway'
31
31
  export * from './signedResponse'
32
32
  export * from './auditLog'
33
+ export * from './telemetry'
34
+ export * from './otel'
35
+ export * from './cborCodec'
36
+ export * from './envelopeCbor'
37
+ export * from './encryption'
38
+ export * from './capability'
39
+ export * from './stream'