הצפנת קבצים בפייתון

פוסט זה כולל טיפ קצר לעבודה יעילה עם Python. אם אתם רוצים ללמוד פייתון יותר לעומק אני ממליץ על קורס Python כאן באתר.
הקורס כולל עשרות שיעורי וידאו והמון תרגול מעשי וילמד אתכם Python בצורה מקצועית מההתחלה ועד הנושאים המתקדמים.

כתבתי כאן בעבר על הצפנת הודעות קצרות בפייתון והפוסט ההוא עדיין רלוונטי. אבל המעבר לקבצים גדולים יותר מכיל מספר אתגרים שלא קיבלו מענה שם. השבוע זה זמן טוב לפתוח שוב את הנושא ולראות את האתגרים וגם את הקוד שמתמודד איתם.

1. איך זה עובד

יש מספר ספריות הצפנה טובות לפייתון וכולן כוללות מהקופסא מנגנון להצפנת הודעות קטנות. אבל מה קורה כשרוצים להעביר את זה להצפנת קבצים גדולים יותר? מסתבר שהעסק לא כל כך פשוט.

האתגר הראשון הוא שקובץ יכול להיות גדול מכדי להיכנס לזיכרון ולכן יש לקרוא אותו בבלוקים. בשביל שסדר הבלוקים לא יסגיר מידע על הקובץ נצטרך להשתמש ב Encryption Mode מתאים (כדוגמת GCM או AEX) או במצפין זרם.

בנוסף לפני הפיענוח יהיה עלינו לוודא שתוכן כל בלוק לא השתנה וגם שסדר הבלוקים לא השתנה, ולכן יש לחתום על הקובץ (בנוסף לחתימה על כל בלוק שאנחנו מקבלים ממצפין הזרם באופן אוטומטי).

לכן הצפנה (ופיענוח) קובץ גדול בפייתון מורכבים מהשלבים הבאים:

  1. יצירת מפתח הצפנה שצריך להישמר סודי. את המפתח יש לתת רק למי שאמור לפתוח את הקובץ, או אם זה קובץ שאנחנו מצפינים לעצמנו לשמור את המפתח אצלנו בצורה מאובטחת. הקוד שיוצג שומר את המפתח לקובץ, אז שימו לב לשמור טוב על קובץ זה.

  2. בעזרת אותו המפתח אפשר יהיה להצפין הרבה קבצים. מאחר ומצפין זרם לא אוהב שמשתמשים באותו מפתח כמה פעמים, אנחנו נשתמש בערך אקראי שיוגרל לפני הצפנת כל קובץ. ערך כזה נקרא nonce והוא משתלב עם המפתח ליצירת מפתח ייחודי לקובץ. ה nonce נשמר כחלק מהמידע המוצפן באופן אוטומטי על ידי המצפין.

  3. אחרי שיש לנו מפתח נחשב nonce ונצפין קובץ בלוק אחר בלוק. בדוגמת הקוד נראה הצפנה בבלוקים של 16 ק"ב. המצפין כבר חותם על כל בלוק בשבילנו כך שמובטח שהמידע בבלוק לא ישתנה, אבל זה לא מספיק. יכול להיות שסדר הבלוקים ישתנה ואז בפיענוח נקבל קובץ אחר ממה שהצפנו.

  4. לכן עלינו לחשב גם חתימה על כל המידע המוצפן. נקרא את הקובץ ונחשב עליו חתימה באמצעות מפתח נוסף (מפתח החתימה).

  5. בשלב הפיענוח נצטרך קודם כל לוודא את החתימה, ולאחר מכן לקרוא ולפענח כל בלוק בנפרד.

2. יצירת מפתחות

הקוד הבא משתמש בספריית pynacl כדי ליצור מפתחות הצפנה. המפתחות יישמרו בתיקית keys בתוך התיקיה הנוכחית:

import nacl.secret
import nacl.utils
import nacl.encoding
import nacl.signing
import os

key = nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE)
os.makedirs('keys', exist_ok=True)
os.chdir('keys')

with open('symkey.bin', 'wb') as f:
    f.write(key)

auth_key = nacl.utils.random(size=64)
with open('authkey.bin', 'wb') as f:
    f.write(auth_key)

