LaravelZero 從零實現區塊鏈(五)錢包、地址與金鑰

鼓樓的夜色中發表於2020-05-12

你可能聽說過比特幣是基於密碼學,密碼學可以用來證明祕密的知識,而不會洩露祕密(數字簽名),或證明資料的真實性(數字指紋)。 (也許這就是數學的魅力吧)

這些型別的加密證明是比特幣中關鍵的數學工具並在比特幣應用程式中被廣泛使用。今天我們來實現比特幣中用來控制資金的所有權的密碼學,包括金鑰,地址和錢包。程式碼差異較大,具體點選檢視

比特幣中沒有儲存任何個人帳戶相關的資訊,但是當別人傳送一些幣給我時,總要有某種途徑識別出我是交易輸出的所有者(即我擁有在這些輸出上鎖定的幣)。比特幣的所有權是通過數字金鑰、比特幣地址和數字簽名來確定的。

數字金鑰實際上並不儲存在網路中,而是由使用者生成之後,儲存在一個叫做錢包的檔案或簡單的資料庫中。儲存在使用者錢包中的數字金鑰完全獨立於比特幣協議,可由使用者的錢包軟體生成並管理,而無需參照區塊鏈或訪問網路。金鑰實現了比特幣的許多有趣特性,包括去中心化信任和控制、所有權認證和基於密碼學證明的安全模型。

公鑰加密(public-key cryptography)演算法使用的是成對的金鑰:私鑰+由私鑰衍生出的唯一的公鑰。

私鑰、公鑰、地址

私鑰

私鑰其實就是一個隨機選出的數字而已,私鑰用於生成支付比特幣所必需的簽名以證明對資金的所有權。所以私鑰一定要保密,不能洩露給第三方。私鑰還必須進行備份,以防意外丟失,因為私鑰一旦丟失就難以復原,其所保護的比特幣也將永遠丟失。

公鑰

公鑰是通過橢圓曲線乘法從私鑰計算得到的,在數學上,這是不可逆轉的過程,所以我們無法從公鑰推匯出私鑰。具體的數學原理就不展開了,有興趣的小夥伴可以去學習下。

地址

比特幣地址是一個由數字和字母組成的字串,可以與任何想給你比特幣的人分享。地址是由公鑰經過雜湊計算得到,我們平時見到的地址是公鑰雜湊以後,再加上版本字首,最後通過 Base58Check 編碼的到的。也就是類似這樣 “1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa
”以 1 打頭的地址。

地址生成過程

數字簽名

在數學和密碼學中,有一個數字簽名(digital signature)的概念,演算法可以保證:

  • 當資料從傳送方傳送到接收方時,資料不會被修改;
  • 資料由某一確定的傳送方建立;
  • 傳送方無法否認傳送過資料這一事實。

通過在資料上應用簽名演算法(也就是對資料進行簽名),你就可以得到一個簽名,這個簽名晚些時候會被驗證。生成數字簽名需要一個私鑰,而驗證簽名需要一個公鑰。

為了對資料進行簽名,我們需要下面兩樣東西:

  • 要簽名的資料
  • 私鑰

應用簽名演算法可以生成一個簽名,並且這個簽名會被儲存在交易輸入中。為了對一個簽名進行驗證,我們需要以下三樣東西:

  • 被簽名的資料
  • 簽名
  • 公鑰

在比特幣中,每一筆交易輸入都會由建立交易的人簽名。在被放入到一個塊之前,必須要對每一筆交易進行驗證。除了一些其他步驟,驗證意味著:

  • 檢查交易輸入有權使用來自之前交易的輸出
  • 檢查交易簽名是正確的

簽名與驗證

現在來回顧一個交易完整的生命週期:

  1. 起初,創世塊裡面包含了一個 coinbase 交易。在 coinbase 交易中,沒有輸入,所以也就不需要簽名。coinbase 交易的輸出包含了一個雜湊過的公鑰(使用的是 RIPEMD16(SHA256(PubKey)) 演算法)
  2. 當一個人傳送幣時,就會建立一筆交易。這筆交易的輸入會引用之前交易的輸出。每個輸入會儲存一個公鑰(沒有被雜湊)和整個交易的一個簽名。
  3. 比特幣網路中接收到交易的其他節點會對該交易進行驗證。除了一些其他事情,他們還會檢查:在一個輸入中,公鑰雜湊與所引用的輸出雜湊相匹配(這保證了傳送方只能花費屬於自己的幣);簽名是正確的(這保證了交易是由幣的實際擁有者所建立)。
  4. 當一個礦工準備挖一個新塊時,他會將交易放到塊中,然後開始挖礦。
  5. 當新塊被挖出來以後,網路中的所有其他節點會接收到一條訊息,告訴其他人這個塊已經被挖出並被加入到區塊鏈。
  6. 當一個塊被加入到區塊鏈以後,交易就算完成,它的輸出就可以在新的交易中被引用。

