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

Digital Signature ও PKI — সই তো হলো, কিন্তু কার সই?

Digital Signatures and Public Key Infrastructure

Digital signature encryption-এর ভূমিকা উল্টে দেয় — private key দিয়ে sign, public key দিয়ে verify — কিন্তু শুধু প্রমাণ করে 'এই key-র মালিক সাইন করেছে', কে সেই মালিক তা নয়; certificate authority আর X.509 chain-of-trust ঠিক এই ফাঁকটাই ভরাট করে, root থেকে leaf পর্যন্ত signature-এর একটা শৃঙ্খল দিয়ে।

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

  • Digital signature আসলে asymmetric encryption-এর ভূমিকা কীভাবে উল্টে দেয় — private key দিয়ে sign, public key দিয়ে verify — তা ব্যাখ্যা করতে পারবেন, আর raw message না, message-এর hash কেন সাইন করা হয় তার দুইটা স্বতন্ত্র কারণ (efficiency আর algebraic forgery) যুক্তি দিয়ে বলতে পারবেন।
  • একটা toy RSA keypair generate করে, বাস্তব সংখ্যায় hash sign আর verify চালিয়ে, তারপর message tamper করে verification ঠিক কোথায় ভেঙে যায় তা হাতে-কলমে দেখাতে পারবেন।
  • শুধু signature verify হওয়া কেন 'কোন key' প্রশ্নের উত্তর দেয়, 'কার key' প্রশ্নের উত্তর দেয় না তা চিহ্নিত করে, সেই ফাঁক থেকে কেন certificate authority আর PKI অনিবার্য হয়ে ওঠে তার যুক্তি নির্মাণ করতে পারবেন।
  • X.509 certificate-এর মূল field (subject, issuer, public key, validity, CA-এর signature) চিনে, root CA থেকে intermediate হয়ে leaf certificate পর্যন্ত chain-of-trust একটা browser ধাপে ধাপে কীভাবে verify করে তা reconstruct করতে পারবেন।
  • Certificate revocation কেন একটা কাঠামোগতভাবে কঠিন, প্রায়-অমীমাংসিত সমস্যা তার প্রাথমিক কারণ ব্যাখ্যা করতে পারবেন, আর কোন পরবর্তী লেসনে এর পূর্ণ সমাধান (OCSP, pinning, downgrade-protection) আলোচিত হবে তা চিনতে পারবেন।

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

আগে এটা বুঝি

আগের লেসনে asymmetric (public-key) cryptography-র মডেল তৈরি হয়েছে — দুইটা আলাদা key, গাণিতিকভাবে জোড়া বাঁধা, একটা প্রকাশ্য (public), একটা গোপন (private)। Encryption-এর দিকে সেই মডেল সহজ: যে কেউ Alice-এর public key দিয়ে কিছু encrypt করতে পারে, কিন্তু শুধু Alice-ই, তার private key দিয়ে, সেটা ফেরত পড়তে পারে। উদ্দেশ্য — confidentiality, শুধু নির্দিষ্ট একজনের জন্য বার্তা।

আজকের প্রাইমিটিভ ঠিক একই keypair, ঠিক একই গাণিতিক সম্পর্ক নেয় — কিন্তু ভূমিকা উল্টে দেয়। এবার Alice তার private key দিয়ে কিছু “প্রক্রিয়া” করে, আর যে কেউ Alice-এর public key দিয়ে সেটা যাচাই করতে পারে:

Encryption:        E(public, m) = c    শুধু private key-ধারী পড়তে পারে
Digital signature:  S(private, m) = s    যে কেউ public key দিয়ে যাচাই করতে পারে

উদ্দেশ্যও উল্টে যায়। Encryption-এ গোপনীয়তা রক্ষা পায় — বার্তা শুধু একজনের জন্য পড়াযোগ্য। Signature-এ বার্তা প্রায়ই সম্পূর্ণ প্রকাশ্য, কারো পড়তে কোনো বাধা নেই — যা রক্ষা পায় তা হলো উৎস: “এই বার্তা সত্যিই Alice-এর কাছ থেকে এসেছে, আর পথে কেউ এক অক্ষরও বদলায়নি।” Software vendor একটা installer প্রকাশ করে সাইন করে দেয় (উৎস প্রমাণ), Git commit সাইন করা যায় (কে commit করেছে তার প্রমাণ), একটা bank transaction সাইন হয় (Alice নিজে অস্বীকার করতে পারবে না — non-repudiation, কারণ শুধু তার কাছেই সেই private key ছিল)।

মূল ধারণা

Digital signature — সংজ্ঞা আর hash-then-sign

Digital signature স্কিম তিনটা ফাংশনের সেট — key generation বাদ দিলে মূলত দুইটা:

sign(message, private_key)   -> signature
verify(message, signature, public_key) -> true / false

নিরাপত্তার শর্ত একটাই বাক্যে: private key ছাড়া কেউ এমন কোনো (message, signature) জোড়া বানাতে পারবে না যা verify দিয়ে পাস করে। এই সংজ্ঞা যতটা সহজ শোনায়, বাস্তবায়নে একটা প্রশ্ন সঙ্গে সঙ্গে ওঠে: message সরাসরি sign করব, নাকি আগের লেসনের যন্ত্রটা মাঝে বসাব? বাস্তব প্রতিটা signature scheme উত্তর দেয় — hash আগে, sign পরে:

sign(message, priv)  = S( H(message), priv )        raw message না, তার hash sign হয়
verify(message, s, pub) = ( D(s, pub) == H(message) )  independently recomputed hash-এর সাথে তুলনা

দুইটা সম্পূর্ণ স্বতন্ত্র কারণ এই “hash আগে” নিয়মের পেছনে — একটা বাস্তবিক, একটা গাণিতিক।

