Foundationপ্রথম নীতি থেকে
LEVEL 10লেসন ৪/২০অ্যাডভান্সড৫৫ মিনিট

Symmetric Encryption ও Block Cipher Mode — একই চাবি, দুই কাজ

Symmetric Encryption and Block Cipher Modes

একই চাবি এনক্রিপ্ট-ডিক্রিপ্ট দুই-ই করে — AES এই যুগের block cipher, কিন্তু আসল যুদ্ধ mode-এ: ECB পুনরাবৃত্তি ফাঁস করে, CBC/CTR সেটা ঢাকে, GCM এক ধাক্কায় authentication-ও দেয়; nonce reuse করলে সেই নিরাপত্তা এক XOR-এই ধসে পড়ে।

এই লেসন শেষে আপনি পারবেন

  • Symmetric-key model বুঝে block cipher-এর সংজ্ঞা (fixed-size block, keyed permutation) ও AES-এর key size (128/192/256-bit) ব্যাখ্যা করতে পারবেন
  • ECB mode কেন block-independent encryption-এ pattern leak করে তা কোড চালিয়ে visually প্রমাণ করতে পারবেন, আর CBC/CTR কীভাবে সেটা ঠেকায় তুলনা করতে পারবেন
  • CBC (IV+chaining, padding-oracle ইতিহাস), CTR (keystream/counter), আর GCM (AEAD = encryption+authentication একসাথে) — চারটা mode-এর trade-off ছক করে বলতে পারবেন
  • Nonce-reuse হলে কেন একই (key, nonce) জোড়ায় keystream বাতিল হয়ে যায় তা XOR-attack চালিয়ে দেখাতে ও practical guard ডিজাইন করতে পারবেন
  • Production system-এ hand-rolled crypto এড়িয়ে audited library + GCM/ChaCha20-Poly1305 বেছে নেওয়ার যুক্তি দিতে পারবেন, আর asymmetric encryption-এর প্রয়োজনীয়তা চিনতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

আগের লেসনে hash function দেখেছি — one-way, deterministic, কোনো চাবি লাগে না। আজকের যন্ত্র সম্পূর্ণ ভিন্ন প্রজাতির: encryption। উদ্দেশ্য fingerprint বানানো নয়, বরং তথ্য এমনভাবে ঢেকে দেওয়া যাতে সঠিক চাবি ছাড়া কেউ ফেরত পড়তে না পারে — আর সঠিক চাবি থাকলে হুবহু ফেরত পাওয়া যায়।

Symmetric encryption-এর মডেল নিরস্ত্রভাবে সহজ: একটাই secret key, সেটাই এনক্রিপ্ট করে, সেটাই ডিক্রিপ্ট করে। Alice আর Bob-এর দুজনের কাছেই একই চাবি — Alice ciphertext = E(key, plaintext) পাঠায়, Bob plaintext = D(key, ciphertext) চালায়। WhatsApp-এর message body, disk-এর BitLocker/LUKS volume, TLS session-এর বেশিরভাগ ডেটা — সবই আসলে এই এক প্যাটার্নের চাকরি।

Alice: plaintext --E(key)--> ciphertext ---------> Bob
Bob:   ciphertext --D(key)--> plaintext        (একই key!)

(পরের লেসনের জন্য একটা প্রশ্ন তুলে রাখি — Alice আর Bob যদি আগে কখনো না দেখা করে থাকে, তারা এই “একই key” প্রথমে কীভাবে পেল, adversary যে channel শুনছে সেখান দিয়ে? আজ সেই প্রশ্নের উত্তর দেব না — শুধু মনে রাখব প্রশ্নটা আছে।)

আজকের আসল বিষয় দুইটা স্তরে। প্রথম স্তর সহজ: AES — আজকের block cipher standard। দ্বিতীয় স্তরটাই লেসনের প্রাণ, আর সবচেয়ে বেশি production incident-এর উৎস: cipher নিজে perfect হলেও সেটাকে কীভাবে ব্যবহার করছেন — block cipher mode — সেখানেই আসল যুদ্ধ। ভুল mode বাছলে বিশ্বের সেরা cipher-ও তথ্য ফাঁস করে দেয়; আজ সেটা চোখে দেখব, ধারণায় নয়।

মূল ধারণা

Block cipher — fixed block, keyed permutation

Block cipher একটা ফাংশন যা fixed-size ব্লক (AES-এ ঠিক ১২৮ বিট = ১৬ byte) নেয়, সেই একই সাইজের ব্লক ফেরত দেয়, আর কাজটা করে একটা secret key দিয়ে keyed permutation হিসেবে — মানে প্রতিটা সম্ভাব্য input-block-এর জন্য ঠিক একটা output-block, key বদলালে পুরো mapping-টাই বদলে যায়। Rijndael-এর ভেতরের S-box, round-function, key-schedule-এর গণিত এই লেসনে দরকার নেই — শুধু এই চুক্তিটুকু ধরে রাখুন:

E(key, block)  একটা নির্দিষ্ট (key, block)-এর জন্য সবসময় একই output দেয়
             — deterministic, key-বিহীন কেউ invert করতে পারবে না

AES (Advanced Encryption Standard) ২০০১ সালে NIST-এর Rijndael-নির্বাচনের ফসল — DES-এর জায়গায় এখনকার de facto standard, প্রায় সব হার্ডওয়্যারে dedicated instruction (AES-NI) দিয়ে ত্বরান্বিত। তিনটা key size: 128, 192, 256-বিট — বড় key মানে বেশি round (যথাক্রমে ১০, ১২, ১৪), বেশি brute-force resistance, সামান্য বেশি compute। ব্যবহারিক পরামর্শ: AES-128 আজও নিরাপদ, AES-256 দেয় future-proofing মার্জিন — কিন্তু এই লেসনের মূল শিক্ষা সেখানে নয়।

