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

Password Storage — Salt, ধীর KDF, আর Rainbow Table-এর বিরুদ্ধে যুদ্ধ

Password Storage: Salting and Key Derivation Functions

প্রমাণ করে দেখানো হয় কেন প্লেইন SHA-256(password) নিমেষেই precomputed dictionary দিয়ে ভাঙা যায়, কীভাবে একটা random per-user salt সেই precomputation-কে অকার্যকর করে দেয়, আর কেন salting-এর পরেও bcrypt/Argon2-এর মতো ইচ্ছাকৃত-ধীর, memory-hard KDF ছাড়া targeted brute-force ঠেকানো যায় না — সাথে constant-time comparison আর pepper-এর ভূমিকাও বাস্তব সংখ্যায় যাচাই করা হয়।

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

  • কেন plain cryptographic hash (এমনকি SHA-256-ও) password store করার জন্য যথেষ্ট না, তা একটা precomputed dictionary/rainbow-table attack বাস্তবে চালিয়ে সংখ্যায় প্রমাণ করতে পারবেন
  • Salt কী, কেন প্রতি-user আলাদা ও random হওয়া লাগে, আর কীভাবে এটা precomputation-attack-কে অকার্যকর করে দেয় তা কোড চালিয়ে দেখাতে পারবেন
  • bcrypt-এর tunable work factor আর Argon2-এর memory-hardness — এই দুইটা ইচ্ছাকৃত-ধীর KDF কেন fast general-purpose hash-এর প্রয়োজনীয় বিকল্প, বাস্তব timing measurement দিয়ে যুক্তি দিতে পারবেন
  • Constant-time comparison (hmac.compare_digest) কেন দরকার, আর naive early-exit comparison ঠিক কীভাবে timing-এর মাধ্যমে তথ্য ফাঁস করতে পারে তা পরিমাপ করে দেখাতে পারবেন
  • একটা production password-storage সিস্টেম ডিজাইন করার সময় salt, KDF-choice, work factor, আর pepper — এই চারটা সিদ্ধান্ত কীভাবে একসাথে কাজ করে তা যুক্তিসহ ব্যাখ্যা করতে পারবেন

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

আগে এটা বুঝি

আগের লেসনে cryptographic hash function-এর তিনটা সম্পত্তি দেখা হয়েছে — preimage resistance, second-preimage resistance, collision resistance — আর misconception অংশে একটা লাইন ইচ্ছা করেই ফেলে রাখা হয়েছিল: “SHA-256(password) দিয়ে password store করা তাত্ত্বিকভাবে one-way হলেও ব্যবহারিকভাবে বিপজ্জনক, কারণ SHA-256 দ্রুত।” আজকের পুরো লেসন সেই একটা লাইনকে হাতে-কলমে, সংখ্যায় প্রমাণ করা।

প্রশ্নটা প্রথমে অদ্ভুত শোনাতে পারে: preimage resistance তো বলে দিচ্ছে H(m) = h থেকে m বের করা “computationally infeasible” — তাহলে সমস্যা কোথায়? সমস্যাটা সংজ্ঞায় না, আক্রমণের কৌশলে। Preimage resistance-এর সংজ্ঞা ধরে নেয় attacker প্রতিটা m-এর জন্য নতুন করে H(m) গণনা করছে, একটা নির্দিষ্ট, আগে-থেকে-অজানা h-এর বিরুদ্ধে। কিন্তু বাস্তব world-এ password-এর একটা লুকানো দুর্বলতা আছে যা generic preimage-security হিসাব করে না: মানুষ সীমিত সংখ্যক password-ই বারবার ব্যবহার করে। 123456, password, qwerty — এই ধরনের কয়েক হাজার string বিশ্বের কোটি কোটি account-এর একটা বড় অংশ ঢেকে ফেলে। আর যেহেতু H deterministic (একই input সবসময় একই output), attacker-এর একবার বসে এই সীমিত candidate-সেটের হ্যাশ আগে থেকেই গণনা করে রাখার সুযোগ থাকে — breach হওয়ার আগেই, কোনো নির্দিষ্ট leaked hash না দেখেই।

সমস্যাটা এক লাইনে:
  H(password) দ্রুত + deterministic + মানুষ সীমিত password পুনরাবৃত্তি করে
  = attacker precompute করতে পারে -- breach ঘটার বহু আগেই

আজকের লেসন এই precomputation-attack-টা বাস্তবে বানিয়ে দেখাবে, তারপর পর্যায়ক্রমে দুইটা defense যোগ করবে — salt (precomputation অকার্যকর করে) আর ইচ্ছাকৃত-ধীর key derivation function তথা KDF (targeted brute-force-কেও ব্যয়বহুল করে) — আর শেষে দেখাবে কেন এই দুইটাই একসাথে লাগে, একটা একা যথেষ্ট না।

মূল ধারণা

Precomputed dictionary attack — একবার বানাও, চিরকাল ব্যবহার করো

ধরুন একটা সার্ভার শুধু stored = SHA-256(password) রাখে — কোনো অতিরিক্ত কিছু ছাড়া, ঠিক আগের লেসনের misconception অংশে যেভাবে বলা হয়েছিল। Attacker-এর কৌশলটা খুব সরল, দুই ধাপে:

ধাপ ১ (offline, একবার, breach-এর আগেই):
   একটা সাধারণ password-এর তালিকা (wordlist) নাও
   প্রতিটার জন্য SHA-256 গণনা করো
   একটা dictionary বানাও: {hash -> password}

ধাপ ২ (breach-এর পরে, বারবার):
   leaked database-এর প্রতিটা hash নাও
   dictionary-তে lookup করো -- O(1), কোনো নতুন গণনা লাগে না

লক্ষ্য করুন — ধাপ ১-এর খরচ একবারই দিতে হয়, আর সেটা attacker-এর নিজের সময়মতো, চাপহীনভাবে করা যায়। ধাপ ২-এর খরচ প্রায় শূন্য — একটা hash table lookup, নির্বিশেষে কতগুলো leaked hash আছে বা wordlist কত বড়। এটাই মূল কাঠামোগত সমস্যা: hashing দ্রুত হলে, precomputation-এর খরচ leaked hash-এর সংখ্যার সাথে ভাগ হয়ে যায় — একবার বানানো dictionary দিয়ে হাজার হাজার ভিন্ন ভিন্ন breach-এর hash ভাঙা যায়।

Rainbow table — একই আক্রমণ, কম মেমোরিতে

