Thursday, November 3, 2016

Bài 33: Mã hóa dữ liệu

Một trong những phương pháp bảo mật tốt là mã hóa dữ liệu. Dữ liệu mà mọi dịch vụ web đều phải tính chuyện mã hóa là mật khẩu người dùng. Tiếp theo là các thông tin quan trọng khi trao đổi trên môi trường mạng. Trong bài này chúng ta sẽ tìm hiểu về các loại mã hóa 1 chiều và mã hóa 2 chiều.


Mã hóa một chiều thường được biết dưới tên hàm băm (hashing function). Đó là kiểu mã hóa nhận một chuỗi chiều dài bất kỳ và cho ra chuỗi mã hóa có kích thước cố định. Đặc điểm thú vị của nó là không có cách gì để quay lại chuỗi gốc. Hàm băm phù hợp với mã hóa mật khẩu trước khi lưu trữ. Nó cũng được sử dụng để kiểm tra tính toàn vẹn của dữ liệu truyền nhờ tính năng chỉ một thay đổi nhỏ trong dữ liệu thì chuỗi mã hóa sẽ khác nhau. Hàm băm phổ biến là MD5 và SHA.

Mã hóa hai chiều tức là có thể giải mã về lại chuỗi gốc từ chuỗi đã mã hóa. Mã hóa hai chiều phù hợp cho việc mã hóa dữ liệu tránh bị người khác đọc được và tạo chữ ký số. Có hai loại mã hóa loại này là mã hóa đối xứng và mã hóa bất đối xứng:
- Mã hóa đối xứng là kiểu mã hóa mà khóa dùng để mã hóa và giải mã là một. Đây là kiểu mã hóa thông thường. Điểm dở của nó là 2 bên mã hóa và giải mã cần có cùng 1 khóa nhưng việc trao đổi khóa này có thể bị lộ ảnh hưởng đến sự an toàn của dữ liệu cần bảo mật. Mã hóa nổi tiếng nhất loại này có lẽ là Ceasar nhưng nó cũng rất dễ phá. Các thuật toán mã hóa đối xứng phổ biến là DES, 3DES, AES, v.v...
- Mã hóa bất đối xứng hay còn gọi là mã hóa khóa công khai là mã hóa gồm hai khóa: khóa công khai đưa cho bên mã hóa, ai biết cũng được, còn khóa bí mật người nhận giữ để giải mã nội dung. Điểm mạnh của mã hóa bất đối xứng là tính bảo mật vốn đảm bảo khóa bí mật được giữ kín. Điểm yếu của nó là việc mã hóa và giải mã khá phức tạp. Các thuật toán mã hóa bất đối xứng nổi tiếng là RSA, DSA, PGP, v.v.. 

Go cung cấp package crypto cung cấp các struct và hằng số chung cho việc mã hóa. Các package con của crypto cung cấp chức năng mã hóa cho hầu hết các loại mã hóa phổ biến như MD5, SHA1, SHA256, SHA512, HMAC, DES, AES, RSA, DSA, v.v...

Mã hóa mật khẩu


Thường mật khẩu được mã hóa với hàm băm như MD5 hay SHA256 vì chúng ta không có nhu cầu giải lại mật khẩu. Khi cần so sánh mật khẩu người dùng gửi lên, chúng ta tiến hành mã hóa nó rồi so sánh với giá trị đã lưu. 
// import "crypto/sha256"
h := sha256.New()
pw := "asdfghjkl"
io.WriteString(h, pw)
pwsha256 := fmt.Sprintf("%x", h.Sum(nil))
fmt.Println(pwsha256)
// 5c80565db6f29da0b01aa12522c37b32f121cbe47a861ef7f006cb22922dffa1
// import "crypto/sha1"
h := sha1.New()
pw := "qwertyuiop"
io.WriteString(h, pw)
pwsh1 := fmt.Sprintf("%x", h.Sum(nil))
fmt.Println(pwsha1) // 5fa339bbbb1eeaced3b52e54f44576aaf0d77d96
// import "crypto/md5"
h := md5.New()
pw := "qwertyuiop"
io.WriteString(h, pw)
pwmd5 := fmt.Sprintf("%x", h.Sum(nil))
fmt.Println(pwmd5) // c44a471bd78cc6c2fea32b9fe028d30a
Mật khẩu được mã hóa bởi hàm băm thì không cách nào giải mã được nhưng có cách để xác định mật khẩu gốc đó là sử dụng một bảng gồm các chuỗi mã hóa của các loại mật khẩu phổ biến để tìm xem chuỗi mã hóa có thuộc trong đó không. Nếu có sẽ xác định được chuỗi gốc. Do đó nếu cơ sở dữ liệu bị tấn công, hacker chiếm được thông tin mật khẩu thì có thể lần ra mật khẩu gốc của các tài khoản. Việc này khá nguy hiểm vì thường người dùng sử dụng cùng mật khẩu hoặc thay đổi chút ít ở nhiều dịch vụ khác nhau. Để giải quyết vấn đề này chúng ta có thể sử dụng thủ thuật nhỏ bổ sung thêm giá trị vào mật khẩu người dùng (phương pháp salting) để tạo thành mật khẩu mới trước khi mã hóa khiến cho quá trình giải mã khó khăn hơn và nếu có cũng khó lần ra mật khẩu gốc là gì:
// import "crypto/md5"
h := md5.New()
username := "abc"
password := "zxcvbnm"
io.WriteString(h, password)
pwmd5 := fmt.Sprintf("%x", h.Sum(nil))
fmt.Println(pwmd5) //Kết quả: 02c75fb22c75b23dc963c7eb91a062cc

