@tuskydp/cli 0.1.0 → 0.1.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 (87) hide show
  1. package/bin/tuskydp.ts +2 -0
  2. package/dist/src/commands/account.d.ts.map +1 -1
  3. package/dist/src/commands/account.js +5 -2
  4. package/dist/src/commands/account.js.map +1 -1
  5. package/dist/src/commands/auth.d.ts.map +1 -1
  6. package/dist/src/commands/auth.js +2 -1
  7. package/dist/src/commands/auth.js.map +1 -1
  8. package/dist/src/commands/files.d.ts.map +1 -1
  9. package/dist/src/commands/files.js +9 -4
  10. package/dist/src/commands/files.js.map +1 -1
  11. package/dist/src/commands/mcp.js +1 -1
  12. package/dist/src/commands/mcp.js.map +1 -1
  13. package/dist/src/commands/rehydrate.d.ts.map +1 -1
  14. package/dist/src/commands/rehydrate.js +5 -2
  15. package/dist/src/commands/rehydrate.js.map +1 -1
  16. package/dist/src/commands/upload.d.ts.map +1 -1
  17. package/dist/src/commands/upload.js +5 -0
  18. package/dist/src/commands/upload.js.map +1 -1
  19. package/dist/src/config.d.ts +0 -2
  20. package/dist/src/config.d.ts.map +1 -1
  21. package/dist/src/config.js +5 -4
  22. package/dist/src/config.js.map +1 -1
  23. package/dist/src/index.js +16 -2
  24. package/dist/src/index.js.map +1 -1
  25. package/dist/src/lib/keyring.d.ts.map +1 -1
  26. package/dist/src/lib/keyring.js +3 -5
  27. package/dist/src/lib/keyring.js.map +1 -1
  28. package/dist/src/lib/output.js +1 -1
  29. package/dist/src/lib/output.js.map +1 -1
  30. package/dist/src/lib/resolve.js +1 -1
  31. package/dist/src/lib/resolve.js.map +1 -1
  32. package/dist/src/mcp/tools/files.d.ts.map +1 -1
  33. package/dist/src/mcp/tools/files.js +20 -0
  34. package/dist/src/mcp/tools/files.js.map +1 -1
  35. package/dist/src/mcp/tools/folders.d.ts.map +1 -1
  36. package/dist/src/mcp/tools/folders.js +15 -0
  37. package/dist/src/mcp/tools/folders.js.map +1 -1
  38. package/dist/src/mcp/tools/trash.d.ts.map +1 -1
  39. package/dist/src/mcp/tools/trash.js +14 -0
  40. package/dist/src/mcp/tools/trash.js.map +1 -1
  41. package/dist/src/sdk.d.ts +1 -1
  42. package/dist/src/sdk.d.ts.map +1 -1
  43. package/dist/src/sdk.js +3 -3
  44. package/dist/src/sdk.js.map +1 -1
  45. package/dist/src/tui/auth-screen.d.ts.map +1 -1
  46. package/dist/src/tui/auth-screen.js +7 -1
  47. package/dist/src/tui/auth-screen.js.map +1 -1
  48. package/package.json +12 -18
  49. package/src/__tests__/crypto.test.ts +315 -0
  50. package/src/commands/account.ts +82 -0
  51. package/src/commands/auth.ts +190 -0
  52. package/src/commands/decrypt.ts +276 -0
  53. package/src/commands/download.ts +82 -0
  54. package/src/commands/encryption.ts +305 -0
  55. package/src/commands/export.ts +251 -0
  56. package/src/commands/files.ts +192 -0
  57. package/src/commands/mcp.ts +220 -0
  58. package/src/commands/rehydrate.ts +37 -0
  59. package/src/commands/tui.ts +11 -0
  60. package/src/commands/upload.ts +143 -0
  61. package/src/commands/vault.ts +132 -0
  62. package/src/config.ts +38 -0
  63. package/src/crypto.ts +130 -0
  64. package/src/index.ts +79 -0
  65. package/src/lib/keyring.ts +50 -0
  66. package/src/lib/output.ts +36 -0
  67. package/src/lib/progress.ts +5 -0
  68. package/src/lib/resolve.ts +26 -0
  69. package/src/mcp/context.ts +22 -0
  70. package/src/mcp/server.ts +140 -0
  71. package/src/mcp/tools/account.ts +40 -0
  72. package/src/mcp/tools/files.ts +428 -0
  73. package/src/mcp/tools/folders.ts +109 -0
  74. package/src/mcp/tools/helpers.ts +28 -0
  75. package/src/mcp/tools/trash.ts +82 -0
  76. package/src/mcp/tools/vaults.ts +114 -0
  77. package/src/sdk.ts +115 -0
  78. package/src/tui/auth-screen.ts +176 -0
  79. package/src/tui/dialogs.ts +339 -0
  80. package/src/tui/files-panel.ts +165 -0
  81. package/src/tui/helpers.ts +206 -0
  82. package/src/tui/index.ts +420 -0
  83. package/src/tui/overview.ts +155 -0
  84. package/src/tui/status-bar.ts +61 -0
  85. package/src/tui/vaults-panel.ts +143 -0
  86. package/tsconfig.json +9 -0
  87. package/vitest.config.ts +7 -0
