@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.
- package/.test-data/05a845c4-1682-41b9-97e5-65a5263156c0/spending.sqlite +0 -0
- package/.test-data/10e18ba5-98f0-42e0-8899-06a09459ae85/agents.sqlite +0 -0
- package/.test-data/20464456-23cb-4ff7-8df5-3c129fb95a90/agents.sqlite +0 -0
- package/.test-data/2e2ec66c-e8b4-43eb-945f-cca84ed0a0f6/agents.sqlite +0 -0
- package/.test-data/2f5ef9b7-01da-4ba0-a6fa-af8063a2567c/agents.sqlite +0 -0
- package/.test-data/3ac13cd5-84c9-4e71-8b89-6d3ef4dd819c/agents.sqlite +0 -0
- package/.test-data/40b7dd4a-fae0-4a5d-a6a0-64c9048af35e/agents.sqlite +0 -0
- package/.test-data/59511a04-42f8-4286-bb1e-733795e08749/agents.sqlite +0 -0
- package/.test-data/6778e8c5-6eb5-416d-8aca-9d559cdf83e4/agents.sqlite +0 -0
- package/.test-data/7648dc86-df90-4460-8f9c-55cdb481324b/agents.sqlite +0 -0
- package/.test-data/77162d98-6b22-41b1-a50f-536fab739f8a/agents.sqlite +0 -0
- package/.test-data/798dbdab-cbe7-4edd-8a5b-ae99c285fe17/agents.sqlite +0 -0
- package/.test-data/8033d6ac-8b85-454d-b708-00ac695b22f8/agents.sqlite +0 -0
- package/.test-data/9155a4f4-cda3-487c-9eed-921c82d7550f/agents.sqlite +0 -0
- package/.test-data/9bfeee53-c231-46c3-8c93-2180933f5d50/agents.sqlite +0 -0
- package/.test-data/a4c64287-79f6-46e9-847d-2803c63a74fd/agents.sqlite +0 -0
- package/.test-data/b8f58952-1ed8-46ff-abd7-21fb86e9457f/agents.sqlite +0 -0
- package/.test-data/c3060504-3187-41ed-8532-82332be48b0b/spending.sqlite +0 -0
- package/.test-data/cc471629-8006-4fc1-b8a1-399d2df2cc4e/agents.sqlite +0 -0
- package/.test-data/dbca3bef-397d-4bbc-bd4c-4f7b14103e04/spending.sqlite +0 -0
- package/.test-data/f1283dd1-6602-4de7-a050-16aac7abc288/agents.sqlite +0 -0
- package/.turbo/turbo-build.log +14 -0
- package/dist/index.d.ts +1675 -0
- package/dist/index.js +8006 -0
- package/dist/index.js.map +1 -0
- package/package.json +41 -0
- package/src/auth/device-flow.ts +108 -0
- package/src/auth/ed25519-provider.ts +43 -0
- package/src/auth/errors.ts +82 -0
- package/src/auth/evm-key.ts +55 -0
- package/src/auth/index.ts +8 -0
- package/src/auth/session-state.ts +25 -0
- package/src/auth/session-store.ts +510 -0
- package/src/auth/types.ts +81 -0
- package/src/auth/zklogin-provider.ts +902 -0
- package/src/blobstore/WALRUS_FINDINGS.md +284 -0
- package/src/blobstore/encrypted-store.ts +56 -0
- package/src/blobstore/fs-store.ts +91 -0
- package/src/blobstore/hybrid-store.ts +144 -0
- package/src/blobstore/index.ts +5 -0
- package/src/blobstore/interface.ts +33 -0
- package/src/blobstore/walrus-spike.ts +345 -0
- package/src/blobstore/walrus-store.ts +551 -0
- package/src/cache/agent-cache.ts +403 -0
- package/src/cache/index.ts +1 -0
- package/src/crypto/encryption.ts +152 -0
- package/src/crypto/index.ts +2 -0
- package/src/crypto/x25519.ts +41 -0
- package/src/dispute/client.ts +191 -0
- package/src/dispute/index.ts +1 -0
- package/src/events/index.ts +2 -0
- package/src/events/parser.ts +291 -0
- package/src/events/subscription.ts +131 -0
- package/src/evm/constants.ts +6 -0
- package/src/evm/index.ts +2 -0
- package/src/evm/wallet.ts +136 -0
- package/src/identity/did.ts +36 -0
- package/src/identity/index.ts +4 -0
- package/src/identity/keypair.ts +199 -0
- package/src/identity/signing.ts +28 -0
- package/src/index.ts +22 -0
- package/src/internal/parsing.ts +416 -0
- package/src/marketplace/client.ts +349 -0
- package/src/marketplace/index.ts +1 -0
- package/src/metering/hash-chain.ts +94 -0
- package/src/metering/index.ts +4 -0
- package/src/metering/meter.ts +80 -0
- package/src/metering/streaming.ts +196 -0
- package/src/metering/verification.ts +104 -0
- package/src/payment/index.ts +1 -0
- package/src/payment/rail-selector.ts +41 -0
- package/src/registry/client.ts +328 -0
- package/src/registry/index.ts +1 -0
- package/src/relay/consumer-client.ts +497 -0
- package/src/relay/index.ts +1 -0
- package/src/relay-registry/client.ts +295 -0
- package/src/relay-registry/discovery.ts +109 -0
- package/src/relay-registry/index.ts +2 -0
- package/src/reputation/anchor-client.ts +126 -0
- package/src/reputation/event-publisher.ts +67 -0
- package/src/reputation/index.ts +5 -0
- package/src/reputation/merkle.ts +79 -0
- package/src/reputation/score-calculator.ts +133 -0
- package/src/reputation/serialization.ts +37 -0
- package/src/reputation/store.ts +165 -0
- package/src/reputation/validation.ts +135 -0
- package/src/routing/circuit-breaker.ts +111 -0
- package/src/routing/fan-out.ts +266 -0
- package/src/routing/index.ts +4 -0
- package/src/routing/performance.ts +244 -0
- package/src/routing/selector.ts +225 -0
- package/src/spending/index.ts +1 -0
- package/src/spending/policy.ts +271 -0
- package/src/staking/client.ts +319 -0
- package/src/staking/index.ts +1 -0
- package/src/sui/client.ts +214 -0
- package/src/sui/index.ts +2 -0
- package/src/sui/tx-helpers.ts +1070 -0
- package/src/task/client.ts +215 -0
- package/src/task/index.ts +1 -0
- package/src/x402/client.ts +295 -0
- package/src/x402/index.ts +1 -0
- package/tests/auth/device-flow.test.ts +62 -0
- package/tests/auth/ed25519-provider.test.ts +24 -0
- package/tests/auth/evm-key.test.ts +31 -0
- package/tests/auth/session-store.test.ts +201 -0
- package/tests/auth/zklogin-provider.test.ts +366 -0
- package/tests/blobstore/encrypted-store.test.ts +78 -0
- package/tests/blobstore.test.ts +91 -0
- package/tests/cache.test.ts +124 -0
- package/tests/crypto/encryption.test.ts +70 -0
- package/tests/crypto/x25519.test.ts +47 -0
- package/tests/dispute/client.test.ts +238 -0
- package/tests/events.test.ts +202 -0
- package/tests/evm/wallet.test.ts +101 -0
- package/tests/hybrid-store.test.ts +121 -0
- package/tests/identity.test.ts +161 -0
- package/tests/marketplace.test.ts +308 -0
- package/tests/metering/hash-chain.test.ts +32 -0
- package/tests/metering/meter.test.ts +23 -0
- package/tests/metering/streaming.test.ts +52 -0
- package/tests/metering/verification.test.ts +27 -0
- package/tests/payment/rail-selector.test.ts +95 -0
- package/tests/registry.test.ts +183 -0
- package/tests/relay-consumer-client.test.ts +119 -0
- package/tests/relay-registry/client.test.ts +261 -0
- package/tests/reputation/event-publisher.test.ts +70 -0
- package/tests/reputation/merkle.test.ts +44 -0
- package/tests/reputation/score-calculator.test.ts +104 -0
- package/tests/reputation/store.test.ts +94 -0
- package/tests/routing/circuit-breaker.test.ts +45 -0
- package/tests/routing/fan-out.test.ts +123 -0
- package/tests/routing/performance.test.ts +49 -0
- package/tests/routing/selector.test.ts +114 -0
- package/tests/spending.test.ts +133 -0
- package/tests/staking/client.test.ts +286 -0
- package/tests/sui-client.test.ts +85 -0
- package/tests/task.test.ts +249 -0
- package/tests/tx-helpers.test.ts +70 -0
- package/tests/walrus-spike.test.ts +100 -0
- package/tests/walrus-store.test.ts +196 -0
- package/tests/x402/client.test.ts +116 -0
- package/tsconfig.json +9 -0
- package/tsup.config.ts +11 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import pino from 'pino';
|
|
6
|
+
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
|
|
7
|
+
|
|
8
|
+
import { SessionExpiredError, SessionRefreshError } from './errors.js';
|
|
9
|
+
import {
|
|
10
|
+
SessionState,
|
|
11
|
+
type SessionRefreshPolicy,
|
|
12
|
+
type SessionStateChangeCallback,
|
|
13
|
+
} from './session-state.js';
|
|
14
|
+
import type { OAuthProvider, StoredZkLoginSession } from './types.js';
|
|
15
|
+
|
|
16
|
+
interface SessionEnvelopeV1 {
|
|
17
|
+
version: 1;
|
|
18
|
+
metadata: {
|
|
19
|
+
address: string;
|
|
20
|
+
iss: string;
|
|
21
|
+
sub: string;
|
|
22
|
+
maxEpoch: number;
|
|
23
|
+
updatedAt: number;
|
|
24
|
+
};
|
|
25
|
+
iv: string;
|
|
26
|
+
tag: string;
|
|
27
|
+
ciphertext: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface SessionEnvelopeV2 {
|
|
31
|
+
version: 2;
|
|
32
|
+
metadata: {
|
|
33
|
+
maxEpoch: number;
|
|
34
|
+
updatedAt: number;
|
|
35
|
+
};
|
|
36
|
+
iv: string;
|
|
37
|
+
tag: string;
|
|
38
|
+
ciphertext: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type SessionEnvelope = SessionEnvelopeV1 | SessionEnvelopeV2;
|
|
42
|
+
|
|
43
|
+
type SessionStoreLogger = Pick<ReturnType<typeof pino>, 'info' | 'warn' | 'error'>;
|
|
44
|
+
|
|
45
|
+
type SerializedSession = Omit<StoredZkLoginSession, 'ephemeralKeypair' | 'provider'> & {
|
|
46
|
+
provider?: OAuthProvider;
|
|
47
|
+
ephemeralSecretKey: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export interface ZkLoginSessionStoreOptions {
|
|
51
|
+
refresh?: SessionRefreshPolicy;
|
|
52
|
+
logger?: SessionStoreLogger;
|
|
53
|
+
onSessionStateChange?: SessionStateChangeCallback;
|
|
54
|
+
sleep?: (ms: number) => Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const SESSION_ENCRYPTION_INFO = Buffer.from('agentic-mesh:zklogin-session-store:v2', 'utf8');
|
|
58
|
+
const SESSION_ENCRYPTION_SALT = Buffer.from('aes-256-gcm', 'utf8');
|
|
59
|
+
const DEFAULT_REFRESH_POLICY: Required<SessionRefreshPolicy> = {
|
|
60
|
+
maxAttempts: 3,
|
|
61
|
+
backoffMs: [1_000, 2_000, 4_000],
|
|
62
|
+
maxConsecutiveFailures: 3,
|
|
63
|
+
};
|
|
64
|
+
const logger = pino({ name: '@hivemind-os/collective-core:auth:session-store' });
|
|
65
|
+
|
|
66
|
+
export class ZkLoginSessionStore {
|
|
67
|
+
private readonly encryptionKey: Buffer;
|
|
68
|
+
private readonly legacyEncryptionKey: Buffer;
|
|
69
|
+
private readonly refreshPolicy: Required<SessionRefreshPolicy>;
|
|
70
|
+
private readonly logger: SessionStoreLogger;
|
|
71
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
72
|
+
private readonly stateChangeListeners = new Set<SessionStateChangeCallback>();
|
|
73
|
+
|
|
74
|
+
private sessionState = SessionState.EXPIRED;
|
|
75
|
+
private refreshFailureCount = 0;
|
|
76
|
+
|
|
77
|
+
constructor(
|
|
78
|
+
private readonly baseDir: string,
|
|
79
|
+
encryptionKey: Uint8Array,
|
|
80
|
+
options: ZkLoginSessionStoreOptions = {},
|
|
81
|
+
) {
|
|
82
|
+
const keyMaterial = Buffer.from(encryptionKey);
|
|
83
|
+
this.legacyEncryptionKey = Buffer.from(createHash('sha256').update(keyMaterial).digest());
|
|
84
|
+
this.encryptionKey = Buffer.from(hkdfSync('sha256', keyMaterial, SESSION_ENCRYPTION_SALT, SESSION_ENCRYPTION_INFO, 32));
|
|
85
|
+
this.refreshPolicy = {
|
|
86
|
+
maxAttempts: options.refresh?.maxAttempts ?? DEFAULT_REFRESH_POLICY.maxAttempts,
|
|
87
|
+
backoffMs: [...(options.refresh?.backoffMs ?? DEFAULT_REFRESH_POLICY.backoffMs)],
|
|
88
|
+
maxConsecutiveFailures: options.refresh?.maxConsecutiveFailures ?? DEFAULT_REFRESH_POLICY.maxConsecutiveFailures,
|
|
89
|
+
};
|
|
90
|
+
this.logger = options.logger ?? logger;
|
|
91
|
+
this.sleep = options.sleep ?? wait;
|
|
92
|
+
if (options.onSessionStateChange) {
|
|
93
|
+
this.stateChangeListeners.add(options.onSessionStateChange);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
getSessionState(): SessionState {
|
|
98
|
+
return this.sessionState;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
getRefreshFailureCount(): number {
|
|
102
|
+
return this.refreshFailureCount;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
onSessionStateChange(callback: SessionStateChangeCallback): () => void {
|
|
106
|
+
this.stateChangeListeners.add(callback);
|
|
107
|
+
return () => {
|
|
108
|
+
this.stateChangeListeners.delete(callback);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async save(session: StoredZkLoginSession): Promise<void> {
|
|
113
|
+
await this.ensureBaseDir();
|
|
114
|
+
|
|
115
|
+
const normalizedSession = this.normalizeSession(session);
|
|
116
|
+
const payload = this.serializeSession(normalizedSession);
|
|
117
|
+
const envelope = this.encrypt(payload);
|
|
118
|
+
const path = join(this.baseDir, getSessionFilename(normalizedSession));
|
|
119
|
+
|
|
120
|
+
await writeFile(path, JSON.stringify(envelope, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
121
|
+
await chmod(path, 0o600);
|
|
122
|
+
this.updateSessionState(this.resolveSessionState(normalizedSession), normalizedSession, 'session_saved');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async loadLatest(): Promise<StoredZkLoginSession | null> {
|
|
126
|
+
const sessions = await this.loadAll();
|
|
127
|
+
sessions.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
128
|
+
return sessions[0] ?? null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async loadAll(): Promise<StoredZkLoginSession[]> {
|
|
132
|
+
try {
|
|
133
|
+
const entries = await readdir(this.baseDir, { withFileTypes: true });
|
|
134
|
+
const sessions = await Promise.all(
|
|
135
|
+
entries
|
|
136
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
137
|
+
.map(async (entry) => {
|
|
138
|
+
try {
|
|
139
|
+
const contents = await readFile(join(this.baseDir, entry.name), 'utf8');
|
|
140
|
+
return this.decrypt(JSON.parse(contents) as SessionEnvelope);
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return sessions.filter((session): session is StoredZkLoginSession => session !== null);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (isErrnoException(error, 'ENOENT')) {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async loadLatestValid(currentEpoch: number): Promise<StoredZkLoginSession | null> {
|
|
158
|
+
const latest = await this.loadLatest();
|
|
159
|
+
if (!latest) {
|
|
160
|
+
this.updateSessionState(SessionState.EXPIRED, null, 'session_missing');
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const sessionState = this.resolveSessionState(latest, currentEpoch);
|
|
165
|
+
if (sessionState !== SessionState.VALID) {
|
|
166
|
+
this.updateSessionState(sessionState, sessionState === SessionState.EXPIRED ? null : latest, 'session_invalid');
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this.updateSessionState(SessionState.VALID, latest, 'session_loaded');
|
|
171
|
+
return latest;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async hasValidSession(currentEpoch: number): Promise<boolean> {
|
|
175
|
+
return (await this.loadLatestValid(currentEpoch)) !== null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
isExpired(session: Pick<StoredZkLoginSession, 'maxEpoch'>, currentEpoch: number): boolean {
|
|
179
|
+
return currentEpoch >= session.maxEpoch;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
isNearExpiry(
|
|
183
|
+
session: Pick<StoredZkLoginSession, 'maxEpoch'>,
|
|
184
|
+
currentEpoch: number,
|
|
185
|
+
remainingEpochs = 1,
|
|
186
|
+
): boolean {
|
|
187
|
+
return currentEpoch + remainingEpochs >= session.maxEpoch;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async refreshIfNeeded(
|
|
191
|
+
currentEpoch: number,
|
|
192
|
+
refresher: (session: StoredZkLoginSession) => Promise<StoredZkLoginSession | null>,
|
|
193
|
+
options: { force?: boolean } = {},
|
|
194
|
+
): Promise<StoredZkLoginSession | null> {
|
|
195
|
+
const session = await this.loadLatest();
|
|
196
|
+
if (!session) {
|
|
197
|
+
this.updateSessionState(SessionState.EXPIRED, null, 'session_missing');
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const currentState = this.resolveSessionState(session, currentEpoch);
|
|
202
|
+
if (currentState === SessionState.EXPIRED) {
|
|
203
|
+
this.updateSessionState(SessionState.EXPIRED, null, 'session_expired');
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (currentState === SessionState.NEEDS_REAUTH) {
|
|
208
|
+
this.updateSessionState(SessionState.NEEDS_REAUTH, session, 'session_needs_reauth');
|
|
209
|
+
throw new SessionExpiredError('Stored zkLogin session requires re-authentication.', {
|
|
210
|
+
consecutiveFailures: session.refreshFailureCount ?? 0,
|
|
211
|
+
session,
|
|
212
|
+
sessionState: SessionState.NEEDS_REAUTH,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.updateSessionState(SessionState.VALID, session, 'session_available');
|
|
217
|
+
if ((!options.force && !this.isNearExpiry(session, currentEpoch)) || !session.refreshToken) {
|
|
218
|
+
return session;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.updateSessionState(SessionState.REFRESHING, session, 'refresh_started');
|
|
222
|
+
|
|
223
|
+
let lastError: SessionRefreshError | null = null;
|
|
224
|
+
for (let attempt = 1; attempt <= this.refreshPolicy.maxAttempts; attempt += 1) {
|
|
225
|
+
this.logger.info(
|
|
226
|
+
{
|
|
227
|
+
address: session.address,
|
|
228
|
+
attempt,
|
|
229
|
+
currentEpoch,
|
|
230
|
+
maxAttempts: this.refreshPolicy.maxAttempts,
|
|
231
|
+
maxEpoch: session.maxEpoch,
|
|
232
|
+
refreshFailureCount: session.refreshFailureCount ?? 0,
|
|
233
|
+
},
|
|
234
|
+
'Refreshing zkLogin session.',
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const refreshed = await refresher(session);
|
|
239
|
+
if (!refreshed) {
|
|
240
|
+
throw new Error('zkLogin refresher returned no session.');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const normalizedSession = this.normalizeSession({
|
|
244
|
+
...refreshed,
|
|
245
|
+
refreshFailureCount: 0,
|
|
246
|
+
sessionState: SessionState.VALID,
|
|
247
|
+
});
|
|
248
|
+
await this.save(normalizedSession);
|
|
249
|
+
this.logger.info(
|
|
250
|
+
{
|
|
251
|
+
address: normalizedSession.address,
|
|
252
|
+
currentEpoch,
|
|
253
|
+
maxEpoch: normalizedSession.maxEpoch,
|
|
254
|
+
},
|
|
255
|
+
'Refreshed zkLogin session.',
|
|
256
|
+
);
|
|
257
|
+
return normalizedSession;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
const consecutiveFailures = (session.refreshFailureCount ?? 0) + attempt;
|
|
260
|
+
lastError = new SessionRefreshError('Failed to refresh zkLogin session.', {
|
|
261
|
+
attempts: attempt,
|
|
262
|
+
maxAttempts: this.refreshPolicy.maxAttempts,
|
|
263
|
+
retryDelaysMs: this.refreshPolicy.backoffMs,
|
|
264
|
+
consecutiveFailures,
|
|
265
|
+
sessionState: SessionState.REFRESHING,
|
|
266
|
+
session,
|
|
267
|
+
cause: error,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
this.logger.warn(
|
|
271
|
+
{
|
|
272
|
+
address: session.address,
|
|
273
|
+
attempt,
|
|
274
|
+
currentEpoch,
|
|
275
|
+
err: error,
|
|
276
|
+
maxAttempts: this.refreshPolicy.maxAttempts,
|
|
277
|
+
maxEpoch: session.maxEpoch,
|
|
278
|
+
consecutiveFailures,
|
|
279
|
+
},
|
|
280
|
+
'zkLogin session refresh attempt failed.',
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
if (attempt < this.refreshPolicy.maxAttempts) {
|
|
284
|
+
await this.sleep(this.refreshPolicy.backoffMs[Math.min(attempt - 1, this.refreshPolicy.backoffMs.length - 1)]);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const consecutiveFailures = (session.refreshFailureCount ?? 0) + this.refreshPolicy.maxAttempts;
|
|
290
|
+
const nextState =
|
|
291
|
+
consecutiveFailures >= this.refreshPolicy.maxConsecutiveFailures ? SessionState.NEEDS_REAUTH : SessionState.VALID;
|
|
292
|
+
const failedSession = this.normalizeSession({
|
|
293
|
+
...session,
|
|
294
|
+
refreshFailureCount: consecutiveFailures,
|
|
295
|
+
sessionState: nextState,
|
|
296
|
+
updatedAt: Date.now(),
|
|
297
|
+
});
|
|
298
|
+
await this.save(failedSession);
|
|
299
|
+
|
|
300
|
+
if (nextState === SessionState.NEEDS_REAUTH) {
|
|
301
|
+
this.logger.error(
|
|
302
|
+
{
|
|
303
|
+
address: failedSession.address,
|
|
304
|
+
currentEpoch,
|
|
305
|
+
maxConsecutiveFailures: this.refreshPolicy.maxConsecutiveFailures,
|
|
306
|
+
maxEpoch: failedSession.maxEpoch,
|
|
307
|
+
refreshFailureCount: consecutiveFailures,
|
|
308
|
+
},
|
|
309
|
+
'zkLogin session refresh failed and re-authentication is required.',
|
|
310
|
+
);
|
|
311
|
+
throw new SessionExpiredError('zkLogin session refresh failed. Re-authentication is required.', {
|
|
312
|
+
attempts: this.refreshPolicy.maxAttempts,
|
|
313
|
+
maxAttempts: this.refreshPolicy.maxAttempts,
|
|
314
|
+
retryDelaysMs: this.refreshPolicy.backoffMs,
|
|
315
|
+
consecutiveFailures,
|
|
316
|
+
session: failedSession,
|
|
317
|
+
sessionState: nextState,
|
|
318
|
+
cause: lastError,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
throw new SessionRefreshError('zkLogin session refresh failed after retries.', {
|
|
323
|
+
attempts: this.refreshPolicy.maxAttempts,
|
|
324
|
+
maxAttempts: this.refreshPolicy.maxAttempts,
|
|
325
|
+
retryDelaysMs: this.refreshPolicy.backoffMs,
|
|
326
|
+
consecutiveFailures,
|
|
327
|
+
sessionState: nextState,
|
|
328
|
+
session: failedSession,
|
|
329
|
+
cause: lastError,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async delete(session: Pick<StoredZkLoginSession, 'iss' | 'sub'>): Promise<void> {
|
|
334
|
+
await rm(join(this.baseDir, getSessionFilename(session)), { force: true });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async deleteExpired(currentEpoch: number): Promise<void> {
|
|
338
|
+
try {
|
|
339
|
+
const entries = await readdir(this.baseDir, { withFileTypes: true });
|
|
340
|
+
await Promise.all(
|
|
341
|
+
entries
|
|
342
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
343
|
+
.map(async (entry) => {
|
|
344
|
+
const path = join(this.baseDir, entry.name);
|
|
345
|
+
try {
|
|
346
|
+
const contents = await readFile(path, 'utf8');
|
|
347
|
+
const session = this.decrypt(JSON.parse(contents) as SessionEnvelope);
|
|
348
|
+
if (this.isExpired(session, currentEpoch)) {
|
|
349
|
+
await rm(path, { force: true });
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
await rm(path, { force: true });
|
|
353
|
+
}
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
if (!isErrnoException(error, 'ENOENT')) {
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private serializeSession(session: StoredZkLoginSession): SerializedSession {
|
|
364
|
+
const normalizedSession = this.normalizeSession(session);
|
|
365
|
+
return {
|
|
366
|
+
...normalizedSession,
|
|
367
|
+
ephemeralSecretKey: normalizedSession.ephemeralKeypair.getSecretKey(),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private deserializeSession(session: SerializedSession): StoredZkLoginSession {
|
|
372
|
+
return this.normalizeSession({
|
|
373
|
+
...session,
|
|
374
|
+
provider: session.provider ?? inferOAuthProvider(session.iss),
|
|
375
|
+
ephemeralKeypair: Ed25519Keypair.fromSecretKey(session.ephemeralSecretKey),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private normalizeSession(session: StoredZkLoginSession): StoredZkLoginSession {
|
|
380
|
+
return {
|
|
381
|
+
...session,
|
|
382
|
+
refreshFailureCount: session.refreshFailureCount ?? 0,
|
|
383
|
+
sessionState: normalizePersistedSessionState(session.sessionState),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private resolveSessionState(session: StoredZkLoginSession, currentEpoch?: number): SessionState {
|
|
388
|
+
if (typeof currentEpoch === 'number' && this.isExpired(session, currentEpoch)) {
|
|
389
|
+
return SessionState.EXPIRED;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return session.sessionState === SessionState.NEEDS_REAUTH ? SessionState.NEEDS_REAUTH : SessionState.VALID;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private updateSessionState(
|
|
396
|
+
nextState: SessionState,
|
|
397
|
+
session: StoredZkLoginSession | null,
|
|
398
|
+
reason?: string,
|
|
399
|
+
error?: unknown,
|
|
400
|
+
): void {
|
|
401
|
+
const previousState = this.sessionState;
|
|
402
|
+
this.sessionState = nextState;
|
|
403
|
+
this.refreshFailureCount = session?.refreshFailureCount ?? (nextState === SessionState.EXPIRED ? 0 : this.refreshFailureCount);
|
|
404
|
+
if (previousState === nextState) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
for (const listener of this.stateChangeListeners) {
|
|
409
|
+
listener({
|
|
410
|
+
previousState,
|
|
411
|
+
currentState: nextState,
|
|
412
|
+
session,
|
|
413
|
+
reason,
|
|
414
|
+
refreshFailureCount: this.refreshFailureCount,
|
|
415
|
+
error,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private encrypt(session: SerializedSession): SessionEnvelope {
|
|
421
|
+
const metadata: SessionEnvelopeV2['metadata'] = {
|
|
422
|
+
maxEpoch: session.maxEpoch,
|
|
423
|
+
updatedAt: session.updatedAt,
|
|
424
|
+
};
|
|
425
|
+
const iv = randomBytes(12);
|
|
426
|
+
const cipher = createCipheriv('aes-256-gcm', this.encryptionKey, iv);
|
|
427
|
+
cipher.setAAD(Buffer.from(JSON.stringify(metadata), 'utf8'));
|
|
428
|
+
const plaintext = Buffer.from(JSON.stringify(session), 'utf8');
|
|
429
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
430
|
+
const tag = cipher.getAuthTag();
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
version: 2,
|
|
434
|
+
metadata,
|
|
435
|
+
iv: iv.toString('base64'),
|
|
436
|
+
tag: tag.toString('base64'),
|
|
437
|
+
ciphertext: ciphertext.toString('base64'),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private decrypt(envelope: SessionEnvelope): StoredZkLoginSession {
|
|
442
|
+
const version = (envelope as { version: number }).version;
|
|
443
|
+
if (version === 1) {
|
|
444
|
+
return this.decryptEnvelope(envelope, this.legacyEncryptionKey);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (version !== 2) {
|
|
448
|
+
throw new Error(`Unsupported zkLogin session version: ${String(version)}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return this.decryptEnvelope(envelope, this.encryptionKey, envelope.metadata);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private decryptEnvelope(
|
|
455
|
+
envelope: SessionEnvelope,
|
|
456
|
+
encryptionKey: Buffer,
|
|
457
|
+
metadata?: SessionEnvelopeV2['metadata'],
|
|
458
|
+
): StoredZkLoginSession {
|
|
459
|
+
const decipher = createDecipheriv('aes-256-gcm', encryptionKey, Buffer.from(envelope.iv, 'base64'));
|
|
460
|
+
if (metadata) {
|
|
461
|
+
decipher.setAAD(Buffer.from(JSON.stringify(metadata), 'utf8'));
|
|
462
|
+
}
|
|
463
|
+
decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
|
|
464
|
+
|
|
465
|
+
const plaintext = Buffer.concat([
|
|
466
|
+
decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
|
|
467
|
+
decipher.final(),
|
|
468
|
+
]).toString('utf8');
|
|
469
|
+
|
|
470
|
+
return this.deserializeSession(JSON.parse(plaintext) as SerializedSession);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private async ensureBaseDir(): Promise<void> {
|
|
474
|
+
await mkdir(this.baseDir, { recursive: true, mode: 0o700 });
|
|
475
|
+
await chmod(this.baseDir, 0o700);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function getSessionFilename(session: Pick<StoredZkLoginSession, 'iss' | 'sub'>): string {
|
|
480
|
+
const digest = createHash('sha256')
|
|
481
|
+
.update(`${session.iss}:${session.sub}`)
|
|
482
|
+
.digest('hex');
|
|
483
|
+
return `${digest}.json`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function inferOAuthProvider(issuer: string): OAuthProvider {
|
|
487
|
+
if (issuer === 'https://accounts.google.com') {
|
|
488
|
+
return 'google';
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (issuer === 'https://appleid.apple.com') {
|
|
492
|
+
return 'apple';
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
throw new Error(`Unsupported zkLogin issuer: ${issuer}`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function normalizePersistedSessionState(sessionState?: SessionState): SessionState {
|
|
499
|
+
return sessionState === SessionState.NEEDS_REAUTH ? SessionState.NEEDS_REAUTH : SessionState.VALID;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function isErrnoException(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
503
|
+
return error instanceof Error && 'code' in error && error.code === code;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function wait(ms: number): Promise<void> {
|
|
507
|
+
await new Promise((resolvePromise) => {
|
|
508
|
+
setTimeout(resolvePromise, ms);
|
|
509
|
+
});
|
|
510
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Signer } from '@mysten/sui/cryptography';
|
|
2
|
+
import type { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
|
|
3
|
+
|
|
4
|
+
import type { SessionState, SessionStateChangeCallback } from './session-state.js';
|
|
5
|
+
|
|
6
|
+
export type AuthMode = 'ed25519' | 'zklogin';
|
|
7
|
+
export type OAuthProvider = 'google' | 'apple';
|
|
8
|
+
|
|
9
|
+
export interface AuthProvider {
|
|
10
|
+
mode: AuthMode;
|
|
11
|
+
getAddress(): Promise<string>;
|
|
12
|
+
getDID(): string;
|
|
13
|
+
signTransaction(tx: Uint8Array): Promise<Uint8Array>;
|
|
14
|
+
signPersonalMessage(message: Uint8Array): Promise<{ signature: Uint8Array }>;
|
|
15
|
+
isAuthenticated(): boolean;
|
|
16
|
+
getPublicKey(): Uint8Array;
|
|
17
|
+
toSuiSigner(): Signer;
|
|
18
|
+
getSessionState?(): SessionState;
|
|
19
|
+
isSessionValid?(): boolean | Promise<boolean>;
|
|
20
|
+
onSessionStateChange?(callback: SessionStateChangeCallback): () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ZkLoginSession {
|
|
24
|
+
provider: OAuthProvider;
|
|
25
|
+
jwt: string;
|
|
26
|
+
salt: string;
|
|
27
|
+
epoch: number;
|
|
28
|
+
ephemeralKeypair: Ed25519Keypair;
|
|
29
|
+
proof: ZkLoginProof;
|
|
30
|
+
maxEpoch: number;
|
|
31
|
+
address: string;
|
|
32
|
+
sub: string;
|
|
33
|
+
iss: string;
|
|
34
|
+
aud: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface StoredZkLoginSession extends ZkLoginSession {
|
|
38
|
+
randomness: string;
|
|
39
|
+
refreshToken?: string;
|
|
40
|
+
createdAt: number;
|
|
41
|
+
updatedAt: number;
|
|
42
|
+
sessionState?: SessionState;
|
|
43
|
+
refreshFailureCount?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ZkLoginProof {
|
|
47
|
+
proofPoints: { a: string[]; b: string[][]; c: string[] };
|
|
48
|
+
issBase64Details: { value: string; indexMod4: number };
|
|
49
|
+
headerBase64: string;
|
|
50
|
+
addressSeed: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface OAuthConfig {
|
|
54
|
+
provider: OAuthProvider;
|
|
55
|
+
clientId: string;
|
|
56
|
+
redirectUri: string;
|
|
57
|
+
authorizationEndpoint?: string;
|
|
58
|
+
tokenEndpoint?: string;
|
|
59
|
+
deviceCodeEndpoint?: string;
|
|
60
|
+
saltEndpoint?: string;
|
|
61
|
+
proverEndpoint?: string;
|
|
62
|
+
issuer?: string;
|
|
63
|
+
scopes?: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface OAuthTokenResponse {
|
|
67
|
+
jwt: string;
|
|
68
|
+
refreshToken?: string;
|
|
69
|
+
accessToken?: string;
|
|
70
|
+
expiresIn?: number;
|
|
71
|
+
tokenType?: string;
|
|
72
|
+
scope?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface DeviceFlowStatus {
|
|
76
|
+
userCode: string;
|
|
77
|
+
verificationUri: string;
|
|
78
|
+
deviceCode: string;
|
|
79
|
+
pollInterval: number;
|
|
80
|
+
expiresIn: number;
|
|
81
|
+
}
|