// Bổ sung 2 thành phần:
salt1 := "@#$%"
salt2 := "^&*()"

//Xóa nội dung chuỗi băm
h.Reset()

// salt1 + username + salt2 + PWMD5
io.WriteString(h, salt1)
io.WriteString(h, username)
io.WriteString(h, salt2)
io.WriteString(h, pwmd5)
finalpw := fmt.Sprintf("%x", h.Sum(nil))
fmt.Println(finalpw) //Kết quả: a58ac84c53eb80cff5169cdc0ca1a32f

Mã hóa Base64


Base64 là thuật toán mã hóa đối xứng thường dùng để chuyển các dữ liệu nhị phân (hình ảnh, âm thanh) thành chuỗi ký tự để thuận tiện trong việc trao đổi và lưu trữ trong môi trường web. 64 trong base64 là số lượng ký tự sử dụng trong bảng mã này gồm A-Z, a-z, 0-9, dấu + và dấu /. Nguyên tắc mã hóa của nó khá đơn giản:
- Chuyển chuỗi cần mã hóa thành chuỗi bit.
- Tách mỗi 6 bit thành nhóm.
- So sánh giá trị mỗi nhóm đó với bảng mã Base64 cho trước (A-Z ứng với 0-25, a-z ứng với 26-51, 0-9 ứng với 52-61, + ứng với 62 và / ứng với 63)

Để ý chúng ta sẽ thấy là 3 byte (3x8bit=24 bit) dữ liệu sẽ được mã hóa thành  4 ký tự base64 (24 bit = 4x6bit). Do đó chuỗi sẽ gom nhóm 3 byte xử lý và nếu nhóm cuối không đủ 3 byte thì sẽ bổ sung thêm 1 hoặc 2 dấu = cho đủ, gọi là padding. Việc thêm hay không thêm phần padding không có nhiều ý nghĩa nếu chỉ xử lý và trao đổi trên 1 chuỗi. Nếu có nhiều hơn 1 chuỗi base64 nối vào nhau thì padding này sẽ giúp tách chuỗi con ra để giải mã.

Go cung cấp chức năng mã hóa và giải mã base64 với package base64 hỗ trợ mã hóa và giải mã base64 cho 2 loại chuẩn và phù hợp URL (thay + bằng -, thay / bằng _, không padding):
// import "encoding/base64"
rawstr := "Chào thế giới Go!"
// Base64 chuẩn
stdBase64 := base64.StdEncoding.EncodeToString([]byte(rawstr))
fmt.Println(stdBase64)
// decode
deStd, err := base64.StdEncoding.DecodeString(stdBase64)
if err != nil {
    fmt.Println(err.Error())
}
fmt.Println(string(deStd))
// Base64 phù hợp URL, Raw sẽ loại bỏ padding
urlBase64 := base64.RawURLEncoding.EncodeToString([]byte(rawstr))
fmt.Println(urlBase64)
// decode
deURL, err := base64.RawURLEncoding.DecodeString(urlBase64)
if err != nil {
    fmt.Println(err.Error())
}
fmt.Println(string(deURL))
Kết quả xuất ra màn hình:
Q2jDoG8gdGjhur8gZ2nhu5tpIEdvIQ==
Chào thế giới Go!
Q2jDoG8gdGjhur8gZ2nhu5tpIEdvIQ
Chào thế giới Go!


Mã hóa đối xứng