কারণ ১ — efficiency। RSA-স্টাইল sign/verify মানে modular exponentiation, আর সেই খরচ input-এর সাইজের সাথে বাড়ে। একটা ২ গিগাবাইট installer সরাসরি sign করতে হলে হয় পুরো ফাইলকে ছোট ব্লকে কেটে প্রতিটা ব্লক আলাদাভাবে sign করতে হবে (আর তখন ব্লক-reordering আক্রমণ ঠেকানোর নতুন সমস্যা তৈরি হয় — গত লেসনের ECB-মোডের মতোই), নয়তো পুরো ফাইলটাকেই এক বিশাল সংখ্যা হিসেবে treat করতে হবে (আর n-এর চেয়ে বড় input-এর জন্য modular exponentiation-এর সংজ্ঞাই ভেঙে পড়ে)। আগের লেসনের cryptographic hash function এই সমস্যা এক ধাক্কায় সমাধান করে — যেকোনো সাইজের ইনপুট থেকে একটা fixed-size (SHA-256-এ ২৫৬-বিট) digest, সেই ছোট, নির্দিষ্ট-সাইজের digest-টাই sign করা হয়। ফাইল যত বড়ই হোক, signature-operation ঠিক একবার চলে।

কারণ ২ — algebraic (existential) forgery। এটাই কম প্রচলিত কিন্তু বেশি গুরুত্বপূর্ণ কারণ। “টেক্সটবুক” RSA sign/verify-এর নিজস্ব একটা বীজগাণিতিক গঠন আছে যা attacker-এর হাতে অস্ত্র তুলে দেয়। RSA-তে sign(m) = m^d mod n। এখন লক্ষ্য করুন:

sign(m1) * sign(m2) mod n = m1^d * m2^d mod n = (m1*m2)^d mod n = sign(m1*m2 mod n)

Attacker যদি victim-কে দুইটা (নির্দোষ দেখতে) message m1, m2 sign করাতে রাজি করাতে পারে (বা এমনিতেই দুইটা signature জোগাড় করে), তাহলে কোনো private key না ছুঁয়েই, শুধু গুণ করে, m1*m2 mod n-এর জন্য একটা বৈধ signature বানিয়ে ফেলতে পারে — এমন একটা message যা victim কখনো sign করতে রাজি হতো না। নিচের “hood” অংশে এই আক্রমণটা আসলেই সংখ্যায় চালিয়ে দেখানো হবে, আর দেখানো হবে hash কেন এই বীজগাণিতিক structure ভেঙে দেয়।

ভেতরে কী ঘটছে

গণিতটা উল্টানো — sign আসলে যে অপারেশন

RSA-র keypair generation-এর গণিত (prime p, q, n = pq, φ(n), e, d — ed ≡ 1 mod φ(n)) আগের লেসনেই দেখা হয়ে গেছে। আজকের নতুন বিষয় শুধু এই — encryption আর signature ঠিক একই দুইটা অপারেশন ব্যবহার করে, শুধু কে কোনটা চালায় তা উল্টে যায়:

Encryption:  Alice publish করে (n, e)  — anyone: c = m^e mod n  (public-এ encrypt)
             Alice নিজে:  m = c^d mod n              (private দিয়ে decrypt)

Signature:   Alice নিজে:  s = h^d mod n               (private দিয়ে "sign" — decrypt-operation!)
             anyone:       h' = s^e mod n  (public দিয়ে "verify" — encrypt-operation!)

Sign করার সময় Alice literally সেই একই d-exponentiation চালাচ্ছে যা decrypt-এ চালাত — শুধু input এখন কারো পাঠানো ciphertext না, নিজের message-এর hash। Verify করার সময় anyone সেই একই e-exponentiation চালাচ্ছে যা encrypt-এ চালাত — শুধু ফলাফলটা কাউকে পাঠানো হচ্ছে না, নিজের কাছে independently recompute করা hash-এর সাথে মেলানো হচ্ছে। একই দুইটা গিয়ার, বিপরীত দিকে ঘোরানো।

এবার আগের অংশের algebraic-forgery দাবিটা সংখ্যায় দেখি — classic textbook RSA উদাহরণ (p=61, q=53 থেকে n=3233, e=17, d=2753) দিয়ে, hash ছাড়া raw signing-এ:

# forgery.py — raw RSA signing-এর multiplicative forgery, আর hash-then-sign কীভাবে সেটা ভাঙে
n, e, d = 3233, 17, 2753   # classic textbook RSA keypair (Wikipedia-এর উদাহরণ)

def sign_raw(m):
    return pow(m, d, n)          # sign = private-exponent operation

def verify_raw(m, s):
    return pow(s, e, n) == m     # verify = public-exponent operation

m1, m2 = 65, 91
s1, s2 = sign_raw(m1), sign_raw(m2)
print(f"sign_raw({m1}) = {s1}   verify: {verify_raw(m1, s1)}")
print(f"sign_raw({m2}) = {s2}   verify: {verify_raw(m2, s2)}")

# attacker কখনো d স্পর্শ করেনি — শুধু দুইটা বৈধ signature হাতে পেয়ে multiply করে
m3 = (m1 * m2) % n
forged = (s1 * s2) % n
print(f"\nm3 = m1*m2 mod n        = {m3}")
print(f"forged = s1*s2 mod n    = {forged}")
print(f"verify_raw(m3, forged)  = {verify_raw(m3, forged)}   <- d না জেনেই একটা বৈধ signature!")

# এবার hash-then-sign দিয়ে ঠিক একই trick চেষ্টা করি
import hashlib
def h(m):
    return int(hashlib.sha256(str(m).encode()).hexdigest(), 16) % n

h1, h2, h3 = h(m1), h(m2), h(m3)
print(f"\nH(m1) mod n = {h1}   H(m2) mod n = {h2}   H(m3) mod n = {h3}")
print(f"H(m1)*H(m2) mod n = {(h1*h2) % n}   vs আসল H(m3) = {h3}")
print(f"same trick hashed scheme-এ কাজ করে: {(h1*h2) % n == h3}")

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

sign_raw(65) = 588   verify: True
sign_raw(91) = 2251   verify: True

m3 = m1*m2 mod n        = 2682
forged = s1*s2 mod n    = 1291
verify_raw(m3, forged)  = True   <- d না জেনেই একটা বৈধ signature!

H(m1) mod n = 1863   H(m2) mod n = 3076   H(m3) mod n = 3111
H(m1)*H(m2) mod n = 1712   vs আসল H(m3) = 3111
same trick hashed scheme-এ কাজ করে: False

