Symmetric Encryption ও Block Cipher Mode — একই চাবি, দুই কাজ
Symmetric Encryption and Block Cipher Modes
একই চাবি এনক্রিপ্ট-ডিক্রিপ্ট দুই-ই করে — AES এই যুগের block cipher, কিন্তু আসল যুদ্ধ mode-এ: ECB পুনরাবৃত্তি ফাঁস করে, CBC/CTR সেটা ঢাকে, GCM এক ধাক্কায় authentication-ও দেয়; nonce reuse করলে সেই নিরাপত্তা এক XOR-এই ধসে পড়ে।
আগে এটা বুঝি
আগের লেসনে 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 আলাদাভাবে encrypt | identical block → identical ciphertext — broken |
| CBC | প্রতিটা block আগেরটার ciphertext-এর সাথে XOR হয়ে তারপর encrypt | pattern লুকায়; IV predictable হলে, বা padding check leak করলে বিপদ |
| CTR | counter এনক্রিপ্ট করে keystream বানায়, plaintext-এর সাথে XOR | stream cipher-এর মতো, parallelizable, no padding — কিন্তু keystream reuse হলে বিপর্যয় |
| GCM | CTR + built-in authentication tag | AEAD — 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 করা গিয়েছিল।
- application codeপ্রতিটা encrypt-call-এর আগে fresh nonce লাগবে
- nonce policyrandom-128-bit অথবা monotonic-counter — দুইটার একটা বেছে নিন, মেশাবেন না
- AEAD library (GCM/ChaCha20-Poly1305)শুধু uniqueness ধরে নেয়, নিজে check করে না
- block cipher (AES)keystream গণনা করে — reuse হলেও কিছু জানে না
উদাহরণ
Worked comparison — একই ৪৮-byte plaintext, চার mode
ধরুন plaintext তিনটা identical ১৬-byte block নিয়ে গঠিত (যেমন কোনো ফাইলের padding বা কোনো ফর্মের ফাঁকা default value বারবার):
| Mode | ciphertext-এ ব্লক ৩টা | কেন |
|---|---|---|
| ECB | identical, identical, identical | independent 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 AAD (plaintext, tamper-protected) ─┐
plaintext ──[ CTR-mode encrypt ]──► ciphertext ─┐
├──[ GHASH ]──► tag
AAD ─────────────────────────────────────────────┘
decrypt: tag পুনর্গণনা → mismatch হলে REJECT (plaintext কখনো বেরোয় না)
→ match হলে ciphertext → CTR-decrypt → plaintextনিজে চালিয়ে দেখুন
ECB penguin — সংখ্যায়: pattern কি সত্যিই ciphertext-এ থেকে যায়?
# 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-ই নির্ধারক।
নিজে বানান
NonceGuard — reuse-কে prayer থেকে enforced invariant বানানো
- toy CTR encrypt লিখুন — keystream_i = keyed-function(key, nonce, i), ciphertext = plaintext XOR keystream
- একটা unguarded Channel দিয়ে দেখান bug (retry-এ nonce reuse) কীভাবে চুপচাপ পুরো message ফাঁস করে দেয়
- NonceGuard ক্লাস লিখুন — প্রতিটা (key_id, nonce) জোড়া মনে রাখে, দ্বিতীয়বার দেখলে ValueError তোলে
- একই bug guard-সহ চালান — exception-এ ধরা পড়ুক, catastrophe নয়
- সঠিক 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-এ নেই।
নিজে বাড়ান:
NonceGuard-কে persistent বানান (ফাইল/DB-backed) — process restart-এর পরেও counter মনে থাকবে কীভাবে?- Random-nonce policy যোগ করুন (
os.urandom(12)-ধাঁচ) — কত nonce-এর পরে birthday-collision probability কার্যকর হয়ে ওঠে, হিসাব করুন - Multi-key version —
NonceGuard-এরseenset 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”-ভাষায়
লেখে।
বুঝেছেন কি না দেখুন
1Block cipher-এর সংজ্ঞায় “keyed permutation” কথাটার মানে কী, আর AES-এ
key size কী কী হতে পারে?
স্মরণ
প্রতিটা key-এর জন্য input-block থেকে output-block-এ একটা নির্দিষ্ট, deterministic, invertible mapping — key বদলালে পুরো mapping বদলে যায়। AES-এ key size ১২৮, ১৯২, অথবা ২৫৬-বিট — block size সবসময় ১২৮-বিট (১৬ byte) হয়, key size-নির্বিশেষে।
2Experiment-এ 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 সেটা আটকাবে?
প্রয়োগ
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।
4GCM-কে “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 তারই সংখ্যাভিত্তিক পুনর্নির্মাণ