账号格式
在之前我们用拼音名代替账号,现在我们来认识比特币中真正的账号格式:
1KY9b81NiCDYzYPTH7yLAR3ZAD7jKejdLG
这是1个典型的比特币账号(地址),以1开头,总共34个字符,区分大小写。
这种格式称为Pay-to-Public-Key-Hash (P2PKH),意思是支付给公钥的的哈希,是比特币的公钥经过哈希再进行base58编码而得到的账号,顾名思义,这种格式不是公钥,而是还要哈希一下。即对公钥还要经过哈希。
当然还有其它的格式,比如Pay-to-Public-Key (P2PK),直接就是公钥做账号,这是最原始的账号形式,还有P2WPKH / P2WSH 等格式(bc1开头),这里我们就拿主流的P2PKH格式来做说明。
关于P2PKH用base58编码而非base64这里也有个有意思的原因,稍后做说明。
生成来源
可以看到,p2pkh账号是通过公钥编码得来的,那么公钥怎么来的呢?公钥是通过私钥生成的,
私钥是什么,私钥是一串32字节,256位二进制的数字,这个是随机的。比如我说的小一点。
一个5位数的数字,12345,28723,99999,都可以用来当作你的私钥,然后通过算法(椭圆算法)计算出公钥,注意这种算法是单向的,无法从公钥反推出私钥,当然这里的5位数字太小了,这样极不安全,很容易被破解。我这里只是举个易懂的例子。
所以这是一个64位十六进制,也即256位二进制的数字,这个数字约等于无穷大,从安全性来讲,你找不到别人的那个数字,也就是私钥,包括暴力计算。从0开始不停的生成公钥找到匹配你的那个公钥。
所以你自己现在可以从里面随机找一个,然后再通过算法生成公钥,你就拥有了属于自己的账号了,注意保护好你的私钥哦,也就是你的那个64位的十六进制的数字。
公私钥关系及说明
私钥经过椭圆算法计算出来公钥,然后呢,仅此而已吗?不,这个算法有个非常有用的特性。
即,这样生成的公私钥,可以用私钥对一串数据进行签名(用特定算法),然后得到签名(其实也是一串数据),可以用公钥来验证是否一致。
这样有什么用呢,大家可能不太理解,我用加解密来说明吧,事实上这不是加解密行为,只是为了便于理解。
比如一个私钥,23345,加密生成了一个公钥22号,现在22号账号有50btc,22号想转账给1号10个btc。
那么怎么说明呢?
那么转账行为即 "22 to 1 50btc",我用私钥把这串数字加密签名,得到签名"xxjjwwww"。
然后你可以只用公钥22,来验证22 to 1 50btc,是否被22的私钥签过名(签名数据xxjjwwww),如果一致。则证明确实是22号想转钱给别人,在不用私钥的情况下能确认账号的所属者。上面这三者,你改了一个字一个标点符号,验证都会出错。
当然实际这个签名验证,不是加解密行为,而是一种签名算法:ECDSA(基于 secp256k1 椭圆曲线),它的工作过程是这样的:
ECDSA(基于 secp256k1 椭圆曲线)
1.签名:用私钥基于数据z生成r, s(两个 256 位整数)
2.验证:根据验证公式,公钥依赖于数据z,和r,s,会计算出一个点P出来。
3.然后验证P的x坐标是否等于签名里的r值,如果相等,那么就证明这个数据确实被你签名过。
4.公钥也是椭圆算法算出来的,所以它们同属一个私钥的话,它们就会有上述的关系。
公钥格式
这个通过椭圆算法从私钥算出来的公钥,长度为65个字节,最前面一个字节规定是04,表明是未压缩的。实际有效数据可以看成64个字节,512位二进制,当然还有另一种长度,是压缩的,前缀不是04,而是02,03这个是表明压缩格式的。
比如在源码中,创世块的账户直接是65个字节的公钥做账户名,如下:
cpp
txNew.vout[0].scriptPubKey = CScript() <<
CBigNum("0x5F1DF16B2B704C8A578D0BBAF74D385CDE12C11EE50455F3C438EF4C3FBCF649B6DE611FEAE06279A60939E028A8D65C10B73071A6F16719274855FEB0FD8A6704")
<< OP_CHECKSIG;
当然后面的版本为了节省空间,是用压缩的方法。
这里我们可以看到一个奇怪的点,开头并不是04,而5f,还是由于大小端序的问题,我们需要反着看,末尾是04.
实际上以太坊的公钥也是同样的算法生成的,只不过是去掉了前缀04,是64个字节,然后不同的是比特币最终采用base58编码,以太坊是哈希公钥后,得到32字节的哈希值,然后直接取后20个字节,前面加上0x,这就是以太坊的账户生成规则。
我们后面将以未压缩的公钥格式来举例。
base58编码
说到base58编码,就不得不说到base64编码,什么是base64编码,就是大小写26个字母,加上0-9的数字,再加上"+" "/"这两个字符。总共有64个。
这种编码用来表达一串数字,其实你可以把它看作是64进制的数字。
那么base58就是58进制,只不它比base64少了,0(数字0),O(o的大写字母),l(L的小写字母),I(i的大小字母),+和/,总共6个字符,为什么要去掉这些,因为这个字符做账号时容易引起混淆,那么64-6=58,就变成了58进制,这就是base58编码的由来,也是比特币为什么采用base58编码的原因。
比特币账号生成规则
接一下来说一下账号详细的生成过程。
1.先对公钥进行sha256哈希,得到32字节的数据。
2.然后再次这个哈希值进行RIPEMD-160哈希,得到20字节的数据。
RIPEMD-160也是一种哈希算法,只不过跟sha256不同的是,看名称后面,就可以明白,256代表32个字节,那160肯定就对应着20个字节了。
3.给这个20字节前面加上版本号"0x00"
4.对这21个字节,进行双重sha256哈希,然后得到一个值,取前4个字节作为校验和
5.将这4个字节加到第3步结果的后面。
6.然后对这个结果进行base58编码。
其实,核心地址,在第2步就已经生成了,就是对公钥进行两次哈希,一次sha256,一次RIPEMD-160。
后面的加版本号0x00,在进行58编码后,对应着58编码里的数字1,这就是为什么账号地址是1开头。
然后又为了,防止输入和传输错误,又加上了校验和,即对这个地址进行两次哈希,得到一个哈希值后,取前四个字节加在账号后面,这就形成了最终的账号。
这个校验和让你可以识别一个账号是不是合法的地址,通过去掉后四个字节,然后对数据进行两次哈希,看这个值的前4个字节是否一致。如果你是胡乱打一个账号,那肯定是不一致的,这不是一个合法的账号。
CKey类说明
在源码中,是使用Ckey类来生成公私钥的,可随机生成。
这个类在key.h文件中,我们新建一个key.h头文件,然后将下面的代码复制进去:
cpp
// Copyright (c) 2009 Satoshi Nakamoto
// Distributed under the MIT/X11 software license, see the accompanying
// file license.txt or http://www.opensource.org/licenses/mit-license.php.
// secp160k1
// const unsigned int PRIVATE_KEY_SIZE = 192;
// const unsigned int PUBLIC_KEY_SIZE = 41;
// const unsigned int SIGNATURE_SIZE = 48;
//
// secp192k1
// const unsigned int PRIVATE_KEY_SIZE = 222;
// const unsigned int PUBLIC_KEY_SIZE = 49;
// const unsigned int SIGNATURE_SIZE = 57;
//
// secp224k1
// const unsigned int PRIVATE_KEY_SIZE = 250;
// const unsigned int PUBLIC_KEY_SIZE = 57;
// const unsigned int SIGNATURE_SIZE = 66;
//
// secp256k1:
// const unsigned int PRIVATE_KEY_SIZE = 279;
// const unsigned int PUBLIC_KEY_SIZE = 65;
// const unsigned int SIGNATURE_SIZE = 72;
//
// see www.keylength.com
// script supports up to 75 for single byte push
#include <openssl/ec.h> // EC_KEY_new_by_curve_name, EC_KEY_generate_key 等
#include <openssl/ecdsa.h> // ECDSA_sign, ECDSA_verify
#include <openssl/obj_mac.h> //支持NID_secp256k1定义
class key_error : public std::runtime_error
{
public:
explicit key_error(const std::string& str) : std::runtime_error(str) {}
};
// secure_allocator is defined is serialize.h
//typedef vector<unsigned char, secure_allocator<unsigned char> > CPrivKey;
//不采用上面的secure_allocator,会有版本兼容性问题,少了私钥安全擦除问题
//就是变量释放后,还得将数据擦除,否则内存块有残留数据之类的。
// 少了这个功能对程序运行无影响,只是这样不太安全。
typedef vector<unsigned char> CPrivKey;
class CKey
{
protected:
EC_KEY* pkey;
public:
CKey()
{
pkey = EC_KEY_new_by_curve_name(NID_secp256k1);
if (pkey == NULL)
throw key_error("CKey::CKey() : EC_KEY_new_by_curve_name failed");
}
CKey(const CKey& b)
{
pkey = EC_KEY_dup(b.pkey);
if (pkey == NULL)
throw key_error("CKey::CKey(const CKey&) : EC_KEY_dup failed");
}
CKey& operator=(const CKey& b)
{
if (!EC_KEY_copy(pkey, b.pkey))
throw key_error("CKey::operator=(const CKey&) : EC_KEY_copy failed");
return (*this);
}
~CKey()
{
EC_KEY_free(pkey);
}
void MakeNewKey()
{
if (!EC_KEY_generate_key(pkey))
throw key_error("CKey::MakeNewKey() : EC_KEY_generate_key failed");
}
bool SetPrivKey(const CPrivKey& vchPrivKey)
{
const unsigned char* pbegin = &vchPrivKey[0];
if (!d2i_ECPrivateKey(&pkey, &pbegin, vchPrivKey.size()))
return false;
return true;
}
CPrivKey GetPrivKey() const
{
unsigned int nSize = i2d_ECPrivateKey(pkey, NULL);
if (!nSize)
throw key_error("CKey::GetPrivKey() : i2d_ECPrivateKey failed");
CPrivKey vchPrivKey(nSize, 0);
unsigned char* pbegin = &vchPrivKey[0];
if (i2d_ECPrivateKey(pkey, &pbegin) != nSize)
throw key_error("CKey::GetPrivKey() : i2d_ECPrivateKey returned unexpected size");
return vchPrivKey;
}
bool SetPubKey(const vector<unsigned char>& vchPubKey)
{
const unsigned char* pbegin = &vchPubKey[0];
if (!o2i_ECPublicKey(&pkey, &pbegin, vchPubKey.size()))
return false;
return true;
}
vector<unsigned char> GetPubKey() const
{
unsigned int nSize = i2o_ECPublicKey(pkey, NULL);
if (!nSize)
throw key_error("CKey::GetPubKey() : i2o_ECPublicKey failed");
vector<unsigned char> vchPubKey(nSize, 0);
unsigned char* pbegin = &vchPubKey[0];
if (i2o_ECPublicKey(pkey, &pbegin) != nSize)
throw key_error("CKey::GetPubKey() : i2o_ECPublicKey returned unexpected size");
return vchPubKey;
}
bool Sign(uint256 hash, vector<unsigned char>& vchSig)
{
vchSig.clear();
unsigned char pchSig[10000];
unsigned int nSize = 0;
if (!ECDSA_sign(0, (unsigned char*)&hash, sizeof(hash), pchSig, &nSize, pkey))
return false;
vchSig.resize(nSize);
memcpy(&vchSig[0], pchSig, nSize);
return true;
}
bool Verify(uint256 hash, const vector<unsigned char>& vchSig)
{
// -1 = error, 0 = bad sig, 1 = good
if (ECDSA_verify(0, (unsigned char*)&hash, sizeof(hash), &vchSig[0], vchSig.size(), pkey) != 1)
return false;
return true;
}
static bool Sign(const CPrivKey& vchPrivKey, uint256 hash, vector<unsigned char>& vchSig)
{
CKey key;
if (!key.SetPrivKey(vchPrivKey))
return false;
return key.Sign(hash, vchSig);
}
static bool Verify(const vector<unsigned char>& vchPubKey, uint256 hash, const vector<unsigned char>& vchSig)
{
CKey key;
if (!key.SetPubKey(vchPubKey))
return false;
return key.Verify(hash, vchSig);
}
};
对比源码,我们还需要改一些东西,保证兼容性,需要包含一些头文件,因为CKey类涉及的算法,也是使用openssl库来完成的。也就是为了支持里面的那些EC_KEY之类的函数。
我们需要包含:
cpp
#include <openssl/ec.h> // EC_KEY_new_by_curve_name, EC_KEY_generate_key 等
#include <openssl/ecdsa.h> // ECDSA_sign, ECDSA_verify
#include <openssl/obj_mac.h> //支持NID_secp256k1定义
只要引入头文件就行了,我附加库在之间的章节已经操作过了。所以这里就不再讲了。
然后是secure_allocator又有兼容性问题,我直接去掉了。
公私钥生成
然后我们只要调用CKey类的MakeNewKey,就能生成一对公私钥:
cpp
int main() {
CKey key;
//生成新钥
key.MakeNewKey();
//获取私钥输出
CPrivKey der_priv = key.GetPrivKey();
std::cout << "DER 私钥 (hex):\n";
for (unsigned char c : der_priv) {
std::cout << std::hex << std::setw(2) << std::setfill('0') << (int)c;
}
std::cout << "\n\n";
// 获取公钥输出
CPrivKey pub = key.GetPubKey();
std::cout << "公钥 (hex):\n";
for (unsigned char c : pub) {
std::cout << std::hex << std::setw(2) << std::setfill('0') << (int)c;
}
std::cout << std::endl;
}
注意包含key.h头文件,以及
std::cout << std::hex << std::setw(2) << std::setfill('0') << (int)c;
setw setfill所需的头文件#include <iomanip>。
这个是用来设置输出格式的,表明一个字节占两位,空位补0。否则一个字节,如果正好是9,那么只会输出0x9,而不是0x09。
运行结果:

(注意,这里每次生成的数据都不一样,因为是随机的)
DER格式私钥
我们可以看到,公钥是没问题的,130个字符(130占位),65个字节,但是私钥明显不对啊,应该是32个字节,按照之前的说法。但这里的私钥足足558位,279个字节,这是为什么呢,这是程序出错了,乱码吗?不是的,请看下面这个,key.h里的注释:
cpp
// Copyright (c) 2009 Satoshi Nakamoto
// Distributed under the MIT/X11 software license, see the accompanying
// file license.txt or http://www.opensource.org/licenses/mit-license.php.
// secp160k1
// const unsigned int PRIVATE_KEY_SIZE = 192;
// const unsigned int PUBLIC_KEY_SIZE = 41;
// const unsigned int SIGNATURE_SIZE = 48;
//
// secp192k1
// const unsigned int PRIVATE_KEY_SIZE = 222;
// const unsigned int PUBLIC_KEY_SIZE = 49;
// const unsigned int SIGNATURE_SIZE = 57;
//
// secp224k1
// const unsigned int PRIVATE_KEY_SIZE = 250;
// const unsigned int PUBLIC_KEY_SIZE = 57;
// const unsigned int SIGNATURE_SIZE = 66;
//
// secp256k1:
// const unsigned int PRIVATE_KEY_SIZE = 279;
// const unsigned int PUBLIC_KEY_SIZE = 65;
// const unsigned int SIGNATURE_SIZE = 72;
//
// see www.keylength.com
// script supports up to 75 for single byte push
请看secp256
private_key_size=279这里,256的椭圆算法,它这里的私钥就是279个字节,这是一种DER格式的私钥,是一种结构,其中包含了32字节的私钥和椭圆算法的一些参数数据等。
所以这个是正常的,关于从里面提取32字节私钥的方法,这里就不做说明了,暂时还用不到,现在有个大概了解即可。如果实在想要,算了,我这里还是提供一下,网上找来的,如下代码函数,提取私钥的函数:
DER提取32字节私钥
cpp
// 函数:从 DER 格式私钥提取 32 字节原始私钥(大端,补前导零)
std::vector<unsigned char> ExtractPrvKeyFromDer(const CPrivKey& der_priv) {
// 1. 从 DER 重新解析出 EC_KEY
const unsigned char* p = der_priv.data();
EC_KEY* temp_key = d2i_ECPrivateKey(nullptr, &p, der_priv.size());
if (!temp_key) {
throw std::runtime_error("d2i_ECPrivateKey 解析失败");
}
// 2. 获取 BIGNUM 格式的私钥
const BIGNUM* bn = EC_KEY_get0_private_key(temp_key);
if (!bn) {
EC_KEY_free(temp_key);
throw std::runtime_error("DER 中没有私钥数据");
}
// 3. 计算实际字节长度(secp256k1 私钥最多 32 字节)
int priv_len = BN_num_bytes(bn);
if (priv_len > 32) {
EC_KEY_free(temp_key);
throw std::runtime_error("私钥长度超过 32 字节");
}
// 4. 创建 32 字节缓冲区,前导补零
std::vector<unsigned char> raw_priv(32, 0);
BN_bn2bin(bn, raw_priv.data() + (32 - priv_len));
// 5. 释放临时 EC_KEY
EC_KEY_free(temp_key);
return raw_priv;
}
调用:
cpp
int main() {
CKey key;
//生成新钥
key.MakeNewKey();
//获取DER私钥
CPrivKey der_priv = key.GetPrivKey();
// 提取32字节私钥
auto extracted_prv = ExtractPrvKeyFromDer(der_priv);
std::cout << "原始32字节私钥 (hex):\n";
for (unsigned char c : extracted_prv) {
std::cout << std::hex << std::setw(2) << std::setfill('0') << (int)c;
}
std::cout << "\n";
// 获取公钥输出
CPrivKey pub = key.GetPubKey();
std::cout << "公钥 (hex):\n";
for (unsigned char c : pub) {
std::cout << std::hex << std::setw(2) << std::setfill('0') << (int)c;
}
std::cout << std::endl;
}
结果:

当然,我没验证过啊,不保证准确。验证方法,把私钥导入其他工具,看公钥是否一致。
应该是没问题的,目前就不验证了,等以后私钥转最终的账号时候,用到这个功能,再验证。
另:关于65字节公钥怎么转1开头的地址,可以用源码中的PubKeyToAddress函数实现。
这个函数实现原理将在后续章节说明。