DES là chuẩn mã hóa đối xứng trước đây nhưng nay đã được xác nhận là có thể phá được. 3DES bảo mật tốt hơn nhưng do kế thừa từ DES nên cũng có khả năng bị phá. AES cho đến hiện tại vẫn an toàn. Bây giờ chúng ta sẽ tìm hiểu về cách sử dụng AES trong Go. DES và 3DES cũng sử dụng tương tự với package crypto/des.

AES (viết tắt của từ tiếng Anh: Advanced Encryption Standard) là một thuật toán mã hóa khối được chính phủ Hoa kỳ áp dụng làm tiêu chuẩn mã hóa. AES được chấp thuận làm tiêu chuẩn liên bang bởi Viện tiêu chuẩn và công nghệ quốc gia Hoa kỳ (NIST) sau một quá trình tiêu chuẩn hóa kéo dài 5 năm. Giống như tiêu chuẩn tiền nhiệm DES, AES được kỳ vọng áp dụng trên phạm vi thế giới và đã được nghiên cứu rất kỹ lưỡng. Go hỗ trợ mã hóa AES với package crypto/aes 

Do AES là mã hóa đối xứng nên để mã hóa và giải mã được 2 bên cần thống nhất với nhau khóa hoặc bên mã hóa gửi khóa cho bên giải mã. Khóa này cần có độ dài 16, 24 hay 32 byte, thuật toán tương ứng sẽ là AES-128, AES-192, or AES-256. Giả sử A muốn gửi cho B thông điệp "I love You!" và họ thống nhất khóa là "ABBAabbaBAABbaab". Nội dung cần gửi và cách giải mã sẽ được thể hiện như bên dưới:
- Đầu tiên là khai báo package sử dụng:
import (
    "crypto/aes"
    "crypto/cipher"
    "fmt"
    "log"
)
Do AES mã hóa khối nên chuỗi cần mã hóa sẽ được chia thành các nhóm nhỏ (khối) để mã hóa với AES. Muốn gộp các khối thành chuỗi mã hóa cần áp dụng một kiểu mã hóa khối như CFB, OFB hay CTR, ... Package crypto/cipher cung cấp các hàm phục vụ việc này.
- Khai báo chuỗi IV:
var commonIV = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}
IV, viết tắt của initialization vector, là chuỗi dùng để thêm vào mã hóa cho khối đầu tiên. Nguyên tắt mã hóa khối thường là chuỗi mã hóa khối trước sẽ làm IV cho việc mã hóa khối sau, còn commonIV khai báo ở trên để phục vụ cho việc mã hóa khối đầu tiên. Chuỗi IV này không cần bí mật nhưng nó đòi hỏi phải khác nhau ở các lần mã hóa nếu dùng cùng khóa bởi nếu có 2 chuỗi cần mã hóa mà có nội dung khối đầu giống nhau thì chuỗi mã hóa khối đầu sẽ như nhau nếu cùng IV. Dựa trên cơ sở này hacker có thể lần ra khóa và chuỗi gốc. Chuỗi IV này cũng cần cho quá trình giải mã mà chúng ta sẽ thấy bên dưới.
- Thông điện cần chuyển và khóa:
plaintext := []byte("I love You")
keytext := "ABBAabbaBAABbaab"
- Tạo đối tượng thực hiện mã hóa khối AES:
c, err := aes.NewCipher([]byte(keytext))
if err != nil {
    log.Fatalf("Lỗi %s", err.Error())
}
- Mã hóa thông điệp:
cfb := cipher.NewCFBEncrypter(c, commonIV)
ciphertext := make([]byte, len(plaintext))
cfb.XORKeyStream(ciphertext, plaintext)
fmt.Printf("%s=>%x\n", plaintext, ciphertext)
Ở đây tôi dùng kiểu mã hóa CFB. Bạn có thể dùng kiểu khác do ciper cung cấp. Chuỗi mã hóa được tạo bằng cách XOR từng byte khối mã hóa với chuỗi cần mã hóa qua hàm XORKeyStream.
- Kết quả in ra màn hình:
I love You=>bc32f9f3d055628994fa
- Giải mã thông điệp:
cfbdec := cipher.NewCFBDecrypter(c, commonIV)
plaintextCopy := make([]byte, len(plaintext))
cfbdec.XORKeyStream(plaintextCopy, ciphertext)
fmt.Printf("%x=>%s\n", ciphertext, plaintextCopy)
Ở đây tận dụng biến c mã hóa AES có sẵn ở trên. Nếu làm hàm riêng bạn cần tạo ra nó từ khóa và sử dụng IV cho việc giải mã từ chuỗi mã hóa.
- Kết quả in ra màn hình:
bc32f9f3d055628994fa=>I love You


