انتقل إلى المحتوى
HexaTransfer
العودة إلى المدونة
التشفير والأمان

التشفير التدريجي للملفات الكبيرة: البث والتشفير

شفّر الملفات الكبيرة تدريجياً باستخدام واجهات البث. عالج ملفات بحجم عدة جيجابايت دون نفاد الذاكرة.

يُعالج التشفير التدريجي (المتدفق) الملفَ جزءاً جزءاً دون تحميل الحمولة الكاملة في الذاكرة. لرفع ملف بحجم 10 جيجابايت في المتصفح، هذا هو الفارق بين تطبيق يعمل وآخر يتعطّل. النمط: قراءة جزء عبر File.stream()، تشفيره بـ AES-256-GCM باستخدام nonce فريد، توجيه النص المشفر مباشرةً إلى تدفق رفع عبر fetch بجسم ReadableStream، تحرير المخزن المؤقت، ثم الانتقال. يبقى استخدام الذاكرة محدوداً بـ 4-16 ميجابايت بغض النظر عن حجم الملف. تُضيف crypto_secretstream_xchacha20poly1305 من libsodium دلالات AEAD متدفقة صحيحة تشمل كشف البتر. إليك التنفيذ الملموس مع أرقام تصمد على أجهزة حقيقية.

لماذا يفشل التشفير الكامل للمخزن

إن FileReader.readAsArrayBuffer(file) على ملف بحجم 10 جيجابايت يُخصّص 10 جيجابايت في ذاكرة المتصفح. على سطح مكتب Chrome بـ 32 جيجابايت ذاكرة RAM قد يعمل هذا. على Safari للأجهزة المحمولة بحدّ 400 ميجابايت لكل علامة تبويب، يتعطّل قبل الإتمام. في Firefox، تصطدم ArrayBuffer التي تتجاوز 2 جيجابايت بحدود V8 الداخلية وتُلقي RangeError.

حتى على أجهزة تستطيع التعامل مع التخصيص، يحجب الاحتفاظ بـ 10 جيجابايت جمعَ القمامة ويُثير ترقيماً مرضياً. الإجابة الصحيحة هي عدم تخصيص المخزن الكامل قط.

نمط البث

async function streamEncrypt(file, key, uploadURL) {
  const CHUNK_SIZE = 4 * 1024 * 1024; // 4 ميجابايت
  const reader = file.stream().getReader();
  let chunkIndex = 0;
  let buffer = new Uint8Array(0);

  const uploadStream = new ReadableStream({
    async pull(controller) {
      while (buffer.length < CHUNK_SIZE) {
        const { done, value } = await reader.read();
        if (done) {
          if (buffer.length > 0) {
            await enqueueEncrypted(controller, buffer, chunkIndex++, key);
          }
          controller.close();
          return;
        }
        const newBuf = new Uint8Array(buffer.length + value.length);
        newBuf.set(buffer, 0);
        newBuf.set(value, buffer.length);
        buffer = newBuf;
      }
      const chunk = buffer.subarray(0, CHUNK_SIZE);
      buffer = buffer.subarray(CHUNK_SIZE);
      await enqueueEncrypted(controller, chunk, chunkIndex++, key);
    }
  });

  await fetch(uploadURL, {
    method: "POST",
    body: uploadStream,
    duplex: "half",
    headers: { "Content-Type": "application/octet-stream" },
  });
}

async function enqueueEncrypted(controller, chunk, index, key) {
  const iv = new Uint8Array(12);
  new DataView(iv.buffer).setBigUint64(4, BigInt(index));
  const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, chunk);
  controller.enqueue(new Uint8Array(ct));
}

واجهتا برمجة API رئيسيتان: File.stream() تُعطي ReadableStream لمحتويات الملف؛ وfetch مع جسم ReadableStream تبثّ الرفع دون تخزين الجسم كاملاً. duplex: "half" مطلوب في Chrome 105+ لأجسام طلبات متدفقة.

استخدام الذاكرة: في أي لحظة، جزء مصدر واحد، ومخزن مؤقت متبقٍّ واحد، وجزء مشفر واحد. ذروة ~12-16 ميجابايت لحجم جزء 4 ميجابايت.

إدارة الـ Nonce في التدفقات

يحتاج كل جزء إلى nonce فريد. ثلاثة مناهج:

مبني على عداد: ضمّن فهرس الجزء في nonce ذي 96 بتاً. اجعل الـ 32 بتاً العليا بادئة عشوائية (لتجنب التصادمات عبر ملفات تستخدم المفتاح ذاته)، والـ 64 بتاً الدنيا هي فهرس الجزء.

