瞭解與實現“工作量證明”的源頭 Hashcash
介紹
Hashcash 是一種用於減少垃圾郵件和 DDoS(拒絕服務攻擊)的工作量證明體系,最近因其在比特幣(和其他加密貨幣)中被作為挖礦演算法的重要組成部分而聞名。這種體系由 Adam Back 於 1997 年 3 月提出。 ——維基百科
你可以在這裡閱讀 Adam Back 的論文。
讓我們來看看 Hashcash 的思路:一封要證明其合法性的電子郵件需要附帶一些對字串的 hash 值來證明其耗費了一定的時間/資源運行了某個演算法(Hashcash 中是需要執行 SHA-1,去計算出一個前 20 位均為 0 的 hash 值)。
由於通過暴力方式進行計算來找到符合特定條件的 hash 值會耗費一定的計算時間,垃圾郵件的製造者在傳送大量郵件時可能會因此知難而退。
按 hashcash.org 上的說法,每條 hashcash 可以被認為是“幫助 hashcash 使用者在濾除郵件時避免由內容過濾或是黑名單機制誤殺錯過重要郵件的的‘白名單通行證’。”
如今“工作量證明機制”這一概念最主要的應用,是比特幣的挖礦功能。所謂挖礦,就是“在區塊鏈演進過程中充當投票角色並驗證交易日誌”。而《比特幣之書》(The Book of Bitcoin)是這樣說的:“Hashcash 使得任何對區塊鏈的更改都需要付出一定的成本,而只有各個節點協商一致認可的更改才能為礦工掙得能夠抵償更改成本的報酬。因此,比特幣通過採用 Hashcash 機制能保護其區塊鏈免受惡意篡改的影響。在比特幣的設計中,Hashcash 所要計算的問題的複雜度隨著最近的求解時間的變化而變化,一般會使得平均求解時間控制在 10 分鐘左右。”
其他實現
hashcash.org 上有一個連結,指向了 SourceForge 上的一種 C# 實現。但是,在我測試這個演算法時,發現了一些錯誤。其中,日期戳這裡有一個小錯誤:
string stampDate = date.ToString("yymmdd");
注意了,這個時間格式是:年—分鐘—日!(譯者注:對 C# 中的日期物件的 ToString
方法而言,yyMMdd
才代表年月日)
一個更顯著的問題是,計算出的頭部資訊常常不能通過校驗:
SHA1CryptoServiceProvider sha = new SHA1CryptoServiceProvider(); byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(header));
最後發現,計算出的 hash 常常只有前 16 或 18 位被置 0(譯者注:而標準要求前 20 位均被置 0)。我認為這是一個與進行 base64 編碼時,對位元組進行補齊處理的演算法的問題。
演算法
Hashcash 頭具有以下欄位(來自 維基百科):
- 版本號:(目前為 1)
- 零位數:hash 開頭一共有多少個連續的 0 位
- 時間戳:日期/時間戳(時間部分是可選的)
- 資源:需要傳輸的資料字串,例如IP地址,電子郵件地址或其他資料
- 擴充套件:在版本 1 中被忽略
- 隨機種子:經過 base-64 編碼的隨機字符集
- 計數器:0 和 2^{20}(1,048,576)之間的某個經過 base-64 編碼的二進位制計數器
如果你要寫程式碼實現這一機制,需要考慮到一些問題和演算法設計中的一個缺陷。
- 隨機種子應該長多少個字元?
- 編碼二進位制計數器時,它應遵循大端編碼還是小端編碼?在將整數(4位元組整型)轉換為位元組陣列時,應該去掉頭部的零(大端模式下)還是末尾的零(小端模式下)?
- 一個更重要的問題是:在很多情況下,對於最大可以為 2^{20} 的計數器值來說,並不一定存在一個對應解。我見到過有一次計數器值為 8,069,934(0x7B232E)時,這一實現仍在要求求解。
我修改後的演算法是:
- 隨機種子是8個字元
- 計數器從
int.MinValue()
開始遞增,直到求出解為止 - 計數器值中 4 個小端編碼的代表這一整數的位元組會被進行 base64 編碼
- 如果計數器的值增加到了
int.MaxValue()
,則丟擲異常
履行
我當然不會說這個演算法的實現非常高效。不過,再次申明,由於這個機制本身就是要消耗一些 CPU 時間的,我對於效能問題並不是特別擔心。
驗證
首先看看頭部如何驗證:
public class HashCash
{
public static bool Verify(string header)
{
// 我們假設要被置 0 的幾位所代表的值在 10 到 99 之間
int zbits = int.Parse(header.Substring(2, 2));
int bytesToCheck = zbits / 8;
int remainderBitsToCheck = zbits % 8;
byte[] zArray = Enumerable.Repeat((byte)0x00, bytesToCheck).ToArray();
byte remainderMask = (byte)(0xFF << (8 - remainderBitsToCheck));
SHA1CryptoServiceProvider sha = new SHA1CryptoServiceProvider();
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(header));
return hash.Take(bytesToCheck).SequenceEqual(zArray) && ((hash[bytesToCheck] & remainderMask) == 0);
}
}
完成這些位操作的方式不止一種,例如使用 BitArray(https://msdn.microsoft.com/en-us/library/system.collections.bitarray(v=vs.110%29.aspx),不過我的以上實現沒有用到它。
我們可以像這樣對維基百科上舉出的頭驗證例子進行實驗:
var check = HashCash.Verify("1:20:1303030600:[email protected]::McMybZIhxKXu57jd:ckvi");
Console.WriteLine(check ? "驗證通過" : "驗證失敗");
執行結果是驗證通過。看到演算法給出驗證通過的結果,我們可以對訊息的真實性給出一定的信任。要進一步增強對訊息有效性的驗證,我們可以進行如下驗證:
- 在計算 hash 時用到了幾個 0 位
- 時間戳是否在預期的範圍內
- 隨機種子是否獨特(沒有被重複使用)
所有這些驗證都有助於將訊息列入白名單。
初始化
一些建構函式提供了一些初始化 hash 頭的方法:
public HashCash(string resource, int zbits = 20)
{
rand = GetRandomAlphaNumeric();
this.msgDate = DateTime.Now;
this.resource = resource;
this.zbits = zbits;
Initialize();
}
public HashCash(DateTime msgDate, string resource, int zbits = 20)
{
rand = GetRandomAlphaNumeric();
this.msgDate = msgDate;
this.resource = resource;
this.zbits = zbits;
Initialize();
}
public HashCash(DateTime msgDate, string resource, string rand, int zbits = 20)
{
this.rand = rand;
this.msgDate = msgDate;
this.resource = resource;
this.zbits = zbits;
Initialize();
}
如果你不提供隨機種子,那麼就應該為你生成一個:
public string GetRandomAlphaNumeric(int len = 8)
{
var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
return new String(chars.Select(c => chars[rnd.Next(chars.Length)]).Take(len).ToArray());
}
在內部,一些計算過程中一直要被用到的一些值也會先被算出來:
private void Initialize()
{
counter = 0;
sha = new SHA1CryptoServiceProvider();
bytesToCheck = zbits / 8;
remainderBitsToCheck = zbits % 8;
zArray = Enumerable.Repeat((byte)0x00, bytesToCheck).ToArray();
remainderMask = (byte)(0xFF << (8 - remainderBitsToCheck));
}
測試頭部生成
一旦我們構造了頭部,我們就可以通過驗證其前 n 位是否 0 來測試我們的實現:
private bool AcceptableHeader(string header)
{
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(header));
return hash.Take(bytesToCheck).SequenceEqual(zArray) && ((hash[bytesToCheck] & remainderMask) == 0);
}
計算頭部
這個步驟包括構造頭部,以及對每次構造失敗後,遞增計數器值,直到雜湊頭部通過位測試:
public string Compute()
{
string[] headerParts = new string[]
{
"1",
zbits.ToString(),
msgDate.ToString("yyMMddhhmmss"),
resource,
"",
Convert.ToBase64String(Encoding.UTF8.GetBytes(rand)),
Convert.ToBase64String(BitConverter.GetBytes(counter))
};
string ret = String.Join(":", headerParts);
counter = int.MinValue;
Iterations = 0;
while (!AcceptableHeader(ret))
{
headerParts[COUNTER_IDX] = Convert.ToBase64String(BitConverter.GetBytes(counter));
ret = String.Join(":", headerParts);
// Failed
if (counter == int.MaxValue)
{
throw new HashCashException("Failed to find solution.");
}
++counter;
++Iterations;
}
return ret;
}
測試
我整理了一個簡單的測試,執行100次“工作證明”:
static void TestHashCash()
{
var check = HashCash.Verify("1:20:1303030600:[email protected]::McMybZIhxKXu57jd:ckvi");
Console.WriteLine(check ? "驗證通過" : "驗證失敗");
int totalTime = 0;
for (int i = 0; i < iterations; i++)
{
try
{
HashCash hc = new HashCash("[email protected]");
DateTime start = DateTime.Now;
string header = hc.Compute();
DateTime stop = DateTime.Now;
bool ret = HashCash.Verify(header);
if (!ret)
{
throw new HashCashException("驗證失敗");
}
int ms = (int)((stop - start).TotalMilliseconds);
Console.WriteLine(i + "-> 時間:" + ms + "毫秒 迴圈數 = " + hc.Iterations);
totalTime += ms;
}
catch (HashCashException ex)
{
Console.WriteLine(ex.Message);
break;
}
}
Console.WriteLine("平均時間:" + (int)(totalTime / iterations) + "毫秒");
}
輸出示例(最近19次迭代):
平均而言,算出一個符合要求的 hash 平均需要一秒以上的時間!
結論
我覺得 Hashcash 非常有趣——它在某些方面與驗證碼機制差不多完全相反。Hashcash 驗證發件人是一臺機器(沒有人可以手算那麼多 hash),但是:
- 這臺機器不是被用來發垃圾郵件或是虛假訊息的
- 傳送訊息的機器正在驗證訊息頭(可以擴充套件到包含訊息主體)
- 類似 Hashcash 的機制可以為非法程式上一道閘門,防止其壓垮伺服器。
- 這種“工作證明”演算法已被用於防止拒絕服務攻擊。
NHashCash(我之前釋出過的sourceforge連結)也包含在內,但測試程式碼都被註釋掉了。