সংখ্যাগুলো নিজেই সিদ্ধান্ত দেয়: raw signing-এ verify_raw(m3, forged) সত্যি হয়ে গেছে — attacker d-এর একটা বিটও না জেনে, শুধু গুণ করে, একটা সম্পূর্ণ নতুন বার্তার জন্য বৈধ signature বানিয়ে ফেলেছে। Hash যোগ করার পরে H(m1)*H(m2) mod n আর আসল H(m3) সম্পূর্ণ ভিন্ন — সেই একই গুণের কৌশল এখানে অচল।

Signature verify হওয়া মানে ঠিক কী প্রমাণিত হলো — আর কী হলো না

এখানেই আজকের লেসনের কেন্দ্রীয় মোড়। ধরুন verify(message, signature, public_key) সত্যি ফেরত দিল। এটা ঠিক কী প্রমাণ করে? শুধু এইটুকু:

"যে-ই এই public_key-র সাথে জোড়া বাঁধা private key ধরে আছে,
 সে-ই এই message-টা sign করেছে।"

লক্ষ্য করুন কী নেই এই বাক্যে — কোথাও লেখা নেই সেই private-key-ধারী মানুষটা কে। public_key নিজে শুধু একগুচ্ছ সংখ্যা — কোনো নাম, কোনো domain, কোনো identity তার সাথে built-in আটকানো নেই। একজন attacker নিজের জন্য একটা সম্পূর্ণ বৈধ RSA keypair বানিয়ে, “আমি bKash-support” লিখে একটা message সাইন করে পাঠাতে পারে — সেই signature verify দিয়ে পাস করবে, কারণ সে সত্যিই তার নিজের private key দিয়েই সাইন করেছে। গণিত মিথ্যা বলেনি — প্রশ্নটাই ছিল ভুল জায়গায়: গণিত জানে “কোন key”, মানুষ জানতে চায় “কার key”।

উদাহরণ

Certificate — একটা key-কে identity-র সাথে বাঁধার signed statement

সমাধানটা conceptually সহজ: যদি কোনো একটা তৃতীয়-পক্ষ, যাকে দুই পক্ষই আগে থেকে বিশ্বাস করে, একটা statement সাইন করে দেয় — “আমি, X, যাচাই করেছি যে এই public key-টা সত্যিই Y-এর” — তাহলে সেই তৃতীয়-পক্ষের signature verify করেই key-ownership-এর identity-প্রশ্নের উত্তর পাওয়া যায়। এই তৃতীয়-পক্ষকে বলে Certificate Authority (CA), আর সেই signed statement-কে বলে certificate।

বাস্তবে ব্যবহৃত সবচেয়ে প্রচলিত ফরম্যাট X.509। একটা certificate-এর মূল field:

Fieldকী বোঝায়
Subjectকার identity — ব্যক্তি, organization, বা (TLS-এ সবচেয়ে বেশি দেখা যায়) একটা domain name
Issuerকে এই certificate সাইন করেছে — সাধারণত একটা CA-র নাম
Subject Public Key InfoSubject-এর public key (আর কোন algorithm — RSA/ECDSA)
Validity (Not Before / Not After)কতদিন পর্যন্ত এই বাইন্ডিং বৈধ
Extensions (SAN, Key Usage…)Subject Alternative Name (কোন কোন domain এই cert-এ কভার হয়), key কীসের জন্য ব্যবহারযোগ্য
Signatureissuer-এর private key দিয়ে উপরের সবকিছুর hash sign করা — এটাই certificate-কে “signed statement” বানায়

গাণিতিকভাবে certificate-এর signature ঠিক আগের অংশের sign/verify-ই — পার্থক্য শুধু কী sign করা হচ্ছে:

tbs = "subject=example.com | issuer=Some CA | pubkey=... | valid=2026..2027 | ..."
signature = sign( H(tbs), CA_private_key )

(“tbs” — X.509-এর নিজস্ব পরিভাষা, “to-be-signed” — certificate-এর সেই অংশ যা signature-এর ইনপুট হিসেবে hash হয়।) যে কেউ, CA_public_key জানা থাকলে, verify(tbs, signature, CA_public_key) চালিয়ে নিশ্চিত হতে পারে — এই নির্দিষ্ট public key সত্যিই এই CA-র মতে example.com-এর।

কিন্তু এখানেই একটা রিকার্সিভ প্রশ্ন জন্ম নেয়: CA_public_key-টা নিজে কি বিশ্বাসযোগ্য? সেটা কে বলল? এই প্রশ্নের উত্তরেই আসে chain-of-trust — পরের অংশের বিষয়।

ছবিতে

Chain of trust — root থেকে leaf পর্যন্ত

রিকার্সিভ প্রশ্নটা অসীমভাবে চলতে পারত না — কোথাও একটা থামার জায়গা লাগবে। বাস্তব PKI তিন স্তরের একটা কাঠামোয় থামে:

Root CA
  subject = "Root CA"        issuer = "Root CA"        (নিজেই নিজেকে সাইন — self-signed)
  │
  │  signs
  ▼
Intermediate CA
  subject = "Intermediate CA"   issuer = "Root CA"      (root-এর private key দিয়ে সাইন করা)
  │
  │  signs
  ▼
Leaf certificate (example.com)
  subject = "example.com"       issuer = "Intermediate CA"  (intermediate-এর private key দিয়ে সাইন করা)
Root CA (self-signed, trust store-এ pre-installed) → Intermediate CA (root-এর signature) → Leaf/end-entity certificate (intermediate-এর signature)।

Root CA কেন self-signed হয়ে থামে। Root-এর signature verify করতে আরেকটা উঁচু authority লাগত, আর সেটা লাগলে সমস্যাটা আবার সেই একই জায়গায় ফিরে যেত — অসীম chain। বাস্তব সমাধান গাণিতিক না, সামাজিক/প্রশাসনিক: root CA-দের public key একটা নির্দিষ্ট, সীমিত তালিকায় (OS-এর বা browser-এর trust store-এ) আগে থেকেই বসিয়ে রাখা হয় — Apple, Microsoft, Google, Mozilla নিজেদের কঠোর audit-প্রক্রিয়ায় (WebTrust, CA/Browser Forum-এর Baseline Requirements) পাস করা root CA-গুলোকেই সেই তালিকায় ঢোকায়। “বিশ্বাস” এখানে গণিত থেকে আসছে না — আসছে trust store maintainer-এর গৃহীত একটা সিদ্ধান্ত থেকে, যা পুরো chain-এর ভিত্তি।

