Foundationপ্রথম নীতি থেকে
LEVEL 10লেসন ৩/২০কঠিন৫০ মিনিট

Cryptographic Hash Function — ফিঙ্গারপ্রিন্ট, চাবি ছাড়াই

Cryptographic Hash Functions

একই input সবসময় একই fixed-size fingerprint দেয়, এক অক্ষর বদলালে আউটপুট প্রায় অর্ধেক বদলে যায়; কিন্তু collision resistance-এর আসল খরচ birthday paradox-এর কারণে অর্ধেক বিটেই নেমে আসে, আর SHA-1-এর SHAttered ধস প্রমাণ করে theoretical দুর্বলতা কবে practical বিপর্যয়ে বদলায়।

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

  • Preimage resistance, second-preimage resistance, আর collision resistance — cryptographic hash function-এর তিনটা আবশ্যিক property নিজের সংজ্ঞা আর concrete failure scenario দিয়ে আলাদা করে ব্যাখ্যা করতে পারবেন
  • Avalanche effect আসলে SHA-256 চালিয়ে সংখ্যায় দেখাতে পারবেন, আর কেন এক অক্ষর বদলালে output-এর প্রায় অর্ধেক বিট বদলে যায় তা যুক্তি দিতে পারবেন
  • Birthday paradox থেকে কীভাবে collision-search-এর কাজ preimage-search-এর বর্গমূলে নেমে আসে তা derive করতে পারবেন, আর কেন SHA-256-এর ২৫৬-বিট output মাত্র ১২৮-বিট collision-security দেয় তা ব্যাখ্যা করতে পারবেন
  • SHA-1-এর SHAttered collision (২০১৭) কেস স্টাডি থেকে theoretical দুর্বলতা কীভাবে practical exploit-এ রূপান্তরিত হয় তা বিশ্লেষণ করতে পারবেন
  • Merkle–Damgård construction-এর মৌলিক গঠন আর length-extension attack-এর যান্ত্রিকতা বুঝে HMAC কেন দরকার তা reasoning করে বলতে পারবেন

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

আগে এটা বুঝি

আগের লেসনে Kerckhoffs-এর নীতি আর threat-model-এর ভাষা তৈরি হয়েছে — attacker কী জানে, কী চায়, কী দাম দিতে রাজি। আজকের প্রথম যন্ত্রটা সেই ভাষায় অদ্ভুত রকমের সহজ, কারণ এর কোনো secret নেই। কোনো key ছাড়াই এমন একটা ফাংশন চাই যেটা যেকোনো সাইজের ডেটা নিয়ে একটা নির্দিষ্ট, ছোট, fixed-size “ছাপ” বানিয়ে দেয় — সেই ছাপ দেখেই বলা যায় ডেটা বদলেছে কিনা, অথচ ছাপ থেকে আসল ডেটা ফেরত পাওয়া যায় না।

এটাই cryptographic hash function। Git-এর প্রতিটা commit একটা hash দিয়ে চেনা যায়, ডাউনলোড করা ISO ফাইলের পাশে একটা SHA256SUMS থাকে, password database-এ raw password থাকে না — hash থাকে। তিনটা ব্যবহারই একই সংজ্ঞার ওপর দাঁড়িয়ে:

H : যেকোনো-length input  -->  fixed-length output (digest / fingerprint)

লক্ষ্য করুন — গত লেসনের ভাষায় এটা encryption না। কোনো key নেই, কোনো decrypt নেই, উদ্দেশ্য গোপন করা নয় বরং যাচাই করা। “একই input সবসময় একই output” — deterministic; “output থেকে input বের করা অসম্ভব” — one-way। এই দুইটা গুণ মিলিয়েই hash function একটা fingerprint-যন্ত্র, আর বাকি লেসনে দেখব ঠিক কতটা “অসম্ভব” বলা যায়, আর সেই “অসম্ভব”-এর সংখ্যাটা কীভাবে হিসাব হয়।

মূল ধারণা

তিনটা সম্পত্তি — এক লাইনে না, প্রতিটার নিজস্ব সংজ্ঞা লাগবে

“One-way” কথাটা শুনতে একটাই গুণ মনে হয়, কিন্তু cryptographic hash function-এর নিরাপত্তা আসলে তিনটা আলাদা সম্পত্তির ওপর দাঁড়িয়ে — প্রতিটা ভাঙলে আলাদা attack সম্ভব হয়, তাই প্রতিটার সংজ্ঞা আলাদা করে ধরতে হবে।

১. Preimage resistance (one-wayness)। একটা digest h দেওয়া হলে, এমন কোনো m খুঁজে বের করা computationally infeasible হওয়া উচিত যেন H(m) = h। Failure scenario: কোনো authentication server-এর leaked database-এ শুধু password-এর raw hash আছে ধরুন। Hash function যদি preimage-resistant না হতো, attacker সরাসরি প্রতিটা h-কে invert করে আসল password ফেরত পেয়ে যেত — কোনো guessing, কোনো dictionary লাগত না, সরাসরি গাণিতিক inversion।

২. Second-preimage resistance। একটা নির্দিষ্ট m1 দেওয়া হলে, এমন কোনো ভিন্ন m2 খুঁজে বের করা infeasible হওয়া উচিত যেন H(m1) = H(m2)। Failure scenario: একটা software vendor একটা legitimate installer m1 প্রকাশ করে তার hash সাইন করে দিয়েছে। যদি second-preimage resistance ভাঙা যেত, attacker সেই নির্দিষ্ট m1-এর জন্য একটা malware-সহ m2 বানাতে পারত যার hash হুবহু একই — signature এখনও valid, কিন্তু ফাইলটা malicious।