Mode — একটা ব্লক-cipher থেকে পুরো-message-cipher

বাস্তব plaintext ১৬ byte-এর হয় না — কিলোবাইট, মেগাবাইট। একটা ১৬-byte block cipher দিয়ে যেকোনো লম্বার message এনক্রিপ্ট করার নিয়মকে বলে block cipher mode of operation। চারটা mode আজকের বিষয়:

Modeধারণামূল ঝুঁকি/গুণ
ECBপ্রতিটা block আলাদাভাবে encryptidentical block → identical ciphertext — broken
CBCপ্রতিটা block আগেরটার ciphertext-এর সাথে XOR হয়ে তারপর encryptpattern লুকায়; IV predictable হলে, বা padding check leak করলে বিপদ
CTRcounter এনক্রিপ্ট করে keystream বানায়, plaintext-এর সাথে XORstream cipher-এর মতো, parallelizable, no padding — কিন্তু keystream reuse হলে বিপর্যয়
GCMCTR + built-in authentication tagAEAD — modern default, কিন্তু nonce reuse-এ সবচেয়ে বেশি ক্ষতি

চারটাই একই AES ব্যবহার করে — পার্থক্য শুধু কীভাবে ব্লকগুলোকে সাজানো, চেইন করা, বা counter-এ রূপান্তর করা হচ্ছে। বাকি লেসন এই টেবিলটার প্রতিটা সেল প্রমাণ করবে।

ভেতরে কী ঘটছে

ECB — কেন “সহজতম” mode-টাই ভাঙা

Electronic Codebook (ECB) সবচেয়ে naive approach: plaintext-কে ব্লকে কাটো, প্রতিটা ব্লক স্বাধীনভাবে E(key, block_i) চালাও, ফলাফল পাশাপাশি বসাও। কোনো chaining নেই, কোনো IV নেই। সমস্যাটা এক লাইনে:

block_i == block_j  ⟹  E(key, block_i) == E(key, block_j)

Cipher যতই শক্তিশালী হোক — deterministic function হওয়ার কারণে একই input সবসময় একই output দেয়। Plaintext-এ যদি পুনরাবৃত্তি থাকে (কোনো ছবির flat background, কোনো ফাইলের header pattern, ডাটাবেসের একই default-value কলাম), সেই পুনরাবৃত্তি ciphertext-এও হুবহু থেকে যায় — নিচের experiment-এ এটা সংখ্যায় দেখাব।

CBC — IV আর chaining দিয়ে পুনরাবৃত্তি ঢাকা

Cipher Block Chaining (CBC) সমাধান করে একটা সহজ trick দিয়ে: প্রতিটা plaintext block encrypt করার আগে আগের ব্লকের ciphertext-এর সাথে XOR করে নেওয়া হয়। প্রথম ব্লকের জন্য “আগের ব্লক” নেই — তাই একটা random Initialization Vector (IV) ব্যবহার হয়:

c_0 = E(key, p_0 XOR IV)
c_i = E(key, p_i XOR c_{i-1})     i >= 1

এখন দুইটা identical plaintext block-ও ভিন্ন ciphertext দেয়, কারণ তাদের “আগের ciphertext” আলাদা (যদি না পুরো prefix-ই identical হয়)। কিন্তু দুইটা শর্ত ভাঙলে বিপদ:

IV-র জন্য দ্বিতীয় শর্ত: unpredictable হতে হবে (শুধু unique নয়) — নইলে chosen-plaintext attack-এ attacker আগে থেকেই XOR-এর ফলাফল আন্দাজ করতে পারে (TLS-এর BEAST attack, ২০১১, এই দুর্বলতাই কাজে লাগিয়েছিল)।

CTR — block cipher থেকে stream cipher

Counter mode (CTR) সম্পূর্ণ আলাদা কৌশল নেয়: plaintext সরাসরি encrypt না করে, cipher দিয়ে একটা keystream বানানো হয় — nonce আর একটা বাড়তে-থাকা counter মিলিয়ে প্রতিটা block-এর জন্য E(key, nonce || counter_i) চালিয়ে; তারপর সেই keystream শুধু plaintext-এর সাথে XOR হয়:

keystream_i = E(key, nonce || i)
c_i = p_i XOR keystream_i

তিনটা সুবিধা সরাসরি এই সংজ্ঞা থেকে আসে: (১) parallelizable — প্রতিটা keystream_i স্বাধীনভাবে গণনা করা যায়, block chain-এর জন্য অপেক্ষা লাগে না; (২) no padding — XOR যেকোনো length-এ কাজ করে, শেষ ব্লক ছোট হলেও সমস্যা নেই; (৩) decrypt = encrypt, একই keystream আবার XOR করলেই plaintext ফেরত। কিন্তু এই সরলতার দাম একটাই কড়া নিয়মে: keystream কখনো দুইবার ব্যবহার করা যাবে না — নিচে দেখুন কেন।

GCM — CTR + built-in authentication

Galois/Counter Mode (GCM) হলো CTR-এর ওপর একটা authentication layer বসানো সংস্করণ — encryption আর authentication এক পাসেই হয়ে যায় বলে একে বলা হয় AEAD (Authenticated Encryption with Associated Data)। GCM ভেতরে CTR-mode দিয়েই encrypt করে, সাথে সাথে GHASH নামের একটা Galois-field function দিয়ে একটা authentication tag গণনা করে। Output: ciphertext + tag। Decrypt-এর সময় tag verify আগে, plaintext ব্যবহার পরে — tag না মিললে পুরো output বাতিল, একটা byte-ও touch করা হয় না।