Intermediate CA কেন মাঝে বসে। Root-এর private key দিয়ে সরাসরি প্রতিদিন লক্ষ লক্ষ leaf certificate সাইন করা হয় না — root private key সাধারণত offline, একটা locked vault-এ রাখা হয় (compromise হলে পুরো tree-ই বাতিল করতে হয়)। Intermediate CA দৈনন্দিন সাইনিং-এর কাজ করে; কোনো কারণে intermediate compromise হলে শুধু সেই intermediate-টা revoke করে root নতুন intermediate ইস্যু করতে পারে — root নিজে অক্ষত থাকে।

এবার browser একটা real HTTPS connection-এ ঠিক কীভাবে এই chain ধাপে ধাপে verify করে:

Browser কীভাবে একটা HTTPS certificate chain verify করে
  1. server পাঠায়: leaf + intermediateroot পাঠায় না — সেটা browser-এর কাছে আগে থেকেই আছে
  2. leaf cert যাচাইhostname (SAN) match করে কিনা, validity date-এর ভিতরে কিনা, signature intermediate-এর public key দিয়ে verify হয় কিনা
  3. intermediate cert যাচাইএর signature root-এর public key দিয়ে verify হয় কিনা, validity ঠিক আছে কিনা
  4. root cert যাচাইsubject == issuer (self-signed), আর সেই public key browser-এর trust store-এ আছে কিনা
  5. সব ধাপ পাস → trustleaf-এর public key-কে "example.com-এর" বলে গ্রহণ করা হয়, TLS handshake চালিয়ে যাওয়া হয়

কোনো একটা ধাপ ভাঙলে — signature না মিললে, একটা date expire হয়ে থাকলে, hostname না মিললে, বা root trust store-এ না পেলে — পুরো chain বাতিল, browser সেই “certificate error” পাতা দেখায়। Build অংশে এই পাঁচটা ধাপই আসলেই চালিয়ে দেখানো হবে, একটা toy তিন-স্তরের PKI বানিয়ে।

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

EXPERIMENT

Sign, verify, tamper — সংখ্যায় দেখা toy RSA signature

macOS / Linux / Windows (Python 3)· ১৫ মিনিট
# toy_sign.py — toy RSA keypair, sign a HASH, verify, then tamper and watch it fail
import random, hashlib, math

random.seed(20260827)  # deterministic — same run, same numbers, every time

def is_prime(n, rounds=25):
    if n < 2:
        return False
    for p in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37):
        if n % p == 0:
            return n == p
    d, r = n - 1, 0
    while d % 2 == 0:
        d //= 2
        r += 1
    for _ in range(rounds):
        a = random.randrange(2, n - 1)
        x = pow(a, d, n)
        if x in (1, n - 1):
            continue
        for _ in range(r - 1):
            x = pow(x, 2, n)
            if x == n - 1:
                break
        else:
            return False
    return True

def gen_prime(bits):
    while True:
        cand = random.getrandbits(bits) | (1 << (bits - 1)) | 1
        if is_prime(cand):
            return cand

def gen_keypair(bits=140):
    p, q = gen_prime(bits), gen_prime(bits)
    while p == q:
        q = gen_prime(bits)
    n = p * q
    phi = (p - 1) * (q - 1)
    e = 65537
    while math.gcd(e, phi) != 1:
        e += 2
    d = pow(e, -1, phi)
    return (n, e), (n, d), (p, q)

def h_int(message: bytes) -> int:
    return int(hashlib.sha256(message).hexdigest(), 16)

def sign(message: bytes, priv):
    n, d = priv
    h = h_int(message)
    return pow(h, d, n)   # sign the HASH with the PRIVATE key

def verify(message: bytes, signature: int, pub):
    n, e = pub
    recovered = pow(signature, e, n)     # decrypt signature with PUBLIC key
    expected = h_int(message)            # independently recompute the hash
    return recovered == expected, recovered, expected

pub, priv, (p, q) = gen_keypair(bits=140)
n, e = pub
_, d = priv
print(f"p = {p}")
print(f"q = {q}")
print(f"n = p*q = {n}")
print(f"public key  (n, e) = (n, {e})")
print(f"private key (n, d) = (n, {d})")
print()

message = b"Pay 500 BDT to Rahim's account #4471"
h = h_int(message)
print(f"message           = {message!r}")
print(f"H(message)        = {h}")

s = sign(message, priv)
print(f"signature = H(message)^d mod n = {s}")
print()

ok, recovered, expected = verify(message, s, pub)
print("verify(message, signature, public_key):")
print(f"  recovered = signature^e mod n = {recovered}")
print(f"  expected  = H(message)        = {expected}")
print(f"  recovered == expected         = {ok}")
print()

tampered = b"Pay 500 BDT to Rahim's account #9999"
ok2, recovered2, expected2 = verify(tampered, s, pub)
print(f"TAMPERED message  = {tampered!r}")
print("verify(tampered_message, ORIGINAL signature, public_key):")
print(f"  recovered = signature^e mod n = {recovered2}")
print(f"  expected  = H(tampered)       = {expected2}")
print(f"  recovered == expected         = {ok2}")

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

p = 1038415079593719885689070949489361380931237
q = 1356905138488955212499496737820922222264037
n = p*q = 1409030757385135931329702167145484769681250019761124713174352439359051018185555023769
public key  (n, e) = (n, 65537)
private key (n, d) = (n, 949494565488193816245992901836306878304361487986690869626906411305048986558339542577)

message           = b"Pay 500 BDT to Rahim's account #4471"
H(message)        = 64642026551426890139835916725977571749643312122046554921762387407221061979332
signature = H(message)^d mod n = 1031846089203696458683967716565701564801711660079949133477066093740535031272076627277

