引言
你可能聽說過比特幣是基于密碼學(xué),密碼學(xué)可以用來證明秘密的知識,而不會泄露秘密(數(shù)字簽名),或證明數(shù)據(jù)的真實(shí)性(數(shù)字指紋)。 (也許這就是數(shù)學(xué)的魅力吧)
這些類型的加密證明是比特幣中關(guān)鍵的數(shù)學(xué)工具并在比特幣應(yīng)用程序中被廣泛使用。今天我們來實(shí)現(xiàn)比特幣中用來控制資金的所有權(quán)的密碼學(xué),包括密鑰,地址和錢包。代碼差異較大,具體點(diǎn)擊查看
簡介
比特幣中沒有存儲任何個人帳戶相關(guān)的信息,但是當(dāng)別人發(fā)送一些幣給我時,總要有某種途徑識別出我是交易輸出的所有者(即我擁有在這些輸出上鎖定的幣)。比特幣的所有權(quán)是通過數(shù)字密鑰、比特幣地址和數(shù)字簽名來確定的。
數(shù)字密鑰實(shí)際上并不存儲在網(wǎng)絡(luò)中,而是由用戶生成之后,存儲在一個叫做錢包的文件或簡單的數(shù)據(jù)庫中。存儲在用戶錢包中的數(shù)字密鑰完全獨(dú)立于比特幣協(xié)議,可由用戶的錢包軟件生成并管理,而無需參照區(qū)塊鏈或訪問網(wǎng)絡(luò)。密鑰實(shí)現(xiàn)了比特幣的許多有趣特性,包括去中心化信任和控制、所有權(quán)認(rèn)證和基于密碼學(xué)證明的安全模型。
公鑰加密
公鑰加密(public-key cryptography)算法使用的是成對的密鑰:私鑰+由私鑰衍生出的唯一的公鑰。

私鑰
私鑰其實(shí)就是一個隨機(jī)選出的數(shù)字而已,私鑰用于生成支付比特幣所必需的簽名以證明對資金的所有權(quán)。所以私鑰一定要保密,不能泄露給第三方。私鑰還必須進(jìn)行備份,以防意外丟失,因?yàn)樗借€一旦丟失就難以復(fù)原,其所保護(hù)的比特幣也將永遠(yuǎn)丟失。
公鑰
公鑰是通過橢圓曲線乘法從私鑰計(jì)算得到的,在數(shù)學(xué)上,這是不可逆轉(zhuǎn)的過程,所以我們無法從公鑰推導(dǎo)出私鑰。具體的數(shù)學(xué)原理就不展開了,有興趣的小伙伴可以去學(xué)習(xí)下。
地址
比特幣地址是一個由數(shù)字和字母組成的字符串,可以與任何想給你比特幣的人分享。地址是由公鑰經(jīng)過哈希計(jì)算得到,我們平時見到的地址是公鑰哈希以后,再加上版本前綴,最后通過 Base58Check 編碼的到的。也就是類似這樣 “1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa
”以 1 打頭的地址。

數(shù)字簽名
在數(shù)學(xué)和密碼學(xué)中,有一個數(shù)字簽名(digital signature)的概念,算法可以保證:
- 當(dāng)數(shù)據(jù)從發(fā)送方傳送到接收方時,數(shù)據(jù)不會被修改;
- 數(shù)據(jù)由某一確定的發(fā)送方創(chuàng)建;
- 發(fā)送方無法否認(rèn)發(fā)送過數(shù)據(jù)這一事實(shí)。
通過在數(shù)據(jù)上應(yīng)用簽名算法(也就是對數(shù)據(jù)進(jìn)行簽名),你就可以得到一個簽名,這個簽名晚些時候會被驗(yàn)證。生成數(shù)字簽名需要一個私鑰,而驗(yàn)證簽名需要一個公鑰。
為了對數(shù)據(jù)進(jìn)行簽名,我們需要下面兩樣?xùn)|西:
- 要簽名的數(shù)據(jù)
- 私鑰
應(yīng)用簽名算法可以生成一個簽名,并且這個簽名會被存儲在交易輸入中。為了對一個簽名進(jìn)行驗(yàn)證,我們需要以下三樣?xùn)|西:
- 被簽名的數(shù)據(jù)
- 簽名
- 公鑰
在比特幣中,每一筆交易輸入都會由創(chuàng)建交易的人簽名。在被放入到一個塊之前,必須要對每一筆交易進(jìn)行驗(yàn)證。除了一些其他步驟,驗(yàn)證意味著:
- 檢查交易輸入有權(quán)使用來自之前交易的輸出
- 檢查交易簽名是正確的