הקוד יוצר שני קבצי מפתח, ואת שניהם עלינו לשמור בסוד. הם ישמשו אותנו כדי לפענח את הקבצים ולוודא שהמידע המוצפן לא השתנה בזמן שעבר מאז ההצפנה.

3. הצפנת קובץ

הקוד הבא מצפין קובץ גדול בפייתון:

import nacl.secret
import nacl.utils
import sys

if len(sys.argv) != 4:
    exit("Usage: {} <key> <input_file_name> <output_encrypted_file_name>".format(*sys.argv))

(_, keyfile, input_file, output_file) = sys.argv

def chunk_nonce(base, index):
    size = nacl.secret.SecretBox.NONCE_SIZE
    return int.to_bytes(int.from_bytes(base, byteorder='big') + index, length=size, byteorder='big')

def read_in_chunks(file_object, chunk_size=16 * 1024):
    """Lazy function (generator) to read a file piece by piece.
    Default chunk size: 16k."""
    index = 0
    while True:
        data = file_object.read(chunk_size)
        if not data:
            break
        yield (data, index)
        index += 1

with open(keyfile, 'rb') as f:
    key = f.read()

box = nacl.secret.SecretBox(key)

nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE)
with open(output_file, 'wb') as fout:
    with open(input_file, 'rb') as fin:
        for chunk, index in read_in_chunks(fin, chunk_size=16*1024 - 40):
            enc = box.encrypt(chunk, chunk_nonce(nonce, index))
            fout.write(enc)

התוכנית מקבלת שם קובץ מפתח, שם קובץ ושם קובץ פלט. היא מצפינה את הקובץ ושומרת את התוצאה לקובץ.

שימו לב למנגנון העבודה עם ה Nonce:

  1. אין להשתמש ב nonce יותר מפעם אחת, כי אחרת זה יגרום לשימוש חוזר באותו מפתח (וזה משהו שמצפיני זרם לא אוהבים).

  2. לכן בתחילת הצפנת הקובץ מגרילים ערך אקראי חדש שיהיה ה nonce עבור הקובץ הנוכחי.

  3. בכל בלוק מקדמים את הערך ב-1 כדי שבמהלך הצפנת הקובץ לא נשתמש באותו nonce פעמיים.

  4. ה nonce של כל בלוק נכתב באופן אוטומטי לסוף הבלוק יחד עם החתימה על הבלוק. לכן גודל בלוק הוא 16kb פחות 40. ה-40 בתים בסוף הבלוק שייכים למידע מנהלתי זה.

  5. כך מובטח לנו שכל בלוק יקבל את ה nonce שלו, והסיכוי להגרלת אותו nonce למספר קבצים הוא אפסי.

4. חתימה על התוכן המוצפן

לאחר שיצרנו קובץ מוצפן יש לחתום עליו כדי לוודא שלא ישתנה עד שננסה לפענח אותו. התוכנית הבאה חותמת על קובץ באמצעות מפתח חתימה שיצרנו:

import nacl.encoding
import sys
import binascii
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, hmac

def read_in_chunks(file_object, chunk_size=16 * 1024):
    """Lazy function (generator) to read a file piece by piece.
    Default chunk size: 16k."""
    index = 0
    while True:
        data = file_object.read(chunk_size)
        if not data:
            break
        yield (data, index)
        index += 1

if len(sys.argv) != 3:
    sys.exit("Usage: {} <auth_key> <input_file>".format(sys.argv[0]))

(_, key_file, input_file) = sys.argv

with open(key_file, 'rb') as f:
    auth_key = f.read()

with open(input_file, 'rb') as f:
    h = hmac.HMAC(auth_key, hashes.SHA512(), backend=default_backend())
    for chunk, _ in read_in_chunks(f):
        h.update(chunk)

    print(binascii.hexlify(h.finalize()))

התוכנית מקבלת שם קובץ מפתח ושם קובץ לחתימה ומדפיסה חתימה ייחודית של הקובץ. כדי לייצר חתימה כזו יש צורך בקובץ המפתח, ולכן תוקפים לא יוכלו לשנות את המידע לפני הפיענוח.

שימו לב לחתום על המידע לאחר ההצפנה (לא על הקובץ המקורי). את החתימה שמודפסת יש לשמור ליד הקובץ המוצפן, אפשר כקובץ טקסט.

5. וידוא חתימה לפני פיענוח