我們先從錢包開始,錢包其實是幫助我們生成並管理金鑰對的地方。
在這之前我們先安裝兩個庫

composer require mdanter/ecc
composer require bitwasp/bitcoin

新建一個 Wallet.phpWallets.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 當中,我們儲存一對金鑰,newKeyPair() 方法中使用第三方庫(PrivateKeyFactory)建立了私鑰與公鑰,並返回對應的十六進位制字串。

getAddress() 則是從公鑰計算出地址(解釋下P2pkhScriptDataFactory,P2PKH是比特幣中最常見的交易型別,即支付到一個公鑰雜湊,還記得之前說的生成地址時的字首嗎?型別不同,其實字首是不一樣的,這裡我們只實現P2PKH這一種型別的地址就好了)。

Wallets 中,則是建立 Wallet 放入一個map中,有一些輔助方法saveToFile() loadFromFile()讓我們能持久化錢包資料。

下面更新 * 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();    // 這是攜帶版本+字尾校驗的值,需要裁剪一下
        return $pubKeyHash = substr($pubKeyHash, 6, mb_strlen($pubKeyHash) - 10);
    }
}

注意,現在我們已經不再需要 scriptPubKeyscriptSig 欄位,因為我們不會實現一個指令碼語言。相反,scriptSig 會被分為 signaturepubKey 欄位,scriptPubKey 被重新命名為 pubKeyHash。我們會實現跟比特幣裡一樣的輸出鎖定/解鎖和輸入簽名邏輯,不同的是我們會通過方法(method)來實現。

usesKey 方法檢查輸入使用了指定金鑰來解鎖一個輸出。注意到輸入儲存的是原生的公鑰(也就是沒有被雜湊的公鑰),但是這個函式要求的是雜湊後的公鑰。isLockedWithKey 檢查是否提供的公鑰雜湊被用於鎖定輸出。這是一個 usesKey 的輔助函式,並且它們都被用於 findUnspentTransactions 來形成交易之間的聯絡。

lock 只是簡單地鎖定了一個輸出。當我們給某個人傳送幣時,我們只知道他的地址,因為這個函式使用一個地址作為唯一的引數。然後,地址會被解碼,從中提取出公鑰雜湊並儲存在 pubKeyHash 欄位。

另外為了方便修改,我們建立一個新的 newTxOutput,外面建立 TXOutput 的地方,都使用該方法代替。

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 複製出一個修剪後的交易副本,而不是一個完整交易,然後對交易的每一個輸出構建好 pubKey,此時計算出當前交易的 hash 值作為簽名的資料,在賦值回真實的交易輸入簽名欄位。
$txCopy->txInputs[$inId]->pubKey = ''; 是為了保證每個輸入($txInput)不受上一次迭代的影響。

verify 驗證方法當然也是一樣,構造出簽名資料,用公鑰驗證。只有所有的交易輸入簽名都通過驗證時,該方法才會返回 true

修改 NewUTXOTransaction 現在構造一筆交易時,需要簽名($bc->signTransaction($tx, $wallet->privateKey);)。

BlockChain

class BlockChain {
    public function mineBlock(array $transactions)
    {
        $lastHash = Cache::get('l');
        if (is_null($lastHash)) {
            echo "還沒有區塊鏈,請先初始化";
            exit;
        }

        foreach ($transactions as $tx) {
            if (!$this->verifyTransaction($tx)) {
                echo "交易驗證失敗";
                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);
    }

   // 還有些其他方法的修改
}

現在 mineBlock 時,需要驗證交易的每個輸入。還有些其他的修改,比如 findUnspentTransactions findSpentOutputs findSpendableOutputs findUTXO等方法,不再使用地址,而是 pubKeyHash 去尋找未花費輸出。

新建一個 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 = '建立一個錢包';

    /**
     * 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,看起來沒啥問題。

本次我們實現了錢包、地址、簽名等功能,再次提醒,程式碼變動較大,有啥疑問請點選這裡

在下一節我們會修改一下交易,讓它看起來更接近真實的區塊鏈。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章