[翻譯] 安全的儲存密碼 (Storing Passwords Securely)

其實我的對於系統儲存密碼這件事情,原本是以為用MD5、SHA之類的單向雜湊函式來儲存就「夠安全了」。前幾天看到了Storing Passwords Securely這篇文章,才徹底的把我從迷思中拉出來,文章還蠻淺顯易懂的,所以就試著翻譯一下。接下來是免責聲明,雖然我略懂一些資訊安全的皮毛,但我不是資訊安全方面的專家,加上我的英文能力不佳所以翻譯可能有錯誤,如果有任何不精確的地方希望能海涵,然後以下的內容僅翻譯文章中間的部分,前面與最後面Additional Measures、Extra我沒有翻譯,如果有興趣可以去看看原文

密碼的儲存

系統的設計者會從下列兩種方式中擇一來儲存使用者的密碼:

  • 儲存密碼原本的格式,也就是儲存明文(plain text)
  • 利用單向雜湊函式(one-way hash function)來產生digest (註:有人翻譯為摘要)

顯而易見的第一種方法是個很糟糕的想法,使用者儲存在資料庫的密碼可能也使用在其他網站。但讓你更為驚訝的是,作為大多數網路系統中所使用的方式的後者,能提供更強的安全性嗎?

單向雜湊函式(one-way hash function)

儲存經過單向雜湊函式處理的使用者密碼這是安全的作法(嗎?)。單向雜湊函式是指一系列的數學運算,可以將輸入轉換成一種”指紋”(fingerprint)稱為digest。

如果你利用知名的加密雜湊函式SHA-256來運算 hunter2,會得到:

f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7

單向雜湊函式的名稱就暗示了,這個函式是單向(one-way)的,也就是說你可以把一個輸入產生出一個digest,但你無法利用digest恢復成他原本的樣子。當使用者建立一個帳號,系統儲存使用者的帳戶資訊,但不直接把密碼儲存起來,而是儲存透過這些單向雜湊函式產生的digest。當使用者再度登入系統,系統再將使用者提供的密碼經過單向雜湊函式的運算出的digest與現存的digest比對。系統只需要能夠比對單向雜湊函式的輸出結果,而不需要儲存任何有關密碼的資訊,而且也不需要記住使用者的密碼。

一個好的密碼學雜湊函式(cryptographic hash function),也就是我們所討論的單向雜湊函式,會因為輸入的些許不同而能產生出差異化非常大的digest (這稱為avalanche effect),我們可以利用SHA-256產生出hunter3的digest,雖然他的原文非常相近但是產生出的digest卻非常不同:

fb8c2e2b85ca81eb4350199faddd983cb26af3064614e737ea9f479621cfa57a

這個特性讓人難以利用digest推斷出原始的輸入,例如:每次猜測只要改變一個字元,但是這會讓猜測或破解使用者的密碼更困難嗎?

密碼學雜湊函數(Cryptographic Hash Functions)不是密碼雜湊函數(Password Hash Functions)

在Python中利用標準的函式庫,我們可以這樣寫來取得與比較密碼的SHA-256 digest。

import hashlib

def getDigest(password):
    return hashlib.sha256(password).hexdigest()

def isPassword(password, digest):
    return getDigest(password) == digest

這是常見的密碼驗證機制(authentication mechanism),密碼無法利用digest推討出來,所以取得digest就無法做任何更進一步的動作嗎?

如果單純的使用雜湊函式來處理密碼有兩個主要的問題:

  1. 可識別性 (Recognizability)
    同樣的訊息會產生出相同的digest是主要的缺點嗎?如果攻擊者為許多可能的密碼預先運算了多的digest,而且攻擊者能夠存取資料庫他可以比對所有的digest來取得原本的密碼。此外,攻擊者可以利用同一個預先產的digest清單來比對他們所入侵的所有使用者資料庫。(這種digest清單通常稱為raindow table)
    更糟的是如果攻擊者要猜測或攻擊一個密碼,他們可以搜尋你的資料庫中所有相同的digest,他們就會知道有哪些使用者用了和他們猜測中一樣的密碼。
  2. 速度 (Speed)
    雜湊函式(Hash function)在密碼學(cryptography)中經常被利用。最常見的運用是驗證訊息(verifying message)是否正確,訊息可以是一段加密過的資料資料串流。但是雜湊函式不是被設計來儲存密碼或者任何秘密。所以雜湊函式被設計成能夠非常快速,讓整個加密程序不至於被拖慢,但這就是問題所在。因為當儲存密碼的時候攻擊者可以非常快速的用任意的字串來進行雜湊並比較輸出結果(譯註:輸出即digest)。像是MD5演算法,只要用一般的電腦每秒鐘就可以產生出56億組以上的密碼與digest。就算密碼無法直接利用digest反推回來,如果密碼不是非常長或是複雜那麼被猜中的就很容易,而許多的使用者選擇的密碼既不長也不複雜,所以就很容易被猜中。最後雜湊函式的高運算速度反而不是一種優點。

