@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,380 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateEd25519KeypairBase64Url,
|
|
3
|
+
signCanonicalPayloadEd25519,
|
|
4
|
+
verifyCanonicalPayloadEd25519,
|
|
5
|
+
} from './protocol'
|
|
6
|
+
import { matchGlob } from './routePolicy'
|
|
7
|
+
|
|
8
|
+
// Re-export for test convenience
|
|
9
|
+
export { generateEd25519KeypairBase64Url }
|
|
10
|
+
|
|
11
|
+
export interface CapabilityScope {
|
|
12
|
+
pathGlob: string
|
|
13
|
+
methods?: string[]
|
|
14
|
+
maxDelegations?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CapabilityToken {
|
|
18
|
+
id: string
|
|
19
|
+
version: '7h3-cap/1'
|
|
20
|
+
issuer: string
|
|
21
|
+
subject: string
|
|
22
|
+
scopes: CapabilityScope[]
|
|
23
|
+
issuedAt: number
|
|
24
|
+
expiresAt: number
|
|
25
|
+
delegationDepth: number
|
|
26
|
+
parentTokenId?: string
|
|
27
|
+
/** How many more times this token can be re-delegated. undefined = unlimited, 0 = no further delegation. */
|
|
28
|
+
maxDelegations: number | undefined
|
|
29
|
+
signature: string
|
|
30
|
+
keyId: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type CapabilityVerifyResult =
|
|
34
|
+
| { ok: true; token: CapabilityToken; chain: CapabilityToken[] }
|
|
35
|
+
| { ok: false; reason: string }
|
|
36
|
+
|
|
37
|
+
export const CAP_HEADER = 'x-7h3-capability'
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Canonicalization
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function canonicalizeScope(scope: CapabilityScope): string {
|
|
44
|
+
const parts: string[] = []
|
|
45
|
+
if (scope.maxDelegations !== undefined) {
|
|
46
|
+
parts.push(`"maxDelegations":${scope.maxDelegations}`)
|
|
47
|
+
}
|
|
48
|
+
if (scope.methods !== undefined) {
|
|
49
|
+
parts.push(`"methods":${JSON.stringify(scope.methods)}`)
|
|
50
|
+
}
|
|
51
|
+
parts.push(`"pathGlob":${JSON.stringify(scope.pathGlob)}`)
|
|
52
|
+
return `{${parts.join(',')}}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Deterministic canonical form for signing: alphabetically sorted top-level fields,
|
|
57
|
+
* each scope also sorted. The `signature` field is excluded.
|
|
58
|
+
*/
|
|
59
|
+
export function canonicalizeCapabilityToken(token: Omit<CapabilityToken, 'signature'>): string {
|
|
60
|
+
const parts: string[] = []
|
|
61
|
+
|
|
62
|
+
parts.push(`"delegationDepth":${token.delegationDepth}`)
|
|
63
|
+
parts.push(`"expiresAt":${token.expiresAt}`)
|
|
64
|
+
parts.push(`"id":${JSON.stringify(token.id)}`)
|
|
65
|
+
parts.push(`"issuedAt":${token.issuedAt}`)
|
|
66
|
+
parts.push(`"issuer":${JSON.stringify(token.issuer)}`)
|
|
67
|
+
parts.push(`"keyId":${JSON.stringify(token.keyId)}`)
|
|
68
|
+
|
|
69
|
+
if (token.maxDelegations !== undefined) {
|
|
70
|
+
parts.push(`"maxDelegations":${token.maxDelegations}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (token.parentTokenId !== undefined) {
|
|
74
|
+
parts.push(`"parentTokenId":${JSON.stringify(token.parentTokenId)}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const scopesStr = `[${token.scopes.map(canonicalizeScope).join(',')}]`
|
|
78
|
+
parts.push(`"scopes":${scopesStr}`)
|
|
79
|
+
|
|
80
|
+
parts.push(`"subject":${JSON.stringify(token.subject)}`)
|
|
81
|
+
parts.push(`"version":${JSON.stringify(token.version)}`)
|
|
82
|
+
|
|
83
|
+
return `{${parts.join(',')}}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Issue
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export async function issueCapabilityToken(opts: {
|
|
91
|
+
issuerPrivateKey: string
|
|
92
|
+
issuerId: string
|
|
93
|
+
subject: string
|
|
94
|
+
scopes: CapabilityScope[]
|
|
95
|
+
ttlMs: number
|
|
96
|
+
maxDelegations?: number
|
|
97
|
+
keyId?: string
|
|
98
|
+
}): Promise<CapabilityToken> {
|
|
99
|
+
const now = Date.now()
|
|
100
|
+
const id = `cap-${now}-${Math.random().toString(36).slice(2, 12)}`
|
|
101
|
+
const keyId = opts.keyId ?? `${opts.issuerId}-key`
|
|
102
|
+
const maxDelegations = opts.maxDelegations ?? 0
|
|
103
|
+
|
|
104
|
+
const unsigned: Omit<CapabilityToken, 'signature'> = {
|
|
105
|
+
id,
|
|
106
|
+
version: '7h3-cap/1',
|
|
107
|
+
issuer: opts.issuerId,
|
|
108
|
+
subject: opts.subject,
|
|
109
|
+
scopes: opts.scopes,
|
|
110
|
+
issuedAt: now,
|
|
111
|
+
expiresAt: now + opts.ttlMs,
|
|
112
|
+
delegationDepth: 0,
|
|
113
|
+
maxDelegations,
|
|
114
|
+
keyId,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const payload = canonicalizeCapabilityToken(unsigned)
|
|
118
|
+
const signature = await signCanonicalPayloadEd25519(payload, opts.issuerPrivateKey)
|
|
119
|
+
|
|
120
|
+
return { ...unsigned, signature }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Delegate
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check whether childScope is a subset of parentScope.
|
|
129
|
+
* - pathGlob: the parent must matchGlob the child's pathGlob literal (or the child's glob must be
|
|
130
|
+
* at least as restrictive). We check that every path that matches child also matches parent by
|
|
131
|
+
* testing the child pathGlob against the parent pathGlob pattern.
|
|
132
|
+
* - methods: parent undefined = any (child is allowed anything); child undefined = any =
|
|
133
|
+
* broader than a defined parent → reject.
|
|
134
|
+
*/
|
|
135
|
+
function scopeIsSubset(child: CapabilityScope, parent: CapabilityScope): boolean {
|
|
136
|
+
// Path: child path must be covered by parent path pattern
|
|
137
|
+
if (!matchGlob(parent.pathGlob, child.pathGlob)) {
|
|
138
|
+
// Also try: child glob should be at most as broad as parent
|
|
139
|
+
// For glob subset we check that child's pathGlob matches under the parent glob pattern
|
|
140
|
+
// This covers the common case of /api/payments/** under /api/**
|
|
141
|
+
// We fall back: if parent.pathGlob doesn't match child.pathGlob directly,
|
|
142
|
+
// test by checking child is a "narrower" glob — just use matchGlob both ways
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Methods: parent undefined = allows all; child must not exceed parent
|
|
147
|
+
if (parent.methods !== undefined) {
|
|
148
|
+
if (child.methods === undefined) {
|
|
149
|
+
// child wants all methods, parent is restricted — not a subset
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
// Every child method must be in parent methods
|
|
153
|
+
for (const m of child.methods) {
|
|
154
|
+
if (!parent.methods.includes(m)) {
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// parent.methods === undefined → parent allows all; child can be anything
|
|
160
|
+
|
|
161
|
+
return true
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function scopesAreSubset(childScopes: CapabilityScope[], parentScopes: CapabilityScope[]): boolean {
|
|
165
|
+
// Every child scope must be covered by at least one parent scope
|
|
166
|
+
for (const child of childScopes) {
|
|
167
|
+
const covered = parentScopes.some((parent) => scopeIsSubset(child, parent))
|
|
168
|
+
if (!covered) return false
|
|
169
|
+
}
|
|
170
|
+
return true
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function delegateCapabilityToken(opts: {
|
|
174
|
+
parentToken: CapabilityToken
|
|
175
|
+
delegatorPrivateKey: string
|
|
176
|
+
delegatorId: string
|
|
177
|
+
newSubject: string
|
|
178
|
+
scopes?: CapabilityScope[]
|
|
179
|
+
ttlMs: number
|
|
180
|
+
keyId?: string
|
|
181
|
+
}): Promise<CapabilityToken> {
|
|
182
|
+
const { parentToken, delegatorId, newSubject } = opts
|
|
183
|
+
|
|
184
|
+
// Validate delegator is parent token's subject
|
|
185
|
+
if (delegatorId !== parentToken.subject) {
|
|
186
|
+
throw new Error(`delegatorId '${delegatorId}' does not match parentToken.subject '${parentToken.subject}'`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Validate parent not expired
|
|
190
|
+
const now = Date.now()
|
|
191
|
+
if (now >= parentToken.expiresAt) {
|
|
192
|
+
throw new Error('Parent token is expired')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Validate delegation is still allowed
|
|
196
|
+
if (parentToken.maxDelegations !== undefined && parentToken.maxDelegations <= 0) {
|
|
197
|
+
throw new Error('Parent token does not allow further delegation (maxDelegations=0)')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Determine new scopes
|
|
201
|
+
const newScopes = opts.scopes ?? parentToken.scopes
|
|
202
|
+
|
|
203
|
+
// Validate new scopes are a subset of parent scopes
|
|
204
|
+
if (!scopesAreSubset(newScopes, parentToken.scopes)) {
|
|
205
|
+
throw new Error('Delegated scopes exceed parent token scopes')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Validate TTL does not exceed parent remaining TTL
|
|
209
|
+
const parentRemainingMs = parentToken.expiresAt - now
|
|
210
|
+
if (opts.ttlMs > parentRemainingMs) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`Delegated ttlMs (${opts.ttlMs}) exceeds parent token remaining TTL (${parentRemainingMs})`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const id = `cap-${now}-${Math.random().toString(36).slice(2, 12)}`
|
|
217
|
+
const keyId = opts.keyId ?? `${delegatorId}-key`
|
|
218
|
+
|
|
219
|
+
// Compute new maxDelegations: decrement from parent (undefined = unlimited stays unlimited)
|
|
220
|
+
const newMaxDelegations =
|
|
221
|
+
parentToken.maxDelegations === undefined ? undefined : parentToken.maxDelegations - 1
|
|
222
|
+
|
|
223
|
+
const unsigned: Omit<CapabilityToken, 'signature'> = {
|
|
224
|
+
id,
|
|
225
|
+
version: '7h3-cap/1',
|
|
226
|
+
issuer: delegatorId,
|
|
227
|
+
subject: newSubject,
|
|
228
|
+
scopes: newScopes,
|
|
229
|
+
issuedAt: now,
|
|
230
|
+
expiresAt: now + opts.ttlMs,
|
|
231
|
+
delegationDepth: parentToken.delegationDepth + 1,
|
|
232
|
+
parentTokenId: parentToken.id,
|
|
233
|
+
maxDelegations: newMaxDelegations,
|
|
234
|
+
keyId,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const payload = canonicalizeCapabilityToken(unsigned)
|
|
238
|
+
const signature = await signCanonicalPayloadEd25519(payload, opts.delegatorPrivateKey)
|
|
239
|
+
|
|
240
|
+
return { ...unsigned, signature }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Verify single token
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
export async function verifyCapabilityToken(
|
|
248
|
+
token: CapabilityToken,
|
|
249
|
+
publicKey: string,
|
|
250
|
+
opts?: { now?: number },
|
|
251
|
+
): Promise<boolean> {
|
|
252
|
+
const now = opts?.now ?? Date.now()
|
|
253
|
+
|
|
254
|
+
// Check expiry
|
|
255
|
+
if (now >= token.expiresAt) {
|
|
256
|
+
return false
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Reconstruct unsigned form
|
|
260
|
+
const { signature, ...unsigned } = token
|
|
261
|
+
|
|
262
|
+
const payload = canonicalizeCapabilityToken(unsigned)
|
|
263
|
+
return verifyCanonicalPayloadEd25519(payload, signature, publicKey)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Verify chain
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
export async function verifyCapabilityChain(
|
|
271
|
+
chain: CapabilityToken[],
|
|
272
|
+
keyRegistry: { getPublicKey(id: string): Promise<string | null> },
|
|
273
|
+
opts?: { requiredPathGlob?: string; requiredMethod?: string; now?: number },
|
|
274
|
+
): Promise<CapabilityVerifyResult> {
|
|
275
|
+
if (chain.length === 0) {
|
|
276
|
+
return { ok: false, reason: 'empty-chain' }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const now = opts?.now ?? Date.now()
|
|
280
|
+
|
|
281
|
+
// Verify each token in the chain
|
|
282
|
+
for (let i = 0; i < chain.length; i++) {
|
|
283
|
+
const token = chain[i]
|
|
284
|
+
|
|
285
|
+
// Structural check: root must have depth 0
|
|
286
|
+
if (i === 0 && token.delegationDepth !== 0) {
|
|
287
|
+
return { ok: false, reason: 'root-token-delegation-depth-must-be-zero' }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Chain linkage: issuer of token[i] must be subject of token[i-1]
|
|
291
|
+
if (i > 0) {
|
|
292
|
+
const prev = chain[i - 1]
|
|
293
|
+
if (token.issuer !== prev.subject) {
|
|
294
|
+
return {
|
|
295
|
+
ok: false,
|
|
296
|
+
reason: `chain-broken: token[${i}].issuer '${token.issuer}' !== token[${i - 1}].subject '${prev.subject}'`,
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (token.parentTokenId !== prev.id) {
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
reason: `chain-broken: token[${i}].parentTokenId '${token.parentTokenId}' !== token[${i - 1}].id '${prev.id}'`,
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (token.delegationDepth !== prev.delegationDepth + 1) {
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
reason: `chain-broken: token[${i}].delegationDepth ${token.delegationDepth} !== ${prev.delegationDepth + 1}`,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Expiry check
|
|
314
|
+
if (now >= token.expiresAt) {
|
|
315
|
+
return { ok: false, reason: `token[${i}]-expired` }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Get public key for issuer
|
|
319
|
+
const issuerPub = await keyRegistry.getPublicKey(token.issuer)
|
|
320
|
+
if (!issuerPub) {
|
|
321
|
+
return { ok: false, reason: `no-public-key-for-issuer:${token.issuer}` }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Verify signature
|
|
325
|
+
const { signature, ...unsigned } = token
|
|
326
|
+
const payload = canonicalizeCapabilityToken(unsigned)
|
|
327
|
+
const valid = await verifyCanonicalPayloadEd25519(payload, signature, issuerPub)
|
|
328
|
+
if (!valid) {
|
|
329
|
+
return { ok: false, reason: `invalid-signature-at-index:${i}` }
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const leaf = chain[chain.length - 1]
|
|
334
|
+
|
|
335
|
+
// Check required scope
|
|
336
|
+
if (opts?.requiredPathGlob !== undefined) {
|
|
337
|
+
if (!tokenMatchesScope(leaf, opts.requiredPathGlob, opts.requiredMethod)) {
|
|
338
|
+
return { ok: false, reason: 'leaf-token-does-not-cover-required-scope' }
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { ok: true, token: leaf, chain }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Scope matching
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
export function tokenMatchesScope(token: CapabilityToken, pathGlob: string, method?: string): boolean {
|
|
350
|
+
for (const scope of token.scopes) {
|
|
351
|
+
// Check path: the scope's pathGlob must match the requested path
|
|
352
|
+
if (!matchGlob(scope.pathGlob, pathGlob)) {
|
|
353
|
+
continue
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Check method: if scope has no methods restriction, any method is allowed
|
|
357
|
+
if (scope.methods === undefined) {
|
|
358
|
+
return true
|
|
359
|
+
}
|
|
360
|
+
if (method === undefined) {
|
|
361
|
+
return true
|
|
362
|
+
}
|
|
363
|
+
if (scope.methods.includes(method.toUpperCase())) {
|
|
364
|
+
return true
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return false
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// Serialization
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
export function serializeCapabilityChain(chain: CapabilityToken[]): string {
|
|
375
|
+
return JSON.stringify(chain)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function parseCapabilityChain(serialized: string): CapabilityToken[] {
|
|
379
|
+
return JSON.parse(serialized) as CapabilityToken[]
|
|
380
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { encodeCbor, decodeCbor, CborEncoder, CborDecoder } from './cborCodec'
|
|
3
|
+
import { encodeEnvelopeCbor, decodeEnvelopeCbor } from './envelopeCbor'
|
|
4
|
+
import type { ProtocolEnvelope } from './protocol'
|
|
5
|
+
|
|
6
|
+
// Helper: compare Uint8Arrays
|
|
7
|
+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
8
|
+
if (a.length !== b.length) return false
|
|
9
|
+
for (let i = 0; i < a.length; i++) {
|
|
10
|
+
if (a[i] !== b[i]) return false
|
|
11
|
+
}
|
|
12
|
+
return true
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('CborCodec - round-trip', () => {
|
|
16
|
+
it('null round-trips', () => {
|
|
17
|
+
const encoded = encodeCbor(null)
|
|
18
|
+
expect(decodeCbor(encoded)).toBe(null)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('boolean false round-trips', () => {
|
|
22
|
+
const encoded = encodeCbor(false)
|
|
23
|
+
expect(decodeCbor(encoded)).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('boolean true round-trips', () => {
|
|
27
|
+
const encoded = encodeCbor(true)
|
|
28
|
+
expect(decodeCbor(encoded)).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('integer 0 round-trips', () => {
|
|
32
|
+
const encoded = encodeCbor(0)
|
|
33
|
+
expect(decodeCbor(encoded)).toBe(0)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('integer 42 round-trips', () => {
|
|
37
|
+
const encoded = encodeCbor(42)
|
|
38
|
+
expect(decodeCbor(encoded)).toBe(42)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('negative integer round-trips', () => {
|
|
42
|
+
const encoded = encodeCbor(-100)
|
|
43
|
+
expect(decodeCbor(encoded)).toBe(-100)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('float round-trips', () => {
|
|
47
|
+
const encoded = encodeCbor(3.14)
|
|
48
|
+
const decoded = decodeCbor(encoded)
|
|
49
|
+
expect(typeof decoded).toBe('number')
|
|
50
|
+
expect(decoded as number).toBeCloseTo(3.14, 10)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('string round-trips', () => {
|
|
54
|
+
const encoded = encodeCbor('hello world')
|
|
55
|
+
expect(decodeCbor(encoded)).toBe('hello world')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('empty string round-trips', () => {
|
|
59
|
+
const encoded = encodeCbor('')
|
|
60
|
+
expect(decodeCbor(encoded)).toBe('')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('array round-trips', () => {
|
|
64
|
+
const arr = [1, 'two', true, null, 3.14]
|
|
65
|
+
const encoded = encodeCbor(arr)
|
|
66
|
+
const decoded = decodeCbor(encoded)
|
|
67
|
+
expect(decoded).toEqual(arr)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('nested object round-trips', () => {
|
|
71
|
+
const obj = { a: 1, b: 'hello', c: [1, 2, 3], d: { e: true } }
|
|
72
|
+
const encoded = encodeCbor(obj)
|
|
73
|
+
const decoded = decodeCbor(encoded)
|
|
74
|
+
expect(decoded).toEqual(obj)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('Uint8Array round-trips', () => {
|
|
78
|
+
const bytes = new Uint8Array([0x01, 0x02, 0x03, 0xff])
|
|
79
|
+
const encoded = encodeCbor(bytes)
|
|
80
|
+
const decoded = decodeCbor(encoded)
|
|
81
|
+
expect(decoded).toBeInstanceOf(Uint8Array)
|
|
82
|
+
expect(bytesEqual(decoded as Uint8Array, bytes)).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('CborCodec - deterministic encoding', () => {
|
|
87
|
+
it('encoding same object twice gives identical bytes', () => {
|
|
88
|
+
const obj = { z: 3, a: 1, m: 2, nested: { x: 'hello' } }
|
|
89
|
+
const enc1 = encodeCbor(obj)
|
|
90
|
+
const enc2 = encodeCbor(obj)
|
|
91
|
+
expect(bytesEqual(enc1, enc2)).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('encoding independently with two encoder instances gives identical bytes', () => {
|
|
95
|
+
const obj = { foo: 'bar', baz: 42 }
|
|
96
|
+
const enc1 = new CborEncoder().encode(obj)
|
|
97
|
+
const enc2 = new CborEncoder().encode(obj)
|
|
98
|
+
expect(bytesEqual(enc1, enc2)).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('CborCodec - key ordering', () => {
|
|
103
|
+
it('map keys {z, a, m} are encoded in a < m < z byte order', () => {
|
|
104
|
+
const obj = { z: 3, a: 1, m: 2 }
|
|
105
|
+
const encoded = encodeCbor(obj)
|
|
106
|
+
const decoded = decodeCbor(encoded) as Record<string, unknown>
|
|
107
|
+
// Verify all keys present and correct values
|
|
108
|
+
expect(decoded['a']).toBe(1)
|
|
109
|
+
expect(decoded['m']).toBe(2)
|
|
110
|
+
expect(decoded['z']).toBe(3)
|
|
111
|
+
|
|
112
|
+
// Verify byte order: find the offset of each key char in encoded bytes
|
|
113
|
+
// Key 'a' (0x61) must appear before 'm' (0x6d) which must appear before 'z' (0x7a)
|
|
114
|
+
const textA = 0x61 // 'a'
|
|
115
|
+
const textM = 0x6d // 'm'
|
|
116
|
+
const textZ = 0x7a // 'z'
|
|
117
|
+
|
|
118
|
+
let posA = -1, posM = -1, posZ = -1
|
|
119
|
+
for (let i = 0; i < encoded.length; i++) {
|
|
120
|
+
if (encoded[i] === textA && posA === -1) posA = i
|
|
121
|
+
if (encoded[i] === textM && posM === -1) posM = i
|
|
122
|
+
if (encoded[i] === textZ && posZ === -1) posZ = i
|
|
123
|
+
}
|
|
124
|
+
expect(posA).toBeGreaterThanOrEqual(0)
|
|
125
|
+
expect(posM).toBeGreaterThan(posA)
|
|
126
|
+
expect(posZ).toBeGreaterThan(posM)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('CborCodec - integer encoding sizes', () => {
|
|
131
|
+
it('0 encodes to 1 byte', () => {
|
|
132
|
+
const encoded = encodeCbor(0)
|
|
133
|
+
expect(encoded.length).toBe(1)
|
|
134
|
+
expect(encoded[0]).toBe(0x00)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('23 encodes to 1 byte', () => {
|
|
138
|
+
const encoded = encodeCbor(23)
|
|
139
|
+
expect(encoded.length).toBe(1)
|
|
140
|
+
expect(encoded[0]).toBe(0x17) // 0<<5 | 23
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('24 encodes to 2 bytes', () => {
|
|
144
|
+
const encoded = encodeCbor(24)
|
|
145
|
+
expect(encoded.length).toBe(2)
|
|
146
|
+
expect(encoded[0]).toBe(0x18) // 0<<5 | 24 (1-byte additional)
|
|
147
|
+
expect(encoded[1]).toBe(24)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('255 encodes to 2 bytes', () => {
|
|
151
|
+
const encoded = encodeCbor(255)
|
|
152
|
+
expect(encoded.length).toBe(2)
|
|
153
|
+
expect(encoded[0]).toBe(0x18)
|
|
154
|
+
expect(encoded[1]).toBe(255)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('256 encodes to 3 bytes', () => {
|
|
158
|
+
const encoded = encodeCbor(256)
|
|
159
|
+
expect(encoded.length).toBe(3)
|
|
160
|
+
expect(encoded[0]).toBe(0x19) // 0<<5 | 25 (2-byte additional)
|
|
161
|
+
expect(encoded[1]).toBe(0x01)
|
|
162
|
+
expect(encoded[2]).toBe(0x00)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
describe('envelopeCbor - round-trip', () => {
|
|
167
|
+
const baseEnvelope: ProtocolEnvelope = {
|
|
168
|
+
header: {
|
|
169
|
+
version: '7h3/0.1',
|
|
170
|
+
messageId: 'msg-1234567890-abc12345',
|
|
171
|
+
timestampMs: 1700000000000,
|
|
172
|
+
ttlMs: 60000,
|
|
173
|
+
sender: 'agent:alice@example.com',
|
|
174
|
+
nonce: 'abcdefghij',
|
|
175
|
+
},
|
|
176
|
+
body: {
|
|
177
|
+
intent: 'TASK',
|
|
178
|
+
content: 'Hello, agent!',
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
it('round-trip preserves all required fields', () => {
|
|
183
|
+
const encoded = encodeEnvelopeCbor(baseEnvelope)
|
|
184
|
+
const decoded = decodeEnvelopeCbor(encoded)
|
|
185
|
+
|
|
186
|
+
expect(decoded.header.version).toBe(baseEnvelope.header.version)
|
|
187
|
+
expect(decoded.header.messageId).toBe(baseEnvelope.header.messageId)
|
|
188
|
+
expect(decoded.header.timestampMs).toBe(baseEnvelope.header.timestampMs)
|
|
189
|
+
expect(decoded.header.ttlMs).toBe(baseEnvelope.header.ttlMs)
|
|
190
|
+
expect(decoded.header.sender).toBe(baseEnvelope.header.sender)
|
|
191
|
+
expect(decoded.header.nonce).toBe(baseEnvelope.header.nonce)
|
|
192
|
+
expect(decoded.body.intent).toBe(baseEnvelope.body.intent)
|
|
193
|
+
expect(decoded.body.content).toBe(baseEnvelope.body.content)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('round-trip preserves optional fields: recipient, capability, correlationId', () => {
|
|
197
|
+
const full: ProtocolEnvelope = {
|
|
198
|
+
...baseEnvelope,
|
|
199
|
+
header: { ...baseEnvelope.header, recipient: 'agent:bob@example.com' },
|
|
200
|
+
body: { ...baseEnvelope.body, capability: 'file.read', correlationId: 'corr-xyz' },
|
|
201
|
+
signature: {
|
|
202
|
+
alg: 'HS256',
|
|
203
|
+
keyId: 'my-key-id',
|
|
204
|
+
value: 'base64urlencodedvalue',
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const encoded = encodeEnvelopeCbor(full)
|
|
209
|
+
const decoded = decodeEnvelopeCbor(encoded)
|
|
210
|
+
|
|
211
|
+
expect(decoded.header.recipient).toBe('agent:bob@example.com')
|
|
212
|
+
expect(decoded.body.capability).toBe('file.read')
|
|
213
|
+
expect(decoded.body.correlationId).toBe('corr-xyz')
|
|
214
|
+
expect(decoded.signature?.alg).toBe('HS256')
|
|
215
|
+
expect(decoded.signature?.keyId).toBe('my-key-id')
|
|
216
|
+
expect(decoded.signature?.value).toBe('base64urlencodedvalue')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('CBOR envelope is smaller than JSON equivalent (size comparison)', () => {
|
|
220
|
+
const full: ProtocolEnvelope = {
|
|
221
|
+
...baseEnvelope,
|
|
222
|
+
header: { ...baseEnvelope.header, recipient: 'agent:bob@example.com' },
|
|
223
|
+
body: { ...baseEnvelope.body, capability: 'file.read', correlationId: 'corr-xyz' },
|
|
224
|
+
signature: {
|
|
225
|
+
alg: 'HS256',
|
|
226
|
+
keyId: 'my-key-id',
|
|
227
|
+
value: 'base64urlencodedvalue',
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const cborBytes = encodeEnvelopeCbor(full)
|
|
232
|
+
const jsonBytes = new TextEncoder().encode(JSON.stringify(full))
|
|
233
|
+
|
|
234
|
+
console.log(`CBOR size: ${cborBytes.length} bytes, JSON size: ${jsonBytes.length} bytes, ratio: ${(cborBytes.length / jsonBytes.length).toFixed(2)}`)
|
|
235
|
+
expect(cborBytes.length).toBeLessThan(jsonBytes.length)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('optional fields absent in input are absent in output', () => {
|
|
239
|
+
// No recipient, capability, correlationId, signature
|
|
240
|
+
const minimal: ProtocolEnvelope = {
|
|
241
|
+
header: {
|
|
242
|
+
version: '7h3/0.1',
|
|
243
|
+
messageId: 'msg-min',
|
|
244
|
+
timestampMs: 1700000000000,
|
|
245
|
+
ttlMs: 30000,
|
|
246
|
+
sender: 'agent:sender',
|
|
247
|
+
nonce: 'nonce123',
|
|
248
|
+
},
|
|
249
|
+
body: {
|
|
250
|
+
intent: 'PING',
|
|
251
|
+
content: 'ping',
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const encoded = encodeEnvelopeCbor(minimal)
|
|
256
|
+
const decoded = decodeEnvelopeCbor(encoded)
|
|
257
|
+
|
|
258
|
+
expect(decoded.header.recipient).toBeUndefined()
|
|
259
|
+
expect(decoded.body.capability).toBeUndefined()
|
|
260
|
+
expect(decoded.body.correlationId).toBeUndefined()
|
|
261
|
+
expect(decoded.signature).toBeUndefined()
|
|
262
|
+
})
|
|
263
|
+
})
|