基于签名的鉴权架构
Last updated on January 16, 2025 pm
请求处理的核心一环是鉴权,而在加密货币交易所中,鉴权的要求更为严格,因为它涉及到用户的资产安全。本文介绍一下我在前司基础架构组工作时瞥见的鉴权架构与实践。
核心原理
简单来说,签名鉴权的核心原理就是使用一个字符串对另一个字符串进行散列 / 哈希,然后得到一个新的字符串,该字符串不可逆,且不考虑碰撞的情况下,唯一对应于原字符串。
我们将它套用到请求场景中,可以变化为:
实践中,我们一般使用 HMAC-SHA256 算法进行签名:
func main() {
data := "Hello, world!"
key := "my_secret_key"
h := hmac.New(sha256.New, []byte(key))
h.Write([]byte(data))
hash := h.Sum(nil)
hashHex := hex.EncodeToString(hash)
fmt.Println("HMAC-SHA-256 Hash:", hashHex)
}
HMAC-SHA-256 Hash: 62aedf0125252922581bf109e6efc01ee2fbef97f9d60f5c065ce4a25e75273b
一般来说,需要参与签名的请求信息主要是请求参数,比如 Binance 给出的命令行示例:
echo -n "symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000×tamp=1499827319559" |
openssl dgst -sha256 -hmac "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j"
SHA2-256(stdin)= c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71
生成出来的 signature
作为一个额外的参数附加在请求中捎带给服务器:
curl -H "X-MBX-APIKEY: vmPUZE6mv9SD5VNHk4HlWFsOr6aKE2zvsw0MuIgwCIPy6utIco14y7Ju91duEh8A" \
-X POST 'https://api.binance.com/api/v3/order' \
-d 'symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000×tamp=1499827319559&signature=c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71'
服务端在接到请求后,会根据请求参数和密钥重新计算签名,然后与请求中的签名进行比对,如果一致则通过,否则拒绝。
考虑一下比对不一致的情况,有如下两种原因:
- 请求被篡改,比如用户想买一个 BTC,被篡改为买 100 个 BTC
- 密钥不正确
SUMMARY:
从上可见,如果签名验证成功,我们可以说请求是合法的。
因为上述签名是基于密钥的,所以混合了防篡改和鉴权的功能。
不难看出,签名鉴权的核心在于密钥的管理 —— 用户需要使用密钥生成签名,服务器需要使用相同的密钥验证签名。
用户创建
前面提到,签名的核心在于密钥的管理,而密钥的初始化就发生在用户创建的时候 —— 用户注册时,我们需要给用户生成一个密钥,后续用户的请求就使用这个密钥进行签名。
这里引出三个问题:
-
创建请求本身的鉴权 —— 此时还没有用户密钥
-
用户密钥的安全传输 —— 生成密钥后,如何安全地传输给用户
-
用户密钥的安全存储 —— 如何安全地保存密钥
调用方发起请求
classDiagram
direction LR
class CLIENT {
data_key: string
fingerprint: string
user_info: dict
gen_data_key() string
gen_fingerprint() string
encrypt_user_info(user_info, data_key) dict
encrypt_fingerprint(fingerprint, data_key) string
encrypt_data_key(data_key) string
sign(dict) string
}
class REQUEST {
data_key_encrypted: string
fingerprint_encrypted: string
user_info_encrypted: dict
signature: string
}
CLIENT --> REQUEST : sends
考虑请求的构建过程如下:
- 用户注册时,初始状态下,主要包含 3 个明文信息:
data_key
:当前请求的数据加密密钥,是对称的。并且该密钥是临时生成的,特用于处理账号注册请求。fingerprint
:当前用户的指纹,使用特殊算法生成,可以包含设备 ID 或其它的特征。user_info
: 用户的基本信息,比如邮箱、电话等。
- 然后根据以上信息生成
signature
:
考虑这里的签名是如何生成的:
可以确定的是,这里的签名和用户密钥无关,因为此时用户还没有密钥。
这里的签名实际上是调用方在 AWS KMS 中,使用非对称密钥生成的签名,这个密钥对只有两端能够访问,分别是:
- 外层调用方服务
- 内层基础架构账号服务
也就是说,内层基础服务依赖 AWS KMS 来验证请求方的身份,只处理真正来自我司外层服务团队发过来的请求。
- 接着,使用
data_key
加密user_info
和fingerprint
data_key + user_info -> user_info_encrypted
data_key + fingerprint -> fingerprint_encrypted
- 最后,加密
data_key
本身
这一步也是利用 AWS KMS 完成的,同样保证只有两端能够访问。即data_key + KMS -> data_key_encrypted
至此,我们将上述信息打包成一个请求,发送给服务端。
SUMMARY:
考虑这里的请求是如何保证安全性的:
data_key
的理论粒度是请求级别的, 每个新用户注册时使用的data_key
都是不同的。- 所有数据都使用
data_key
加密,保证了数据的机密性。 - 使用基于 AWS KMS 的内部签名,保证了请求的完整性和 ACL 功能。
服务端验证请求
接下来看服务端接到请求之后如何对请求进行验证。
classDiagram
direction LR
class REQUEST {
data_key_encrypted: string
fingerprint_encrypted: string
user_info_encrypted: dict
signature: string
}
class KMS {
decrypt_data_key() string
decrypt_common_salt() string
sign()
}
class SERVER {
data_key: string
common_salt: string
}
class DATABASE {
common_salt_encrypted: string
}
REQUEST --> KMS
DATABASE --> KMS
KMS --> SERVER : data_key + common_salt
- 服务端经过上面步骤可以拿到
data_key
和common_salt
- 然后使用
data_key
对请求中的user_info
和fingerprint
进行解密,得到明文信息 - 使用解密后的信息找 KMS 验证签名
至此,验证步骤已经完成。
用户密钥的生成、存储和返回
生成步骤比较简单,根据一定算法随机生成一下即可。这里省略具体的生成算法。假设我们已经生成了 secret_key_plain_text,重点关注后续步骤:即存储和返回。
-
存储
分两步骤考虑存储问题:- 如何加密存储
secret_key_plain_text
—— 显然我们不能直接存储明文。 - 进一步的,密钥的粒度是什么 —— 全局唯一吗?
一个简单的思路是,继续利用 AWS KMS 对这个
secret_key_plain_text
进行加密,然后存储密文在数据库中。其实对于大部分场景,这个方案是基本够用的,但是这个方案最明显的问题在于:所有用户的secret_key
都用的一个密钥加密。假设这个唯一的密钥被破解了,那么所有用户的密钥都可以被解密。因此我们选择了另外一种方案,在不影响性能的情况下,做到用户粒度的密钥加密,即每个用户的密钥都使用不同的密钥加密。这样即使某个密钥被破解,也只能影响到一个用户:
上一步中我们拿到了
common_salt
以及fingerprint
的明文,我们可以直接使用这两个信息拼接生成加密密钥。func GenStorageKey(commonSalt, fingerprint string) string { return hash(commonSalt + fingerprint) }
然后我们使用该
storage_key
加密secret_key
得到secret_key_encrypted
再进行存储。存储了用户
secret_key
还不够,storage_key
也需要存储,否则后续无法解密secret_key
。这一步我们选择使用 AWS KMS 分别加密fingerprint
和common_salt
,然后存储在数据库中,而不是加密storage_key
在存储。classDiagram direction RL class UserSecretKeyRecord { user_id: u64 secret_key_encrypted: string fingerprint_encrypted: string } class CommonSecretRecord { common_salt_encrypted: string } UserSecretKeyRecord --|> "+" CommonSecretRecord
- 如何加密存储
-
返回生成的密钥,结束请求
使用调用方传过来的data_key
加密我们生成的secret_key
进行返回即可。保证该响应只有调用方能够解密查看。
SUMMARY:
Field | Security Layer | Helper |
---|---|---|
data_key | 请求本身的加密 | KMS |
signature | 请求的完整性 | KMS |
fingerprint | 用户指纹,secret_key 的构成部分 | KMS |
common_salt | secret_key 的构成部分 | KMS |
用户请求
用户注册完成后,用户拿到了自己的密钥,接下来的请求就可以使用这个密钥进行签名了。
classDiagram
direction LR
class USER {
api_key: string
secret_key: string
sign(dict) string
}
class REQUEST {
params: dict
signature: string
api_key: string
}
USER --> REQUEST : sends
服务端接到请求后,首先根据 api_key
找到对应的 secret_key
(前面我们省略了关于 api_key
的细节,在这个场景下可以简单理解为 user_id
),然后使用 secret_key
对请求参数进行签名,最后与请求中的签名进行比对即可。
上述核心原理比较好理解,复杂的点在于工程实践上的实际架构。出于保密等原因,这里不展开说明。
多层签名的实践
前面的场景中,主要介绍的是如何利用签名进行用户请求的合法性校验。在实际工作中,前司的 CTO 提出了一个更高级的要求 —— 基于多层签名验证全链路的合法性。
考虑一个典型的场景:
flowchart LR
User --> Gateway --> Service_A --> Service_B --> Service_C
对于服务 C 来说,假设它需要验证请求是否合法,方式与用户请求类似,验证请求中的签名。考虑验证通过的情况下,能够说明什么问题?
服务能过验证说明请求是合法的,但是并不能说明请求的来源是合法的。
换句话来说,服务 C 在这种状况下不关心请求的来源,只关心请求的合法性。无论是谁,只要发送相同的请求到 C,都能通过验证。
CTO 提出,为了避免员工"监守自盗"的问题,需要在基础架构服务上加入验证机制,保证请求来源的可信。
这里指的是,假设服务 A 作为入口上游是可信的,服务 C 作为公司基础架构层也是可靠的,此时假设中间服务 B 是外包开发。
那么 B 开发者可以利用自己的权限,在没有接到 A 的调用的情况下,手动构造发送一个转账请求到服务 C,于是就未经用户授权,直接将用户的资产转走了。
为了应对这种情况,我们引入了多层签名验证机制。
classDiagram
direction LR
class REQUEST_A {
payload_a: string
signature_a: string
}
class REQUEST_B {
payload_b: string
signature_b: string
}
class SERVICE_C {
}
REQUEST_A "wrap" <-- REQUEST_B
REQUEST_B --> SERVICE_C : sends
-
A 构造消息:
A 构造原始请求 PAYLOAD_A
A 生成签名 SIGN_A(PAYLOAD_A),然后使用 A 的私钥加密
将消息打包为 REQUEST_A = (PAYLOAD_A, SIGN_A),发送给 B -
B 接收并签名:
B 收到 REQUEST_A 后,使用 A 的公钥解密 SIGN_A,再验证 A 的签名是否有效。
如果有效,B 对 PAYLOAD_A 和自己的 PAYLOAD_B 信息整体签名,生成 SIGN_B(PAYLOAD_A, PAYLOAD_B)。
将消息打包为 REQUEST_B = (PAYLOAD_A, SIGN_A, PAYLOAD_B, SIGN_B),发送给 C。 -
C 接收并验证:
C 收到 REQUEST_B 后,逐层验证签名:
先使用 A 的公钥解密后,验证 SIGN_A(PAYLOAD_A) 是否有效。
再使用 B 的公钥解密后,验证 SIGN_B(PAYLOAD_A, PAYLOAD_B) 是否有效。
如果所有签名都通过,C 可以确定请求从 A → B → C 的过程中未被篡改。
这里给一个 Demo 演示基本原理:
package main
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"time"
)
var (
PubKeyA *rsa.PublicKey
PriKeyA *rsa.PrivateKey
PubKeyB *rsa.PublicKey
PriKeyB *rsa.PrivateKey
)
type RequestA struct {
Payload map[string]string
Signature string
}
type RequestB struct {
PayloadA map[string]string // A's payload
SignatureA string
Payload map[string]string // B's payload
Signature string
}
func main() {
chC := serviceC()
chB := serviceB(chC)
// 模拟 A 发送请求给 B
payload := map[string]string{"serviceA": "serviceA"}
signature := sign(payload, PriKeyA)
req := RequestA{
Payload: payload,
Signature: signature,
}
chB <- req
// 等待异步处理完成
time.Sleep(1 * time.Second)
}
func serviceB(chanC chan RequestB) chan RequestA {
ch := make(chan RequestA)
go func() {
for req := range ch {
params := make(map[string]string)
params["serviceB"] = "serviceB"
for k, v := range req.Payload {
params[k] = v
}
signature := sign(params, PriKeyB)
reqB := RequestB{
PayloadA: req.Payload,
SignatureA: req.Signature,
Payload: map[string]string{"serviceB": "serviceB"},
Signature: signature,
}
chanC <- reqB
}
}()
return ch
}
func serviceC() chan RequestB {
ch := make(chan RequestB)
go func() {
for req := range ch {
// Verify signature A
if !verifySignature(req.PayloadA, req.SignatureA, PubKeyA) {
println("Invalid signature A")
continue
}
// Verify signature B
payload := make(map[string]string)
for k, v := range req.Payload {
payload[k] = v
}
for k, v := range req.PayloadA {
payload[k] = v
}
if !verifySignature(payload, req.Signature, PubKeyB) {
println("Invalid signature B")
continue
}
println("Verify OK")
}
}()
return ch
}
func sign(payload map[string]string, privateKey *rsa.PrivateKey) string {
payloadBs, _ := json.Marshal(payload)
h := sha256.New()
h.Write(payloadBs)
hash := h.Sum(nil)
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hash)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(signature)
}
func verifySignature(payload map[string]string, signature string, publicKey *rsa.PublicKey) bool {
payloadBs, _ := json.Marshal(payload)
h := sha256.New()
h.Write(payloadBs)
hash := h.Sum(nil)
signatureBytes, _ := base64.StdEncoding.DecodeString(signature)
err := rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hash, signatureBytes)
return err == nil
}
func init() {
generateRSAKeyPair := func(bits int) (*rsa.PrivateKey, *rsa.PublicKey, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, bits)
if err != nil {
return nil, nil, err
}
return privateKey, &privateKey.PublicKey, nil
}
PriKeyA, PubKeyA, _ = generateRSAKeyPair(2048)
PriKeyB, PubKeyB, _ = generateRSAKeyPair(2048)
}
实践中, 可能会发生链路长 / 请求参数过大的情况,为了避免最后一个端点的请求性能严重下降,我们可以把捎带的请求参数列表更换为捎带请求参数的 SHA-256 摘要:
type Request struct {
PayloadDigestSigChain map[string]string // payload digest -> signature
}
实际签名的步骤为:
- 将参数列表转换为 string 拼接起来
- 对这个 string 进行 SHA-256 摘要
- 使用私钥对摘要进行签名
这样可以避免传输完整的参数列表,减轻服务端的压力。