“Associated Data” অংশটা গুরুত্বপূর্ণ: TLS-এর packet header, HTTP-এর কিছু metadata — এগুলো এনক্রিপ্ট করার দরকার নেই (plaintext-ই থাকবে), কিন্তু tamper করা যাবে না। GCM-কে সেই extra ডেটা (AAD হিসেবে) দিলে সেটাও tag-এর হিসাবে ঢুকে যায় — encrypt না করেও authenticate করা যায়।

Nonce-reuse বিপর্যয় — এক XOR-এই সব শেষ

CTR আর GCM দুটোই keystream E(key, nonce || counter)-এর ওপর নির্ভর করে। যদি একই (key, nonce) জোড়া দুইবার ব্যবহার হয় — দুইটা ভিন্ন message-এর জন্য — তাহলে দুইটা ক্ষেত্রেই হুবহু একই keystream তৈরি হয়। আর তখন সহজ বীজগণিত:

c1 = p1 XOR ks         c2 = p2 XOR ks
c1 XOR c2 = (p1 XOR ks) XOR (p2 XOR ks) = p1 XOR p2     (ks বাতিল!)

Attacker key জানে না, তবু c1 XOR c2 গণনা করেই সরাসরি p1 XOR p2 পেয়ে যায় — keystream সম্পূর্ণ কেটে যায়। আর যদি attacker p1-এর কিছু অংশ জানে বা আন্দাজ করতে পারে (যেমন প্রায়-সব API request-এর একই fixed header), সেই অংশ XOR করে p2-র সেই একই অংশ বেরিয়ে আসে — আর common-prefix-বাদে বাকিটাও frequency- analysis দিয়ে ভাঙা যায়। নিচের কোডটা আসলেই চালানো, hand-wave নয়:

# nonce_reuse.py — CTR-স্টাইল keystream reuse বিপর্যয় (AES-এর জায়গায় toy stand-in)
import hashlib

def keystream_block(key, nonce, counter):
    # AES(key, nonce||counter)-এর ভূমিকা এখানে একটা keyed hash খেলছে
    return hashlib.sha256(key + nonce + counter.to_bytes(4, "big")).digest()

def ctr_keystream(key, nonce, nbytes):
    out, counter = b"", 0
    while len(out) < nbytes:
        out += keystream_block(key, nonce, counter)
        counter += 1
    return out[:nbytes]

def ctr_encrypt(key, nonce, plaintext):
    ks = ctr_keystream(key, nonce, len(plaintext))
    return bytes(p ^ k for p, k in zip(plaintext, ks))

def xor_bytes(a, b):
    n = min(len(a), len(b))
    return bytes(a[i] ^ b[i] for i in range(n))

key = b"aes-256-key-goes-right-here!!!!"
nonce = b"\x00\x00\x00\x00\x00\x00\x00\x00"     # একই nonce দুইবার — এখানেই bug

msg1 = b"To: bank-api  Amount: $100.00  To-Acct: alice-0091"
msg2 = b"To: bank-api  Amount: $9999.99 To-Acct: mallory-77"

c1 = ctr_encrypt(key, nonce, msg1)
c2 = ctr_encrypt(key, nonce, msg2)

xor_c = xor_bytes(c1, c2)
xor_p = xor_bytes(msg1, msg2)
print(f"c1 XOR c2 == m1 XOR m2 : {xor_c == xor_p}")

recovered = xor_bytes(xor_c, msg1)   # attacker msg1 জানে (fixed header)
print(f"recovered m2 = (c1 XOR c2) XOR m1 -> {recovered.decode()!r}")
print(f"recovered m2 == real m2 : {recovered == msg2}")

প্রত্যাশিত output (deterministic — হুবহু এটাই আসবে):

c1 XOR c2 == m1 XOR m2 : True
recovered m2 = (c1 XOR c2) XOR m1 -> 'To: bank-api  Amount: $9999.99 To-Acct: mallory-77'
recovered m2 == real m2 : True

লক্ষ্য করুন: msg1 (attacker-এর জানা “known plaintext”) আর c1 XOR c2 দিয়েই msg2 পুরোপুরি উদ্ধার হয়ে গেছে — key ভাঙা লাগেনি, brute-force লাগেনি। শুধু একটা XOR। এই একই গণিত ২০১৬ সালের বাস্তব nonce-reuse incident-গুলোর ভিত্তি — কিছু TLS implementation-এ IV generation-এর bug থাকায় GCM nonce পুনরাবৃত্তি হয়েছিল, আর forgery/decryption বাস্তবে demonstrate করা গিয়েছিল।

কোন layer-এ nonce-uniqueness রক্ষা করা উচিত
  1. application codeপ্রতিটা encrypt-call-এর আগে fresh nonce লাগবে
  2. nonce policyrandom-128-bit অথবা monotonic-counter — দুইটার একটা বেছে নিন, মেশাবেন না
  3. AEAD library (GCM/ChaCha20-Poly1305)শুধু uniqueness ধরে নেয়, নিজে check করে না
  4. block cipher (AES)keystream গণনা করে — reuse হলেও কিছু জানে না

উদাহরণ

Worked comparison — একই ৪৮-byte plaintext, চার mode

ধরুন plaintext তিনটা identical ১৬-byte block নিয়ে গঠিত (যেমন কোনো ফাইলের padding বা কোনো ফর্মের ফাঁকা default value বারবার):

