@tuskydp/cli 0.1.0 → 0.1.2
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/bin/tuskydp.ts +2 -0
- package/dist/src/commands/account.d.ts.map +1 -1
- package/dist/src/commands/account.js +5 -2
- package/dist/src/commands/account.js.map +1 -1
- package/dist/src/commands/auth.d.ts.map +1 -1
- package/dist/src/commands/auth.js +2 -1
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/files.d.ts.map +1 -1
- package/dist/src/commands/files.js +9 -4
- package/dist/src/commands/files.js.map +1 -1
- package/dist/src/commands/mcp.js +1 -1
- package/dist/src/commands/mcp.js.map +1 -1
- package/dist/src/commands/rehydrate.d.ts.map +1 -1
- package/dist/src/commands/rehydrate.js +5 -2
- package/dist/src/commands/rehydrate.js.map +1 -1
- package/dist/src/commands/upload.d.ts.map +1 -1
- package/dist/src/commands/upload.js +5 -0
- package/dist/src/commands/upload.js.map +1 -1
- package/dist/src/config.d.ts +0 -2
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +5 -4
- package/dist/src/config.js.map +1 -1
- package/dist/src/index.js +16 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/keyring.d.ts.map +1 -1
- package/dist/src/lib/keyring.js +3 -5
- package/dist/src/lib/keyring.js.map +1 -1
- package/dist/src/lib/output.js +1 -1
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/resolve.js +1 -1
- package/dist/src/lib/resolve.js.map +1 -1
- package/dist/src/mcp/tools/files.d.ts.map +1 -1
- package/dist/src/mcp/tools/files.js +20 -0
- package/dist/src/mcp/tools/files.js.map +1 -1
- package/dist/src/mcp/tools/folders.d.ts.map +1 -1
- package/dist/src/mcp/tools/folders.js +15 -0
- package/dist/src/mcp/tools/folders.js.map +1 -1
- package/dist/src/mcp/tools/trash.d.ts.map +1 -1
- package/dist/src/mcp/tools/trash.js +14 -0
- package/dist/src/mcp/tools/trash.js.map +1 -1
- package/dist/src/sdk.d.ts +1 -1
- package/dist/src/sdk.d.ts.map +1 -1
- package/dist/src/sdk.js +3 -3
- package/dist/src/sdk.js.map +1 -1
- package/dist/src/tui/auth-screen.d.ts.map +1 -1
- package/dist/src/tui/auth-screen.js +7 -1
- package/dist/src/tui/auth-screen.js.map +1 -1
- package/package.json +12 -18
- package/src/__tests__/crypto.test.ts +315 -0
- package/src/commands/account.ts +82 -0
- package/src/commands/auth.ts +190 -0
- package/src/commands/decrypt.ts +276 -0
- package/src/commands/download.ts +82 -0
- package/src/commands/encryption.ts +305 -0
- package/src/commands/export.ts +251 -0
- package/src/commands/files.ts +192 -0
- package/src/commands/mcp.ts +220 -0
- package/src/commands/rehydrate.ts +37 -0
- package/src/commands/tui.ts +11 -0
- package/src/commands/upload.ts +143 -0
- package/src/commands/vault.ts +132 -0
- package/src/config.ts +38 -0
- package/src/crypto.ts +130 -0
- package/src/index.ts +79 -0
- package/src/lib/keyring.ts +50 -0
- package/src/lib/output.ts +36 -0
- package/src/lib/progress.ts +5 -0
- package/src/lib/resolve.ts +26 -0
- package/src/mcp/context.ts +22 -0
- package/src/mcp/server.ts +140 -0
- package/src/mcp/tools/account.ts +40 -0
- package/src/mcp/tools/files.ts +428 -0
- package/src/mcp/tools/folders.ts +109 -0
- package/src/mcp/tools/helpers.ts +28 -0
- package/src/mcp/tools/trash.ts +82 -0
- package/src/mcp/tools/vaults.ts +114 -0
- package/src/sdk.ts +115 -0
- package/src/tui/auth-screen.ts +176 -0
- package/src/tui/dialogs.ts +339 -0
- package/src/tui/files-panel.ts +165 -0
- package/src/tui/helpers.ts +206 -0
- package/src/tui/index.ts +420 -0
- package/src/tui/overview.ts +155 -0
- package/src/tui/status-bar.ts +61 -0
- package/src/tui/vaults-panel.ts +143 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { cliConfig, getApiUrl, getApiKey } from '../config.js';
|
|
4
|
+
import { createSDKClient, AuthClient } from '../sdk.js';
|
|
5
|
+
import { formatBytes } from '../lib/output.js';
|
|
6
|
+
|
|
7
|
+
export function registerAccountCommands(program: Command) {
|
|
8
|
+
const account = program.command('account').description('Account management');
|
|
9
|
+
|
|
10
|
+
account.command('info')
|
|
11
|
+
.description('Show account info')
|
|
12
|
+
.action(async () => {
|
|
13
|
+
const apiUrl = getApiUrl(program.opts().apiUrl);
|
|
14
|
+
const apiKey = getApiKey(program.opts().apiKey);
|
|
15
|
+
const sdk = createSDKClient(apiUrl, apiKey);
|
|
16
|
+
|
|
17
|
+
const format = program.opts().format || cliConfig.get('outputFormat');
|
|
18
|
+
const acct = await sdk.account.get();
|
|
19
|
+
|
|
20
|
+
if (format === 'json') {
|
|
21
|
+
console.log(JSON.stringify(acct, null, 2));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const usagePct = acct.storageLimitBytes > 0
|
|
26
|
+
? ((acct.storageUsedBytes / acct.storageLimitBytes) * 100).toFixed(1)
|
|
27
|
+
: '0.0';
|
|
28
|
+
console.log(`Email: ${acct.email}`);
|
|
29
|
+
console.log(`Plan: ${acct.planName}`);
|
|
30
|
+
console.log(`Storage Used: ${acct.storageUsedFormatted} / ${acct.storageLimitFormatted} (${usagePct}%)`);
|
|
31
|
+
console.log(`Encryption: ${acct.encryptionSetupComplete ? chalk.green('Set up') : chalk.yellow('Not set up')}`);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
account.command('plan')
|
|
35
|
+
.description('Show current plan and available upgrades')
|
|
36
|
+
.action(async () => {
|
|
37
|
+
const apiUrl = getApiUrl(program.opts().apiUrl);
|
|
38
|
+
const apiKey = getApiKey(program.opts().apiKey);
|
|
39
|
+
const sdk = createSDKClient(apiUrl, apiKey);
|
|
40
|
+
const authClient = new AuthClient(apiUrl);
|
|
41
|
+
|
|
42
|
+
const acct = await sdk.account.get();
|
|
43
|
+
const { plans } = await authClient.getPlans();
|
|
44
|
+
const currentTier = acct.planTier;
|
|
45
|
+
|
|
46
|
+
console.log(chalk.bold('Available Plans:\n'));
|
|
47
|
+
for (const plan of plans) {
|
|
48
|
+
const isCurrent = plan.tier === currentTier;
|
|
49
|
+
const prefix = isCurrent ? chalk.green('> ') : ' ';
|
|
50
|
+
const label = isCurrent ? chalk.green.bold(plan.displayName) : plan.displayName;
|
|
51
|
+
const price = plan.priceMonthly === 0 ? 'Free' : `$${(plan.priceMonthly / 100).toFixed(0)}/mo`;
|
|
52
|
+
|
|
53
|
+
console.log(`${prefix}${label} (${price})`);
|
|
54
|
+
console.log(` Storage: ${formatBytes(plan.storageLimitBytes)}, Max file: ${formatBytes(plan.maxFileSizeBytes)}`);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
account.command('usage')
|
|
59
|
+
.description('Show detailed usage breakdown')
|
|
60
|
+
.action(async () => {
|
|
61
|
+
const apiUrl = getApiUrl(program.opts().apiUrl);
|
|
62
|
+
const apiKey = getApiKey(program.opts().apiKey);
|
|
63
|
+
const sdk = createSDKClient(apiUrl, apiKey);
|
|
64
|
+
|
|
65
|
+
const acct = await sdk.account.get();
|
|
66
|
+
const vaults = await sdk.vaults.list();
|
|
67
|
+
|
|
68
|
+
console.log(chalk.bold('Storage Usage by Vault:\n'));
|
|
69
|
+
for (const v of vaults) {
|
|
70
|
+
const icon = v.visibility === 'private' ? '[private]' : '[public]';
|
|
71
|
+
console.log(` ${v.name} ${icon} — ${v.fileCount} files, ${formatBytes(v.totalSizeBytes)}`);
|
|
72
|
+
}
|
|
73
|
+
console.log('');
|
|
74
|
+
console.log(` Total: ${formatBytes(acct.storageUsedBytes)} / ${formatBytes(acct.storageLimitBytes)}`);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Default action for `tuskydp account` with no subcommand
|
|
78
|
+
account.action(async () => {
|
|
79
|
+
// Run `info` subcommand
|
|
80
|
+
await account.commands.find(c => c.name() === 'info')?.parseAsync([], { from: 'user' });
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { cliConfig, getApiUrl, getApiKey } from '../config.js';
|
|
4
|
+
import { createSDKClient, AuthClient } from '../sdk.js';
|
|
5
|
+
import { createTable, formatDate } from '../lib/output.js';
|
|
6
|
+
import { clearSession } from '../lib/keyring.js';
|
|
7
|
+
|
|
8
|
+
export function registerAuthCommands(program: Command) {
|
|
9
|
+
program.command('signup')
|
|
10
|
+
.description('Create a new account')
|
|
11
|
+
.argument('<email>', 'Email address')
|
|
12
|
+
.argument('<password>', 'Password')
|
|
13
|
+
.requiredOption('--access-code <code>', 'Access code (required for signup)')
|
|
14
|
+
.option('--name <name>', 'Display name for the account')
|
|
15
|
+
.action(async (email: string, password: string, options) => {
|
|
16
|
+
const apiUrl = getApiUrl(program.opts().apiUrl);
|
|
17
|
+
const authClient = new AuthClient(apiUrl);
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const { user, accessToken } = await authClient.register(email, password, options.accessCode, options.name);
|
|
21
|
+
const keyResult = await authClient.createApiKeyWithJwt(accessToken, 'CLI Key');
|
|
22
|
+
|
|
23
|
+
cliConfig.set('apiKey', keyResult.apiKey);
|
|
24
|
+
cliConfig.set('apiUrl', apiUrl);
|
|
25
|
+
|
|
26
|
+
console.log(chalk.green(`✓ Account created: ${user.email}`));
|
|
27
|
+
console.log(chalk.dim(`API: ${apiUrl}`));
|
|
28
|
+
console.log('');
|
|
29
|
+
console.log(chalk.yellow('Save this API key — it will not be shown again:'));
|
|
30
|
+
console.log(chalk.bold(keyResult.apiKey));
|
|
31
|
+
console.log('');
|
|
32
|
+
console.log(chalk.dim('API key auto-saved to config. You\'re logged in.'));
|
|
33
|
+
} catch (err: any) {
|
|
34
|
+
console.error(chalk.red(`Signup failed: ${err.message}`));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
program.command('login')
|
|
40
|
+
.description('Authenticate with TuskyDP')
|
|
41
|
+
.option('--api-key <key>', 'Authenticate with an existing API key')
|
|
42
|
+
.option('--email <email>', 'Email address for login')
|
|
43
|
+
.option('--password <password>', 'Password for login')
|
|
44
|
+
.action(async (options) => {
|
|
45
|
+
// Check both local and parent-level --api-key (Commander.js may assign to parent)
|
|
46
|
+
const apiKey = options.apiKey || program.opts().apiKey;
|
|
47
|
+
if (apiKey) {
|
|
48
|
+
const apiUrl = getApiUrl(program.opts().apiUrl);
|
|
49
|
+
try {
|
|
50
|
+
const sdk = createSDKClient(apiUrl, apiKey);
|
|
51
|
+
const account = await sdk.account.get();
|
|
52
|
+
cliConfig.set('apiKey', apiKey);
|
|
53
|
+
cliConfig.set('apiUrl', apiUrl);
|
|
54
|
+
console.log(chalk.green(`Authenticated as ${account.email} (${account.planName} plan)`));
|
|
55
|
+
console.log(chalk.dim(`API: ${apiUrl}`));
|
|
56
|
+
console.log(chalk.dim(`API key stored in config`));
|
|
57
|
+
} catch (err: any) {
|
|
58
|
+
console.error(chalk.red(`Authentication failed: ${err.message}`));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (options.email && options.password) {
|
|
65
|
+
const apiUrl = getApiUrl(program.opts().apiUrl);
|
|
66
|
+
const authClient = new AuthClient(apiUrl);
|
|
67
|
+
try {
|
|
68
|
+
const { accessToken } = await authClient.login(options.email, options.password);
|
|
69
|
+
const keyResult = await authClient.createApiKeyWithJwt(accessToken, 'CLI Key');
|
|
70
|
+
|
|
71
|
+
cliConfig.set('apiKey', keyResult.apiKey);
|
|
72
|
+
cliConfig.set('apiUrl', apiUrl);
|
|
73
|
+
|
|
74
|
+
console.log(chalk.green(`Authenticated as ${options.email}`));
|
|
75
|
+
console.log(chalk.dim(`API: ${apiUrl}`));
|
|
76
|
+
console.log('');
|
|
77
|
+
console.log(chalk.yellow('Save this API key — it will not be shown again:'));
|
|
78
|
+
console.log(chalk.bold(keyResult.apiKey));
|
|
79
|
+
console.log('');
|
|
80
|
+
console.log(chalk.dim('API key auto-saved to config. You\'re logged in.'));
|
|
81
|
+
} catch (err: any) {
|
|
82
|
+
console.error(chalk.red(`Login failed: ${err.message}`));
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log(chalk.yellow('Please provide credentials to log in.'));
|
|
89
|
+
console.log(chalk.dim('Use: tusky login --email <email> --password <password>'));
|
|
90
|
+
console.log(chalk.dim(' or: tusky login --api-key <key>'));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
program.command('logout')
|
|
94
|
+
.description('Clear stored credentials and encryption session')
|
|
95
|
+
.action(() => {
|
|
96
|
+
cliConfig.delete('apiKey');
|
|
97
|
+
cliConfig.delete('defaultVault');
|
|
98
|
+
clearSession();
|
|
99
|
+
console.log(chalk.green('Logged out. Credentials and session cleared.'));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
program.command('whoami')
|
|
103
|
+
.description('Show current user, plan, and storage usage')
|
|
104
|
+
.action(async () => {
|
|
105
|
+
const apiUrl = getApiUrl(program.opts().apiUrl);
|
|
106
|
+
const apiKey = getApiKey(program.opts().apiKey);
|
|
107
|
+
const sdk = createSDKClient(apiUrl, apiKey);
|
|
108
|
+
|
|
109
|
+
const account = await sdk.account.get();
|
|
110
|
+
console.log(`Email: ${account.email}`);
|
|
111
|
+
console.log(`Plan: ${account.planName} (${account.planTier})`);
|
|
112
|
+
console.log(`Storage: ${account.storageUsedFormatted} / ${account.storageLimitFormatted}`);
|
|
113
|
+
console.log(`Encryption: ${account.encryptionSetupComplete ? chalk.green('Set up') : chalk.yellow('Not set up')}`);
|
|
114
|
+
console.log(`Account ID: ${chalk.dim(account.id)}`);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// API Key subcommands
|
|
118
|
+
const apiKeys = program.command('api-keys').description('Manage API keys');
|
|
119
|
+
|
|
120
|
+
apiKeys.command('list')
|
|
121
|
+
.description('List all API keys')
|
|
122
|
+
.action(async () => {
|
|
123
|
+
const apiUrl = getApiUrl(program.opts().apiUrl);
|
|
124
|
+
const apiKey = getApiKey(program.opts().apiKey);
|
|
125
|
+
const sdk = createSDKClient(apiUrl, apiKey);
|
|
126
|
+
|
|
127
|
+
const format = program.opts().format || cliConfig.get('outputFormat');
|
|
128
|
+
const keys = await sdk.apiKeys.list();
|
|
129
|
+
|
|
130
|
+
if (format === 'json') {
|
|
131
|
+
console.log(JSON.stringify(keys, null, 2));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (keys.length === 0) {
|
|
136
|
+
console.log(chalk.dim('No API keys found.'));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const table = createTable(['Name', 'Prefix', 'Scopes', 'Last Used', 'Created']);
|
|
141
|
+
for (const k of keys) {
|
|
142
|
+
table.push([
|
|
143
|
+
k.name,
|
|
144
|
+
chalk.dim(k.keyPrefix + '...'),
|
|
145
|
+
k.scopes?.join(', ') || chalk.dim('all'),
|
|
146
|
+
k.lastUsedAt ? formatDate(k.lastUsedAt) : chalk.dim('never'),
|
|
147
|
+
formatDate(k.createdAt),
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
console.log(table.toString());
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
apiKeys.command('create <name>')
|
|
154
|
+
.description('Create a new API key')
|
|
155
|
+
.option('--scopes <scopes>', 'Comma-separated scopes')
|
|
156
|
+
.option('--vaults <vaults>', 'Comma-separated vault IDs')
|
|
157
|
+
.option('--expires <days>', 'Auto-expire after N days', parseInt)
|
|
158
|
+
.action(async (name: string, options) => {
|
|
159
|
+
const apiUrl = getApiUrl(program.opts().apiUrl);
|
|
160
|
+
const apiKey = getApiKey(program.opts().apiKey);
|
|
161
|
+
const sdk = createSDKClient(apiUrl, apiKey);
|
|
162
|
+
|
|
163
|
+
const result = await sdk.apiKeys.create({
|
|
164
|
+
name,
|
|
165
|
+
scopes: options.scopes?.split(','),
|
|
166
|
+
vaultIds: options.vaults?.split(','),
|
|
167
|
+
expiresInDays: options.expires,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
console.log(chalk.yellow('Save this API key — it will not be shown again:'));
|
|
171
|
+
console.log('');
|
|
172
|
+
console.log(chalk.bold(result.apiKey));
|
|
173
|
+
console.log('');
|
|
174
|
+
console.log(`Name: ${result.name}`);
|
|
175
|
+
console.log(`Prefix: ${result.keyPrefix}`);
|
|
176
|
+
if (result.scopes) console.log(`Scopes: ${result.scopes.join(', ')}`);
|
|
177
|
+
if (result.expiresAt) console.log(`Expires: ${new Date(result.expiresAt).toLocaleDateString()}`);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
apiKeys.command('revoke <key-id>')
|
|
181
|
+
.description('Revoke an API key')
|
|
182
|
+
.action(async (keyId: string) => {
|
|
183
|
+
const apiUrl = getApiUrl(program.opts().apiUrl);
|
|
184
|
+
const apiKey = getApiKey(program.opts().apiKey);
|
|
185
|
+
const sdk = createSDKClient(apiUrl, apiKey);
|
|
186
|
+
|
|
187
|
+
await sdk.apiKeys.revoke(keyId);
|
|
188
|
+
console.log(chalk.green('API key revoked.'));
|
|
189
|
+
});
|
|
190
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tusky decrypt` — Decrypt a file downloaded from Walrus.
|
|
3
|
+
*
|
|
4
|
+
* Works in two modes:
|
|
5
|
+
* 1. With --export <manifest.json> — reads wrappedKey/iv from the export
|
|
6
|
+
* manifest, derives master key from passphrase + salt. Fully offline.
|
|
7
|
+
* 2. Without --export — fetches encryption params from the Tusky API and
|
|
8
|
+
* looks up the file metadata by ID. Requires API access.
|
|
9
|
+
*
|
|
10
|
+
* In both modes, the passphrase is resolved from:
|
|
11
|
+
* --passphrase flag > TUSKYDP_PASSWORD env var > interactive prompt
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Command } from 'commander';
|
|
15
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
16
|
+
import { resolve, basename } from 'path';
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
import inquirer from 'inquirer';
|
|
19
|
+
import { getApiUrl, getApiKey } from '../config.js';
|
|
20
|
+
import { createSDKClient } from '../sdk.js';
|
|
21
|
+
import { createSpinner } from '../lib/progress.js';
|
|
22
|
+
import { loadMasterKey } from '../lib/keyring.js';
|
|
23
|
+
import {
|
|
24
|
+
deriveMasterKey,
|
|
25
|
+
verifyPassphrase,
|
|
26
|
+
unwrapMasterKey,
|
|
27
|
+
decryptBuffer,
|
|
28
|
+
} from '../crypto.js';
|
|
29
|
+
import type { ExportManifest, ExportedFile } from './export.js';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Helpers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the master key. Priority:
|
|
37
|
+
* 1. Session keyring (~/.tusky/session.enc)
|
|
38
|
+
* 2. Passphrase derivation (requires API or --salt/--encrypted-master-key)
|
|
39
|
+
*/
|
|
40
|
+
async function resolveMasterKey(options: {
|
|
41
|
+
passphrase?: string;
|
|
42
|
+
salt?: string;
|
|
43
|
+
verifier?: string;
|
|
44
|
+
encryptedMasterKey?: string;
|
|
45
|
+
}): Promise<Buffer> {
|
|
46
|
+
// Try session keyring first
|
|
47
|
+
const sessionKey = loadMasterKey();
|
|
48
|
+
if (sessionKey) return sessionKey;
|
|
49
|
+
|
|
50
|
+
// Need passphrase
|
|
51
|
+
let passphrase = options.passphrase ?? process.env.TUSKYDP_PASSWORD;
|
|
52
|
+
if (!passphrase) {
|
|
53
|
+
const answers = await inquirer.prompt([{
|
|
54
|
+
type: 'password',
|
|
55
|
+
name: 'passphrase',
|
|
56
|
+
message: 'Enter encryption passphrase:',
|
|
57
|
+
mask: '*',
|
|
58
|
+
}]);
|
|
59
|
+
passphrase = answers.passphrase as string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!options.salt) {
|
|
63
|
+
throw new Error('Cannot derive master key: salt is required. Use --export with an export manifest, or ensure the Tusky API is accessible.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const salt = Buffer.from(options.salt, 'base64');
|
|
67
|
+
const wrappingKey = deriveMasterKey(passphrase, salt);
|
|
68
|
+
|
|
69
|
+
// Verify if we have a verifier
|
|
70
|
+
if (options.verifier) {
|
|
71
|
+
const verifierBuf = Buffer.from(options.verifier, 'base64');
|
|
72
|
+
if (!verifyPassphrase(wrappingKey, verifierBuf)) {
|
|
73
|
+
throw new Error('Invalid passphrase.');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Unwrap master key if account has one
|
|
78
|
+
if (options.encryptedMasterKey) {
|
|
79
|
+
return unwrapMasterKey(Buffer.from(options.encryptedMasterKey, 'base64'), wrappingKey);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Legacy: wrapping key IS the master key
|
|
83
|
+
return wrappingKey;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Command registration
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export function registerDecryptCommand(program: Command) {
|
|
91
|
+
program
|
|
92
|
+
.command('decrypt <encrypted-file>')
|
|
93
|
+
.description('Decrypt a file downloaded from Walrus using your encryption passphrase')
|
|
94
|
+
.option('-o, --output <path>', 'Output path for decrypted file')
|
|
95
|
+
.option('--file-id <id>', 'Tusky file ID (to look up wrappedKey/iv from API)')
|
|
96
|
+
.option('--export <path>', 'Path to tusky-export.json manifest (for offline decryption)')
|
|
97
|
+
.option('--passphrase <passphrase>', 'Encryption passphrase (also reads TUSKYDP_PASSWORD env var)')
|
|
98
|
+
.option('--wrapped-key <key>', 'Per-file wrapped key (base64, from export manifest)')
|
|
99
|
+
.option('--iv <iv>', 'Per-file encryption IV (base64, from export manifest)')
|
|
100
|
+
.option('--checksum <sha256>', 'Expected plaintext SHA-256 checksum (hex)')
|
|
101
|
+
.action(async (encryptedFile: string, options: {
|
|
102
|
+
output?: string;
|
|
103
|
+
fileId?: string;
|
|
104
|
+
export?: string;
|
|
105
|
+
passphrase?: string;
|
|
106
|
+
wrappedKey?: string;
|
|
107
|
+
iv?: string;
|
|
108
|
+
checksum?: string;
|
|
109
|
+
}) => {
|
|
110
|
+
const spinner = createSpinner('Preparing decryption...');
|
|
111
|
+
spinner.start();
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
// Resolve the encrypted file
|
|
115
|
+
const inputPath = resolve(encryptedFile);
|
|
116
|
+
if (!existsSync(inputPath)) {
|
|
117
|
+
spinner.fail(`File not found: ${inputPath}`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let wrappedKey: string;
|
|
122
|
+
let iv: string;
|
|
123
|
+
let checksum: string | undefined = options.checksum;
|
|
124
|
+
let fileName: string = basename(inputPath).replace(/\.enc$/, '');
|
|
125
|
+
let encryptionParams: { salt?: string; verifier?: string; encryptedMasterKey?: string } = {};
|
|
126
|
+
|
|
127
|
+
// ── Mode 1: Export manifest ──────────────────────────────────
|
|
128
|
+
if (options.export) {
|
|
129
|
+
spinner.text = 'Reading export manifest...';
|
|
130
|
+
const manifestPath = resolve(options.export);
|
|
131
|
+
if (!existsSync(manifestPath)) {
|
|
132
|
+
spinner.fail(`Export manifest not found: ${manifestPath}`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const manifest: ExportManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
137
|
+
let exportedFile: ExportedFile | undefined;
|
|
138
|
+
|
|
139
|
+
if (options.fileId) {
|
|
140
|
+
exportedFile = manifest.files.find((f) => f.fileId === options.fileId);
|
|
141
|
+
} else {
|
|
142
|
+
// Try to match by filename
|
|
143
|
+
const inputBase = basename(inputPath);
|
|
144
|
+
exportedFile = manifest.files.find((f) =>
|
|
145
|
+
f.name === inputBase || f.name === fileName,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
if (!exportedFile && manifest.files.length === 1) {
|
|
149
|
+
exportedFile = manifest.files[0];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!exportedFile) {
|
|
154
|
+
spinner.fail(
|
|
155
|
+
options.fileId
|
|
156
|
+
? `File ID ${options.fileId} not found in export manifest.`
|
|
157
|
+
: `Could not match "${basename(inputPath)}" to a file in the export manifest. Use --file-id to specify.`,
|
|
158
|
+
);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!exportedFile.encrypted) {
|
|
163
|
+
spinner.fail(`File "${exportedFile.name}" is not encrypted (public vault). No decryption needed.`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!exportedFile.wrappedKey || !exportedFile.encryptionIv) {
|
|
168
|
+
spinner.fail(`File "${exportedFile.name}" is missing encryption metadata in the export.`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
wrappedKey = exportedFile.wrappedKey;
|
|
173
|
+
iv = exportedFile.encryptionIv;
|
|
174
|
+
checksum = checksum ?? exportedFile.plaintextChecksumSha256 ?? undefined;
|
|
175
|
+
fileName = exportedFile.name;
|
|
176
|
+
|
|
177
|
+
// Read account-level encryption params from manifest
|
|
178
|
+
if (manifest.encryption) {
|
|
179
|
+
encryptionParams = {
|
|
180
|
+
salt: manifest.encryption.salt ?? undefined,
|
|
181
|
+
verifier: manifest.encryption.verifier ?? undefined,
|
|
182
|
+
encryptedMasterKey: manifest.encryption.encryptedMasterKey ?? undefined,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Mode 2: Direct flags ─────────────────────────────────────
|
|
187
|
+
} else if (options.wrappedKey && options.iv) {
|
|
188
|
+
wrappedKey = options.wrappedKey;
|
|
189
|
+
iv = options.iv;
|
|
190
|
+
|
|
191
|
+
// ── Mode 3: Fetch from API ───────────────────────────────────
|
|
192
|
+
} else if (options.fileId) {
|
|
193
|
+
spinner.text = 'Fetching file metadata from API...';
|
|
194
|
+
const apiUrl = getApiUrl(program.opts().apiUrl);
|
|
195
|
+
const apiKey = getApiKey(program.opts().apiKey);
|
|
196
|
+
const sdk = createSDKClient(apiUrl, apiKey);
|
|
197
|
+
|
|
198
|
+
const file = await sdk.files.get(options.fileId);
|
|
199
|
+
if (!file.encrypted) {
|
|
200
|
+
spinner.fail('This file is not encrypted. No decryption needed.');
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!file.wrappedKey || !file.encryptionIv) {
|
|
204
|
+
spinner.fail('File is missing encryption metadata.');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
wrappedKey = file.wrappedKey;
|
|
209
|
+
iv = file.encryptionIv;
|
|
210
|
+
checksum = checksum ?? file.plaintextChecksumSha256 ?? undefined;
|
|
211
|
+
fileName = file.name;
|
|
212
|
+
|
|
213
|
+
// Also fetch encryption params for master key derivation
|
|
214
|
+
const params = await sdk.account.getEncryptionParams();
|
|
215
|
+
encryptionParams = {
|
|
216
|
+
salt: params.salt ?? undefined,
|
|
217
|
+
verifier: params.verifier ?? undefined,
|
|
218
|
+
encryptedMasterKey: params.encryptedMasterKey ?? undefined,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
} else {
|
|
222
|
+
spinner.fail(
|
|
223
|
+
'Need encryption metadata. Use one of:\n' +
|
|
224
|
+
' --export <manifest.json> (offline, from tusky export)\n' +
|
|
225
|
+
' --file-id <id> (online, fetches from API)\n' +
|
|
226
|
+
' --wrapped-key <key> --iv <iv> (manual, base64 values)',
|
|
227
|
+
);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// If we don't have encryption params yet, try API
|
|
232
|
+
if (!encryptionParams.salt) {
|
|
233
|
+
try {
|
|
234
|
+
const apiUrl = getApiUrl(program.opts().apiUrl);
|
|
235
|
+
const apiKey = getApiKey(program.opts().apiKey);
|
|
236
|
+
const sdk = createSDKClient(apiUrl, apiKey);
|
|
237
|
+
const params = await sdk.account.getEncryptionParams();
|
|
238
|
+
encryptionParams = {
|
|
239
|
+
salt: params.salt ?? undefined,
|
|
240
|
+
verifier: params.verifier ?? undefined,
|
|
241
|
+
encryptedMasterKey: params.encryptedMasterKey ?? undefined,
|
|
242
|
+
};
|
|
243
|
+
} catch {
|
|
244
|
+
// API unavailable — that's ok if we have a session key
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Resolve master key
|
|
249
|
+
spinner.text = 'Deriving encryption key...';
|
|
250
|
+
const masterKey = await resolveMasterKey({
|
|
251
|
+
passphrase: options.passphrase,
|
|
252
|
+
...encryptionParams,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Read and decrypt
|
|
256
|
+
spinner.text = 'Decrypting...';
|
|
257
|
+
const ciphertext = readFileSync(inputPath);
|
|
258
|
+
const plaintext = decryptBuffer(ciphertext, wrappedKey, iv, masterKey, checksum);
|
|
259
|
+
|
|
260
|
+
// Write output
|
|
261
|
+
const outputPath = options.output ? resolve(options.output) : resolve(fileName);
|
|
262
|
+
writeFileSync(outputPath, plaintext);
|
|
263
|
+
|
|
264
|
+
spinner.succeed(`Decrypted -> ${outputPath} (${plaintext.length} bytes)`);
|
|
265
|
+
|
|
266
|
+
if (checksum) {
|
|
267
|
+
console.log(chalk.dim(' Integrity check passed (SHA-256)'));
|
|
268
|
+
}
|
|
269
|
+
} catch (err: any) {
|
|
270
|
+
spinner.fail(`Decryption failed: ${err.message}`);
|
|
271
|
+
if (err.message.includes('Unsupported state') || err.message.includes('auth tag')) {
|
|
272
|
+
console.log(chalk.dim(' This usually means the passphrase is incorrect or the file is corrupted.'));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { getApiUrl, getApiKey } from '../config.js';
|
|
6
|
+
import { createSDKClient } from '../sdk.js';
|
|
7
|
+
import { formatBytes } from '../lib/output.js';
|
|
8
|
+
import { createSpinner } from '../lib/progress.js';
|
|
9
|
+
import { decryptBuffer } from '../crypto.js';
|
|
10
|
+
import { loadMasterKey } from '../lib/keyring.js';
|
|
11
|
+
|
|
12
|
+
export async function downloadCommand(fileId: string, options: {
|
|
13
|
+
output?: string;
|
|
14
|
+
}, program: Command) {
|
|
15
|
+
const apiUrl = getApiUrl(program.opts().apiUrl);
|
|
16
|
+
const apiKey = getApiKey(program.opts().apiKey);
|
|
17
|
+
const sdk = createSDKClient(apiUrl, apiKey);
|
|
18
|
+
const spinner = createSpinner('Fetching file info...');
|
|
19
|
+
spinner.start();
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
// Get download URL and encryption metadata
|
|
23
|
+
let dlResponse = await sdk.files.getDownloadUrl(fileId);
|
|
24
|
+
let { downloadUrl, status, encryption } = dlResponse;
|
|
25
|
+
|
|
26
|
+
if (status === 'rehydrating') {
|
|
27
|
+
spinner.text = 'File is being retrieved from Walrus. Polling...';
|
|
28
|
+
for (let i = 0; i < 30; i++) {
|
|
29
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
30
|
+
const check = await sdk.files.getDownloadUrl(fileId);
|
|
31
|
+
if (check.status === 'ready') {
|
|
32
|
+
downloadUrl = check.downloadUrl;
|
|
33
|
+
encryption = check.encryption;
|
|
34
|
+
status = 'ready';
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (status !== 'ready') {
|
|
39
|
+
spinner.fail('File rehydration timed out. Try again later.');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!downloadUrl) {
|
|
45
|
+
spinner.fail('No download URL available.');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Download
|
|
50
|
+
spinner.text = 'Downloading...';
|
|
51
|
+
const response = await fetch(downloadUrl);
|
|
52
|
+
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
|
|
53
|
+
const arrayBuf = await response.arrayBuffer();
|
|
54
|
+
let fileBuffer: Buffer = Buffer.from(new Uint8Array(arrayBuf));
|
|
55
|
+
|
|
56
|
+
// Decrypt if needed
|
|
57
|
+
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;
|
|
63
|
+
}
|
|
64
|
+
fileBuffer = decryptBuffer(
|
|
65
|
+
fileBuffer,
|
|
66
|
+
encryption.wrappedKey!,
|
|
67
|
+
encryption.iv!,
|
|
68
|
+
masterKey,
|
|
69
|
+
encryption.plaintextChecksumSha256 ?? undefined,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Write to disk — use getStatus to get filename
|
|
74
|
+
const fileInfo = await sdk.files.getStatus(fileId);
|
|
75
|
+
const outputPath = options.output || join(process.cwd(), fileInfo.name);
|
|
76
|
+
writeFileSync(outputPath, fileBuffer);
|
|
77
|
+
|
|
78
|
+
spinner.succeed(`Downloaded -> ${outputPath} (${formatBytes(fileBuffer.length)})`);
|
|
79
|
+
} catch (err: any) {
|
|
80
|
+
spinner.fail(`Download failed: ${err.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|