squidcloudctl 3.0.2 → 3.0.4

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.
@@ -82,6 +82,8 @@ const OPTIONS = {
82
82
  'files ls': [['--refresh', 'Bypass the local listing cache']],
83
83
  'files up': [['--folder <ref>', 'Target folder id or name'], ['--quiet', 'Suppress progress output']],
84
84
  'files dl': [['-o, --out <path>', 'Output path']],
85
+ 'storage dl': [['-o, --out <path>', 'Output path']],
86
+ 'files list': [['--refresh', 'Bypass listing cache']],
85
87
  'storage ls': [['--folder <ref>', 'List inside a folder'], ['--search <q>', 'Filter by name']],
86
88
  'share create': [
87
89
  ['--expires <dur>', 'Expiry like 24h / 7d / 30d'],
@@ -14,7 +14,7 @@ export default async function filesDownload(...args) {
14
14
  const { entry } = await resolveFileRef(ref);
15
15
  if (!isJsonMode())
16
16
  console.log(`\n ${chalk.dim(`fetching ${entry.name}…`)}`);
17
- const finalPath = await downloadFile({ id: entry.id, name: entry.name }, opts.out ? path.resolve(String(opts.out)) : undefined);
17
+ const finalPath = await downloadFile({ id: entry.id, name: entry.name, userId: entry.user_id }, opts.out ? path.resolve(String(opts.out)) : undefined);
18
18
  const size = fs.statSync(finalPath).size;
19
19
  if (isJsonMode())
20
20
  console.log(JSON.stringify({ file: finalPath, bytes: size, id: entry.id }, null, 2));
@@ -0,0 +1,94 @@
1
+ import { gcm } from '@noble/ciphers/aes.js';
2
+ import { hkdf } from '@noble/hashes/hkdf.js';
3
+ import { sha256 } from '@noble/hashes/sha2.js';
4
+ import { randomBytes } from '@noble/hashes/utils.js';
5
+ /* res54 chunk-format support for the CLI — mirrors the platform's
6
+ encryption-v3 / v2 / legacy layouts exactly so every stored file
7
+ decrypts locally without server-side help. */
8
+ export const V3_MAGIC = new Uint8Array([0x52, 0x45, 0x03]);
9
+ const KEY_LEN = 32;
10
+ const IV_LEN = 12;
11
+ export function utf8(n) {
12
+ return typeof n === 'string' ? new TextEncoder().encode(n) : n;
13
+ }
14
+ function concat(...parts) {
15
+ const total = parts.reduce((s, p) => s + p.length, 0);
16
+ const out = new Uint8Array(total);
17
+ let o = 0;
18
+ for (const p of parts) {
19
+ out.set(p, o);
20
+ o += p.length;
21
+ }
22
+ return out;
23
+ }
24
+ function u32le(n) {
25
+ const b = new Uint8Array(4);
26
+ new DataView(b.buffer).setUint32(0, n, true);
27
+ return b;
28
+ }
29
+ /** Platform key normalisation: key.slice(0,32).padEnd(32,'0') as UTF-8 bytes. */
30
+ export function deriveRawKey(keyString) {
31
+ return utf8(keyString.slice(0, 32).padEnd(32, '0'));
32
+ }
33
+ function hkdfExpand(ikm, info, len) {
34
+ return hkdf(sha256, ikm, new Uint8Array(0), info, len);
35
+ }
36
+ export function detectChunkVersion(blob) {
37
+ if (blob.length > 3 && blob[0] === V3_MAGIC[0] && blob[1] === V3_MAGIC[1] && blob[2] === V3_MAGIC[2])
38
+ return 3;
39
+ if (blob.length > 4 + 12 + 16) {
40
+ const adLen = new DataView(blob.buffer, blob.byteOffset).getUint32(0, true);
41
+ if (adLen >= 8 && adLen <= 128 && blob.length > 4 + adLen + 12)
42
+ return 2;
43
+ }
44
+ return 1;
45
+ }
46
+ /** v3: [magic3][origLen u32le][iv12][AES-GCM ct||tag] */
47
+ export function decryptV3(blob, masterRaw, userId, fileId, chunkIndex, totalChunks) {
48
+ const fileKeyRaw = hkdfExpand(masterRaw, concat(utf8('v3-file-master'), utf8(fileId)), KEY_LEN);
49
+ const idx = u32le(chunkIndex);
50
+ const chunkKey = hkdfExpand(fileKeyRaw, concat(utf8('v3-chunk'), idx), KEY_LEN);
51
+ const iv = blob.slice(7, 7 + IV_LEN);
52
+ const ct = blob.slice(7 + IV_LEN);
53
+ const aad = utf8(`res54-v3|${userId}|${fileId}|${chunkIndex}|${totalChunks}`);
54
+ const aes = gcm(chunkKey, iv, aad);
55
+ return aes.decrypt(ct);
56
+ }
57
+ /** v2: [4B adLen LE][AD][12B iv][ct] — key = raw utf8 of key string */
58
+ export function decryptV2(blob, keyRaw) {
59
+ const adLen = new DataView(blob.buffer, blob.byteOffset).getUint32(0, true);
60
+ const ad = blob.slice(4, 4 + adLen);
61
+ const iv = blob.slice(4 + adLen, 4 + adLen + 12);
62
+ const ct = blob.slice(4 + adLen + 12);
63
+ const aes = gcm(keyRaw, iv, ad);
64
+ return aes.decrypt(ct);
65
+ }
66
+ /** legacy: [12B iv][ct] */
67
+ export function decryptLegacy(blob, keyRaw) {
68
+ const aes = gcm(keyRaw, blob.slice(0, 12));
69
+ return aes.decrypt(blob.slice(12));
70
+ }
71
+ /** Try every known format; throws only if none succeed. */
72
+ export function decryptAny(blob, keyString, ctx) {
73
+ const attempts = [];
74
+ const version = detectChunkVersion(blob);
75
+ const keyRaw = deriveRawKey(keyString);
76
+ if (version === 3 && ctx?.userId && ctx.fileId) {
77
+ attempts.push(() => decryptV3(blob, keyRaw, ctx.userId, ctx.fileId, ctx.chunkIndex ?? 0, ctx.totalChunks ?? 1));
78
+ }
79
+ attempts.push(() => decryptV2(blob, keyRaw));
80
+ attempts.push(() => decryptLegacy(blob, keyRaw));
81
+ let lastErr = null;
82
+ for (const attempt of attempts) {
83
+ try {
84
+ return { plain: attempt(), version };
85
+ }
86
+ catch (e) {
87
+ lastErr = e;
88
+ }
89
+ }
90
+ throw new Error(`Decryption failed (${blob.length}B, v${version})${lastErr ? `: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}` : ''}`);
91
+ }
92
+ export function generateKeyHex() {
93
+ return Buffer.from(randomBytes(32)).toString('hex');
94
+ }
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { Readable } from 'node:stream';
4
4
  import { pipeline } from 'node:stream/promises';
5
- import { apiRaw, apiPost } from './api/client.js';
5
+ import { apiRaw, apiGet, apiPost } from './api/client.js';
6
6
  const CONCURRENCY = 4;
7
7
  const CHUNK_RETRIES = 3;
8
8
  function humanBytes(n) {
@@ -21,9 +21,8 @@ export function renderProgressBar(current, total, width = 26) {
21
21
  if (total <= 0)
22
22
  return '░'.repeat(width);
23
23
  const filled = Math.round((current / total) * width);
24
- return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled));
24
+ return '█'.repeat(Math.max(0, Math.min(width, filled))) + '░'.repeat(Math.max(0, width - filled));
25
25
  }
26
- /** Single-line live progress renderer for TTYs. */
27
26
  export class TransferProgress {
28
27
  label;
29
28
  totalBytes;
@@ -41,21 +40,18 @@ export class TransferProgress {
41
40
  const pct = this.totalBytes > 0 ? Math.min(100, (currentBytes / this.totalBytes) * 100) : 0;
42
41
  const elapsed = (Date.now() - this.startedAt) / 1000;
43
42
  const bps = elapsed > 0 ? currentBytes / elapsed : 0;
44
- const eta = bps > 0 && currentBytes > 0 ? (this.totalBytes - currentBytes) / bps : Infinity;
45
- const etaText = Number.isFinite(eta)
46
- ? eta > 90 ? `${Math.round(eta / 60)}m` : `${Math.round(eta)}s`
47
- : '—';
43
+ const eta = bps > 0 && currentBytes > 0 && currentBytes < this.totalBytes
44
+ ? (this.totalBytes - currentBytes) / bps : Infinity;
45
+ const etaText = Number.isFinite(eta) ? (eta > 90 ? `${Math.round(eta / 60)}m` : `${Math.round(eta)}s`) : '—';
48
46
  const line = `${this.label} ${renderProgressBar(currentBytes, this.totalBytes)} ${pct.toFixed(0)}% ${humanBytes(currentBytes)}/${humanBytes(this.totalBytes)} ${humanBytes(bps)}/s eta ${etaText}${extra ? ` ${extra}` : ''}`;
49
47
  if (line !== this.lastLine) {
50
48
  process.stdout.write(`\r\x1b[2K${line}`);
51
49
  this.lastLine = line;
52
50
  }
53
51
  }
54
- finish(ok) {
52
+ finish() {
55
53
  if (this.enabled)
56
54
  process.stdout.write('\r\x1b[2K');
57
- if (!ok && !this.enabled)
58
- return;
59
55
  }
60
56
  }
61
57
  export async function uploadFile(filePath, opts = {}) {
@@ -78,16 +74,15 @@ export async function uploadFile(filePath, opts = {}) {
78
74
  const data = await fs.promises.readFile(filePath);
79
75
  const chunkSize = init.chunk_size || 512 * 1024;
80
76
  const totalChunks = Math.max(1, Math.ceil(data.length / chunkSize));
81
- let completedBytes = 0;
82
- // Parallel chunk PUTs against signed cluster URLs — same dispatch shape as the web app.
83
77
  const urlByIndex = new Map(init.urls.map((u) => [u.index, u]));
84
78
  const queue = Array.from({ length: totalChunks }, (_, i) => i);
79
+ let completedBytes = 0;
85
80
  let done = 0;
86
81
  const worker = async () => {
87
- while (queue.length > 0) {
82
+ for (;;) {
88
83
  const idx = queue.shift();
89
84
  if (idx === undefined)
90
- break;
85
+ return;
91
86
  const target = urlByIndex.get(idx);
92
87
  if (!target?.upload_url)
93
88
  throw new Error(`Missing upload URL for chunk ${idx}`);
@@ -97,20 +92,20 @@ export async function uploadFile(filePath, opts = {}) {
97
92
  let attempt = 0;
98
93
  for (;;) {
99
94
  try {
100
- const res = await fetch(target.upload_url, {
95
+ const r = await fetch(target.upload_url, {
101
96
  method: 'PUT',
102
97
  headers: { 'Content-Type': 'application/octet-stream' },
103
98
  body: new Uint8Array(slice),
104
99
  });
105
- if (!res.ok)
106
- throw new Error(`Chunk ${idx} rejected (HTTP ${res.status})`);
100
+ if (!r.ok)
101
+ throw new Error(`chunk ${idx} HTTP ${r.status}`);
107
102
  break;
108
103
  }
109
104
  catch (err) {
110
105
  attempt++;
111
106
  if (attempt >= CHUNK_RETRIES)
112
107
  throw err;
113
- await new Promise((r) => setTimeout(r, 500 * Math.pow(2, attempt)));
108
+ await new Promise((r2) => setTimeout(r2, 500 * 2 ** attempt));
114
109
  }
115
110
  }
116
111
  done++;
@@ -120,38 +115,262 @@ export async function uploadFile(filePath, opts = {}) {
120
115
  }
121
116
  };
122
117
  await Promise.all(Array.from({ length: Math.min(CONCURRENCY, totalChunks) }, worker));
123
- progress.finish(true);
118
+ progress.finish();
124
119
  const complete = await apiPost('/upload/complete', {
125
120
  upload_id: init.upload_id,
126
121
  chunk_size: chunkSize,
127
122
  });
128
- return {
129
- id: complete?.id || complete?.file_id || init.file_id,
130
- name,
131
- size: stat.size,
132
- };
123
+ return { id: complete?.id || complete?.file_id || init.file_id, name, size: stat.size };
124
+ }
125
+ const isUsableKey = (k) => !!k && !/^(managed_key|sha256:.*|byok_encrypted|byok_protected)$/.test(k);
126
+ async function fetchVaultKey(fileId) {
127
+ const { SUPABASE_URL } = await import('./auth/constants.js');
128
+ const { authToken, anonKey } = await import('./auth/oauth-session.js');
129
+ if (!authToken)
130
+ return null;
131
+ try {
132
+ const r = await fetch(`${SUPABASE_URL}/functions/v1/file-key`, {
133
+ method: 'POST',
134
+ headers: {
135
+ 'Content-Type': 'application/json',
136
+ Authorization: `Bearer ${authToken}`,
137
+ ...(anonKey ? { apikey: anonKey } : {}),
138
+ },
139
+ body: JSON.stringify({ fileId }),
140
+ });
141
+ if (r.status === 500) {
142
+ const t = await r.text().catch(() => '');
143
+ if (/decrypt file key/i.test(t)) {
144
+ throw new Error('VAULT_KEY_DRIFT: the SQUIDVEIL_MASTER_KEY secret has drifted since this file was uploaded - restore it in Supabase > Edge Functions > Secrets to decrypt managed files.');
145
+ }
146
+ }
147
+ const d = (await r.json());
148
+ return d?.success && d.key ? d.key : null;
149
+ }
150
+ catch (err) {
151
+ if (err instanceof Error && err.message.startsWith('VAULT_KEY_DRIFT'))
152
+ throw err;
153
+ return null;
154
+ }
155
+ }
156
+ async function getBridgeKey() {
157
+ if (process.env.SQUIDCLOUD_API_KEY)
158
+ return process.env.SQUIDCLOUD_API_KEY;
159
+ const fs2 = await import('node:fs');
160
+ const os2 = await import('node:os');
161
+ const path2 = await import('node:path');
162
+ const keyFile = path2.join(os2.homedir(), '.squidcloud', 'bridge-key.json');
163
+ try {
164
+ const cached = JSON.parse(fs2.readFileSync(keyFile, 'utf8'));
165
+ if (cached?.key?.startsWith('cb_'))
166
+ return cached.key;
167
+ }
168
+ catch { }
169
+ const created = await apiPost('/keys', {
170
+ name: 'cli-bridge-' + new Date().toISOString().slice(0, 10),
171
+ });
172
+ const key = created?.raw_key;
173
+ if (!key)
174
+ throw new Error('Could not provision bridge key');
175
+ try {
176
+ fs2.mkdirSync(path2.dirname(keyFile), { recursive: true });
177
+ fs2.writeFileSync(keyFile, JSON.stringify({ key, id: created?.key?.id }), { mode: 0o600 });
178
+ }
179
+ catch { }
180
+ return key;
181
+ }
182
+ function parseTags(rawTags) {
183
+ let cur = rawTags;
184
+ for (let i = 0; i < 3; i++) {
185
+ if (typeof cur === 'string') {
186
+ try {
187
+ cur = JSON.parse(cur);
188
+ }
189
+ catch {
190
+ break;
191
+ }
192
+ }
193
+ else if (Array.isArray(cur)) {
194
+ cur = cur[0];
195
+ }
196
+ else
197
+ break;
198
+ }
199
+ if (cur && typeof cur === 'object' && Array.isArray(cur.chunks)) {
200
+ const t = cur;
201
+ return { chunks: t.chunks, encryptionKey: t.encryptionKey };
202
+ }
203
+ return { chunks: [] };
204
+ }
205
+ async function bridgeResolve(apiKey, chunks) {
206
+ const { SUPABASE_URL } = await import('./auth/constants.js');
207
+ const { authToken, anonKey } = await import('./auth/oauth-session.js');
208
+ if (!apiKey && !authToken)
209
+ throw new Error('Not authenticated');
210
+ const r = await fetch(`${SUPABASE_URL}/functions/v1/squidfs-bridge`, {
211
+ method: 'POST',
212
+ headers: {
213
+ 'Content-Type': 'application/json',
214
+ ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
215
+ ...(anonKey ? { apikey: anonKey } : {}),
216
+ },
217
+ body: JSON.stringify({ api_key: apiKey ?? '', action: 'resolve_download', chunks }),
218
+ });
219
+ const d = (await r.json());
220
+ if (!r.ok || !d.success || !Array.isArray(d.urls))
221
+ throw new Error(d.error || `bridge HTTP ${r.status}`);
222
+ const map = new Map();
223
+ for (const u of d.urls)
224
+ if (u.downloadUrl)
225
+ map.set(u.index ?? 0, u.downloadUrl);
226
+ return chunks.map((c) => {
227
+ const u = map.get(c.index);
228
+ if (!u)
229
+ throw new Error(`bridge missing URL for chunk ${c.index}`);
230
+ return u;
231
+ });
232
+ }
233
+ export async function downloadFile(target, destPath, onProgress) {
234
+ const errors = [];
235
+ // Authoritative chunk list (also covers SquidFS-written files).
236
+ let chunks = [];
237
+ let tagsKey;
238
+ let encrypted = false;
239
+ try {
240
+ const metaRow = await apiGet(`/files/${target.id}/metadata`);
241
+ const row = metaRow.file ?? {};
242
+ encrypted = row.encrypted === true;
243
+ const parsed = parseTags(row.tags);
244
+ chunks = parsed.chunks;
245
+ tagsKey = parsed.encryptionKey;
246
+ if (!target.userId && typeof row.user_id === 'string')
247
+ target.userId = row.user_id;
248
+ }
249
+ catch { /* metadata optional */ }
250
+ const hasB2 = chunks.length > 0 && chunks.every((c) => c.repo === 'b2');
251
+ const hasBridgeOnly = chunks.length > 0 && !hasB2;
252
+ // 1) Signed API mode for platform-stored chunks.
253
+ if (hasB2 || chunks.length === 0) {
254
+ try {
255
+ const res = await apiRaw(`/download/${target.id}?signed=true`);
256
+ if (res.ok) {
257
+ const meta = (await res.json());
258
+ if (meta?.success && Array.isArray(meta.signed_urls) && meta.signed_urls.length > 0) {
259
+ return await fetchDecryptWrite(meta.signed_urls, target, destPath, onProgress);
260
+ }
261
+ errors.push('signed: empty urls');
262
+ }
263
+ else {
264
+ errors.push(`signed: HTTP ${res.status}`);
265
+ }
266
+ }
267
+ catch (e) {
268
+ errors.push(`signed: ${e instanceof Error ? e.message : String(e)}`);
269
+ }
270
+ }
271
+ // 2) Bridge mode (SquidFS-origin files).
272
+ if (hasBridgeOnly) {
273
+ try {
274
+ const apiKey = process.env.SQUIDCLOUD_API_KEY ?? null;
275
+ const urls = await bridgeResolve(apiKey, chunks.map((c) => ({ path: c.path, index: c.index, bucket: c.bucket })));
276
+ return await fetchDecryptWrite(urls, target, destPath, onProgress);
277
+ }
278
+ catch (e) {
279
+ errors.push(`bridge: ${e instanceof Error ? e.message : String(e)}`);
280
+ }
281
+ }
282
+ // 3) Plain server-decrypted stream (non-distributed storage).
283
+ if (!encrypted && chunks.length === 0) {
284
+ try {
285
+ return await streamPlain(`/download/${target.id}`, target.name, destPath, onProgress);
286
+ }
287
+ catch (e) {
288
+ errors.push(`plain: ${e instanceof Error ? e.message : String(e)}`);
289
+ }
290
+ }
291
+ throw new Error(errors.join(' · ') || 'No downloadable strategy succeeded');
292
+ }
293
+ async function resolveKey(fileId, tagsKey) {
294
+ if (tagsKey && !/^(managed_key|sha256:\S+|byok_encrypted|byok_protected)$/.test(tagsKey))
295
+ return tagsKey;
296
+ return fetchVaultKey(fileId);
297
+ }
298
+ async function fetchDecryptWrite(urls, target, destPath, onProgress) {
299
+ const totalChunks = urls.length;
300
+ const finalPath = destPath || target.name;
301
+ const out = fs.createWriteStream(finalPath);
302
+ const progress = new TransferProgress('download', target.size ?? 0, process.stdout.isTTY);
303
+ let keyString;
304
+ for (let i = 0; i < totalChunks; i++) {
305
+ const r = await fetch(urls[i % urls.length]);
306
+ if (!r.ok)
307
+ throw new Error(`chunk ${i} HTTP ${r.status}`);
308
+ const blob = new Uint8Array(await r.arrayBuffer());
309
+ const { detectChunkVersion, decryptAny } = await import('./crypto/res54.js');
310
+ const version = detectChunkVersion(blob);
311
+ if (version === 1 && blob.length <= 28) {
312
+ out.write(Buffer.from(blob));
313
+ continue;
314
+ }
315
+ if (keyString === undefined) {
316
+ keyString = (await resolveKey(target.id)) ?? '';
317
+ if (!keyString) {
318
+ out.end();
319
+ throw new Error('Encrypted file but its key is unavailable (BYOK without key?)');
320
+ }
321
+ }
322
+ let plain;
323
+ try {
324
+ plain = decryptAny(blob, keyString, {
325
+ userId: target.userId,
326
+ fileId: target.id,
327
+ chunkIndex: i,
328
+ totalChunks,
329
+ }).plain;
330
+ }
331
+ catch (err) {
332
+ if (version === 1) {
333
+ plain = blob;
334
+ }
335
+ else {
336
+ out.end();
337
+ throw err;
338
+ }
339
+ }
340
+ progress.update(i + 1, `chunk ${i + 1}/${totalChunks}`);
341
+ void onProgress;
342
+ const buf = Buffer.from(plain);
343
+ if (!out.write(buf))
344
+ await new Promise((r2) => out.once('drain', () => r2()));
345
+ }
346
+ out.end();
347
+ await new Promise((resolve, reject) => {
348
+ out.on('finish', () => resolve());
349
+ out.on('error', reject);
350
+ });
351
+ progress.finish();
352
+ return finalPath;
133
353
  }
134
- export async function downloadFile(fileRef, destPath, onProgress) {
135
- const res = await apiRaw(`/download/${fileRef.id}`);
354
+ async function streamPlain(reqPath, fallbackName, destPath, onProgress) {
355
+ const res = await apiRaw(reqPath);
136
356
  if (!res.ok) {
137
- const text = await res.text().catch(() => '');
138
- throw new Error(text.slice(0, 300) || `Download failed (HTTP ${res.status})`);
357
+ const t = await res.text().catch(() => '');
358
+ throw new Error(t.slice(0, 300) || `Download failed (HTTP ${res.status})`);
139
359
  }
140
360
  const total = Number(res.headers.get('content-length')) || 0;
141
361
  const progress = new TransferProgress('download', total, process.stdout.isTTY);
142
362
  const nodeStream = Readable.fromWeb(res.body);
143
363
  const header = res.headers.get('content-disposition') || '';
144
- const match = header.match(/filename="?([^";]+)"?/i);
145
- const fileName = match?.[1] || fileRef.name;
146
- const finalPath = destPath || fileName;
364
+ const m = header.match(/filename="?([^";]+)"?/i);
365
+ const finalPath = destPath || m?.[1] || fallbackName;
147
366
  let received = 0;
148
- nodeStream.on('data', (chunk) => {
149
- received += chunk.length;
367
+ nodeStream.on('data', (c) => {
368
+ received += c.length;
150
369
  onProgress?.({ current: received, total: total || received, stage: 'downloading' });
151
370
  progress.update(received);
152
371
  });
153
372
  await pipeline(nodeStream, fs.createWriteStream(finalPath));
154
- progress.finish(true);
373
+ progress.finish();
155
374
  return finalPath;
156
375
  }
157
376
  function guessMime(name) {
@@ -166,3 +385,4 @@ function guessMime(name) {
166
385
  };
167
386
  return table[ext] || 'application/octet-stream';
168
387
  }
388
+ export { apiPost };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidcloudctl",
3
- "version": "3.0.2",
3
+ "version": "3.0.4",
4
4
  "description": "SquidCloud CLI (squidcloudctl) — encrypted storage, shares and secrets from your terminal",
5
5
  "main": "dist/bin/squidcloud.js",
6
6
  "bin": {