@tuskydp/cli 0.1.2 → 0.2.1

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 (54) hide show
  1. package/bin/tusky-dev.sh +5 -0
  2. package/dist/src/commands/account.d.ts.map +1 -1
  3. package/dist/src/commands/account.js +32 -2
  4. package/dist/src/commands/account.js.map +1 -1
  5. package/dist/src/commands/download.d.ts.map +1 -1
  6. package/dist/src/commands/download.js +27 -7
  7. package/dist/src/commands/download.js.map +1 -1
  8. package/dist/src/commands/upload.d.ts.map +1 -1
  9. package/dist/src/commands/upload.js +23 -2
  10. package/dist/src/commands/upload.js.map +1 -1
  11. package/dist/src/commands/vault.d.ts.map +1 -1
  12. package/dist/src/commands/vault.js +145 -7
  13. package/dist/src/commands/vault.js.map +1 -1
  14. package/dist/src/config.d.ts +5 -0
  15. package/dist/src/config.d.ts.map +1 -1
  16. package/dist/src/config.js +7 -0
  17. package/dist/src/config.js.map +1 -1
  18. package/dist/src/index.js +3 -1
  19. package/dist/src/index.js.map +1 -1
  20. package/dist/src/mcp/context.d.ts +11 -2
  21. package/dist/src/mcp/context.d.ts.map +1 -1
  22. package/dist/src/mcp/context.js +3 -2
  23. package/dist/src/mcp/context.js.map +1 -1
  24. package/dist/src/mcp/server.d.ts.map +1 -1
  25. package/dist/src/mcp/server.js +13 -0
  26. package/dist/src/mcp/server.js.map +1 -1
  27. package/dist/src/mcp/tools/files.d.ts +2 -1
  28. package/dist/src/mcp/tools/files.d.ts.map +1 -1
  29. package/dist/src/mcp/tools/files.js +49 -12
  30. package/dist/src/mcp/tools/files.js.map +1 -1
  31. package/dist/src/mcp/tools/sharedVaults.d.ts +7 -0
  32. package/dist/src/mcp/tools/sharedVaults.d.ts.map +1 -0
  33. package/dist/src/mcp/tools/sharedVaults.js +90 -0
  34. package/dist/src/mcp/tools/sharedVaults.js.map +1 -0
  35. package/dist/src/mcp/tools/vaults.js +1 -1
  36. package/dist/src/mcp/tools/vaults.js.map +1 -1
  37. package/dist/src/seal.d.ts +58 -0
  38. package/dist/src/seal.d.ts.map +1 -0
  39. package/dist/src/seal.js +207 -0
  40. package/dist/src/seal.js.map +1 -0
  41. package/package.json +16 -13
  42. package/src/__tests__/seal.test.ts +357 -0
  43. package/src/commands/account.ts +35 -2
  44. package/src/commands/download.ts +34 -13
  45. package/src/commands/upload.ts +25 -2
  46. package/src/commands/vault.ts +158 -7
  47. package/src/config.ts +8 -0
  48. package/src/index.ts +3 -1
  49. package/src/mcp/context.ts +13 -2
  50. package/src/mcp/server.ts +13 -0
  51. package/src/mcp/tools/files.ts +61 -19
  52. package/src/mcp/tools/sharedVaults.ts +122 -0
  53. package/src/mcp/tools/vaults.ts +2 -2
  54. package/src/seal.ts +266 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * MCP tools — Shared vault member management