একটা পূর্ণ {hash: password} dictionary-র সমস্যা হলো মেমোরি — লক্ষ লক্ষ candidate-এর জন্য প্রতিটা হ্যাশ আলাদাভাবে store করতে বিশাল জায়গা লাগে। Rainbow table ঠিক এই memory সমস্যার সমাধান, একটা classic time-memory tradeoff (Martin Hellman, ১৯৮০-তে প্রথম প্রস্তাবিত ধারণা, Philippe Oechslin ২০০৩-এ rainbow-chain দিয়ে উন্নত করেন): পুরো {hash: password} জোড়া store না করে, একটা reduce ফাংশন (hash থেকে নতুন candidate-এর মতো string বানানো) দিয়ে লম্বা “chain” বানানো হয়, আর শুধু প্রতিটা chain-এর শুরু আর শেষ value store করা হয়। Lookup-এর সময় leaked hash থেকে chain পুনরায় চালিয়ে (re-run করে) আসল candidate reconstruct করা হয় — মেমোরি অনেক কম, কিন্তু প্রতিটা lookup-এ বাড়তি গণনা লাগে। আজকের experiment-এ এই chain-compression বাদ দিয়ে সরাসরি পূর্ণ dictionary বানানো হবে — ছোট wordlist-এ পার্থক্য নেই, কিন্তু কাঠামোগত ধারণাটা (precompute once, lookup instantly) হুবহু একই।

Preimage-security-এর সংজ্ঞা যা ধরে নেয়বাস্তবে যা ঘটে
Targetএকটা নির্দিষ্ট, আগে-অজানা hAttacker breach-এর আগেই, কোনো নির্দিষ্ট target ছাড়াই কাজ শুরু করে
Candidate spaceপুরো input-space (2^n-স্তরের)মানুষের password-বাছাই highly skewed — কয়েক হাজার string-ই সিংহভাগ ঢাকে
খরচ কখন দিতে হয়প্রতিটা attack-এ নতুন করেএকবারই — dictionary পুনর্ব্যবহারযোগ্য

ভেতরে কী ঘটছে

নিজে বানিয়ে দেখা — precomputed dictionary, সত্যিই চালিয়ে

নিচের কোডটা আসলেই চালানো — একটা ছোট common-password wordlist দিয়ে একবার dictionary বানানো হচ্ছে (attacker-এর “offline” ধাপ), তারপর একটা “leaked” unsalted hash সেই dictionary-তে lookup করে “cracking” দেখানো হচ্ছে:

# rainbow_lookup.py -- precompute a plain-hash lookup dictionary, "crack" a leaked unsalted hash
import hashlib, time

COMMON_PASSWORDS = [
    "123456", "password", "123456789", "12345678", "qwerty",
    "abc123", "password1", "111111", "letmein", "iloveyou",
    "admin", "welcome", "monkey", "dragon", "football",
]

t0 = time.perf_counter()
precomputed = {hashlib.sha256(pw.encode()).hexdigest(): pw for pw in COMMON_PASSWORDS}
build_time = time.perf_counter() - t0

print(f"precomputed dictionary size: {len(precomputed)} entries")
print(f"build time (offline, once, before any breach): {build_time*1000:.3f} ms")
print()

leaked_hash = hashlib.sha256("dragon".encode()).hexdigest()
print(f"leaked hash from a breach: {leaked_hash}")

t0 = time.perf_counter()
cracked = precomputed.get(leaked_hash)
lookup_time = time.perf_counter() - t0
print(f"dictionary lookup result: {cracked!r}  (took {lookup_time*1e6:.2f} microseconds)")

প্রত্যাশিত output (hash value deterministic — হুবহু এই hex-string আসবে; timing machine-ভেদে সামান্য বদলাতে পারে, কিন্তু সবসময় microsecond-স্কেলে):

precomputed dictionary size: 15 entries
build time (offline, once, before any breach): 0.028 ms

leaked hash from a breach: a9c43be948c5cabd56ef2bacffb77cdaa5eec49dd5eb0cc4129cf3eda5f0e74c
dictionary lookup result: 'dragon'  (took 0.70 microseconds)

সংখ্যাগুলো নিজেই আজকের মূল বিন্দু: dictionary বানাতে লেগেছে ০.০২৮ মিলিসেকেন্ড (মাত্র ১৫টা candidate বলে এত দ্রুত — বাস্তব attacker-এর wordlist কোটি-কোটি entry-র হতে পারে, তবু সেটাও একবারই বানানো লাগে), আর leaked hash থেকে আসল password বের করতে লেগেছে ০.৭ মাইক্রোসেকেন্ড — কোনো “cracking” গণনা হয়নি, শুধু একটা hash-table lookup। SHA-256-এর 2^256-স্তরের preimage-security এখানে সম্পূর্ণ irrelevant, কারণ attacker কখনো 2^256-স্তরের search চালায়নি — শুধু একটা ছোট, বাস্তবসম্মত candidate-space precompute করেছে।

উদাহরণ

Salt যোগ করা — একই আক্রমণ, এবার ব্যর্থ

এখন defense-এর প্রথম স্তর: salt। প্রতি-user একটা random, unique মান বসিয়ে stored = H(salt || password) রাখা হয় — salt নিজে গোপন না, hash-এর পাশেই plaintext-এ store হয়। লক্ষ্য: attacker-এর “একবার precompute করো, বারবার ব্যবহার করো” কৌশলটাকেই ভাঙা।

নিচের কোডটা আগের experiment-এর ঠিক সেই precomputed dictionary-টাই পুনর্ব্যবহার করছে — এবার salted hash-এর বিরুদ্ধে:

# salted_lookup.py -- same password, different salts -> different hashes; precomputed dict now fails
import hashlib, random

COMMON_PASSWORDS = [
    "123456", "password", "123456789", "12345678", "qwerty",
    "abc123", "password1", "111111", "letmein", "iloveyou",
    "admin", "welcome", "monkey", "dragon", "football",
]
precomputed = {hashlib.sha256(pw.encode()).hexdigest(): pw for pw in COMMON_PASSWORDS}

rng = random.Random(42)
password = "dragon"
users = ["alice", "bob", "carol"]

print("same password, three users, three random per-user salts:")
rows = []
for u in users:
    salt = rng.getrandbits(64).to_bytes(8, "big").hex()
    h = hashlib.sha256((salt + password).encode()).hexdigest()
    rows.append((u, salt, h))
    print(f"  user={u:6} salt={salt}  H(salt+pw)={h}")

print()
distinct = len({h for _, _, h in rows})
print(f"same password 'dragon' for all 3 users -> {distinct} distinct stored hashes")
print()

print("attacker tries the OLD unsalted precomputed dictionary against these salted hashes:")
for u, salt, h in rows:
    hit = precomputed.get(h)
    print(f"  user={u:6} lookup({h[:16]}...) = {hit!r}")

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