Modeciphertext-এ ব্লক ৩টাকেন
ECBidentical, identical, identicalindependent encrypt, একই input → একই output
CBCতিনটাই আলাদাপ্রতিটা ব্লক আগের ciphertext-এর সাথে XOR হয়ে যায়
CTRতিনটাই আলাদাপ্রতিটা ব্লকের keystream আলাদা counter থেকে আসে
GCMতিনটাই আলাদা + একটা authentication tag যোগ হয়ভেতরে CTR + tag

এই টেবিলের প্রথম row-ই ECB-র মৃত্যু-পরোয়ানা: শুধু structural leak-ই না, attacker “কোন দুইটা ব্লক identical” সরাসরি দেখতে পায় — key না ভেঙেও অনেক তথ্য বেরিয়ে যায়। পরের অংশে এটাই ছবি হয়ে দেখব — শুধু টেবিলে নয়, চোখে।

ছবিতে

  CBC (sequential — প্রতিটা ব্লক আগেরটার ওপর নির্ভরশীল)
  IV ──XOR──► [ AES ] ──► c0 ──XOR──► [ AES ] ──► c1 ──XOR──► [ AES ] ──► c2
       p0 ───────┘             p1 ───────┘             p2 ───────┘

  CTR (parallel — প্রতিটা keystream_i স্বাধীনভাবে গণনাযোগ্য)
  nonce||0 ──[AES]──► ks0 ──XOR── p0 ──► c0
  nonce||1 ──[AES]──► ks1 ──XOR── p1 ──► c1     (একসাথে, যেকোনো ক্রমে)
  nonce||2 ──[AES]──► ks2 ──XOR── p2 ──► c2
CBC বনাম CTR — chaining বনাম counter-keystream। CBC-তে ব্লক sequential (আগেরটার ciphertext লাগে); CTR-এ প্রতিটা keystream_i স্বাধীন, তাই parallel।
   AAD (plaintext, tamper-protected)  ─┐
   plaintext ──[ CTR-mode encrypt ]──► ciphertext ─┐
                                                     ├──[ GHASH ]──► tag
   AAD ─────────────────────────────────────────────┘

   decrypt: tag পুনর্গণনা → mismatch হলে REJECT (plaintext কখনো বেরোয় না)
                          → match হলে ciphertext → CTR-decrypt → plaintext
GCM = CTR (encrypt) + GHASH (authenticate) — AAD encrypt হয় না, কিন্তু tag-এর হিসাবে ঢোকে; tag mismatch হলে plaintext কখনো release হয় না।

নিজে চালিয়ে দেখুন

EXPERIMENT

ECB penguin — সংখ্যায়: pattern কি সত্যিই ciphertext-এ থেকে যায়?

macOS / Linux / Windows (Python 3)· ১৫ মিনিট
# ecb_penguin.py — ECB pattern-leak বনাম CBC, সংখ্যায় (AES-এর জায়গায় toy stand-in)
import hashlib

BLOCK = 4  # bytes/block — ছোট রাখা হয়েছে যাতে "ছবি"টা স্ক্রিনে ধরে

def toy_cipher(key, block):
    # সত্যিকারের block cipher (AES) যে গ্যারান্টি দেয় — একই (key,block) সবসময়
    # একই output, ভিন্ন block pseudorandom output — এই ফাংশনও ঠিক সেটাই দেয়।
    # experiment-টা শুধু এই গুণের ওপর নির্ভর করছে, AES-এর ভেতরের গণিতের ওপর না।
    return hashlib.sha256(key + block).digest()[:BLOCK]

def xor_bytes(a, b):
    return bytes(x ^ y for x, y in zip(a, b))

# ---- "বিটম্যাপ": ১০ সারি x ১২ ব্লক, একটা প্লাস-চিহ্ন দুইটা tile দিয়ে ----
TILE_BG = b"\x00\x00\x00\x00"
TILE_FG = b"\xff\xff\xff\xff"
W, H = 12, 10
rows = []
for y in range(H):
    row = []
    for x in range(W):
        on_bar = (4 <= x <= 7)
        on_beam = (3 <= y <= 6)
        row.append(TILE_FG if (on_bar or on_beam) else TILE_BG)
    rows.append(row)
plain_blocks = [b for row in rows for b in row]

key = b"super-secret-key-32-bytes-long!"
ecb_blocks = [toy_cipher(key, b) for b in plain_blocks]

iv = b"\x13\x37\xbe\xef"
cbc_blocks, prev = [], iv
for b in plain_blocks:
    c = toy_cipher(key, xor_bytes(b, prev))
    cbc_blocks.append(c)
    prev = c

def render(blocks, w, h):
    palette = " #.:-=+*%@$"
    seen = {}
    out_rows = []
    for y in range(h):
        line = []
        for x in range(w):
            blk = blocks[y * w + x]
            if blk not in seen:
                seen[blk] = palette[len(seen) % len(palette)]
            line.append(seen[blk])
        out_rows.append("".join(line))
    return out_rows, len(seen)

pt_rows, pt_unique = render(plain_blocks, W, H)
print("plaintext (দুইটা distinct tile, প্লাস-চিহ্ন আকারে সাজানো):")
for r in pt_rows: print("  " + r)
print(f"  unique plaintext blocks: {pt_unique}")

ecb_rows, ecb_unique = render(ecb_blocks, W, H)
print("\nECB ciphertext (প্রতিটা ব্লক independently encrypt):")
for r in ecb_rows: print("  " + r)
print(f"  unique ciphertext blocks: {ecb_unique}")