৩. Collision resistance। কোনো input fix করা নেই — attacker স্বাধীনভাবে দুইটাই বেছে নিতে পারে: এমন যেকোনো ভিন্ন m1, m2 জোড়া খুঁজে বের করা infeasible হওয়া উচিত যেন H(m1) = H(m2)। Failure scenario: এটাই আজকের বাস্তব case study — attacker দুইটা PDF বানায়, একটা নিরীহ contract, একটা malicious version, দুইটার hash হুবহু এক; নিরীহটা সাইন করিয়ে নিয়ে পরে malicious version-টা “একই signature-এ বৈধ” বলে চালিয়ে দেয়।

PropertyAttacker কী স্বাধীনভাবে বেছে নিতে পারেকী ফিক্সড থাকেNaive brute-force খরচ
Preimagemh (target)2^n
Second-preimagem2m1 (fixed)2^n
Collisionm1 এবং m2কিছুই না2^(n/2) — কেন, পরের অংশে

শেষ কলামটাই আজকের সবচেয়ে গুরুত্বপূর্ণ চমক: প্রথম দুইটার খরচ 2^n, তৃতীয়টা হঠাৎ 2^(n/2)-এ নেমে যায় — attacker-এর “দুইটাই স্বাধীনভাবে বেছে নেওয়ার” স্বাধীনতাই এই পার্থক্যের উৎস। এই একটা লাইন বোঝাই “hood” অংশের কাজ।

ভেতরে কী ঘটছে

Avalanche effect — সংখ্যায় দেখা

Cryptographic hash function-এর একটা ডিজাইন-লক্ষ্য হলো avalanche effect: input-এ এক বিট বদলালেও output-এর প্রায় অর্ধেক বিট বদলে যাওয়া উচিত — deterministic হলেও predictable নয়। নিচের কোডটা আসলেই চালানো:

# avalanche.py — SHA-256-এ প্রায়-অভিন্ন string-এর হ্যাশ কতটা আলাদা
import hashlib

def sha256_hex(s):
    return hashlib.sha256(s.encode()).hexdigest()

messages = [
    "transfer:100.00",
    "transfer:100.01",   # শেষ অক্ষর বদল
    "Transfer:100.00",   # প্রথম অক্ষরের case বদল
    "transfer:100.00 ",  # একটা trailing space যোগ
]

for m in messages:
    print(f"{m!r:22} -> {sha256_hex(m)}")

def bit_diff(hex1, hex2):
    b1 = bin(int(hex1, 16))[2:].zfill(256)
    b2 = bin(int(hex2, 16))[2:].zfill(256)
    return sum(c1 != c2 for c1, c2 in zip(b1, b2))

h0 = sha256_hex(messages[0])
for m in messages[1:]:
    h = sha256_hex(m)
    d = bit_diff(h0, h)
    print(f"bit-diff({messages[0]!r}, {m!r}) = {d}/256 ({100*d/256:.1f}%)")

প্রত্যাশিত output (deterministic — SHA-256 আজীবন একই input-এ একই output দেয়):

'transfer:100.00'      -> 796ec17891de48aadb05cf7ec6ec4baf6e2e7db20ac4623c26c41895266933ba
'transfer:100.01'      -> 252905657c5bb8e1df3577c6067d65e922b13bb919d31a69340ba59b45519735
'Transfer:100.00'      -> 6735e1b2911686e4ec3042e9d7a0b1d237a62b369ec92426554be7104e76b3ae
'transfer:100.00 '     -> e1ce428785387e8b3cde0c8ce61dac2c5bd06743caa85d43d52674975788f6a4
bit-diff('transfer:100.00', 'transfer:100.01') = 117/256 (45.7%)
bit-diff('transfer:100.00', 'Transfer:100.00') = 117/256 (45.7%)
bit-diff('transfer:100.00', 'transfer:100.00 ') = 134/256 (52.3%)

তিনটা পরিবর্তনই ন্যূনতম — একটা অক্ষর, একটা case, একটা trailing space — অথচ প্রতিবারই আউটপুটের প্রায় অর্ধেক বিট (৪৫-৫২%) বদলে গেছে। এই সংখ্যাগুলো নিজেই একটা লুকানো তথ্য দেয়: ideal random function-এ দুইটা independent ২৫৬-বিট string-এর expected bit-diff ঠিক ৫০%। SHA-256 সেই আদর্শের এত কাছে যে “কতটুকু বদলেছি” থেকে “মূল input কী ছিল” আন্দাজ করার কোনো পথ থাকে না — সেটাই avalanche effect-এর আসল কাজ: input আর output-এর মধ্যে সব statistical সূত্র মুছে দেওয়া।

Birthday paradox — কেন collision অর্ধেক বিটেই সস্তা

