@7h3/protocol 0.1.2 → 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 (276) hide show
  1. package/.dockerignore +19 -0
  2. package/.github/dependabot.yml +32 -0
  3. package/.github/workflows/ci.yml +31 -0
  4. package/.github/workflows/publish.yml +59 -0
  5. package/.github/workflows/scorecard.yml +37 -0
  6. package/7h3.example.yaml +125 -0
  7. package/CHANGELOG.md +152 -0
  8. package/CONTRIBUTING.md +82 -0
  9. package/Dockerfile +73 -0
  10. package/GOVERNANCE.md +62 -0
  11. package/README.md +1317 -6
  12. package/SECURITY.md +70 -0
  13. package/bench-results/replay-cache-full-1777891033256.json +10 -0
  14. package/bench-results/replay-cache-full-1777896317488.json +10 -0
  15. package/bench-results/replay-cache-full-1777900993184.json +10 -0
  16. package/bench-results/replay-cache-full-1777901019285.json +10 -0
  17. package/bench-results/replay-cache-quick-1777870170126.json +10 -0
  18. package/bench-results/signature-profiles-quick-1775875160079.json +85 -0
  19. package/bench-results/signature-profiles-quick-1775983539716.json +85 -0
  20. package/bench-results/signature-profiles-quick-1776237913190.json +85 -0
  21. package/bench-results/wire-codecs-full-1777891019803.json +93 -0
  22. package/bench-results/wire-codecs-full-1777896260964.json +93 -0
  23. package/bench-results/wire-codecs-full-1777901004247.json +93 -0
  24. package/bench-results/wire-codecs-quick-1775972879056.json +93 -0
  25. package/bench-results/wire-codecs-quick-1775983541111.json +93 -0
  26. package/bench-results/wire-codecs-quick-1776237914299.json +93 -0
  27. package/bench-results/wire-codecs-quick-1777841285236.json +93 -0
  28. package/bench-results/wire-codecs-quick-1777841321772.json +93 -0
  29. package/bench-results/wire-codecs-quick-1777841330408.json +93 -0
  30. package/bench-results/wire-codecs-quick-1777852886082.json +93 -0
  31. package/bench-results/wire-codecs-quick-1777852988773.json +93 -0
  32. package/bench-results/wire-codecs-quick-1777870188095.json +93 -0
  33. package/bench-results/wire-codecs-quick-1777870263918.json +93 -0
  34. package/bench-results/wire-codecs-quick-1777870455034.json +93 -0
  35. package/bench-results/wire-codecs-quick-1778816163081.json +93 -0
  36. package/bench-results/wire-codecs-quick-1778843936130.json +93 -0
  37. package/bin/7h3.ts +406 -0
  38. package/conformance/7h3_v0_1.json +77 -0
  39. package/conformance/7h3_v0_1_binary.json +20 -0
  40. package/conformance/aip_v0_1_binary.json +20 -0
  41. package/docker-compose.yaml +77 -0
  42. package/docs/ADOPTION_PLAN.md +120 -0
  43. package/docs/AGENTS.md +77 -0
  44. package/docs/AIP_RFC_v0.1.md +97 -0
  45. package/docs/AI_DECISION_CARD.md +122 -0
  46. package/docs/AI_RUNTIME_POLICY.json +126 -0
  47. package/docs/AI_RUNTIME_POLICY.yaml +110 -0
  48. package/docs/BACKPRESSURE_TUNING.md +65 -0
  49. package/docs/BENCHMARK_CLAIM_MATRIX.md +42 -0
  50. package/docs/BENCHMARK_REPORT_TEMPLATE.md +169 -0
  51. package/docs/BINARY_CODEC_BENCH.md +23 -0
  52. package/docs/CLEAN_CLONE_RUNBOOK.md +36 -0
  53. package/docs/CLOCK_SKEW_POLICY.md +30 -0
  54. package/docs/DISTRIBUTED_REPLAY.md +142 -0
  55. package/docs/FUZZ_CAMPAIGN.md +121 -0
  56. package/docs/GATEWAY.md +195 -0
  57. package/docs/KEY_MANAGEMENT_POLICY.md +53 -0
  58. package/docs/KEY_REVOCATION.md +69 -0
  59. package/docs/MCP_WRAPPER.md +159 -0
  60. package/docs/MIGRATION_GUIDE.md +40 -0
  61. package/docs/OPERATORS.md +184 -0
  62. package/docs/PERF_REGRESSION_POLICY.md +34 -0
  63. package/docs/PROJECT_EXAMINATION_2026-05-31.md +219 -0
  64. package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +135 -0
  65. package/docs/RELEASE_GATE.md +25 -0
  66. package/docs/RELEASE_NOTES_v0.1.0.md +54 -0
  67. package/docs/SECURITY_REVIEW_2026-06-05.md +165 -0
  68. package/docs/TELEMETRY.md +41 -0
  69. package/docs/THREAT_MODEL.md +89 -0
  70. package/docs/VERSIONING_POLICY.md +30 -0
  71. package/docs/assets/banner-github.png +0 -0
  72. package/docs/assets/banner.png +0 -0
  73. package/docs/assets/banner.svg +123 -0
  74. package/eslint.config.js +15 -0
  75. package/fuzz/ts/harness-decode.ts +136 -0
  76. package/fuzz/ts/harness-verify.ts +121 -0
  77. package/fuzz/ts/run.ts +35 -0
  78. package/mcp-server/README.md +38 -0
  79. package/mcp-server/package-lock.json +1187 -0
  80. package/mcp-server/package.json +35 -0
  81. package/mcp-server/src/index.ts +236 -0
  82. package/mcp-server/tsconfig.json +14 -0
  83. package/package.json +121 -13
  84. package/scripts/aip-framework-quickstart.ts +110 -0
  85. package/scripts/aip-mcp-gateway.ts +38 -0
  86. package/scripts/aip-mcp-wrap-demo.ts +72 -0
  87. package/scripts/aip-quickstart.ts +60 -0
  88. package/scripts/bench-diff.ts +118 -0
  89. package/scripts/bench-protocol-e2e.ts +937 -0
  90. package/scripts/bench-protocol-openloop.ts +1397 -0
  91. package/scripts/bench-replay-cache.ts +76 -0
  92. package/scripts/bench-signature-profiles.ts +180 -0
  93. package/scripts/bench-wire-codecs.ts +161 -0
  94. package/scripts/build-binary-conformance.ts +36 -0
  95. package/scripts/build-release-dashboard.ts +175 -0
  96. package/scripts/canary-rollout.ts +38 -0
  97. package/scripts/mcpGatewayCli.test.ts +116 -0
  98. package/scripts/prepare-aip-package.ts +88 -0
  99. package/scripts/regen-conformance-sigs.ts +18 -0
  100. package/scripts/release-gate.ts +19 -0
  101. package/scripts/validate-runtime-policy.ts +18 -0
  102. package/sdk/browser/index.test.ts +162 -0
  103. package/sdk/browser/index.ts +257 -0
  104. package/sdk/browser/package.json +13 -0
  105. package/sdk/go/cbor.go +551 -0
  106. package/sdk/go/cbor_test.go +232 -0
  107. package/sdk/go/encryption.go +280 -0
  108. package/sdk/go/encryption_test.go +318 -0
  109. package/sdk/go/go.mod +7 -0
  110. package/sdk/go/go.sum +4 -0
  111. package/sdk/go/http.go +135 -0
  112. package/sdk/go/protocol.go +324 -0
  113. package/sdk/go/protocol_test.go +334 -0
  114. package/sdk/go/replay.go +121 -0
  115. package/sdk/go/replay_test.go +149 -0
  116. package/sdk/go/webhook.go +136 -0
  117. package/sdk/pq/package-lock.json +1358 -0
  118. package/sdk/pq/package.json +42 -0
  119. package/sdk/pq/src/index.test.ts +143 -0
  120. package/sdk/pq/src/index.ts +166 -0
  121. package/sdk/pq/tsconfig.json +14 -0
  122. package/sdk/pq/vitest.config.ts +7 -0
  123. package/sdk/python/README.md +18 -0
  124. package/sdk/python/protocol_7h3/__init__.py +46 -0
  125. package/sdk/python/protocol_7h3/encryption.py +252 -0
  126. package/sdk/python/protocol_7h3/http.py +212 -0
  127. package/sdk/python/protocol_7h3/keys.py +149 -0
  128. package/sdk/python/protocol_7h3/pq.py +244 -0
  129. package/sdk/python/protocol_7h3/protocol.py +525 -0
  130. package/sdk/python/protocol_7h3/queue.py +118 -0
  131. package/sdk/python/protocol_7h3/replay.py +98 -0
  132. package/sdk/python/protocol_7h3/webhook.py +116 -0
  133. package/sdk/python/pyproject.toml +40 -0
  134. package/sdk/python/tests/test_conformance.py +110 -0
  135. package/sdk/python/tests/test_encryption.py +206 -0
  136. package/sdk/python/tests/test_http.py +305 -0
  137. package/sdk/python/tests/test_keys.py +417 -0
  138. package/sdk/python/tests/test_queue.py +120 -0
  139. package/sdk/python/tests/test_webhook.py +345 -0
  140. package/sdk/rust/Cargo.lock +371 -0
  141. package/sdk/rust/Cargo.toml +25 -0
  142. package/sdk/rust/README.md +31 -0
  143. package/sdk/rust/fuzz/Cargo.toml +29 -0
  144. package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +46 -0
  145. package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +11 -0
  146. package/sdk/rust/src/bin/aip_mcp_gateway.rs +59 -0
  147. package/sdk/rust/src/http.rs +145 -0
  148. package/sdk/rust/src/keys.rs +161 -0
  149. package/sdk/rust/src/lib.rs +688 -0
  150. package/sdk/rust/src/queue.rs +79 -0
  151. package/sdk/rust/src/webhook.rs +86 -0
  152. package/sdk/rust/tests/conformance.rs +148 -0
  153. package/sdk/rust/tests/gateway.rs +130 -0
  154. package/sdk/rust/tests/http_webhook_queue.rs +201 -0
  155. package/sdk/rust/tests/keys.rs +189 -0
  156. package/sdk/threshold/index.d.ts +68 -0
  157. package/sdk/threshold/index.d.ts.map +1 -0
  158. package/sdk/threshold/index.js +254 -0
  159. package/sdk/threshold/package-lock.json +1361 -0
  160. package/sdk/threshold/package.json +39 -0
  161. package/sdk/threshold/src/index.d.ts +68 -0
  162. package/sdk/threshold/src/index.d.ts.map +1 -0
  163. package/sdk/threshold/src/index.js +254 -0
  164. package/sdk/threshold/src/index.test.ts +238 -0
  165. package/sdk/threshold/src/index.ts +355 -0
  166. package/sdk/threshold/tsconfig.json +19 -0
  167. package/sdk/threshold/vitest.config.ts +12 -0
  168. package/src/agentAdapter.test.ts +48 -0
  169. package/src/agentAdapter.ts +56 -0
  170. package/src/auditLog.test.ts +145 -0
  171. package/src/auditLog.ts +147 -0
  172. package/src/capability.test.ts +504 -0
  173. package/src/capability.ts +380 -0
  174. package/src/cborCodec.test.ts +263 -0
  175. package/src/cborCodec.ts +339 -0
  176. package/src/conformance.test.ts +136 -0
  177. package/src/conformanceVectors.ts +99 -0
  178. package/src/encryption.test.ts +206 -0
  179. package/src/encryption.ts +245 -0
  180. package/src/envelopeCbor.ts +140 -0
  181. package/src/frameworkAdapters.test.ts +290 -0
  182. package/src/frameworkAdapters.ts +261 -0
  183. package/src/gateway.test.ts +343 -0
  184. package/src/gateway.ts +246 -0
  185. package/src/grpcBinding.test.ts +211 -0
  186. package/src/grpcBinding.ts +103 -0
  187. package/src/httpBinding.test.ts +376 -0
  188. package/src/httpBinding.ts +189 -0
  189. package/src/index.ts +39 -0
  190. package/src/keyInfra.test.ts +278 -0
  191. package/src/keyInfra.ts +228 -0
  192. package/src/keyRegistry.ts +59 -0
  193. package/src/keyRotation.test.ts +78 -0
  194. package/src/keyRotation.ts +72 -0
  195. package/src/mcpGateway.test.ts +129 -0
  196. package/src/mcpGateway.ts +250 -0
  197. package/src/mcpTransports.test.ts +92 -0
  198. package/src/mcpTransports.ts +169 -0
  199. package/src/mcpWrapper.test.ts +179 -0
  200. package/src/mcpWrapper.ts +206 -0
  201. package/src/otel.ts +136 -0
  202. package/src/policyEnforcer.test.ts +99 -0
  203. package/src/policyEnforcer.ts +169 -0
  204. package/src/policyTelemetryFeedback.test.ts +25 -0
  205. package/src/policyTelemetryFeedback.ts +38 -0
  206. package/src/protocol.bench.ts +37 -0
  207. package/{protocol.d.ts → src/protocol.d.ts} +2 -1
  208. package/src/protocol.d.ts.map +1 -0
  209. package/src/protocol.js +294 -0
  210. package/src/protocol.test.ts +155 -0
  211. package/src/protocol.ts +414 -0
  212. package/src/protocolAgent.test.ts +105 -0
  213. package/src/protocolAgent.ts +169 -0
  214. package/src/protocolBinary.test.ts +165 -0
  215. package/src/protocolBinary.ts +312 -0
  216. package/src/protocolCapabilities.ts +70 -0
  217. package/src/protocolFuzz.advanced.test.ts +235 -0
  218. package/src/protocolFuzz.test.ts +111 -0
  219. package/src/protocolNegative.test.ts +97 -0
  220. package/src/protocolReplay.test.ts +71 -0
  221. package/src/protocolReplay.ts +194 -0
  222. package/src/protocolTransport.test.ts +556 -0
  223. package/src/protocolTransport.ts +483 -0
  224. package/src/queueBinding.test.ts +130 -0
  225. package/src/queueBinding.ts +102 -0
  226. package/src/rateLimiter.test.ts +96 -0
  227. package/src/rateLimiter.ts +46 -0
  228. package/src/redisClient.ts +140 -0
  229. package/src/redisIntegration.test.ts +134 -0
  230. package/src/replayStores.test.ts +273 -0
  231. package/src/replayStores.ts +215 -0
  232. package/src/revocation.test.ts +98 -0
  233. package/src/revocation.ts +0 -0
  234. package/src/routePolicy.test.ts +87 -0
  235. package/src/routePolicy.ts +72 -0
  236. package/src/runtimePolicy.test.ts +49 -0
  237. package/src/runtimePolicy.ts +81 -0
  238. package/src/runtimePolicyManager.test.ts +29 -0
  239. package/src/runtimePolicyManager.ts +50 -0
  240. package/src/runtimePolicyPresets.ts +43 -0
  241. package/src/signedResponse.test.ts +111 -0
  242. package/src/signedResponse.ts +83 -0
  243. package/src/stream.test.ts +254 -0
  244. package/src/stream.ts +417 -0
  245. package/src/telemetry.test.ts +251 -0
  246. package/src/telemetry.ts +299 -0
  247. package/src/webhookBinding.test.ts +144 -0
  248. package/src/webhookBinding.ts +115 -0
  249. package/src/wsBinding.test.ts +221 -0
  250. package/src/wsBinding.ts +200 -0
  251. package/tsconfig.json +15 -0
  252. package/tsconfig.lib.json +23 -0
  253. package/vite.lib.config.ts +16 -0
  254. package/vitest.config.ts +11 -0
  255. package/agentAdapter.d.ts +0 -26
  256. package/conformanceVectors.d.ts +0 -20
  257. package/frameworkAdapters.d.ts +0 -72
  258. package/index.d.ts +0 -20
  259. package/index.js +0 -1702
  260. package/keyRotation.d.ts +0 -20
  261. package/mcpGateway.d.ts +0 -37
  262. package/mcpTransports.d.ts +0 -62
  263. package/mcpWrapper.d.ts +0 -83
  264. package/policyEnforcer.d.ts +0 -50
  265. package/policyTelemetryFeedback.d.ts +0 -11
  266. package/protocolAgent.d.ts +0 -58
  267. package/protocolBinary.d.ts +0 -8
  268. package/protocolCapabilities.d.ts +0 -24
  269. package/protocolReplay.d.ts +0 -35
  270. package/protocolTransport.d.ts +0 -73
  271. package/redisClient.d.ts +0 -49
  272. package/replayStores.d.ts +0 -32
  273. package/revocation.d.ts +0 -71
  274. package/runtimePolicy.d.ts +0 -24
  275. package/runtimePolicyManager.d.ts +0 -15
  276. package/runtimePolicyPresets.d.ts +0 -11
@@ -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
+ })