@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,201 @@
|
|
|
1
|
+
import { createCipheriv, createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { SessionExpiredError } from '../../src/auth/errors.js';
|
|
9
|
+
import { ZkLoginSessionStore } from '../../src/auth/session-store.js';
|
|
10
|
+
import { SessionState } from '../../src/auth/session-state.js';
|
|
11
|
+
import type { StoredZkLoginSession } from '../../src/auth/types.js';
|
|
12
|
+
|
|
13
|
+
const createdPaths: string[] = [];
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await Promise.all(createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
async function createTestDir(): Promise<string> {
|
|
20
|
+
const dir = resolve(process.cwd(), '.test-data', randomUUID());
|
|
21
|
+
createdPaths.push(dir);
|
|
22
|
+
await mkdir(dir, { recursive: true });
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createSession(overrides: Partial<StoredZkLoginSession> = {}): StoredZkLoginSession {
|
|
27
|
+
return {
|
|
28
|
+
provider: 'google',
|
|
29
|
+
jwt: 'header.payload.signature',
|
|
30
|
+
salt: '12345',
|
|
31
|
+
epoch: 10,
|
|
32
|
+
ephemeralKeypair: Ed25519Keypair.generate(),
|
|
33
|
+
proof: {
|
|
34
|
+
proofPoints: { a: ['1', '2'], b: [['3'], ['4']], c: ['5', '6'] },
|
|
35
|
+
issBase64Details: { value: 'issuer', indexMod4: 0 },
|
|
36
|
+
headerBase64: 'header',
|
|
37
|
+
addressSeed: '98765',
|
|
38
|
+
},
|
|
39
|
+
maxEpoch: 12,
|
|
40
|
+
address: '0x123',
|
|
41
|
+
sub: 'subject-1',
|
|
42
|
+
iss: 'https://accounts.google.com',
|
|
43
|
+
aud: 'client-id',
|
|
44
|
+
randomness: '999',
|
|
45
|
+
refreshToken: 'refresh-token',
|
|
46
|
+
createdAt: 1,
|
|
47
|
+
updatedAt: 2,
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function getOnlySessionFile(dir: string): Promise<string> {
|
|
53
|
+
const entries = await readdir(dir);
|
|
54
|
+
const sessionFile = entries.find((entry) => entry.endsWith('.json'));
|
|
55
|
+
if (!sessionFile) {
|
|
56
|
+
throw new Error('Expected a persisted session file.');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return join(dir, sessionFile);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function writeLegacySession(dir: string, encryptionKey: Uint8Array, session: StoredZkLoginSession): Promise<void> {
|
|
63
|
+
const key = createHash('sha256').update(Buffer.from(encryptionKey)).digest();
|
|
64
|
+
const iv = randomBytes(12);
|
|
65
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
66
|
+
const plaintext = Buffer.from(JSON.stringify({
|
|
67
|
+
...session,
|
|
68
|
+
ephemeralSecretKey: session.ephemeralKeypair.getSecretKey(),
|
|
69
|
+
}), 'utf8');
|
|
70
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
71
|
+
const tag = cipher.getAuthTag();
|
|
72
|
+
const filename = `${createHash('sha256').update(`${session.iss}:${session.sub}`).digest('hex')}.json`;
|
|
73
|
+
|
|
74
|
+
await writeFile(
|
|
75
|
+
join(dir, filename),
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
version: 1,
|
|
78
|
+
metadata: {
|
|
79
|
+
address: session.address,
|
|
80
|
+
iss: session.iss,
|
|
81
|
+
sub: session.sub,
|
|
82
|
+
maxEpoch: session.maxEpoch,
|
|
83
|
+
updatedAt: session.updatedAt,
|
|
84
|
+
},
|
|
85
|
+
iv: iv.toString('base64'),
|
|
86
|
+
tag: tag.toString('base64'),
|
|
87
|
+
ciphertext: ciphertext.toString('base64'),
|
|
88
|
+
}),
|
|
89
|
+
'utf8',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('ZkLoginSessionStore', () => {
|
|
94
|
+
it('stores and loads encrypted sessions without leaking sensitive metadata', async () => {
|
|
95
|
+
const dir = await createTestDir();
|
|
96
|
+
const encryptionKey = new Uint8Array([1, 2, 3, 4]);
|
|
97
|
+
const store = new ZkLoginSessionStore(dir, encryptionKey);
|
|
98
|
+
const session = createSession();
|
|
99
|
+
|
|
100
|
+
await store.save(session);
|
|
101
|
+
|
|
102
|
+
const raw = await readFile(await getOnlySessionFile(dir), 'utf8');
|
|
103
|
+
const envelope = JSON.parse(raw) as { version: number; metadata: Record<string, unknown> };
|
|
104
|
+
const loaded = await store.loadLatest();
|
|
105
|
+
|
|
106
|
+
expect(envelope.version).toBe(2);
|
|
107
|
+
expect(envelope.metadata).toEqual({ maxEpoch: session.maxEpoch, updatedAt: session.updatedAt });
|
|
108
|
+
expect(raw).not.toContain(session.address);
|
|
109
|
+
expect(raw).not.toContain(session.sub);
|
|
110
|
+
expect(raw).not.toContain(session.refreshToken ?? '');
|
|
111
|
+
expect(loaded?.address).toBe(session.address);
|
|
112
|
+
expect(loaded?.refreshToken).toBe(session.refreshToken);
|
|
113
|
+
expect(loaded?.ephemeralKeypair.getSecretKey()).toBe(session.ephemeralKeypair.getSecretKey());
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('loads legacy version 1 session envelopes', async () => {
|
|
117
|
+
const dir = await createTestDir();
|
|
118
|
+
const encryptionKey = new Uint8Array([9, 8, 7, 6]);
|
|
119
|
+
const store = new ZkLoginSessionStore(dir, encryptionKey);
|
|
120
|
+
const session = createSession({ sub: 'legacy-user' });
|
|
121
|
+
|
|
122
|
+
await writeLegacySession(dir, encryptionKey, session);
|
|
123
|
+
|
|
124
|
+
await expect(store.loadLatest()).resolves.toMatchObject({
|
|
125
|
+
address: session.address,
|
|
126
|
+
sub: session.sub,
|
|
127
|
+
refreshToken: session.refreshToken,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('filters expired sessions and refreshes near-expiry sessions', async () => {
|
|
132
|
+
const dir = await createTestDir();
|
|
133
|
+
const store = new ZkLoginSessionStore(dir, new Uint8Array([4, 3, 2, 1]));
|
|
134
|
+
const session = createSession();
|
|
135
|
+
|
|
136
|
+
await store.save(session);
|
|
137
|
+
expect(await store.loadLatestValid(11)).not.toBeNull();
|
|
138
|
+
expect(await store.refreshIfNeeded(11, async (current) => ({ ...current, maxEpoch: 20, updatedAt: 3 }))).toMatchObject({
|
|
139
|
+
maxEpoch: 20,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await store.deleteExpired(20);
|
|
143
|
+
expect(await store.loadLatest()).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('retries refresh failures with exponential backoff before succeeding', async () => {
|
|
147
|
+
const dir = await createTestDir();
|
|
148
|
+
const sleep = vi.fn(async () => undefined);
|
|
149
|
+
const store = new ZkLoginSessionStore(dir, new Uint8Array([7, 7, 7, 7]), { sleep });
|
|
150
|
+
const session = createSession({ maxEpoch: 11 });
|
|
151
|
+
const refresher = vi
|
|
152
|
+
.fn(async (current: StoredZkLoginSession) => ({
|
|
153
|
+
...current,
|
|
154
|
+
maxEpoch: 20,
|
|
155
|
+
updatedAt: 5,
|
|
156
|
+
refreshFailureCount: 0,
|
|
157
|
+
sessionState: SessionState.VALID,
|
|
158
|
+
}))
|
|
159
|
+
.mockRejectedValueOnce(new Error('temporary-1'))
|
|
160
|
+
.mockRejectedValueOnce(new Error('temporary-2'));
|
|
161
|
+
|
|
162
|
+
await store.save(session);
|
|
163
|
+
|
|
164
|
+
const refreshed = await store.refreshIfNeeded(10, refresher);
|
|
165
|
+
|
|
166
|
+
expect(refresher).toHaveBeenCalledTimes(3);
|
|
167
|
+
expect(sleep).toHaveBeenNthCalledWith(1, 1_000);
|
|
168
|
+
expect(sleep).toHaveBeenNthCalledWith(2, 2_000);
|
|
169
|
+
expect(refreshed).toMatchObject({ maxEpoch: 20, refreshFailureCount: 0, sessionState: SessionState.VALID });
|
|
170
|
+
expect(store.getSessionState()).toBe(SessionState.VALID);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('marks sessions as needing re-authentication after max refresh failures', async () => {
|
|
174
|
+
const dir = await createTestDir();
|
|
175
|
+
const sleep = vi.fn(async () => undefined);
|
|
176
|
+
const states: SessionState[] = [];
|
|
177
|
+
const store = new ZkLoginSessionStore(dir, new Uint8Array([5, 5, 5, 5]), {
|
|
178
|
+
sleep,
|
|
179
|
+
onSessionStateChange: ({ currentState }) => {
|
|
180
|
+
states.push(currentState);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
const session = createSession({ maxEpoch: 11 });
|
|
184
|
+
|
|
185
|
+
await store.save(session);
|
|
186
|
+
|
|
187
|
+
await expect(store.refreshIfNeeded(10, async () => {
|
|
188
|
+
throw new Error('refresh-down');
|
|
189
|
+
})).rejects.toBeInstanceOf(SessionExpiredError);
|
|
190
|
+
|
|
191
|
+
expect(sleep).toHaveBeenNthCalledWith(1, 1_000);
|
|
192
|
+
expect(sleep).toHaveBeenNthCalledWith(2, 2_000);
|
|
193
|
+
expect(states).toEqual([SessionState.VALID, SessionState.REFRESHING, SessionState.NEEDS_REAUTH]);
|
|
194
|
+
expect(store.getSessionState()).toBe(SessionState.NEEDS_REAUTH);
|
|
195
|
+
expect(store.getRefreshFailureCount()).toBe(3);
|
|
196
|
+
await expect(store.loadLatest()).resolves.toMatchObject({
|
|
197
|
+
refreshFailureCount: 3,
|
|
198
|
+
sessionState: SessionState.NEEDS_REAUTH,
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { jwtToAddress } from '@mysten/sui/zklogin';
|
|
8
|
+
|
|
9
|
+
import { ZkLoginSessionStore } from '../../src/auth/session-store.js';
|
|
10
|
+
import { SessionState } from '../../src/auth/session-state.js';
|
|
11
|
+
import { ZkLoginProvider } from '../../src/auth/zklogin-provider.js';
|
|
12
|
+
import type { OAuthConfig } from '../../src/auth/types.js';
|
|
13
|
+
|
|
14
|
+
const createdPaths: string[] = [];
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await Promise.all(createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
async function createTestDir(): Promise<string> {
|
|
21
|
+
const dir = resolve(process.cwd(), '.test-data', randomUUID());
|
|
22
|
+
createdPaths.push(dir);
|
|
23
|
+
await mkdir(dir, { recursive: true });
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createJwt(claims: Record<string, unknown>): string {
|
|
28
|
+
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
|
29
|
+
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
30
|
+
return `${header}.${payload}.signature`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createFetchFn(options: { failRefresh?: boolean } = {}): typeof fetch {
|
|
34
|
+
return vi.fn(async (input, init) => {
|
|
35
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
36
|
+
if (url.includes('get_salt')) {
|
|
37
|
+
return new Response(JSON.stringify({ salt: '123456' }));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (url.includes('/v1')) {
|
|
41
|
+
return new Response(
|
|
42
|
+
JSON.stringify({
|
|
43
|
+
proofPoints: { a: ['1', '2'], b: [['3'], ['4']], c: ['5', '6'] },
|
|
44
|
+
issBase64Details: { value: 'issuer', indexMod4: 1 },
|
|
45
|
+
headerBase64: 'header',
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (url.includes('/token')) {
|
|
51
|
+
const body = new URLSearchParams(String(init?.body ?? ''));
|
|
52
|
+
if (body.get('grant_type') === 'refresh_token' && options.failRefresh) {
|
|
53
|
+
return new Response(JSON.stringify({ error: 'temporarily_unavailable' }), { status: 503, statusText: 'Unavailable' });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return new Response(JSON.stringify({
|
|
57
|
+
id_token: createJwt({
|
|
58
|
+
iss: 'https://accounts.google.com',
|
|
59
|
+
aud: 'google-client-id',
|
|
60
|
+
sub: 'test-subject',
|
|
61
|
+
nonce: body.get('nonce') ?? 'refresh-nonce',
|
|
62
|
+
exp: Math.floor(Date.now() / 1_000) + 3600,
|
|
63
|
+
}),
|
|
64
|
+
refresh_token: 'refresh-token-2',
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
throw new Error(`Unexpected URL: ${url}`);
|
|
69
|
+
}) as typeof fetch;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('ZkLoginProvider', () => {
|
|
73
|
+
it('creates an auth request, authenticates a session, and signs with zkLogin', async () => {
|
|
74
|
+
const oauth: OAuthConfig = {
|
|
75
|
+
provider: 'google',
|
|
76
|
+
clientId: 'google-client-id',
|
|
77
|
+
redirectUri: 'http://127.0.0.1:3000/auth/callback',
|
|
78
|
+
authorizationEndpoint: 'https://accounts.example/auth',
|
|
79
|
+
tokenEndpoint: 'https://accounts.example/token',
|
|
80
|
+
saltEndpoint: 'https://salt.example/get_salt',
|
|
81
|
+
proverEndpoint: 'https://prover.example/v1',
|
|
82
|
+
};
|
|
83
|
+
const client = {
|
|
84
|
+
getCurrentEpoch: vi.fn().mockResolvedValue({ epoch: '42' }),
|
|
85
|
+
};
|
|
86
|
+
const fetchFn: typeof fetch = vi.fn(async (input) => {
|
|
87
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
88
|
+
if (url.includes('get_salt')) {
|
|
89
|
+
return new Response(JSON.stringify({ salt: '123456' }));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (url.includes('/v1')) {
|
|
93
|
+
return new Response(
|
|
94
|
+
JSON.stringify({
|
|
95
|
+
proofPoints: { a: ['1', '2'], b: [['3'], ['4']], c: ['5', '6'] },
|
|
96
|
+
issBase64Details: { value: 'issuer', indexMod4: 1 },
|
|
97
|
+
headerBase64: 'header',
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
throw new Error(`Unexpected URL: ${url}`);
|
|
103
|
+
}) as typeof fetch;
|
|
104
|
+
const provider = new ZkLoginProvider({ client, oauth, fetchFn });
|
|
105
|
+
|
|
106
|
+
const authRequest = await provider.createAuthorizationRequest({
|
|
107
|
+
redirectUri: oauth.redirectUri,
|
|
108
|
+
state: 'portal-state',
|
|
109
|
+
codeChallenge: 'pkce-challenge',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(authRequest.authorizationUrl).toContain('code_challenge=pkce-challenge');
|
|
113
|
+
expect(authRequest.authorizationUrl).toContain('state=portal-state');
|
|
114
|
+
expect(authRequest.pendingSession.maxEpoch).toBe(44);
|
|
115
|
+
|
|
116
|
+
const jwt = createJwt({
|
|
117
|
+
iss: 'https://accounts.google.com',
|
|
118
|
+
aud: oauth.clientId,
|
|
119
|
+
sub: 'test-subject',
|
|
120
|
+
email: 'mesh@example.com',
|
|
121
|
+
nonce: authRequest.pendingSession.nonce,
|
|
122
|
+
});
|
|
123
|
+
const session = await provider.authenticateWithJwt(jwt, {
|
|
124
|
+
pendingSession: authRequest.pendingSession,
|
|
125
|
+
refreshToken: 'refresh-token',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(provider.isAuthenticated()).toBe(true);
|
|
129
|
+
await expect(provider.isSessionValid?.()).resolves.toBe(true);
|
|
130
|
+
expect(provider.getSessionState()).toBe(SessionState.VALID);
|
|
131
|
+
expect(await provider.getAddress()).toBe(jwtToAddress(jwt, session.salt));
|
|
132
|
+
expect(provider.getDID()).toBe(`did:mesh:zklogin:${session.address}`);
|
|
133
|
+
expect(provider.getSession()?.provider).toBe('google');
|
|
134
|
+
expect(provider.getSession()?.refreshToken).toBe('refresh-token');
|
|
135
|
+
expect(provider.toSuiSigner().toSuiAddress()).toBe(session.address);
|
|
136
|
+
|
|
137
|
+
const signedTransaction = await provider.signTransaction(new Uint8Array([1, 2, 3]));
|
|
138
|
+
const signedMessage = await provider.signPersonalMessage(new Uint8Array([4, 5, 6]));
|
|
139
|
+
|
|
140
|
+
expect(signedTransaction.length).toBeGreaterThan(0);
|
|
141
|
+
expect(signedMessage.signature.length).toBeGreaterThan(0);
|
|
142
|
+
expect(provider.getPublicKey().length).toBeGreaterThan(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('uses Apple-specific authorization parameters when requested', async () => {
|
|
146
|
+
const provider = new ZkLoginProvider({
|
|
147
|
+
client: {
|
|
148
|
+
getCurrentEpoch: vi.fn().mockResolvedValue({ epoch: '42' }),
|
|
149
|
+
},
|
|
150
|
+
oauth: {
|
|
151
|
+
provider: 'apple',
|
|
152
|
+
clientId: 'apple-client-id',
|
|
153
|
+
redirectUri: 'http://127.0.0.1:3000/auth/apple/callback',
|
|
154
|
+
authorizationEndpoint: 'https://appleid.example/auth',
|
|
155
|
+
tokenEndpoint: 'https://appleid.example/token',
|
|
156
|
+
saltEndpoint: 'https://salt.example/get_salt',
|
|
157
|
+
proverEndpoint: 'https://prover.example/v1',
|
|
158
|
+
},
|
|
159
|
+
fetchFn: vi.fn() as typeof fetch,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const authRequest = await provider.createAuthorizationRequest({
|
|
163
|
+
redirectUri: 'http://127.0.0.1:3000/auth/apple/callback',
|
|
164
|
+
state: 'apple-state',
|
|
165
|
+
codeChallenge: 'apple-challenge',
|
|
166
|
+
scopes: ['name', 'email'],
|
|
167
|
+
});
|
|
168
|
+
const authorizationUrl = new URL(authRequest.authorizationUrl);
|
|
169
|
+
|
|
170
|
+
expect(authorizationUrl.searchParams.get('response_mode')).toBe('form_post');
|
|
171
|
+
expect(authorizationUrl.searchParams.get('response_type')).toBe('code id_token');
|
|
172
|
+
expect(authorizationUrl.searchParams.get('scope')).toBe('name email');
|
|
173
|
+
expect(authorizationUrl.searchParams.get('nonce')).toBe(authRequest.pendingSession.nonce);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('throws SessionExpiredError after refresh retries are exhausted', async () => {
|
|
177
|
+
const dir = await createTestDir();
|
|
178
|
+
const sleep = vi.fn(async () => undefined);
|
|
179
|
+
const client = {
|
|
180
|
+
getCurrentEpoch: vi
|
|
181
|
+
.fn()
|
|
182
|
+
.mockResolvedValueOnce({ epoch: '41' })
|
|
183
|
+
.mockResolvedValue({ epoch: '42' }),
|
|
184
|
+
};
|
|
185
|
+
const oauth: OAuthConfig = {
|
|
186
|
+
provider: 'google',
|
|
187
|
+
clientId: 'google-client-id',
|
|
188
|
+
redirectUri: 'http://127.0.0.1:3000/auth/callback',
|
|
189
|
+
authorizationEndpoint: 'https://accounts.example/auth',
|
|
190
|
+
tokenEndpoint: 'https://accounts.example/token',
|
|
191
|
+
saltEndpoint: 'https://salt.example/get_salt',
|
|
192
|
+
proverEndpoint: 'https://prover.example/v1',
|
|
193
|
+
};
|
|
194
|
+
const sessionStore = new ZkLoginSessionStore(resolve(dir, 'sessions'), new Uint8Array([1, 9, 9, 1]), { sleep });
|
|
195
|
+
const provider = new ZkLoginProvider({
|
|
196
|
+
client,
|
|
197
|
+
oauth,
|
|
198
|
+
fetchFn: createFetchFn({ failRefresh: true }),
|
|
199
|
+
sessionStore,
|
|
200
|
+
});
|
|
201
|
+
const sessionStates: SessionState[] = [];
|
|
202
|
+
provider.onSessionStateChange((event) => {
|
|
203
|
+
sessionStates.push(event.currentState);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const authRequest = await provider.createAuthorizationRequest({
|
|
207
|
+
redirectUri: oauth.redirectUri,
|
|
208
|
+
state: 'refresh-state',
|
|
209
|
+
codeChallenge: 'refresh-challenge',
|
|
210
|
+
});
|
|
211
|
+
const jwt = createJwt({
|
|
212
|
+
iss: 'https://accounts.google.com',
|
|
213
|
+
aud: oauth.clientId,
|
|
214
|
+
sub: 'test-subject',
|
|
215
|
+
nonce: authRequest.pendingSession.nonce,
|
|
216
|
+
exp: Math.floor(Date.now() / 1_000) + 60,
|
|
217
|
+
});
|
|
218
|
+
await provider.authenticateWithJwt(jwt, {
|
|
219
|
+
pendingSession: authRequest.pendingSession,
|
|
220
|
+
refreshToken: 'refresh-token',
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await expect(provider.restoreSession()).rejects.toMatchObject({ name: 'SessionExpiredError' });
|
|
224
|
+
|
|
225
|
+
expect(sleep).toHaveBeenNthCalledWith(1, 1_000);
|
|
226
|
+
expect(sleep).toHaveBeenNthCalledWith(2, 2_000);
|
|
227
|
+
expect(provider.isAuthenticated()).toBe(false);
|
|
228
|
+
expect(provider.getSession()).toBeNull();
|
|
229
|
+
expect(provider.getSessionState()).toBe(SessionState.NEEDS_REAUTH);
|
|
230
|
+
expect(await provider.isSessionValid?.()).toBe(false);
|
|
231
|
+
expect(sessionStates).toContain(SessionState.REFRESHING);
|
|
232
|
+
expect(sessionStates.at(-1)).toBe(SessionState.NEEDS_REAUTH);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('treats expired sessions as unauthenticated and surfaces SessionExpiredError', async () => {
|
|
236
|
+
const oauth: OAuthConfig = {
|
|
237
|
+
provider: 'google',
|
|
238
|
+
clientId: 'google-client-id',
|
|
239
|
+
redirectUri: 'http://127.0.0.1:3000/auth/callback',
|
|
240
|
+
authorizationEndpoint: 'https://accounts.example/auth',
|
|
241
|
+
tokenEndpoint: 'https://accounts.example/token',
|
|
242
|
+
saltEndpoint: 'https://salt.example/get_salt',
|
|
243
|
+
proverEndpoint: 'https://prover.example/v1',
|
|
244
|
+
};
|
|
245
|
+
const provider = new ZkLoginProvider({
|
|
246
|
+
client: {
|
|
247
|
+
getCurrentEpoch: vi.fn().mockResolvedValue({ epoch: '42' }),
|
|
248
|
+
},
|
|
249
|
+
oauth,
|
|
250
|
+
fetchFn: vi.fn(async (input) => {
|
|
251
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
252
|
+
if (url.includes('get_salt')) {
|
|
253
|
+
return new Response(JSON.stringify({ salt: '123456' }));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (url.includes('/v1')) {
|
|
257
|
+
return new Response(
|
|
258
|
+
JSON.stringify({
|
|
259
|
+
proofPoints: { a: ['1', '2'], b: [['3'], ['4']], c: ['5', '6'] },
|
|
260
|
+
issBase64Details: { value: 'issuer', indexMod4: 1 },
|
|
261
|
+
headerBase64: 'header',
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
throw new Error(`Unexpected URL: ${url}`);
|
|
267
|
+
}) as typeof fetch,
|
|
268
|
+
});
|
|
269
|
+
const authRequest = await provider.createAuthorizationRequest({
|
|
270
|
+
redirectUri: oauth.redirectUri,
|
|
271
|
+
state: 'portal-state',
|
|
272
|
+
codeChallenge: 'pkce-challenge',
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await provider.authenticateWithJwt(
|
|
276
|
+
createJwt({
|
|
277
|
+
iss: 'https://accounts.google.com',
|
|
278
|
+
aud: oauth.clientId,
|
|
279
|
+
sub: 'test-subject',
|
|
280
|
+
nonce: authRequest.pendingSession.nonce,
|
|
281
|
+
exp: Math.floor(Date.now() / 1_000) - 60,
|
|
282
|
+
}),
|
|
283
|
+
{ pendingSession: authRequest.pendingSession },
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
expect(provider.isAuthenticated()).toBe(false);
|
|
287
|
+
await expect(provider.getAddress()).rejects.toMatchObject({ name: 'SessionExpiredError' });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('rejects JWTs with mismatched issuer, audience, or nonce claims', async () => {
|
|
291
|
+
const oauth: OAuthConfig = {
|
|
292
|
+
provider: 'google',
|
|
293
|
+
clientId: 'google-client-id',
|
|
294
|
+
redirectUri: 'http://127.0.0.1:3000/auth/callback',
|
|
295
|
+
authorizationEndpoint: 'https://accounts.example/auth',
|
|
296
|
+
tokenEndpoint: 'https://accounts.example/token',
|
|
297
|
+
saltEndpoint: 'https://salt.example/get_salt',
|
|
298
|
+
proverEndpoint: 'https://prover.example/v1',
|
|
299
|
+
};
|
|
300
|
+
const provider = new ZkLoginProvider({
|
|
301
|
+
client: {
|
|
302
|
+
getCurrentEpoch: vi.fn().mockResolvedValue({ epoch: '42' }),
|
|
303
|
+
},
|
|
304
|
+
oauth,
|
|
305
|
+
fetchFn: vi.fn(async (input) => {
|
|
306
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
307
|
+
if (url.includes('get_salt')) {
|
|
308
|
+
return new Response(JSON.stringify({ salt: '123456' }));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (url.includes('/v1')) {
|
|
312
|
+
return new Response(
|
|
313
|
+
JSON.stringify({
|
|
314
|
+
proofPoints: { a: ['1', '2'], b: [['3'], ['4']], c: ['5', '6'] },
|
|
315
|
+
issBase64Details: { value: 'issuer', indexMod4: 1 },
|
|
316
|
+
headerBase64: 'header',
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
throw new Error(`Unexpected URL: ${url}`);
|
|
322
|
+
}) as typeof fetch,
|
|
323
|
+
});
|
|
324
|
+
const authRequest = await provider.createAuthorizationRequest({
|
|
325
|
+
redirectUri: oauth.redirectUri,
|
|
326
|
+
state: 'portal-state',
|
|
327
|
+
codeChallenge: 'pkce-challenge',
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await expect(
|
|
331
|
+
provider.authenticateWithJwt(
|
|
332
|
+
createJwt({
|
|
333
|
+
iss: 'https://issuer.example',
|
|
334
|
+
aud: oauth.clientId,
|
|
335
|
+
sub: 'test-subject',
|
|
336
|
+
nonce: authRequest.pendingSession.nonce,
|
|
337
|
+
}),
|
|
338
|
+
{ pendingSession: authRequest.pendingSession },
|
|
339
|
+
),
|
|
340
|
+
).rejects.toThrow('OAuth token issuer mismatch.');
|
|
341
|
+
|
|
342
|
+
await expect(
|
|
343
|
+
provider.authenticateWithJwt(
|
|
344
|
+
createJwt({
|
|
345
|
+
iss: 'https://accounts.google.com',
|
|
346
|
+
aud: 'someone-else',
|
|
347
|
+
sub: 'test-subject',
|
|
348
|
+
nonce: authRequest.pendingSession.nonce,
|
|
349
|
+
}),
|
|
350
|
+
{ pendingSession: authRequest.pendingSession },
|
|
351
|
+
),
|
|
352
|
+
).rejects.toThrow('OAuth token audience mismatch.');
|
|
353
|
+
|
|
354
|
+
await expect(
|
|
355
|
+
provider.authenticateWithJwt(
|
|
356
|
+
createJwt({
|
|
357
|
+
iss: 'https://accounts.google.com',
|
|
358
|
+
aud: oauth.clientId,
|
|
359
|
+
sub: 'test-subject',
|
|
360
|
+
nonce: 'unexpected-nonce',
|
|
361
|
+
}),
|
|
362
|
+
{ pendingSession: authRequest.pendingSession },
|
|
363
|
+
),
|
|
364
|
+
).rejects.toThrow('OAuth token nonce mismatch.');
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
EncryptedBlobStore,
|
|
9
|
+
FilesystemBlobStore,
|
|
10
|
+
generateX25519KeyPair,
|
|
11
|
+
parseEncryptedPayload,
|
|
12
|
+
} from '../../src/index.js';
|
|
13
|
+
|
|
14
|
+
const createdPaths: string[] = [];
|
|
15
|
+
const encoder = new TextEncoder();
|
|
16
|
+
|
|
17
|
+
async function createBaseDir(): Promise<string> {
|
|
18
|
+
const baseDir = resolve(process.cwd(), '.test-data', randomUUID());
|
|
19
|
+
createdPaths.push(baseDir);
|
|
20
|
+
await mkdir(baseDir, { recursive: true });
|
|
21
|
+
return baseDir;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await Promise.all(createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('EncryptedBlobStore', () => {
|
|
29
|
+
it('stores encrypted data and fetches decrypted plaintext', async () => {
|
|
30
|
+
const inner = new FilesystemBlobStore(await createBaseDir());
|
|
31
|
+
const sender = new EncryptedBlobStore(inner, generateX25519KeyPair());
|
|
32
|
+
const recipientKeys = generateX25519KeyPair();
|
|
33
|
+
const recipient = new EncryptedBlobStore(inner, recipientKeys);
|
|
34
|
+
const data = encoder.encode('private payload');
|
|
35
|
+
|
|
36
|
+
const stored = await sender.storeEncrypted(data, recipientKeys.publicKey);
|
|
37
|
+
const decrypted = await recipient.fetchDecrypted(stored.blobId);
|
|
38
|
+
|
|
39
|
+
expect(Buffer.from(decrypted ?? [])).toEqual(Buffer.from(data));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('stores ciphertext instead of plaintext', async () => {
|
|
43
|
+
const inner = new FilesystemBlobStore(await createBaseDir());
|
|
44
|
+
const sender = new EncryptedBlobStore(inner, generateX25519KeyPair());
|
|
45
|
+
const recipient = generateX25519KeyPair();
|
|
46
|
+
const plaintext = encoder.encode('ciphertext check');
|
|
47
|
+
|
|
48
|
+
const stored = await sender.storeEncrypted(plaintext, recipient.publicKey);
|
|
49
|
+
const raw = await sender.fetch(stored.blobId);
|
|
50
|
+
|
|
51
|
+
expect(Buffer.from(raw ?? [])).not.toEqual(Buffer.from(plaintext));
|
|
52
|
+
expect(parseEncryptedPayload(raw ?? new Uint8Array())).not.toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('keeps the regular blobstore interface unencrypted for compatibility', async () => {
|
|
56
|
+
const inner = new FilesystemBlobStore(await createBaseDir());
|
|
57
|
+
const store = new EncryptedBlobStore(inner, generateX25519KeyPair());
|
|
58
|
+
const plaintext = encoder.encode('plain compatibility');
|
|
59
|
+
|
|
60
|
+
const stored = await store.store(plaintext);
|
|
61
|
+
const fetched = await store.fetch(stored.blobId);
|
|
62
|
+
const decrypted = await store.fetchDecrypted(stored.blobId);
|
|
63
|
+
|
|
64
|
+
expect(Buffer.from(fetched ?? [])).toEqual(Buffer.from(plaintext));
|
|
65
|
+
expect(Buffer.from(decrypted ?? [])).toEqual(Buffer.from(plaintext));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('fails to decrypt with the wrong recipient key', async () => {
|
|
69
|
+
const inner = new FilesystemBlobStore(await createBaseDir());
|
|
70
|
+
const sender = new EncryptedBlobStore(inner, generateX25519KeyPair());
|
|
71
|
+
const recipient = generateX25519KeyPair();
|
|
72
|
+
const outsider = new EncryptedBlobStore(inner, generateX25519KeyPair());
|
|
73
|
+
|
|
74
|
+
const stored = await sender.storeEncrypted(encoder.encode('recipient only'), recipient.publicKey);
|
|
75
|
+
|
|
76
|
+
await expect(outsider.fetchDecrypted(stored.blobId)).rejects.toBeInstanceOf(Error);
|
|
77
|
+
});
|
|
78
|
+
});
|