@tuskydp/cli 0.1.2 → 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 (51) 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/mcp/context.d.ts +11 -2
  19. package/dist/src/mcp/context.d.ts.map +1 -1
  20. package/dist/src/mcp/context.js +3 -2
  21. package/dist/src/mcp/context.js.map +1 -1
  22. package/dist/src/mcp/server.d.ts.map +1 -1
  23. package/dist/src/mcp/server.js +13 -0
  24. package/dist/src/mcp/server.js.map +1 -1
  25. package/dist/src/mcp/tools/files.d.ts +2 -1
  26. package/dist/src/mcp/tools/files.d.ts.map +1 -1
  27. package/dist/src/mcp/tools/files.js +49 -12
  28. package/dist/src/mcp/tools/files.js.map +1 -1
  29. package/dist/src/mcp/tools/sharedVaults.d.ts +7 -0
  30. package/dist/src/mcp/tools/sharedVaults.d.ts.map +1 -0
  31. package/dist/src/mcp/tools/sharedVaults.js +90 -0
  32. package/dist/src/mcp/tools/sharedVaults.js.map +1 -0
  33. package/dist/src/mcp/tools/vaults.js +1 -1
  34. package/dist/src/mcp/tools/vaults.js.map +1 -1
  35. package/dist/src/seal.d.ts +58 -0
  36. package/dist/src/seal.d.ts.map +1 -0
  37. package/dist/src/seal.js +207 -0
  38. package/dist/src/seal.js.map +1 -0
  39. package/package.json +16 -13
  40. package/src/__tests__/seal.test.ts +357 -0
  41. package/src/commands/account.ts +35 -2
  42. package/src/commands/download.ts +34 -13
  43. package/src/commands/upload.ts +25 -2
  44. package/src/commands/vault.ts +158 -7
  45. package/src/config.ts +8 -0
  46. package/src/mcp/context.ts +13 -2
  47. package/src/mcp/server.ts +13 -0
  48. package/src/mcp/tools/files.ts +61 -19
  49. package/src/mcp/tools/sharedVaults.ts +122 -0
  50. package/src/mcp/tools/vaults.ts +2 -2
  51. package/src/seal.ts +266 -0
@@ -8,6 +8,7 @@ import { formatBytes } from '../lib/output.js';
8
8
  import { createSpinner } from '../lib/progress.js';
9
9
  import { decryptBuffer } from '../crypto.js';
10
10
  import { loadMasterKey } from '../lib/keyring.js';
11
+ import { sealDecrypt, getSuiKeypair } from '../seal.js';
11
12
 