const noncePrefix = crypto.getRandomValues(new Uint32Array(1));
function makeNonce(chunkIndex) {
  const iv = new Uint8Array(12);
  new DataView(iv.buffer).setUint32(0, noncePrefix[0]);
  new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex));
  return iv;
}

عشوائي لكل جزء: crypto.getRandomValues(new Uint8Array(12)). آمن لمفاتيح لكل ملف؛ تصادمات التوليد العشوائي عبر الأجزاء تضرب عند ~2^48. خزّن الـ nonce بجانب النص المشفر لكل جزء.

مشتق عبر HKDF: استخدم HKDF لاشتقاق مفاتيح لكل جزء، ثم استخدم nonce ثابتاً. مبالغة لمعظم الحالات.

لمفتاح جديد لكل ملف، المنهج المبني على عداد هو الأبسط ويُجنّب الحاجة إلى تخزين nonce منفصل لكل جزء.

هجمات البتر وكيفية كشفها

ثغرة حرجة في AES-GCM المجزّأ الساذج: يمكن للمهاجم إسقاط الأجزاء الذيلية وكل جزء ناجٍ يُفكّ تشفيره بنجاح. يتطلب الكشف ربط الأجزاء ببعضها.

الخيار الأول: أدرج إجمالي عدد الأجزاء في AAD كل جزء. يتحقق المستقبل من أن العدد يطابق ما استُقبل.

const aad = new TextEncoder().encode(JSON.stringify({
  totalChunks,
  fileSize: file.size,
}));
const ct = await crypto.subtle.encrypt(
  { name: "AES-GCM", iv, additionalData: aad },
  key,
  chunk
);

الخيار الثاني: استخدم crypto_secretstream_xchacha20poly1305 من libsodium. تربط الأجزاء تشفيرياً وتُصدر علامة TAG_FINAL يتحقق منها المستقبل:

const { state, header } = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
// لكل جزء، ادفع بـ TAG_MESSAGE
// للجزء الأخير، ادفع بـ TAG_FINAL
const lastCt = sodium.crypto_secretstream_xchacha20poly1305_push(
  state, lastChunk, null,
  sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
);

عند فك التشفير، تُتحقق استدعاءات pull للمستقبل من السلسلة وتكشف الأجزاء الذيلية المفقودة. هذا الخيار الأنظف إذا كنت مستعداً لشحن libsodium.js.

فك التشفير المتدفق على جانب المستقبل

النمط المتماثل على جانب المستقبل:

async function streamDecrypt(downloadURL, key, onChunk) {
  const response = await fetch(downloadURL);
  const reader = response.body.getReader();
  let buffer = new Uint8Array(0);
  let chunkIndex = 0;
  const ENCRYPTED_CHUNK_SIZE = 4 * 1024 * 1024 + 16; // زائد وسم GCM

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const newBuf = new Uint8Array(buffer.length + value.length);
    newBuf.set(buffer);
    newBuf.set(value, buffer.length);
    buffer = newBuf;
    while (buffer.length >= ENCRYPTED_CHUNK_SIZE) {
      const ct = buffer.subarray(0, ENCRYPTED_CHUNK_SIZE);
      buffer = buffer.subarray(ENCRYPTED_CHUNK_SIZE);
      const iv = makeNonce(chunkIndex++);
      const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
      onChunk(new Uint8Array(pt));
    }
  }
  if (buffer.length > 0) {
    const iv = makeNonce(chunkIndex);
    const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, buffer);
    onChunk(new Uint8Array(pt));
  }
}

على جانب المستقبل، يمكن لاستدعاءات onChunk توجيه البايتات المفككة التشفير إلى File System Access API للكتابة المباشرة على القرص، أو تجميعها في Blob للتنزيل الأصلي للمتصفح.

الكتابة على القرص عبر File System Access API

للتنزيلات الكبيرة جداً، يُبطل تحميل النتيجة المفككة تشفيرياً بالكامل في Blob الغرضَ من البث. تتيح File System Access API (Chrome 86+، وجزئياً Safari عبر OPFS) للمستقبل اختيار ملف محلي وكتابة الأجزاء مباشرةً:

const handle = await window.showSaveFilePicker({
  suggestedName: "decrypted-file",
});
const writable = await handle.createWritable();

await streamDecrypt(url, key, async (chunk) => {
  await writable.write(chunk);
});
await writable.close();

يبقى استخدام الذاكرة محدوداً لأن الأجزاء تذهب إلى القرص فوراً. تُظهر واجهة المستخدم تقدماً واقعياً. يمكن للمستخدمين الإلغاء في منتصف التنزيل.

