diff --git a/api/file/[code]/confirm.js b/api/file/[code]/confirm.js index 21f404a..9df511c 100644 --- a/api/file/[code]/confirm.js +++ b/api/file/[code]/confirm.js @@ -1,33 +1,18 @@ /** - * POST /api/file/:code/confirm + * POST /api/file/:code/confirm — DÉPRÉCIÉ (no-op) * - * Confirme un téléchargement réussi côté destinataire (appelé par le client - * UNIQUEMENT après un déchiffrement réussi). Incrémente le compteur ; si le - * quota est atteint, supprime tout le transfert. + * Historiquement, ce endpoint consommait le quota après un déchiffrement réussi + * côté client. Problème : un client malveillant pouvait télécharger en boucle + * sans jamais appeler /confirm, contournant entièrement `maxDownloads`. * - * Pourquoi un endpoint séparé de /download : - * Le service est zero-knowledge — le serveur ne peut pas vérifier le mot de - * passe. Si /download consommait le quota, un simple typo (déchiffrement - * échoué côté client) détruirait le fichier. En déplaçant la consommation - * ici, un mauvais mot de passe ou un download interrompu ne consomme rien : - * le fichier reste disponible jusqu'à sa confirmation, son expiration, ou le - * cron de nettoyage. - * - * Best-effort : le Blob store n'est pas transactionnel. Le filet de sécurité - * ultime reste l'expiration (EXPIRATION_HOURS) + le cron cleanup. + * La consommation du quota est désormais faite côté serveur, au téléchargement + * du dernier chunk (voir api/file/[code]/download.js). Ce endpoint est conservé + * uniquement pour ne pas casser d'anciens clients déjà chargés dans des + * navigateurs : il valide la requête et répond 200 sans rien modifier (sinon il + * provoquerait un double comptage). */ -import { list, put, del } from '@vercel/blob'; -import { timingSafeEqual } from 'crypto'; - -const CODE_REGEX = /^[A-Z2-9]{6}$/; -const VERIFIER_REGEX = /^[0-9a-f]{64}$/; - -/** Comparaison en temps constant de deux chaînes hex de même format. */ -function safeEqual(a, b) { - if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false; - return timingSafeEqual(Buffer.from(a), Buffer.from(b)); -} +const CODE_REGEX = /^[A-Z2-9]{6}$/; export default async function handler(req, res) { if (req.method !== 'POST') { @@ -39,68 +24,6 @@ export default async function handler(req, res) { return res.status(400).json({ error: 'Format de code invalide' }); } - const token = process.env.BLOB_READ_WRITE_TOKEN; - if (!token) { - return res.status(500).json({ error: 'Configuration serveur incomplète' }); - } - - try { - const { blobs: metaBlobs } = await list({ prefix: `metadata/${code}.json`, limit: 1 }); - if (!metaBlobs.length) { - // Déjà consommé/supprimé → succès idempotent. - return res.json({ ok: true, consumed: true }); - } - - const metaResponse = await fetch(metaBlobs[0].url, { - headers: { Authorization: `Bearer ${token}` }, - cache: 'no-store', - }); - if (!metaResponse.ok) { - return res.json({ ok: true, consumed: true }); - } - const meta = await metaResponse.json(); - - // Preuve de connaissance du mot de passe : sans le verifier (dérivé du mot - // de passe via PBKDF2), impossible de consommer le quota — et donc de - // détruire un transfert en connaissant seulement le code. - if (meta.verifier) { - const provided = (req.headers['x-blob-verifier'] || '').toLowerCase(); - if (!VERIFIER_REGEX.test(provided) || !safeEqual(provided, meta.verifier)) { - return res.status(403).json({ error: 'Verifier invalide' }); - } - } - - const newCount = (meta.downloadCount || 0) + 1; - const reachedLimit = meta.maxDownloads > 0 && newCount >= meta.maxDownloads; - - if (reachedLimit) { - // Quota atteint → purge complète (métadonnées + tous les chunks). - const urlsToDelete = [metaBlobs[0].url]; - if (meta.files) { - for (const f of meta.files) { - if (f.chunkUrls) urlsToDelete.push(...f.chunkUrls); - } - } - await del(urlsToDelete).catch(e => - console.error('[confirm] Erreur suppression :', e.message) - ); - console.log(`[confirm] Transfert ${code} consommé et supprimé (quota atteint)`); - return res.json({ ok: true, consumed: true }); - } - - // Sinon, incrémenter le compteur et conserver le transfert. - const updatedMeta = { ...meta, downloadCount: newCount }; - await put(metaBlobs[0].pathname, JSON.stringify(updatedMeta, null, 2), { - access: 'private', - contentType: 'application/json', - addRandomSuffix: false, - allowOverwrite: true, - }); - console.log(`[confirm] Transfert ${code} : téléchargement ${newCount}/${meta.maxDownloads}`); - return res.json({ ok: true, consumed: false, downloadCount: newCount }); - - } catch (err) { - console.error('[confirm] Erreur :', err.message); - return res.status(500).json({ error: 'Erreur lors de la confirmation' }); - } + // No-op : la consommation réelle a lieu dans /download (chunk final). + return res.json({ ok: true, deprecated: true }); } diff --git a/api/file/[code]/download.js b/api/file/[code]/download.js index 7df44e2..91694d9 100644 --- a/api/file/[code]/download.js +++ b/api/file/[code]/download.js @@ -4,13 +4,17 @@ * Proxy serveur pour télécharger un chunk de fichier chiffré. * Paramètres query : ?file=0&chunk=0 * - * Gestion du quota : ce endpoint ne fait que VÉRIFIER le quota (lecture seule). - * La consommation réelle (incrément + suppression) est déclenchée par le client - * via POST .../confirm, une fois le déchiffrement réussi. Ainsi un mauvais mot - * de passe ou un téléchargement interrompu ne consomme ni ne détruit rien. + * Gestion du quota : le quota est consommé côté serveur quand le DERNIER chunk + * du dernier fichier est servi — c'est-à-dire quand le destinataire a récupéré + * l'intégralité du ciphertext. Impossible d'obtenir le fichier complet sans + * demander ce chunk, donc la limite est réellement appliquée sans dépendre d'un + * appel client. Un mauvais mot de passe est rejeté (403) AVANT tout service, et + * un téléchargement interrompu n'atteint jamais le chunk final → rien n'est + * consommé ni détruit dans ces cas. (L'endpoint .../confirm est conservé en + * no-op pour compatibilité avec d'anciens clients.) */ -import { list } from '@vercel/blob'; +import { list, put, del } from '@vercel/blob'; import { timingSafeEqual } from 'crypto'; const CODE_REGEX = /^[A-Z2-9]{6}$/; @@ -22,6 +26,34 @@ function safeEqual(a, b) { return timingSafeEqual(Buffer.from(a), Buffer.from(b)); } +/** + * Consomme un téléchargement : incrémente le compteur, et purge tout le + * transfert (métadonnées + chunks) si le quota est atteint. Appelé une seule + * fois par récupération complète, après que le chunk final a été servi. + */ +async function consumeDownload(meta, metaBlob) { + const newCount = (meta.downloadCount || 0) + 1; + const reachedLimit = meta.maxDownloads > 0 && newCount >= meta.maxDownloads; + + if (reachedLimit) { + const urls = [metaBlob.url]; + for (const f of meta.files || []) { + if (f.chunkUrls) urls.push(...f.chunkUrls); + } + await del(urls); + console.log(`[download] Transfert ${meta.code} consommé et purgé (quota atteint)`); + return; + } + + await put(metaBlob.pathname, JSON.stringify({ ...meta, downloadCount: newCount }, null, 2), { + access: 'private', + contentType: 'application/json', + addRandomSuffix: false, + allowOverwrite: true, + }); + console.log(`[download] Transfert ${meta.code} : téléchargement ${newCount}/${meta.maxDownloads}`); +} + export default async function handler(req, res) { if (req.method !== 'GET') { return res.status(405).json({ error: 'Méthode non autorisée' }); @@ -86,8 +118,8 @@ export default async function handler(req, res) { return res.status(410).json({ error: 'Fichier sans chunks' }); } - // Vérification du quota en LECTURE SEULE. Aucune écriture ici : la - // consommation a lieu via POST .../confirm après déchiffrement réussi. + // Garde quota : si déjà atteint, on ne sert rien (le transfert a en principe + // déjà été purgé, mais on protège le cas où la purge a échoué). if (meta.maxDownloads > 0 && meta.downloadCount >= meta.maxDownloads) { return res.status(410).json({ error: 'Nombre maximum de téléchargements atteint' }); } @@ -97,6 +129,12 @@ export default async function handler(req, res) { return res.status(400).json({ error: 'Index de chunk invalide' }); } + // Le chunk final du dernier fichier = récupération complète du ciphertext. + // C'est lui qui consommera le quota une fois le flux terminé (voir plus bas). + const isFinalChunk = + (fileIndex === files.length - 1) && + (chunkIndex === file.chunkUrls.length - 1); + // Télécharger et streamer le chunk const chunkUrl = file.chunkUrls[chunkIndex]; const chunkResponse = await fetch(chunkUrl, { @@ -113,18 +151,31 @@ export default async function handler(req, res) { res.setHeader('X-Content-Type-Options', 'nosniff'); const reader = chunkResponse.body.getReader(); + let streamOk = false; try { while (true) { const { done, value } = await reader.read(); if (done) break; res.write(Buffer.from(value)); } - res.end(); + streamOk = true; } catch (streamErr) { console.error(`[download] Erreur stream :`, streamErr.message); - if (!res.writableEnded) res.end(); } + // Consommer le quota UNIQUEMENT si le ciphertext complet vient d'être servi + // (chunk final + flux terminé sans erreur). Le verifier ayant déjà été validé + // plus haut, atteindre ce point prouve la connaissance du mot de passe. + if (streamOk && isFinalChunk && meta.maxDownloads > 0) { + try { + await consumeDownload(meta, metaBlobs[0]); + } catch (e) { + console.error('[download] Consommation quota échouée :', e.message); + } + } + + if (!res.writableEnded) res.end(); + } catch (err) { console.error('[download] Erreur :', err.message); if (!res.headersSent) { diff --git a/api/file/[code]/info.js b/api/file/[code]/info.js index ae82ddb..bc48062 100644 --- a/api/file/[code]/info.js +++ b/api/file/[code]/info.js @@ -1,69 +1,121 @@ /** * GET /api/file/:code/info * - * Retourne les métadonnées publiques d'un transfert. + * Retourne les métadonnées d'un transfert. Les NOMS de fichiers ne sont inclus + * que si l'appelant fournit un verifier valide (preuve de mot de passe) via + * l'en-tête x-blob-verifier ; sinon seul un sous-ensemble non sensible est + * renvoyé (sel, expiration, nombre de fichiers, taille totale). + * + * Anti-énumération : le temps de réponse est uniformisé. Tous les chemins + * (succès, code mal formé, introuvable, expiré, quota atteint) répondent après + * le même délai plancher + un jitter aléatoire. Sans ça, un code valide + * répondrait plus vite qu'un code inexistant → un oracle temporel permettrait + * de distinguer les codes valides. Le blocage brute-force reste assuré en amont + * par le middleware edge ; ce plancher supprime juste la fuite par timing. */ import { list } from '@vercel/blob'; +import { timingSafeEqual } from 'crypto'; const BLOB_TOKEN = () => process.env.BLOB_READ_WRITE_TOKEN; -const CODE_REGEX = /^[A-Z2-9]{6}$/; +const CODE_REGEX = /^[A-Z2-9]{6}$/; +const VERIFIER_REGEX = /^[0-9a-f]{64}$/; + +/** Comparaison en temps constant de deux chaînes hex de même format. */ +function safeEqual(a, b) { + if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false; + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} + +// Plancher de temps de réponse (ms). Surchargé à 0 dans les tests pour la vitesse. +const MIN_RESPONSE_MS = () => parseInt(process.env.INFO_MIN_RESPONSE_MS || '500', 10); +const JITTER_MS = 80; -// Délai artificiel sur les codes invalides — ralentit l'énumération brute-force -const delay = (ms) => new Promise(r => setTimeout(r, ms)); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** + * Répond après avoir égalisé le temps écoulé au plancher (+ jitter), de sorte + * que la durée ne dépende pas de l'issue (code valide ou non). + */ +async function respond(start, res, status, body) { + const elapsed = Date.now() - start; + const jitter = Math.floor(Math.random() * JITTER_MS); + const wait = Math.max(0, MIN_RESPONSE_MS() - elapsed) + jitter; + if (wait > 0) await sleep(wait); + return res.status(status).json(body); +} export default async function handler(req, res) { if (req.method !== 'GET') { return res.status(405).json({ error: 'Méthode non autorisée' }); } - const code = (req.query.code || '').toString().toUpperCase(); + const start = Date.now(); + const code = (req.query.code || '').toString().toUpperCase(); + if (!CODE_REGEX.test(code)) { - await delay(1000); - return res.status(400).json({ error: 'Format de code invalide' }); + return respond(start, res, 400, { error: 'Format de code invalide' }); } try { const { blobs } = await list({ prefix: `metadata/${code}.json`, limit: 1 }); if (!blobs.length) { - await delay(1000); - return res.status(404).json({ error: 'Code invalide ou expiré' }); + return respond(start, res, 404, { error: 'Code invalide ou expiré' }); } const response = await fetch(blobs[0].url, { headers: { Authorization: `Bearer ${BLOB_TOKEN()}` }, }); if (!response.ok) { - return res.status(404).json({ error: 'Métadonnée introuvable' }); + return respond(start, res, 404, { error: 'Métadonnée introuvable' }); } const meta = await response.json(); if (Date.now() > meta.expiresAt) { - return res.status(410).json({ error: 'Ce fichier a expiré' }); + return respond(start, res, 410, { error: 'Ce fichier a expiré' }); } if (meta.maxDownloads > 0 && meta.downloadCount >= meta.maxDownloads) { - return res.status(410).json({ error: 'Nombre maximum de téléchargements atteint' }); + return respond(start, res, 410, { error: 'Nombre maximum de téléchargements atteint' }); } - if (!meta.files || !meta.files.length) { - return res.status(410).json({ error: 'Format de transfert non supporté' }); + return respond(start, res, 410, { error: 'Format de transfert non supporté' }); } - const files = meta.files.map(f => ({ - originalName: f.originalName, - size: f.size, - chunkCount: f.chunkCount || 0, - })); + // Les NOMS de fichiers sont des données sensibles : on ne les révèle qu'à un + // client capable de prouver la connaissance du mot de passe (verifier). Sans + // verifier valide, on ne renvoie que le strict nécessaire — sel (requis pour + // dériver la clé), expiration, nombre de fichiers et taille totale — pour + // l'aperçu, sans divulguer les noms à quiconque possède seulement le code. + const provided = (req.headers['x-blob-verifier'] || '').toLowerCase(); + let includeFiles = false; + if (!meta.verifier) { + includeFiles = true; // transfert legacy non protégé → rien à cacher + } else if (provided) { + if (!VERIFIER_REGEX.test(provided) || !safeEqual(provided, meta.verifier)) { + return respond(start, res, 403, { error: 'Mot de passe incorrect' }); + } + includeFiles = true; + } - return res.json({ - files, + const payload = { salt: meta.salt, expiresAt: meta.expiresAt, maxDownloads: meta.maxDownloads, downloadCount: meta.downloadCount, - }); + fileCount: meta.files.length, + totalSize: meta.totalSize, + }; + if (includeFiles) { + payload.files = meta.files.map((f) => ({ + originalName: f.originalName, + size: f.size, + chunkCount: f.chunkCount || 0, + })); + } + + return respond(start, res, 200, payload); } catch (err) { console.error('[info] Erreur :', err.message); - return res.status(500).json({ error: 'Erreur interne' }); + return respond(start, res, 500, { error: 'Erreur interne' }); } } diff --git a/api/upload/chunk.js b/api/upload/chunk.js index a21e8d0..eba75ec 100644 --- a/api/upload/chunk.js +++ b/api/upload/chunk.js @@ -22,7 +22,7 @@ import { randomBytes } from 'crypto'; const MAX_FILE_SIZE_MB = parseInt(process.env.MAX_FILE_SIZE_MB || '25', 10); const EXPIRATION_HOURS = parseInt(process.env.EXPIRATION_HOURS || '1', 10); -const MAX_DOWNLOADS = parseInt(process.env.MAX_DOWNLOADS || '3', 10); +const MAX_DOWNLOADS = parseInt(process.env.MAX_DOWNLOADS || '1', 10); const MAX_CHUNK_BYTES = 4 * 1024 * 1024; const MAX_FILES = 50; const MAX_CHUNKS_PER_FILE = 100; diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index b52cf9e..7fbdbea 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -123,8 +123,16 @@ function sendChunk(code, chunkData, chunkIndex, chunkTotal, fileIndex, fileTotal }); } -export async function getFileInfo(code) { - const res = await fetch(`/api/file/${encodeURIComponent(code)}/info`); +/** + * Récupère les infos d'un transfert. + * Sans `verifier` : sous-ensemble non sensible (sel, expiration, nombre de + * fichiers, taille totale) — les noms de fichiers sont masqués. + * Avec un `verifier` valide : inclut les noms de fichiers (et renvoie 403 si le + * mot de passe est incorrect). + */ +export async function getFileInfo(code, verifier) { + const opts = verifier ? { headers: { 'x-blob-verifier': verifier } } : undefined; + const res = await fetch(`/api/file/${encodeURIComponent(code)}/info`, opts); const body = await res.json().catch(() => ({})); if (!res.ok) { const err = new Error(body.error || `Erreur HTTP ${res.status}`); @@ -134,29 +142,6 @@ export async function getFileInfo(code) { return body; } -/** - * Confirme un téléchargement réussi (à appeler UNIQUEMENT après un déchiffrement - * réussi de tous les fichiers). Déclenche la consommation du quota côté serveur. - * Best-effort : un échec ne doit pas casser l'UX, le fichier reste protégé par - * son expiration. - */ -export async function confirmDownload(code, verifier) { - // Quelques tentatives : un échec laisserait le quota non consommé. - for (let attempt = 0; attempt < 3; attempt++) { - try { - const res = await fetch(`/api/file/${encodeURIComponent(code)}/confirm`, { - method: 'POST', - headers: { 'x-blob-verifier': verifier }, - }); - if (res.ok || res.status < 500) return; // 4xx : inutile de réessayer - } catch { - // erreur réseau → retry - } - await new Promise(r => setTimeout(r, 1000 * (attempt + 1))); - } - // silencieux — l'expiration + le cron de nettoyage restent le filet ultime -} - export async function cancelTransfer(code, deleteToken) { const res = await fetch(`/api/file/${encodeURIComponent(code)}/delete`, { method: 'POST', diff --git a/frontend/src/i18n/translations.js b/frontend/src/i18n/translations.js index 35aacd1..67cc10b 100644 --- a/frontend/src/i18n/translations.js +++ b/frontend/src/i18n/translations.js @@ -67,10 +67,11 @@ export const translations = { 'receive.error.notfound': 'Code introuvable. Vérifiez-le et réessayez.', 'receive.error.invalid': 'Format de code invalide.', 'receive.files.count': 'fichiers', + 'receive.files.one': 'fichier', + 'receive.names.hidden': 'Noms affichés après saisie du mot de passe', 'receive.error.password': "Veuillez saisir le mot de passe fourni par l'expéditeur.", 'receive.error.download': 'Téléchargement du fichier chiffré échoué', 'receive.error.badpassword': "Mot de passe incorrect. Vérifiez-le et réessayez — rien n'a été consommé.", - 'receive.downloads.remaining': '{count} téléchargement(s) restant(s)', 'receive.saved.hint': 'Si votre navigateur a bloqué certains téléchargements, enregistrez-les ici :', 'receive.saved.save': 'Enregistrer', 'common.showpass': 'Afficher le mot de passe', @@ -188,10 +189,11 @@ export const translations = { 'receive.error.notfound': 'Code not found. Please check and try again.', 'receive.error.invalid': 'Invalid code format.', 'receive.files.count': 'files', + 'receive.files.one': 'file', + 'receive.names.hidden': 'Names shown after entering the password', 'receive.error.password': 'Please enter the password provided by the sender.', 'receive.error.download': 'Failed to download encrypted file', 'receive.error.badpassword': 'Incorrect password. Check it and try again — nothing was consumed.', - 'receive.downloads.remaining': '{count} download(s) remaining', 'receive.saved.hint': 'If your browser blocked some downloads, save them here:', 'receive.saved.save': 'Save', 'common.showpass': 'Show password', diff --git a/frontend/src/index.css b/frontend/src/index.css index edf4840..2a9ec6f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -772,6 +772,13 @@ a:hover { color: var(--color-muted); } +.file-names-hidden { + font-size: 0.8rem; + color: var(--color-muted); + font-style: italic; + margin-top: 0.35rem; +} + /* ---- Boutons ---- */ .btn { display: inline-flex; diff --git a/frontend/src/pages/Receive.jsx b/frontend/src/pages/Receive.jsx index 021e167..8240440 100644 --- a/frontend/src/pages/Receive.jsx +++ b/frontend/src/pages/Receive.jsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import BackButton from '../components/BackButton.jsx'; import ProgressBar from '../components/ProgressBar.jsx'; -import { getFileInfo, confirmDownload } from '../api/client.js'; +import { getFileInfo } from '../api/client.js'; import { deriveKeyAndVerifier, decryptFile } from '../utils/crypto.js'; import { formatSize } from '../utils/format.js'; import { useI18n } from '../i18n/I18nContext.jsx'; @@ -63,7 +63,20 @@ export default function Receive() { setProgress(0); setSubLabel(''); - const files = fileInfo.files; + const { key: cryptoKey, verifier } = await deriveKeyAndVerifier(pass, fileInfo.salt, 'decrypt'); + + // Récupérer les noms de fichiers : ils ne sont révélés qu'avec le bon + // verifier (preuve de mot de passe). Un mot de passe erroné est détecté + // ici (403), avant tout téléchargement — rien n'est consommé. + let files; + try { + const detailed = await getFileInfo(code, verifier); + files = detailed.files; + } catch (err) { + if (err.status === 403) throw new Error(t('receive.error.badpassword')); + throw err; + } + if (!files || !files.length) throw new Error(t('receive.error.download')); const totalFiles = files.length; // Calculer le nombre total de chunks pour la progression globale @@ -73,7 +86,6 @@ export default function Receive() { } let chunksDownloaded = 0; - const { key: cryptoKey, verifier } = await deriveKeyAndVerifier(pass, fileInfo.salt, 'decrypt'); const fetchOpts = { headers: { 'x-blob-verifier': verifier } }; const downloaded = []; @@ -178,11 +190,8 @@ export default function Receive() { setSavedFiles(downloaded); - // Tous les fichiers ont été déchiffrés et enregistrés avec succès : - // on confirme au serveur pour consommer le quota (et purger si atteint). - // Un mot de passe erroné aurait échoué avant ce point → rien n'est consommé. - await confirmDownload(code, verifier); - + // Le quota est consommé côté serveur dès que le dernier chunk a été servi + // (voir api/file/[code]/download.js) — aucune confirmation client requise. setProgress(100); setStatus('done'); @@ -207,9 +216,10 @@ export default function Receive() { const showInput = status === 'idle' || status === 'loading' || status === 'error'; - // Calculs multi-fichier - const totalSize = fileInfo?.files?.reduce((s, f) => s + f.size, 0) || 0; - const fileCount = fileInfo?.files?.length || 0; + // Aperçu : on s'appuie sur le sous-ensemble non sensible renvoyé par /info + // (les noms ne sont récupérés qu'au moment du téléchargement, avec le verifier). + const totalSize = fileInfo?.totalSize || 0; + const fileCount = fileInfo?.fileCount || 0; // Temps restant en format lisible function formatRemaining() { @@ -261,35 +271,16 @@ export default function Receive() { {status === 'ready' && fileInfo && (
- {fileCount === 1 ? ( - <> -

{fileInfo.files[0].originalName}

-

{formatSize(fileInfo.files[0].size)}

- - ) : ( - <> -

- {fileCount} {t('receive.files.count')} -

- -

{formatSize(totalSize)}

- - )} + {/* Aperçu sans les noms : ils restent confidentiels tant que le mot + de passe (verifier) n'est pas fourni — voir api/.../info.js (#3). */} +

+ {fileCount} {fileCount > 1 ? t('receive.files.count') : t('receive.files.one')} +

+

{formatSize(totalSize)}

+

{t('receive.names.hidden')}

{t('receive.expires.single')} {t('receive.expires.time', { remaining: formatRemaining() })} - {fileInfo.maxDownloads > 0 && ( - - {t('receive.downloads.remaining', { count: Math.max(0, fileInfo.maxDownloads - (fileInfo.downloadCount || 0)) })} - - )}

diff --git a/middleware.js b/middleware.js index 565c8fc..d1def30 100644 --- a/middleware.js +++ b/middleware.js @@ -185,9 +185,13 @@ export default async function middleware(request) { response.headers.set('X-RateLimit-Remaining', String(remaining)); // ── Traquer les échecs pour l'anti-énumération ── - // 404/410 : code inconnu/expiré. 403 : verifier invalide (mot de passe - // erroné) — throttle aussi les tentatives de brute-force online du mot de passe. - if (isFileEndpoint && (response.status === 404 || response.status === 410 || response.status === 403)) { + // Seuls 404 (code inconnu) et 410 (expiré/épuisé) signalent une énumération de + // codes. On NE compte PAS les 403 (verifier invalide) : un 403 prouve que le + // code EXISTE — c'est une erreur de mot de passe, pas de l'énumération. Les + // compter bloquait toute l'IP (faux positif sur NAT/IP partagée dès qu'un + // destinataire légitime se trompe de mot de passe). Le brute-force de mot de + // passe reste borné par le rate limiter (/api/file) + le coût PBKDF2 client. + if (isFileEndpoint && (response.status === 404 || response.status === 410)) { recordEnumFailure(ip); } diff --git a/tests/api.test.js b/tests/api.test.js index b0cdf13..6ff5701 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -131,8 +131,19 @@ beforeEach(() => { vi.clearAllMocks(); process.env.BLOB_READ_WRITE_TOKEN = 'test-token'; process.env.CRON_SECRET = 'test-cron-secret'; + process.env.INFO_MIN_RESPONSE_MS = '0'; // pas de plancher anti-timing en test (vitesse) }); +/** Métadonnée multi-chunks (un seul fichier découpé en N chunks). */ +function multiChunkMetadata(chunkCount, overrides = {}) { + const chunkUrls = Array.from({ length: chunkCount }, (_, c) => + `https://blob.test/transfers/AB3K7P/f000-chunk-${String(c).padStart(3, '0')}.enc`); + return createTestMetadata({ + files: [{ originalName: 'big.bin', size: 1024 * chunkCount, chunkCount, chunkUrls }], + ...overrides, + }); +} + // ── Tests info endpoint ───────────────────────────────────────────────────── describe('GET /api/file/:code/info', async () => { @@ -332,61 +343,127 @@ describe('GET /api/file/:code/download', async () => { expect(res.statusCode).toBe(410); }); - it('ne consomme PAS le quota (lecture seule) lors du téléchargement', async () => { - seedTransfer(createTestMetadata({ downloadCount: 0, maxDownloads: 1 })); - const req = createMockReq('GET', { code: 'AB3K7P', file: '0', chunk: '0' }); + it('ne consomme PAS le quota sur un chunk NON final', async () => { + seedTransfer(multiChunkMetadata(2, { downloadCount: 0, maxDownloads: 2 })); + const req = createMockReq('GET', { code: 'AB3K7P', file: '0', chunk: '0' }); // chunk 0/2 const res = createMockRes(); await downloadHandler(req, res); expect(res.statusCode).toBe(200); - // Le compteur ne doit pas avoir bougé et le transfert ne doit pas être supprimé + // Tant que le chunk final n'est pas servi, rien n'est consommé. const meta = JSON.parse(blobStore.get('metadata/AB3K7P.json')); expect(meta.downloadCount).toBe(0); expect(blobStore.has('metadata/AB3K7P.json')).toBe(true); }); }); -// ── Tests confirm endpoint ────────────────────────────────────────────────── +// ── Tests download : consommation du quota (chunk final) ──────────────────── -describe('POST /api/file/:code/confirm', async () => { - const { default: confirmHandler } = await import('../api/file/[code]/confirm.js'); +describe('GET /api/file/:code/download — consommation du quota', async () => { + const { default: downloadHandler } = await import('../api/file/[code]/download.js'); + const VERIFIER = 'c'.repeat(64); - it('incrémente le compteur sans supprimer quand le quota n\'est pas atteint', async () => { - seedTransfer(createTestMetadata({ downloadCount: 0, maxDownloads: 3 })); - const req = createMockReq('POST', { code: 'AB3K7P' }); + it('incrémente le compteur quand le chunk FINAL est servi (sans atteindre le quota)', async () => { + seedTransfer(multiChunkMetadata(2, { downloadCount: 0, maxDownloads: 3 })); + const req = createMockReq('GET', { code: 'AB3K7P', file: '0', chunk: '1' }); // chunk final const res = createMockRes(); - await confirmHandler(req, res); + await downloadHandler(req, res); expect(res.statusCode).toBe(200); - expect(res._body.consumed).toBe(false); const meta = JSON.parse(blobStore.get('metadata/AB3K7P.json')); expect(meta.downloadCount).toBe(1); expect(blobStore.has('metadata/AB3K7P.json')).toBe(true); }); - it('supprime tout le transfert quand le quota est atteint', async () => { - seedTransfer(createTestMetadata({ downloadCount: 0, maxDownloads: 1 })); - const req = createMockReq('POST', { code: 'AB3K7P' }); + it('purge tout le transfert quand le chunk final atteint le quota', async () => { + seedTransfer(createTestMetadata({ downloadCount: 0, maxDownloads: 1 })); // 1 seul chunk = final + const req = createMockReq('GET', { code: 'AB3K7P', file: '0', chunk: '0' }); const res = createMockRes(); - await confirmHandler(req, res); + await downloadHandler(req, res); expect(res.statusCode).toBe(200); - expect(res._body.consumed).toBe(true); + expect(res._ended).toBe(true); expect(blobStore.has('metadata/AB3K7P.json')).toBe(false); expect(blobStore.has('transfers/AB3K7P/f000-chunk-000.enc')).toBe(false); }); - it('est idempotent sur un code déjà consommé (404 → ok)', async () => { - const req = createMockReq('POST', { code: 'ZZZZZZ' }); + it('le chunk final du DERNIER fichier consomme (multi-fichier)', async () => { + seedTransfer(createTestMetadata({ + downloadCount: 0, maxDownloads: 3, + files: [ + { originalName: 'a.pdf', size: 500, chunkCount: 1, chunkUrls: ['https://blob.test/transfers/AB3K7P/f000-chunk-000.enc'] }, + { originalName: 'b.jpg', size: 800, chunkCount: 1, chunkUrls: ['https://blob.test/transfers/AB3K7P/f001-chunk-000.enc'] }, + ], + })); + // chunk final du fichier 0 → PAS le dernier fichier → ne consomme pas + await downloadHandler(createMockReq('GET', { code: 'AB3K7P', file: '0', chunk: '0' }), createMockRes()); + expect(JSON.parse(blobStore.get('metadata/AB3K7P.json')).downloadCount).toBe(0); + // chunk final du dernier fichier → consomme + await downloadHandler(createMockReq('GET', { code: 'AB3K7P', file: '1', chunk: '0' }), createMockRes()); + expect(JSON.parse(blobStore.get('metadata/AB3K7P.json')).downloadCount).toBe(1); + }); + + it('ne consomme rien si le verifier est invalide (rejet 403 avant tout service)', async () => { + seedTransfer(createTestMetadata({ verifier: VERIFIER, downloadCount: 0, maxDownloads: 1 })); + const req = createMockReq('GET', { code: 'AB3K7P', file: '0', chunk: '0' }); // sans verifier + const res = createMockRes(); + + await downloadHandler(req, res); + + expect(res.statusCode).toBe(403); + const meta = JSON.parse(blobStore.get('metadata/AB3K7P.json')); + expect(meta.downloadCount).toBe(0); + expect(blobStore.has('metadata/AB3K7P.json')).toBe(true); + }); + + it('téléchargements répétés : le quota finit par être épuisé même sans /confirm', async () => { + seedTransfer(createTestMetadata({ downloadCount: 0, maxDownloads: 2 })); // 1 chunk = final + // 1er téléchargement complet → count 1 + await downloadHandler(createMockReq('GET', { code: 'AB3K7P', file: '0', chunk: '0' }), createMockRes()); + expect(JSON.parse(blobStore.get('metadata/AB3K7P.json')).downloadCount).toBe(1); + // 2e téléchargement complet → atteint le quota → purge + await downloadHandler(createMockReq('GET', { code: 'AB3K7P', file: '0', chunk: '0' }), createMockRes()); + expect(blobStore.has('metadata/AB3K7P.json')).toBe(false); + // 3e tentative → transfert disparu + const res3 = createMockRes(); + await downloadHandler(createMockReq('GET', { code: 'AB3K7P', file: '0', chunk: '0' }), res3); + expect(res3.statusCode).toBe(404); + }); +}); + +// ── Tests confirm endpoint ────────────────────────────────────────────────── + +describe('POST /api/file/:code/confirm (déprécié — no-op)', async () => { + const { default: confirmHandler } = await import('../api/file/[code]/confirm.js'); + + it('répond 200 SANS muter le transfert (consommation déléguée à /download)', async () => { + seedTransfer(createTestMetadata({ downloadCount: 0, maxDownloads: 3 })); + const req = createMockReq('POST', { code: 'AB3K7P' }); + const res = createMockRes(); + + await confirmHandler(req, res); + + expect(res.statusCode).toBe(200); + expect(res._body.deprecated).toBe(true); + // Aucun effet de bord : compteur inchangé, transfert toujours présent. + const meta = JSON.parse(blobStore.get('metadata/AB3K7P.json')); + expect(meta.downloadCount).toBe(0); + expect(blobStore.has('metadata/AB3K7P.json')).toBe(true); + }); + + it('ne supprime pas, même quand le quota serait atteint (anti double comptage)', async () => { + seedTransfer(createTestMetadata({ downloadCount: 0, maxDownloads: 1 })); + const req = createMockReq('POST', { code: 'AB3K7P' }); const res = createMockRes(); await confirmHandler(req, res); expect(res.statusCode).toBe(200); - expect(res._body.consumed).toBe(true); + expect(blobStore.has('metadata/AB3K7P.json')).toBe(true); + expect(blobStore.has('transfers/AB3K7P/f000-chunk-000.enc')).toBe(true); }); it('retourne 400 pour un code mal formaté', async () => { @@ -406,55 +483,88 @@ describe('POST /api/file/:code/confirm', async () => { expect(res.statusCode).toBe(405); }); +}); + +// ── Tests info : temps de réponse uniforme (anti-énumération) ──────────────── + +describe('GET /api/file/:code/info — temps de réponse uniforme', async () => { + const { default: infoHandler } = await import('../api/file/[code]/info.js'); + + async function measure(code) { + const req = createMockReq('GET', { code }); + const res = createMockRes(); + const t0 = Date.now(); + await infoHandler(req, res); + return Date.now() - t0; + } + + it('un code valide ne répond pas plus vite qu\'un code inexistant', async () => { + process.env.INFO_MIN_RESPONSE_MS = '200'; + seedTransfer(); // AB3K7P existe + + const tValid = await measure('AB3K7P'); + const tMissing = await measure('ZZZZZZ'); + + // Les deux respectent le plancher → pas d'oracle « existe = plus rapide ». + expect(tValid).toBeGreaterThanOrEqual(200); + expect(tMissing).toBeGreaterThanOrEqual(200); + // L'écart ne trahit pas la validité (seul le jitter ~80ms joue). + expect(Math.abs(tValid - tMissing)).toBeLessThan(150); + }); +}); - // ── Verifier (preuve de mot de passe) ── +// ── Tests info : noms de fichiers gated par le verifier (#3) ───────────────── + +describe('GET /api/file/:code/info — noms protégés par le verifier', async () => { + const { default: infoHandler } = await import('../api/file/[code]/info.js'); const VERIFIER = 'c'.repeat(64); - it('refuse (403) la confirmation sans verifier quand le transfert en a un', async () => { + it('masque les noms de fichiers sans verifier (transfert protégé)', async () => { seedTransfer(createTestMetadata({ verifier: VERIFIER })); - const req = createMockReq('POST', { code: 'AB3K7P' }); + const req = createMockReq('GET', { code: 'AB3K7P' }); const res = createMockRes(); - await confirmHandler(req, res); + await infoHandler(req, res); - expect(res.statusCode).toBe(403); - // Rien ne doit avoir été consommé ni supprimé - const meta = JSON.parse(blobStore.get('metadata/AB3K7P.json')); - expect(meta.downloadCount).toBe(0); + expect(res.statusCode).toBe(200); + expect(res._body.files).toBeUndefined(); // aucun nom divulgué + expect(res._body.fileCount).toBe(1); // mais le nombre, oui + expect(res._body.totalSize).toBe(1024); // et la taille totale + expect(res._body.salt).toBe('a'.repeat(64)); // et le sel (requis) }); - it('refuse (403) un verifier incorrect', async () => { + it('révèle les noms avec le bon verifier', async () => { seedTransfer(createTestMetadata({ verifier: VERIFIER })); - const req = createMockReq('POST', { code: 'AB3K7P' }, { 'x-blob-verifier': 'd'.repeat(64) }); + const req = createMockReq('GET', { code: 'AB3K7P' }, { 'x-blob-verifier': VERIFIER }); const res = createMockRes(); - await confirmHandler(req, res); + await infoHandler(req, res); - expect(res.statusCode).toBe(403); - expect(blobStore.has('metadata/AB3K7P.json')).toBe(true); + expect(res.statusCode).toBe(200); + expect(res._body.files).toHaveLength(1); + expect(res._body.files[0].originalName).toBe('test.pdf'); }); - it('accepte le bon verifier et consomme le quota', async () => { - seedTransfer(createTestMetadata({ verifier: VERIFIER, maxDownloads: 1 })); - const req = createMockReq('POST', { code: 'AB3K7P' }, { 'x-blob-verifier': VERIFIER }); + it('retourne 403 avec un mauvais verifier (mot de passe incorrect)', async () => { + seedTransfer(createTestMetadata({ verifier: VERIFIER })); + const req = createMockReq('GET', { code: 'AB3K7P' }, { 'x-blob-verifier': 'd'.repeat(64) }); const res = createMockRes(); - await confirmHandler(req, res); + await infoHandler(req, res); - expect(res.statusCode).toBe(200); - expect(res._body.consumed).toBe(true); - expect(blobStore.has('metadata/AB3K7P.json')).toBe(false); + expect(res.statusCode).toBe(403); + expect(res._body.files).toBeUndefined(); }); - it('reste compatible avec les anciens transferts sans verifier', async () => { - seedTransfer(createTestMetadata({ maxDownloads: 3 })); - const req = createMockReq('POST', { code: 'AB3K7P' }); + it('reste compatible avec un transfert legacy sans verifier (noms visibles)', async () => { + seedTransfer(createTestMetadata()); // pas de verifier → rien à protéger + const req = createMockReq('GET', { code: 'AB3K7P' }); const res = createMockRes(); - await confirmHandler(req, res); + await infoHandler(req, res); expect(res.statusCode).toBe(200); - expect(res._body.consumed).toBe(false); + expect(res._body.files).toHaveLength(1); }); });