לפני שמפענחים את הקובץ יש לוודא שהחתימה הכללית נכונה. התוכנית הבאה מקבלת קובץ מפתח, קובץ מוצפן וחתימה ומדפיסה Valid אם הקובץ המוצפן אכן מתאים לחתימה שהועברה, כלומר לא השתנה מאז שחתמנו עליו. הספריה pynacl לא מספקת רכיב חתימה מבוסס בלוקים ולכן עברתי לספריית cryptography. זו האחרונה מספקת API רחב בהרבה, ולכן כדאי להיות יותר זהירים כשמשתמשים בה:

import nacl.encoding
import sys
import binascii
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, hmac

def read_in_chunks(file_object, chunk_size=16 * 1024):
    """Lazy function (generator) to read a file piece by piece.
    Default chunk size: 16k."""
    index = 0
    while True:
        data = file_object.read(chunk_size)
        if not data:
            break
        yield (data, index)
        index += 1

if len(sys.argv) != 4:
    sys.exit("Usage: {} <key_file> <input_file> <sig>".format(sys.argv[0]))

(_, key_file, input_file, sig) = sys.argv

with open(key_file, 'rb') as f:
    auth_key = f.read()

sig_bytes = binascii.unhexlify(sig)

with open(input_file, 'rb') as f:
    h = hmac.HMAC(auth_key, hashes.SHA512(), backend=default_backend())
    for chunk, _ in read_in_chunks(f):
        h.update(chunk)

    h.verify(sig_bytes)

print("Valid")

6. פיענוח הקובץ

אחרי שאנחנו בטוחים שהמידע שבידינו אותנטי אפשר לפענח את הקובץ המוצפן כדי להגיע לתוכן המקורי. התוכנית הבאה מקבלת קובץ מפתח, קובץ מוצפן ושם קובץ פלט, מפענחת את הקובץ וכותבת את התוצאה לקובץ הפלט:

import nacl.secret
import nacl.utils
import sys

if len(sys.argv) != 4:
    exit("Usage: {} <key> <input_file_name> <output_decrypted_file_name>".format(*sys.argv))

(_, keyfile, input_file, output_file) = sys.argv

def chunk_nonce(base, index):
    size = nacl.secret.SecretBox.NONCE_SIZE
    return int.to_bytes(int.from_bytes(base, byteorder='big') + index, length=size, byteorder='big')

def read_in_chunks(file_object, chunk_size=16 * 1024):
    """Lazy function (generator) to read a file piece by piece.
    Default chunk size: 16k."""
    index = 0
    while True:
        data = file_object.read(chunk_size)
        if not data:
            break
        yield (data, index)
        index += 1

with open(keyfile, 'rb') as f:
    key = f.read()

box = nacl.secret.SecretBox(key)

print("Decrypting {} to {}".format(input_file, output_file))

with open(output_file, 'wb') as fout:
    with open(input_file, 'rb') as fin:
        for chunk, index in read_in_chunks(fin):
            enc = box.decrypt(chunk)
            fout.write(enc)

שימו לב שהפעם גודל הבלוק הוא 40 תווים יותר מאשר גודל הבלוק בהצפנה. הסיבה ש-40 התווים האחרונים בכל בלוק הם המידע המנהלתי שדרוש לצורך פיענוח הבלוק (ה nonce והחתימה על הבלוק).

7. לסיכום

קריפטוגרפיה זה קשה. ברוב המקרים שאתם כותבים קוד קל לכולם לראות אם הקוד עובד או לא עובד. בקוד שקשור לאבטחה ובפרט לקריפטוגרפיה, אין דרך טובה לדעת עד כמה גרוע המנגנון שכתבתם. ההמלצה הכללית היא להשתמש בספריות אבל כמו שראיתם כאן גם את זה צריך לדעת לעשות ויש המון ניואנסים קטנים שאפשר לטעות בהם.

עד כמה שאני יודע הקוד שמתואר כאן מאובטח למדי, כל עוד אתם שומרים את המפתחות שלכם בסוד. אם יש לכם רעיון איך לשבור אותו או שאתם רואים בעית אבטחה שאני לא ראיתי אשמח מאוד לשמוע בתגובות. וכמובן מוזמנים לשתף אם לכם יש דרכים אחרות ואולי טובות יותר להצפין קבצים גדולים.