আগের অংশের টেবিলের রহস্য এখানে ভাঙি। ক্লাসিক birthday paradox: একটা ঘরে কতজন মানুষ থাকলে দুইজনের জন্মদিন মিলে যাওয়ার সম্ভাবনা ৫০% ছাড়িয়ে যায়? উত্তর মাত্র ২৩ জন — অথচ বছরে ৩৬৫ দিন। এত কম কেন? কারণ প্রশ্নটা “একটা নির্দিষ্ট দিনের সাথে কারও মিল” নয় — প্রশ্নটা “যেকোনো দুইজনের মধ্যে মিল”, আর k জনের মধ্যে সম্ভাব্য জোড়ার সংখ্যা k(k-1)/2 — k-এর সাথে বর্গের হারে বাড়ে।

Cryptographic hash-এ ঠিক একই গণিত। n-বিট hash মানে N = 2^nটা সম্ভাব্য output। kটা random input হ্যাশ করলে কোনো-না-কোনো জোড়া মেলার সম্ভাবনা:

P(collision) ≈ 1 - e^(-k²/2N)

P = 0.5 বসিয়ে সমাধান করলে:

k ≈ 1.1774 * sqrt(N) = 1.1774 * 2^(n/2)

এটাই birthday bound। মূল কারণ pigeonhole-এর সাথে combinatorial explosion-এর মিলন: preimage/second-preimage-এ attacker-কে একটা নির্দিষ্ট target-এর সাথে মেলাতে হয় — তাই কাজ ~N-এর সমানুপাতিক। Collision-এ target-ই নেই, attacker শুধু যেকোনো জোড়া খুঁজছে — আর জোড়ার সংখ্যা k²-এর হারে বাড়ায় মাত্র ~sqrt(N)টা sample-এই একটা জোড়া মেলার সম্ভাবনা ৫০% ছাড়িয়ে যায়।

নিচের experiment অংশে এই 1.1774 * sqrt(N) predicton সত্যিই সংখ্যায় মিলিয়ে দেখানো হবে — hand-wave নয়।

উদাহরণ

Digest size বনাম কাজের-খরচ — একটা টেবিলে পুরো ইতিহাস

নিচের টেবিলটা 2^n (preimage) বনাম 2^(n/2) (collision) সংখ্যায় দেখায় — আর সেই সাথে বাস্তব হ্যাশ ফাংশনগুলোর ভাগ্য:

Digest size (n)Preimage কাজ (2^n)Collision কাজ (2^(n/2))বাস্তব উদাহরণ
64-বিট (toy)1.845×10^194.295×10^9শুধু demo-তে ব্যবহারযোগ্য
128-বিট (MD5)3.403×10^381.845×10^19Collision broken ২০০৪-এ — 2^19-এর কাছাকাছি ব্যবহারিক আক্রমণ
160-বিট (SHA-1)1.462×10^481.209×10^24 (তাত্ত্বিক)Collision practically broken ২০১৭-এ (SHAttered, ~2^63.1 — নিচে)
256-বিট (SHA-256)1.158×10^773.403×10^38আজও কোনো practical collision নেই

MD5-এর গল্পটা spelling-out করার মতো: 2^128 preimage-security থেকে দূরে কিছু হয়নি — MD5-এর preimage resistance আজও দুর্বল নয় ব্যবহারিক অর্থে। ভেঙেছে collision resistance, আর তার কারণ শুধু গাণিতিক 2^64 বাধা নয় — MD5-এর compression function-এ structural weakness পাওয়া গেছে যা এই সংখ্যাটাকেও অনেক নিচে নামিয়ে দিয়েছে (Wang et al., ২০০৪-এ কয়েক ঘণ্টায় collision)। SHA-1-এর গল্প নিচের case study-তে — সেখানে birthday bound (2^80) থেকেও দুর্বল, structural আক্রমণে ~2^63.1-এ নেমেছিল।

ছবিতে

IV ──►[compress]──► state1 ──►[compress]──► state2 ──►[compress]──► digest
        ▲                       ▲                       ▲
        │                       │                       │
      block0                 block1                 block2 (+ padding: 0x80, শূন্য, length)

state_i = compress(state_{i-1}, block_i)      digest = state_শেষ
Merkle–Damgård construction — যেকোনো-length message-কে ফিক্সড-size block-এ কেটে একটা compression function বারবার চালানো; শেষ state-ই digest।

MD5, SHA-1, আর SHA-256 — তিনটাই এই একই কাঠামোর ওপর দাঁড়িয়ে (SHA-3 আলাদা, sponge construction — আজকের বিষয় না)। ধারণাটা সরল: fixed-size input-এর জন্য একটা ভালো compression function ডিজাইন করো, তারপর সেটা বারবার chain-করে যেকোনো-length input হ্যান্ডল করো। কিন্তু এই সরলতারই একটা কাঠামোগত মূল্য আছে — length-extension attack।

ভিকটিম:     tag = H(secret || message)                     (attacker শুধু tag দেখে)

attacker-এর trick:
   tag নিজেই তো compression-chain-এর "শেষ state" —
   attacker সেই state-কে *নতুন IV* বানিয়ে চালিয়ে যায়:

   tag ──►[compress]──► state' ──►[compress]──► forged_tag
            ▲                        ▲
          glue-padding          extension (attacker-এর নিজের যোগ করা ডেটা)

ফলাফল: forged_tag == H(secret || message || glue || extension)
        — অথচ attacker কখনো secret দেখেনি!
Length-extension attack-এর anatomy — attacker digest-টাকেই নতুন 'IV' হিসেবে ব্যবহার করে, secret না জেনেই আরও ব্লক যোগ করে।