verify(message, signature, public_key):
  recovered = signature^e mod n = 64642026551426890139835916725977571749643312122046554921762387407221061979332
  expected  = H(message)        = 64642026551426890139835916725977571749643312122046554921762387407221061979332
  recovered == expected         = True

TAMPERED message  = b"Pay 500 BDT to Rahim's account #9999"
verify(tampered_message, ORIGINAL signature, public_key):
  recovered = signature^e mod n = 64642026551426890139835916725977571749643312122046554921762387407221061979332
  expected  = H(tampered)       = 50296965355202864122430907676414993605451142626242990418188565153967834721591
  recovered == expected         = False

মূল মুহূর্তটা শেষ লাইনে: recovered (signature থেকে ফেরত পাওয়া) একই থাকল — signature বদলায়নি, বদলানোর কারণও নেই, attacker তো signature ছোঁয়নি। কিন্তু expected (নতুন message-এর independently-recomputed hash) সম্পূর্ণ ভিন্ন — আর তুলনাটা মিথ্যা ফেরত দিল। একটা account-number-এর একটা অক্ষর (4471 → 9999) বদলে দেওয়াই যথেষ্ট হলো পুরো verification ভেঙে ফেলতে — ঠিক যে avalanche effect গত লেসনে দেখা গিয়েছিল, এখানেই তার আসল কাজে লাগা।

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

একটা toy RSA keypair দিয়ে message-এর hash sign করলে verify সত্যি ফেরত দেয় — signature private key দিয়ে, verify public key দিয়ে; আর sign করার পরে message-এর একটা অক্ষর বদলালেই সেই একই signature-এ verify মিথ্যা ফেরত দেয়, কারণ independently recomputed hash আর মেলে না।

নিজে বানান

BUILD IT

ToyPKI — root → intermediate → leaf, আর একটা browser-স্টাইল validator

Python · ●●●●○
  1. toy RSA sign/verify লিখুন (আগের experiment থেকে reuse) — এবার একাধিক keypair-এর জন্য
  2. certificate গঠন করুন — subject, issuer, public key, validity — আর একটা canonical "to-be-signed" bytes ফাংশন
  3. তিনটা keypair বানান: root, intermediate, leaf — root cert self-signed, intermediate root-এর দিয়ে সাইন, leaf intermediate-এর দিয়ে সাইন
  4. browser_validate_chain() লিখুন — LayerTrace-এর পাঁচটা ধাপ (root self-signed, trust-store membership, root-sig, intermediate-sig, leaf-sig) হুবহু কোডে বসান
  5. attacker leaf certificate-এর public key বদলে দিন, পুরনো signature রেখেই — দেখান chain validation ঠিক কোন ধাপে ধরে ফেলে

লক্ষ্য: শুধু “chain of trust কাজ করে” বলাই না, একটা browser-এর মতোই পাঁচটা ধাপ কোডে বাস্তবায়ন করে দেখানো — আর তারপর একটা বাস্তবসম্মত আক্রমণ (leaf-এ নিজের key বসিয়ে দেওয়া, পুরনো signature সহ) চালিয়ে দেখানো ঠিক কোন ধাপে সেটা ধরা পড়ে।

# toy_pki.py — root CA -> intermediate CA -> leaf certificate, আর একটা toy
# "browser" যেটা ঠিক একই ভাবে chain-টা হাঁটে যেভাবে বাস্তব TLS client হাঁটে
import random, hashlib, math

random.seed(20260827)

def is_prime(n, rounds=25):
    if n < 2:
        return False
    for p in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37):
        if n % p == 0:
            return n == p
    d, r = n - 1, 0
    while d % 2 == 0:
        d //= 2
        r += 1
    for _ in range(rounds):
        a = random.randrange(2, n - 1)
        x = pow(a, d, n)
        if x in (1, n - 1):
            continue
        for _ in range(r - 1):
            x = pow(x, 2, n)
            if x == n - 1:
                break
        else:
            return False
    return True

def gen_prime(bits):
    while True:
        cand = random.getrandbits(bits) | (1 << (bits - 1)) | 1
        if is_prime(cand):
            return cand

def gen_keypair(bits=96):
    p, q = gen_prime(bits), gen_prime(bits)
    while p == q:
        q = gen_prime(bits)
    n = p * q
    phi = (p - 1) * (q - 1)
    e = 65537
    while math.gcd(e, phi) != 1:
        e += 2
    d = pow(e, -1, phi)
    return (n, e), (n, d)

def h_int(data: bytes) -> int:
    return int(hashlib.sha256(data).hexdigest(), 16)

def sign_bytes(data: bytes, priv):
    n, d = priv
    return pow(h_int(data), d, n)

def verify_bytes(data: bytes, signature: int, pub) -> bool:
    n, e = pub
    return pow(signature, e, n) == h_int(data) % n

# ---------- একটা certificate: কার, কার দ্বারা ইস্যু, কোন public key, কতদিন ----------
def tbs(subject, issuer, pub_n, valid_from, valid_to):
    # "to-be-signed" body — ঠিক যা X.509 বলে TBSCertificate
    return f"subject={subject}|issuer={issuer}|pubkey_n={pub_n}|valid={valid_from}..{valid_to}".encode()

def issue_cert(subject, issuer, subject_pub, issuer_priv, valid_from, valid_to):
    body = tbs(subject, issuer, subject_pub[0], valid_from, valid_to)
    sig = sign_bytes(body, issuer_priv)
    return {"subject": subject, "issuer": issuer, "pub": subject_pub,
            "valid_from": valid_from, "valid_to": valid_to, "sig": sig}

def cert_verifies_against(cert, issuer_pub) -> bool:
    body = tbs(cert["subject"], cert["issuer"], cert["pub"][0], cert["valid_from"], cert["valid_to"])
    return verify_bytes(body, cert["sig"], issuer_pub)

# ---------- chain বানানো: root (self-signed) -> intermediate -> leaf ----------
root_pub, root_priv = gen_keypair()
inter_pub, inter_priv = gen_keypair()
leaf_pub, leaf_priv = gen_keypair()