لا يدعم Firefox بعد showSaveFilePicker على سطح المكتب. احتط ببناء Blob في الذاكرة (مناسب للملفات أقل من بضع مئات ميجابايت) أو Origin Private File System لمهام Firefox متعددة الجيجابايت.

البث في الرفع عبر fetch

Chrome 105+ وFirefox 127+ يدعمان أجسام طلبات متدفقة مع duplex: "half". قبل ذلك، كان على الرفع أن يكون إما مخازن كاملة أو متعدد الأجزاء مع ترميز نقل مجزّأ يُدار يدوياً.

للرفع المتعدد الأجزاء المتوافق مع S3، يُرفع كل جزء كطلب مستقل. قسّم التدفق المشفر إلى أجزاء من 5-25 ميجابايت (الحجم الأدنى لجزء S3 هو 5 ميجابايت، والأقصى 5 جيجابايت) وأكمل باستدعاء CompleteMultipartUpload الأخير. يعمل هذا على جميع المتصفحات ويمنحك إمكانية الاستئناف مجاناً.

الإبلاغ عن التقدم عبر التدفقات

تتبّع البايتات المعالجة:

let processed = 0;
const onChunk = (chunkSize) => {
  processed += chunkSize;
  updateProgressBar(processed / file.size);
};

قيّد تحديثات التقدم بـ 10-20 مرة في الثانية مع requestAnimationFrame لتجنب إعادة الرسم المهدورة. على ملفات بحجم 10 جيجابايت بسرعة معالجة 100 ميجابايت/ثانية، هذا لا يزال 100 حدث في الثانية خاماً، أكثر بكثير مما تحتاجه واجهة المستخدم.

نتائج قياسية على ملف بحجم 10 جيجابايت

على MacBook Pro 2024 (M3 Max) مع SSD سريع: القراءة الخام من القرص عبر File.stream() بـ 2.5 جيجابايت/ثانية، AES-256-GCM عبر Web Crypto بـ 1.7 جيجابايت/ثانية، خط الأنابيب المجمّع بـ 1.1 جيجابايت/ثانية (مقيّد بالسلسلة التسلسلية)، الرفع على إيثرنت جيجابت بـ 115 ميجابايت/ثانية (مقيّد بالشبكة)، ذروة الذاكرة 14 ميجابايت بغض النظر عن حجم الملف. أرقام الأجهزة المحمولة تبلغ تقريباً 30-50% من سطح المكتب. يستغرق ملف 10 جيجابايت ~90 ثانية على إيثرنت جيجابت، و~15 دقيقة على اتصال منزلي نموذجي. التشفير ليس عنق الزجاجة — الشبكة هي.

التعافي من الأخطاء

انقطاعات الشبكة أثناء رفع 10 جيجابايت شائعة. الاستراتيجيات:

  • رفع قابل للاستئناف عبر التجزئة: كل جزء مستقل؛ أعد رفع الجزء الفاشل فقط.
  • بروتوكول tus: معيار رفع قابل للاستئناف مفتوح تدعمه شركات مثل Vimeo؛ أصلي لعمل التدفق.
  • إبقاء مقبض الملف المصدر مفتوحاً: إذا كان File.slice قابلاً للتكرار، أعد التشغيل من آخر جزء ناجح.

سقف 10 جيجابايت في HexaTransfer قابل للتحقيق في علامة تبويب متصفح واحدة لأن خط أنابيب البث هذا يُبقي الذاكرة محدودة ويتعامل بأناقة مع الانقطاع عبر إعادة المحاولة المجزّأة. يتوسّع النمط ذاته لحدود أكبر إذا كان الخلفية تدعمها.

الملخص

لا تخصّص الملف كاملاً. اقرأ في أجزاء، وشفّر في أجزاء، وارفع في أجزاء، وحرّر كل جزء أثناء التقدم. اربط الأجزاء تشفيرياً عبر AAD أو AEAD المتدفق للتصدي للبتر. أضف شريط تقدم لكل شيء. اختبر على الأجهزة المحمولة لا سطح المكتب فقط.

جرّبها على hexatransfer.com — مجاني، بدون حساب، حتى 10 جيجابايت.

أرسل ملفات كبيرة بأمان مع تشفير من طرف إلى طرف

انقل ملفات حتى 10 جيجابايت مجاناً مع تشفير من طرف إلى طرف. لا حاجة لحساب. يتم تشفير ملفاتك في متصفحك قبل الرفع — لا أحد آخر يستطيع قراءتها.

إرسال ملف