same password, three users, three random per-user salts:
  user=alice  salt=1c80317fa3b1799d  H(salt+pw)=7c7bff45922c3a3b8a0bbc2a6b21d4398e2a55961d54219db0d44488f3d54052
  user=bob    salt=bdd640fb06671ad1  H(salt+pw)=d748b55232d7b8097409f2b067f1897a4b855e5fd705060f629dc1166122ebf6
  user=carol  salt=3eb13b9046685257  H(salt+pw)=91d3be3af6592ad945253d9e2b8a4f80a876231928538f0bc84c75a4753dfffa

same password 'dragon' for all 3 users -> 3 distinct stored hashes

attacker tries the OLD unsalted precomputed dictionary against these salted hashes:
  user=alice  lookup(7c7bff45922c3a3b...) = None
  user=bob    lookup(d748b55232d7b809...) = None
  user=carol  lookup(91d3be3af6592ad9...) = None

দুইটা জিনিস এখানে প্রমাণিত: এক, একই password তিনজন user-এর জন্য তিনটা সম্পূর্ণ ভিন্ন hash তৈরি করেছে — attacker leaked database দেখে বলতেই পারবে না কোন দুইজন user-এর password একই। দুই, আগের experiment-এর পুরনো precomputed dictionary সবগুলো lookup-এ None ফিরিয়েছে — salt না জেনে সেই dictionary সম্পূর্ণ অকেজো। Attacker যদি এখনও attack চালাতে চায়, প্রতিটা user-এর জন্য আলাদা dictionary বানাতে হবে (সেই user-এর নির্দিষ্ট salt দিয়ে) — precomputation-এর পুরো সুবিধাটাই হারিয়ে যায়, প্রতিটা hash এখন কার্যত একটা fresh, ব্যয়বহুল targeted-attack।

ছবিতে

সরল dictionary attack (আজকের experiment-এ যেভাবে বানানো হয়েছে):
   candidate1 --H--> hash1 ─┐
   candidate2 --H--> hash2 ─┼── store: {hash: candidate}   (memory ∝ candidate সংখ্যা)
   candidate3 --H--> hash3 ─┘
   leaked_hash lookup -> O(1), কোনো নতুন গণনা লাগে না

Rainbow table (কম মেমোরি, বেশি compute -- Hellman ১৯৮০, Oechslin ২০০৩):
   start_1 --H--> h --R(educe)--> c2 --H--> h2 --R--> ... --> end_1   (শুধু start_1, end_1 store হয়)
   start_2 --H--> h --R--> c2'--H--> h2'--R--> ...                --> end_2
   leaked_hash মিললে chain re-run করে candidate reconstruct করা হয়

Salting-এর পর:
   stored = H(salt || password)  -- salt প্রতি-user আলাদা
   precompute করতে হলে attacker-কে AGE থেকেই প্রতিটা সম্ভাব্য salt জানতে হবে
   -- কিন্তু salt breach-এর আগে অজানা, তাই precomputation-ই অসম্ভব হয়ে যায়
সরল dictionary attack বনাম rainbow table — একই ধারণা, ভিন্ন memory/compute tradeoff। Salting দুইটাকেই অকার্যকর করে দেয়, কারণ precompute করার আগেই salt জানা লাগে।
SHA-256    :  ~1.2 sec                    (দ্রুত -- এখানেই সমস্যা)
Argon2id   :  ~13.9 ঘণ্টা   ( 41,355x ধীর SHA-256-এর তুলনায় )
bcrypt     :  ~61.9 ঘণ্টা   (184,033x ধীর SHA-256-এর তুলনায় )

একই ১০ লক্ষ candidate, একই attacker হার্ডওয়্যার -- পার্থক্য শুধু
verification function-এর ইচ্ছাকৃত ধীরগতি। পরের "experiment" অংশে
এই সংখ্যাগুলোই সরাসরি চালিয়ে বের করা হয়েছে।
আজকের experiment-এর real timing দিয়ে আক্রমণের খরচ -- ১০ লক্ষ candidate (যেমন একটা ৬-ডিজিট PIN) নিঃশেষ করতে কত সময় লাগে, single core-এ।

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

EXPERIMENT

Attacker cost — fast hash বনাম ইচ্ছাকৃত-ধীর KDF, real timing দিয়ে

macOS / Linux / Windows (Python 3, বাস্তব bcrypt ও argon2 লাইব্রেরি সহ)· ১৫ মিনিট

Salting একটা মাত্র সমস্যা সমাধান করে — precomputation। কিন্তু একটা targeted attack (নির্দিষ্ট একজন user-এর salt জানা আছে, এবার শুধু সেই user-এর password brute-force করা হচ্ছে) salting-এ থামে না। এখানেই দ্বিতীয় defense দরকার: verification function নিজেই ইচ্ছাকৃতভাবে ধীর বানানো। নিচের কোডটা সত্যিই চালিয়ে, একই মেশিনে SHA-256, bcrypt (cost factor ১২, industry-standard default), আর Argon2id (Password Hashing Competition-এর বিজয়ী, memory-hard) — তিনটার প্রকৃত throughput মাপা হয়েছে:

# attacker_cost.py -- attacker cost: fast general-purpose hash vs deliberately-slow KDFs
import hashlib, time, bcrypt, argon2

N_SHA = 200_000
t0 = time.perf_counter()
for i in range(N_SHA):
    hashlib.sha256(f"guess{i}".encode()).hexdigest()
sha_elapsed = time.perf_counter() - t0
sha_rate = N_SHA / sha_elapsed
print(f"SHA-256: {N_SHA:,} hashes in {sha_elapsed:.4f}s -> {sha_rate:,.0f} hashes/sec (single core, pure Python loop)")

N_BCRYPT = 5
t0 = time.perf_counter()
for i in range(N_BCRYPT):
    bcrypt.hashpw(f"guess{i}".encode(), bcrypt.gensalt(rounds=12))
bcrypt_elapsed = time.perf_counter() - t0
bcrypt_rate = N_BCRYPT / bcrypt_elapsed
print(f"bcrypt (cost=12): {N_BCRYPT} hashes in {bcrypt_elapsed:.4f}s -> {bcrypt_rate:.2f} hashes/sec")

ph = argon2.PasswordHasher()  # defaults: time_cost=3, memory_cost=64MB, parallelism=4
N_ARGON2 = 5
t0 = time.perf_counter()
for i in range(N_ARGON2):
    ph.hash(f"guess{i}")
argon2_elapsed = time.perf_counter() - t0
argon2_rate = N_ARGON2 / argon2_elapsed
print(f"Argon2id (default): {N_ARGON2} hashes in {argon2_elapsed:.4f}s -> {argon2_rate:.2f} hashes/sec")
print()

