squidcloudctl 3.0.4 → 3.0.6

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.
@@ -0,0 +1 @@
1
+ upload test v3
@@ -230,9 +230,56 @@ async function bridgeResolve(apiKey, chunks) {
230
230
  return u;
231
231
  });
232
232
  }
233
+ /** Frontend-parity chunk transport: squidcloud-cluster issues the signed
234
+ object URL for each chunk. Requires a real Supabase JWT (sb_token from
235
+ browser login). */
236
+ async function getSupabaseJwt() {
237
+ const { sbToken, sbTokenExpiresAt } = await import('./auth/oauth-session.js');
238
+ if (sbToken && (!sbTokenExpiresAt || Date.now() < sbTokenExpiresAt))
239
+ return sbToken;
240
+ throw new Error('WEB_SESSION_REQUIRED: this download needs a short-lived web session token. Run `squidcloudctl login` again, then retry.');
241
+ }
242
+ async function clusterDownloadChunk(chunk, userId) {
243
+ const { SUPABASE_URL } = await import('./auth/constants.js');
244
+ const jwt = await getSupabaseJwt();
245
+ const r = await fetch(`${SUPABASE_URL}/functions/v1/squidcloud-cluster`, {
246
+ method: 'POST',
247
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}` },
248
+ body: JSON.stringify({
249
+ action: 'download',
250
+ path: chunk.path,
251
+ repo: chunk.repo,
252
+ clusterId: chunk.clusterId,
253
+ bucket: chunk.bucket,
254
+ userId,
255
+ }),
256
+ });
257
+ const text = await r.text();
258
+ let data;
259
+ try {
260
+ data = JSON.parse(text);
261
+ }
262
+ catch {
263
+ throw new Error(`cluster HTTP ${r.status}: ${text.slice(0, 120)}`);
264
+ }
265
+ if (!r.ok)
266
+ throw new Error(data?.error || `cluster HTTP ${r.status}`);
267
+ if (data.legacy) {
268
+ const decoded = Buffer.from(String(data.content).replace(/\s/g, ''), 'base64').toString('utf8');
269
+ const parsed = JSON.parse(decoded);
270
+ const b64 = parsed.v === '2.3' ? parsed.d : parsed.chunkData;
271
+ return new Uint8Array(Buffer.from(String(b64), 'base64'));
272
+ }
273
+ if (!data.downloadUrl)
274
+ throw new Error('No download URL received');
275
+ const obj = await fetch(data.downloadUrl);
276
+ if (!obj.ok)
277
+ throw new Error(`CDN HTTP ${obj.status}`);
278
+ return new Uint8Array(await obj.arrayBuffer());
279
+ }
233
280
  export async function downloadFile(target, destPath, onProgress) {
234
281
  const errors = [];
235
- // Authoritative chunk list (also covers SquidFS-written files).
282
+ // Parse metadata exactly like the web app.
236
283
  let chunks = [];
237
284
  let tagsKey;
238
285
  let encrypted = false;
@@ -246,50 +293,88 @@ export async function downloadFile(target, destPath, onProgress) {
246
293
  if (!target.userId && typeof row.user_id === 'string')
247
294
  target.userId = row.user_id;
248
295
  }
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) {
296
+ catch { /* optional */ }
297
+ // Frontend key-resolution order.
298
+ let defaultKey = tagsKey;
299
+ if (defaultKey === 'managed_key')
300
+ defaultKey = (await fetchVaultKey(target.id)) ?? undefined;
301
+ // ── Strategy A: exact frontend transport (squidcloud-cluster) ──
302
+ if (chunks.length > 0) {
254
303
  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);
304
+ const finalPath2 = destPath || target.name;
305
+ const out = fs.createWriteStream(finalPath2);
306
+ const progress = new TransferProgress('download', target.size ?? 0, process.stdout.isTTY);
307
+ const noble = await import('@noble/hashes/hkdf.js');
308
+ const sha2 = await import('@noble/hashes/sha2.js');
309
+ const enc = new TextEncoder();
310
+ const { detectChunkVersion, decryptAny, decryptV3, deriveRawKey } = await import('./crypto/res54.js');
311
+ const master = deriveRawKey(defaultKey || '');
312
+ const fileKeyRaw = noble.hkdf(sha2.sha256, master, new Uint8Array(0), enc.encode('v3-file-master' + target.id), 32);
313
+ let activeMaster = master;
314
+ for (let i = 0; i < chunks.length; i++) {
315
+ const blob = await clusterDownloadChunk(chunks[i], target.userId || '');
316
+ const version = detectChunkVersion(blob);
317
+ let plain;
318
+ if (version === 1 && !encrypted && !defaultKey) {
319
+ plain = blob;
260
320
  }
261
- errors.push('signed: empty urls');
262
- }
263
- else {
264
- errors.push(`signed: HTTP ${res.status}`);
321
+ else {
322
+ if (!defaultKey && byokCache === undefined) {
323
+ byokCache = (await askByokKey(target.name)) ?? '';
324
+ }
325
+ const attemptKey = defaultKey || byokCache || '';
326
+ try {
327
+ if (version === 3) {
328
+ const m2 = attemptKey ? deriveRawKey(attemptKey) : master;
329
+ plain = decryptV3(blob, m2, target.userId || '', target.id, i, chunks.length);
330
+ activeMaster = m2;
331
+ }
332
+ else {
333
+ plain = decryptAny(blob, attemptKey, {
334
+ userId: target.userId, fileId: target.id, chunkIndex: i, totalChunks: chunks.length,
335
+ }).plain;
336
+ }
337
+ }
338
+ catch (err) {
339
+ if (!attemptKey)
340
+ throw new Error('BYOK_KEY_REQUIRED: pass --key or run interactively');
341
+ throw err;
342
+ }
343
+ void activeMaster;
344
+ }
345
+ void fileKeyRaw;
346
+ progress.update(i + 1, `chunk ${i + 1}/${chunks.length}`);
347
+ void onProgress;
348
+ const buf = Buffer.from(plain);
349
+ if (!out.write(buf))
350
+ await new Promise((r2) => out.once('drain', () => r2()));
265
351
  }
352
+ out.end();
353
+ await new Promise((resolve, reject) => { out.on('finish', () => resolve()); out.on('error', reject); });
354
+ progress.finish();
355
+ return finalPath2;
266
356
  }
267
357
  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)}`);
358
+ const msg = e instanceof Error ? e.message : String(e);
359
+ if (msg.startsWith('VAULT_KEY_DRIFT'))
360
+ throw e;
361
+ errors.push(`cluster: ${msg}`);
289
362
  }
290
363
  }
291
364
  throw new Error(errors.join(' · ') || 'No downloadable strategy succeeded');
292
365
  }
366
+ let byokCache;
367
+ async function askByokKey(fileName) {
368
+ if (byokCache !== undefined)
369
+ return byokCache;
370
+ if (!process.stdin.isTTY || process.env.SQUIDCLOUD_NONINTERACTIVE === '1')
371
+ return null;
372
+ const readline = await import('node:readline');
373
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
374
+ const answer = await new Promise((res) => rl.question(`\u25cf decryption key for ${fileName}: `, (v) => { rl.close(); res(v); }));
375
+ byokCache = answer.trim() || null;
376
+ return byokCache;
377
+ }
293
378
  async function resolveKey(fileId, tagsKey) {
294
379
  if (tagsKey && !/^(managed_key|sha256:\S+|byok_encrypted|byok_protected)$/.test(tagsKey))
295
380
  return tagsKey;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidcloudctl",
3
- "version": "3.0.4",
3
+ "version": "3.0.6",
4
4
  "description": "SquidCloud CLI (squidcloudctl) — encrypted storage, shares and secrets from your terminal",
5
5
  "main": "dist/bin/squidcloud.js",
6
6
  "bin": {