@hivemind-os/collective-core 0.2.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 (145) hide show
  1. package/.test-data/05a845c4-1682-41b9-97e5-65a5263156c0/spending.sqlite +0 -0
  2. package/.test-data/10e18ba5-98f0-42e0-8899-06a09459ae85/agents.sqlite +0 -0
  3. package/.test-data/20464456-23cb-4ff7-8df5-3c129fb95a90/agents.sqlite +0 -0
  4. package/.test-data/2e2ec66c-e8b4-43eb-945f-cca84ed0a0f6/agents.sqlite +0 -0
  5. package/.test-data/2f5ef9b7-01da-4ba0-a6fa-af8063a2567c/agents.sqlite +0 -0
  6. package/.test-data/3ac13cd5-84c9-4e71-8b89-6d3ef4dd819c/agents.sqlite +0 -0
  7. package/.test-data/40b7dd4a-fae0-4a5d-a6a0-64c9048af35e/agents.sqlite +0 -0
  8. package/.test-data/59511a04-42f8-4286-bb1e-733795e08749/agents.sqlite +0 -0
  9. package/.test-data/6778e8c5-6eb5-416d-8aca-9d559cdf83e4/agents.sqlite +0 -0
  10. package/.test-data/7648dc86-df90-4460-8f9c-55cdb481324b/agents.sqlite +0 -0
  11. package/.test-data/77162d98-6b22-41b1-a50f-536fab739f8a/agents.sqlite +0 -0
  12. package/.test-data/798dbdab-cbe7-4edd-8a5b-ae99c285fe17/agents.sqlite +0 -0
  13. package/.test-data/8033d6ac-8b85-454d-b708-00ac695b22f8/agents.sqlite +0 -0
  14. package/.test-data/9155a4f4-cda3-487c-9eed-921c82d7550f/agents.sqlite +0 -0
  15. package/.test-data/9bfeee53-c231-46c3-8c93-2180933f5d50/agents.sqlite +0 -0
  16. package/.test-data/a4c64287-79f6-46e9-847d-2803c63a74fd/agents.sqlite +0 -0
  17. package/.test-data/b8f58952-1ed8-46ff-abd7-21fb86e9457f/agents.sqlite +0 -0
  18. package/.test-data/c3060504-3187-41ed-8532-82332be48b0b/spending.sqlite +0 -0
  19. package/.test-data/cc471629-8006-4fc1-b8a1-399d2df2cc4e/agents.sqlite +0 -0
  20. package/.test-data/dbca3bef-397d-4bbc-bd4c-4f7b14103e04/spending.sqlite +0 -0
  21. package/.test-data/f1283dd1-6602-4de7-a050-16aac7abc288/agents.sqlite +0 -0
  22. package/.turbo/turbo-build.log +14 -0
  23. package/dist/index.d.ts +1675 -0
  24. package/dist/index.js +8006 -0
  25. package/dist/index.js.map +1 -0
  26. package/package.json +41 -0
  27. package/src/auth/device-flow.ts +108 -0
  28. package/src/auth/ed25519-provider.ts +43 -0
  29. package/src/auth/errors.ts +82 -0
  30. package/src/auth/evm-key.ts +55 -0
  31. package/src/auth/index.ts +8 -0
  32. package/src/auth/session-state.ts +25 -0
  33. package/src/auth/session-store.ts +510 -0
  34. package/src/auth/types.ts +81 -0
  35. package/src/auth/zklogin-provider.ts +902 -0
  36. package/src/blobstore/WALRUS_FINDINGS.md +284 -0
  37. package/src/blobstore/encrypted-store.ts +56 -0
  38. package/src/blobstore/fs-store.ts +91 -0
  39. package/src/blobstore/hybrid-store.ts +144 -0
  40. package/src/blobstore/index.ts +5 -0
  41. package/src/blobstore/interface.ts +33 -0
  42. package/src/blobstore/walrus-spike.ts +345 -0
  43. package/src/blobstore/walrus-store.ts +551 -0
  44. package/src/cache/agent-cache.ts +403 -0
  45. package/src/cache/index.ts +1 -0
  46. package/src/crypto/encryption.ts +152 -0
  47. package/src/crypto/index.ts +2 -0
  48. package/src/crypto/x25519.ts +41 -0
  49. package/src/dispute/client.ts +191 -0
  50. package/src/dispute/index.ts +1 -0
  51. package/src/events/index.ts +2 -0
  52. package/src/events/parser.ts +291 -0
  53. package/src/events/subscription.ts +131 -0
  54. package/src/evm/constants.ts +6 -0
  55. package/src/evm/index.ts +2 -0
  56. package/src/evm/wallet.ts +136 -0
  57. package/src/identity/did.ts +36 -0
  58. package/src/identity/index.ts +4 -0
  59. package/src/identity/keypair.ts +199 -0
  60. package/src/identity/signing.ts +28 -0
  61. package/src/index.ts +22 -0
  62. package/src/internal/parsing.ts +416 -0
  63. package/src/marketplace/client.ts +349 -0
  64. package/src/marketplace/index.ts +1 -0
  65. package/src/metering/hash-chain.ts +94 -0
  66. package/src/metering/index.ts +4 -0
  67. package/src/metering/meter.ts +80 -0
  68. package/src/metering/streaming.ts +196 -0
  69. package/src/metering/verification.ts +104 -0
  70. package/src/payment/index.ts +1 -0
  71. package/src/payment/rail-selector.ts +41 -0
  72. package/src/registry/client.ts +328 -0
  73. package/src/registry/index.ts +1 -0
  74. package/src/relay/consumer-client.ts +497 -0
  75. package/src/relay/index.ts +1 -0
  76. package/src/relay-registry/client.ts +295 -0
  77. package/src/relay-registry/discovery.ts +109 -0
  78. package/src/relay-registry/index.ts +2 -0
  79. package/src/reputation/anchor-client.ts +126 -0
  80. package/src/reputation/event-publisher.ts +67 -0
  81. package/src/reputation/index.ts +5 -0
  82. package/src/reputation/merkle.ts +79 -0
  83. package/src/reputation/score-calculator.ts +133 -0
  84. package/src/reputation/serialization.ts +37 -0
  85. package/src/reputation/store.ts +165 -0
  86. package/src/reputation/validation.ts +135 -0
  87. package/src/routing/circuit-breaker.ts +111 -0
  88. package/src/routing/fan-out.ts +266 -0
  89. package/src/routing/index.ts +4 -0
  90. package/src/routing/performance.ts +244 -0
  91. package/src/routing/selector.ts +225 -0
  92. package/src/spending/index.ts +1 -0
  93. package/src/spending/policy.ts +271 -0
  94. package/src/staking/client.ts +319 -0
  95. package/src/staking/index.ts +1 -0
  96. package/src/sui/client.ts +214 -0
  97. package/src/sui/index.ts +2 -0
  98. package/src/sui/tx-helpers.ts +1070 -0
  99. package/src/task/client.ts +215 -0
  100. package/src/task/index.ts +1 -0
  101. package/src/x402/client.ts +295 -0
  102. package/src/x402/index.ts +1 -0
  103. package/tests/auth/device-flow.test.ts +62 -0
  104. package/tests/auth/ed25519-provider.test.ts +24 -0
  105. package/tests/auth/evm-key.test.ts +31 -0
  106. package/tests/auth/session-store.test.ts +201 -0
  107. package/tests/auth/zklogin-provider.test.ts +366 -0
  108. package/tests/blobstore/encrypted-store.test.ts +78 -0
  109. package/tests/blobstore.test.ts +91 -0
  110. package/tests/cache.test.ts +124 -0
  111. package/tests/crypto/encryption.test.ts +70 -0
  112. package/tests/crypto/x25519.test.ts +47 -0
  113. package/tests/dispute/client.test.ts +238 -0
  114. package/tests/events.test.ts +202 -0
  115. package/tests/evm/wallet.test.ts +101 -0
  116. package/tests/hybrid-store.test.ts +121 -0
  117. package/tests/identity.test.ts +161 -0
  118. package/tests/marketplace.test.ts +308 -0
  119. package/tests/metering/hash-chain.test.ts +32 -0
  120. package/tests/metering/meter.test.ts +23 -0
  121. package/tests/metering/streaming.test.ts +52 -0
  122. package/tests/metering/verification.test.ts +27 -0
  123. package/tests/payment/rail-selector.test.ts +95 -0
  124. package/tests/registry.test.ts +183 -0
  125. package/tests/relay-consumer-client.test.ts +119 -0
  126. package/tests/relay-registry/client.test.ts +261 -0
  127. package/tests/reputation/event-publisher.test.ts +70 -0
  128. package/tests/reputation/merkle.test.ts +44 -0
  129. package/tests/reputation/score-calculator.test.ts +104 -0
  130. package/tests/reputation/store.test.ts +94 -0
  131. package/tests/routing/circuit-breaker.test.ts +45 -0
  132. package/tests/routing/fan-out.test.ts +123 -0
  133. package/tests/routing/performance.test.ts +49 -0
  134. package/tests/routing/selector.test.ts +114 -0
  135. package/tests/spending.test.ts +133 -0
  136. package/tests/staking/client.test.ts +286 -0
  137. package/tests/sui-client.test.ts +85 -0
  138. package/tests/task.test.ts +249 -0
  139. package/tests/tx-helpers.test.ts +70 -0
  140. package/tests/walrus-spike.test.ts +100 -0
  141. package/tests/walrus-store.test.ts +196 -0
  142. package/tests/x402/client.test.ts +116 -0
  143. package/tsconfig.json +9 -0
  144. package/tsup.config.ts +11 -0
  145. package/vitest.config.ts +8 -0