現(xiàn)在來回顧一個交易完整的生命周期:
- 起初,創(chuàng)世塊里面包含了一個 coinbase 交易。在 coinbase 交易中,沒有輸入,所以也就不需要簽名。coinbase 交易的輸出包含了一個哈希過的公鑰(使用的是 RIPEMD16(SHA256(PubKey)) 算法)
- 當(dāng)一個人發(fā)送幣時,就會創(chuàng)建一筆交易。這筆交易的輸入會引用之前交易的輸出。每個輸入會存儲一個公鑰(沒有被哈希)和整個交易的一個簽名。
- 比特幣網(wǎng)絡(luò)中接收到交易的其他節(jié)點(diǎn)會對該交易進(jìn)行驗(yàn)證。除了一些其他事情,他們還會檢查:在一個輸入中,公鑰哈希與所引用的輸出哈希相匹配(這保證了發(fā)送方只能花費(fèi)屬于自己的幣);簽名是正確的(這保證了交易是由幣的實(shí)際擁有者所創(chuàng)建)。
- 當(dāng)一個礦工準(zhǔn)備挖一個新塊時,他會將交易放到塊中,然后開始挖礦。
- 當(dāng)新塊被挖出來以后,網(wǎng)絡(luò)中的所有其他節(jié)點(diǎn)會接收到一條消息,告訴其他人這個塊已經(jīng)被挖出并被加入到區(qū)塊鏈。
- 當(dāng)一個塊被加入到區(qū)塊鏈以后,交易就算完成,它的輸出就可以在新的交易中被引用。
地址實(shí)現(xiàn)
我們先從錢包開始,錢包其實(shí)是幫助我們生成并管理密鑰對的地方。
在這之前我們先安裝兩個庫
composer require mdanter/ecc
composer require bitwasp/bitcoin
新建一個 Wallet.php 與 Wallets.php
class Wallet
{
/**
* @var string $privateKey
*/
public $privateKey;
/**
* @var string $publicKey
*/
public $publicKey;
/**
* Wallet constructor.
* @throws \BitWasp\Bitcoin\Exceptions\RandomBytesFailure
*/
public function __construct()
{
list($privateKey, $publicKey) = $this->newKeyPair();
$this->privateKey = $privateKey;
$this->publicKey = $publicKey;
}
/**
* @return string
* @throws \Exception
*/
public function getAddress(): string
{
$addrCreator = new AddressCreator();
$factory = new P2pkhScriptDataFactory();
$scriptPubKey = $factory->convertKey((new PublicKeyFactory())->fromHex($this->publicKey))->getScriptPubKey();
$address = $addrCreator->fromOutputScript($scriptPubKey);
return $address->getAddress(Bitcoin::getNetwork());
}
/**
* @return array
* @throws \BitWasp\Bitcoin\Exceptions\RandomBytesFailure
*/
private function newKeyPair(): array
{
$privateKeyFactory = new PrivateKeyFactory();
$privateKey = $privateKeyFactory->generateCompressed(new Random());
$publicKey = $privateKey->getPublicKey();
return [$privateKey->getHex(), $publicKey->getHex()];
}
}
class Wallets
{
/**
* @var Wallet[] $wallets
*/
public $wallets;
public function __construct()
{
$this->loadFromFile();
}
public function createWallet(): string
{
$wallet = new Wallet();
$address = $wallet->getAddress();
$this->wallets[$address] = $wallet;
return $address;
}
public function saveToFile()
{
$walletsSer = serialize($this->wallets);
if (!is_dir(storage_path())) {
mkdir(storage_path(), 0777, true);
}
file_put_contents(storage_path() . '/walletFile', $walletsSer);
}
public function loadFromFile()
{
$wallets = [];
if (file_exists(storage_path() . '/walletFile')) {
$contents = file_get_contents(storage_path() . '/walletFile');
if (!empty($contents)) {
$wallets = unserialize($contents);
}
}
$this->wallets = $wallets;
}
public function getWallet(string $from)
{
if (isset($this->wallets[$from])) {
return $this->wallets[$from];
}
echo "錢包不存在該地址";
exit(0);
}
public function getAddresses(): array
{
return array_keys($this->wallets);
}
}
在 Wallet 當(dāng)中,我們存儲一對密鑰,newKeyPair() 方法中使用第三方庫(PrivateKeyFactory)創(chuàng)建了私鑰與公鑰,并返回對應(yīng)的十六進(jìn)制字符串。getAddress() 則是從公鑰計(jì)算出地址(解釋下P2pkhScriptDataFactory,P2PKH是比特幣中最常見的交易類型,即支付到一個公鑰哈希,還記得之前說的生成地址時的前綴嗎?類型不同,其實(shí)前綴是不一樣的,這里我們只實(shí)現(xiàn)P2PKH這一種類型的地址就好了)。
在 Wallets 中,則是創(chuàng)建 Wallet 放入一個map中,并且有一些輔助方法讓我們能持久化錢包數(shù)據(jù)。
下面更新 * TXInput* 與 * TXOutput*
class TXInput
{
/**
* @var string $txId
*/
public $txId;
/**
* @var int $vOut
*/
public $vOut;
/**
* @var string $signature
*/
public $signature;
/**
* @var string $pubKey
*/
public $pubKey;
public function __construct(string $txId, int $vOut, string $signature, string $pubKey)
{
$this->txId = $txId;
$this->vOut = $vOut;
$this->signature = $signature;
$this->pubKey = $pubKey;
}
/**
* @param string $pubKeyHash
* @return bool
* @throws \Exception
*/
public function usesKey(string $pubKeyHash): bool
{
$pubKeyIns = (new PublicKeyFactory())->fromHex($this->pubKey);
return $pubKeyIns->getPubKeyHash()->getHex() == $pubKeyHash;
}
}
class TXOutput
{
/**
* @var int $value
*/
public $value;
/**
* @var string $pubKeyHash
*/
public $pubKeyHash;
public function __construct(int $value, string $pubKeyHash)
{
$this->value = $value;
$this->pubKeyHash = $pubKeyHash;
}
public function isLockedWithKey(string $pubKeyHash): bool
{
return $this->pubKeyHash == $pubKeyHash;
}
public static function NewTxOutput(int $value, string $address)
{
$txOut = new TXOutput($value, '');
$pubKeyHash = $txOut->lock($address);
$txOut->pubKeyHash = $pubKeyHash;
return $txOut;
}
private function lock(string $address): string
{
$addCreator = new AddressCreator();
$addInstance = $addCreator->fromString($address);
$pubKeyHash = $addInstance->getScriptPubKey()->getHex(); // 這是攜帶版本+后綴校驗(yàn)的值,需要裁剪一下
return $pubKeyHash = substr($pubKeyHash, 6, mb_strlen($pubKeyHash) - 10);
}
}
注意,現(xiàn)在我們已經(jīng)不再需要 ScriptPubKey 和 ScriptSig 字段,因?yàn)槲覀儾粫?shí)現(xiàn)一個腳本語言。相反,ScriptSig 會被分為 Signature 和 PubKey 字段,ScriptPubKey 被重命名為 PubKeyHash。我們會實(shí)現(xiàn)跟比特幣里一樣的輸出鎖定/解鎖和輸入簽名邏輯,不同的是我們會通過方法(method)來實(shí)現(xiàn)。
UsesKey 方法檢查輸入使用了指定密鑰來解鎖一個輸出。注意到輸入存儲的是原生的公鑰(也就是沒有被哈希的公鑰),但是這個函數(shù)要求的是哈希后的公鑰。IsLockedWithKey 檢查是否提供的公鑰哈希被用于鎖定輸出。這是一個 UsesKey 的輔助函數(shù),并且它們都被用于 FindUnspentTransactions 來形成交易之間的聯(lián)系。
Lock 只是簡單地鎖定了一個輸出。當(dāng)我們給某個人發(fā)送幣時,我們只知道他的地址,因?yàn)檫@個函數(shù)使用一個地址作為唯一的參數(shù)。然后,地址會被解碼,從中提取出公鑰哈希并保存在 PubKeyHash 字段。
另外為了方便修改,我們創(chuàng)建一個新的 NewTxOutput,外面創(chuàng)建 TXOutput 的地方,都使用該方法代替。
實(shí)現(xiàn)簽名
class Transaction {
public function sign(string $privateKey, array $prevTXs)
{
if ($this->isCoinbase()) {
return;
}
$txCopy = $this->trimmedCopy();
foreach ($txCopy->txInputs as $inId => $txInput) {
$prevTx = $prevTXs[$txInput->txId];
$txCopy->txInputs[$inId]->signature = '';
$txCopy->txInputs[$inId]->pubKey = $prevTx->txOutputs[$txInput->vOut]->pubKeyHash;
$txCopy->setId();
$txCopy->txInputs[$inId]->pubKey = '';
$signature = (new PrivateKeyFactory())->fromHexCompressed($privateKey)->sign(new Buffer($txCopy->id))->getHex();
$this->txInputs[$inId]->signature = $signature;
}
}
public function verify(array $prevTXs): bool
{
$txCopy = $this->trimmedCopy();
foreach ($this->txInputs as $inId => $txInput) {
$prevTx = $prevTXs[$txInput->txId];
$txCopy->txInputs[$inId]->signature = '';
$txCopy->txInputs[$inId]->pubKey = $prevTx->txOutputs[$txInput->vOut]->pubKeyHash;
$txCopy->setId();
$txCopy->txInputs[$inId]->pubKey = '';
$signature = $txInput->signature;
$signatureInstance = SignatureFactory::fromHex($signature);
$pubKey = $txInput->pubKey;
$pubKeyInstance = (new PublicKeyFactory())->fromHex($pubKey);
$bool = $pubKeyInstance->verify(new Buffer($txCopy->id), $signatureInstance);
if ($bool == false) {
return false;
}
}
return true;
}
private function trimmedCopy(): Transaction
{
$inputs = [];
$outputs = [];
foreach ($this->txInputs as $txInput) {
$inputs[] = new TXInput($txInput->txId, $txInput->vOut, '', '');
}
foreach ($this->txOutputs as $txOutput) {
$outputs[] = new TXOutput($txOutput->value, $txOutput->pubKeyHash);
}
return new Transaction($inputs, $outputs);
}
public static function NewUTXOTransaction(string $from, string $to, int $amount, BlockChain $bc): Transaction
{
$wallets = new Wallets();
$wallet = $wallets->getWallet($from);
list($acc, $validOutputs) = $bc->findSpendableOutputs($wallet->getPubKeyHash(), $amount);
if ($acc < $amount) {
echo "余額不足";
exit;
}
......
$tx = new Transaction($inputs, $outputs);
$bc->signTransaction($tx, $wallet->privateKey);
return $tx;
}
}
trimmedCopy 復(fù)制出一個修剪后的交易副本,而不是一個完整交易,然后對交易的每一個輸出構(gòu)建好 pubKey,此時計(jì)算出當(dāng)前交易的 hash 值最為簽名的數(shù)據(jù),在賦值回真實(shí)的交易輸入簽名字段。
$txCopy->txInputs[$inId]->pubKey = ''; 是為了保證每個輸入($txInput)不受上一次迭代的影響。
verify 驗(yàn)證方法當(dāng)然也是一樣,構(gòu)造出簽名數(shù)據(jù),用公鑰驗(yàn)證。只有所有的交易輸入簽名都通過驗(yàn)證時,該方法采訪會 true。
修改 NewUTXOTransaction 現(xiàn)在構(gòu)造一筆交易時,需要簽名($bc->signTransaction($tx, $wallet->privateKey);)。
BlockChain
class BlockChain {
public function mineBlock(array $transactions)
{
$lastHash = Cache::get('l');
if (is_null($lastHash)) {
echo "還沒有區(qū)塊鏈,請先初始化";
exit;
}
foreach ($transactions as $tx) {
if (!$this->verifyTransaction($tx)) {
echo "交易驗(yàn)證失敗";
exit(0);
}
}
......
}
public function signTransaction(Transaction $tx, string $privateKey)
{
$prevTXs = [];
foreach ($tx->txInputs as $txInput) {
$prevTx = $this->findTransaction($txInput->txId);
$prevTXs[$prevTx->id] = $prevTx;
}
$tx->sign($privateKey, $prevTXs);
}
public function verifyTransaction(Transaction $tx): bool
{
$prevTXs = [];
foreach ($tx->txInputs as $txInput) {
$prevTx = $this->findTransaction($txInput->txId);
$prevTXs[$prevTx->id] = $prevTx;
}
return $tx->verify($prevTXs);
}
// 還有些其他方法的修改
}
現(xiàn)在 mineBlock 時,需要驗(yàn)證交易的每個輸入。還有些其他的修改,比如 findUnspentTransactions findSpentOutputs findSpendableOutputs findUTXO等方法,不再使用地址,而是 pubKeyHash 去尋找未花費(fèi)輸出。
CLI 更新
新建一個 CreateWallet 以及 ListAddresses 命令。
class CreateWallet extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'createwallet';
/**
* The console command description.
*
* @var string
*/
protected $description = '創(chuàng)建一個錢包';
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$wallets = new Wallets();
$address = $wallets->createWallet();
$wallets->saveToFile();
$this->info("Your new address: {$address}");
}
}
class ListAddresses extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'listaddresses';
/**
* The console command description.
*
* @var string
*/
protected $description = '錢包所有地址';
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$wallets = new Wallets();
$addresses = $wallets->getAddresses();
foreach ($addresses as $address) {
$this->info($address);
}
}
}
測試
$ php blockchain createwallet
Your new address: 1LRqVSu8Kv9fPgdvXLWP5mTnMxC7TYiYjt
$ php blockchain init-blockchain 1LRqVSu8Kv9fPgdvXLWP5mTnMxC7TYiYjt
init blockchain: ?
$ php blockchain createwallet
Your new address: 1PWiJKQzxdWnePvWjfD3EPnfskAxiGfejX
$ php blockchain send 1LRqVSu8Kv9fPgdvXLWP5mTnMxC7TYiYjt 1PWiJKQzxdWnePvWjfD3EPnfskAxiGfejX 30
send success
0000015150b22a1a85a78f3a408b2caca4a6a7165677654b3f461937eab982eb
$ php blockchain getbalance 1LRqVSu8Kv9fPgdvXLWP5mTnMxC7TYiYjt
balance of address '1LRqVSu8Kv9fPgdvXLWP5mTnMxC7TYiYjt' is: 20
$ php blockchain getbalance 1PWiJKQzxdWnePvWjfD3EPnfskAxiGfejX
balance of address '1PWiJKQzxdWnePvWjfD3EPnfskAxiGfejX' is: 30
$ php blockchain listaddresses
1LRqVSu8Kv9fPgdvXLWP5mTnMxC7TYiYjt
1PWiJKQzxdWnePvWjfD3EPnfskAxiGfejX
Wow,看起來沒啥問題。
總結(jié)
本次我們實(shí)現(xiàn)了錢包、地址、簽名等功能,再次提醒,代碼變動較大,有啥疑問請點(diǎn)擊這里
在下一節(jié)我們會修改一下交易,讓它看起來更接近真實(shí)的區(qū)塊鏈。