舉例來說在網路上對密碼的操作通常不是對時間敏感(time sensitive)的,像是某個人要登入你的網站輸入帳號密碼只要花他20秒鐘。系統花1秒鐘或是幾毫秒呢來產生密碼的digest來確認密碼是不是正確的對使用者來說都是一樣的。(譯註:所以產生密碼的digest時速度並不會影響使用者的操作)

幸好,我們有幾個方法來解決上面的兩個問題。

  • Salting(譯註:Salting是加鹽的意思,為了避免誤導這個詞就先不翻譯了)
    Salt是指在雜湊函數中或是密碼上加入一串隨機的位元(a random sequence of bytes)。所以你可以產生一組digest像是來當salt,密碼就會像是:6zvz3ylalpkp03lua8r4yyzdoq7e2js2 + sw0rdf1sh,不僅只有sw0rdf1sh。(譯註:前面一串是隨意產生的digest,當成salt,後面的sw0rdf1sh是使用者的密碼) 而任何人知道sw0rdf1sh的digest也無法符合salted後的digest。如果每個使用者都有不同的salt,就沒有簡單的方法來辨識出使用者是否使用相同的digest。這個很大程度上解決了可識別性 (Recognizability)的問題。

每個密碼都要有自己的salt,而且salt的長度至少要有32位元以上來增加猜測正確salt與digest的難度。實作密碼Salting可以像這樣:

import base64
import hashlib
import os

def getDigest(password, salt=None):
    if not salt:
        salt = base64.b64encode(os.urandom(32))
    digest = hashlib.sha256(salt + password).hexdigest()
    return salt, digest

def isPassword(password, salt, digest):
    return getDigest(password, salt)[1] == digest

然後我們在資料庫儲存使用者的salt與digest。驗證密碼的時候執行isPassword(providedPassword, storedSalt, storedDigest)這樣就可以檢查密碼是不是正確。

  • Stretching (延展)
    如果你利用雜湊函式產生密碼(password)的digest,然後又利用digest產生digest,再用上一個digest產生digest,最後再用上一個digest產生digest。像是這樣進行四次雜湊函式迭代(iterations),你第一次的digest與最後一次的digest就不相同,因為最後一次的digest是第三次的digest運算出來的結果,而第三次digest又是第二次digest的運算結果。所以要對密碼進行比對,必須執行相同次數的雜湊函式,才能比對第四次運算出的digest,這個行為我們稱為Stretching。
    一個良好的密碼儲存系統會為了單一輸入花一點時間(例如:在一個現代的機器上的0.2秒)。這樣可以很明顯的加長利用暴力法(brute force)來猜測密碼所需的時間。(如果利用SHA-256大概需要十萬次以上的循環才夠)先前提到每秒可以進行56億次的digest比對,而經過Stretching後在相同的機器上也許只能進行5次的比對。此外,也許有成百上千的攻擊者利用硬體像是GPUs來進行運算,但仍可以顯著的低於56億次。

因為現在電腦越來越強大,所以可以利用增加迭代循環的次數來運算的時間增加到0.2秒或更多,也許你可以這樣寫:

import hashlib
import os

def getDigest(password):
    digest = hashlib.sha256(password).hexdigest()
    for x in range(0, 100001):
        digest = hashlib.sha256(digest).hexdigest()
    return digest