SPACE = 1_000_000  # e.g. a 6-digit PIN keyspace
print(f"attacker must try up to {SPACE:,} candidates (e.g. a 6-digit PIN) to guarantee a hit:")
print(f"  through SHA-256   : {SPACE / sha_rate:.2f} sec   (~{SPACE / sha_rate / 60:.4f} min)")
print(f"  through bcrypt    : {SPACE / bcrypt_rate:.0f} sec   (~{SPACE / bcrypt_rate / 3600:.1f} hours)")
print(f"  through Argon2id  : {SPACE / argon2_rate:.0f} sec   (~{SPACE / argon2_rate / 3600:.1f} hours)")
print()
print(f"slowdown factor vs SHA-256: bcrypt {sha_rate/bcrypt_rate:,.0f}x, Argon2id {sha_rate/argon2_rate:,.0f}x")

প্রত্যাশিত output (আজ, এই মেশিনে, সত্যিই চালিয়ে পাওয়া — timing machine-ভেদে ওঠানামা করবে, কিন্তু magnitude-এর পার্থক্য একই থাকবে):

SHA-256: 200,000 hashes in 0.2422s -> 825,610 hashes/sec (single core, pure Python loop)
bcrypt (cost=12): 5 hashes in 1.1145s -> 4.49 hashes/sec
Argon2id (default): 5 hashes in 0.2505s -> 19.96 hashes/sec

attacker must try up to 1,000,000 candidates (e.g. a 6-digit PIN) to guarantee a hit:
  through SHA-256   : 1.21 sec   (~0.0202 min)
  through bcrypt    : 222905 sec   (~61.9 hours)
  through Argon2id  : 50090 sec   (~13.9 hours)

slowdown factor vs SHA-256: bcrypt 184,033x, Argon2id 41,355x

সংখ্যাগুলো নিজেই আজকের কেন্দ্রীয় দাবি প্রমাণ করে: একই ১০ লক্ষ-candidate keyspace SHA-256-এ ভাঙতে লাগে সোয়া এক সেকেন্ড, bcrypt-এ প্রায় ৬২ ঘণ্টা, Argon2id-এ প্রায় ১৪ ঘণ্টা — একই attacker, একই হার্ডওয়্যার, শুধু verification function বদলেছে। এখানে একটা কৌতূহলোদ্দীপক পর্যবেক্ষণ: এই নির্দিষ্ট single-thread মাপে bcrypt (cost=12) আসলে Argon2id-এর ডিফল্ট সেটিং-এর চেয়েও ধীর দেখাচ্ছে — কিন্তু এই তুলনাটা বিভ্রান্তিকর হতে পারে, কারণ দুইটার “ধীরতা”-র উৎস ভিন্ন অক্ষে। bcrypt শুধু CPU-time ব্যয়বহুল করে — GPU/ASIC-এ হাজার হাজার instance সমান্তরালে চালানো যায় কারণ প্রতিটা instance-এর মেমোরি-প্রয়োজন সামান্য। Argon2 memory-hard — প্রতিটা parallel attempt-এর জন্য আলাদা মেমোরি ব্লক (এখানে ডিফল্ট ৬৪ মেগাবাইট) লাগে, তাই GPU-তে হাজার instance সমান্তরাল চালাতে হাজার-গুণ মেমোরি লাগবে, যা বাস্তবে সহজলভ্য না — এই কারণেই Argon2 GPU/ASIC cracking-এর বিরুদ্ধে বিশেষভাবে ডিজাইন করা, যদিও single-thread wall-clock সময়ে সবসময় bcrypt-এর চেয়ে “ধীর” দেখাবে না।

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

একই candidate-space brute-force করতে SHA-256-এর তুলনায় bcrypt (cost=12) প্রায় ১,৮০,০০০x আর Argon2id প্রায় ৪০,০০০x বেশি সময় নেয় -- salting-এর পরেও targeted brute-force ঠেকাতে কেন deliberately-slow KDF আবশ্যক তা সরাসরি পরিমাপ করে দেখায়।

নিজে বানান

BUILD IT

password_store.py — bcrypt, pepper, tunable work factor, আর constant-time compare

Python · ●●●○○
  1. bcrypt দিয়ে hash_password/verify_password ফাংশন লিখুন -- bcrypt নিজেই random salt generate করে হ্যাশের ভেতরে embed করে রাখে
  2. একই password দুইবার hash করে দেখান stored hash প্রতিবার আলাদা, কিন্তু verify_password উভয় ক্ষেত্রেই True দেয়
  3. pepper যোগ করুন -- password-এর সাথে একটা app-wide secret জুড়ে hash করুন, দেখান database-only leak (pepper ছাড়া) দিয়ে verify করা কেন fail করে
  4. bcrypt-এর cost factor ১০/১২/১৪-এ বদলে hash-time-এর real পার্থক্য measure করুন
  5. session-token-এর মতো একটা secret তুলনার জন্য constant-time comparison লিখুন, আর naive early-exit comparison-এর সাথে timing পার্থক্য পরিমাপ করে দেখান

লক্ষ্য: একটা production-realistic password-storage module বানানো — শুধু hash করা না, বরং salt (automatic), pepper (manual, আলাদা secret), tunable cost, আর secret-তুলনার timing-safety — এই চারটা সিদ্ধান্তই একসাথে কাজ করছে তা প্রমাণ করা।

# password_store.py -- bcrypt + pepper + tunable cost + constant-time compare
import bcrypt, hmac, time

PEPPER = b"app-wide-secret-not-in-db-2026"  # env var / secrets manager -- NEVER in the database

def hash_password(password: str, pepper: bytes = PEPPER, cost: int = 12) -> bytes:
    peppered = password.encode() + pepper
    return bcrypt.hashpw(peppered, bcrypt.gensalt(rounds=cost))  # bcrypt নিজেই salt বসায়, embed করে

def verify_password(password: str, stored_hash: bytes, pepper: bytes = PEPPER) -> bool:
    peppered = password.encode() + pepper
    return bcrypt.checkpw(peppered, stored_hash)

# ---------- ধাপ ১-২: একই password দুইবার hash -> bcrypt নিজেই আলাদা salt বসায় ----------
pw = "correct horse battery staple"
h1, h2 = hash_password(pw), hash_password(pw)
print(f"hash #1: {h1.decode()}")
print(f"hash #2: {h2.decode()}")
print(f"h1 == h2 ? {h1 == h2}   (bcrypt নিজেই random salt বসায় -- একই password, ভিন্ন stored bytes)")
print(f"verify_password(pw, h1) = {verify_password(pw, h1)}")
print(f"verify_password(pw, h2) = {verify_password(pw, h2)}")
print()