cbc_rows, cbc_unique = render(cbc_blocks, W, H)
print("\nCBC ciphertext (IV + XOR-with-previous-ciphertext দিয়ে chained):")
for r in cbc_rows: print("  " + r)
print(f"  unique ciphertext blocks: {cbc_unique}")

print(f"\nECB প্লাস-চিহ্ন দেখায়: {'হ্যাঁ' if ecb_unique == pt_unique else 'না'}")
print(f"CBC প্লাস-চিহ্ন লুকায়: {'হ্যাঁ' if cbc_unique == len(plain_blocks) else 'না'} "
      f"({cbc_unique}/{len(plain_blocks)} ব্লক সব আলাদা)")

প্রত্যাশিত output (deterministic — হুবহু এটাই আসবে):

plaintext (দুইটা distinct tile, প্লাস-চিহ্ন আকারে সাজানো):
      ####    
      ####    
      ####    
  ############
  ############
  ############
  ############
      ####    
      ####    
      ####    
  unique plaintext blocks: 2

ECB ciphertext (প্রতিটা ব্লক independently encrypt):
      ####    
      ####    
      ####    
  ############
  ############
  ############
  ############
      ####    
      ####    
      ####    
  unique ciphertext blocks: 2

CBC ciphertext (IV + XOR-with-previous-ciphertext দিয়ে chained):
   #.:-=+*%@$ 
  #.:-=+*%@$ #
  .:-=+*%@$ #.
  :-=+*%@$ #.:
  -=+*%@$ #.:-
  =+*%@$ #.:-=
  +*%@$ #.:-=+
  *%@$ #.:-=+*
  %@$ #.:-=+*%
  @$ #.:-=+*%@
  unique ciphertext blocks: 120

ECB প্লাস-চিহ্ন দেখায়: হ্যাঁ
CBC প্লাস-চিহ্ন লুকায়: হ্যাঁ (120/120 ব্লক সব আলাদা)

এই output-ই বিখ্যাত “ECB penguin” ছবির সংখ্যাগত সংস্করণ — আসল demo-তে Tux-কে AES-ECB দিয়ে এনক্রিপ্ট করলে penguin-এর silhouette স্পষ্ট থেকে যায়, ঠিক যেমন এখানে প্লাস-চিহ্ন 120-ভাগের-2-এ কমেনি — ২-ই রয়ে গেছে। CBC একই কী, একই cipher, শুধু একটা IV আর chaining যোগ করে ব্লক-প্রতি output-কে 120-এর 120-ই আলাদা বানিয়ে দিয়েছে।

এটা কী প্রমাণ করে

ECB mode-এ identical plaintext block সবসময় identical ciphertext block দেয় — একটা structured 'ছবি' (plus-sign প্যাটার্ন) ECB-encrypt করার পরেও দেখা যায়, অথচ CBC-encrypt করলে সেই একই ছবি সম্পূর্ণ random দেখায়। Cipher-এর শক্তি অপ্রাসঙ্গিক — mode-ই নির্ধারক।

নিজে বানান

BUILD IT

NonceGuard — reuse-কে prayer থেকে enforced invariant বানানো

Python · ●●●○○
  1. toy CTR encrypt লিখুন — keystream_i = keyed-function(key, nonce, i), ciphertext = plaintext XOR keystream
  2. একটা unguarded Channel দিয়ে দেখান bug (retry-এ nonce reuse) কীভাবে চুপচাপ পুরো message ফাঁস করে দেয়
  3. NonceGuard ক্লাস লিখুন — প্রতিটা (key_id, nonce) জোড়া মনে রাখে, দ্বিতীয়বার দেখলে ValueError তোলে
  4. একই bug guard-সহ চালান — exception-এ ধরা পড়ুক, catastrophe নয়
  5. সঠিক fix দেখান — monotonic counter-based nonce, guard-সহ, তিনটা send সফল

লক্ষ্য: একই bug (retry-code একই nonce দুইবার পাঠায়) তিনটা ভিন্ন প্রেক্ষাপটে চালিয়ে দেখানো — guard ছাড়া বিপর্যয়, guard-সহ blocked exception, আর সঠিক design-এ প্রশ্নটাই ওঠে না।

# nonce_guard.py — NonceGuard: (key, nonce) reuse-কে চুপচাপ ঘটতে না দেওয়া
import hashlib

def keystream_block(key, nonce, counter):
    return hashlib.sha256(key + nonce + counter.to_bytes(4, "big")).digest()

def ctr_keystream(key, nonce, nbytes):
    out, counter = b"", 0
    while len(out) < nbytes:
        out += keystream_block(key, nonce, counter)
        counter += 1
    return out[:nbytes]

def ctr_encrypt(key, nonce, plaintext):
    ks = ctr_keystream(key, nonce, len(plaintext))
    return bytes(p ^ k for p, k in zip(plaintext, ks))

def xor_bytes(a, b):
    n = min(len(a), len(b))
    return bytes(a[i] ^ b[i] for i in range(n))


class NonceGuard:
    """প্রতিটা (key_id, nonce) জোড়া মনে রাখে — দ্বিতীয়বার দেখলে refuse করে।
    চুপচাপ বিপর্যয়কে সশব্দ, তাৎক্ষণিক exception-এ বদলে দেয়।"""
    def __init__(self):
        self.seen = set()

    def check(self, key_id, nonce):
        k = (key_id, nonce)
        if k in self.seen:
            raise ValueError(f"NONCE REUSE blocked: key={key_id} nonce={nonce.hex()}")
        self.seen.add(k)