সমস্যাটার মূল কারণ: H(secret || message)-এর পুরো internal state-ই হলো final digest — কোনো অতিরিক্ত “finalization” ধাপ নেই যা সেই state-কে আড়াল করে। যে কেউ digest দেখেছে, সে আসলে “compression chain-এর মাঝখানে বসার” টিকিট পেয়ে গেছে — এখান থেকে আরও ব্লক যোগ করা secret ছাড়াই সম্ভব। Build অংশে এটা আসলেই চালিয়ে দেখানো হবে, আর দেখানো হবে কেন H(key || H(key || message))-জাতীয় নেস্টেড গঠন (HMAC-এর ভিত্তি) এই আক্রমণ আটকায়।

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

EXPERIMENT

Birthday attack — সংখ্যায়: 2^n নাকি sqrt(2^n)?

macOS / Linux / Windows (Python 3)· ১৫ মিনিট
# birthday.py — n-bit truncated হ্যাশে collision কত হ্যাশে মিলে
import hashlib, random, math

def truncated_hash(s, n_bits):
    full = hashlib.sha256(s.encode()).digest()
    full_int = int.from_bytes(full, "big")
    return full_int & ((1 << n_bits) - 1)

def find_collision(n_bits, rng):
    seen = {}
    tries = 0
    while True:
        tries += 1
        s = str(rng.getrandbits(64))
        h = truncated_hash(s, n_bits)
        if h in seen:
            return tries, seen[h], s
        seen[h] = s

N_BITS = 20
TRIALS = 20
rng = random.Random(1337)

predicted = 1.2533 * math.sqrt(2 ** N_BITS)
print(f"n_bits = {N_BITS}, space size 2^{N_BITS} = {2**N_BITS:,}")
print(f"predicted collision at ~1.2533*sqrt(2^n) = {predicted:.1f} hashes")
print()

counts = []
for t in range(1, TRIALS + 1):
    tries, s1, s2 = find_collision(N_BITS, rng)
    counts.append(tries)
    print(f"trial {t:2d}: collision after {tries:5d} hashes")

avg = sum(counts) / len(counts)
print()
print(f"average over {TRIALS} trials: {avg:.1f} hashes")
print(f"predicted (1.2533*sqrt(2^n)):  {predicted:.1f} hashes")
print(f"ratio observed/predicted: {avg/predicted:.3f}")

print()
print(f"naive guess (2^n, brute-force whole space): {2**N_BITS:,}")
print(f"actual order of magnitude needed: ~sqrt(2^n) = {math.sqrt(2**N_BITS):,.0f}")

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

n_bits = 20, space size 2^20 = 1,048,576
predicted collision at ~1.2533*sqrt(2^n) = 1283.4 hashes

trial  1: collision after  2139 hashes
trial  2: collision after  1589 hashes
trial  3: collision after  1928 hashes
trial  4: collision after   778 hashes
trial  5: collision after  1155 hashes
trial  6: collision after   628 hashes
trial  7: collision after  1120 hashes
trial  8: collision after  2298 hashes
trial  9: collision after  1233 hashes
trial 10: collision after   348 hashes
trial 11: collision after  2676 hashes
trial 12: collision after   488 hashes
trial 13: collision after  2022 hashes
trial 14: collision after   391 hashes
trial 15: collision after  1289 hashes
trial 16: collision after  1973 hashes
trial 17: collision after  1040 hashes
trial 18: collision after   331 hashes
trial 19: collision after   731 hashes
trial 20: collision after  1916 hashes

average over 20 trials: 1303.7 hashes
predicted (1.2533*sqrt(2^n)):  1283.4 hashes
ratio observed/predicted: 1.016

naive guess (2^n, brute-force whole space): 1,048,576
actual order of magnitude needed: ~sqrt(2^n) = 1,024

সংখ্যাগুলো নিজেই কথা বলে: naive brute-force ভাবত 2^20 = 1,048,576টা হ্যাশ লাগবে, বাস্তবে গড়ে মাত্র ~১৩০৪ হ্যাশেই collision মিলে গেছে — আর সেই ১৩০৪, predicted 1283.4-এর মাত্র 1.6% বেশি, যেটা ২০টা trial-এর statistical noise-এর ভেতরেই। SHA-256-এ n=256 বসালে এই একই অনুপাত 2^256-কে 2^128-এ নামিয়ে আনে — এখানকার toy পরীক্ষাটাই সেই সূত্রের প্রমাণ, শুধু স্কেল আলাদা।

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

একটা truncated (n-bit) hash-এ collision খুঁজতে গড়ে প্রায় 1.1774*sqrt(2^n) হ্যাশই যথেষ্ট — 2^n নয় — আর এই predicted সংখ্যা observed গড়ের সাথে হুবহু মিলে যায়, birthday bound-কে হাতে-কলমে সত্য প্রমাণ করে।

নিজে বানান

BUILD IT

Length-extension attack — কেন raw H(secret || message) কখনো MAC নয়

Python · ●●●●○
  1. toy Merkle–Damgård hash লিখুন — IV, compress(state, block), আর Merkle–Damgård padding (0x80 + শূন্য + length-suffix)
  2. ভিকটিম সিস্টেম বানান যেটা tag = H(secret || message) দিয়ে message সাইন করে
  3. attacker-এর "continue from digest" trick লিখুন — শুধু tag, message, আর secret-এর length আন্দাজ করেই একটা forged extension যোগ করুন
  4. সার্ভার-সাইড verify করে দেখান forged_tag সত্যিই real secret দিয়ে recompute করা tag-এর সাথে মেলে
  5. HMAC-স্টাইল নেস্টেড hash (H(secret || H(secret || message))) দিয়ে দেখান একই trick কেন আর কাজ করে না