# ---------- ধাপ ৩: pepper -- শুধু DB leak হলে সেটা যথেষ্ট না ----------
stolen_hash = hash_password("hunter2")
match_no_pepper = bcrypt.checkpw(b"hunter2", stolen_hash)
match_with_pepper = verify_password("hunter2", stolen_hash)
print(f"শুধু DB চুরি, সঠিক password কিন্তু pepper ছাড়া: match = {match_no_pepper}")
print(f"একই password, সঠিক pepper সহ (app secret)     : match = {match_with_pepper}")
print()

# ---------- ধাপ ৪: work factor -- cost বাড়ালে সময় কতটা বাড়ে ----------
for cost in (10, 12, 14):
    t0 = time.perf_counter()
    hash_password(pw, cost=cost)
    dt = time.perf_counter() - t0
    print(f"bcrypt cost={cost}: {dt*1000:.1f} ms/hash")
print()

# ---------- ধাপ ৫: constant-time comparison ----------
def naive_compare(a: bytes, b: bytes) -> bool:
    if len(a) != len(b):
        return False
    for x, y in zip(a, b):      # early-exit byte-loop -- ঠিক এই প্যাটার্নটাই ঐতিহাসিক bug-এর উৎস
        if x != y:
            return False
    return True

TOKEN = h1[7:39]  # ৩২-byte token ধরে নেওয়া হলো (bcrypt hash থেকে কাটা)

def timed(fn, guess, trials=200_000):
    t0 = time.perf_counter()
    for _ in range(trials):
        fn(TOKEN, guess)
    return (time.perf_counter() - t0) / trials * 1e9

early = b"X" + TOKEN[1:]
late = TOKEN[:-1] + b"X"

t_naive_e, t_naive_l = timed(naive_compare, early), timed(naive_compare, late)
t_eq_e, t_eq_l = timed(lambda a, b: a == b, early), timed(lambda a, b: a == b, late)
t_safe_e, t_safe_l = timed(hmac.compare_digest, early), timed(hmac.compare_digest, late)

print(f"হাতে-লেখা early-exit loop : early-mismatch {t_naive_e:.1f} ns, late-mismatch {t_naive_l:.1f} ns, "
      f"ratio {t_naive_l/t_naive_e:.2f}x")
print(f"Python-এর নিজস্ব bytes ==  : early-mismatch {t_eq_e:.1f} ns, late-mismatch {t_eq_l:.1f} ns, "
      f"ratio {t_eq_l/t_eq_e:.2f}x")
print(f"hmac.compare_digest        : early-mismatch {t_safe_e:.1f} ns, late-mismatch {t_safe_l:.1f} ns, "
      f"ratio {t_safe_l/t_safe_e:.2f}x")

প্রত্যাশিত output (bcrypt নিজে random salt generate করে বলে hash-এর exact byte প্রতিবার আলাদা হবে — নিচের bcrypt-hash-string দুইটা তাই শুধু গঠন/প্যাটার্নের প্রতিনিধি; boolean আর timing ফলাফল সবসময় একই প্যাটার্নে আসবে):

hash #1: $2b$12$GU4rE2YqWnjsGFGfCNcwvOZ08iNIGjRwsy8Yzjh0aY1SYOPE3McL6
hash #2: $2b$12$VmBd1JO47drRMlN.2EShAO95qw6jnBxCyrarIrFHyZhJ5VxjDhEEG
h1 == h2 ? False   (bcrypt নিজেই random salt বসায় -- একই password, ভিন্ন stored bytes)
verify_password(pw, h1) = True
verify_password(pw, h2) = True

শুধু DB চুরি, সঠিক password কিন্তু pepper ছাড়া: match = False
একই password, সঠিক pepper সহ (app secret)     : match = True

bcrypt cost=10: 74.7 ms/hash
bcrypt cost=12: 293.0 ms/hash
bcrypt cost=14: 1144.2 ms/hash

হাতে-লেখা early-exit loop : early-mismatch 567.9 ns, late-mismatch 2911.8 ns, ratio 5.13x
Python-এর নিজস্ব bytes ==  : early-mismatch 170.2 ns, late-mismatch 184.9 ns, ratio 1.09x
hmac.compare_digest        : early-mismatch 120.4 ns, late-mismatch 118.1 ns, ratio 0.98x

তিনটা মুহূর্ত এখানে গুরুত্বপূর্ণ। এক, h1 == h2 হলো False অথচ দুইটাই একই password-এর জন্য verify_password দিয়ে True — কারণ bcrypt-এর নিজের salt hash-string-এর ভেতরেই embedded ($2b$12$ এর পরের অংশে), verify করার সময় সেই salt বের করে আবার একই গণনা করা হয়। দুই, cost 10→12→14 প্রায় প্রতিবার সময় দ্বিগুণ-চারগুণ করে দিচ্ছে (10-এ ৭৪.৭ms, 14-এ ১১৪৪.২ms) — এটাই “tunable work factor”-এর ব্যবহারিক অর্থ: হার্ডওয়্যার দ্রুত হলে শুধু একটা সংখ্যা বাড়িয়ে দিলেই একই primitive বছরের পর বছর টিকে থাকে। তিন, হাতে-লেখা early-exit loop-এ late-mismatch, early-mismatch-এর চেয়ে ৫.১৩ গুণ বেশি সময় নিয়েছে — একটা স্পষ্ট timing side-channel। কিন্তু লক্ষ্য করুন — Python-এর নিজস্ব built-in bytes == অপারেটরে এই পার্থক্য প্রায় অদৃশ্য (1.09x)। কারণ কী? CPython-এর bytes.__eq__ C-level-এ implemented, আর ৩২ byte-এর জন্য C-level early-exit-এর সময়-পার্থক্য এত ছোট যে Python function-call-এর নিজস্ব overhead-এর নিচে চাপা পড়ে যায় — এই নির্দিষ্ট microbenchmark-এ signal পাওয়া যায়নি, যদিও hmac.compare_digest-ও প্রায় একই মাত্রার (0.98x) constant-time ফলাফল দিচ্ছে।

