@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.
- package/CHANGELOG.md +60 -0
- package/README.md +1169 -175
- package/bin/7h3.ts +22 -1
- package/docs/assets/banner-github.png +0 -0
- package/docs/assets/banner.svg +123 -0
- package/package.json +55 -13
- package/sdk/browser/package.json +1 -1
- package/sdk/go/cbor.go +551 -0
- package/sdk/go/cbor_test.go +232 -0
- package/sdk/go/encryption.go +280 -0
- package/sdk/go/encryption_test.go +318 -0
- package/sdk/go/go.mod +5 -1
- package/sdk/go/go.sum +4 -0
- package/sdk/go/replay.go +121 -0
- package/sdk/go/replay_test.go +149 -0
- package/sdk/pq/package-lock.json +1358 -0
- package/sdk/pq/package.json +42 -0
- package/sdk/pq/src/index.test.ts +143 -0
- package/sdk/pq/src/index.ts +166 -0
- package/sdk/pq/tsconfig.json +14 -0
- package/sdk/pq/vitest.config.ts +7 -0
- package/sdk/python/protocol_7h3/encryption.py +252 -0
- package/sdk/python/protocol_7h3/pq.py +244 -0
- package/sdk/python/protocol_7h3/replay.py +98 -0
- package/sdk/python/pyproject.toml +1 -1
- package/sdk/python/tests/test_encryption.py +206 -0
- package/sdk/rust/Cargo.lock +1 -1
- package/sdk/rust/Cargo.toml +1 -1
- package/sdk/threshold/index.d.ts +68 -0
- package/sdk/threshold/index.d.ts.map +1 -0
- package/sdk/threshold/index.js +254 -0
- package/sdk/threshold/package-lock.json +1361 -0
- package/sdk/threshold/package.json +39 -0
- package/sdk/threshold/src/index.d.ts +68 -0
- package/sdk/threshold/src/index.d.ts.map +1 -0
- package/sdk/threshold/src/index.js +254 -0
- package/sdk/threshold/src/index.test.ts +238 -0
- package/sdk/threshold/src/index.ts +355 -0
- package/sdk/threshold/tsconfig.json +19 -0
- package/sdk/threshold/vitest.config.ts +12 -0
- package/src/capability.test.ts +504 -0
- package/src/capability.ts +380 -0
- package/src/cborCodec.test.ts +263 -0
- package/src/cborCodec.ts +339 -0
- package/src/encryption.test.ts +206 -0
- package/src/encryption.ts +245 -0
- package/src/envelopeCbor.ts +140 -0
- package/src/gateway.ts +75 -0
- package/src/httpBinding.ts +37 -11
- package/src/index.ts +7 -0
- package/src/otel.ts +136 -0
- package/src/protocol.d.ts +67 -0
- package/src/protocol.d.ts.map +1 -0
- package/src/protocol.js +294 -0
- package/src/protocol.ts +1 -0
- package/src/replayStores.test.ts +133 -1
- package/src/replayStores.ts +136 -3
- package/src/stream.test.ts +254 -0
- package/src/stream.ts +417 -0
- package/src/telemetry.test.ts +251 -0
- package/src/telemetry.ts +299 -0
- package/src/wsBinding.ts +100 -0
- 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
|
|
package/src/httpBinding.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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'
|