root_cert = issue_cert("Root CA", "Root CA", root_pub, root_priv, 2026, 2046)              # self-signed
inter_cert = issue_cert("Intermediate CA", "Root CA", inter_pub, root_priv, 2026, 2036)    # signed BY root
leaf_cert = issue_cert("example.com", "Intermediate CA", leaf_pub, inter_priv, 2026, 2027) # signed BY intermediate

TRUST_STORE = {"Root CA": root_pub}   # যা OS/browser আগে থেকেই pre-installed রাখে

def browser_validate_chain(leaf, inter, root, trust_store):
    print(f"  [1] root cert subject == issuer (self-signed)?  {root['subject'] == root['issuer']}")
    root_known = root["subject"] in trust_store and trust_store[root["subject"]] == root["pub"]
    print(f"  [2] root public key present in trust store?      {root_known}")
    root_ok = cert_verifies_against(root, root["pub"])
    print(f"  [3] root cert signature self-verifies?           {root_ok}")
    inter_ok = cert_verifies_against(inter, root["pub"])
    print(f"  [4] intermediate cert verifies with ROOT's pub?   {inter_ok}")
    leaf_ok = cert_verifies_against(leaf, inter["pub"])
    print(f"  [5] leaf cert verifies with INTERMEDIATE's pub?   {leaf_ok}")
    return root_known and root_ok and inter_ok and leaf_ok

print("=== legitimate chain: example.com <- Intermediate CA <- Root CA ===")
ok = browser_validate_chain(leaf_cert, inter_cert, root_cert, TRUST_STORE)
print(f"  => chain trusted, bind pubkey to 'example.com': {ok}")

print()
print("=== attack: swap the leaf cert's public key, KEEP the old signature ===")
mallory_pub, mallory_priv = gen_keypair()
forged_leaf = dict(leaf_cert)
forged_leaf["pub"] = mallory_pub          # attacker-এর নিজের key, example.com-এর আসল key-র বদলে
# forged_leaf["sig"] অপরিবর্তিত রইল — attacker-এর কাছে Intermediate CA-র private key
# ছাড়া নতুন বৈধ signature বানানোর কোনো উপায় নেই
ok2 = browser_validate_chain(forged_leaf, inter_cert, root_cert, TRUST_STORE)
print(f"  => chain trusted, bind pubkey to 'example.com': {ok2}")

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

=== legitimate chain: example.com <- Intermediate CA <- Root CA ===
  [1] root cert subject == issuer (self-signed)?  True
  [2] root public key present in trust store?      True
  [3] root cert signature self-verifies?           True
  [4] intermediate cert verifies with ROOT's pub?   True
  [5] leaf cert verifies with INTERMEDIATE's pub?   True
  => chain trusted, bind pubkey to 'example.com': True

=== attack: swap the leaf cert's public key, KEEP the old signature ===
  [1] root cert subject == issuer (self-signed)?  True
  [2] root public key present in trust store?      True
  [3] root cert signature self-verifies?           True
  [4] intermediate cert verifies with ROOT's pub?   True
  [5] leaf cert verifies with INTERMEDIATE's pub?   False
  => chain trusted, bind pubkey to 'example.com': False

লক্ষ্য করুন ঠিক কোথায় attack ধরা পড়ল — ধাপ ১ থেকে ৪ পর্যন্ত সব সবুজ, কারণ attacker root বা intermediate কিছুই ছোঁয়নি। শুধু ধাপ ৫-এ, leaf-এর নিজস্ব check-এ, ধরা পড়ল — কারণ forged_leaf["pub"] বদলানোর সাথে সাথে tbs(...)-এর bytes বদলে গেছে (pubkey_n field-টাই তো ওই bytes-এর অংশ), তাই H(tbs) বদলেছে, কিন্তু signature পুরনোই রয়ে গেছে — সেই পুরনো signature নতুন hash-এর সাথে মেলে না। Attacker-এর কাছে একমাত্র উপায় ছিল Intermediate CA-র private key দিয়ে নতুন করে সাইন করা — যা তার কাছে কখনোই ছিল না।

নিজে বাড়ান:

  1. leaf_cert["valid_to"]-কে অতীতের একটা বছরে বদলে দিন (validity বদলাননি signature বদলায়নি) — cert_verifies_against কি তাও পাস করবে? কেন validity-check আলাদা করে করতে হয়, শুধু signature verify যথেষ্ট না কেন, ব্যাখ্যা করুন।
  2. একটা চতুর্থ স্তর যোগ করুন — একটা দ্বিতীয় intermediate, প্রথম intermediate-এর দ্বারা সাইন করা (“sub-intermediate”)। browser_validate_chain কীভাবে বদলাতে হবে একটা arbitrary-length chain হ্যান্ডল করতে?
  3. TRUST_STORE-এ যদি ভুলবশত mallory_pub-ও ঢুকিয়ে দেওয়া হয় (একটা malicious root trusted হয়ে গেছে), legitimate chain validation-এ তার কী প্রভাব পড়বে? এখান থেকেই বোঝা যায় root CA compromise কেন এত বিধ্বংসী।

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

বাস্তব জগতে PKI

Let’s Encrypt আর ACME — automation দিয়ে PKI-কে গণতান্ত্রিক করা। ২০১৬-র আগে একটা TLS certificate পাওয়া ছিল ধীর, দামি, ম্যানুয়াল প্রক্রিয়া। Let’s Encrypt (ISRG) ACME protocol দিয়ে পুরো ইস্যু-প্রক্রিয়া automate করে দিল — domain-control প্রমাণ (server-এ একটা নির্দিষ্ট ফাইল বা DNS record বসাতে বলা) আর ৯০ দিনের short-lived certificate, script দিয়ে auto-renew। ফলাফল আজকের ইন্টারনেটের বেশিরভাগ HTTPS adoption — chain-of-trust একই থাকল, শুধু “কে issue করছে, কত দ্রুত” আমূল বদলে গেল।