12
13
  export async function downloadCommand(fileId: string, options: {
13
14
  output?: string;
@@ -53,21 +54,41 @@ export async function downloadCommand(fileId: string, options: {
53
54
  const arrayBuf = await response.arrayBuffer();
54
55
  let fileBuffer: Buffer = Buffer.from(new Uint8Array(arrayBuf));
55
56
 
56
- // Decrypt if needed
57
+ // Decrypt if needed — dispatch on encryption type
57
58
  if (encryption?.encrypted) {
58
- spinner.text = 'Decrypting...';
59
- const masterKey = loadMasterKey();
60
- if (!masterKey) {
61
- spinner.fail('Encryption session not unlocked. Run: tusky encryption unlock');
62
- return;
59
+ if ('type' in encryption && encryption.type === 'seal') {
60
+ // SEAL-encrypted (shared vault)
61
+ spinner.text = 'SEAL decrypting...';
62
+ const keypair = getSuiKeypair();
63
+ if (!keypair) {
64
+ spinner.fail(
65
+ 'TUSKYDP_SUI_PRIVATE_KEY env var required to decrypt shared vault files (SEAL encryption).',
66
+ );
67
+ return;
68
+ }
69
+ // Need vault details for SEAL config
70
+ const fileInfo = await sdk.files.get(fileId);
71
+ const vault = await sdk.vaults.get(fileInfo.vaultId);
72
+ const decrypted = await sealDecrypt(new Uint8Array(fileBuffer), vault, keypair);
73
+ fileBuffer = Buffer.from(decrypted);
74
+ } else {
75
+ // Passphrase-encrypted (private vault)
76
+ spinner.text = 'Decrypting...';
77
+ const masterKey = loadMasterKey();
78
+ if (!masterKey) {
79
+ spinner.fail('Encryption session not unlocked. Run: tusky encryption unlock');
80
+ return;
81
+ }
82
+ // Narrow to passphrase type
83
+ const enc = encryption as { type: 'passphrase'; encrypted: true; wrappedKey: string; iv: string; plaintextChecksumSha256: string | null };
84
+ fileBuffer = decryptBuffer(
85
+ fileBuffer,
86
+ enc.wrappedKey,
87
+ enc.iv,
88
+ masterKey,
89
+ enc.plaintextChecksumSha256 ?? undefined,
90
+ );
63
91
  }
64
- fileBuffer = decryptBuffer(
65
- fileBuffer,
66
- encryption.wrappedKey!,
67
- encryption.iv!,
68
- masterKey,
69
- encryption.plaintextChecksumSha256 ?? undefined,
70
- );
71
92
  }
72
93
 
73
94
  // Write to disk — use getStatus to get filename
@@ -1,5 +1,6 @@
1
1
  import type { Command } from 'commander';
2
2
  import { readFileSync, statSync, readdirSync } from 'fs';
3
+ import { randomUUID } from 'crypto';
3
4
  import { basename, join, resolve } from 'path';
4
5
  import { lookup } from 'mime-types';
5
6
  import chalk from 'chalk';
@@ -10,6 +11,7 @@ import { formatBytes } from '../lib/output.js';
10
11
  import { createSpinner } from '../lib/progress.js';
11
12
  import { encryptBuffer } from '../crypto.js';
12
13
  import { loadMasterKey } from '../lib/keyring.js';
14
+ import { isSealConfigured, sealEncrypt, getSuiKeypair } from '../seal.js';
13
15
 
14
16
  async function expandPaths(paths: string[], recursive?: boolean): Promise<string[]> {
15
17
  const result: string[] = [];
@@ -50,6 +52,7 @@ export async function uploadCommand(paths: string[], options: {
50
52
  const vaultId = await resolveVault(sdk, options.vault);
51
53
  const vault = await sdk.vaults.get(vaultId);
52
54
  const isPrivate = vault.visibility === 'private';
55
+ const isShared = vault.visibility === 'shared' && isSealConfigured(vault);
53
56
 
54
57
  let masterKey: Buffer | null = null;
55
58
  if (isPrivate) {
@@ -60,6 +63,14 @@ export async function uploadCommand(paths: string[], options: {
60
63
  }
61
64
  }
62
65
 
66
+ if (isShared) {
67
+ const keypair = getSuiKeypair();
68
+ if (!keypair) {
69
+ console.error(chalk.red('TUSKYDP_SUI_PRIVATE_KEY env var required for shared vault uploads (SEAL encryption).'));
70
+ process.exit(1);
71
+ }
72
+ }
73
+
63
74
  const filePaths = await expandPaths(paths, options.recursive);
64
75
  if (filePaths.length === 0) {
65
76
  console.log(chalk.yellow('No files to upload.'));
@@ -84,6 +95,8 @@ export async function uploadCommand(paths: string[], options: {
84
95
  encryptionIv?: string;
85
96
  plaintextSizeBytes?: number;
86
97
  plaintextChecksumSha256?: string;
98
+ sealIdentity?: string;
99
+ sealEncryptedObject?: string;
87
100
  } = {};
88
101
 
89
102
  if (isPrivate && masterKey) {
@@ -96,6 +109,16 @@ export async function uploadCommand(paths: string[], options: {
96
109
  plaintextSizeBytes: stat.size,
97
110
  plaintextChecksumSha256: plaintextChecksum,
98
111
  };
112
+ } else if (isShared) {
113
+ spinner.text = `SEAL encrypting ${basename(filePath)}...`;
114
+ const fileNonce = randomUUID();
115
+ const sealResult = await sealEncrypt(new Uint8Array(fileBuffer), vault, fileNonce);
116
+ uploadBody = Buffer.from(sealResult.encryptedData);
117
+ encryptionMeta = {
118
+ sealIdentity: sealResult.sealIdentity,
119
+ sealEncryptedObject: sealResult.sealEncryptedObject,
120
+ plaintextSizeBytes: stat.size,
121
+ };
99
122
  } else {
100
123
  uploadBody = fileBuffer;
101
124
  }
@@ -137,7 +160,7 @@ export async function uploadCommand(paths: string[], options: {
137
160
  }
138
161
 
139
162
  if (filePaths.length > 1) {
140
- const icon = isPrivate ? 'private' : 'public';
141
- console.log(`\nUploaded ${successCount} file(s) (${formatBytes(totalSize)}) to vault "${vault.name}" [${icon}]`);
163
+ const label = isPrivate ? 'private' : isShared ? 'shared' : 'public';
164
+ console.log(`\nUploaded ${successCount} file(s) (${formatBytes(totalSize)}) to vault "${vault.name}" [${label}]`);
142
165
  }
143
166
  }
@@ -3,19 +3,35 @@ import chalk from 'chalk';
3
3
  import inquirer from 'inquirer';
4
4
  import { cliConfig } from '../config.js';
5
5
  import { getSDKClientFromParent } from '../sdk.js';
6
- import { createTable, formatBytes, shortId } from '../lib/output.js';
6
+ import { createTable, formatBytes, formatDate, shortId } from '../lib/output.js';
7
7
  import { resolveVault } from '../lib/resolve.js';
8
8
 
9
+ /** Map vault visibility to a display label. */
10
+ function visibilityLabel(v: string): string {
11
+ if (v === 'private') return 'private';
12
+ if (v === 'shared') return 'shared';
13
+ return 'public';
14
+ }
15
+
9
16
  export function registerVaultCommands(program: Command) {
10
17
  const vault = program.command('vault').description('Manage vaults');
11
18
 
19
+ // ── create ──────────────────────────────────────────────────────────
12
20
  vault.command('create <name>')
13
21
  .description('Create a new vault (private/encrypted by default)')
14
22
  .option('--public', 'Create as public vault (unencrypted, shareable via URL)')
23
+ .option('--shared', 'Create as shared vault (SEAL-encrypted, requires linked Sui address)')
15
24
  .option('--description <text>', 'Vault description')
16
25
  .action(async (name: string, options) => {
17
26
  const sdk = getSDKClientFromParent(vault);
18
- const visibility = options.public ? 'public' as const : 'private' as const;
27
+ let visibility: 'public' | 'private' | 'shared' = 'private';
28
+ if (options.public) visibility = 'public';
29
+ if (options.shared) visibility = 'shared';
30
+
31
+ if (options.public && options.shared) {
32
+ console.error(chalk.red('Cannot use both --public and --shared.'));
33
+ return;
34
+ }
19
35
 
20
36
  if (visibility === 'private') {
21
37
  try {
@@ -30,11 +46,28 @@ export function registerVaultCommands(program: Command) {
30
46
  }
31
47
  }
32
48
 
49
+ if (visibility === 'shared') {
50
+ try {
51
+ const acct = await sdk.account.get();
52
+ if (!(acct as any).suiAddress) {
53
+ console.error(chalk.red('A Sui address must be linked to create shared vaults.'));
54
+ console.error(chalk.dim(' Run: tusky account link-sui <address>'));
55
+ return;
56
+ }
57
+ } catch {
58
+ // Proceed and let the API return the error
59
+ }
60
+ }
61
+
33
62
  const created = await sdk.vaults.create({ name, description: options.description, visibility });
34
- const icon = visibility === 'private' ? 'private' : 'public';
35
- console.log(chalk.green(`Created ${visibility} vault [${icon}] "${created.name}" (${created.id})`));
63
+ console.log(chalk.green(`Created ${visibility} vault [${visibilityLabel(visibility)}] "${created.name}" (${created.id})`));
64
+
65
+ if (visibility === 'shared' && created.sealAllowlistObjectId) {
66
+ console.log(chalk.dim(` SEAL allowlist: ${created.sealAllowlistObjectId}`));
67
+ }
36
68
  });
37
69
 
70
+ // ── list ────────────────────────────────────────────────────────────
38
71
  vault.command('list')
39
72
  .description('List all vaults')
40
73
  .action(async () => {
@@ -54,11 +87,10 @@ export function registerVaultCommands(program: Command) {
54
87
 
55
88
  const table = createTable(['Name', 'Slug', 'Visibility', 'Files', 'Size', 'ID']);
56
89
  for (const v of vaults) {
57
- const visIcon = v.visibility === 'private' ? 'private' : 'public';
58
90
  table.push([
59
91
  v.isDefault ? `${v.name} ${chalk.dim('(default)')}` : v.name,
60
92
  v.slug,
61
- visIcon,
93
+ visibilityLabel(v.visibility),
62
94
  String(v.fileCount),
63
95
  formatBytes(v.totalSizeBytes),
64
96
  chalk.dim(shortId(v.id)),
@@ -67,6 +99,40 @@ export function registerVaultCommands(program: Command) {
67
99
  console.log(table.toString());
68
100
  });
69
101
 
102
+ // ── shared ──────────────────────────────────────────────────────────
103
+ vault.command('shared')
104
+ .description('List shared vaults you are a member of (not owner)')
105
+ .action(async () => {
106
+ const sdk = getSDKClientFromParent(vault);
107
+ const format = program.opts().format || cliConfig.get('outputFormat');
108
+ const { vaults } = await sdk.sharedVaults.list();
109
+
110
+ if (format === 'json') {
111
+ console.log(JSON.stringify(vaults, null, 2));
112
+ return;
113
+ }
114
+
115
+ if (vaults.length === 0) {
116
+ console.log(chalk.dim('No shared vault memberships found.'));
117
+ return;
118
+ }
119
+
120
+ const table = createTable(['Name', 'Slug', 'Role', 'Files', 'Size', 'Granted', 'ID']);
121
+ for (const entry of vaults) {
122
+ table.push([
123
+ entry.vault.name,
124
+ entry.vault.slug,
125
+ entry.role,
126
+ String(entry.vault.fileCount),
127
+ formatBytes(entry.vault.totalSizeBytes),
128
+ formatDate(entry.grantedAt),
129
+ chalk.dim(shortId(entry.vault.id)),
130
+ ]);
131
+ }
132
+ console.log(table.toString());
133
+ });
134
+
135
+ // ── info ────────────────────────────────────────────────────────────
70
136
  vault.command('info <vault>')
71
137
  .description('Show vault details')
72
138
  .action(async (vaultRef: string) => {
@@ -82,15 +148,47 @@ export function registerVaultCommands(program: Command) {
82
148
 
83
149
  console.log(`Name: ${v.name}`);
84
150
  console.log(`Slug: ${v.slug}`);
85
- console.log(`Visibility: ${v.visibility === 'private' ? 'private' : 'public'}`);
151
+ console.log(`Visibility: ${visibilityLabel(v.visibility)}`);
86
152
  console.log(`Description: ${v.description || chalk.dim('(none)')}`);
87
153
  console.log(`Files: ${v.fileCount}`);
88
154
  console.log(`Total Size: ${formatBytes(v.totalSizeBytes)}`);
89
155
  console.log(`Default: ${v.isDefault ? 'Yes' : 'No'}`);
90
156
  console.log(`Created: ${new Date(v.createdAt).toLocaleString()}`);
91
157
  console.log(`ID: ${v.id}`);
158
+
159
+ if (v.visibility === 'shared') {
160
+ console.log('');
161
+ console.log(chalk.bold('SEAL Configuration:'));
162
+ console.log(` Allowlist: ${v.sealAllowlistObjectId || chalk.dim('(none)')}`);
163
+ console.log(` Package: ${v.sealPackageId || chalk.dim('(none)')}`);
164
+ console.log(` Threshold: ${v.sealThreshold ?? chalk.dim('(none)')}`);
165
+ console.log(` Key Servers: ${v.sealKeyServerIds?.length ?? 0}`);
166
+ }
167
+ });
168
+
169
+ // ── update ──────────────────────────────────────────────────────────
170
+ vault.command('update <vault>')
171
+ .description('Update vault name and/or description')
172
+ .option('--name <name>', 'New vault name')
173
+ .option('--description <text>', 'New vault description')
174
+ .action(async (vaultRef: string, options) => {
175
+ const sdk = getSDKClientFromParent(vault);
176
+ const vaultId = await resolveVault(sdk, vaultRef);
177
+
178
+ if (!options.name && !options.description) {
179
+ console.error(chalk.red('Provide --name and/or --description to update.'));
180
+ return;
181
+ }
182
+
183
+ const params: { name?: string; description?: string } = {};
184
+ if (options.name) params.name = options.name;
185
+ if (options.description) params.description = options.description;
186
+
187
+ await sdk.vaults.update(vaultId, params);
188
+ console.log(chalk.green('Vault updated.'));
92
189
  });
93
190
 
191
+ // ── rename (alias for update --name) ────────────────────────────────
94
192
  vault.command('rename <vault> <new-name>')
95
193
  .description('Rename a vault')
96
194
  .action(async (vaultRef: string, newName: string) => {
@@ -100,6 +198,7 @@ export function registerVaultCommands(program: Command) {
100
198
  console.log(chalk.green(`Vault renamed to "${newName}"`));
101
199
  });
102
200
 
201
+ // ── delete ──────────────────────────────────────────────────────────
103
202
  vault.command('delete <vault>')
104
203
  .description('Delete a vault')
105
204
  .option('--force', 'Delete vault and all its files')
@@ -121,6 +220,7 @@ export function registerVaultCommands(program: Command) {
121
220
  console.log(chalk.green('Vault deleted.'));
122
221
  });
123
222
 
223
+ // ── set-default ─────────────────────────────────────────────────────
124
224
  vault.command('set-default <vault>')
125
225
  .description('Set the default vault for uploads')
126
226
  .action(async (vaultRef: string) => {
@@ -129,4 +229,55 @@ export function registerVaultCommands(program: Command) {
129
229
  cliConfig.set('defaultVault', vaultId);
130
230
  console.log(chalk.green(`Default vault set to ${vaultRef}`));
131
231
  });
232
+
233
+ // ── members ─────────────────────────────────────────────────────────
234
+ const members = vault.command('members').description('Manage shared vault members');
235
+
236
+ members.command('list <vault>')
237
+ .description('List members of a shared vault')
238
+ .action(async (vaultRef: string) => {
239
+ const sdk = getSDKClientFromParent(vault);
240
+ const format = program.opts().format || cliConfig.get('outputFormat');
241
+ const vaultId = await resolveVault(sdk, vaultRef);
242
+ const { members: memberList } = await sdk.sharedVaults.listMembers(vaultId);
243
+
244
+ if (format === 'json') {
245
+ console.log(JSON.stringify(memberList, null, 2));
246
+ return;
247
+ }
248
+
249
+ if (memberList.length === 0) {
250
+ console.log(chalk.dim('No members found.'));
251
+ return;
252
+ }
253
+
254
+ const table = createTable(['Sui Address', 'Role', 'Granted', 'Member ID']);
255
+ for (const m of memberList) {
256
+ table.push([
257
+ m.suiAddress,
258
+ m.role,
259
+ formatDate(m.grantedAt),
260
+ chalk.dim(shortId(m.id)),
261
+ ]);
262
+ }
263
+ console.log(table.toString());
264
+ });
265
+
266
+ members.command('add <vault> <sui-address>')
267
+ .description('Grant access to a shared vault by Sui address')
268
+ .action(async (vaultRef: string, suiAddress: string) => {
269
+ const sdk = getSDKClientFromParent(vault);
270
+ const vaultId = await resolveVault(sdk, vaultRef);
271
+ const { member } = await sdk.sharedVaults.addMember(vaultId, { suiAddress });
272
+ console.log(chalk.green(`Access granted to ${suiAddress} (member ID: ${member.id})`));
273
+ });
274
+
275
+ members.command('remove <vault> <member-id>')
276
+ .description('Revoke access from a shared vault member')
277
+ .action(async (vaultRef: string, memberId: string) => {
278
+ const sdk = getSDKClientFromParent(vault);
279
+ const vaultId = await resolveVault(sdk, vaultRef);
280
+ await sdk.sharedVaults.removeMember(vaultId, memberId);
281
+ console.log(chalk.green(`Access revoked for member ${memberId}`));
282
+ });
132
283
  }
package/src/config.ts CHANGED
@@ -36,3 +36,11 @@ export function getOutputFormat(override?: string): 'table' | 'json' | 'plain' {
36
36
  if (override && VALID_FORMATS.has(override)) return override as 'table' | 'json' | 'plain';
37
37
  return cliConfig.get('outputFormat');
38
38
  }
39
+
40
+ /**
41
+ * Get the Sui private key for SEAL operations on shared vaults.
42
+ * Only read from env var (never persisted to config for security).
43
+ */
44
+ export function getSuiPrivateKey(): string | null {
45
+ return process.env.TUSKYDP_SUI_PRIVATE_KEY || null;
46
+ }
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Shared context for MCP tool handlers.
3
3
  *
4
- * Holds the authenticated SDK client and (optionally) the unlocked
5
- * master key for client-side encryption/decryption of private vault files.
4
+ * Holds the authenticated SDK client, the unlocked master key for
5
+ * private vault encryption, and the Sui keypair for shared vault
6
+ * SEAL encryption/decryption.
6
7
  */
7
8
 
8
9
  import type { TuskyClient } from '@tuskydp/sdk';
10
+ import type { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
9
11
 
10
12
  export interface McpContext {
11
13
  /** Authenticated Tusky SDK client. */
@@ -19,4 +21,13 @@ export interface McpContext {
19
21
 
20
22
  /** True when the master key is available for encrypt/decrypt operations. */
21
23
  isEncryptionReady(): boolean;
24
+
25
+ /**
26
+ * Returns the Sui Ed25519 keypair for SEAL operations, or null if
27
+ * TUSKYDP_SUI_PRIVATE_KEY was not provided.
28
+ */
29
+ getSuiKeypair(): Ed25519Keypair | null;
30
+
31
+ /** True when the Sui keypair is available for shared vault operations. */
32
+ isSealReady(): boolean;
22
33
  }
package/src/mcp/server.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  unwrapMasterKey,
16
16
  } from '../crypto.js';
17
17
  import { loadMasterKey } from '../lib/keyring.js';
18
+ import { getSuiKeypair } from '../seal.js';
18
19
  import type { McpContext } from './context.js';
19
20
 
20
21
  // Tool registrars
@@ -23,6 +24,7 @@ import { registerVaultTools } from './tools/vaults.js';
23
24
  import { registerFolderTools } from './tools/folders.js';
24
25
  import { registerFileTools } from './tools/files.js';
25
26
  import { registerTrashTools } from './tools/trash.js';
27
+ import { registerSharedVaultTools } from './tools/sharedVaults.js';
26
28
 
27
29
  // ---------------------------------------------------------------------------
28
30
  // Encryption bootstrap
@@ -113,11 +115,21 @@ export async function startMcpServer(options: McpServerOptions): Promise<void> {
113
115
  console.error('[tusky-mcp] Set TUSKYDP_PASSWORD env var or run `tusky encryption unlock` first.');
114
116
  }
115
117
 
118
+ // Load Sui keypair for SEAL shared vault operations (best effort)
119
+ const suiKeypair = getSuiKeypair();
120
+ if (suiKeypair) {
121
+ console.error(`[tusky-mcp] Sui wallet loaded (${suiKeypair.toSuiAddress()}) — shared vault operations available.`);
122
+ } else {
123
+ console.error('[tusky-mcp] No TUSKYDP_SUI_PRIVATE_KEY — shared vault encrypt/decrypt unavailable.');
124
+ }
125
+
116
126
  // Build context
117
127
  const ctx: McpContext = {
118
128
  sdk,
119
129
  getMasterKey: () => masterKey,
120
130
  isEncryptionReady: () => masterKey !== null,
131
+ getSuiKeypair: () => suiKeypair,
132
+ isSealReady: () => suiKeypair !== null,
121
133
  };
122
134
 
123
135
  // Create MCP server
@@ -132,6 +144,7 @@ export async function startMcpServer(options: McpServerOptions): Promise<void> {
132
144
  registerFolderTools(server, ctx);
133
145
  registerFileTools(server, ctx);
134
146
  registerTrashTools(server, ctx);
147
+ registerSharedVaultTools(server, ctx);
135
148
 
136
149
  // Connect via stdio transport
137
150
  const transport = new StdioServerTransport();
@@ -2,16 +2,19 @@
2
2
  * MCP tools — File operations
3
3
  *
4
4
  * Handles upload with client-side encryption for private vaults,
5
- * download with decryption and rehydration polling for cold files.
5
+ * SEAL encryption for shared vaults, download with decryption and
6
+ * rehydration polling for cold files.
6
7
  */
7
8
 
8
9
  import { z } from 'zod';
10
+ import { randomUUID } from 'crypto';
9
11
  import { readFileSync, writeFileSync, statSync } from 'fs';
10
12
  import { basename, resolve, join } from 'path';
11
13
  import { lookup } from 'mime-types';
12
14
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
15
  import type { McpContext } from '../context.js';
14
16
  import { encryptBuffer, decryptBuffer } from '../../crypto.js';
17
+ import { isSealConfigured, sealEncrypt, sealDecrypt } from '../../seal.js';
15
18
  import { wrapToolError } from './helpers.js';
16
19
 
17
20
  // ---------------------------------------------------------------------------
@@ -76,22 +79,39 @@ async function fetchAndDecryptFile(fileId: string, ctx: McpContext) {
76
79
  const arrayBuf = await response.arrayBuffer();
77
80
  let fileBuffer: Buffer = Buffer.from(arrayBuf as ArrayBuffer);
78
81
 
79
- // Decrypt if needed
82
+ // Decrypt if needed — dispatch on encryption type
80
83
  if (encryption?.encrypted) {
81
- const masterKey = ctx.getMasterKey();
82
- if (!masterKey) {
83
- throw new Error(
84
- 'Encryption passphrase required to decrypt this file. ' +
85
- 'Set the TUSKYDP_PASSWORD environment variable in your MCP server config.',
84
+ if ('type' in encryption && encryption.type === 'seal') {
85
+ // SEAL-encrypted (shared vault)
86
+ const keypair = ctx.getSuiKeypair();
87
+ if (!keypair) {
88
+ throw new Error(
89
+ 'Sui wallet key required to decrypt shared vault files. ' +
90
+ 'Set the TUSKYDP_SUI_PRIVATE_KEY environment variable in your MCP server config.',
91
+ );
92
+ }
93
+ const fileInfo = await ctx.sdk.files.get(fileId);
94
+ const vault = await ctx.sdk.vaults.get(fileInfo.vaultId);
95
+ const decrypted = await sealDecrypt(new Uint8Array(fileBuffer), vault, keypair);
96
+ fileBuffer = Buffer.from(decrypted);
97
+ } else {
98
+ // Passphrase-encrypted (private vault)
99
+ const masterKey = ctx.getMasterKey();
100
+ if (!masterKey) {
101
+ throw new Error(
102
+ 'Encryption passphrase required to decrypt this file. ' +
103
+ 'Set the TUSKYDP_PASSWORD environment variable in your MCP server config.',
104
+ );
105
+ }
106
+ const enc = encryption as { type: 'passphrase'; encrypted: true; wrappedKey: string; iv: string; plaintextChecksumSha256: string | null };
107
+ fileBuffer = decryptBuffer(
108
+ fileBuffer,
109
+ enc.wrappedKey,
110
+ enc.iv,
111
+ masterKey,
112
+ enc.plaintextChecksumSha256 ?? undefined,
86
113
  );
87
114
  }
88
- fileBuffer = decryptBuffer(
89
- fileBuffer,
90
- encryption.wrappedKey!,
91
- encryption.iv!,
92
- masterKey,
93
- encryption.plaintextChecksumSha256 ?? undefined,
94
- );
95
115
  }
96
116
 
97
117
  return { fileBuffer, encryption };
@@ -111,6 +131,7 @@ async function encryptAndUpload(
111
131
  ) {
112
132
  const vault = await ctx.sdk.vaults.get(vaultId);
113
133
  const isPrivate = vault.visibility === 'private';
134
+ const isShared = vault.visibility === 'shared' && isSealConfigured(vault);
114
135
 
115
136
  let uploadBody: Buffer;
116
137
  let encryptionMeta: {
@@ -118,6 +139,8 @@ async function encryptAndUpload(
118
139
  encryptionIv?: string;
119
140
  plaintextSizeBytes?: number;
120
141
  plaintextChecksumSha256?: string;
142
+ sealIdentity?: string;
143
+ sealEncryptedObject?: string;
121
144
  } = {};
122
145
 
123
146
  if (isPrivate) {
@@ -137,6 +160,23 @@ async function encryptAndUpload(
137
160
  plaintextSizeBytes: fileBuffer.length,
138
161
  plaintextChecksumSha256: plaintextChecksum,
139
162
  };
163
+ } else if (isShared) {
164
+ const keypair = ctx.getSuiKeypair();
165
+ if (!keypair) {
166
+ throw new Error(
167
+ 'Sui wallet key required for shared vault uploads (SEAL encryption). ' +
168
+ 'Set the TUSKYDP_SUI_PRIVATE_KEY environment variable in your MCP server config.',
169
+ );
170
+ }
171
+
172
+ const fileNonce = randomUUID();
173
+ const sealResult = await sealEncrypt(new Uint8Array(fileBuffer), vault, fileNonce);
174
+ uploadBody = Buffer.from(sealResult.encryptedData);
175
+ encryptionMeta = {
176
+ sealIdentity: sealResult.sealIdentity,
177
+ sealEncryptedObject: sealResult.sealEncryptedObject,
178
+ plaintextSizeBytes: fileBuffer.length,
179
+ };
140
180
  } else {
141
181
  uploadBody = fileBuffer;
142
182
  }
@@ -165,7 +205,7 @@ async function encryptAndUpload(
165
205
  // Confirm upload
166
206
  const file = await ctx.sdk.files.confirmUpload(fileId);
167
207
 
168
- return { file, isPrivate, uploadedSizeBytes: uploadBody.length };
208
+ return { file, isPrivate, isShared, uploadedSizeBytes: uploadBody.length };
169
209
  }
170
210
 
171
211
  // ---------------------------------------------------------------------------
@@ -201,7 +241,7 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
201
241
  const fileName = basename(resolvedPath);
202
242
  const mimeType = lookup(resolvedPath) || 'application/octet-stream';
203
243
 
204
- const { file, isPrivate, uploadedSizeBytes } = await encryptAndUpload(
244
+ const { file, isPrivate, isShared, uploadedSizeBytes } = await encryptAndUpload(
205
245
  fileBuffer, fileName, mimeType, vaultId, folderId, ctx,
206
246
  );
207
247
 
@@ -209,7 +249,8 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
209
249
  content: [{ type: 'text' as const, text: JSON.stringify({
210
250
  ...file,
211
251
  localPath: resolvedPath,
212
- encrypted: isPrivate,
252
+ encrypted: isPrivate || isShared,
253
+ encryptionType: isPrivate ? 'passphrase' : isShared ? 'seal' : 'none',
213
254
  uploadedSizeBytes,
214
255
  }, null, 2) }],
215
256
  };
@@ -241,14 +282,15 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
241
282
 
242
283
  const mimeType = lookup(name) || 'application/octet-stream';
243
284
 
244
- const { file, isPrivate, uploadedSizeBytes } = await encryptAndUpload(
285
+ const { file, isPrivate, isShared, uploadedSizeBytes } = await encryptAndUpload(
245
286
  fileBuffer, name, mimeType, vaultId, folderId, ctx,
246
287
  );
247
288
 
248
289
  return {
249
290
  content: [{ type: 'text' as const, text: JSON.stringify({
250
291
  ...file,
251
- encrypted: isPrivate,
292
+ encrypted: isPrivate || isShared,
293
+ encryptionType: isPrivate ? 'passphrase' : isShared ? 'seal' : 'none',
252
294
  uploadedSizeBytes,
253
295
  }, null, 2) }],
254
296
  };