লক্ষ্য: H(secret || message)-কে সরাসরি MAC হিসেবে ব্যবহার করা কেন বিপজ্জনক — attacker secret না জেনেই একটা valid extended tag বানিয়ে ফেলবে, শুধু original tag আর length জেনেই।

# length_ext.py — toy Merkle-Damgard hash-এ length-extension attack
import hashlib

BLOCK = 8   # bytes/block — ছোট রাখা হয়েছে demo readable রাখতে
IV = b"\x01\x23\x45\x67\x89\xab\xcd\xef"

def compress(state, block):
    # toy compression function — SHA-256 real cipher-এর জায়গায় stand-in,
    # কিন্তু ঠিক একই কাঠামোগত ভূমিকা: state+block -> নতুন state
    assert len(block) == BLOCK
    return hashlib.sha256(state + block).digest()[:BLOCK]

def md_pad(total_len):
    # Merkle-Damgard padding: 0x80, তারপর শূন্য, শেষে ৮-byte bit-length
    pad = b"\x80"
    while (total_len + len(pad)) % BLOCK != 0:
        pad += b"\x00"
    pad += (total_len * 8).to_bytes(8, "big")
    return pad

def toy_hash(message):
    state, data = IV, message + md_pad(len(message))
    for i in range(0, len(data), BLOCK):
        state = compress(state, data[i:i + BLOCK])
    return state

def toy_hash_continue(state, prior_len, extra):
    # আগে prior_len byte প্রসেস হয়ে state-এ পৌঁছানো হয়েছে ধরে নিয়ে,
    # extra যোগ করে নতুন মোট-length অনুযায়ী শেষ-padding বসায়
    total_len = prior_len + len(extra)
    data = extra + md_pad(total_len)
    for i in range(0, len(data), BLOCK):
        state = compress(state, data[i:i + BLOCK])
    return state

# ---------- ভিকটিম সিস্টেম: MAC = toy_hash(secret || message) ----------
SECRET = b"k3y!!!!!"                      # attacker এটা জানে না
message = b"uid=1001&role=user"
tag = toy_hash(SECRET + message)
print(f"victim tag = toy_hash(secret||message) = {tag.hex()}")

# ---------- attacker: শুধু tag, message, len(secret) আন্দাজ জানে ----------
GUESSED_SECRET_LEN = len(SECRET)          # বাস্তবে brute-force/known-length
extension = b"&role=admin"

L = GUESSED_SECRET_LEN + len(message)
glue = md_pad(L)                          # victim-এর hash ঠিক এখানেই padding বসিয়েছিল
forged_tag = toy_hash_continue(tag, L + len(glue), extension)
forged_message = message + glue + extension

print(f"attacker forged_tag              = {forged_tag.hex()}")
print(f"attacker sends forged_message    = {forged_message!r}")

# ---------- সার্ভার-সাইড ভেরিফিকেশন: real secret দিয়ে recompute ----------
server_check = toy_hash(SECRET + forged_message)
print(f"server recompute toy_hash(secret||forged_message) = {server_check.hex()}")
print(f"forged_tag MATCHES server recompute : {forged_tag == server_check}")
print("-> attacker secret না জেনেই একটা valid tag বানিয়ে ফেলল role=admin যোগ করে!")

# ---------- HMAC-স্টাইল fix: নেস্টেড হ্যাশ attacker-কে আটকায় ----------
def hmac_style(secret, message):
    inner = toy_hash(secret + message)
    return toy_hash(secret + inner)       # বাইরের hash-এ আবার secret লাগবে

real_hmac = hmac_style(SECRET, message)
print(f"\nHMAC-style tag = {real_hmac.hex()}")
print("attacker-এর কাছে কোনো উপায় নেই বাইরের hash extend করার — SECRET ছাড়া অসম্ভব।")

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

victim tag = toy_hash(secret||message) = f3eed9251e5f62ef
attacker forged_tag              = 6a12f8777cff103e
attacker sends forged_message    = b'uid=1001&role=user\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xd0&role=admin'
server recompute toy_hash(secret||forged_message) = 6a12f8777cff103e
forged_tag MATCHES server recompute : True
-> attacker secret না জেনেই একটা valid tag বানিয়ে ফেলল role=admin যোগ করে!

HMAC-style tag = ab1f863488309f36
attacker-এর কাছে কোনো উপায় নেই বাইরের hash extend করার — SECRET ছাড়া অসম্ভব।

মূল মুহূর্ত forged_tag MATCHES server recompute : True — attacker SECRET-এর একটা byte-ও না জেনে, শুধু tag আর length আন্দাজ করে, এমন একটা forged_message বানিয়ে ফেলেছে যা সার্ভারের চোখে সম্পূর্ণ বৈধ। HMAC-স্টাইল নেস্টেড hash-এ এই trick অচল, কারণ attacker-এর কাছে যা আছে তা শুধু inner hash-এর state — বাইরের toy_hash(secret + inner) চালাতে আবার SECRET লাগবে, যেটা attacker-এর কাছে কখনোই ছিল না।

