Mật khẩu người dùng được Devise lưu và bảo mật như thế nào.

I.Devise gem và và bcrypt Hiện nay với các ứng dụng sử dụng Framework Rails, chúng ta thường sử dụng gem devise cho module đăng kí, đăng nhập ( Devise hiện tại đang là gem được sử dụng nhiều nhất cho tính năng đăng nhập, theo thống kê năm 2016). Với module mã hóa mật khẩu người dùng và kiểm tra mật khẩu đăng nhập, Devise mặc định sử dụng bcrypt. Sơ qua về bcrypt, Bcrypt là một chức năng mã hóa mật khẩu thiết kế bởi Niels Provos và David Mazières, dựa trên các thuật toán mã hóa Blowfish, và trình bày tại USENIX trong năm 1999.Bcrypt là một thuật toán mã hóa một chiều. Bạn không thể lấy lại mật khẩu khi đã biết chuỗi mật khẩu trong dữ liệu databse trước đó mà bạn hay bất kỳ ai tấn công vào để đánh cắp.

Trong bài viết hôm nay, chúng ta cùng xem xét bcrypt hoạt động như thế nào, mật khẩu chúng ta được mã hóa và lưu trữ ra sao. II.Salt là gì? Tác dụng của salt trong bcrypt Với các website cũ sử dụng các thuật toán MD5 hoặc SHA1, cách thức module đăng nhập thường hoạt động như sau:

  1. User đăng ký tài khoản, password user được hash (bằng MD5 hoặc SHA1) và lưu vào database
  2. Lúc user đăng nhập, hash lại password mà user nhập vào rồi so sánh với password đã hash trong database, nếu trùng nhau thì login, không trùng thì bye bye 😄. Cách làm này có 1 nhược điểm rất lớn đó là thuật toán mã hóa luôn trả về 1 chuỗi cố định với 1 string truyền vào cố định. Ví dụ với thuật toán MD5, chuỗi "password" luôn trả về hash "5f4dcc3b5aa765d61d8327deb882cf99". Với SHA1, chuỗi password luôn có giá trị "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8".

Điều này dẫn đến việc khi hacker tấn công vào database, lấy được dữ liệu là hash mật khẩu của người dùng, hacker chỉ cần brute-force 1 lần để tạo ra Rainbow Table là có thể lấy được password của toàn bộ user (do các user có mật khẩu là "password" hoặc "123456" có chuỗi hash giống hệt nhau).

Như vậy, chúng ta cần 1 giải pháp để các chuỗi mật khẩu giống nhau sinh ra các hash khác nhau, nhưng khi user đăng nhập thì kết quả so sánh vẫn phải trùng khớp. Đó là lý do cần thêm "salt" vào mật khẩu.

Với bcrypt, salt là một chuỗi hash được sinh ngẫu nhiên dựa vào "cost" truyền vào. "cost" là 1 số tự nhiên và lớn hơn 4 và nhỏ hơn 31, cost càng cao thì chuỗi mã hóa càng phức tạp. Mặc định trong bcypt, cost là 10. Đây là hàm sinh ra salt của bcrypt, có thể thấy salt được sinh ngẫu nhiên tại mỗi lần gọi hàm generate.

    # Generates a random salt with a given computational cost.
    def self.generate_salt(cost = self.cost)
      cost = cost.to_i
      if cost > 0
        if cost < MIN_COST
          cost = MIN_COST
        end
        if RUBY_PLATFORM == "java"
          Java.bcrypt_jruby.BCrypt.gensalt(cost)
        else
          prefix = "$2a$05$CCCCCCCCCCCCCCCCCCCCC.E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW"
          __bc_salt(prefix, cost, OpenSSL::Random.random_bytes(MAX_SALT_LENGTH))
        end
      else
        raise Errors::InvalidCost.new("cost must be numeric and > 0")
      end
    end

III. Bcrypt thực hiện kiểm tra mật khẩu chính xác như thế nào? Từ chuỗi salt ngẫu nhiên sinh ra bởi hàm generate_salt bên trên , bcrypt mã hóa mật khẩu người dùng thành 1 hash, có dạng như sau.

BCrypt::Password.create("testing")
=> "$2a$10$Ww/nuq6UNXXIWaUaAg.FDe4vHKwTdAK4cqldlNDtyj63M5Tv034xS".

Trong chuỗi hash trên, mật khẩu được chia thành 3 phần, phân cách bởi dấu $ và được lưu vào trong database. 2a Đây là phiên bản thuật toán mã hóa bcrypt đang sử dụng. 10 là "cost" sử dụng để mã hóa (Có thể hiểu đơn giản là cost càng lớn thì hash càng khó bị máy tính dò ra.) "10$Ww/nuq6UNXXIWaUaAg.FDe4vHKwTdAK4cqldlNDtyj63M5Tv034xS" là chuỗi hash bao gồm salt và mật khẩu người dùng đã được mã hóa. 22 kí tự đầu tiên chính là salt mà chúng ta đã random ra. 31 kí tự sau là hash của mật khẩu sinh ra từ salt và cost.

Thử nghiệm với chuỗi "testing", giả sử có 2 người dùng cùng sử dụng chuỗi này làm mật khẩu, ta có chuỗi mật khẩu như sau