Mã hóa RSA


RSA viết tắt từ tên 3 tác giả là Ron Rivest, Adi Shamir và Len Adleman. RSA là thuật toán bất đối xứng được sử dụng rộng rãi bởi nó đáp ứng những tiêu chuẩn của một loại mã hóa hiện đại: mất hàng trăm năm để phá mã và xử lý nhanh. RSA còn được sử dụng để làm chữ ký số.

Giả sử A muốn gửi thông điệp "Anh yêu em!" cho B sau khi mã hóa RSA. Quá trình tạo khóa, mã hóa và giải mã được thể hiện bằng ngôn ngữ Go như bên dưới:
- Khai báo package sử dụng:
import (
    "crypto/rand"
    "crypto/rsa"
    "fmt"
    "log"
)
- Tạo khóa bí mật và khóa công khai cho B:
bPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
    log.Fatalf("Error: %s", err.Error())
}
bPublicKey := &bPrivateKey.PublicKey

fmt.Println("Khóa mật: ", bPrivateKey)
fmt.Println("Khóa công khai: ", bPublicKey)
B gửi khóa công khai cho A và giữ lại khóa bí mật để giải mã thông điệp A gửi.
- A mã hóa thông điệp "Anh yêu em!": nhớ import thêm "crypto" và "crypto/sha256" 
aMessage := []byte("Anh yêu em!")
label := []byte("")

ciphertext, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, bPublicKey, aMessage, label)
if err != nil {
    log.Fatalf("Error: %s", err.Error())
}
fmt.Printf("Mã hóa OAEP [%s] thành \n[%x]\n", string(aMessage), ciphertext)
OAEP là kiểu mã hóa nên xài cho RSA vì nó bảo mật tốt hơn PKCS1:
func EncryptOAEP(hash hash.Hash, random io.Reader, pub *PublicKey, msg []byte, label []byte) ([]byte, error)
func EncryptPKCS1v15(rand io.Reader, pub *PublicKey, msg []byte) ([]byte, error)
Hai tham số đầu của EncryptOAEP là giá trị băm và random, nên dùng trực tiếp như ở trên. Tham số thứ 3 là khóa công khai B gửi cho. Tham số thứ 4 là chuỗi cần mã hóa. Tham số cuối là chuỗi padding giúp tăng cường mã hóa, một số nơi dùng nil cũng được. 
Kết quả mã hóa:
Mã hóa OAEP [Anh yêu em!] thành
[21bb65777b1688f845123529882584d5e975026aecc356a7f332492752099918ffa8b5cf8abfccffe0415d170d68ea411499610ca61a109e51763e4801d5e46ce307f7af3745b9afc97df3954a3166c43dea6136919509b5dc9cb72ae93b5a05e10c3527ecdcb89148e91c222f9aedc4c67e23a7ce1a1ef2279ed06cacc46e28610cf25e89c4dfbf4edcfd852f1e25fee582a97d40a2e868ee7b20a525ff8b890718cfe92d895241f8b92c1d89ee216e324119b3698da6d02bec6a3e14a483a02f9d9dfa3a621156ce8df9f84520d6f562853cf41bff976fbbb840c92851478b967c558c66325f7f6d3e26e1df67d6d6f3f19e1fbd2b9e352d90cd4f3fe53f45]
- B nhận chuỗi mã hóa và giải mã:
messageFromA, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, bPrivateKey, ciphertext, label)
if err != nil {
    log.Fatalf("Error: %s", err.Error())
}
fmt.Printf("Giải mã OAEP [%x] được \n[%s]\n", ciphertext, messageFromA)
Kết quả giải mã:
Giải mã OAEP [21bb65777b1688f845123529882584d5e975026aecc356a7f332492752099918ffa8b5cf8abfccffe0415d170d68ea411499610ca61a109e51763e4801d5e46ce307f7af3745b9afc97df3954a3166c43dea6136919509b5dc9cb72ae93b5a05e10c3527ecdcb89148e91c222f9aedc4c67e23a7ce1a1ef2279ed06cacc46e28610cf25e89c4dfbf4edcfd852f1e25fee582a97d40a2e868ee7b20a525ff8b890718cfe92d895241f8b92c1d89ee216e324119b3698da6d02bec6a3e14a483a02f9d9dfa3a621156ce8df9f84520d6f562853cf41bff976fbbb840c92851478b967c558c66325f7f6d3e26e1df67d6d6f3f19e1fbd2b9e352d90cd4f3fe53f45] được
[Anh yêu em!]