class Channel:
    def __init__(self, key, key_id="k1", guard=None):
        self.key, self.key_id, self.guard = key, key_id, guard
        self._counter = 0

    def next_nonce(self):
        n = self._counter.to_bytes(8, "big")   # নিরাপদ: প্রতি channel-এ monotonic
        self._counter += 1
        return n

    def send(self, plaintext, nonce):
        if self.guard is not None:
            self.guard.check(self.key_id, nonce)
        return ctr_encrypt(self.key, nonce, plaintext)


KEY = b"aes-256-key-goes-right-here!!!!"
BUGGY_NONCE = b"\x00\x00\x00\x00\x00\x00\x00\x00"
msg1 = b"To: bank-api  Amount: $100.00  To-Acct: alice-0091"
msg2 = b"To: bank-api  Amount: $9999.99 To-Acct: mallory-77"

print("— শিনারিও ১: unguarded channel, retry-bug একই nonce দুইবার পাঠায় —")
unguarded = Channel(KEY, guard=None)
c1 = unguarded.send(msg1, BUGGY_NONCE)
c2 = unguarded.send(msg2, BUGGY_NONCE)     # bug: nonce reused, কেউ আটকায়নি
recovered = xor_bytes(xor_bytes(c1, c2), msg1)
print("  send #1 ok, send #2 ok (কেউ আপত্তি করেনি)")
print(f"  attacker recovers msg2 from c1,c2,known-msg1: {recovered == msg2}")
print(f"  recovered text: {recovered.decode()!r}")

print("\n— শিনারিও ২: একই bug, কিন্তু এবার NonceGuard বসানো —")
guard = NonceGuard()
guarded = Channel(KEY, guard=guard)
c1 = guarded.send(msg1, BUGGY_NONCE)
print("  send #1 ok — first use of this nonce")
try:
    c2 = guarded.send(msg2, BUGGY_NONCE)   # একই bug আবার
    print("  send #2 ok — এটা হওয়ার কথা না!")
except ValueError as e:
    print(f"  send #2 BLOCKED: {e}")

print("\n— শিনারিও ৩: bug ঠিক করে counter-based nonce, guard-সহ —")
fixed = Channel(KEY, guard=NonceGuard())
sent = []
for i, m in enumerate((msg1, msg2, b"To: bank-api  Amount: $5.00     To-Acct: carol-1002"), 1):
    n = fixed.next_nonce()
    c = fixed.send(m, n)
    sent.append((n, c))
    print(f"  send #{i} ok — nonce={n.hex()}")
print(f"  সব nonce distinct: {len({n for n, _ in sent}) == len(sent)}")

প্রত্যাশিত output (deterministic — হুবহু এটাই আসবে):

— শিনারিও ১: unguarded channel, retry-bug একই nonce দুইবার পাঠায় —
  send #1 ok, send #2 ok (কেউ আপত্তি করেনি)
  attacker recovers msg2 from c1,c2,known-msg1: True
  recovered text: 'To: bank-api  Amount: $9999.99 To-Acct: mallory-77'

— শিনারিও ২: একই bug, কিন্তু এবার NonceGuard বসানো —
  send #1 ok — first use of this nonce
  send #2 BLOCKED: NONCE REUSE blocked: key=k1 nonce=0000000000000000

— শিনারিও ৩: bug ঠিক করে counter-based nonce, guard-সহ —
  send #1 ok — nonce=0000000000000000
  send #2 ok — nonce=0000000000000001
  send #3 ok — nonce=0000000000000002
  সব nonce distinct: True

তিন মুহূর্তের পাঠ: শিনারিও ১-এ bug নীরবে পুরো msg2 ফাঁস করে দিল — কোনো error, কোনো log নেই, শুধু একটা compromised transaction; শিনারিও ২-এ একই bug তাৎক্ষণিক exception হয়ে ধরা পড়ল — production-এ এটাই crash হয়ে alert তোলে, silent breach হয় না; শিনারিও ৩-এ প্রশ্নটাই অবান্তর — nonce কখনো repeat করার সুযোগই design-এ নেই।

নিজে বাড়ান:

  1. NonceGuard-কে persistent বানান (ফাইল/DB-backed) — process restart-এর পরেও counter মনে থাকবে কীভাবে?
  2. Random-nonce policy যোগ করুন (os.urandom(12)-ধাঁচ) — কত nonce-এর পরে birthday-collision probability কার্যকর হয়ে ওঠে, হিসাব করুন
  3. Multi-key version — NonceGuard-এর seen set memory-তে unbounded বাড়বে; বাস্তব system-এ eviction policy কী হতে পারে?

বাস্তব সিস্টেমে

বাস্তব জগতে symmetric encryption ও mode-বাছাই

TLS 1.3। Default cipher suite-গুলো (AES_128_GCM_SHA256, AES_256_GCM_SHA384, CHACHA20_POLY1305_SHA256) সবই AEAD — TLS 1.3 spec থেকে non-AEAD mode একেবারে বাদ দিয়েছে, কারণ padding-oracle ইতিহাস (POODLE, Lucky13) বারবার CBC-কে টার্গেট করেছিল। ChaCha20-Poly1305 হলো GCM-এর alternative — hardware AES-acceleration না থাকা mobile/embedded device-এ দ্রুততর।

Adobe password breach (2013)। ১৫ কোটির বেশি user-এর password এনক্রিপ্ট করা ছিল 3DES ECB mode-এ, hash না করে। ফলাফল: identical password-এর user-রা identical ciphertext পেল — attacker শুধু frequency analysis দিয়েই লক্ষ লক্ষ password-hint আর pattern মিলিয়ে অনুমান করতে পেরেছিল। ঠিক আজকের experiment-এর মতোই — শুধু password-এ, penguin-এ নয়।