user1 = BCrypt::Password.create("testing")
=> "$2a$10$5zSbKjZGKAKXZSB4HeqkPeY1QffBD55X68UMT9Zbd6C/muvis8Bry"
user2 = BCrypt::Password.create("testing")
=> "$2a$10$Z/fXEUtArotInY1.75mef.9NgMZR0QVhT.9TZBDuI7jZVJc7FBLFq"

Do 2 chuỗi salt được random nên tạo ra 2 hash hoàn toàn khác nhau, tuyệt vời 😄.

Tiếp theo, giả sử khi người dùng 1 đăng nhập thì điều gì xảy ra? 2 người dùng khi nhập mật khẩu cùng là "testing" thì có đăng nhập được không? Mình đã xem code và thấy bcrypt sử dụng toán tử so sánh "==" khi so sánh mật khẩu người dùng với chuỗi hash trong database, y hệt như khi so sánh chuỗi.

 BCrypt::Password.new(user1) == "testing"
 => true
  BCrypt::Password.new(user2) == "testing"
  => true
   BCrypt::Password.new(user1) == "testing1"
   => false

Thật là vi diệu!. Tiếp tục xem code, thì ra nó đã overide toán tử "=="

    # Compares a potential secret against the hash. Returns true if the secret is the original secret, false otherwise.
    def ==(secret)
      super(BCrypt::Engine.hash_secret(secret, @salt))
    end

Vậy là bcryt sử dụng hàm hash_secret, dựa vào chuỗi salt và mật khẩu người dùng nhập để sinh ra chuỗi hash giống chuỗi hash đã lưu trong database. Để kiểm chứng, chúng ta bắt đầu từ chuỗi hash trong database, ta sẽ cắt lấy 29 kí tự đầu (Do chuỗi salt có 22 kí tự, cộng thêm 7 kí tự đầu gồm cost, 2 dấu $ và mã phiên bản bcrypt) và được chuỗi "salt" như sau:

salt1 = user1.first(29)  
=> "$2a$10$5zSbKjZGKAKXZSB4HeqkPe"
salt 2 = user2.first(29)
=> "$2a$10$Z/fXEUtArotInY1.75mef."

Sau đó chúng ta truyền salt của 2 user này vào hàm hash_secret để so sánh với 2 chuỗi hash được lưu trong database bên trên.

 BCrypt::Engine.hash_secret("testing", salt1)
=> "$2a$10$5zSbKjZGKAKXZSB4HeqkPeY1QffBD55X68UMT9Zbd6C/muvis8Bry"
BCrypt::Engine.hash_secret("testing", salt2)
=> "$2a$10$Z/fXEUtArotInY1.75mef.9NgMZR0QVhT.9TZBDuI7jZVJc7FBLFq"

Hash được sinh ra khớp với 2 hash ban đầu lưu trong database, đăng nhập thôi 😄. Tóm lại bản chất việc các chuỗi hash sinh ra khác nhau với các chuỗi đầu vào như nhau bản chất là do chuỗi "salt" ban đầu được bcrypt sinh ra ngẫu nhiên. Nếu cắt chuỗi salt này từ database và truyền vào hàm hash_secret ta sẽ được chuỗi hash y hệt như ban đầu sinh ra bởi hàm create. Vậy với chuỗi salt, cost và chuỗi mật khẩu, bcrypt làm thế nào để sinh ra đoạn hash dài 31 kí tự phía sau không đổi.

Mình tiếp tục mở hàm hash_secret trong thư viện của bcrypt

    def self.hash_secret(secret, salt, _ = nil)
      if valid_secret?(secret)
        if valid_salt?(salt)
          if RUBY_PLATFORM == "java"
            Java.bcrypt_jruby.BCrypt.hashpw(secret.to_s, salt.to_s)
          else
            __bc_crypt(secret.to_s, salt)
          end
        else
          raise Errors::InvalidSalt.new("invalid salt")
        end
      else
        raise Errors::InvalidSecret.new("invalid secret")
      end
    end

Và dẫn đến hàm bc_crypt (trong trường hợp RUBY_PLATFORM không phải java), đến hàm này thì mình bó tay vì nó chuyên sâu quá về thuật toán bảo mật và được code bằng C, có bạn nào cao thủ giải thích được thuật toán này thì hỗ trợ mình ở mục bình luận bên dưới nhé =)). Đây là code hàm bc_crypt, các hàm liên quan các bạn có thể tìm thấy trong thư mục ext của bcrypt tại github: https://github.com/codahale/bcrypt-ruby

/* Given a secret and a salt, generates a salted hash (which you can then store safely).
*/
static VALUE bc_crypt(VALUE self, VALUE key, VALUE setting) {
    char * value;
    void * data;
    int size;
    VALUE out;

    data = NULL;
    size = 0xDEADBEEF;

    if(NIL_P(key) || NIL_P(setting)) return Qnil;

    value = crypt_ra(
	    NIL_P(key) ? NULL : StringValuePtr(key),
	    NIL_P(setting) ? NULL : StringValuePtr(setting),
	    &data,
	    &size);

    if(!value) return Qnil;

    out = rb_str_new(data, size - 1);

    xfree(data);

    return out;
}

Cảm ơn các bạn đã dành thời gian theo dõi bài viết.

All Rights Reserved