নিজে বাড়ান:

  1. PEPPER-কে ফাংশনের ডিফল্ট প্যারামিটার হিসেবে না রেখে module-level কনস্ট্যান্ট বানালে কী সুবিধা/অসুবিধা হয়? Pepper rotate করতে হলে (leak সন্দেহ হলে) এই ডিজাইনে কী সমস্যা হবে?
  2. bcrypt.checkpw নিজেই ভেতরে constant-time comparison ব্যবহার করে কিনা যাচাই করুন (bcrypt লাইব্রেরির source/docs দেখে) — আর কেন এটা password-verification-এর জন্য একটা অলিখিত requirement
  3. Argon2id-এ migrate করলে hash_password/verify_password-এর সিগনেচার কী বদলাবে? argon2.PasswordHasher দিয়ে পুরো module আবার লিখুন, আর bcrypt-versioned hash আর Argon2-versioned hash — দুইটাই একই DB column-এ থাকলে verify করার সময় কীভাবে চিনবেন কোনটা কোন KDF দিয়ে বানানো (ইঙ্গিত: bcrypt hash-string $2b$ দিয়ে শুরু হয়, Argon2 hash-string $argon2id$ দিয়ে — নিজেই self-describing)

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

বাস্তব জগতে password storage

LinkedIn, ২০১২ — unsalted SHA-1-এর মূল্য। ২০১২ সালে LinkedIn-এর প্রায় ৬৫ কোটি password hash leak হয় — unsalted SHA-1 দিয়ে store করা। ফলাফল ছিল বিপর্যয়কর: leak হওয়ার কয়েক দিনের মধ্যেই বেশিরভাগ hash precomputed dictionary আর GPU-accelerated brute-force দিয়ে plaintext-এ ফেরত আনা হয়ে যায়, ঠিক আজকের প্রথম experiment-এর মতোই কৌশলে — শুধু স্কেল ভিন্ন। এই ঘটনার পরে LinkedIn bcrypt-এ migrate করে, আর এটাই industry-wide একটা turning point হয়ে দাঁড়ায় — “salted, slow hash” optional best-practice থেকে expected baseline-এ পরিণত হয়।

Adobe, ২০১৩ — encryption ব্যবহার করেও ভুল। Adobe-এর ব্রিচে password hashed ছিল না — encrypted ছিল (একই key দিয়ে, ECB mode-জাতীয় কাঠামোয়, সাথে একটা optional plaintext password-hint)। ফলাফল ছিল আরও খারাপ: একই encrypted value থাকা মানেই একই আসল password — pattern দেখেই বহু account-এর password অনুমান করা গিয়েছিল, hint-field তাতে আরও সাহায্য করেছিল। এই কেসটা আজকের লেসনের একটা গভীর নীতি স্পষ্ট করে: password store করতে hashing লাগে, encryption না — আগের লেসনের ভাষায়, encryption reversible হওয়ার জন্য ডিজাইন করা, password store-এ সেই reversibility-ই সবচেয়ে বড় দায়।

Ashley Madison, ২০১৫ — bcrypt কীভাবে ক্ষতি সীমিত করেছিল। এই ব্রিচে প্রায় ৩৬ লক্ষ password bcrypt দিয়ে (উচ্চ cost factor সহ) store করা ছিল, বাকিদের একটা অংশ ভুলবশত দুর্বল MD5-ভিত্তিক legacy scheme-এ। ফলাফল স্পষ্ট বৈসাদৃশ্য দেখায়: MD5-ভিত্তিক অংশ দ্রুত ভাঙা গিয়েছিল, কিন্তু bcrypt-সুরক্ষিত অংশ ব্যাপক brute-force চেষ্টার পরেও বহুলাংশে অক্ষত থেকে যায় — একটা বাস্তব, লক্ষ-লক্ষ-account-স্কেলের প্রমাণ যে work-factor আসলেই attacker-এর বাস্তব সময়-খরচ বাড়ায়, শুধু তাত্ত্বিক সংখ্যা না।

OWASP-এর বর্তমান সুপারিশ। Password Storage Cheat Sheet অনুযায়ী (নিয়মিত হালনাগাদ হয়) — Argon2id প্রথম পছন্দ (memory ≥ ১৯ MiB, iterations ≥ ২, parallelism = ১, নির্দিষ্ট প্রয়োজন অনুযায়ী টিউন করা), না থাকলে bcrypt (cost ≥ ১০) অথবা PBKDF2 (উচ্চ iteration count, HMAC-SHA-256 সহ)। প্রতিটাতেই automatic per-user salt বাধ্যতামূলক ধরে নেওয়া হয় — এটা এখন KDF-এর built-in অংশ, আলাদা করে implement করার বিষয় না।

Pepper — ব্যবহারিক অবস্থান। NIST SP 800-63B “additional iteration of a key derivation function” হিসেবে একটা secret value উল্লেখ করে (ব্যবহারিকভাবে pepper) — শর্ত থাকে সেটা একটা আলাদা, সুরক্ষিত জায়গায় (HSM, secrets manager, অথবা অন্তত আলাদা environment variable) store হবে, database-এর সাথে না। Pepper defense-এর একটা নির্দিষ্ট threat model ঠেকায়: শুধু database leak (SQL injection, backup-চুরি) হলেও attacker password verify করতে পারবে না, কারণ pepper ছাড়া bcrypt/Argon2 হ্যাশ পুনর্গণনা করা অসম্ভব — তবে pepper কোনো salt-এর বিকল্প না, দুইটা সম্পূর্ণ আলাদা কাজ করে (নিচের misconception অংশে বিস্তারিত)।

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

“SHA-256(password) নিরাপদ, কারণ hash function preimage-resistant — 'one-way' মানেই যথেষ্ট।”

আগের লেসনের misconception-টাই আজ পুরোপুরি খুলে দেখানো হলো। Preimage resistance-এর সংজ্ঞা ধরে নেয় attacker একটা random, আগে-অজানা target-এর বিরুদ্ধে কাজ করছে — কিন্তু password-এর candidate-space ছোট আর পুনরাবৃত্তিমূলক বলে attacker breach-এর আগেই precompute করে ফেলতে পারে (আজকের প্রথম experiment)। “One-way” আর “password-নিরাপদ” দুইটা আলাদা দাবি — প্রথমটা preimage resistance-এর সংজ্ঞা, দ্বিতীয়টার জন্য salt আর ইচ্ছাকৃত ধীরগতি — দুইটাই লাগে।

“Salt গোপন রাখতে হয়, নাহলে কোনো লাভ নেই — অনেকটা password-এর মতোই সুরক্ষিত রাখা উচিত।”

Salt-এর কাজ secrecy না, uniqueness। এটা database-এ hash-এর পাশেই plaintext-এ store হয়, attacker leak হলে সেটাও দেখতে পারে — এবং সেটা সমস্যা না, কারণ salt-এর পুরো কাজ হলো প্রতিটা user-এর জন্য আলাদা input বানানো যাতে একটাই precomputed dictionary সবার বিরুদ্ধে ব্যবহার করা না যায়। আজকের example অংশেই দেখা গেছে — salt জানার পরেও attacker-কে প্রতিটা user-এর জন্য আলাদাভাবে dictionary বানাতে হবে, যা ব্যবহারিকভাবে precomputation-এর পুরো সুবিধাটাই কেড়ে নেয়। যেটা গোপন রাখতেই হবে সেটা pepper — সেই দুইটা আলাদা primitive, আলাদা কাজ।