Full-disk encryption — LUKS/BitLocker। ডিস্ক-এনক্রিপশনের নিজস্ব সমস্যা: একই sector বারবার rewrite হয়, তাই CTR/GCM-এর নিয়মিত nonce-scheme অচল হয়ে যায়। সমাধান একটা specialized mode — XTS — যেখানে প্রতিটা sector নিজের অবস্থান থেকে deterministic-ভাবে একটা “tweak” পায়; আজকের চারটা mode-এর বাইরে, কিন্তু একই নীতির সম্প্রসারণ: predictable structure এড়ানোর জন্য প্রতিটা mode নিজের মতো chaining/tweak বানায়।

Signal Protocol। ঐতিহাসিকভাবে AES-256-CBC + HMAC-SHA256 (encrypt-then-MAC, হাতে জোড়া লাগানো AEAD) ব্যবহার করত — সঠিকভাবে করলে নিরাপদ, কিন্তু ভুল করার সুযোগ বেশি বলেই আধুনিক ডিজাইন GCM/ChaCha20- Poly1305-এর মতো built-in AEAD-কে প্রাধান্য দেয়।

যে ভুলগুলো সবাই করে

“AES ব্যবহার করলেই ডেটা নিরাপদ — কোন mode সেটা গুরুত্বপূর্ণ না।”

Experiment-টাই সরাসরি এর বিরুদ্ধে প্রমাণ: একই AES, ECB mode-এ প্লাস-চিহ্ন হুবহু ফাঁস করে দিল, CBC mode-এ সম্পূর্ণ random দেখাল। Cipher-এর শক্তি আর mode-এর সঠিক ব্যবহার — সম্পূর্ণ ভিন্ন axis; একটা ঠিক থাকলেও অন্যটা ভুল হলে পুরো নিরাপত্তা ধসে পড়ে।

“Encryption করা মানেই ডেটা tamper-proof — কেউ বদলাতে পারবে না।”

Plain encryption (ECB/CBC/CTR শুধু) কোনো integrity guarantee দেয় না — attacker ciphertext bit-flip করলে decrypt-এর পরে predictable-ভাবে plaintext বদলে যায়, আর receiver বুঝতেই পারে না কিছু বদলেছে। CBC padding- oracle attack-এর পুরো ভিত্তিই এটা: attacker ciphertext modify করে receiver-এর error-response পর্যবেক্ষণ করে। শুধু AEAD (GCM, ChaCha20-Poly1305) built-in authentication tag দিয়ে এই ফাঁক বন্ধ করে।

“CTR/GCM-এর nonce IV-র মতোই unpredictable (random) হতে হবে।”

CTR/GCM-এর জন্য মূল শর্ত শুধু uniqueness — একই (key, nonce) জোড়া দুইবার নয়। তাই একটা monotonic counter (0, 1, 2, ...) সম্পূর্ণ বৈধ nonce policy — build section-এ ঠিক এটাই দেখানো হয়েছে। CBC-র IV-র শর্ত ভিন্ন ও কড়া: unpredictable হতে হবে (BEAST attack এই পার্থক্যই কাজে লাগিয়েছিল)। দুই mode-এর দুই আলাদা চুক্তি — গুলিয়ে ফেললে বিপদ দুই দিকেই।

“Nonce একবার reuse হলে শুধু ওই একটা message-ই ঝুঁকিতে পড়ে।”

Experiment দেখাল: reuse হলে দুইটা message-ই একসাথে বিপদে — c1 XOR c2 = p1 XOR p2, দুইটার কোনোটাই একা রক্ষা পায় না। GCM-এ আরও খারাপ: nonce reuse authentication key-ও ফাঁস করতে পারে (GHASH subkey পুনরুদ্ধারযোগ্য হয়ে ওঠে), মানে শুধু ওই দুইটা message নয় — ভবিষ্যতের forgery-র দরজাও খুলে যায়। এই জন্যই NIST SP 800-38D নিজেই nonce-uniqueness-কে “MUST NEVER”-ভাষায় লেখে।

বুঝেছেন কি না দেখুন

1

Block cipher-এর সংজ্ঞায় “keyed permutation” কথাটার মানে কী, আর AES-এ key size কী কী হতে পারে?

স্মরণ

প্রতিটা key-এর জন্য input-block থেকে output-block-এ একটা নির্দিষ্ট, deterministic, invertible mapping — key বদলালে পুরো mapping বদলে যায়। AES-এ key size ১২৮, ১৯২, অথবা ২৫৬-বিট — block size সবসময় ১২৮-বিট (১৬ byte) হয়, key size-নির্বিশেষে।

2

Experiment-এ ECB আর CBC একই key, একই plaintext ব্যবহার করেও ভিন্ন ফলাফল দিল। কোন একটা mechanical পার্থক্য এই পুরো ফলাফলের কারণ?

যুক্তি

ECB প্রতিটা ব্লক independently encrypt করে — একই input block সবসময় একই output block দেয়, তাই plaintext-এর পুনরাবৃত্তি ciphertext-এও থেকে যায়। CBC প্রতিটা ব্লককে IV/আগের-ciphertext-এর সাথে XOR করে নেয় — identical plaintext block-ও ভিন্ন “আগের ciphertext”-এর কারণে ভিন্ন output দেয়।

3

একটা backend service প্রতিটা encrypt-call-এ os.urandom(12) দিয়ে nonce বানায় GCM-এর জন্য — কিন্তু retry-logic-এ একই request দুইবার একই nonce-সহ পাঠানো হচ্ছে (caller নিজেই nonce মনে রেখে পাঠাচ্ছে, প্রতিবার নতুন বানাচ্ছে না)। এই bug-এ ঠিক কী ঘটবে, আর NonceGuard-ধাঁচের কোন check সেটা আটকাবে?