নিজে বাড়ান:

  1. GUESSED_SECRET_LEN ভুল হলে কী ঘটে দেখুন — attack fail করে কীভাবে, আর কেন length-brute-force বাস্তব আক্রমণের একটা প্রয়োজনীয় ধাপ
  2. প্রকৃত HMAC (hmac.new(key, msg, hashlib.sha256)) দিয়ে একই attack চেষ্টা করুন — কোথায় trick ভেঙে যায় দেখান
  3. toy_hash-এ যদি চূড়ান্ত ধাপে একটা “finalization” মিশ্রণ (যেমন state-কে আরেকবার আলাদা constant দিয়ে compress করা) যোগ করেন, length-extension কি তাতেও আটকায়? কেন/কেন না যুক্তি দিন (ইঙ্গিত: SHA-512/256-জাতীয় truncated variant এই কৌশলই ব্যবহার করে)

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

বাস্তব জগতে hash function

SHAttered — SHA-1-এর প্রথম practical collision (২০১৭)। ফেব্রুয়ারি ২৩, ২০১৭-এ Google আর CWI Amsterdam যৌথভাবে দুইটা ভিন্ন PDF ফাইল প্রকাশ করে — বিষয়বস্তু আলাদা, কিন্তু SHA-1 hash হুবহু এক। আক্রমণের খরচ ছিল প্রায় 2^63.1 SHA-1 কম্পিউটেশন — birthday bound (2^80, n=160 বসিয়ে) থেকেও অনেক কম, কারণ attackers SHA-1-এর compression function-এ structural weakness (differential cryptanalysis) কাজে লাগিয়েছিল, শুধু জেনেরিক birthday attack চালায়নি। ব্যবহারিক হিসাবে এটা ছিল Google-এর হিসাবে প্রায় ৬৫০০ CPU-বছর আর ১১০ GPU-বছরের সমতুল্য কম্পিউট — বিশাল কিন্তু ২০১৭ সালের একটা বড় cloud provider-এর জন্য অসম্ভব নয়। ফলাফল ছিল দ্রুত ও ব্যাপক: browser vendor-রা (Chrome, Firefox) SHA-1-signed certificate আগেই deprecate করছিল, SHAttered সেই সিদ্ধান্তের চূড়ান্ত প্রমাণ হয়ে দাঁড়াল। Git — যেটা প্রতিটা commit-এর জন্য SHA-1 ব্যবহার করে — নিজস্ব sha1collisiondetection library যোগ করে এই নির্দিষ্ট আক্রমণের প্যাটার্ন চিনে সতর্ক করার ব্যবস্থা করেছে, আর দীর্ঘমেয়াদে SHA-256-এ migration-এর কাজ চলছে।

Git commit hash — integrity, কিন্তু security-boundary নয়। Git-এর SHA-1 ব্যবহার মূলত content-addressing আর corruption-detection-এর জন্য — প্রতিটা object তার নিজের hash দিয়ে চেনা যায়, ডুপ্লিকেট এড়ানো যায়। SHAttered-এর পরেও এটা “নিরাপদ” থেকেছে বেশিরভাগ ব্যবহারের জন্য কারণ আক্রমণের জন্য attacker-এর দুইটা binary blob বানানোর ক্ষমতা লাগে (collision), যা সাধারণ repository-workflow-এ সহজ নয় — কিন্তু supply-chain আক্রমণে (malicious commit inject করা) এটা বাস্তব ঝুঁকি, তাই migration চলছেই।

Password storage — raw hash যথেষ্ট নয়। SHA-256(password) দিয়ে password store করা তাত্ত্বিকভাবে “one-way” হলেও ব্যবহারিকভাবে বিপজ্জনক — SHA-256 দ্রুত, তাই attacker billions-per-second rate-এ guess/ rainbow-table চালাতে পারে। Password-এর জন্য দরকার ইচ্ছাকৃত-ধীর, memory-hard KDF (bcrypt, scrypt, Argon2) — এই লেসনের বিষয় না, কিন্তু misconception অংশে এই পার্থক্যটা স্পষ্ট করা দরকার।

TLS certificate fingerprint, code-signing, blockchain। TLS certificate pinning-এ SHA-256 fingerprint, code-signing-এ digest-এর ওপর signature, ব্লকচেইনের প্রতিটা block-এ আগের block-এর hash — সবগুলোই একই ভিত্তির ওপর: collision resistance ভাঙলে দুইটা ভিন্ন জিনিস “একই” প্রমাণিত হতে পারে, আর পুরো chain-of-trust ধসে পড়ে।

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

“Hash function encryption-এরই একটা রূপ — শুধু চাবি ছাড়া, তাই কম নিরাপদ।”

Hash function আর encryption সম্পূর্ণ ভিন্ন primitive, নিরাপত্তার মাত্রার প্রশ্নই না। Encryption reversible হওয়ার জন্যই ডিজাইন করা — সঠিক key দিয়ে ফেরত পড়া যাবে। Hash function ইচ্ছাকৃতভাবে irreversible — কোনো key দিয়েও ফেরত পাওয়ার কথা না। দুইটার উদ্দেশ্যই আলাদা: encryption গোপন রাখে, hash যাচাই করে।

“Collision resistance ভাঙলে preimage resistance-ও ভেঙে গেছে ধরে নেওয়া উচিত।”

