</>Học Dev
Bài học

Tuần 2 - Ngày 10: MFA & Passkeys

Tuần 2 – Ngày 10

Mục tiêu học tập

  • Hiểu vì sao password đơn lẻ không còn đủ trong threat landscape 2024
  • Cách hoạt động của TOTP (RFC 6238) — Google Authenticator, Authy
  • Vì sao SMS OTP yếu và khi nào còn chấp nhận được
  • WebAuthn / Passkeys — public-key crypto thay thế password, chống phishing hoàn toàn
  • Recovery codes và backup methods cho khi user mất device

1. Vì sao password không đủ

Threat landscape 2024

ThreatCách hoạt độngPassword defense
PhishingSite giả g00gle.com lừa user nhập passwordYếu — user dễ bị lừa
Credential stuffingDùng password leak từ site khácYếu — 60-80% user reuse
Brute forceThử password phổ biếnMạnh nếu password phức tạp
Database breachHash password leak → crack offlinePhụ thuộc hashing algo
Keylogger / malwareGhi keystrokeYếu hoàn toàn
Shoulder surfingNhìn lén khi gõYếu
Social engineeringLừa user/support reset passwordPhụ thuộc process

Số liệu (Microsoft, Verizon DBIR 2024):

  • ~99.9% account compromise có thể chặn được nếu enable MFA
  • Credential stuffing chiếm 40%+ breach pattern
  • Phishing là vector #1 cho initial access

Các "factor" của authentication

SomethingyouSomethingyouSomethingyouKNOWHAVEARE-password-phone(SMS)-fingerprint-PIN-TOTPapp-face-securityQ-hardwarekey-voice

Multi-Factor Authentication (MFA) = ≥ 2 factor khác loại. Password + PIN = 1 factor (cùng "know"). Password + phone OTP = 2 factor.


2. TOTP — Time-based One-Time Password

Chuẩn

RFC 6238 — TOTP, dựa trên RFC 4226 — HOTP (counter-based).

TOTP(secret, time) = HOTP(secret, floor(time / 30))

HOTP(K, C) = Truncate(HMAC-SHA1(K, C))
  • secret: random 160-bit (20 bytes), share giữa server và user device khi enroll
  • time: Unix timestamp tính bằng giây
  • Time step: 30 giây (standard)
  • Output: 6 chữ số

Flow enrollment

UserServerTOTPAppEnableMFAGenerate20-byterandomsecret="JBSWY3DPEHPK3PXP"QRcode:otpauth://totp/App:user@email.com?secret=JBSWY3DPEHPK3PXP&issuer=AppScanQR(manualentryalternative)StoresecretinkeychainEntercurrent6-digitcodetoconfirmPOST/verify-mfa{code}TOTP(secret,now)==code?LưusecretvàoDBcauserSuccess