প্রয়োগ

দুইটা ভিন্ন plaintext একই keystream দিয়ে XOR হবে — c1 XOR c2 = p1 XOR p2, আর GCM-এ authentication key-ও ঝুঁকিতে পড়তে পারে। NonceGuard-এর (key_id, nonce)-tracking check সেই দ্বিতীয় ব্যবহারে ValueError তুলে retry-টা fail-fast করে দেবে — silent breach-এর বদলে সশব্দ bug।

4

GCM-কে “AEAD” বলা হয় কেন — শুধু “encryption” বললে কী তথ্য হারিয়ে যায়?

যুক্তি

“Encryption” শুধু confidentiality বোঝায় — কেউ পড়তে পারবে না। AEAD আরও দুইটা জিনিস একসাথে দেয়: authentication (tag verify না হলে plaintext release হয় না, tamper-detect) আর “Associated Data” — এমন ডেটা যেটা এনক্রিপ্ট হয় না (plaintext থাকে) কিন্তু authenticate হয় (tamper হলে ধরা পড়ে)। শুধু “encryption” শব্দে এই দুই অতিরিক্ত গ্যারান্টি হারিয়ে যায়।

5

একটা নতুন mobile chat app design করছেন — message body এনক্রিপ্ট করতে হবে, প্রতিটা device-এর battery কম, আর সার্ভার message-metadata (sender-id, timestamp) না-এনক্রিপ্ট রাখতে চায় কিন্তু tamper-proof করতে চায়। কোন mode বাছবেন, আর nonce policy কী হবে?

ডিজাইন

AEAD (GCM অথবা battery-friendly হলে ChaCha20-Poly1305, যেহেতু hardware AES-acceleration mobile-এ সবসময় থাকে না) — encryption + authentication এক পাসেই, আর metadata AAD হিসেবে দিলে না-এনক্রিপ্ট থেকেও tamper-protected হবে। Nonce policy: per-message monotonic counter (device+session-scoped) অথবা যথেষ্ট-বড় random value — দুই-ই বৈধ যতক্ষণ uniqueness guaranteed; counter সহজ কিন্তু state persist করা লাগবে (device restart-এ reset না হওয়া চাই), random সহজ কিন্তু NIST-এর recommend করা ৯৬-বিটের কমে collision- ঝুঁকি বাড়ে।

6

“AES-256 GCM ব্যবহার করছি, তাই আমার crypto নিরাপদ” — এই দাবিতে কোন প্রশ্নটা এখনো অনুত্তরিত থেকে যায়, যা এই লেসন থেকেই স্পষ্ট হওয়া উচিত?

যুক্তি

Nonce policy কী — এবং সেটা সত্যিই কখনো reuse হচ্ছে না তা নিশ্চিত কিনা। Mode সঠিক (GCM) হলেও nonce-uniqueness ভঙ্গ হলে build/experiment-এ দেখা বিপর্যয়ই ঘটে — key size বা mode-নাম যথেষ্ট নয়, বাস্তবায়নের প্রতিটা ধাপ (বিশেষত nonce generation) সঠিক হতে হবে।

এরপর কী

এরপর কী

Symmetric encryption দ্রুত, দক্ষ — কিন্তু একটা মৌলিক chicken-and-egg সমস্যা রেখে যায়: দুইজন মানুষ যারা আগে কখনো দেখা করেনি, adversary যে channel শুনছে তার ওপর দিয়েই কীভাবে একটা shared secret key-তে একমত হবে? Symmetric primitive দিয়ে এই প্রশ্নের সরাসরি উত্তর নেই — চাবিটাই তো লাগবে চাবি পাঠাতে।

পরের লেসনে সেই সমাধান: asymmetric (public-key) cryptography — যেখানে encrypt-key আর decrypt-key আলাদা, একটা প্রকাশ্য (public), একটা গোপন (private)। RSA আর Diffie-Hellman-এর গণিত দিয়ে দেখব কীভাবে দুইজন অচেনা মানুষ প্রকাশ্য channel-এ কথা বলেই একটা গোপন কিছুতে একমত হতে পারে — আর কেন বাস্তব system-গুলো (TLS-সহ) asymmetric আর symmetric দুইটাকেই একসাথে ব্যবহার করে, একটাকে বাদ দিয়ে না।

আরও পড়ুন

  • Advanced Encryption Standard (AES) — FIPS 197 — National Institute of Standards and Technology (NIST) · AES-এর সরকারি standard — block size, key schedule, key size তিনটার আনুষ্ঠানিক সংজ্ঞা
  • Recommendation for Block Cipher Modes of Operation: Galois/Counter Mode (GCM) and GMAC — SP 800-38D — National Institute of Standards and Technology (NIST) · GCM-এর আনুষ্ঠানিক সংজ্ঞা — nonce-uniqueness requirement এই ডকুমেন্টেই কড়াভাষায় লেখা
  • Security Flaws Induced by CBC Padding — Applications to SSL, IPSEC, WTLS — Serge Vaudenay — EUROCRYPT 2002 · Padding-oracle attack-এর মূল paper — CBC-mode-এর ইতিহাসে সবচেয়ে প্রভাবশালী দুর্বলতা
  • Block cipher mode of operation — ECB illustration ('ECB penguin') — Wikipedia contributors · Tux-কে AES-ECB দিয়ে এনক্রিপ্ট করলে silhouette রয়ে যায় — এই লেসনের experiment তারই সংখ্যাভিত্তিক পুনর্নির্মাণ