DigiNotar (২০১১) — একটা compromised CA-র বিপর্যয়। ডাচ CA DigiNotar-এর সিস্টেম হ্যাক হয়ে যায়, আর attacker *.google.com-সহ কয়েকশো ভুয়া কিন্তু সম্পূর্ণ গাণিতিকভাবে বৈধ certificate ইস্যু করে ফেলে — কারণ DigiNotar-এর root browser trust store-এ ছিল, আর private key attacker-এর হাতে চলে গিয়েছিল। এই ভুয়া certificate ইরানে man-in-the-middle attack-এ ব্যবহৃত হয় বলে ধারণা করা হয়। ফলাফল দ্রুত ও চূড়ান্ত: সব বড় browser vendor DigiNotar-এর root permanently distrust করে দেয়, DigiNotar মাস কয়েকের মধ্যে দেউলিয়া হয়ে যায়। শিক্ষা: chain-of-trust-এর নিরাপত্তা তার দুর্বলতম CA-র সমান — হাজারটা root trust store-এ থাকলে, একটা compromise পুরো model-কে প্রশ্নবিদ্ধ করে।

Certificate revocation — একটা প্রায়-অমীমাংসিত সমস্যা। একটা certificate ইস্যু হওয়ার পরে যদি তার private key leak হয়ে যায়, বা CA ভুল করে ভুল domain-কে ইস্যু করে ফেলে — সেই certificate-কে validity-শেষ হওয়ার আগেই বাতিল করার দরকার হয়। সমস্যাটা structural: leaf certificate ইস্যু হওয়ার পরে সেটা কোটি কোটি জায়গায় ছড়িয়ে যায়, কোনো কেন্দ্রীয় সার্ভার প্রতিটা browser-কে সরাসরি জানাতে পারে না “এটা এখন বাতিল।” CRL (Certificate Revocation List) আর OCSP (Online Certificate Status Protocol) — দুইটাই এই সমস্যার সমাধানের চেষ্টা, দুইটারই নিজস্ব trade-off (latency, privacy, fail-open বনাম fail-closed)। এই লেসনে শুধু বীজ বুনে রাখছি — revocation, OCSP stapling, certificate pinning, আর downgrade-attack — এই পুরো আলোচনা পরের লেসন, TLS-security-তে গভীরভাবে হবে।

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

“Digital signature দিয়ে message-টাও গোপন থেকে যায় — signature মানেই একধরনের encryption।”

সম্পূর্ণ উল্টো। Signature-এর উদ্দেশ্য confidentiality না, authenticity — message প্রায়ই সম্পূর্ণ plaintext, সবাই পড়তে পারে। sign করা মানে private key দিয়ে message-এর hash-এর ওপর একটা গাণিতিক ছাপ বসানো, যা যে কেউ public key দিয়ে যাচাই করতে পারে — message নিজে কখনো encrypt হয়নি। কেউ যদি গোপনও রাখতে চায় এবং sign-ও করতে চায়, দুইটা আলাদা অপারেশন — sign-then- encrypt বা encrypt-then-sign — আলাদাভাবে করতে হয়।

“Signature verify হয়ে গেলে বোঝা যায় message-টা সেই দাবিকৃত ব্যক্তির কাছ থেকেই এসেছে।”

“Hood” অংশের কেন্দ্রীয় শিক্ষা এটাই ভাঙে। Verify সত্যি ফেরত দিলে শুধু এটাই প্রমাণিত হয় — এই public key-র জোড়া private key-ধারী সাইন করেছে। সেই public key কার, তা signature নিজে বলে না — একজন attacker নিজের keypair বানিয়ে “আমি bank” লিখে সম্পূর্ণ বৈধভাবে সাইন করতে পারে। Identity-র প্রশ্নের উত্তর দেয় certificate আর chain-of-trust, signature একা না।

“Root CA যেহেতু সবচেয়ে গুরুত্বপূর্ণ, তার certificate-ও কোনো উঁচু authority সাইন করে দেয়।”

উল্টো — root CA-র certificate self-signed, কারণ চেইনটা কোথাও থামতে হবে, আর উঁচুতে আর কেউ নেই। বিশ্বাসের উৎস গাণিতিক প্রমাণ না — root CA-র public key সরাসরি OS/browser-এর trust store-এ pre-installed থাকে বলেই বিশ্বাসযোগ্য, একটা প্রশাসনিক/সামাজিক সিদ্ধান্ত (WebTrust audit, CA/Browser Forum membership) দিয়ে সেই তালিকায় ওঠা যায়।

“একটা certificate একবার বৈধভাবে ইস্যু হলে, তার validity period শেষ না হওয়া পর্যন্ত এটা নিরাপদ ধরে নেওয়া যায়।”

Validity period শুধু “কতদিনের জন্য ইস্যু করা হয়েছিল” বলে — private key compromise, ভুল domain-এ ইস্যু, বা CA-র নিজের compromise (DigiNotar-এর মতো) যেকোনো সময় ঘটতে পারে, তখন সেই certificate validity-শেষ হওয়ার অনেক আগেই বিপজ্জনক হয়ে যায়। সেই জন্যই revocation (CRL/OCSP) দরকার — যদিও এই লেসনের realworld অংশে দেখা গেছে, revocation নিজেই একটা কাঠামোগতভাবে কঠিন সমস্যা, পরের লেসনে যার গভীর আলোচনা হবে।

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

1

Digital signature তৈরি করতে কোন key ব্যবহার হয়, আর verify করতে কোনটা? এই বিভাজন encryption-এর তুলনায় কীভাবে উল্টো?

স্মরণ

Sign করা হয় private key দিয়ে, verify করা হয় public key দিয়ে। Encryption-এ এর উল্টো — encrypt করা হয় public key দিয়ে (যে কেউ পারে), decrypt করা হয় private key দিয়ে (শুধু মালিক পারে)। একই দুইটা key, একই গাণিতিক অপারেশন (RSA-তে m^e mod n আর m^d mod n), শুধু কে কোনটা কখন চালায় তা উল্টে গেছে।

2

Raw message সরাসরি sign না করে কেন প্রথমে hash করে, তারপর সেই hash sign করা হয়? দুইটা স্বতন্ত্র কারণ কী কী?

যুক্তি