MD5 আর SHA-1-এর collision resistance ভেঙেছে — practical attack আছে — কিন্তু আজও কোনো practical preimage attack নেই কোনোটার বিরুদ্ধে। Concept অংশের টেবিলে দেখা গেছে: এই তিনটা সম্পত্তি স্বাধীন axis, একটার আক্রমণ কৌশল আরেকটায় সরাসরি কাজ করে না। এই ভুল ধারণার বিপদ: কেউ ভাবতে পারে “MD5 collision-broken, তাই password-hash-এও অনিরাপদ” — অথচ password storage-এর সমস্যা সম্পূর্ণ ভিন্ন কারণে (speed, নিচে দেখুন)।

“Password store করতে SHA-256(password) যথেষ্ট, কারণ hash one-way।”

“One-way” মানে কোনো সময়ে invert করা কঠিন, “যথেষ্ট ধীর” মানে এই হার্ডওয়্যারে invert করা ব্যবহারিকভাবে অসম্ভব — দুইটা আলাদা দাবি। SHA-256 GPU-তে প্রতি সেকেন্ডে বিলিয়ন-বার চালানো যায়, তাই leaked hash-database-এ dictionary/rainbow-table attack দ্রুত সফল হয়। এই জন্যই password-এর জন্য আলাদা primitive দরকার — ইচ্ছাকৃত ধীর, memory-hard (bcrypt/Argon2) — আজকের general-purpose hash function তাদের replacement না।

“Birthday attack মানে attacker একটা নির্দিষ্ট, আগে-থেকে-জানা hash-এর সাথে মিল খুঁজছে।”

এটাই সবচেয়ে বেশি গুলিয়ে যাওয়া অংশ। Birthday attack-এ কোনো target fix করা থাকে না — attacker যেকোনো দুইটা input-এর মধ্যে যেকোনো মিল খুঁজছে, নিজের বেছে নেওয়া উভয় দিক থেকেই। একটা নির্দিষ্ট hash-এর সাথে মেলানো (targeted) এখনও 2^n-স্তরের কাজ — সেটা preimage-এর প্রশ্ন, birthday-র না। Experiment-এর কোডেও লক্ষ্য করুন — কোনো fixed target hash ছিল না, শুধু “কোনো দুইটা random string-এর মধ্যে মিল হলো কিনা” সেটাই দেখা হয়েছে।

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

1

Preimage resistance, second-preimage resistance, আর collision resistance — এই তিনটার সংজ্ঞায় মূল পার্থক্য কী, আর প্রতিটার naive brute-force খরচ কত?

স্মরণ

Preimage: digest h fixed, m খোঁজা — খরচ 2^n। Second-preimage: m1 fixed, ভিন্ন m2 খোঁজা যার একই output — খরচ 2^n। Collision: কিছুই fixed না, attacker m1 এবং m2 দুইটাই স্বাধীনভাবে বেছে নেয় — birthday paradox-এর কারণে খরচ নেমে আসে 2^(n/2)-এ।

2

Birthday paradox-এ মাত্র ২৩ জন মানুষেই ৫০% জন্মদিন-মিলের সম্ভাবনা কেন ৩৬৫-এর তুলনায় এত কম সংখ্যায় পৌঁছে যায়? Cryptographic hash-এর collision resistance-এর সাথে এর সম্পর্ক কী?

যুক্তি

কারণ প্রশ্নটা “একটা নির্দিষ্ট দিনের সাথে মিল” নয় — “যেকোনো দুইজনের মধ্যে মিল”। k জনের মধ্যে সম্ভাব্য জোড়ার সংখ্যা k(k-1)/2, যা k-এর বর্গের হারে বাড়ে — তাই সম্ভাবনা sqrt(N)-এর কাছাকাছি k-তেই ৫০% ছাড়িয়ে যায়, N-এর কাছাকাছি না। Hash-এ ঠিক একই গণিত: collision খোঁজা attacker-এর জন্য “যেকোনো জোড়া” প্রশ্ন, তাই খরচ 2^(n/2)।

3

SHAttered আক্রমণের খরচ ছিল ~2^63.1 SHA-1 কম্পিউটেশন। SHA-1-এর ১৬০-বিট output-এর জন্য জেনেরিক birthday bound কত হওয়ার কথা ছিল, আর এই পার্থক্য কী প্রমাণ করে?

প্রয়োগ

জেনেরিক birthday bound 2^(160/2) = 2^80। বাস্তব আক্রমণ হয়েছে 2^63.1-এ — অনেক কম — কারণ attackers শুধু জেনেরিক birthday attack চালায়নি, SHA-1-এর compression function-এর নির্দিষ্ট structural weakness (differential path) কাজে লাগিয়েছে। এটা প্রমাণ করে: birthday bound হলো উচ্চ সীমা (upper bound যদি hash “ideal random” হয়) — একটা নির্দিষ্ট hash function-এর design-এ দুর্বলতা থাকলে বাস্তব আক্রমণ তার চেয়েও সস্তা হতে পারে।

4

Build অংশে H(secret || message)-কে সরাসরি MAC হিসেবে ব্যবহার করলে attacker কীভাবে secret না জেনেই একটা valid extended tag বানাতে পারল?

যুক্তি

