本文作者:aisiji[1]
以太坊是一个公共的区块链网络,可以通过各种不同类型的账户访问。与比特币类似,底层密码学用的是 SECP256K1 椭圆曲线。但这是什么意思呢?什么是账户?什么是密钥?什么是地址?为什么要校验 checksum ?
一个以太坊账户就是一个 SECP256K1 密钥对。"SECP256K1"只是我们使用的特定椭圆曲线的名称。这个曲线的名称或者规范对于理解密钥对的工作原理并不重要,还有很多不同名称和参数的曲线。
一个密钥对包含一个私钥和一个公钥。私钥需要保密的,通过它可以访问账户。一个私钥只是一个简单的数字:1 是私钥,137 是私钥,29863245 是另一个。为了安全,你不能用这样容易被猜到的数字作为私钥,但是如你所见,它并不是魔法。
# A private key is really just a random number.
# To generate a random private key that nobody
# can guess in Ruby, get 32 random bytes.
require "securerandom"
secret = SecureRandom.hex 32
# => "bec52dffb33ec1f4d629f88232796e898f4294079c5894a6645e8a4f7261fabe"
公钥复杂一些,它是椭圆曲线上的一个点,具有 x 坐标、y 坐标,如(0, 1)
, (42, 138)
, 或者(34876, 4893)
。不过这些都不是曲线上的公钥。
要通过私钥找到这个点,你需要对使用的基点`G`[2]和标量(私钥)执行一个椭圆曲线序列乘法[3]运算。结果会得到另一个点,就是你的公钥。
以太坊账户的密码学不对称性,在于可以用你的私钥证明出你的公钥。相反,关于公钥的信息永远不允许反向揭示私钥。
因此,私钥需要保密,只有你自己可以控制以太坊账户的公钥。
# Generate a secure random private-public
# keypair using the `eth` gem.
require "eth"
require "securerandom"
secret = SecureRandom.hex 32
# => "bec52dffb33ec1f4d629f88232796e898f4294079c5894a6645e8a4f7261fabe"
key = Eth::Key.new priv: secret
# => #<Eth::Key:0x000055ae60f86d58
# @private_key=
# #<Secp256k1::PrivateKey:0x000055ae60f86a38
# @data=
# "\xBE\xC5-\xFF\xB3>\xC1\xF4\xD6)\xF8\x822yn\x89\x8FB\x94\a\x9CX\x94\xA6d^\x8AOra\xFA\xBE">,
# @public_key=#<Secp256k1::PublicKey:0x000055ae60f869e8>>
# The private key is just a number.
key.private_key
# => #<Secp256k1::PrivateKey:0x000055ae60e81458
# @data=
# "\xBE\xC5-\xFF\xB3>\xC1\xF4\xD6)\xF8\x822yn\x89\x8FB\x94\a\x9CX\x94\xA6d^\x8AOra\xFA\xBE">
key.private_hex
# => "bec52dffb33ec1f4d629f88232796e898f4294079c5894a6645e8a4f7261fabe"
# The public key is a ... point?
key.public_key
# => #<Secp256k1::PublicKey:0x000055ae60e813e0>
key.public_hex
# => "040f9802cc197adf104916a6f94f6c93374647db7a3b774586ede221f1eea92b11e02a4be750aa0fe9cf975cec1b69a222841648d4c2ced7b1d108a2c9723e89b8"
现在,我们用eth
gem 生成了一个包含私钥和公钥的密钥对,我们可以很容易看到bec52dffb33ec1f4d629f88232796e898f4294079c5894a6645e8a4f7261fabe
是一个数字。它是十进制数86287827574830678407859947509786169732412250582090939460672560997304142789310
的十六进制表示。如我所说,没有魔法。值得注意的是,我们选择如此庞大的一个数字作为密钥以防止被人猜到。
但什么是公钥,它看起来并不像一个点,对吧?那是因为040f9802cc197adf104916a6f94f6c93374647db7a3b774586ede221f1eea92b11e02a4be750aa0fe9cf975cec1b69a222841648d4c2ced7b1d108a2c9723e89b8
是三个字段的序列化:表示公钥类型的前缀字节04
, 点的 x 坐标0f9802cc197adf104916a6f94f6c93374647db7a3b774586ede221f1eea92b11
, y 坐标e02a4be750aa0fe9cf975cec1b69a222841648d4c2ced7b1d108a2c9723e89b8
。这就是椭圆曲线上的点(7053272788600477553676465022741516421197397404297740301327606102773230807825, 101392809526590995390445177899351250341338058145722255694397773992111072250296)
。前缀字节并没有真正被以太坊使用,只与比特币中的(非)压缩密钥相关,所以我们可以忽略它或者假定它永远都是04
。
现在我们有一个私钥86287827574830678407859947509786169732412250582090939460672560997304142789310
,表示公钥的点 (7053272788600477553676465022741516421197397404297740301327606102773230807825, 101392809526590995390445177899351250341338058145722255694397773992111072250296)
, 接下来是什么?我们可以用这个做什么?
回顾一下,账户是一个私密的数字,可以访问椭圆曲线上一个公共的点,就这么简单!但是现在,我们想要使用一些以太坊区块链功能,如交易 token 或者与去中心化交易所交互。
要在以太坊区块链上接收以太币或者其他资产,你不会希望每次都让你的朋友或者家人发送到这个椭圆曲线上只有你可以访问的坐标(7053272788600477553676465022741516421197397404297740301327606102773230807825, 101392809526590995390445177899351250341338058145722255694397773992111072250296)
这可能有点不方便。
取而代之,我们使用地址。
# The address of the previously
# generated keypair.
key.address
# => #<Eth::Address:0x000055ae60fb8178
# @address="0xc16fd2b4d06bcc9407b4b000b3085832f180f557">
key.address.to_s
# => "0xc16Fd2B4d06BCc9407b4B000b3085832F180F557"
地址是直接从公钥派生[4]而来的:删除前缀字节,进行一轮 Keccak-256 哈希运算,取最后 20 个字节。这就是地址了——哈希的 20 个十六进制字节: 0xc16Fd2B4d06BCc9407b4B000b3085832F180F557
.
require "digest/keccak"
# Pack the public key nicely into a byte string.
public_key = [key.public_hex].pack "H*"
# => "\x04\x0F\x98\x02\xCC\x19z\xDF\x10I\x16\xA6\xF9Ol\x937FG\xDBz;wE\x86\xED\xE2!\xF1\xEE\xA9+\x11\xE0*K\xE7P\xAA\x0F\xE9\xCF\x97\\\xEC\ei\xA2\"\x84\x16H\xD4\xC2\xCE\xD7\xB1\xD1\b\xA2\xC9r>\x89\xB8"
# Cut off the first prefix byte.
public_coordinates = public_key[1..-1]
# => "\x0F\x98\x02\xCC\x19z\xDF\x10I\x16\xA6\xF9Ol\x937FG\xDBz;wE\x86\xED\xE2!\xF1\xEE\xA9+\x11\xE0*K\xE7P\xAA\x0F\xE9\xCF\x97\\\xEC\ei\xA2\"\x84\x16H\xD4\xC2\xCE\xD7\xB1\xD1\b\xA2\xC9r>\x89\xB8"
# Hash the coordinate bytes of the pubic point
address_hash = Digest::Keccak.new(256).digest public_coordinates
# => "_s\xB6RA\xD0r\xF8x\xE2\xD1\xC2\xC1o\xD2\xB4\xD0k\xCC\x94\a\xB4\xB0\x00\xB3\bX2\xF1\x80\xF5W"
# Only grab the last 20 bytes.
address_bin = address_hash[-20..-1]
# => "\xC1o\xD2\xB4\xD0k\xCC\x94\a\xB4\xB0\x00\xB3\bX2\xF1\x80\xF5W"
# Unpack the address and prefix it with `0x`.
address = "0x#{address_bin.unpack("H*").first}"
# => "0xc16fd2b4d06bcc9407b4b000b3085832f180f557"
注意,通过哈希公钥并切断哈希最前面的 12 字节,无法从地址恢复公钥。地址只是账户在区块链上一个简单的占位符。
只有当你有一个私钥映射到一个公钥,并且哈希到一个确切地址,这种情况,区块链才会允许您访问存储在分类账簿上的对应占位符名称中的资产。如何从密码学上证明这一点,这超出了本文的讨论范围,我们之后可能会再讨论签名和交易相关主题。
最后一个小细节有待调查:你是否注意到地址有什么不寻常之处?对,key.address.to_s
返回0xc16Fd2B4d06BCc9407b4B000b3085832F180F557
,包含大小写字母。它不仅仅是一个 20 字节的十六进制字符串,看起来还包含随机大小写字母。为什么呢?
这是因为EIP-55:大小写混合校验 checksum 地址编码[5]。为了防止输入错误或者其他复制、粘贴、传输地址等情况出现失误,十六进制字符串有一个校验 checksum 编码
require "digest/keccak"
# Remove the address' hex-prefix.
unprefixed_address = address[2..-1]
# => "c16fd2b4d06bcc9407b4b000b3085832f180f557"
# Get the Keccak-256 hash of the
# unprefixed address.
checksum = Digest::Keccak.new(256).digest(unprefixed_address.downcase).unpack("H*").first
# => "4bd92ec1770ff46b882ff0297df0ab4ee199a0b1947d3a378089e7127ca58d60"
# Map the checksum to address chars and
# determine capitalization.
checksummed_chars = unprefixed_address.chars.zip(checksum.chars).map do |addr, chck|
chck.match(/[0-7]/) ? addr.downcase : addr.upcase
end
# => ["c", "1", "6", "F", "d", "2", "B", "4", "d", "0", "6", "B", "C", "c", "9", "4", "0", "7", "b", "4", "B", "0", "0", "0", "b", "3", "0", "8", "5", "8", "3", "2", "F", "1", "8", "0", "F", "5", "5", "7"]
# Et voilà, une addresse.
checksummed_address = "0x#{checksummed_chars.join}"
# => "0xc16Fd2B4d06BCc9407b4B000b3085832F180F557"
校验 checksum 算法需要再次对地址进行一轮 Keccak-256 哈希运算,并将该哈希值进行校验 checksum 处理。然后,对于每个字符,我们检查校验 checksum 中对应的十六进制数字是否为0..7
或者8..f
。如果小于 8,我们编码一个小写字母;否则,我们使用大写字母。
我们知道地址只是一个区块链上的占位符,用于存储 token 或者其他只能由特定私钥解锁的资产。
但是谁控制智能合约的账户呢?首先什么是智能合约账户?
简单的说,智能合约就是可执行代码。这段代码被部署到智能合约账户,一个地址,区块链上的占位符,其私钥是未知的。
这是故意的。如果访问智能合约账户的密码被发现,你将无法保障这样的合约不被篡改。
要确定智能合约地址,你需要两样东西:发送账户地址和部署合约时的 nonce。
require "eth"
require "digest/keccak"
# Our sender address, an externally
# owned account.
sender = "0xc16Fd2B4d06BCc9407b4B000b3085832F180F557"
# => "0xc16Fd2B4d06BCc9407b4B000b3085832F180F557"
# Our sender's nonce is `0` because we never
# used this account before.
nonce = 0
# => 0
# RLP-encode both sender and nonce.
encoded = Eth::Rlp.encode [sender, nonce]
# => "\xEC\xAA0xc16Fd2B4d06BCc9407b4B000b3085832F180F557\x80"
# Apply one round of Keccak-256 hashing.
hashed = Digest::Keccak.new(256).digest encoded
# => ">0\x1D\xB1\xA4Lv\x04\xD3ul\x10\xA2sT\xDA\xD4\x9C]\x9C\r\x9A +d\xD6\x80\xC5\xFCN\xFC|"
# Again, only get the last 20 bytes
# of the hash.
address = hashed[-20..-1].unpack("H*").first
# => "a27354dad49c5d9c0d9a202b64d680c5fc4efc7c"
# And that's the address.
address = Eth::Address.new("0x#{address}").to_s
# => "0xa27354dAd49c5d9C0D9a202b64D680c5fC4efC7C"
发送者地址和 nunce 被放进一个数组并进行 RLP 编码。RLP 是以太坊的递归长度前缀[6]。编码后的 RLP 再次用 kecak -256 进行哈希,哈希值的最后 20 个字节就是合约帐户地址。
注意,因为我们不会对公钥进行哈希处理,而是 RLP 编码对象,有一点可以肯定的是,我们无法知道智能合约账户0xa27354dAd49c5d9C0D9a202b64D680c5fC4efC7C
的私钥。
回顾一下,以太坊账户就像很多其他加密账户(OTR, PGP, SSH)一样,是一个公钥-私钥对:
[1]aisiji: https://learnblockchain.cn/people/3291
[2]G
:https://github.com/q9f/secp256k1.cr/blob/b018dd5b5dcbecc8f3dee789d7f18d976614a809/src/constants.cr#L45
[3]椭圆曲线序列乘法: https://github.com/q9f/secp256k1.cr/blob/adcb679e9e4996b0c045cea142f32623f4262d36/src/util.cr#L258-L260
[4]直接从公钥派生: https://github.com/q9f/eth.rb/blob/0773bbf89679933fbce9594eac0bae2c854b5f55/lib/eth/util.rb#L29-L33
[5]EIP-55:大小写混合校验checksum地址编码: https://eips.ethereum.org/EIPS/eip-55
[6]递归长度前缀: https://github.com/q9f/rlp.cr/#understand
[7]Afr Schoe: https://dev.to/q9