3
+ */
4
+
5
+ import { z } from 'zod';
6
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
+ import type { McpContext } from '../context.js';
8
+ import { wrapToolError } from './helpers.js';
9
+
10
+ export function registerSharedVaultTools(server: McpServer, ctx: McpContext) {
11
+ // ── Grant access to a shared vault ─────────────────────────────────
12
+ server.tool(
13
+ 'tusky_vault_grant_access',
14
+ 'Grant another user or agent access to a shared vault by their Sui address.',
15
+ {
16
+ vaultId: z.string().describe('Shared vault ID'),
17
+ suiAddress: z.string().describe('Sui address of the user/agent to grant access'),
18
+ },
19
+ async ({ vaultId, suiAddress }) => {
20
+ try {
21
+ const result = await ctx.sdk.sharedVaults.addMember(vaultId, { suiAddress });
22
+ return {
23
+ content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
24
+ };
25
+ } catch (err) {
26
+ return wrapToolError(err);
27
+ }
28
+ },
29
+ );
30
+
31
+ // ── Revoke access from a shared vault ──────────────────────────────
32
+ server.tool(
33
+ 'tusky_vault_revoke_access',
34
+ 'Revoke access from a shared vault member.',
35
+ {
36
+ vaultId: z.string().describe('Shared vault ID'),
37
+ memberId: z.string().describe('Member ID to revoke'),
38
+ },
39
+ async ({ vaultId, memberId }) => {
40
+ try {
41
+ await ctx.sdk.sharedVaults.removeMember(vaultId, memberId);
42
+ return {
43
+ content: [{ type: 'text' as const, text: `Access revoked for member ${memberId}.` }],
44
+ };
45
+ } catch (err) {
46
+ return wrapToolError(err);
47
+ }
48
+ },
49
+ );
50
+
51
+ // ── List members of a shared vault ─────────────────────────────────
52
+ server.tool(
53
+ 'tusky_vault_list_members',
54
+ 'List all members of a shared vault.',
55
+ {
56
+ vaultId: z.string().describe('Shared vault ID'),
57
+ },
58
+ async ({ vaultId }) => {
59
+ try {
60
+ const result = await ctx.sdk.sharedVaults.listMembers(vaultId);
61
+ return {
62
+ content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
63
+ };
64
+ } catch (err) {
65
+ return wrapToolError(err);
66
+ }
67
+ },
68
+ );
69
+
70
+ // ── List shared vaults the user is a member of ─────────────────────
71
+ server.tool(
72
+ 'tusky_vault_list_shared',
73
+ 'List all shared vaults that the authenticated user is a member of (not owner).',
74
+ {},
75
+ async () => {
76
+ try {
77
+ const result = await ctx.sdk.sharedVaults.list();
78
+ return {
79
+ content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
80
+ };
81
+ } catch (err) {
82
+ return wrapToolError(err);
83
+ }
84
+ },
85
+ );
86
+
87
+ // ── Link Sui address to account ────────────────────────────────────
88
+ server.tool(
89
+ 'tusky_account_link_sui',
90
+ 'Link a Sui wallet address to the authenticated account. Required for shared vault access.',
91
+ {
92
+ suiAddress: z.string().describe('Sui wallet address (0x followed by 64 hex characters)'),
93
+ },
94
+ async ({ suiAddress }) => {
95
+ try {
96
+ await ctx.sdk.sharedVaults.linkSuiAddress(suiAddress);
97
+ return {
98
+ content: [{ type: 'text' as const, text: `Sui address ${suiAddress} linked to account.` }],
99
+ };
100
+ } catch (err) {
101
+ return wrapToolError(err);
102
+ }
103
+ },
104
+ );
105
+
106
+ // ── Unlink Sui address from account ─────────────────────────────────
107
+ server.tool(
108
+ 'tusky_account_unlink_sui',
109
+ 'Unlink the Sui wallet address from the authenticated account.',
110
+ {},
111
+ async () => {
112
+ try {
113
+ await ctx.sdk.sharedVaults.unlinkSuiAddress();
114
+ return {
115
+ content: [{ type: 'text' as const, text: 'Sui address unlinked from account.' }],
116
+ };
117
+ } catch (err) {
118
+ return wrapToolError(err);
119
+ }
120
+ },
121
+ );
122
+ }
@@ -15,8 +15,8 @@ export function registerVaultTools(server: McpServer, ctx: McpContext) {
15
15
  {
16
16
  name: z.string().describe('Vault name'),
17
17
  description: z.string().optional().describe('Vault description'),
18
- visibility: z.enum(['public', 'private']).optional().describe(
19
- 'Vault visibility. "private" enables client-side encryption. Defaults to "private".',
18
+ visibility: z.enum(['public', 'private', 'shared']).optional().describe(
19
+ 'Vault visibility. "private" uses passphrase-based E2E encryption. "shared" uses SEAL protocol for multi-party access. Defaults to "private".',
20
20
  ),
21
21
  },
22
22
  async ({ name, description, visibility }) => {
package/src/seal.ts ADDED
@@ -0,0 +1,266 @@
1
+ /**
2
+ * SEAL encryption service for shared vaults (headless/CLI mode).
3
+ *
4
+ * Ported from packages/web/src/lib/seal.ts, adapted for headless use
5
+ * with an Ed25519 keypair instead of a browser wallet signer.
6
+ *
7
+ * Handles client-side encrypt/decrypt using the @mysten/seal SDK.
8
+ * - Encrypt: uses the vault's Whitelist object ID as the identity namespace
9
+ * - Decrypt: creates a SessionKey, signs with Ed25519 keypair, builds seal_approve PTB
10
+ *
11
+ * Requires TUSKYDP_SUI_PRIVATE_KEY env var for decrypt operations.
12
+ * Encrypt only requires a Sui RPC client (no signing needed).
13
+ */
14
+
15
+ import { SealClient, SessionKey, EncryptedObject } from '@mysten/seal';
16
+ import type { SealCompatibleClient } from '@mysten/seal';
17
+ import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
18
+ import { SuiJsonRpcClient } from '@mysten/sui/jsonRpc';
19
+ import { Transaction } from '@mysten/sui/transactions';
20
+ import { fromHex } from '@mysten/sui/utils';
21
+ import { SEAL_PACKAGE_ID, SEAL_DEFAULT_KEY_SERVER_CONFIGS } from '@tuskydp/shared/constants.js';
22
+ import type { VaultResponse } from '@tuskydp/sdk';
23
+ import { getSuiPrivateKey } from './config.js';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Lazy singletons
27
+ // ---------------------------------------------------------------------------
28
+
29
+ let _suiClient: SuiJsonRpcClient | null = null;
30
+ let _suiKeypair: Ed25519Keypair | null = null;
31
+ let _keypairLoaded = false;
32
+
33
+ function getSuiNetwork(): { url: string; network: 'testnet' | 'mainnet' } {
34
+ const tuskyEnv = process.env.TUSKY_ENV || 'production';
35
+ const rpcOverride = process.env.SUI_RPC_URL;
36
+ const network = tuskyEnv === 'development' ? 'testnet' as const : 'mainnet' as const;
37
+ const url = rpcOverride || (network === 'testnet'
38
+ ? 'https://fullnode.testnet.sui.io:443'
39
+ : 'https://fullnode.mainnet.sui.io:443');
40
+ return { url, network };
41
+ }
42
+
43
+ function getSuiClient(): SuiJsonRpcClient {
44
+ if (!_suiClient) {
45
+ const { url, network } = getSuiNetwork();
46
+ _suiClient = new SuiJsonRpcClient({ url, network });
47
+ }
48
+ return _suiClient;
49
+ }
50
+
51
+ /**
52
+ * Load the Sui keypair from TUSKYDP_SUI_PRIVATE_KEY env var.
53
+ * Returns null if the env var is not set.
54
+ * Cached after first call.
55
+ */
56
+ export function getSuiKeypair(): Ed25519Keypair | null {
57
+ if (_keypairLoaded) return _suiKeypair;
58
+ _keypairLoaded = true;
59
+
60
+ const privateKey = getSuiPrivateKey();
61
+ if (!privateKey) return null;
62
+
63
+ try {
64
+ _suiKeypair = Ed25519Keypair.fromSecretKey(privateKey);
65
+ } catch {
66
+ console.error('Invalid TUSKYDP_SUI_PRIVATE_KEY — could not load Ed25519 keypair.');
67
+ return null;
68
+ }
69
+ return _suiKeypair;
70
+ }
71
+
72
+ /**
73
+ * Get the Sui address for the loaded keypair, or null.
74
+ */
75
+ export function getSuiAddress(): string | null {
76
+ const kp = getSuiKeypair();
77
+ return kp ? kp.toSuiAddress() : null;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // SealClient factory
82
+ // ---------------------------------------------------------------------------
83
+
84
+ const _sealClients = new Map<string, SealClient>();
85
+
86
+ function getSealClient(
87
+ serverConfigs: { objectId: string; weight: number }[],
88
+ ): SealClient {
89
+ const cacheKey = serverConfigs.map((c) => c.objectId).join(',');
90
+ let client = _sealClients.get(cacheKey);
91
+ if (!client) {
92
+ client = new SealClient({
93
+ suiClient: getSuiClient() as unknown as SealCompatibleClient,
94
+ serverConfigs,
95
+ verifyKeyServers: true,
96
+ });
97
+ _sealClients.set(cacheKey, client);
98
+ }
99
+ return client;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Identity helpers
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Build SEAL identity bytes for a file in a shared vault.
108
+ * Format: [whitelist_object_id_bytes][file_nonce_hex]
109
+ * Must match the web implementation in packages/web/src/lib/seal.ts.
110
+ */
111
+ export function buildSealId(whitelistObjectId: string, fileNonce: string): string {
112
+ const wlBytes = whitelistObjectId.replace(/^0x/, '');
113
+ const nonceHex = Array.from(new TextEncoder().encode(fileNonce))
114
+ .map((b) => b.toString(16).padStart(2, '0'))
115
+ .join('');
116
+ return wlBytes + nonceHex;
117
+ }
118
+
119
+ /**
120
+ * Check if a vault is SEAL-configured (has the necessary on-chain objects).
121
+ */
122
+ export function isSealConfigured(vault: VaultResponse): boolean {
123
+ return !!(
124
+ vault.visibility === 'shared' &&
125
+ vault.sealAllowlistObjectId &&
126
+ vault.sealPackageId &&
127
+ vault.sealKeyServerIds?.length
128
+ );
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Encrypt
133
+ // ---------------------------------------------------------------------------
134
+
135
+ export interface SealEncryptResult {
136
+ /** The encrypted data (BCS-encoded EncryptedObject) */
137
+ encryptedData: Uint8Array;
138
+ /** The SEAL identity used for encryption */
139
+ sealIdentity: string;
140
+ /** Base64-encoded serialized EncryptedObject metadata (for API storage) */
141
+ sealEncryptedObject: string;
142
+ }
143
+
144
+ /**
145
+ * Encrypt file data using SEAL for a shared vault.
146
+ *
147
+ * Does NOT require a Sui keypair — encryption is purely a client-side
148
+ * operation using the SEAL key servers and vault's allowlist identity.
149
+ */
150
+ export async function sealEncrypt(
151
+ data: Uint8Array,
152
+ vault: VaultResponse,
153
+ fileNonce: string,
154
+ ): Promise<SealEncryptResult> {
155
+ if (!vault.sealAllowlistObjectId || !vault.sealPackageId) {
156
+ throw new Error('Vault is not configured for SEAL encryption (missing allowlist or package ID)');
157
+ }
158
+
159
+ const threshold = vault.sealThreshold ?? 1;
160
+ const serverConfigs = (vault.sealKeyServerIds ?? []).map((id) => ({
161
+ objectId: id,
162
+ weight: 1,
163
+ }));
164
+
165
+ if (serverConfigs.length === 0) {
166
+ throw new Error('No SEAL key servers configured for this vault');
167
+ }
168
+
169
+ const sealClient = getSealClient(serverConfigs);
170
+ const sealIdentity = buildSealId(vault.sealAllowlistObjectId, fileNonce);
171
+
172
+ const { encryptedObject } = await sealClient.encrypt({
173
+ threshold,
174
+ packageId: vault.sealPackageId,
175
+ id: sealIdentity,
176
+ data,
177
+ });
178
+
179
+ // Store compact metadata for the API (same format as web app).
180
+ // The full BCS-encoded EncryptedObject (ciphertext) goes to S3.
181
+ const sealEncryptedObject = Buffer.from(
182
+ JSON.stringify({
183
+ id: sealIdentity,
184
+ packageId: vault.sealPackageId,
185
+ threshold,
186
+ }),
187
+ ).toString('base64');
188
+
189
+ return {
190
+ encryptedData: encryptedObject,
191
+ sealIdentity,
192
+ sealEncryptedObject,
193
+ };
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Decrypt
198
+ // ---------------------------------------------------------------------------
199
+
200
+ const SESSION_TTL_MIN = 10;
201
+
202
+ /**
203
+ * Decrypt SEAL-encrypted file data.
204
+ *
205
+ * Requires a Sui keypair (TUSKYDP_SUI_PRIVATE_KEY) to sign the SessionKey
206
+ * and authorize decryption through the SEAL key servers.
207
+ */
208
+ export async function sealDecrypt(
209
+ encryptedData: Uint8Array,
210
+ vault: VaultResponse,
211
+ keypair: Ed25519Keypair,
212
+ ): Promise<Uint8Array> {
213
+ if (!vault.sealPackageId || !vault.sealAllowlistObjectId) {
214
+ throw new Error('Vault is not configured for SEAL decryption');
215
+ }
216
+
217
+ const serverConfigs = (vault.sealKeyServerIds ?? []).map((id) => ({
218
+ objectId: id,
219
+ weight: 1,
220
+ }));
221
+
222
+ const suiClient = getSuiClient();
223
+ const sealClient = getSealClient(serverConfigs);
224
+ const userAddress = keypair.toSuiAddress();
225
+
226
+ // Parse the encrypted object to extract the identity
227
+ const encryptedObject = EncryptedObject.parse(encryptedData);
228
+
229
+ // Create a session key — in CLI mode we create a fresh one each time
230
+ // (unlike the web app which caches per vault for 10 minutes).
231
+ const sessionKey = await SessionKey.create({
232
+ address: userAddress,
233
+ packageId: vault.sealPackageId,
234
+ ttlMin: SESSION_TTL_MIN,
235
+ suiClient: suiClient as unknown as SealCompatibleClient,
236
+ });
237
+
238
+ // Sign the session key with the Ed25519 keypair
239
+ const personalMessage = sessionKey.getPersonalMessage();
240
+ const { signature } = await keypair.signPersonalMessage(personalMessage);
241
+ await sessionKey.setPersonalMessageSignature(signature);
242
+
243
+ // Build the seal_approve transaction kind bytes
244
+ const tx = new Transaction();
245
+ tx.setSender(userAddress);
246
+ tx.moveCall({
247
+ target: `${vault.sealPackageId}::shared_vault::seal_approve`,
248
+ arguments: [
249
+ tx.pure.vector('u8', Array.from(fromHex(encryptedObject.id))),
250
+ tx.object(vault.sealAllowlistObjectId),
251
+ ],
252
+ });
253
+ const txBytes = await tx.build({
254
+ client: suiClient as any,
255
+ onlyTransactionKind: true,
256
+ });
257
+
258
+ // Decrypt via SEAL key servers
259
+ const decrypted = await sealClient.decrypt({
260
+ data: encryptedData,
261
+ sessionKey,
262
+ txBytes,
263
+ });
264
+
265
+ return decrypted;
266
+ }