প্রথম কারণ efficiency — signature operation-এর খরচ input-সাইজের সাথে বাড়ে, আর হ্যাশ করলে যেকোনো সাইজের message একটা fixed-size digest-এ নেমে আসে, তাই সাইনিং একবারই চালাতে হয়। দ্বিতীয়, বেশি গুরুত্বপূর্ণ কারণ algebraic forgery — raw RSA signing-এ sign(m1)*sign(m2) mod n = sign(m1*m2 mod n), তাই attacker দুইটা বৈধ signature গুণ করে তৃতীয় একটা বার্তার জন্য signature বানিয়ে ফেলতে পারে, private key না ছুঁয়েই। Hash function-এর avalanche property এই গুণিতক সম্পর্ক ধ্বংস করে দেয়, তাই hash-then-sign-এ এই আক্রমণ অচল।

3

Experiment-এ message-এর একটা অক্ষর বদলে (4471 → 9999) tampered বানানো হলো, কিন্তু signature অপরিবর্তিত রাখা হলো। Verify কেন মিথ্যা ফেরত দিল — কোন দুইটা সংখ্যা মেলেনি, আর কেন?

প্রয়োগ

recovered (signature-কে public key দিয়ে “decrypt” করে পাওয়া মান) আর expected (tampered message-এর independently recomputed hash) — এই দুইটা মেলেনি। recovered অপরিবর্তিত রইল কারণ signature-টা original hash থেকেই তৈরি, বদলায়নি; কিন্তু expected বদলে গেছে কারণ hash function-এর avalanche property অনুযায়ী একটা অক্ষর বদলালেই output প্রায় সম্পূর্ণ ভিন্ন হয়ে যায় — তাই তুলনা মিথ্যা ফেরত দেয়।

4

একজন attacker সম্পূর্ণ বৈধ RSA keypair বানিয়ে, “আমি bKash-support” দাবি করে একটা message sign করে পাঠাল। verify() সেই signature-এ true ফেরত দেবে। এটা কি signature scheme-এর কোনো bug — নাকি অন্য কিছু?

যুক্তি

Bug না — এটাই signature-এর প্রকৃত, সীমিত গ্যারান্টি। verify শুধু প্রমাণ করে “যে এই public key-র private key ধরে আছে, সে-ই সাইন করেছে” — সেই public key-টা সত্যিই bKash-support-এর কিনা, তা signature নিজে জানে না, জানার কথাও না। এই ফাঁকটাই certificate আর CA-র প্রয়োজনীয়তা তৈরি করে — একটা বিশ্বস্ত তৃতীয়-পক্ষকে public key-আর-identity-এর বাইন্ডিং সাইন করে দিতে হয়।

5

Build অংশে attacker leaf certificate-এর public key বদলে দিলেও root আর intermediate cert অক্ষত রাখল। browser_validate_chain-এর পাঁচটা ধাপের মধ্যে ঠিক কোনটায় এই আক্রমণ ধরা পড়ল, আর কেন বাকি চারটা পাস করে গেল?

প্রয়োগ

ধাপ ৫-এ (leaf cert intermediate-এর public key দিয়ে verify) ধরা পড়ল। বাকি চারটা পাস করল কারণ attacker root বা intermediate certificate কিছুই ছোঁয়নি — সেগুলোর signature এখনও তাদের নিজস্ব, অপরিবর্তিত data-র সাথে মেলে। কিন্তু leaf-এর pub field বদলানোয় সেই cert-এর “to-be-signed” bytes বদলে গেছে (hash বদলেছে), অথচ পুরনো signature (পুরনো hash-এর ওপর তৈরি) রয়ে গেছে — নতুন hash আর পুরনো signature মেলে না।

6

একটা internal microservices platform ডিজাইন করছেন — কয়েকশো internal service নিজেদের মধ্যে mTLS দিয়ে কথা বলবে, কিন্তু public internet-এর কোনো CA লাগবে না (সব traffic নিজেদের datacenter-এর ভিতরে)। কীভাবে নিজস্ব একটা mini-PKI বানাবেন — কয়টা স্তরের CA লাগবে, root private key কোথায় রাখবেন, আর leaf certificate-এর validity period কেমন ছোট রাখবেন এবং কেন?

ডিজাইন

নিজস্ব internal root CA বানাতে হবে (public trust store-এ থাকার দরকার নেই — শুধু নিজের সব service-এর trust store-এ internal root বসিয়ে দিলেই চলবে), তার নিচে অন্তত একটা intermediate CA যেটা দৈনন্দিন সাইনিং করবে; root private key সম্পূর্ণ offline রাখা উচিত (আদর্শভাবে HSM-এ), শুধু নতুন intermediate ইস্যু করার সময় বের করা হবে। Leaf certificate-এর validity period ইচ্ছাকৃতভাবে ছোট (দিন বা ঘণ্টার স্কেলে) রাখা ভালো, কারণ internal সিস্টেমে automated re-issuance সহজ, আর ছোট validity মানে কোনো key compromise হলেও exposure window সীমিত — revocation-এর কঠিন সমস্যার ওপর কম নির্ভর করতে হয়, কারণ পুরনো certificate এমনিতেই দ্রুত expire করবে।

এরপর কী

এরপর কী

আজ signature-এর গণিত (private key দিয়ে sign, public key দিয়ে verify, hash কেন আগে) আর সেই গণিত একা যা প্রমাণ করে না (identity) তার সমাধান — PKI, certificate, chain-of-trust — দুইটাই দেখা হলো। এখন আমাদের হাতে TLS যে সবগুলো cryptographic primitive দিয়ে বানানো তার প্রতিটাই আছে — hashing, symmetric encryption, key exchange, signature, আর certificate।

পরের লেসনে এই সবকিছু একসাথে কীভাবে মিলে আসল প্রোটোকল-এ রূপ নেয়, যেটা আজকের ওয়েবকে সুরক্ষিত রাখে — আর ঐতিহাসিকভাবে সেই মিলনটা ঠিক কোথায় কোথায় ভেঙে পড়েছে (BEAST, POODLE, Heartbleed, আর revocation-এর অমীমাংসিত সমস্যা) — তা দেখব: security/tls-security।

আরও পড়ুন