@mcp-i/core 0.1.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 (226) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +390 -0
  3. package/dist/auth/handshake.d.ts +104 -0
  4. package/dist/auth/handshake.d.ts.map +1 -0
  5. package/dist/auth/handshake.js +230 -0
  6. package/dist/auth/handshake.js.map +1 -0
  7. package/dist/auth/index.d.ts +3 -0
  8. package/dist/auth/index.d.ts.map +1 -0
  9. package/dist/auth/index.js +2 -0
  10. package/dist/auth/index.js.map +1 -0
  11. package/dist/auth/types.d.ts +31 -0
  12. package/dist/auth/types.d.ts.map +1 -0
  13. package/dist/auth/types.js +7 -0
  14. package/dist/auth/types.js.map +1 -0
  15. package/dist/delegation/audience-validator.d.ts +9 -0
  16. package/dist/delegation/audience-validator.d.ts.map +1 -0
  17. package/dist/delegation/audience-validator.js +17 -0
  18. package/dist/delegation/audience-validator.js.map +1 -0
  19. package/dist/delegation/bitstring.d.ts +37 -0
  20. package/dist/delegation/bitstring.d.ts.map +1 -0
  21. package/dist/delegation/bitstring.js +117 -0
  22. package/dist/delegation/bitstring.js.map +1 -0
  23. package/dist/delegation/cascading-revocation.d.ts +45 -0
  24. package/dist/delegation/cascading-revocation.d.ts.map +1 -0
  25. package/dist/delegation/cascading-revocation.js +148 -0
  26. package/dist/delegation/cascading-revocation.js.map +1 -0
  27. package/dist/delegation/delegation-graph.d.ts +49 -0
  28. package/dist/delegation/delegation-graph.d.ts.map +1 -0
  29. package/dist/delegation/delegation-graph.js +99 -0
  30. package/dist/delegation/delegation-graph.js.map +1 -0
  31. package/dist/delegation/did-key-resolver.d.ts +64 -0
  32. package/dist/delegation/did-key-resolver.d.ts.map +1 -0
  33. package/dist/delegation/did-key-resolver.js +154 -0
  34. package/dist/delegation/did-key-resolver.js.map +1 -0
  35. package/dist/delegation/did-web-resolver.d.ts +83 -0
  36. package/dist/delegation/did-web-resolver.d.ts.map +1 -0
  37. package/dist/delegation/did-web-resolver.js +218 -0
  38. package/dist/delegation/did-web-resolver.js.map +1 -0
  39. package/dist/delegation/index.d.ts +21 -0
  40. package/dist/delegation/index.d.ts.map +1 -0
  41. package/dist/delegation/index.js +21 -0
  42. package/dist/delegation/index.js.map +1 -0
  43. package/dist/delegation/outbound-headers.d.ts +81 -0
  44. package/dist/delegation/outbound-headers.d.ts.map +1 -0
  45. package/dist/delegation/outbound-headers.js +139 -0
  46. package/dist/delegation/outbound-headers.js.map +1 -0
  47. package/dist/delegation/outbound-proof.d.ts +43 -0
  48. package/dist/delegation/outbound-proof.d.ts.map +1 -0
  49. package/dist/delegation/outbound-proof.js +52 -0
  50. package/dist/delegation/outbound-proof.js.map +1 -0
  51. package/dist/delegation/statuslist-manager.d.ts +44 -0
  52. package/dist/delegation/statuslist-manager.d.ts.map +1 -0
  53. package/dist/delegation/statuslist-manager.js +126 -0
  54. package/dist/delegation/statuslist-manager.js.map +1 -0
  55. package/dist/delegation/storage/memory-graph-storage.d.ts +70 -0
  56. package/dist/delegation/storage/memory-graph-storage.d.ts.map +1 -0
  57. package/dist/delegation/storage/memory-graph-storage.js +145 -0
  58. package/dist/delegation/storage/memory-graph-storage.js.map +1 -0
  59. package/dist/delegation/storage/memory-statuslist-storage.d.ts +19 -0
  60. package/dist/delegation/storage/memory-statuslist-storage.d.ts.map +1 -0
  61. package/dist/delegation/storage/memory-statuslist-storage.js +33 -0
  62. package/dist/delegation/storage/memory-statuslist-storage.js.map +1 -0
  63. package/dist/delegation/utils.d.ts +49 -0
  64. package/dist/delegation/utils.d.ts.map +1 -0
  65. package/dist/delegation/utils.js +131 -0
  66. package/dist/delegation/utils.js.map +1 -0
  67. package/dist/delegation/vc-issuer.d.ts +56 -0
  68. package/dist/delegation/vc-issuer.d.ts.map +1 -0
  69. package/dist/delegation/vc-issuer.js +80 -0
  70. package/dist/delegation/vc-issuer.js.map +1 -0
  71. package/dist/delegation/vc-verifier.d.ts +112 -0
  72. package/dist/delegation/vc-verifier.d.ts.map +1 -0
  73. package/dist/delegation/vc-verifier.js +280 -0
  74. package/dist/delegation/vc-verifier.js.map +1 -0
  75. package/dist/index.d.ts +45 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +53 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/logging/index.d.ts +2 -0
  80. package/dist/logging/index.d.ts.map +1 -0
  81. package/dist/logging/index.js +2 -0
  82. package/dist/logging/index.js.map +1 -0
  83. package/dist/logging/logger.d.ts +23 -0
  84. package/dist/logging/logger.d.ts.map +1 -0
  85. package/dist/logging/logger.js +82 -0
  86. package/dist/logging/logger.js.map +1 -0
  87. package/dist/middleware/index.d.ts +7 -0
  88. package/dist/middleware/index.d.ts.map +1 -0
  89. package/dist/middleware/index.js +7 -0
  90. package/dist/middleware/index.js.map +1 -0
  91. package/dist/middleware/with-mcpi.d.ts +152 -0
  92. package/dist/middleware/with-mcpi.d.ts.map +1 -0
  93. package/dist/middleware/with-mcpi.js +472 -0
  94. package/dist/middleware/with-mcpi.js.map +1 -0
  95. package/dist/proof/errors.d.ts +49 -0
  96. package/dist/proof/errors.d.ts.map +1 -0
  97. package/dist/proof/errors.js +61 -0
  98. package/dist/proof/errors.js.map +1 -0
  99. package/dist/proof/generator.d.ts +65 -0
  100. package/dist/proof/generator.d.ts.map +1 -0
  101. package/dist/proof/generator.js +163 -0
  102. package/dist/proof/generator.js.map +1 -0
  103. package/dist/proof/index.d.ts +4 -0
  104. package/dist/proof/index.d.ts.map +1 -0
  105. package/dist/proof/index.js +4 -0
  106. package/dist/proof/index.js.map +1 -0
  107. package/dist/proof/verifier.d.ts +108 -0
  108. package/dist/proof/verifier.d.ts.map +1 -0
  109. package/dist/proof/verifier.js +299 -0
  110. package/dist/proof/verifier.js.map +1 -0
  111. package/dist/providers/base.d.ts +64 -0
  112. package/dist/providers/base.d.ts.map +1 -0
  113. package/dist/providers/base.js +19 -0
  114. package/dist/providers/base.js.map +1 -0
  115. package/dist/providers/index.d.ts +3 -0
  116. package/dist/providers/index.d.ts.map +1 -0
  117. package/dist/providers/index.js +3 -0
  118. package/dist/providers/index.js.map +1 -0
  119. package/dist/providers/memory.d.ts +33 -0
  120. package/dist/providers/memory.d.ts.map +1 -0
  121. package/dist/providers/memory.js +102 -0
  122. package/dist/providers/memory.js.map +1 -0
  123. package/dist/session/index.d.ts +2 -0
  124. package/dist/session/index.d.ts.map +1 -0
  125. package/dist/session/index.js +2 -0
  126. package/dist/session/index.js.map +1 -0
  127. package/dist/session/manager.d.ts +77 -0
  128. package/dist/session/manager.d.ts.map +1 -0
  129. package/dist/session/manager.js +251 -0
  130. package/dist/session/manager.js.map +1 -0
  131. package/dist/types/protocol.d.ts +320 -0
  132. package/dist/types/protocol.d.ts.map +1 -0
  133. package/dist/types/protocol.js +229 -0
  134. package/dist/types/protocol.js.map +1 -0
  135. package/dist/utils/base58.d.ts +31 -0
  136. package/dist/utils/base58.d.ts.map +1 -0
  137. package/dist/utils/base58.js +104 -0
  138. package/dist/utils/base58.js.map +1 -0
  139. package/dist/utils/base64.d.ts +13 -0
  140. package/dist/utils/base64.d.ts.map +1 -0
  141. package/dist/utils/base64.js +99 -0
  142. package/dist/utils/base64.js.map +1 -0
  143. package/dist/utils/crypto-service.d.ts +37 -0
  144. package/dist/utils/crypto-service.d.ts.map +1 -0
  145. package/dist/utils/crypto-service.js +153 -0
  146. package/dist/utils/crypto-service.js.map +1 -0
  147. package/dist/utils/did-helpers.d.ts +156 -0
  148. package/dist/utils/did-helpers.d.ts.map +1 -0
  149. package/dist/utils/did-helpers.js +193 -0
  150. package/dist/utils/did-helpers.js.map +1 -0
  151. package/dist/utils/ed25519-constants.d.ts +18 -0
  152. package/dist/utils/ed25519-constants.d.ts.map +1 -0
  153. package/dist/utils/ed25519-constants.js +21 -0
  154. package/dist/utils/ed25519-constants.js.map +1 -0
  155. package/dist/utils/index.d.ts +5 -0
  156. package/dist/utils/index.d.ts.map +1 -0
  157. package/dist/utils/index.js +5 -0
  158. package/dist/utils/index.js.map +1 -0
  159. package/package.json +105 -0
  160. package/src/__tests__/integration/full-flow.test.ts +362 -0
  161. package/src/__tests__/providers/base.test.ts +173 -0
  162. package/src/__tests__/providers/memory.test.ts +332 -0
  163. package/src/__tests__/utils/mock-providers.ts +319 -0
  164. package/src/__tests__/utils/node-crypto-provider.ts +93 -0
  165. package/src/auth/handshake.ts +411 -0
  166. package/src/auth/index.ts +11 -0
  167. package/src/auth/types.ts +40 -0
  168. package/src/delegation/__tests__/audience-validator.test.ts +110 -0
  169. package/src/delegation/__tests__/bitstring.test.ts +346 -0
  170. package/src/delegation/__tests__/cascading-revocation.test.ts +624 -0
  171. package/src/delegation/__tests__/delegation-graph.test.ts +623 -0
  172. package/src/delegation/__tests__/did-key-resolver.test.ts +265 -0
  173. package/src/delegation/__tests__/did-web-resolver.test.ts +467 -0
  174. package/src/delegation/__tests__/outbound-headers.test.ts +230 -0
  175. package/src/delegation/__tests__/outbound-proof.test.ts +179 -0
  176. package/src/delegation/__tests__/statuslist-manager.test.ts +515 -0
  177. package/src/delegation/__tests__/utils.test.ts +185 -0
  178. package/src/delegation/__tests__/vc-issuer.test.ts +487 -0
  179. package/src/delegation/__tests__/vc-verifier.test.ts +1029 -0
  180. package/src/delegation/audience-validator.ts +24 -0
  181. package/src/delegation/bitstring.ts +160 -0
  182. package/src/delegation/cascading-revocation.ts +224 -0
  183. package/src/delegation/delegation-graph.ts +143 -0
  184. package/src/delegation/did-key-resolver.ts +181 -0
  185. package/src/delegation/did-web-resolver.ts +270 -0
  186. package/src/delegation/index.ts +33 -0
  187. package/src/delegation/outbound-headers.ts +193 -0
  188. package/src/delegation/outbound-proof.ts +90 -0
  189. package/src/delegation/statuslist-manager.ts +219 -0
  190. package/src/delegation/storage/__tests__/memory-graph-storage.test.ts +366 -0
  191. package/src/delegation/storage/__tests__/memory-statuslist-storage.test.ts +228 -0
  192. package/src/delegation/storage/memory-graph-storage.ts +178 -0
  193. package/src/delegation/storage/memory-statuslist-storage.ts +42 -0
  194. package/src/delegation/utils.ts +189 -0
  195. package/src/delegation/vc-issuer.ts +137 -0
  196. package/src/delegation/vc-verifier.ts +440 -0
  197. package/src/index.ts +264 -0
  198. package/src/logging/__tests__/logger.test.ts +366 -0
  199. package/src/logging/index.ts +6 -0
  200. package/src/logging/logger.ts +91 -0
  201. package/src/middleware/__tests__/with-mcpi.test.ts +504 -0
  202. package/src/middleware/index.ts +16 -0
  203. package/src/middleware/with-mcpi.ts +766 -0
  204. package/src/proof/__tests__/proof-generator.test.ts +483 -0
  205. package/src/proof/__tests__/verifier.test.ts +488 -0
  206. package/src/proof/errors.ts +75 -0
  207. package/src/proof/generator.ts +255 -0
  208. package/src/proof/index.ts +22 -0
  209. package/src/proof/verifier.ts +449 -0
  210. package/src/providers/base.ts +68 -0
  211. package/src/providers/index.ts +15 -0
  212. package/src/providers/memory.ts +130 -0
  213. package/src/session/__tests__/session-manager.test.ts +342 -0
  214. package/src/session/index.ts +7 -0
  215. package/src/session/manager.ts +332 -0
  216. package/src/types/protocol.ts +596 -0
  217. package/src/utils/__tests__/base58.test.ts +281 -0
  218. package/src/utils/__tests__/base64.test.ts +239 -0
  219. package/src/utils/__tests__/crypto-service.test.ts +530 -0
  220. package/src/utils/__tests__/did-helpers.test.ts +156 -0
  221. package/src/utils/base58.ts +115 -0
  222. package/src/utils/base64.ts +116 -0
  223. package/src/utils/crypto-service.ts +209 -0
  224. package/src/utils/did-helpers.ts +210 -0
  225. package/src/utils/ed25519-constants.ts +23 -0
  226. package/src/utils/index.ts +9 -0
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Session Manager Tests — @mcp-i/core
3
+ *
4
+ * Verifies platform-agnostic SessionManager behaviour:
5
+ * - Nonce format identical to existing implementation
6
+ * - TTL / expiry behaviour unchanged
7
+ * - getSession returns correct session by ID
8
+ * - validateHandshake behaviour unchanged
9
+ *
10
+ * NodeCryptoProvider is injected (test environment is Node.js).
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach } from "vitest";
14
+ import { SessionManager, createHandshakeRequest, validateHandshakeFormat } from "../manager.js";
15
+ import type { SessionConfig } from "../manager.js";
16
+ import type { HandshakeRequest } from "../../types/protocol.js";
17
+
18
+ // NodeCryptoProvider for test environment (Node.js)
19
+ import { NodeCryptoProvider } from "../../__tests__/utils/node-crypto-provider.js";
20
+
21
+ const cryptoProvider = new NodeCryptoProvider();
22
+
23
+ function makeSessionManager(config: SessionConfig = {}): SessionManager {
24
+ return new SessionManager(cryptoProvider, config);
25
+ }
26
+
27
+ function makeRequest(overrides: Partial<HandshakeRequest> = {}): HandshakeRequest {
28
+ return {
29
+ nonce: SessionManager.generateNonce(),
30
+ audience: "example.com",
31
+ timestamp: Math.floor(Date.now() / 1000),
32
+ agentDid: "did:key:zAgent",
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ describe("SessionManager", () => {
38
+ let manager: SessionManager;
39
+
40
+ beforeEach(() => {
41
+ manager = makeSessionManager();
42
+ });
43
+
44
+ describe("Nonce format", () => {
45
+ it("should generate nonce as base64url string", () => {
46
+ const nonce = SessionManager.generateNonce();
47
+ expect(typeof nonce).toBe("string");
48
+ expect(nonce.length).toBeGreaterThan(0);
49
+ // base64url uses A-Z a-z 0-9 - _ (no padding)
50
+ expect(nonce).toMatch(/^[A-Za-z0-9_-]+$/);
51
+ });
52
+
53
+ it("should generate unique nonces", () => {
54
+ const nonces = new Set(Array.from({ length: 20 }, () => SessionManager.generateNonce()));
55
+ expect(nonces.size).toBe(20);
56
+ });
57
+
58
+ it("should generate nonces of consistent entropy (16-byte = ~22 base64url chars)", () => {
59
+ const nonce = SessionManager.generateNonce();
60
+ // 16 bytes base64url → ceil(16 * 4/3) = 22 chars (no padding)
61
+ expect(nonce.length).toBeGreaterThanOrEqual(20);
62
+ expect(nonce.length).toBeLessThanOrEqual(24);
63
+ });
64
+ });
65
+
66
+ describe("Handshake validation", () => {
67
+ it("should create a valid session on correct handshake", async () => {
68
+ const request = makeRequest();
69
+ const result = await manager.validateHandshake(request);
70
+
71
+ expect(result.success).toBe(true);
72
+ expect(result.session).toBeDefined();
73
+ expect(result.session?.audience).toBe(request.audience);
74
+ expect(result.session?.nonce).toBe(request.nonce);
75
+ expect(result.session?.identityState).toBe("anonymous");
76
+ });
77
+
78
+ it("should return session ID with mcpi_ prefix", async () => {
79
+ const request = makeRequest();
80
+ const result = await manager.validateHandshake(request);
81
+
82
+ expect(result.session?.sessionId).toMatch(/^mcpi_/);
83
+ });
84
+
85
+ it("should reject request with stale timestamp", async () => {
86
+ const staleTs = Math.floor(Date.now() / 1000) - 200; // 200s ago, skew is 120s
87
+ const request = makeRequest({ timestamp: staleTs });
88
+ const result = await manager.validateHandshake(request);
89
+
90
+ expect(result.success).toBe(false);
91
+ expect(result.error?.code).toBe("XMCP_I_EHANDSHAKE");
92
+ });
93
+
94
+ it("should accept request within timestamp skew", async () => {
95
+ const slightlyOldTs = Math.floor(Date.now() / 1000) - 60; // 60s ago, within 120s default
96
+ const request = makeRequest({ timestamp: slightlyOldTs });
97
+ const result = await manager.validateHandshake(request);
98
+
99
+ expect(result.success).toBe(true);
100
+ });
101
+
102
+ it("should reject replayed nonce", async () => {
103
+ const request = makeRequest();
104
+ await manager.validateHandshake(request);
105
+
106
+ // Same request again (same nonce)
107
+ const second = await manager.validateHandshake(request);
108
+ expect(second.success).toBe(false);
109
+ expect(second.error?.code).toBe("XMCP_I_EHANDSHAKE");
110
+ });
111
+
112
+ it("should accept different nonces in succession", async () => {
113
+ const r1 = makeRequest();
114
+ const r2 = makeRequest(); // fresh nonce
115
+
116
+ const res1 = await manager.validateHandshake(r1);
117
+ const res2 = await manager.validateHandshake(r2);
118
+
119
+ expect(res1.success).toBe(true);
120
+ expect(res2.success).toBe(true);
121
+ });
122
+ });
123
+
124
+ describe("Session lookup — getSession", () => {
125
+ it("should return session by ID after handshake", async () => {
126
+ const request = makeRequest();
127
+ const { session } = await manager.validateHandshake(request);
128
+ expect(session).toBeDefined();
129
+
130
+ const found = await manager.getSession(session!.sessionId);
131
+ expect(found).toBeDefined();
132
+ expect(found?.sessionId).toBe(session!.sessionId);
133
+ });
134
+
135
+ it("should return null for unknown session ID", async () => {
136
+ const found = await manager.getSession("mcpi_does-not-exist");
137
+ expect(found).toBeNull();
138
+ });
139
+
140
+ it("should update lastActivity on each getSession call", async () => {
141
+ const request = makeRequest();
142
+ const { session } = await manager.validateHandshake(request);
143
+
144
+ const before = session!.lastActivity;
145
+ // Artificially advance lastActivity to simulate time passing
146
+ await new Promise((r) => setTimeout(r, 10));
147
+ const found = await manager.getSession(session!.sessionId);
148
+
149
+ // lastActivity should be updated (>= before)
150
+ expect(found!.lastActivity).toBeGreaterThanOrEqual(before);
151
+ });
152
+ });
153
+
154
+ describe("Session expiry — TTL behaviour", () => {
155
+ it("should expire idle sessions after TTL", async () => {
156
+ // Use 1-minute TTL but manually manipulate lastActivity
157
+ const sm = makeSessionManager({ sessionTtlMinutes: 1 });
158
+ const request = makeRequest();
159
+ const { session } = await sm.validateHandshake(request);
160
+ expect(session).toBeDefined();
161
+
162
+ // Backdate lastActivity beyond TTL (simulate 70s of idle)
163
+ session!.lastActivity = Math.floor(Date.now() / 1000) - 70;
164
+ // Directly set in sessions map via cleanup
165
+ await sm.cleanup();
166
+
167
+ const found = await sm.getSession(session!.sessionId);
168
+ expect(found).toBeNull();
169
+ });
170
+
171
+ it("should not expire active sessions within TTL", async () => {
172
+ const sm = makeSessionManager({ sessionTtlMinutes: 30 });
173
+ const request = makeRequest();
174
+ const { session } = await sm.validateHandshake(request);
175
+ expect(session).toBeDefined();
176
+
177
+ const found = await sm.getSession(session!.sessionId);
178
+ expect(found).not.toBeNull();
179
+ });
180
+
181
+ it("should expire sessions beyond absolute lifetime", async () => {
182
+ const sm = makeSessionManager({ absoluteSessionLifetime: 1 });
183
+ const request = makeRequest();
184
+ const { session } = await sm.validateHandshake(request);
185
+ expect(session).toBeDefined();
186
+
187
+ // Backdate createdAt beyond 1-minute absolute lifetime
188
+ session!.createdAt = Math.floor(Date.now() / 1000) - 65;
189
+ await sm.cleanup();
190
+
191
+ const found = await sm.getSession(session!.sessionId);
192
+ expect(found).toBeNull();
193
+ });
194
+ });
195
+
196
+ describe("Custom timestamp skew", () => {
197
+ it("should use custom timestampSkewSeconds when provided", async () => {
198
+ const sm = makeSessionManager({ timestampSkewSeconds: 30 });
199
+
200
+ // 40s stale — should fail with 30s skew
201
+ const staleRequest = makeRequest({
202
+ timestamp: Math.floor(Date.now() / 1000) - 40,
203
+ });
204
+ const result = await sm.validateHandshake(staleRequest);
205
+
206
+ expect(result.success).toBe(false);
207
+ });
208
+
209
+ it("should accept request within custom skew", async () => {
210
+ const sm = makeSessionManager({ timestampSkewSeconds: 60 });
211
+
212
+ // 50s stale — should pass with 60s skew
213
+ const request = makeRequest({
214
+ timestamp: Math.floor(Date.now() / 1000) - 50,
215
+ });
216
+ const result = await sm.validateHandshake(request);
217
+
218
+ expect(result.success).toBe(true);
219
+ });
220
+ });
221
+
222
+ describe("getStats", () => {
223
+ it("should report zero active sessions initially", () => {
224
+ const stats = manager.getStats();
225
+ expect(stats.activeSessions).toBe(0);
226
+ });
227
+
228
+ it("should report correct count after handshakes", async () => {
229
+ await manager.validateHandshake(makeRequest());
230
+ await manager.validateHandshake(makeRequest());
231
+
232
+ const stats = manager.getStats();
233
+ expect(stats.activeSessions).toBe(2);
234
+ });
235
+ });
236
+
237
+ describe("clearSessions", () => {
238
+ it("should clear all sessions", async () => {
239
+ await manager.validateHandshake(makeRequest());
240
+ manager.clearSessions();
241
+
242
+ const stats = manager.getStats();
243
+ expect(stats.activeSessions).toBe(0);
244
+ });
245
+ });
246
+
247
+ describe("setServerDid", () => {
248
+ it("should include serverDid in session when set", async () => {
249
+ manager.setServerDid("did:web:example.com:server");
250
+ const request = makeRequest({ audience: "did:web:example.com:server" });
251
+ const { session } = await manager.validateHandshake(request);
252
+
253
+ expect(session?.serverDid).toBe("did:web:example.com:server");
254
+ });
255
+ });
256
+
257
+ describe("Audience validation", () => {
258
+ it("should reject handshake when audience doesn't match serverDid", async () => {
259
+ const sm = makeSessionManager({ serverDid: "did:web:example.com:server" });
260
+ const request = makeRequest({ audience: "did:web:other.com:server" });
261
+ const result = await sm.validateHandshake(request);
262
+
263
+ expect(result.success).toBe(false);
264
+ expect(result.error?.code).toBe("MCPI_AUDIENCE_MISMATCH");
265
+ expect(result.error?.message).toContain("Audience mismatch");
266
+ });
267
+
268
+ it("should accept handshake when audience matches serverDid", async () => {
269
+ const sm = makeSessionManager({ serverDid: "did:web:example.com:server" });
270
+ const request = makeRequest({ audience: "did:web:example.com:server" });
271
+ const result = await sm.validateHandshake(request);
272
+
273
+ expect(result.success).toBe(true);
274
+ expect(result.session?.audience).toBe("did:web:example.com:server");
275
+ });
276
+
277
+ it("should accept handshake when serverDid is not configured (backward compat)", async () => {
278
+ const sm = makeSessionManager(); // No serverDid configured
279
+ const request = makeRequest({ audience: "any-audience" });
280
+ const result = await sm.validateHandshake(request);
281
+
282
+ expect(result.success).toBe(true);
283
+ });
284
+ });
285
+ });
286
+
287
+ describe("createHandshakeRequest", () => {
288
+ it("should create a valid handshake request for an audience", () => {
289
+ const req = createHandshakeRequest("example.com");
290
+
291
+ expect(req.audience).toBe("example.com");
292
+ expect(typeof req.nonce).toBe("string");
293
+ expect(req.nonce.length).toBeGreaterThan(0);
294
+ expect(typeof req.timestamp).toBe("number");
295
+ expect(req.timestamp).toBeGreaterThan(0);
296
+ });
297
+
298
+ it("should generate unique nonces across calls", () => {
299
+ const nonces = new Set(
300
+ Array.from({ length: 20 }, () => createHandshakeRequest("test.com").nonce)
301
+ );
302
+ expect(nonces.size).toBe(20);
303
+ });
304
+
305
+ it("should use current timestamp", () => {
306
+ const before = Math.floor(Date.now() / 1000);
307
+ const req = createHandshakeRequest("test.com");
308
+ const after = Math.floor(Date.now() / 1000);
309
+
310
+ expect(req.timestamp).toBeGreaterThanOrEqual(before);
311
+ expect(req.timestamp).toBeLessThanOrEqual(after);
312
+ });
313
+ });
314
+
315
+ describe("validateHandshakeFormat", () => {
316
+ it("should return true for a valid request", () => {
317
+ const req = createHandshakeRequest("example.com");
318
+ expect(validateHandshakeFormat(req)).toBe(true);
319
+ });
320
+
321
+ it("should return false for missing nonce", () => {
322
+ expect(validateHandshakeFormat({ audience: "x", timestamp: 1 })).toBe(false);
323
+ });
324
+
325
+ it("should return false for empty nonce", () => {
326
+ expect(validateHandshakeFormat({ nonce: "", audience: "x", timestamp: 1 })).toBe(false);
327
+ });
328
+
329
+ it("should return false for missing audience", () => {
330
+ expect(validateHandshakeFormat({ nonce: "abc", timestamp: 1 })).toBe(false);
331
+ });
332
+
333
+ it("should return false for non-integer timestamp", () => {
334
+ expect(validateHandshakeFormat({ nonce: "abc", audience: "x", timestamp: 1.5 })).toBe(false);
335
+ });
336
+
337
+ it("should return false for non-object", () => {
338
+ expect(validateHandshakeFormat(null)).toBe(false);
339
+ expect(validateHandshakeFormat("string")).toBe(false);
340
+ expect(validateHandshakeFormat(42)).toBe(false);
341
+ });
342
+ });
@@ -0,0 +1,7 @@
1
+ export {
2
+ SessionManager,
3
+ createHandshakeRequest,
4
+ validateHandshakeFormat,
5
+ type SessionConfig,
6
+ type HandshakeResult,
7
+ } from './manager.js';
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Session Management — Platform-agnostic Protocol Reference
3
+ *
4
+ * Handles handshake enforcement, session management, and nonce validation
5
+ * according to MCP-I requirements 4.5–4.9 and 19.1–19.2.
6
+ *
7
+ * Platform adapters inject a CryptoProvider for all random byte generation.
8
+ * The static generateNonce() uses globalThis.crypto (available Node 20+ and
9
+ * Cloudflare Workers) to remain synchronous without platform-specific imports.
10
+ */
11
+
12
+ import type {
13
+ HandshakeRequest,
14
+ SessionContext,
15
+ NonceCache,
16
+ } from '../types/protocol.js';
17
+ import type { CryptoProvider } from '../providers/base.js';
18
+ import { MemoryNonceCacheProvider } from '../providers/memory.js';
19
+ import { logger } from '../logging/index.js';
20
+
21
+ export interface SessionConfig {
22
+ timestampSkewSeconds?: number;
23
+ sessionTtlMinutes?: number;
24
+ absoluteSessionLifetime?: number;
25
+ nonceCache?: NonceCache;
26
+ serverDid?: string;
27
+ }
28
+
29
+ export interface HandshakeResult {
30
+ success: boolean;
31
+ session?: SessionContext;
32
+ error?: {
33
+ code: string;
34
+ message: string;
35
+ remediation?: string;
36
+ };
37
+ }
38
+
39
+ export class SessionManager {
40
+ private config: Required<Omit<SessionConfig, 'absoluteSessionLifetime' | 'serverDid'>> & {
41
+ absoluteSessionLifetime?: number;
42
+ serverDid?: string;
43
+ };
44
+ private cryptoProvider: CryptoProvider;
45
+ private sessions = new Map<string, SessionContext>();
46
+
47
+ constructor(cryptoProvider: CryptoProvider, config: SessionConfig = {}) {
48
+ this.cryptoProvider = cryptoProvider;
49
+ this.config = {
50
+ timestampSkewSeconds: config.timestampSkewSeconds ?? 120,
51
+ sessionTtlMinutes: config.sessionTtlMinutes ?? 30,
52
+ nonceCache: config.nonceCache ?? new MemoryNonceCacheProvider(),
53
+ ...(config.absoluteSessionLifetime !== undefined && {
54
+ absoluteSessionLifetime: config.absoluteSessionLifetime,
55
+ }),
56
+ ...(config.serverDid !== undefined && { serverDid: config.serverDid }),
57
+ };
58
+
59
+ if (this.config.nonceCache instanceof MemoryNonceCacheProvider) {
60
+ logger.warn(
61
+ '[SessionManager] Using MemoryNonceCacheProvider — not suitable for ' +
62
+ 'multi-instance deployments. Use Redis, DynamoDB, or Cloudflare KV ' +
63
+ 'for production.'
64
+ );
65
+ }
66
+ }
67
+
68
+ setServerDid(serverDid: string): void {
69
+ this.config.serverDid = serverDid;
70
+ }
71
+
72
+ /**
73
+ * Validate an MCP-I handshake request and create a session.
74
+ *
75
+ * Performs the following checks:
76
+ * - Timestamp within acceptable skew window
77
+ * - Audience matches server DID (if configured)
78
+ * - Nonce not previously used (replay protection)
79
+ *
80
+ * @param request - The handshake request containing nonce, audience, timestamp, and optional agentDid
81
+ * @returns Result object with success flag, session on success, or error details on failure
82
+ */
83
+ async validateHandshake(request: HandshakeRequest): Promise<HandshakeResult> {
84
+ try {
85
+ const now = Math.floor(Date.now() / 1000);
86
+ const timeDiff = Math.abs(now - request.timestamp);
87
+
88
+ if (timeDiff > this.config.timestampSkewSeconds) {
89
+ return {
90
+ success: false,
91
+ error: {
92
+ code: 'XMCP_I_EHANDSHAKE',
93
+ message: `Timestamp outside acceptable range (±${this.config.timestampSkewSeconds}s)`,
94
+ remediation: `Check NTP sync on client and server. Current server time: ${now}, received: ${request.timestamp}, diff: ${timeDiff}s. Adjust timestampSkewSeconds if needed.`,
95
+ },
96
+ };
97
+ }
98
+
99
+ // Validate audience matches this server's DID (SPEC.md §4 MUST)
100
+ if (this.config.serverDid && request.audience !== this.config.serverDid) {
101
+ return {
102
+ success: false,
103
+ error: {
104
+ code: 'MCPI_AUDIENCE_MISMATCH',
105
+ message: `Audience mismatch: expected ${this.config.serverDid}, got ${request.audience}`,
106
+ },
107
+ };
108
+ }
109
+
110
+ const nonceExists = await this.config.nonceCache.has(
111
+ request.nonce,
112
+ request.agentDid
113
+ );
114
+ if (nonceExists) {
115
+ return {
116
+ success: false,
117
+ error: {
118
+ code: 'XMCP_I_EHANDSHAKE',
119
+ message: 'Nonce already used (replay attack prevention)',
120
+ remediation: 'Generate a new unique nonce for each request',
121
+ },
122
+ };
123
+ }
124
+
125
+ const nonceTtlSeconds = this.config.sessionTtlMinutes * 60 + 60;
126
+ await this.config.nonceCache.add(
127
+ request.nonce,
128
+ nonceTtlSeconds,
129
+ request.agentDid
130
+ );
131
+
132
+ const sessionId = await this.generateSessionId();
133
+ const clientInfo = await this.buildClientInfo(request);
134
+
135
+ const session: SessionContext = {
136
+ sessionId,
137
+ audience: request.audience,
138
+ nonce: request.nonce,
139
+ timestamp: request.timestamp,
140
+ createdAt: now,
141
+ lastActivity: now,
142
+ ttlMinutes: this.config.sessionTtlMinutes,
143
+ identityState: 'anonymous',
144
+ agentDid: request.agentDid,
145
+ ...(this.config.serverDid && { serverDid: this.config.serverDid }),
146
+ ...(clientInfo && { clientInfo }),
147
+ };
148
+
149
+ this.sessions.set(sessionId, session);
150
+
151
+ return { success: true, session };
152
+ } catch (error) {
153
+ return {
154
+ success: false,
155
+ error: {
156
+ code: 'XMCP_I_EHANDSHAKE',
157
+ message: `Handshake validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
158
+ },
159
+ };
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Retrieve a session by ID, checking for expiration.
165
+ *
166
+ * Updates lastActivity timestamp on successful retrieval (sliding window expiry).
167
+ * Returns null if session doesn't exist, has exceeded idle TTL, or has exceeded
168
+ * absolute lifetime (if configured).
169
+ *
170
+ * @param sessionId - The session ID (e.g., "mcpi_...")
171
+ * @returns Session context if valid, null if expired or not found
172
+ */
173
+ async getSession(sessionId: string): Promise<SessionContext | null> {
174
+ const session = this.sessions.get(sessionId);
175
+ if (!session) return null;
176
+
177
+ const now = Math.floor(Date.now() / 1000);
178
+ const idleTimeSeconds = now - session.lastActivity;
179
+ const maxIdleSeconds = session.ttlMinutes * 60;
180
+
181
+ if (idleTimeSeconds > maxIdleSeconds) {
182
+ this.sessions.delete(sessionId);
183
+ return null;
184
+ }
185
+
186
+ if (this.config.absoluteSessionLifetime !== undefined) {
187
+ const sessionAgeSeconds = now - session.createdAt;
188
+ const maxAgeSeconds = this.config.absoluteSessionLifetime * 60;
189
+ if (sessionAgeSeconds > maxAgeSeconds) {
190
+ this.sessions.delete(sessionId);
191
+ return null;
192
+ }
193
+ }
194
+
195
+ session.lastActivity = now;
196
+ this.sessions.set(sessionId, session);
197
+ return session;
198
+ }
199
+
200
+ private async generateSessionId(): Promise<string> {
201
+ const bytes = await this.cryptoProvider.randomBytes(16);
202
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40;
203
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80;
204
+ const hex = Array.from(bytes)
205
+ .map((b) => b.toString(16).padStart(2, '0'))
206
+ .join('');
207
+ const uuid = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
208
+ return `mcpi_${uuid}`;
209
+ }
210
+
211
+ private async generateClientId(): Promise<string> {
212
+ const bytes = await this.cryptoProvider.randomBytes(6);
213
+ const hex = Array.from(bytes)
214
+ .map((b) => b.toString(16).padStart(2, '0'))
215
+ .join('');
216
+ return `client_${hex}`;
217
+ }
218
+
219
+ private normalizeClientInfoString(value: unknown): string | undefined {
220
+ if (typeof value !== 'string') return undefined;
221
+ const trimmed = value.trim();
222
+ return trimmed.length > 0 ? trimmed : undefined;
223
+ }
224
+
225
+ private async buildClientInfo(
226
+ request: HandshakeRequest
227
+ ): Promise<SessionContext['clientInfo'] | undefined> {
228
+ const hasMetadata =
229
+ !!request.clientInfo ||
230
+ typeof request.clientProtocolVersion === 'string' ||
231
+ request.clientCapabilities !== undefined;
232
+
233
+ if (!hasMetadata) return undefined;
234
+
235
+ const source = request.clientInfo;
236
+
237
+ return {
238
+ name: this.normalizeClientInfoString(source?.name) ?? 'unknown',
239
+ title: this.normalizeClientInfoString(source?.title),
240
+ version: this.normalizeClientInfoString(source?.version),
241
+ platform: this.normalizeClientInfoString(source?.platform),
242
+ vendor: this.normalizeClientInfoString(source?.vendor),
243
+ persistentId: this.normalizeClientInfoString(source?.persistentId),
244
+ clientId:
245
+ this.normalizeClientInfoString(source?.clientId) ??
246
+ (await this.generateClientId()),
247
+ protocolVersion: this.normalizeClientInfoString(request.clientProtocolVersion),
248
+ capabilities: request.clientCapabilities,
249
+ };
250
+ }
251
+
252
+ static generateNonce(): string {
253
+ const buffer = new Uint8Array(16);
254
+ globalThis.crypto.getRandomValues(buffer);
255
+ let binaryStr = '';
256
+ for (let i = 0; i < buffer.length; i++) {
257
+ binaryStr += String.fromCharCode(buffer[i]!);
258
+ }
259
+ return btoa(binaryStr)
260
+ .replace(/\+/g, '-')
261
+ .replace(/\//g, '_')
262
+ .replace(/=/g, '');
263
+ }
264
+
265
+ async cleanup(): Promise<void> {
266
+ const now = Math.floor(Date.now() / 1000);
267
+
268
+ for (const [sessionId, session] of this.sessions.entries()) {
269
+ const idleTimeSeconds = now - session.lastActivity;
270
+ const maxIdleSeconds = session.ttlMinutes * 60;
271
+ let expired = idleTimeSeconds > maxIdleSeconds;
272
+
273
+ if (!expired && this.config.absoluteSessionLifetime !== undefined) {
274
+ const sessionAgeSeconds = now - session.createdAt;
275
+ const maxAgeSeconds = this.config.absoluteSessionLifetime * 60;
276
+ expired = sessionAgeSeconds > maxAgeSeconds;
277
+ }
278
+
279
+ if (expired) {
280
+ this.sessions.delete(sessionId);
281
+ }
282
+ }
283
+
284
+ await this.config.nonceCache.cleanup();
285
+ }
286
+
287
+ getStats(): {
288
+ activeSessions: number;
289
+ config: {
290
+ timestampSkewSeconds: number;
291
+ sessionTtlMinutes: number;
292
+ absoluteSessionLifetime?: number;
293
+ cacheType: string;
294
+ };
295
+ } {
296
+ return {
297
+ activeSessions: this.sessions.size,
298
+ config: {
299
+ timestampSkewSeconds: this.config.timestampSkewSeconds,
300
+ sessionTtlMinutes: this.config.sessionTtlMinutes,
301
+ absoluteSessionLifetime: this.config.absoluteSessionLifetime,
302
+ cacheType: this.config.nonceCache.constructor.name,
303
+ },
304
+ };
305
+ }
306
+
307
+ clearSessions(): void {
308
+ this.sessions.clear();
309
+ }
310
+ }
311
+
312
+ export function createHandshakeRequest(audience: string): HandshakeRequest {
313
+ return {
314
+ nonce: SessionManager.generateNonce(),
315
+ audience,
316
+ timestamp: Math.floor(Date.now() / 1000),
317
+ };
318
+ }
319
+
320
+ export function validateHandshakeFormat(request: unknown): request is HandshakeRequest {
321
+ return (
322
+ typeof request === 'object' &&
323
+ request !== null &&
324
+ typeof (request as Record<string, unknown>)['nonce'] === 'string' &&
325
+ ((request as Record<string, unknown>)['nonce'] as string).length > 0 &&
326
+ typeof (request as Record<string, unknown>)['audience'] === 'string' &&
327
+ ((request as Record<string, unknown>)['audience'] as string).length > 0 &&
328
+ typeof (request as Record<string, unknown>)['timestamp'] === 'number' &&
329
+ ((request as Record<string, unknown>)['timestamp'] as number) > 0 &&
330
+ Number.isInteger((request as Record<string, unknown>)['timestamp'])
331
+ );
332
+ }