Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 12 additions & 89 deletions api/file/[code]/confirm.js
Original file line number Diff line number Diff line change
@@ -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') {
Expand All @@ -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 });
}
69 changes: 60 additions & 9 deletions api/file/[code]/download.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}$/;
Expand All @@ -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' });
Expand Down Expand Up @@ -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' });
}
Expand All @@ -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, {
Expand All @@ -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) {
Expand Down
98 changes: 75 additions & 23 deletions api/file/[code]/info.js
Original file line number Diff line number Diff line change
@@ -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' });
}
}
2 changes: 1 addition & 1 deletion api/upload/chunk.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading