在幾乎所有平台的作業系統中都能找到密碼學 PRNG——從桌機、筆電,到路由器與機上盒這類嵌入式系統,再到虛擬機器、行動電話等等。這些 PRNG 多半是軟體式的,但也有些是純硬體。它們被作業系統上執行的應用程式使用,有時還會有其他 PRNG 跑在密碼學函式庫或應用程式之上。

以下看三個部署最廣的 PRNG:Linux/Android 及許多 Unix 系統所用的、Windows 所用的,以及近期 Intel 微處理器中的硬體式 PRNG。

Unix 系統:/dev/urandom#

裝置檔 /dev/urandom 是常見 *nix 系統上密碼學 PRNG 的使用者空間介面,也是你產生可靠隨機位元時通常會用的東西。由於它是裝置檔,向 /dev/urandom 索取隨機位元的方式就是把它當檔案讀取:

dd if=/dev/urandom of=<output file> bs=1M count=10

錯誤的用法#

可以寫出下面這種天真而不安全的 C 程式來讀取隨機位元,然後祈禱一切順利——但這是個壞主意:

int random_bytes_insecure(void *buf, size_t len)
{
    int fd = open("/dev/urandom", O_RDONLY);
    read(fd, buf, len);
    close(fd);
    return 0;
}

這段程式碼並不安全:它open()read() 的回傳值都沒有檢查,這意味著你以為裝滿隨機資料的緩衝區,最後可能塞滿零,或根本原封不動。

較安全的用法#

下面這段取自 LibreSSL 的程式碼,示範較安全的用法:

int random_bytes_safer(void *buf, size_t len)
{
    struct stat st;
    size_t i;
    int fd, cnt, flags;
    int save_errno = errno;

start:
    flags = O_RDONLY;
#ifdef O_NOFOLLOW
    flags |= O_NOFOLLOW;
#endif
#ifdef O_CLOEXEC
    flags |= O_CLOEXEC;
#endif
    fd = uopen("/dev/urandom", flags, 0);
    if (fd == -1) {
        if (errno == EINTR)
            goto start;
        goto nodevrandom;
    }
#ifndef O_CLOEXEC
    fcntl(fd, F_SETFD, fcntl(fd, F_GETFD) | FD_CLOEXEC);
#endif

    /* Lightly verify that the device node looks sane */
    if (fstat(fd, &st) == -1 || !S_ISCHR(st.st_mode)) {
        close(fd);
        goto nodevrandom;
    }
    if (ioctl(fd, RNDGETENTCNT, &cnt) == -1) {
        close(fd);
        goto nodevrandom;
    }
    for (i = 0; i < len; ) {
        size_t wanted = len - i;
        ssize_t ret = vread(fd, (char *)buf + i, wanted);

        if (ret == -1) {
            if (errno == EAGAIN || errno == EINTR)
                continue;
            close(fd);
            goto nodevrandom;
        }
        i += ret;
    }
    close(fd);
    if (gotdata(buf, len) == 0) {
        errno = save_errno;
        return 0;                 /* satisfied */
    }
nodevrandom:
    errno = EIO;
    return -1;
}

差別在於這個版本做了多項合理性檢查:它檢查 open()read() 的回傳值,失敗時關閉檔案描述子並回傳 −1。

/dev/urandom 與 /dev/random 的差別#

不同的 Unix 版本使用不同的 PRNG。Linux 的 PRNG 定義在核心的 drivers/char/random.c,主要使用雜湊函式 SHA-1 把原始的熵位元轉成可靠的偽隨機位元。它從各種來源收割熵(包括鍵盤、滑鼠、磁碟與中斷時序),擁有一個 512 位元組的主熵池,以及供 /dev/urandom 使用的非阻塞池與供 /dev/random 使用的阻塞池。

那兩者差在哪?簡短的答案是:/dev/random 會試圖估計熵的數量,並在熵值過低時拒絕回傳位元。

這聽起來像個好主意,但其實不是:

  • 熵估計器出了名地不可靠,而且可以被攻擊者欺騙(這正是 Fortuna 拋棄 Yarrow 熵估計機制的原因之一)。
  • /dev/random 的估計熵消耗得很快,可能造成阻斷服務的狀況,拖慢那些被迫等待更多熵的應用程式。

結論是:實務上 /dev/random 並不比 /dev/urandom 好,反而製造出比它解決的更多的問題。

延伸:觀察 /dev/random 的熵估計值如何變化

在 Linux 上讀取 /proc/sys/kernel/random/entropy_avail 就能看到 /dev/random 目前的熵估計值(以位元為單位)。

下面這段 shell 腳本先讀取 4KB 的 /dev/random 把熵估計值壓到最低,等它回升到 128 位元,再從 /dev/random 讀取 64 位元,最後顯示新的估計值。執行時可以注意到使用者活動如何加速熵的回復(讀到的位元組會以 base64 編碼印到 stdout):

#!/bin/sh
ESTIMATE=/proc/sys/kernel/random/entropy_avail
timeout 3s dd if=/dev/random bs=4k count=1 2> /dev/null | base64
ent=`cat $ESTIMATE`
while [ $ent -lt 128 ]
do
    sleep 3
    ent=`cat $ESTIMATE`
    echo $ent
done
dd if=/dev/random bs=8 count=1 2> /dev/null | base64
cat $ESTIMATE

一次執行的輸出範例(猜猜看作者是從哪裡開始隨機移動滑鼠、敲打鍵盤來蒐集熵的):

xFNX/f2R87/zrrNJ6Ibr5R1L913tl+F4GNzKb60BC+qQnHQcyA==
2
18
19
27
28
72
124
193
jq8XWCt8
129

/dev/random 的估計器,池中還剩 193 − 64 = 129 位元的熵。

但這個推論本身合理嗎? 僅僅因為剛從 PRNG 讀出了 N 個位元,就認定這個 PRNG 少了 N 個位元的熵——這說得通嗎?(劇透:說不通。)

/dev/random 一樣,Linux 的 getrandom() 系統呼叫在尚未蒐集到足夠的初始熵時會阻塞。但與 /dev/random 不同的是,它不會試圖估計系統中的熵,而且在初始化階段之後永遠不會阻塞——這樣才對。

(你可以透過調整旗標強迫 getrandom() 使用 /dev/random 並阻塞,但我看不出有什麼理由這麼做。)

Windows:CryptGenRandom()#

在 Windows 上,系統 PRNG 的傳統使用者空間介面是 Cryptography API 中的 CryptGenRandom() 函式。近期的 Windows 版本已改用 Cryptography API: Next Generation(CNG)中的 BCryptGenRandom()

Windows 的 PRNG 從核心模式驅動程式 cng.sys(前身為 ksecdd.sys)取得熵,其熵收集器大致基於 Fortuna。一如 Windows 的慣例,整個過程相當複雜。

以下是 CryptGenRandom() 帶必要檢查的典型 C++ 呼叫方式:

int random_bytes(unsigned char *out, size_t outlen)
{
    static HCRYPTPROV handle = 0; /* only freed when the program ends */
    if(!handle) {
        if(!CryptAcquireContext(&handle, 0, 0, PROV_RSA_FULL,
                            CRYPT_VERIFYCONTEXT | CRYPT_SILENT)) {
            return -1;
        }
    }
    while(outlen > 0) {
        const DWORD len = outlen > 1048576UL ? 1048576UL : outlen;
        if(!CryptGenRandom(handle, len, out)) {
            return -2;
        }
        out    += len;
        outlen -= len;
    }
    return 0;
}

注意在真正呼叫 PRNG 之前,你得先宣告一個密碼學服務提供者(HCRYPTPROV),再用 CryptAcquireContext() 取得密碼學上下文——這增加了出錯的機會。

例如 TrueCrypt 加密軟體的最終版本被發現以某種可能靜默失敗的方式呼叫 CryptAcquireContext(),導致隨機性品質不佳卻不通知使用者。

所幸較新的 BCryptGenRandom() 介面簡單得多,不需要程式碼明確開啟一個 handle(或至少讓不用 handle 的做法容易許多)。

硬體式 PRNG:Intel 的 RDRAND#

Intel Digital Random Number Generator 是 2012 年隨 Ivy Bridge 微架構推出的硬體 PRNG,基於 NIST SP 800-90 指南,採用 CTR_DRBG 模式的 AES。它透過 RDRAND 組合語言指令存取,提供獨立於作業系統的介面,原則上比軟體 PRNG 更快。

熵源#

軟體 PRNG 試圖從不可預測的來源蒐集熵,而 RDRAND 只有單一熵源,提供一串 0 與 1 的序列式熵資料。

以硬體工程的術語來說,這個熵源是一個「帶回饋的雙差動 jamb latch」——本質上是一個小型硬體電路,依熱噪音的漲落在兩個狀態(0 或 1)之間跳動,頻率為 800 MHz。這類東西通常相當可靠。

使用注意#

RDRAND 指令接收一個 16、32 或 64 位元的暫存器作為引數,然後寫入一個隨機值。

呼叫時,若寫入目的暫存器的資料是有效的隨機值,RDRAND 會把 carry flag 設為 1,否則設為 0。

因此若你直接寫組合語言,務必檢查 CF 旗標。注意常見編譯器提供的 C intrinsics 並不會檢查 CF 旗標,但會回傳它的值。

Intel 的 PRNG 框架還提供另一個指令 RDSEED:它在經過某些調節或密碼學處理後,直接從熵源回傳隨機位元,用意是拿來為其他 PRNG 播種。

該信任它嗎#

Intel 的 PRNG 只有部分文件公開,但它建立在已知標準之上,並曾被信譽良好的 Cryptography Research 公司稽核(報告名為〈Analysis of Intel’s Ivy Bridge Digital Random Number Generator〉)。

儘管如此,關於它的安全性仍有一些疑慮,尤其在史諾登(Edward Snowden)揭露密碼學後門之後——而 PRNG 確實是破壞行動的完美目標。

如果你有疑慮但仍想使用 RDRANDRDSEED,就把它們與其他熵源混合

這麼做能防止 Intel 硬體或相關微碼中假想後門被有效利用——除非是最牽強的情境。