@@ -0,0 +1,902 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+
3
+ import { type PublicKey, Signer, type SignatureScheme } from '@mysten/sui/cryptography';
4
+ import type { SuiClient } from '@mysten/sui/client';
5
+ import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
6
+ import {
7
+ decodeJwt,
8
+ genAddressSeed,
9
+ generateNonce,
10
+ generateRandomness,
11
+ getExtendedEphemeralPublicKey,
12
+ getZkLoginSignature,
13
+ jwtToAddress,
14
+ toZkLoginPublicIdentifier,
15
+ type ZkLoginPublicIdentifier,
16
+ } from '@mysten/sui/zklogin';
17
+
18
+ import { SessionExpiredError, SessionRefreshError } from './errors.js';
19
+ import { ZkLoginSessionStore } from './session-store.js';
20
+ import { SessionState, type SessionStateChangeCallback } from './session-state.js';
21
+ import type {
22
+ AuthProvider,
23
+ OAuthConfig,
24
+ OAuthProvider,
25
+ OAuthTokenResponse,
26
+ StoredZkLoginSession,
27
+ ZkLoginProof,
28
+ } from './types.js';
29
+
30
+ const encoder = new TextEncoder();
31
+ const DEFAULT_SCOPES: Record<OAuthProvider, string[]> = {
32
+ google: ['openid', 'email'],
33
+ apple: ['name', 'email'],
34
+ };
35
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
36
+ const TOKEN_EXPIRY_WARNING_MS = 5 * 60 * 1000;
37
+ const DEFAULT_ENDPOINTS = {
38
+ google: {
39
+ authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
40
+ tokenEndpoint: 'https://oauth2.googleapis.com/token',
41
+ saltEndpoint: 'https://salt.api.mystenlabs.com/get_salt',
42
+ proverEndpoint: 'https://prover.mystenlabs.com/v1',
43
+ issuer: 'https://accounts.google.com',
44
+ },
45
+ apple: {
46
+ authorizationEndpoint: 'https://appleid.apple.com/auth/authorize',
47
+ tokenEndpoint: 'https://appleid.apple.com/auth/token',
48
+ saltEndpoint: 'https://salt.api.mystenlabs.com/get_salt',
49
+ proverEndpoint: 'https://prover.mystenlabs.com/v1',
50
+ issuer: 'https://appleid.apple.com',
51
+ },
52
+ } as const;
53
+
54
+ export interface ZkLoginPendingSession {
55
+ epoch: number;
56
+ maxEpoch: number;
57
+ randomness: string;
58
+ nonce: string;
59
+ ephemeralKeypair: Ed25519Keypair;
60
+ }
61
+
62
+ export interface ZkLoginAuthorizationRequest {
63
+ authorizationUrl: string;
64
+ pendingSession: ZkLoginPendingSession;
65
+ }
66
+
67
+ export interface ZkLoginProviderOptions {
68
+ client: Pick<SuiClient, 'getCurrentEpoch'>;
69
+ oauth: OAuthConfig;
70
+ sessionStore?: ZkLoginSessionStore;
71
+ fetchFn?: typeof fetch;
72
+ }
73
+
74
+ export interface RefreshSessionOptions {
75
+ force?: boolean;
76
+ invalidateOnFailure?: boolean;
77
+ throwOnFailure?: boolean;
78
+ now?: number;
79
+ }
80
+
81
+ export class ZkLoginProvider implements AuthProvider {
82
+ readonly mode = 'zklogin' as const;
83
+
84
+ private session: StoredZkLoginSession | null = null;
85
+ private sessionState = SessionState.EXPIRED;
86
+ private readonly signer = new ZkLoginSuiSigner(() => this.requireSession(), () => this.getPublicIdentifier());
87
+ private readonly fetchFn: typeof fetch;
88
+ private readonly sessionStateListeners = new Set<SessionStateChangeCallback>();
89
+ private oauth: OAuthConfig;
90
+
91
+ constructor(private readonly options: ZkLoginProviderOptions) {
92
+ this.fetchFn = options.fetchFn ?? fetch;
93
+ this.oauth = options.oauth;
94
+ this.options.sessionStore?.onSessionStateChange((event) => {
95
+ this.updateSessionState(event.currentState, event.session, event.reason, event.error);
96
+ });
97
+ }
98
+
99
+ getOAuthConfig(): OAuthConfig {
100
+ return { ...this.oauth };
101
+ }
102
+
103
+ setOAuthConfig(oauth: OAuthConfig): void {
104
+ this.oauth = oauth;
105
+ }
106
+
107
+ async restoreSession(): Promise<boolean> {
108
+ const currentEpoch = await this.getCurrentEpoch();
109
+ await this.options.sessionStore?.deleteExpired(currentEpoch);
110
+
111
+ try {
112
+ const refreshed = await this.refreshSessionIfNeeded(currentEpoch);
113
+ if (refreshed) {
114
+ this.session = refreshed;
115
+ this.updateSessionState(SessionState.VALID, refreshed, 'session_restored');
116
+ return true;
117
+ }
118
+ } catch (error) {
119
+ this.session = null;
120
+ if (error instanceof SessionExpiredError) {
121
+ this.updateSessionState(error.sessionState, null, 'session_restore_failed', error);
122
+ }
123
+ throw error;
124
+ }
125
+
126
+ const loadedSession = (await this.options.sessionStore?.loadLatestValid(currentEpoch)) ?? null;
127
+ if (loadedSession && isSessionTokenExpired(loadedSession, Date.now())) {
128
+ await this.clearSession(loadedSession);
129
+ return false;
130
+ }
131
+
132
+ this.session = loadedSession;
133
+ this.updateSessionState(loadedSession ? SessionState.VALID : SessionState.EXPIRED, loadedSession, 'session_restored');
134
+ return this.session !== null;
135
+ }
136
+
137
+ async createAuthorizationRequest(params: {
138
+ redirectUri: string;
139
+ state: string;
140
+ codeChallenge: string;
141
+ scopes?: string[];
142
+ }): Promise<ZkLoginAuthorizationRequest> {
143
+ const pendingSession = await this.createPendingSession();
144
+ const authorizationUrl = this.buildAuthorizationUrl({
145
+ redirectUri: params.redirectUri,
146
+ state: params.state,
147
+ codeChallenge: params.codeChallenge,
148
+ nonce: pendingSession.nonce,
149
+ scopes: params.scopes,
150
+ });
151
+
152
+ return {
153
+ authorizationUrl,
154
+ pendingSession,
155
+ };
156
+ }
157
+
158
+ async exchangeAuthorizationCode(
159
+ code: string,
160
+ codeVerifier: string,
161
+ redirectUri: string,
162
+ ): Promise<OAuthTokenResponse> {
163
+ const response = await this.fetchJson(
164
+ this.getTokenEndpoint(),
165
+ {
166
+ method: 'POST',
167
+ headers: {
168
+ 'content-type': 'application/x-www-form-urlencoded',
169
+ },
170
+ body: new URLSearchParams({
171
+ code,
172
+ client_id: this.oauth.clientId,
173
+ code_verifier: codeVerifier,
174
+ grant_type: 'authorization_code',
175
+ redirect_uri: redirectUri,
176
+ }).toString(),
177
+ },
178
+ 'token',
179
+ );
180
+
181
+ return normalizeTokenResponse(response);
182
+ }
183
+
184
+ async refreshSessionIfNeeded(
185
+ currentEpoch?: number,
186
+ options: RefreshSessionOptions = {},
187
+ ): Promise<StoredZkLoginSession | null> {
188
+ const epoch = currentEpoch ?? (await this.getCurrentEpoch());
189
+ const current = await this.loadCurrentSession(epoch);
190
+ if (!current) {
191
+ this.session = null;
192
+ this.updateSessionState(this.options.sessionStore?.getSessionState() ?? SessionState.EXPIRED, null, 'session_missing');
193
+ return null;
194
+ }
195
+
196
+ const now = options.now ?? Date.now();
197
+ const tokenExpired = isSessionTokenExpired(current, now);
198
+ const tokenExpiring = isSessionTokenExpiring(current, now);
199
+ const requiresRefresh = options.force || tokenExpired || tokenExpiring || this.options.sessionStore?.isNearExpiry(current, epoch) === true;
200
+
201
+ if (!requiresRefresh) {
202
+ this.session = current;
203
+ this.updateSessionState(SessionState.VALID, current, 'session_available');
204
+ return current;
205
+ }
206
+
207
+ if (!current.refreshToken) {
208
+ return await this.handleRefreshFailure(current, options, undefined, tokenExpired);
209
+ }
210
+
211
+ if (!this.options.sessionStore) {
212
+ try {
213
+ const pendingSession = await this.createPendingSession();
214
+ const tokens = await this.refreshTokens(current.refreshToken, pendingSession.nonce);
215
+ return await this.authenticateWithJwt(tokens.jwt, {
216
+ pendingSession,
217
+ refreshToken: tokens.refreshToken ?? current.refreshToken,
218
+ validateNonce: false,
219
+ });
220
+ } catch (error) {
221
+ return await this.handleRefreshFailure(current, options, error, tokenExpired);
222
+ }
223
+ }
224
+
225
+ try {
226
+ const refreshed = await this.options.sessionStore.refreshIfNeeded(
227
+ epoch,
228
+ async (storedSession) => {
229
+ const pendingSession = await this.createPendingSession();
230
+ const tokens = await this.refreshTokens(storedSession.refreshToken ?? current.refreshToken ?? '', pendingSession.nonce);
231
+ return await this.authenticateWithJwt(tokens.jwt, {
232
+ pendingSession,
233
+ refreshToken: tokens.refreshToken ?? storedSession.refreshToken,
234
+ validateNonce: false,
235
+ });
236
+ },
237
+ { force: true },
238
+ );
239
+ this.session = refreshed;
240
+ this.updateSessionState(refreshed ? SessionState.VALID : SessionState.EXPIRED, refreshed, 'session_refreshed');
241
+ return refreshed;
242
+ } catch (error) {
243
+ if (error instanceof SessionExpiredError) {
244
+ this.session = null;
245
+ this.updateSessionState(error.sessionState, null, 'session_refresh_failed', error);
246
+ throw error;
247
+ }
248
+
249
+ if (error instanceof SessionRefreshError && (options.throwOnFailure || tokenExpired || options.force)) {
250
+ this.session = null;
251
+ this.updateSessionState(SessionState.NEEDS_REAUTH, null, 'session_refresh_failed', error);
252
+ throw new SessionExpiredError('zkLogin session refresh failed. Re-authentication is required.', {
253
+ attempts: error.attempts,
254
+ maxAttempts: error.maxAttempts,
255
+ retryDelaysMs: error.retryDelaysMs,
256
+ consecutiveFailures: error.consecutiveFailures,
257
+ sessionState: SessionState.NEEDS_REAUTH,
258
+ session: current,
259
+ cause: error,
260
+ });
261
+ }
262
+
263
+ throw error;
264
+ }
265
+ }
266
+
267
+ async authenticateWithJwt(
268
+ jwt: string,
269
+ params: {
270
+ pendingSession: ZkLoginPendingSession;
271
+ refreshToken?: string;
272
+ validateNonce?: boolean;
273
+ },
274
+ ): Promise<StoredZkLoginSession> {
275
+ const decoded = validateJwtClaims(decodeJwt(jwt), {
276
+ expectedIssuer: this.getIssuer(),
277
+ expectedClientId: this.oauth.clientId,
278
+ expectedNonce: params.validateNonce === false ? undefined : params.pendingSession.nonce,
279
+ });
280
+ const salt = await this.fetchSalt(jwt);
281
+ const proof = await this.fetchProof(jwt, salt, params.pendingSession);
282
+ const addressSeed = genAddressSeed(salt, 'sub', decoded.sub, decoded.aud).toString();
283
+ const timestamp = Date.now();
284
+
285
+ const session: StoredZkLoginSession = {
286
+ provider: this.oauth.provider,
287
+ jwt,
288
+ salt,
289
+ epoch: params.pendingSession.epoch,
290
+ ephemeralKeypair: params.pendingSession.ephemeralKeypair,
291
+ proof: {
292
+ ...proof,
293
+ addressSeed,
294
+ },
295
+ maxEpoch: params.pendingSession.maxEpoch,
296
+ address: jwtToAddress(jwt, salt),
297
+ sub: decoded.sub,
298
+ iss: decoded.iss,
299
+ aud: decoded.aud,
300
+ randomness: params.pendingSession.randomness,
301
+ refreshToken: params.refreshToken,
302
+ createdAt: timestamp,
303
+ updatedAt: timestamp,
304
+ sessionState: SessionState.VALID,
305
+ refreshFailureCount: 0,
306
+ };
307
+
308
+ this.session = session;
309
+ await this.options.sessionStore?.save(session);
310
+ this.updateSessionState(SessionState.VALID, session, 'session_authenticated');
311
+ return session;
312
+ }
313
+
314
+ async getAddress(): Promise<string> {
315
+ return this.requireSession().address;
316
+ }
317
+
318
+ getDID(): string {
319
+ const session = this.requireSession();
320
+ return `did:mesh:zklogin:${session.address}`;
321
+ }
322
+
323
+ async signTransaction(tx: Uint8Array): Promise<Uint8Array> {
324
+ const { signature } = await this.signer.signTransaction(tx);
325
+ return encoder.encode(signature);
326
+ }
327
+
328
+ async signPersonalMessage(message: Uint8Array): Promise<{ signature: Uint8Array }> {
329
+ const { signature } = await this.signer.signPersonalMessage(message);
330
+ return { signature: encoder.encode(signature) };
331
+ }
332
+
333
+ isAuthenticated(): boolean {
334
+ const session = this.session;
335
+ if (!session) {
336
+ return false;
337
+ }
338
+
339
+ if (this.sessionState === SessionState.NEEDS_REAUTH || this.sessionState === SessionState.EXPIRED) {
340
+ return false;
341
+ }
342
+
343
+ if (isSessionTokenExpired(session, Date.now())) {
344
+ this.dropSession(session);
345
+ return false;
346
+ }
347
+
348
+ return true;
349
+ }
350
+
351
+ getPublicKey(): Uint8Array {
352
+ return this.getPublicIdentifier().toRawBytes();
353
+ }
354
+
355
+ toSuiSigner(): Signer {
356
+ return this.signer;
357
+ }
358
+
359
+ getSession(): StoredZkLoginSession | null {
360
+ return this.session;
361
+ }
362
+
363
+ getSessionState(): SessionState {
364
+ return this.options.sessionStore?.getSessionState() ?? this.sessionState;
365
+ }
366
+
367
+ onSessionStateChange(callback: SessionStateChangeCallback): () => void {
368
+ this.sessionStateListeners.add(callback);
369
+ return () => {
370
+ this.sessionStateListeners.delete(callback);
371
+ };
372
+ }
373
+
374
+ async isSessionValid(currentEpoch?: number): Promise<boolean> {
375
+ const session = this.session;
376
+ if (!session) {
377
+ return false;
378
+ }
379
+
380
+ if (this.getSessionState() === SessionState.NEEDS_REAUTH || this.getSessionState() === SessionState.EXPIRED) {
381
+ return false;
382
+ }
383
+
384
+ const epoch = currentEpoch ?? (await this.getCurrentEpoch());
385
+ if ((this.options.sessionStore?.isExpired(session, epoch) ?? epoch >= session.maxEpoch) || isSessionTokenExpiring(session, Date.now())) {
386
+ return false;
387
+ }
388
+
389
+ return !isSessionTokenExpired(session, Date.now());
390
+ }
391
+
392
+ getSessionExpiryMs(session = this.session): number | null {
393
+ return getSessionExpiryMs(session);
394
+ }
395
+
396
+ async clearSession(session = this.session): Promise<void> {
397
+ if (!session) {
398
+ this.session = null;
399
+ this.updateSessionState(SessionState.EXPIRED, null, 'session_cleared');
400
+ return;
401
+ }
402
+
403
+ this.session = null;
404
+ await this.options.sessionStore?.delete(session);
405
+ this.updateSessionState(SessionState.EXPIRED, null, 'session_cleared');
406
+ }
407
+
408
+ private async refreshTokens(refreshToken: string, nonce: string): Promise<OAuthTokenResponse> {
409
+ const response = await this.fetchJson(
410
+ this.getTokenEndpoint(),
411
+ {
412
+ method: 'POST',
413
+ headers: {
414
+ 'content-type': 'application/x-www-form-urlencoded',
415
+ },
416
+ body: new URLSearchParams({
417
+ client_id: this.oauth.clientId,
418
+ grant_type: 'refresh_token',
419
+ nonce,
420
+ refresh_token: refreshToken,
421
+ }).toString(),
422
+ },
423
+ 'token',
424
+ );
425
+
426
+ return normalizeTokenResponse(response);
427
+ }
428
+
429
+ private async createPendingSession(): Promise<ZkLoginPendingSession> {
430
+ const epoch = await this.getCurrentEpoch();
431
+ const maxEpoch = epoch + 2;
432
+ const ephemeralKeypair = Ed25519Keypair.generate();
433
+ const randomness = generateRandomness();
434
+ const nonce = generateNonce(ephemeralKeypair.getPublicKey(), maxEpoch, randomness);
435
+
436
+ return {
437
+ epoch,
438
+ maxEpoch,
439
+ randomness,
440
+ nonce,
441
+ ephemeralKeypair,
442
+ };
443
+ }
444
+
445
+ private buildAuthorizationUrl(params: {
446
+ redirectUri: string;
447
+ state: string;
448
+ codeChallenge: string;
449
+ nonce: string;
450
+ scopes?: string[];
451
+ }): string {
452
+ const url = new URL(this.getAuthorizationEndpoint());
453
+ url.searchParams.set('client_id', this.oauth.clientId);
454
+ url.searchParams.set('code_challenge', params.codeChallenge);
455
+ url.searchParams.set('code_challenge_method', 'S256');
456
+ url.searchParams.set('nonce', params.nonce);
457
+ url.searchParams.set('redirect_uri', params.redirectUri);
458
+ url.searchParams.set('scope', (params.scopes ?? this.oauth.scopes ?? DEFAULT_SCOPES[this.oauth.provider]).join(' '));
459
+ url.searchParams.set('state', params.state);
460
+
461
+ if (this.oauth.provider === 'apple') {
462
+ url.searchParams.set('response_mode', 'form_post');
463
+ url.searchParams.set('response_type', 'code id_token');
464
+ return url.toString();
465
+ }
466
+
467
+ url.searchParams.set('include_granted_scopes', 'true');
468
+ url.searchParams.set('prompt', 'consent');
469
+ url.searchParams.set('response_type', 'code');
470
+ url.searchParams.set('access_type', 'offline');
471
+ return url.toString();
472
+ }
473
+
474
+ private async fetchSalt(jwt: string): Promise<string> {
475
+ const response = await this.fetchJson(
476
+ this.getSaltEndpoint(),
477
+ {
478
+ method: 'POST',
479
+ headers: {
480
+ 'content-type': 'application/json',
481
+ },
482
+ body: JSON.stringify({ token: jwt }),
483
+ },
484
+ 'salt',
485
+ );
486
+
487
+ const nestedData = isRecord(response.data) ? response.data : undefined;
488
+ const salt = readStringValue(response.salt) ?? readStringValue(nestedData?.salt);
489
+ if (!salt) {
490
+ throw new Error('zkLogin salt response did not contain a salt value.');
491
+ }
492
+
493
+ return salt;
494
+ }
495
+
496
+ private async fetchProof(
497
+ jwt: string,
498
+ salt: string,
499
+ pendingSession: ZkLoginPendingSession,
500
+ ): Promise<Omit<ZkLoginProof, 'addressSeed'>> {
501
+ const response = await this.fetchJson(
502
+ this.getProverEndpoint(),
503
+ {
504
+ method: 'POST',
505
+ headers: {
506
+ 'content-type': 'application/json',
507
+ },
508
+ body: JSON.stringify({
509
+ jwt,
510
+ extendedEphemeralPublicKey: getExtendedEphemeralPublicKey(pendingSession.ephemeralKeypair.getPublicKey()),
511
+ maxEpoch: pendingSession.maxEpoch,
512
+ jwtRandomness: pendingSession.randomness,
513
+ salt,
514
+ keyClaimName: 'sub',
515
+ }),
516
+ },
517
+ 'prover',
518
+ );
519
+
520
+ const payload = isRecord(response.data) ? response.data : response;
521
+ if (!isZkLoginProofPayload(payload)) {
522
+ throw new Error('zkLogin prover response was missing proof fields.');
523
+ }
524
+
525
+ return {
526
+ proofPoints: payload.proofPoints,
527
+ issBase64Details: payload.issBase64Details,
528
+ headerBase64: payload.headerBase64,
529
+ };
530
+ }
531
+
532
+ private async fetchJson(url: string, init: RequestInit, operation: 'token' | 'salt' | 'prover'): Promise<Record<string, unknown>> {
533
+ const controller = new AbortController();
534
+ const timeout = setTimeout(() => {
535
+ controller.abort();
536
+ }, DEFAULT_REQUEST_TIMEOUT_MS);
537
+
538
+ try {
539
+ const response = await this.fetchFn(url, {
540
+ ...init,
541
+ signal: controller.signal,
542
+ });
543
+ const text = await response.text();
544
+ const body = text ? parseJsonObject(text, `OAuth ${operation} response`) : {};
545
+
546
+ if (!response.ok) {
547
+ const detail = readStringValue(body.error_description) ?? readStringValue(body.error) ?? response.statusText;
548
+ throw new Error(`OAuth ${operation} request failed (${response.status}): ${detail}`);
549
+ }
550
+
551
+ return body;
552
+ } catch (error) {
553
+ if (controller.signal.aborted || isAbortError(error)) {
554
+ throw new Error(`OAuth ${operation} request timed out after ${DEFAULT_REQUEST_TIMEOUT_MS}ms.`);
555
+ }
556
+
557
+ if (error instanceof Error && error.message.startsWith('OAuth ')) {
558
+ throw error;
559
+ }
560
+
561
+ if (error instanceof Error) {
562
+ throw new Error(`OAuth ${operation} request failed: ${error.message}`, { cause: error });
563
+ }
564
+
565
+ throw new Error(`OAuth ${operation} request failed.`);
566
+ } finally {
567
+ clearTimeout(timeout);
568
+ }
569
+ }
570
+
571
+ private getPublicIdentifier(): ZkLoginPublicIdentifier {
572
+ const session = this.requireSession();
573
+ return toZkLoginPublicIdentifier(BigInt(session.proof.addressSeed), session.iss);
574
+ }
575
+
576
+ private requireSession(): StoredZkLoginSession {
577
+ const session = this.session;
578
+ if (!session) {
579
+ throw new SessionExpiredError('Authentication expired. Please re-authenticate via the daemon portal.', {
580
+ consecutiveFailures: this.options.sessionStore?.getRefreshFailureCount(),
581
+ sessionState: this.getSessionState(),
582
+ });
583
+ }
584
+
585
+ if (isSessionTokenExpired(session, Date.now())) {
586
+ this.dropSession(session);
587
+ throw new SessionExpiredError('Authentication expired. Please re-authenticate via the daemon portal.', {
588
+ consecutiveFailures: this.options.sessionStore?.getRefreshFailureCount(),
589
+ session,
590
+ sessionState: SessionState.EXPIRED,
591
+ });
592
+ }
593
+
594
+ return session;
595
+ }
596
+
597
+ private async loadCurrentSession(epoch: number): Promise<StoredZkLoginSession | null> {
598
+ const inMemory = this.session;
599
+ if (inMemory && (!this.options.sessionStore || !this.options.sessionStore.isExpired(inMemory, epoch))) {
600
+ return inMemory;
601
+ }
602
+
603
+ if (!this.options.sessionStore) {
604
+ return inMemory ?? null;
605
+ }
606
+
607
+ const session = await this.options.sessionStore.loadLatestValid(epoch);
608
+ this.session = session;
609
+ if (!session && this.options.sessionStore.getSessionState() === SessionState.NEEDS_REAUTH) {
610
+ throw new SessionExpiredError('Authentication expired. Please re-authenticate via the daemon portal.', {
611
+ consecutiveFailures: this.options.sessionStore.getRefreshFailureCount(),
612
+ sessionState: SessionState.NEEDS_REAUTH,
613
+ });
614
+ }
615
+
616
+ return session;
617
+ }
618
+
619
+ private async handleRefreshFailure(
620
+ session: StoredZkLoginSession,
621
+ options: RefreshSessionOptions,
622
+ error?: unknown,
623
+ tokenExpired = false,
624
+ ): Promise<StoredZkLoginSession | null> {
625
+ if (tokenExpired || options.invalidateOnFailure || options.throwOnFailure || options.force) {
626
+ const nextState = tokenExpired ? SessionState.EXPIRED : SessionState.NEEDS_REAUTH;
627
+ this.session = null;
628
+ this.updateSessionState(nextState, null, 'session_refresh_failed', error);
629
+ throw new SessionExpiredError('Authentication expired. Please re-authenticate via the daemon portal.', {
630
+ consecutiveFailures: this.options.sessionStore?.getRefreshFailureCount(),
631
+ session,
632
+ sessionState: nextState,
633
+ cause: error,
634
+ });
635
+ }
636
+
637
+ this.session = session;
638
+ this.updateSessionState(SessionState.VALID, session, 'session_available');
639
+ return session;
640
+ }
641
+
642
+ private dropSession(session: StoredZkLoginSession): void {
643
+ this.session = null;
644
+ this.updateSessionState(SessionState.EXPIRED, null, 'session_expired');
645
+ void this.options.sessionStore?.delete(session).catch(() => undefined);
646
+ }
647
+
648
+ private updateSessionState(
649
+ nextState: SessionState,
650
+ session: StoredZkLoginSession | null,
651
+ reason?: string,
652
+ error?: unknown,
653
+ ): void {
654
+ const previousState = this.sessionState;
655
+ this.sessionState = nextState;
656
+ if (previousState === nextState) {
657
+ return;
658
+ }
659
+
660
+ for (const listener of this.sessionStateListeners) {
661
+ listener({
662
+ previousState,
663
+ currentState: nextState,
664
+ session,
665
+ reason,
666
+ refreshFailureCount: session?.refreshFailureCount ?? this.options.sessionStore?.getRefreshFailureCount() ?? 0,
667
+ error,
668
+ });
669
+ }
670
+ }
671
+
672
+ private async getCurrentEpoch(): Promise<number> {
673
+ const currentEpoch = await this.options.client.getCurrentEpoch();
674
+ return Number.parseInt(currentEpoch.epoch, 10);
675
+ }
676
+
677
+ private getAuthorizationEndpoint(): string {
678
+ return this.oauth.authorizationEndpoint ?? DEFAULT_ENDPOINTS[this.oauth.provider].authorizationEndpoint;
679
+ }
680
+
681
+ private getTokenEndpoint(): string {
682
+ return this.oauth.tokenEndpoint ?? DEFAULT_ENDPOINTS[this.oauth.provider].tokenEndpoint;
683
+ }
684
+
685
+ private getSaltEndpoint(): string {
686
+ return this.oauth.saltEndpoint ?? DEFAULT_ENDPOINTS[this.oauth.provider].saltEndpoint;
687
+ }
688
+
689
+ private getProverEndpoint(): string {
690
+ return this.oauth.proverEndpoint ?? DEFAULT_ENDPOINTS[this.oauth.provider].proverEndpoint;
691
+ }
692
+
693
+ private getIssuer(): string {
694
+ return this.oauth.issuer ?? DEFAULT_ENDPOINTS[this.oauth.provider].issuer;
695
+ }
696
+ }
697
+
698
+ class ZkLoginSuiSigner extends Signer {
699
+ constructor(
700
+ private readonly getSession: () => StoredZkLoginSession,
701
+ private readonly getPublicIdentifier: () => ZkLoginPublicIdentifier,
702
+ ) {
703
+ super();
704
+ }
705
+
706
+ async sign(bytes: Uint8Array): Promise<Uint8Array<ArrayBuffer>> {
707
+ return this.getSession().ephemeralKeypair.sign(bytes);
708
+ }
709
+
710
+ override async signTransaction(bytes: Uint8Array): Promise<{ bytes: string; signature: string }> {
711
+ const session = this.getSession();
712
+ const { bytes: signedBytes, signature: userSignature } = await session.ephemeralKeypair.signTransaction(bytes);
713
+ return {
714
+ bytes: signedBytes,
715
+ signature: getZkLoginSignature({
716
+ inputs: {
717
+ ...session.proof,
718
+ },
719
+ maxEpoch: session.maxEpoch,
720
+ userSignature,
721
+ }),
722
+ };
723
+ }
724
+
725
+ override async signPersonalMessage(bytes: Uint8Array): Promise<{ bytes: string; signature: string }> {
726
+ const session = this.getSession();
727
+ const { bytes: signedBytes, signature: userSignature } = await session.ephemeralKeypair.signPersonalMessage(bytes);
728
+ return {
729
+ bytes: signedBytes,
730
+ signature: getZkLoginSignature({
731
+ inputs: {
732
+ ...session.proof,
733
+ },
734
+ maxEpoch: session.maxEpoch,
735
+ userSignature,
736
+ }),
737
+ };
738
+ }
739
+
740
+ getKeyScheme(): SignatureScheme {
741
+ return 'ZkLogin';
742
+ }
743
+
744
+ getPublicKey(): PublicKey {
745
+ return this.getPublicIdentifier();
746
+ }
747
+ }
748
+
749
+ export function createPkcePair(): { verifier: string; challenge: string } {
750
+ const verifier = base64UrlEncode(randomBytes(64));
751
+ return {
752
+ verifier,
753
+ challenge: base64UrlEncode(createHash('sha256').update(verifier).digest()),
754
+ };
755
+ }
756
+
757
+ function normalizeTokenResponse(value: Record<string, unknown>): OAuthTokenResponse {
758
+ const jwt = readStringValue(value.id_token) ?? readStringValue(value.jwt);
759
+ if (!jwt) {
760
+ throw new Error('OAuth token response did not contain an id_token.');
761
+ }
762
+
763
+ return {
764
+ jwt,
765
+ refreshToken: readStringValue(value.refresh_token),
766
+ accessToken: readStringValue(value.access_token),
767
+ expiresIn:
768
+ typeof value.expires_in === 'number'
769
+ ? value.expires_in
770
+ : typeof value.expires_in === 'string' && /^\d+$/.test(value.expires_in)
771
+ ? Number(value.expires_in)
772
+ : undefined,
773
+ tokenType: readStringValue(value.token_type),
774
+ scope: readStringValue(value.scope),
775
+ };
776
+ }
777
+
778
+ function getSessionExpiryMs(session: Pick<StoredZkLoginSession, 'jwt'> | null | undefined): number | null {
779
+ if (!session) {
780
+ return null;
781
+ }
782
+
783
+ const exp = decodeJwt(session.jwt).exp;
784
+ const expSeconds = typeof exp === 'number' ? exp : typeof exp === 'string' && /^\d+$/.test(exp) ? Number(exp) : null;
785
+ return expSeconds === null ? null : expSeconds * 1_000;
786
+ }
787
+
788
+ function isSessionTokenExpired(session: Pick<StoredZkLoginSession, 'jwt'>, now: number): boolean {
789
+ const expiresAt = getSessionExpiryMs(session);
790
+ return expiresAt !== null && expiresAt <= now;
791
+ }
792
+
793
+ function isSessionTokenExpiring(
794
+ session: Pick<StoredZkLoginSession, 'jwt'>,
795
+ now: number,
796
+ warningMs = TOKEN_EXPIRY_WARNING_MS,
797
+ ): boolean {
798
+ const expiresAt = getSessionExpiryMs(session);
799
+ return expiresAt !== null && expiresAt - now <= warningMs;
800
+ }
801
+
802
+ function base64UrlEncode(value: Uint8Array): string {
803
+ return Buffer.from(value)
804
+ .toString('base64')
805
+ .replace(/\+/g, '-')
806
+ .replace(/\//g, '_')
807
+ .replace(/=+$/g, '');
808
+ }
809
+
810
+ function readStringValue(value: unknown): string | undefined {
811
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
812
+ }
813
+
814
+ function readRequiredStringValue(value: unknown, field: string): string {
815
+ const parsed = readStringValue(value);
816
+ if (!parsed) {
817
+ throw new Error(`OAuth token did not contain a valid ${field} claim.`);
818
+ }
819
+
820
+ return parsed;
821
+ }
822
+
823
+ function validateJwtClaims(
824
+ claims: Record<string, unknown>,
825
+ params: { expectedIssuer: string; expectedClientId: string; expectedNonce?: string },
826
+ ): { iss: string; sub: string; aud: string } {
827
+ const iss = readRequiredStringValue(claims.iss, 'iss');
828
+ if (iss !== params.expectedIssuer) {
829
+ throw new Error(`OAuth token issuer mismatch. Expected ${params.expectedIssuer}.`);
830
+ }
831
+
832
+ const sub = readRequiredStringValue(claims.sub, 'sub');
833
+ const audiences = normalizeAudiences(claims.aud);
834
+ if (!audiences.includes(params.expectedClientId)) {
835
+ throw new Error('OAuth token audience mismatch.');
836
+ }
837
+
838
+ if (params.expectedNonce !== undefined) {
839
+ const nonce = readRequiredStringValue(claims.nonce, 'nonce');
840
+ if (nonce !== params.expectedNonce) {
841
+ throw new Error('OAuth token nonce mismatch.');
842
+ }
843
+ }
844
+
845
+ return {
846
+ iss,
847
+ sub,
848
+ aud: typeof claims.aud === 'string' ? claims.aud : params.expectedClientId,
849
+ };
850
+ }
851
+
852
+ function normalizeAudiences(value: unknown): string[] {
853
+ if (typeof value === 'string' && value.length > 0) {
854
+ return [value];
855
+ }
856
+
857
+ if (Array.isArray(value)) {
858
+ return value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
859
+ }
860
+
861
+ throw new Error('OAuth token did not contain a valid aud claim.');
862
+ }
863
+
864
+ function parseJsonObject(text: string, context: string): Record<string, unknown> {
865
+ let value: unknown;
866
+ try {
867
+ value = JSON.parse(text);
868
+ } catch (error) {
869
+ throw new Error(`${context} was not valid JSON.`, { cause: error as Error });
870
+ }
871
+
872
+ if (!isRecord(value)) {
873
+ throw new Error(`${context} was not a JSON object.`);
874
+ }
875
+
876
+ return value;
877
+ }
878
+
879
+ function isAbortError(error: unknown): boolean {
880
+ return error instanceof Error && error.name === 'AbortError';
881
+ }
882
+
883
+ function isRecord(value: unknown): value is Record<string, unknown> {
884
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
885
+ }
886
+
887
+ function isZkLoginProofPayload(value: Record<string, unknown>): value is {
888
+ proofPoints: ZkLoginProof['proofPoints'];
889
+ issBase64Details: ZkLoginProof['issBase64Details'];
890
+ headerBase64: string;
891
+ } {
892
+ return (
893
+ isRecord(value.proofPoints) &&
894
+ isRecord(value.issBase64Details) &&
895
+ typeof value.headerBase64 === 'string' &&
896
+ Array.isArray((value.proofPoints as Record<string, unknown>).a) &&
897
+ Array.isArray((value.proofPoints as Record<string, unknown>).b) &&
898
+ Array.isArray((value.proofPoints as Record<string, unknown>).c) &&
899
+ typeof (value.issBase64Details as Record<string, unknown>).value === 'string' &&
900
+ typeof (value.issBase64Details as Record<string, unknown>).indexMod4 === 'number'
901
+ );
902
+ }