@@ -0,0 +1,305 @@
1
+ import type { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import inquirer from 'inquirer';
4
+ import { getSDKClientFromParent } from '../sdk.js';
5
+ import {
6
+ deriveMasterKey,
7
+ computeVerifier,
8
+ verifyPassphrase,
9
+ generateSalt,
10
+ generateMasterKey,
11
+ generateRecoveryKey,
12
+ wrapMasterKey,
13
+ unwrapMasterKey,
14
+ } from '../crypto.js';
15
+ import { storeMasterKey, loadMasterKey, clearSession } from '../lib/keyring.js';
16
+
17
+ export function registerEncryptionCommands(program: Command) {
18
+ const encryption = program.command('encryption').description('Manage E2E encryption');
19
+
20
+ encryption.command('setup')
21
+ .description('Set encryption passphrase (first time only)')
22
+ .action(async () => {
23
+ const sdk = getSDKClientFromParent(encryption);
24
+
25
+ // Check if already set up
26
+ const { setupComplete } = await sdk.account.getEncryptionParams();
27
+ if (setupComplete) {
28
+ console.log(chalk.yellow('Encryption is already set up.'));
29
+ console.log(chalk.dim('Use: tusky encryption change-passphrase'));
30
+ return;
31
+ }
32
+
33
+ // Get passphrase
34
+ const answers = await inquirer.prompt([
35
+ {
36
+ type: 'password',
37
+ name: 'passphrase',
38
+ message: 'Enter encryption passphrase:',
39
+ mask: '*',
40
+ validate: (input: string) => input.length >= 8 || 'Passphrase must be at least 8 characters',
41
+ },
42
+ {
43
+ type: 'password',
44
+ name: 'confirm',
45
+ message: 'Confirm passphrase:',
46
+ mask: '*',
47
+ },
48
+ ]);
49
+
50
+ if (answers.passphrase !== answers.confirm) {
51
+ console.error(chalk.red('Passphrases do not match.'));
52
+ return;
53
+ }
54
+
55
+ // Generate random master key and derive wrapping key from passphrase
56
+ const masterKey = generateMasterKey();
57
+ const salt = generateSalt();
58
+ const wrappingKey = deriveMasterKey(answers.passphrase, salt);
59
+ const verifier = computeVerifier(wrappingKey);
60
+
61
+ // Wrap master key with wrapping key (for passphrase unlock)
62
+ const encryptedMasterKey = wrapMasterKey(masterKey, wrappingKey);
63
+
64
+ // Wrap master key with recovery key (for recovery flow)
65
+ const recoveryKeyRaw = generateRecoveryKey();
66
+ const wrappedMasterKeyBackup = wrapMasterKey(masterKey, recoveryKeyRaw);
67
+ const recoveryKeyBase64 = recoveryKeyRaw.toString('base64');
68
+
69
+ // Send to server
70
+ await sdk.account.setupEncryption({
71
+ salt: salt.toString('base64'),
72
+ verifier: verifier.toString('base64'),
73
+ encryptedMasterKey: encryptedMasterKey.toString('base64'),
74
+ wrappedMasterKeyBackup: wrappedMasterKeyBackup.toString('base64'),
75
+ });
76
+
77
+ // Store master key in session
78
+ storeMasterKey(masterKey);
79
+
80
+ // Display recovery key
81
+ console.log('');
82
+ console.log(chalk.yellow.bold('SAVE YOUR RECOVERY KEY — it will not be shown again:'));
83
+ console.log('');
84
+ console.log(chalk.bold(` ${recoveryKeyBase64}`));
85
+ console.log('');
86
+
87
+ const confirmSaved = await inquirer.prompt([{
88
+ type: 'confirm',
89
+ name: 'saved',
90
+ message: 'Have you saved your recovery key?',
91
+ default: false,
92
+ }]);
93
+
94
+ if (!confirmSaved.saved) {
95
+ console.log(chalk.yellow('Please save your recovery key before continuing!'));
96
+ console.log(chalk.bold(` ${recoveryKeyBase64}`));
97
+ }
98
+
99
+ console.log(chalk.green('Encryption configured successfully.'));
100
+ });
101
+
102
+ encryption.command('unlock')
103
+ .description('Unlock encryption for this session')
104
+ .option('--passphrase <passphrase>', 'Passphrase (non-interactive; also reads TUSKYDP_PASSWORD env var)')
105
+ .action(async (options: { passphrase?: string }) => {
106
+ const sdk = getSDKClientFromParent(encryption);
107
+
108
+ const { setupComplete, salt, verifier, encryptedMasterKey } = await sdk.account.getEncryptionParams();
109
+ if (!setupComplete) {
110
+ console.error(chalk.red('Encryption not set up. Run: tusky encryption setup'));
111
+ return;
112
+ }
113
+
114
+ // Resolve passphrase: flag > env var > interactive prompt
115
+ let passphrase = options.passphrase ?? process.env.TUSKYDP_PASSWORD;
116
+ if (!passphrase) {
117
+ const answers = await inquirer.prompt([{
118
+ type: 'password',
119
+ name: 'passphrase',
120
+ message: 'Enter encryption passphrase:',
121
+ mask: '*',
122
+ }]);
123
+ passphrase = answers.passphrase as string;
124
+ }
125
+
126
+ const saltBuffer = Buffer.from(salt!, 'base64');
127
+ const verifierBuffer = Buffer.from(verifier!, 'base64');
128
+ const wrappingKey = deriveMasterKey(passphrase, saltBuffer);
129
+
130
+ if (!verifyPassphrase(wrappingKey, verifierBuffer)) {
131
+ console.error(chalk.red('Invalid passphrase.'));
132
+ process.exit(1);
133
+ }
134
+
135
+ // Unwrap the real master key (or fall back to legacy where wrapping key = master key)
136
+ let masterKey: Buffer;
137
+ if (encryptedMasterKey) {
138
+ masterKey = unwrapMasterKey(Buffer.from(encryptedMasterKey, 'base64'), wrappingKey);
139
+ } else {
140
+ masterKey = wrappingKey;
141
+ }
142
+
143
+ storeMasterKey(masterKey);
144
+ console.log(chalk.green('Encryption session unlocked.'));
145
+ });
146
+
147
+ encryption.command('status')
148
+ .description('Show encryption status')
149
+ .action(async () => {
150
+ const sdk = getSDKClientFromParent(encryption);
151
+ const { setupComplete } = await sdk.account.getEncryptionParams();
152
+ const sessionActive = loadMasterKey() !== null;
153
+
154
+ console.log(`Encryption setup: ${setupComplete ? chalk.green('Complete') : chalk.yellow('Not set up')}`);
155
+ console.log(`Session unlocked: ${sessionActive ? chalk.green('Yes') : chalk.yellow('No')}`);
156
+ });
157
+
158
+ encryption.command('change-passphrase')
159
+ .description('Change encryption passphrase')
160
+ .action(async () => {
161
+ const sdk = getSDKClientFromParent(encryption);
162
+
163
+ const { setupComplete, salt, verifier, encryptedMasterKey } = await sdk.account.getEncryptionParams();
164
+ if (!setupComplete) {
165
+ console.error(chalk.red('Encryption not set up. Run: tusky encryption setup'));
166
+ return;
167
+ }
168
+
169
+ const answers = await inquirer.prompt([
170
+ {
171
+ type: 'password',
172
+ name: 'currentPassphrase',
173
+ message: 'Enter current passphrase:',
174
+ mask: '*',
175
+ },
176
+ {
177
+ type: 'password',
178
+ name: 'newPassphrase',
179
+ message: 'Enter new passphrase:',
180
+ mask: '*',
181
+ validate: (input: string) => input.length >= 8 || 'Passphrase must be at least 8 characters',
182
+ },
183
+ {
184
+ type: 'password',
185
+ name: 'confirmNew',
186
+ message: 'Confirm new passphrase:',
187
+ mask: '*',
188
+ },
189
+ ]);
190
+
191
+ if (answers.newPassphrase !== answers.confirmNew) {
192
+ console.error(chalk.red('New passphrases do not match.'));
193
+ return;
194
+ }
195
+
196
+ // Verify current passphrase
197
+ const currentSalt = Buffer.from(salt!, 'base64');
198
+ const currentVerifier = Buffer.from(verifier!, 'base64');
199
+ const currentWrappingKey = deriveMasterKey(answers.currentPassphrase, currentSalt);
200
+
201
+ if (!verifyPassphrase(currentWrappingKey, currentVerifier)) {
202
+ console.error(chalk.red('Invalid current passphrase.'));
203
+ return;
204
+ }
205
+
206
+ // Unwrap the real master key (or use wrapping key as legacy fallback)
207
+ let masterKey: Buffer;
208
+ if (encryptedMasterKey) {
209
+ masterKey = unwrapMasterKey(Buffer.from(encryptedMasterKey, 'base64'), currentWrappingKey);
210
+ } else {
211
+ masterKey = currentWrappingKey;
212
+ }
213
+
214
+ // Derive new wrapping key, re-wrap master key
215
+ const newSalt = generateSalt();
216
+ const newWrappingKey = deriveMasterKey(answers.newPassphrase, newSalt);
217
+ const newVerifier = computeVerifier(newWrappingKey);
218
+ const newEncryptedMasterKey = wrapMasterKey(masterKey, newWrappingKey);
219
+
220
+ // Generate new recovery key and wrap master key with it
221
+ const newRecoveryKey = generateRecoveryKey();
222
+ const newWrappedBackup = wrapMasterKey(masterKey, newRecoveryKey);
223
+
224
+ await sdk.account.resetEncryption({
225
+ salt: newSalt.toString('base64'),
226
+ verifier: newVerifier.toString('base64'),
227
+ encryptedMasterKey: newEncryptedMasterKey.toString('base64'),
228
+ wrappedMasterKeyBackup: newWrappedBackup.toString('base64'),
229
+ });
230
+
231
+ storeMasterKey(masterKey);
232
+
233
+ console.log(chalk.yellow.bold('NEW RECOVERY KEY (save it!):'));
234
+ console.log(chalk.bold(` ${newRecoveryKey.toString('base64')}`));
235
+ console.log(chalk.green('Passphrase changed successfully.'));
236
+ });
237
+
238
+ encryption.command('recover')
239
+ .description('Recover master key with recovery key')
240
+ .action(async () => {
241
+ const sdk = getSDKClientFromParent(encryption);
242
+
243
+ const answers = await inquirer.prompt([
244
+ {
245
+ type: 'input',
246
+ name: 'recoveryKey',
247
+ message: 'Enter your recovery key (base64):',
248
+ },
249
+ {
250
+ type: 'password',
251
+ name: 'newPassphrase',
252
+ message: 'Enter new passphrase:',
253
+ mask: '*',
254
+ validate: (input: string) => input.length >= 8 || 'Passphrase must be at least 8 characters',
255
+ },
256
+ {
257
+ type: 'password',
258
+ name: 'confirmNew',
259
+ message: 'Confirm new passphrase:',
260
+ mask: '*',
261
+ },
262
+ ]);
263
+
264
+ if (answers.newPassphrase !== answers.confirmNew) {
265
+ console.error(chalk.red('Passphrases do not match.'));
266
+ return;
267
+ }
268
+
269
+ // Get wrapped master key backup from server
270
+ const { wrappedMasterKeyBackup } = await sdk.account.getRecoveryData();
271
+ const wrappedData = Buffer.from(wrappedMasterKeyBackup!, 'base64');
272
+ const recoveryKeyRaw = Buffer.from(answers.recoveryKey, 'base64');
273
+
274
+ let masterKey: Buffer;
275
+ try {
276
+ masterKey = unwrapMasterKey(wrappedData, recoveryKeyRaw);
277
+ } catch {
278
+ console.error(chalk.red('Invalid recovery key.'));
279
+ return;
280
+ }
281
+
282
+ // Derive new wrapping key from new passphrase, wrap the RECOVERED master key
283
+ const newSalt = generateSalt();
284
+ const newWrappingKey = deriveMasterKey(answers.newPassphrase, newSalt);
285
+ const newVerifier = computeVerifier(newWrappingKey);
286
+ const newEncryptedMasterKey = wrapMasterKey(masterKey, newWrappingKey);
287
+
288
+ // Generate new recovery key and wrap master key with it
289
+ const newRecoveryKey = generateRecoveryKey();
290
+ const newWrappedBackup = wrapMasterKey(masterKey, newRecoveryKey);
291
+
292
+ await sdk.account.resetEncryption({
293
+ salt: newSalt.toString('base64'),
294
+ verifier: newVerifier.toString('base64'),
295
+ encryptedMasterKey: newEncryptedMasterKey.toString('base64'),
296
+ wrappedMasterKeyBackup: newWrappedBackup.toString('base64'),
297
+ });
298
+
299
+ storeMasterKey(masterKey);
300
+
301
+ console.log(chalk.yellow.bold('NEW RECOVERY KEY (save it!):'));
302
+ console.log(chalk.bold(` ${newRecoveryKey.toString('base64')}`));
303
+ console.log(chalk.green('Recovery successful. Passphrase reset.'));
304
+ });
305
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * `tusky export` — Export file metadata for disaster recovery.
3
+ *
4
+ * Dumps all file records (including Walrus blob IDs, encryption keys,
5
+ * and epoch info) to a JSON file. This allows users to recover their
6
+ * data directly from Walrus if the Tusky service is unavailable.
7
+ *
8
+ * The export contains everything needed to:
9
+ * 1. Fetch files from Walrus aggregators using blobId
10
+ * 2. Decrypt private vault files using wrappedKey + iv + master key
11
+ * 3. Renew Walrus epochs using blobObjectId + a Sui wallet
12
+ */
13
+
14
+ import type { Command } from 'commander';
15
+ import { writeFileSync } from 'fs';
16
+ import { resolve } from 'path';
17
+ import chalk from 'chalk';
18
+ import { getApiUrl, getApiKey } from '../config.js';
19
+ import { createSDKClient } from '../sdk.js';
20
+ import { createSpinner } from '../lib/progress.js';
21
+
22
+ export interface ExportedFile {
23
+ fileId: string;
24
+ name: string;
25
+ mimeType: string;
26
+ sizeBytes: number;
27
+ vaultId: string;
28
+ vaultName: string;
29
+ vaultVisibility: string;
30
+ folderId: string | null;
31
+ status: string;
32
+ /** Walrus content-addressed blob ID — use to fetch from any Walrus aggregator */
33
+ walrusBlobId: string | null;
34
+ /** Sui object ID — required for epoch renewal via Walrus CLI */
35
+ walrusBlobObjectId: string | null;
36
+ /** Epoch when Walrus storage expires (renew before this) */
37
+ walrusEpochEnd: string | null;
38
+ /** Whether the file is client-side encrypted */
39
+ encrypted: boolean;
40
+ /** AES-256-GCM file key, wrapped with the master key (base64) */
41
+ wrappedKey: string | null;
42
+ /** AES-256-GCM initialization vector (base64) */
43
+ encryptionIv: string | null;
44
+ /** SHA-256 of the plaintext — for integrity verification after decryption */
45
+ plaintextChecksumSha256: string | null;
46
+ /** Original plaintext size in bytes (before encryption) */
47
+ plaintextSizeBytes: number | null;
48
+ createdAt: string;
49
+ }
50
+
51
+ export interface ExportManifest {
52
+ version: 1;
53
+ exportedAt: string;
54
+ account: {
55
+ id: string;
56
+ email: string;
57
+ plan: string;
58
+ };
59
+ /** Account encryption params — needed to derive master key from passphrase */
60
+ encryption: {
61
+ setupComplete: boolean;
62
+ /** PBKDF2 salt (base64) */
63
+ salt: string | null;
64
+ /** HMAC verifier for passphrase validation (base64) */
65
+ verifier: string | null;
66
+ /** Master key wrapped with passphrase-derived key (base64) */
67
+ encryptedMasterKey: string | null;
68
+ };
69
+ walrusNetwork: string;
70
+ walrusAggregatorUrl: string;
71
+ totalFiles: number;
72
+ totalVaults: number;
73
+ files: ExportedFile[];
74
+ recovery: {
75
+ instructions: string[];
76
+ };
77
+ }
78
+
79
+ export function registerExportCommand(program: Command) {
80
+ program
81
+ .command('export')
82
+ .description('Export file metadata for disaster recovery (Walrus blob IDs, encryption keys)')
83
+ .option('-o, --output <path>', 'Output file path', 'tusky-export.json')
84
+ .option('--vault <vaultId>', 'Export only files from a specific vault')
85
+ .action(async (options: { output: string; vault?: string }) => {
86
+ const apiUrl = getApiUrl(program.opts().apiUrl);
87
+ const apiKey = getApiKey(program.opts().apiKey);
88
+ const sdk = createSDKClient(apiUrl, apiKey);
89
+
90
+ const spinner = createSpinner('Fetching account info...');
91
+ spinner.start();
92
+
93
+ try {
94
+ // Fetch account + encryption params
95
+ const account = await sdk.account.get();
96
+ const encryptionParams = await sdk.account.getEncryptionParams();
97
+
98
+ // Fetch all vaults
99
+ spinner.text = 'Fetching vaults...';
100
+ const allVaults = await sdk.vaults.list();
101
+ const vaults = options.vault
102
+ ? allVaults.filter((v) => v.id === options.vault)
103
+ : allVaults;
104
+
105
+ if (vaults.length === 0) {
106
+ spinner.fail(options.vault ? `Vault ${options.vault} not found.` : 'No vaults found.');
107
+ return;
108
+ }
109
+
110
+ // Build vault lookup
111
+ const vaultMap = new Map(allVaults.map((v) => [v.id, v]));
112
+
113
+ // Fetch all files with pagination
114
+ spinner.text = 'Fetching files...';
115
+ const exportedFiles: ExportedFile[] = [];
116
+
117
+ for (const vault of vaults) {
118
+ let cursor: string | undefined;
119
+ do {
120
+ const result = await sdk.files.list({
121
+ vaultId: vault.id,
122
+ limit: 100,
123
+ cursor,
124
+ });
125
+
126
+ for (const file of result.files) {
127
+ const v = vaultMap.get(file.vaultId);
128
+ exportedFiles.push({
129
+ fileId: file.id,
130
+ name: file.name,
131
+ mimeType: file.mimeType,
132
+ sizeBytes: file.sizeBytes,
133
+ vaultId: file.vaultId,
134
+ vaultName: v?.name ?? 'unknown',
135
+ vaultVisibility: v?.visibility ?? 'unknown',
136
+ folderId: file.folderId,
137
+ status: file.status,
138
+ walrusBlobId: file.walrusBlobId,
139
+ walrusBlobObjectId: file.walrusBlobObjectId,
140
+ walrusEpochEnd: file.walrusEpochEnd,
141
+ encrypted: file.encrypted,
142
+ wrappedKey: file.wrappedKey,
143
+ encryptionIv: file.encryptionIv,
144
+ plaintextChecksumSha256: file.plaintextChecksumSha256,
145
+ plaintextSizeBytes: file.plaintextSizeBytes,
146
+ createdAt: file.createdAt,
147
+ });
148
+ }
149
+
150
+ cursor = result.nextCursor ?? undefined;
151
+ spinner.text = `Fetching files... (${exportedFiles.length} so far)`;
152
+ } while (cursor);
153
+ }
154
+
155
+ // Determine Walrus network info
156
+ const tuskyEnv = process.env.TUSKY_ENV || 'production';
157
+ const isMainnet = tuskyEnv === 'production';
158
+ const walrusAggregatorUrl = isMainnet
159
+ ? 'https://aggregator.walrus.space'
160
+ : 'https://aggregator.walrus-testnet.walrus.space';
161
+
162
+ // Build manifest
163
+ const manifest: ExportManifest = {
164
+ version: 1,
165
+ exportedAt: new Date().toISOString(),
166
+ account: {
167
+ id: account.id,
168
+ email: account.email,
169
+ plan: account.planName,
170
+ },
171
+ encryption: {
172
+ setupComplete: encryptionParams.setupComplete,
173
+ salt: encryptionParams.salt ?? null,
174
+ verifier: encryptionParams.verifier ?? null,
175
+ encryptedMasterKey: encryptionParams.encryptedMasterKey ?? null,
176
+ },
177
+ walrusNetwork: isMainnet ? 'mainnet' : 'testnet',
178
+ walrusAggregatorUrl,
179
+ totalFiles: exportedFiles.length,
180
+ totalVaults: vaults.length,
181
+ files: exportedFiles,
182
+ recovery: {
183
+ instructions: [
184
+ '=== TUSKY DISASTER RECOVERY ===',
185
+ '',
186
+ 'This file contains everything needed to recover your data independently of Tusky.',
187
+ '',
188
+ '--- DOWNLOAD FILES FROM WALRUS ---',
189
+ 'For each file with a walrusBlobId, fetch directly from any Walrus aggregator:',
190
+ ` curl -o <filename> ${walrusAggregatorUrl}/v1/blobs/<walrusBlobId>`,
191
+ ' OR: walrus read <walrusBlobId> -o <filename>',
192
+ ' OR: tusky rehydrate <walrusBlobId> -o <filename>',
193
+ '',
194
+ '--- DECRYPT PRIVATE VAULT FILES ---',
195
+ 'Files with encrypted=true are AES-256-GCM encrypted.',
196
+ '',
197
+ 'Option A — Using the Tusky CLI decrypt command (easiest):',
198
+ ' tusky decrypt <encrypted-file> --export <this-file.json> -o <output>',
199
+ ' This reads the wrappedKey/iv from this manifest and prompts for your passphrase.',
200
+ ' You can also pass --passphrase <passphrase> or set TUSKYDP_PASSWORD env var.',
201
+ ' If the Tusky API is still reachable, you can skip the export and use:',
202
+ ' tusky decrypt <encrypted-file> --file-id <fileId> -o <output>',
203
+ '',
204
+ 'Option B — Using the standalone script (zero dependencies, fully offline):',
205
+ ' node tusky-decrypt.mjs <encrypted-file> <output> <passphrase> <salt> <wrappedKey> <iv> [encryptedMasterKey]',
206
+ ' The salt and encryptedMasterKey come from your account encryption params.',
207
+ '',
208
+ 'Option C — Manual decryption (any language with AES-256-GCM + PBKDF2):',
209
+ ' 1. Derive wrapping key: PBKDF2(passphrase, salt, 600000 iterations, SHA-256, 32 bytes)',
210
+ ' 2. If your account has an encryptedMasterKey, unwrap it: AES-256-GCM-decrypt(encryptedMasterKey, wrappingKey)',
211
+ ' Otherwise the wrapping key IS the master key (legacy accounts)',
212
+ ' 3. Unwrap the file key: AES-256-GCM-decrypt(wrappedKey, masterKey)',
213
+ ' wrappedKey format: [12-byte IV | ciphertext | 16-byte auth tag]',
214
+ ' 4. Decrypt the file: AES-256-GCM-decrypt(fileData, fileKey, encryptionIv)',
215
+ ' File data format: [ciphertext | 16-byte auth tag]',
216
+ ' 5. Verify integrity: SHA-256(plaintext) should match plaintextChecksumSha256',
217
+ '',
218
+ '--- RENEW WALRUS EPOCHS ---',
219
+ 'Files with a walrusBlobObjectId can have their storage extended:',
220
+ ' walrus extend <walrusBlobObjectId> --epochs 5',
221
+ 'This requires a Sui wallet with WAL tokens. Check walrusEpochEnd to see when renewal is needed.',
222
+ ],
223
+ },
224
+ };
225
+
226
+ // Write to disk
227
+ const outputPath = resolve(options.output);
228
+ writeFileSync(outputPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
229
+
230
+ spinner.succeed(`Exported ${exportedFiles.length} files from ${vaults.length} vault(s)`);
231
+ console.log(chalk.dim(` Output: ${outputPath}`));
232
+ console.log('');
233
+
234
+ // Summary stats
235
+ const withBlob = exportedFiles.filter((f) => f.walrusBlobId).length;
236
+ const encrypted = exportedFiles.filter((f) => f.encrypted).length;
237
+ const hotOnly = exportedFiles.filter((f) => !f.walrusBlobId && f.status === 'hot').length;
238
+
239
+ console.log(` On Walrus: ${withBlob} files (recoverable from Walrus network)`);
240
+ console.log(` Encrypted: ${encrypted} files (require passphrase to decrypt)`);
241
+ if (hotOnly > 0) {
242
+ console.log(chalk.yellow(` Hot only: ${hotOnly} files (NOT yet on Walrus — only in Tusky hot cache)`));
243
+ }
244
+ console.log('');
245
+ console.log(chalk.yellow(' Store this file securely — it contains encryption keys.'));
246
+ console.log(chalk.dim(' See the "recovery.instructions" field inside for full recovery steps.'));
247
+ } catch (err: any) {
248
+ spinner.fail(`Export failed: ${err.message}`);
249
+ }
250
+ });
251
+ }