TOTP URI format (otpauth://)

otpauth://totp/MyApp:alice@example.com
   ?secret=JBSWY3DPEHPK3PXP
   &issuer=MyApp
   &algorithm=SHA1
   &digits=6
   &period=30

App scan QR → tự lưu, tự generate code mỗi 30s.

Code Node.js với otplib

// npm install otplib qrcode
import { authenticator } from "otplib";
import QRCode from "qrcode";

// 1. Enroll: generate secret cho user
function setupMFA(user) {
  const secret = authenticator.generateSecret();   // base32, 32 chars
  
  // Lưu encrypted vào DB
  const uri = authenticator.keyuri(
    user.email,           // account name
    "MyApp",              // issuer
    secret
  );
  
  return { secret, uri };   // user scan uri → QR code
}

// 2. Render QR
const qrDataUrl = await QRCode.toDataURL(uri);

// 3. Verify code
function verifyMFA(user, userCode) {
  const secret = decrypt(user.mfa_secret);
  
  // window=1 cho phép ±30s clock skew
  return authenticator.verify({ token: userCode, secret });
}

// 4. Generate code (chỉ dùng để test, app làm phía user)
const currentCode = authenticator.generate(secret);
console.log(currentCode);   // "523891"

Python với pyotp

# pip install pyotp qrcode
import pyotp
import qrcode

# Enroll
secret = pyotp.random_base32()   # "JBSWY3DPEHPK3PXP..."
uri = pyotp.totp.TOTP(secret).provisioning_uri(
    name="alice@example.com",
    issuer_name="MyApp"
)

# Generate QR
img = qrcode.make(uri)
img.save("qr.png")

# Verify
totp = pyotp.TOTP(secret)
print(totp.now())              # "523891"
print(totp.verify("523891"))   # True nếu trong window 30s

TOTP pitfalls

  • Clock skew: server và device không sync → verify thường accept ±1 window (±30s)
  • Replay: same code có thể dùng lại trong 30s window → lưu last-used code, từ chối nếu match
  • Secret lưu plaintext: phải encrypt at rest, dùng KMS
  • QR code leak: nếu user screenshot QR và lưu cloud, attacker chiếm cloud lấy được → enrollment không phải one-shot, có thể regenerate

3. SMS OTP — Tại sao yếu?

Cách hoạt động

Server → Twilio/AWS SNS → SMS gateway → carrier → user phone
                ↑                          ↑
        có nhiều mắt xích           có thể intercept

Threats cụ thể

1. SIM swap (SIM hijacking)

Attacker gọi telco của victim:
  "Hi, tôi mất phone, port số XXX sang SIM mới của tôi"
  → Social engineering support → port thành công
  → Mọi SMS đi vào SIM của attacker
  → Attacker nhận OTP, reset password

Đã xảy ra với nhiều người nổi tiếng (Jack Dorsey, crypto whales mất hàng triệu USD).

2. SS7 protocol attack

  • SS7 là protocol cũ kết nối các carrier toàn cầu
  • Attacker truy cập SS7 (mua trên dark web hoặc qua corrupt telco) → intercept SMS bất kỳ số nào toàn cầu
  • Đã được chứng minh thực tế nhiều lần

3. SMS phishing (smishing)

  • "Bank của bạn: gửi OTP 123456 cho support để verify"
  • User gửi OTP cho attacker

4. Telco insider

  • Nhân viên telco có thể nhìn nội dung SMS

Khi nào SMS OTP còn chấp nhận?

Use caseSMS OTP OK?Lý do
Low-value consumer appOKChi phí breach thấp, UX dễ
Bank, crypto exchangeKHÔNGHigh-value target, SIM swap nghiêm trọng
HealthcareKHÔNGHIPAA, dữ liệu nhạy cảm
Internal corporateKHÔNGPhishing/social engineering vector
Fallback khi user mất TOTP/passkeyOK với giới hạnBetter than nothing, nên thêm friction (waiting period)

NIST 800-63B (2017) đã deprecate SMS cho high-assurance. Nhiều app (Google, Microsoft) cho phép disable SMS hoàn toàn.

So sánh MFA methods

MethodPhishing-resistantSIM swapUX
SMS OTPKhôngVulnerableDễ
Email OTPKhôngOK (nếu email an toàn)Dễ
TOTP appKhông (vẫn enter code)An toànTrung bình (cần app)
Push notificationKhông (push fatigue)An toànDễ
Hardware key (FIDO2)An toànCần đem theo
Passkey (WebAuthn)An toànDễ (built-in OS)

4. WebAuthn / Passkeys

Vấn đề mọi MFA "code" gặp phải

User vào g00gle.com (phishing site):
  1. Nhập username + password → attacker capture
  2. Site giả forward username/password tới google.com thật
  3. Google gửi MFA challenge → user nhập TOTP code
  4. User type 6 chữ số vào g00gle.com → attacker capture
  5. Attacker dùng TOTP code trong 30s window → login thành công

TOTP, SMS, email OTP đều không chống phishing vì user "type code" vào bất kỳ site nào.

WebAuthn / FIDO2 nguyên lý

Public-key cryptography:
  - Khi đăng ký: device sinh keypair (private + public)
  - Server lưu PUBLIC key, gắn với origin (https://example.com)
  - Private key NEVER LEAVE device

Authentication:
  - Server gửi challenge (random bytes)
  - Device sign challenge với PRIVATE key
  - GẮN VỚI ORIGIN — browser chỉ cho dùng key với đúng domain
  - Server verify signature với PUBLIC key

Phishing impossible: nếu user đến g00gle.com, browser sẽ không cho dùng key của google.com → không có gì để type/leak.

Passkey vs Hardware Security Key

PasskeyHardwareSecurityKey(YubiKey,Titan)-Lưutrongdevice-Vtlýriêngbit(iCloudKeychain,-USB/NFC/BluetoothGooglePasswordMgr,-KhôngsyncWindowsHello)-Bn,chngmalware-Syncquacloud-Cnđemtheo-UXrttt-Caocp(enterprise)-TouchID/FaceID
PasskeyHardware Key
Phishing-resistant
Sync giữa devicesCó (qua iCloud/Google)Không (cần multiple keys)
Lost device recoveryPhục hồi từ cloudCần backup key
UXExcellentTốt nhưng cần plug-in
CostFree$25-70 mỗi key
Best forConsumerEnterprise, high-value account

Flow registration (WebAuthn)

// 1. Server tạo challenge
const challenge = crypto.randomBytes(32);
// Lưu challenge gắn với session

// 2. Client browser
const credential = await navigator.credentials.create({
  publicKey: {
    challenge: challenge,
    rp: { name: "MyApp", id: "example.com" },
    user: {
      id: Uint8Array.from(userId, c => c.charCodeAt(0)),
      name: "alice@example.com",
      displayName: "Alice",
    },
    pubKeyCredParams: [
      { alg: -7,  type: "public-key" },   // ES256
      { alg: -257, type: "public-key" },  // RS256
    ],
    authenticatorSelection: {
      authenticatorAttachment: "platform",   // built-in (TouchID, etc.)
      userVerification: "required",
      residentKey: "required",               // discoverable cho passkey
    },
    attestation: "none",
  },
});

// 3. Send credential.id, credential.publicKey, clientDataJSON to server
// 4. Server verify, lưu publicKey gắn với user

Flow authentication

// 1. Server tạo challenge
// 2. Client
const assertion = await navigator.credentials.get({
  publicKey: {
    challenge: challenge,
    rpId: "example.com",
    userVerification: "required",
  },
});

// 3. Send signature to server
// 4. Server verify signature với stored publicKey
//    Verify rpIdHash, challenge, origin, signature

Trong thực tế dùng thư viện như SimpleWebAuthn:

// npm install @simplewebauthn/server @simplewebauthn/browser
import { generateRegistrationOptions, verifyRegistrationResponse } from "@simplewebauthn/server";

// Server side
const options = await generateRegistrationOptions({
  rpName: "MyApp",
  rpID: "example.com",
  userID: Buffer.from(user.id),
  userName: user.email,
  attestationType: "none",
  authenticatorSelection: {
    residentKey: "required",
    userVerification: "required",
  },
});

// Browser side
import { startRegistration } from "@simplewebauthn/browser";
const attestation = await startRegistration(options);

// Server verify
const verification = await verifyRegistrationResponse({
  response: attestation,
  expectedChallenge: storedChallenge,
  expectedOrigin: "https://example.com",
  expectedRPID: "example.com",
});

if (verification.verified) {
  // Lưu verification.registrationInfo.credentialPublicKey, credentialID
}

5. Recovery Codes & Backup

User mất phone / hardware key → cần recovery, không thì lock account vĩnh viễn.

Pattern recovery codes

Khi enable MFA, server generate 10 codes one-time:
  4f7a-9b2c
  3d8e-1c5f
  9a2b-7e4d
  ... 7 codes nữa

User được yêu cầu:
  - Lưu offline (print, password manager)
  - Mỗi code dùng được 1 lần
  - Dùng để bypass MFA khi cần

Implementation:

function generateRecoveryCodes(count = 10) {
  return Array.from({ length: count }, () => {
    const bytes = crypto.randomBytes(4);
    return bytes.toString("hex").slice(0, 8);
  });
}

// Lưu HASH của recovery codes (như password)
const hashes = await Promise.all(
  codes.map(c => bcrypt.hash(c, 12))
);
// DB.user.recovery_code_hashes = hashes

// Verify
async function verifyRecoveryCode(user, code) {
  for (let i = 0; i < user.recovery_code_hashes.length; i++) {
    const hash = user.recovery_code_hashes[i];
    if (hash && await bcrypt.compare(code, hash)) {
      user.recovery_code_hashes[i] = null;   // invalidate
      await user.save();
      return true;
    }
  }
  return false;
}

Multiple methods

User profile:
  ✓ Password (always)
  ✓ Passkey 1: "iPhone 15"     (primary)
  ✓ Passkey 2: "Macbook Air"   (backup)
  ✓ TOTP: "Google Authenticator"  (backup)
  ✓ Recovery codes: 8/10 còn lại
  ✗ SMS (disabled by user)

Best practice: cho phép user enroll nhiều passkey trên nhiều device → mất 1 device vẫn login được.

Recovery flow đặt câu hỏi

Recovery methodAn toàn?
Email reset linkOK nếu email có MFA
SMS reset linkYếu (SIM swap)
Security questionsRất yếu (đoán/Google ra)
Backup codesTốt
Identity verification (passport upload)Tốt nhưng manual
Trusted contactOK cho consumer (Apple ID, Google)

6. Câu hỏi ôn tập

  1. Tại sao password + TOTP vẫn không chống được phishing 100%?

    Xem đáp án

    Phishing site có thể proxy real-time: user nhập password trên g00gle.com, site forward đến google.com thật → google.com prompts MFA → user nhập TOTP code trên g00gle.com → site forward code trong window 30s → đăng nhập thành công. TOTP code là "shared secret tạm thời" — bất kỳ ai có code đều dùng được. Chỉ WebAuthn/Passkey (gắn key với origin) mới chống được phishing tuyệt đối.

  2. SMS OTP có ít nhất 3 lỗ hổng chính, kể ra và giải thích?

    Xem đáp án

    (1) SIM swap: attacker social-engineer telco port số sang SIM mới → nhận mọi SMS của victim → bypass MFA. (2) SS7 attack: protocol giữa các carrier không có authentication mạnh → attacker với SS7 access có thể intercept SMS bất kỳ số toàn cầu. (3) Smishing: user bị lừa gửi OTP qua phone/SMS cho fake support. (4) Bonus: SMS không encrypted end-to-end, nhân viên telco và trên đường truyền có thể đọc. NIST 800-63B đã deprecate SMS cho high-assurance.

  3. WebAuthn dùng nguyên lý gì để chống phishing, và "origin binding" nghĩa là gì?

    Xem đáp án

    WebAuthn dùng public-key cryptography: device sinh keypair khi đăng ký, private key never leaves device. Khi authenticate, device sign challenge bằng private key. Origin binding: browser chỉ cho phép dùng key với đúng rpId (domain) đã đăng ký. Nếu user đến g00gle.com (phishing), browser thấy origin không match → không cho dùng key của google.com → user không có gì để "type" hay leak → phishing impossible. Khác với TOTP nơi user gõ code vào bất kỳ site nào.

  4. Khác nhau giữa Passkey và hardware security key (YubiKey)?

    Xem đáp án

    Passkey lưu trong device (iCloud Keychain, Google Password Manager, Windows Hello), sync qua cloud giữa các device của user → mất phone vẫn login được trên laptop. Authenticate qua Touch ID/Face ID. Hardware key là thiết bị vật lý riêng (USB/NFC), không sync, phải cắm/chạm để dùng. Hardware key bền hơn, chống malware tốt hơn, không phụ thuộc cloud provider — phù hợp enterprise high-security. Passkey UX tốt hơn cho consumer, free. Cả hai đều phishing-resistant (đều là FIDO2/WebAuthn).

  5. Tại sao recovery codes nên hash như password, không lưu plaintext?

    Xem đáp án

    Recovery codes có cùng giá trị như password thứ hai — biết code = bypass MFA. Nếu DB breach và codes lưu plaintext, attacker có thể bypass MFA của tất cả user. Hash bằng bcrypt/argon2 giống password chính. Lưu ý: recovery codes ngắn (8-12 ký tự hex, entropy ~32-48 bit) nên brute force nhanh hơn password → vẫn cần hashing với work factor. Một số implementation dùng denylist: lưu list null cho codes đã used thay vì xóa entry để đếm số codes còn lại.


Bài tập thực hành

# 1. TOTP với Node.js + otplib
mkdir mfa-lab && cd mfa-lab
npm init -y
npm install otplib qrcode

cat > totp.mjs << 'EOF'
import { authenticator } from "otplib";
import QRCode from "qrcode";

const secret = authenticator.generateSecret();
console.log("Secret:", secret);

const uri = authenticator.keyuri("alice@example.com", "MyApp", secret);
console.log("URI:", uri);

// Print QR ASCII
console.log(await QRCode.toString(uri, { type: "terminal" }));

// Generate current code
console.log("Current code:", authenticator.generate(secret));

// Verify (paste code từ Google Authenticator app)
import readline from "readline";
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
rl.question("Enter code from app: ", (token) => {
  console.log("Valid:", authenticator.verify({ token, secret }));
  rl.close();
});
EOF

node totp.mjs
# - Mở Google Authenticator/Authy
# - Scan QR code in terminal
# - Nhập 6-digit code, verify

# 2. Passkey demo với SimpleWebAuthn
# Clone https://github.com/MasterKale/SimpleWebAuthn/tree/master/example
# Chạy example, đăng ký passkey với TouchID/Face ID

# 3. Quan sát passkey trong:
# - macOS Keychain (Passwords app)
# - iCloud Keychain settings → Passwords
# - chrome://settings/passwords

# 4. Test phishing-resistance:
# - Đăng ký passkey trên localhost
# - Thử truy cập http://127.0.0.1 (khác origin) → passkey không hoạt động
# - Quan sát browser từ chối dùng key sai origin

Tài liệu tham khảo chính thức


Bài tiếp theo →