(附註:這可能過於簡化,迭代的雜湊函式明顯的優於只運行一次的,但也因此損失了熵(entropy,譯註:在資訊科學中熵是指資訊量)

結合Salting與Stretching實作上會像是這樣:

import base64
import hashlib
import os

def getDigest(password, salt=None):
    if not salt:
        salt = base64.b64encode(os.urandom(32))
    digest = hashlib.sha256(salt + password).hexdigest()
    for x in range(0, 100001):
        digest = hashlib.sha256(digest).hexdigest()
    return salt, digest

def isPassword(password, salt, digest):
    return getDigest(password, salt)[1] == digest

現在我們可以實作一個使用salting與多次迭代的完整系統,雖然它看起來像是一個可以安全儲存密碼的系統。但如果我們可以利用一個廣泛被使用、標準化的系統我們就不應該承擔自己實作並產生錯誤的風險。

自己實作密碼學相關的函式是最有風險的一件事情,如果你不知道有那些可能的漏洞(vulnerabilities),你就無法測試所有的漏洞,而且有可能只有你來檢驗(scrutinize)與使用這個實作,若結果是你的實作中有錯誤,你的使用者就遭殃了,而你就會得到不好的評價。

有許多相似的系統與變形已經被使用在不同的作業系統像是BSD、Linux許多年。

Adaptive Key Derivation Functions (適應性金鑰衍伸函數)

Adaptive Key Derivation Functions就是我們剛剛討論的,這些函數從密碼產生digest並實作了salting與stretching。他們已經實作上面所有的功能,而且如果利用程式語言本身的標準函式庫難以完成的。舉例來說,他們可能讓digest的運算難以利用平行運算來處理,這原本在MD5與SHA家族中非常容易做到的功能(譯註:避免運算的時間被縮短,進而增加暴力攻擊的難度)。事實上攻擊者無法利用一些特殊的硬體例如GPU、FPGAs來顯著提高利用暴力法猜測密碼的速度。

技術上來說,key derivation functions是由一個強健的金鑰被使用在連續的加密所衍伸來而的。然而,既然這個函式也是單向(one-way)所以他也可以被應用在計算密碼的digest。

一些傑出的函式像是:

  • PBKDF2

這大概是最廣泛被使用的Key derivation function。PBKDF2 (Password-Based Key Derivation Function)是一個像是SHA-1、RIPEMD-160等雜湊函數的容器,每組輸入都附加了salt與數次雜湊函式的迭代,用一種不損失熵的方式。所以產生每組digest大概要花一秒。而且他是被NIST認可,被用於美國政府的系統中,用於產生一組相較於使用者堤供的密碼(較弱)強健的加密金鑰。PBKDF2的優點是他非常的輕巧、實作簡單、而且他只使用非常強健且經過檢驗的雜湊函數,像是NSA的SHA。

  • bcrypt

bcrypt是一個適應性(adaptive)的雜湊函數,被特別為密碼的儲存所設計。他是由Bruce Schneier的Blowfish的修改版本,而不只是單純的迭代雜湊函式。他的設計者Niels Provos與David Mazières從它們發表的論文中說明使用方法(A Future-Adaptable Password Scheme, at the 1999 USENIX)。他目前仍是最強健的密碼雜湊機制之一。受惠於他的work factor,這個參數可以決定產生單一digest需要花費多少運算,他也非常容易使用:輸出包含密碼的salt與work factor,所以系統設計者可以一直使用bcrypt,只要藉由提高work factor就可以增加運算所需的時間,不用擔心使用者無法登入系統。bcrypt是對於安全性要求很高的OpenBSD系統的預設密碼驗證機制。而一般認為bcrypt比PBKDF2更具前瞻性。

  • scrypt

scrypt和PBKDF2一樣,是一種Key derivation function,由Colin Percival設計。因為他明顯的使用大量的記憶體所以概念上來說比PBKDF2強健。使用大量的記憶體也代表了這個函式不容易平行處理,所以要用暴力的方式猜測出原本金鑰的輸入就變得更為困難。關於這個演算法的測試明顯比PBKDF2 (而且像是SHA)或 bcrypt少。但他仍看起來很可靠,如果他能應付更多的檢驗,那他則比bcrypt更有前瞻性。

所以我們要怎麼選擇呢?我的觀點是:

  • MD5, SHA-1, SHA-256, SHA-512, 之類的函式不是我們所謂”密碼雜湊函式”。它們的用途是訊息的認證(authentication) 與整體性檢查(integrity checking,譯註:避免內文被竄改)。而不是密碼驗證。
  • 如果你是幫政府工作,想要符合安全認證(security certifications)或者像是ISO27001、FIPS 140-2之類的法規(regulations)或者不想要依賴第三方軟體或是缺乏檢驗的函式庫,那就用PBKDF2-SHA-256或SHA-512並用很多次的迭代來產生使用者密碼的digest(理想來說,產生單一digest應該要超過一秒)。
  • 如果你想要非常強健又容易使用的密碼系統,那就用bcrypt。簡單、容易使用的函式庫幾乎在所有語言都有(只要去google搜尋”bcrypt <語言名稱>”,你就有機會可以找到一個良好的實作。
  • 如果你設計一個系統既可以依靠利用一個弱的使用者密碼來取得敏感的加密資料或是攻擊者無法在一定時間內猜中密碼,那可以參考scrypt。

而且也可以很容易的進行演算法的替換,例如你可以為新使用者產生bcrypt的digest,原本的使用者等他們登入時再遷移到新的密碼系統。