RSA còn dùng để tạo chữ ký số khẳng định bản thân người gửi. Ví dụ ở trên, A muốn chứng minh đúng mình gửi chuỗi mã hóa, có thể gửi kèm chữ ký được tạo ra như sau:
var opts rsa.PSSOptions
opts.SaltLength = rsa.PSSSaltLengthAuto
hashString := sha256.Sum256(aMessage)
aSignature, err := rsa.SignPSS(rand.Reader, aPrivateKey, crypto.SHA256, hashString[:], &opts)
if err != nil {
    log.Fatalf("Lỗi: %s", err.Error())
}
fmt.Printf("Chữ ký: %x\n", aSignature)
Ở đây dùng phương pháp tạo chữ ký RSASSA-PSS qua hàm:
func SignPSS(rand io.Reader, priv *PrivateKey, hash crypto.Hash, hashed []byte, opts *PSSOptions) ([]byte, error)
Tham số đầu tạo giá trị random như ở phần mã hóa. Tham số thứ 2 là khóa bí mật của A (ở trên tôi chỉ tạo khóa cho B, khóa cho A các bạn làm tương tự). Tham số thứ ba là loại mã hóa dùng để băm và tham số thứ 4 là giá trị băm tương ứng của thông điệp cần chuyển.  Ở đây dùng SHA256. Tham số cuối cùng là cấu trúc tạo thêm tùy chọn cho việc gia tăng bảo mật cho chữ ký. Chữ ký của A lúc này là:
Chữ ký: 4ccff62ccd0fa7d18341e52c71c0c13ed1b2098c47d1ffb2c34287fece7524c1ff96d65c51983cf031ea08bb9c20374d384ca523f639352f22332bd6e79bd04b14e90067b490cd5a26a09106b2694a4491e3d7ff240c5c326521c4c3a97d315c4855e0b52537796ab68e8baee0b48100326bb61e4468b4b8dfad60fc8a008ff994fbfc3ee6919f0a0d5dd47540e90f176bd2720134c479586dcb3f36ebfde120c0896f9ed8b3360185d8cd79bcf681415481c744cf07bf3bca62b8a33d8749cd462ace88d5990a1fc27bf199e82795321e2b592dad039a6f1e37b8f04857d0f53e7b67264ee81edde17c32138f7c156797e330f6a9148be9be992f70ed59fb2b
B xác thực chữ ký với khóa công khai của A như sau:
err = rsa.VerifyPSS(aPublicKey, crypto.SHA256, hashString[:], aSignature, &opts)
if err != nil {
    log.Fatal("Lỗi xác thực chữ ký")
} else {
    fmt.Println("Chữ được xác thực của A!")
}
Lưu ý giá trị băm lấy lại bên trên. Trong thực tế giá trị băm được tạo từ chuỗi mà B giải mã từ thông điệp của A. Nếu mọi việc bình thường, thông báo hiện ra sẽ là "Chữ ký được xác thực của A!". B hoàn toàn yên tâm lời tỏ tình của A.

Trong bài tới chúng ta sẽ tìm hiểu về các loại phương thức kết nối xây dựng dịch vụ web.
Tóm tắt:
- Mã hóa gồm mã hóa một chiều (hàm băm) và mã hóa hai chiều. Mã hóa 2 chiều lại chía ra gồm mã hóa đối cứng và mã hóa bất đối xứng.
- Mật khẩu thường được mã hóa bởi hàm băm do không có nhu cầu giải mã. Để tăng dộ bảo mật tránh việc bị so sánh băm để tìm ra mật mã gốc có thể dùng phương pháp salting, thêm dữ liệu vào trước khi mã hóa. Go cung cấp package crypto/md5, crypto/sha1, crypto/sha256, crypto/sha512 để thực hiện băm.
- Base64 là loại mã hóa đối xứng chuyên dùng để chuyển dữ liệu nhị phân ra chuỗi ký tự đọc được. Go cung cấp package encoding/base64 để mã hóa base64 cho cả dạng chuẩn và phù hợp URL.
- Go cung cấp package crypto/aes để mã hóa và giải mã khối AES. AES chỉ mã hóa từng khối có kích thước xác định trước. Để mã hóa nguyên chuỗi hoặc file phải dùng tiếp mã hóa CFB, OFB hay CTR.
- Mã hóa bất đối xứng RSA được Go hỗ trợ qua crypto/rsa.

No comments:

Post a Comment