“bcrypt/Argon2 ব্যবহার করলেই brute-force সম্পূর্ণ অসম্ভব হয়ে যায় — 'slow KDF' মানেই 'unbreakable'।”

Experiment অংশেই সংখ্যায় দেখা গেছে — bcrypt/Argon2 brute-force-কে ব্যয়বহুল করে, অসম্ভব না। একটা দুর্বল, ছোট password (যেমন 123456) এখনও bcrypt দিয়ে হ্যাশ করা থাকলেও কয়েক ঘণ্টা-দিনের মধ্যে ভাঙা যেতে পারে যদি candidate-space যথেষ্ট ছোট হয়। Slow KDF আক্রমণের সময় বাড়ায় (defense-in-depth-এর একটা স্তর), কিন্তু rate-limiting, account-lockout, আর সবচেয়ে গুরুত্বপূর্ণভাবে শক্তিশালী, দীর্ঘ password বা multi-factor authentication ছাড়া একা কখনো সম্পূর্ণ সমাধান না।

“Pepper আসলে দ্বিতীয় salt-ই — দুইটা একই জিনিসের ভিন্ন নাম, একটা থাকলে আরেকটার দরকার নেই।”

Salt আর pepper সম্পূর্ণ ভিন্ন axis-এর defense। Salt: প্রতি-user আলাদা, গোপন না, database-এই hash-এর পাশে থাকে, উদ্দেশ্য precomputation ঠেকানো। Pepper: সব user-এর জন্য একই (app-wide), গোপন থাকতেই হবে, database-এর বাইরে (env var/secrets manager/HSM) আলাদাভাবে store হয়, উদ্দেশ্য শুধু-database-leak-এর বিরুদ্ধে একটা বাড়তি স্তর যোগ করা — আজকের build অংশেই দেখা গেছে, শুধু stolen_hash জানা থাকলে pepper ছাড়া verify fail করে। একটা defeats precomputation, আরেকটা defeats database-only-breach — দুইটা আলাদা threat, দুইটাই একসাথে থাকা উচিত, একটা আরেকটার প্রতিস্থাপন না।

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

1

একটা precomputed dictionary attack চালাতে attacker-এর ঠিক কী কী লাগে, আর কোন ধাপটা “offline” (breach হওয়ার আগেই) করা যায়?

স্মরণ

লাগে একটা common-password wordlist আর একটা hash function (যেমন SHA-256)। Attacker wordlist-এর প্রতিটা candidate-এর হ্যাশ আগে থেকেই গণনা করে {hash: password} একটা dictionary বানায় — এই পুরো ধাপটাই “offline”, breach হওয়ার আগেই, কোনো নির্দিষ্ট leaked hash না দেখেই সম্ভব। Breach হলে শুধু leaked hash-গুলো dictionary-তে lookup করা লাগে — O(1), কোনো নতুন গণনা লাগে না।

2

SHA-256-এর preimage resistance অক্ষত থাকা সত্ত্বেও কীভাবে একটা precomputed dictionary attack সফল হতে পারে? দুইটার মধ্যে দ্বন্দ্ব নেই কেন?

যুক্তি

Preimage resistance-এর সংজ্ঞা ধরে নেয় attacker একটা random, আগে-অজানা h-এর বিরুদ্ধে নতুন করে search চালাচ্ছে — সেই assumption-এ SHA-256 সত্যিই 2^256-স্তরের নিরাপদ। কিন্তু precomputed dictionary attack এই assumption-টাই এড়িয়ে যায়: attacker কোনো নির্দিষ্ট target-এর বিরুদ্ধে search চালাচ্ছে না, বরং মানুষের password-বাছাইয়ের বাস্তব skew (সীমিত, পুনরাবৃত্তিমূলক candidate-space) কাজে লাগিয়ে আগে থেকেই একটা ছোট, বাস্তবসম্মত সেট precompute করে রাখছে। কোনো cryptographic দুর্বলতা কাজে লাগেনি — attack model-টাই আলাদা।

3

Example অংশে দেখা গেছে একই password dragon-এর জন্য alice, bob, carol — তিনজনের stored hash সম্পূর্ণ আলাদা। যদি একজন attacker আগে থেকেই জানত যে তিনজনেরই password dragon, তাহলে salted database দেখে সেটা নিশ্চিত করতে পারত কি? যুক্তি দিন।

প্রয়োগ

না, পারত না শুধু hash দেখে — কারণ salt আলাদা হওয়ায় H(salt||password) তিনজনের জন্য তিনটা সম্পূর্ণ ভিন্ন output দেয় (avalanche effect-এর কারণে, আগের লেসনের ভাষায়), কোনো visual/structural মিল থাকে না। Salt নিজেই (plaintext, database-এ visible) দেখে attacker জানতে পারবে salt আলাদা, কিন্তু stored hash-এর মধ্যে কোনো pattern match খুঁজে “এরা একই password ব্যবহার করছে” বলার কোনো উপায় নেই — ঠিক এটাই salting-এর উদ্দেশ্য।

4

Experiment অংশে bcrypt আর Argon2id দুইটাই SHA-256-এর চেয়ে বহু-গুণ ধীর দেখা গেছে, কিন্তু bcrypt-কে “শুধু ধীর” আর Argon2-কে “memory-hard” বলা হয় — এই পার্থক্যটা কেন গুরুত্বপূর্ণ, বিশেষ করে GPU/ASIC আক্রমণের প্রেক্ষাপটে?

যুক্তি

bcrypt-এর ধীরগতি শুধু CPU-cycle খরচ করে — GPU/ASIC-এ বহু instance সমান্তরালে চালানো তুলনামূলক সহজ কারণ প্রতিটা instance-এর memory-প্রয়োজন কম। Argon2 ইচ্ছাকৃতভাবে বড়-মেমোরি ব্যবহার করে (ডিফল্ট ৬৪ মেগাবাইট/ attempt) — তাই সমান্তরাল হাজার attempt চালাতে হাজার-গুণ মেমোরি লাগবে, যা GPU-তে (তুলনামূলক কম, দ্রুত কিন্তু সীমিত মেমোরির hardware) ব্যয়বহুল/ অসম্ভবপ্রায়। ফলে single-thread wall-clock timing-এ কাছাকাছি দেখালেও, বড়-স্কেল parallel attacker-এর বাস্তব খরচে Argon2-এর সুবিধা bcrypt-এর চেয়ে বেশি — এই কারণেই Argon2 Password Hashing Competition জিতেছিল।