Merkle–Damgård construction-এ digest-ই হলো compression-chain-এর শেষ internal state — কোনো আলাদা finalization নেই যা সেটা আড়াল করে। Attacker digest-কে নতুন “IV” হিসেবে ব্যবহার করে, message-এর length আন্দাজ করে ঠিক সেই glue-padding বসিয়ে আরও ব্লক (extension) যোগ করে চালিয়ে যেতে পারে — ফলাফল হুবহু সেই tag যা real secret দিয়ে পুরো extended message হ্যাশ করলে পাওয়া যেত, অথচ secret-এর একটা byte-ও attacker দেখেনি।

5

Avalanche-experiment-এ "transfer:100.00" আর "transfer:100.00 " (একটা trailing space) — এই দুইটার bit-diff ছিল 134/256, প্রায় আদর্শ ৫০%-এর কাছাকাছি। যদি কোনো hash function-এ এই সংখ্যাটা বারবার ২০% বা ৮০%-এর মতো হতো (৫০% থেকে দূরে), সেটা কী নির্দেশ করত?

প্রয়োগ

সেটা নির্দেশ করত hash function-টা “ideal random function”-এর মতো আচরণ করছে না — input আর output-এর মধ্যে একটা statistical bias/pattern রয়ে গেছে। এমন bias attacker-কে input সম্পর্কে আংশিক তথ্য দিতে পারে (distinguishing attack), যা একটা দুর্বল compression-function design-এর লক্ষণ — ঠিক এই ধরনের bias-ই cryptanalyst-রা খুঁজে বের করে নতুন attack বানানোর ভিত্তি হিসেবে ব্যবহার করে (যেমন SHA-1-এ পাওয়া গিয়েছিল)।

6

একটা document-notarization সার্ভিস ডিজাইন করছেন — প্রতিটা document-এর hash আজ store করবেন, আর ১৫-২০ বছর পরেও প্রমাণ করতে চান document-টা বদলায়নি। কোন hash function বাছবেন, digest size কত হওয়া উচিত, আর SHA-1-এর ইতিহাস থেকে কোন policy-decision নেবেন?

ডিজাইন

SHA-256 (বা SHA-3-256) — কমপক্ষে ২৫৬-বিট output, যাতে collision resistance 2^128-স্তরে থাকে এবং দশকের পরেও computational advance-এর বিপরীতে যথেষ্ট মার্জিন থাকে। SHA-1-এর ইতিহাস থেকে নীতিগত শিক্ষা: তাত্ত্বিক দুর্বলতা প্রকাশিত হওয়ার (২০০৫) সাথে সাথেই migration-plan শুরু করা উচিত, practical exploit (২০১৭)-এর জন্য অপেক্ষা না করে — একটা ১৫-২০ বছরের commitment-এ “আজ নিরাপদ” যথেষ্ট না, “migration-path পূর্বেই প্রস্তুত” থাকা দরকার (যেমন hash-agility: digest-এর সাথে কোন algorithm ব্যবহৃত হয়েছে তা record করে রাখা, যাতে ভবিষ্যতে নতুন hash-এ পুনর্লিপি করা যায়)।

এরপর কী

এরপর কী

আজ hash function দিয়ে fingerprint বানানো শিখলাম — deterministic, one-way, তিনটা resistance property, আর সেই property-গুলোর সীমা কোথায় (birthday bound) তা সংখ্যায় দেখলাম। কিন্তু hash function একটা মৌলিক সীমাবদ্ধতা রেখে যায়: এটা শুধু যাচাই করে ডেটা বদলেছে কিনা — কেউ যদি আসল ডেটা পড়তেই না পারুক, সেই গ্যারান্টি hash function দেয় না।

ক্রমের পরের লেসন — যেটা এখনই পড়া যায় — ঠিক এই ফাঁকটা পূরণ করে: symmetric encryption। Hash-এর ওপর দাঁড়িয়ে সেই লেসন যোগ করে আসল confidentiality — ডেটার content লুকিয়ে ফেলা, শুধু তার fingerprint নেওয়া নয়। AES, block cipher mode (ECB-এর ভাঙন থেকে GCM-এর AEAD পর্যন্ত), আর nonce-reuse-এর বিপর্যয় — সবকিছুর ভিত্তি আজকের এই “deterministic one-way function”-এর বিপরীত এক জগৎ: এমন একটা function যা secret key দিয়ে উল্টানো যায়, ঠিক সেই একজনের জন্য যার কাছে চাবি আছে।

আরও পড়ুন

  • Secure Hash Standard (SHS) — FIPS 180-4 — National Institute of Standards and Technology (NIST) · SHA-2 পরিবারের (SHA-256 সহ) সরকারি সংজ্ঞা — digest size, padding, compression function সবকিছুর আনুষ্ঠানিক উৎস
  • The First Collision for Full SHA-1 — Marc Stevens, Elie Bursztein, Pierre Karpman, Ange Albertini, Yarik Markov — CRYPTO 2017 · SHAttered — প্রথম practical SHA-1 collision, Google ও CWI Amsterdam-এর যৌথ গবেষণা
  • A Design Principle for Hash Functions — Ivan Damgård — CRYPTO 1989 · Merkle–Damgård construction-এর আনুষ্ঠানিক ভিত্তি — কেন compression function iterate করলে hash function নিরাপদ থাকে
  • HMAC: Keyed-Hashing for Message Authentication — RFC 2104 — H. Krawczyk, M. Bellare, R. Canetti — IETF, 1997 · Length-extension attack ঠেকানোর আনুষ্ঠানিক সমাধান — এই লেসনের build-এর ভিত্তি