5

Build অংশে হাতে-লেখা early-exit loop-এ স্পষ্ট timing পার্থক্য (৫.১৩x) দেখা গেলেও Python-এর নিজস্ব bytes ==-এ প্রায় কিছুই দেখা যায়নি (১.০৯x)। এর মানে কি এই যে Python-এ == দিয়ে secret তুলনা করা নিরাপদ? ব্যাখ্যা করুন।

প্রয়োগ

না। এই নির্দিষ্ট microbenchmark-এ signal না-পাওয়ার কারণ হলো CPython-এর bytes.__eq__ C-level-এ implemented বলে ৩২-byte তুলনার early-exit সময়-পার্থক্য এত ছোট যে Python function-call-overhead-এর নিচে চাপা পড়ে যায় — অন্তর্নিহিত algorithm (early-exit byte comparison) এখনও ঠিক একই, শুধু এই একটা পরিবেশে পরিমাপযোগ্য না। দীর্ঘতর secret, ভিন্ন ভাষা/বাস্তবায়ন, বা নেটওয়ার্কের ওপর দিয়ে বহু request-এর statistical average নিলে এই একই দুর্বলতা exploit করা সম্ভব (বাস্তব ঘটনা আছে)। তাই নীতি “profile করে দেখি সমস্যা হয় কিনা” ভিত্তিক না হয়ে categorical হওয়া উচিত — সবসময় hmac.compare_digest ব্যবহার করা।

6

একটা নতুন SaaS product-এর signup/login সিস্টেম ডিজাইন করছেন — ১০ লক্ষ user আশা করা হচ্ছে, সার্ভার সাধারণ cloud VM (GPU নেই, মাঝারি CPU)। Password store করার জন্য কোন KDF বাছবেন, salt কীভাবে handle করবেন, pepper রাখবেন কিনা, আর cost/work-factor কীভাবে ঠিক করবেন? প্রতিটা সিদ্ধান্তের যুক্তি দিন।

ডিজাইন

Argon2id প্রথম পছন্দ (OWASP-এর বর্তমান সুপারিশ, memory-hard defense GPU/ASIC আক্রমণের বিরুদ্ধে সবচেয়ে কার্যকর) — যদি server-এর available memory সীমিত থাকে (shared cloud VM-এ অনেক concurrent login), তাহলে memory_cost কমিয়ে bcrypt (cost ≥ ১২)-এ যাওয়াও গ্রহণযোগ্য বিকল্প, যেহেতু দুইটাই built-in per-user salt দেয়। Salt নিয়ে আলাদা কিছু করার দরকার নেই — bcrypt/Argon2 নিজেই automatic salt generate ও embed করে, custom salt-logic লেখা অপ্রয়োজনীয় জটিলতা ও ভুলের সুযোগ। Pepper যোগ করা উচিত — একটা environment variable বা secrets manager-এ রাখা app-wide secret, যাতে শুধু SQL-injection বা backup-leak-জাতীয় database-only breach-এ attacker সরাসরি hash verify করতে না পারে। Cost factor বাছাই করার নিয়ম: লক্ষ্য একটাই login-attempt সার্ভারে “লক্ষণীয় কিন্তু বিরক্তিকর না” সময় নেবে (~১০০-৩০০ মিলিসেকেন্ড), production হার্ডওয়্যারে বেঞ্চমার্ক করে নির্ধারণ করা উচিত, আর প্রতি ১-২ বছরে হার্ডওয়্যার-উন্নতির সাথে পুনর্মূল্যায়ন করা উচিত (আজকের experiment-এর সংখ্যাগুলোই দেখায় কেন “একবার ঠিক করে ভুলে যাওয়া” চলবে না)।

এরপর কী

এরপর কী

আজ password store করার নিরাপদ উপায় দেখা হলো — salt দিয়ে precomputation ঠেকানো, ইচ্ছাকৃত-ধীর KDF দিয়ে targeted brute-force ব্যয়বহুল করা, constant-time comparison দিয়ে timing-leak বন্ধ করা, আর pepper দিয়ে database-only breach-এর বিরুদ্ধে একটা বাড়তি স্তর যোগ করা। কিন্তু একটা প্রশ্ন ইচ্ছা করেই আজ ছোঁয়া হয়নি: password সঠিক প্রমাণিত হওয়ার পরে কী ঘটে?

User প্রতিটা পরের request-এ তো আর username-password পাঠাবে না — সেটা ধীর, আর প্রতি-request password network-এ পাঠানো মানে exposure-এর সুযোগ বাড়ানো। পরের লেসন — authentication — ঠিক এই ফাঁকটা পূরণ করে: password verify হয়ে যাওয়ার পরে সেই “তুমি কে” তথ্যটা পরবর্তী প্রতিটা request-এ কীভাবে বহনযোগ্য করে তোলা হয়, দুইটা সম্পূর্ণ ভিন্ন দর্শনে — server-side session cookie, আর self-contained, signed JWT। আজকের “কীভাবে secret নিরাপদে রাখব” প্রশ্ন থেকে পরের লেসনের “কীভাবে প্রমাণিত identity বহন করব” প্রশ্নে — একটা স্বাভাবিক পরবর্তী ধাপ।

আরও পড়ুন

  • A Future-Adaptable Password Scheme — Niels Provos, David Mazières — USENIX Annual Technical Conference, 1999 · bcrypt-এর মূল পেপার — tunable work factor আর Blowfish-based key-schedule দিয়ে ইচ্ছাকৃত-ধীর hashing-এর প্রথম আনুষ্ঠানিক প্রস্তাব
  • Argon2: New Generation of Memory-Hard Functions for Password Hashing and Other Applications — Alex Biryukov, Daniel Dinu, Dmitry Khovratovich — IEEE EuroS&P 2016 · Password Hashing Competition (২০১৩-২০১৫)-এর বিজয়ী স্পেসিফিকেশন — memory-hardness-এর আনুষ্ঠানিক ডিজাইন-যুক্তি
  • Password Storage Cheat Sheet — OWASP Foundation · আজকের production সুপারিশ — bcrypt/Argon2/PBKDF2-এর জন্য নির্দিষ্ট parameter-রেঞ্জ, নিয়মিত হালনাগাদ হয়
  • Digital Identity Guidelines — Authentication and Lifecycle Management (SP 800-63B) — National Institute of Standards and Technology (NIST) · Memorized secret verifier-দের জন্য সরকারি নীতিমালা — salting, rate-limiting, আর approved hashing-এর আনুষ্ঠানিক প্রয়োজনীয়তা