基于签名的鉴权架构

Last updated on January 16, 2025 pm

请求处理的核心一环是鉴权,而在加密货币交易所中,鉴权的要求更为严格,因为它涉及到用户的资产安全。本文介绍一下我在前司基础架构组工作时瞥见的鉴权架构与实践。

核心原理

简单来说,签名鉴权的核心原理就是使用一个字符串对另一个字符串进行散列 / 哈希,然后得到一个新的字符串,该字符串不可逆,且不考虑碰撞的情况下,唯一对应于原字符串。

hash(key,data)=signaturehash(key, data) = signature

我们将它套用到请求场景中,可以变化为:

hash(key,request_info)=signaturehash(key, request\_info) = signature

实践中,我们一般使用 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&timestamp=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&timestamp=1499827319559&signature=c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71'

服务端在接到请求后,会根据请求参数和密钥重新计算签名,然后与请求中的签名进行比对,如果一致则通过,否则拒绝。

考虑一下比对不一致的情况,有如下两种原因:

  1. 请求被篡改,比如用户想买一个 BTC,被篡改为买 100 个 BTC
  2. 密钥不正确

SUMMARY:

从上可见,如果签名验证成功,我们可以说请求是合法的。

因为上述签名是基于密钥的,所以混合了防篡改和鉴权的功能。

不难看出,签名鉴权的核心在于密钥的管理 —— 用户需要使用密钥生成签名,服务器需要使用相同的密钥验证签名。

用户创建

前面提到,签名的核心在于密钥的管理,而密钥的初始化就发生在用户创建的时候 —— 用户注册时,我们需要给用户生成一个密钥,后续用户的请求就使用这个密钥进行签名。

这里引出三个问题:

  1. 创建请求本身的鉴权 —— 此时还没有用户密钥

  2. 用户密钥的安全传输 —— 生成密钥后,如何安全地传输给用户

  3. 用户密钥的安全存储 —— 如何安全地保存密钥

调用方发起请求

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

考虑请求的构建过程如下:

  1. 用户注册时,初始状态下,主要包含 3 个明文信息:
  • data_key:当前请求的数据加密密钥,是对称的。并且该密钥是临时生成的,特用于处理账号注册请求。
  • fingerprint:当前用户的指纹,使用特殊算法生成,可以包含设备 ID 或其它的特征。
  • user_info: 用户的基本信息,比如邮箱、电话等。
  1. 然后根据以上信息生成 signature:

考虑这里的签名是如何生成的:

可以确定的是,这里的签名和用户密钥无关,因为此时用户还没有密钥。
这里的签名实际上是调用方在 AWS KMS 中,使用非对称密钥生成的签名,这个密钥对只有两端能够访问,分别是:

  • 外层调用方服务
  • 内层基础架构账号服务

也就是说,内层基础服务依赖 AWS KMS 来验证请求方的身份,只处理真正来自我司外层服务团队发过来的请求。

  1. 接着,使用 data_key 加密 user_infofingerprint
  • data_key + user_info -> user_info_encrypted
  • data_key + fingerprint -> fingerprint_encrypted
  1. 最后,加密 data_key 本身
    这一步也是利用 AWS KMS 完成的,同样保证只有两端能够访问。即 data_key + KMS -> data_key_encrypted

至此,我们将上述信息打包成一个请求,发送给服务端。

SUMMARY:

考虑这里的请求是如何保证安全性的:

  1. data_key 的理论粒度是请求级别的, 每个新用户注册时使用的 data_key 都是不同的。
  2. 所有数据都使用 data_key 加密,保证了数据的机密性。
  3. 使用基于 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
  1. 服务端经过上面步骤可以拿到 data_keycommon_salt
  2. 然后使用 data_key 对请求中的 user_infofingerprint 进行解密,得到明文信息
  3. 使用解密后的信息找 KMS 验证签名

至此,验证步骤已经完成。


用户密钥的生成、存储和返回

生成步骤比较简单,根据一定算法随机生成一下即可。这里省略具体的生成算法。假设我们已经生成了 secret_key_plain_text,重点关注后续步骤:即存储和返回。

  1. 存储
    分两步骤考虑存储问题:

    • 如何加密存储 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 分别加密 fingerprintcommon_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
  2. 返回生成的密钥,结束请求
    使用调用方传过来的 data_key 加密我们生成的 secret_key 进行返回即可。保证该响应只有调用方能够解密查看。

SUMMARY:

FieldSecurity LayerHelper
data_key请求本身的加密KMS
signature请求的完整性KMS
fingerprint用户指纹,secret_key 的构成部分KMS
common_saltsecret_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
  1. A 构造消息:
    A 构造原始请求 PAYLOAD_A
    A 生成签名 SIGN_A(PAYLOAD_A),然后使用 A 的私钥加密
    将消息打包为 REQUEST_A = (PAYLOAD_A, SIGN_A),发送给 B

  2. 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。

  3. 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
}

实际签名的步骤为:

  1. 将参数列表转换为 string 拼接起来
  2. 对这个 string 进行 SHA-256 摘要
  3. 使用私钥对摘要进行签名

这样可以避免传输完整的参数列表,减轻服务端的压力。


基于签名的鉴权架构
https://kayce.world/tech/signature_based_auth_arch/
Author
kayce
Posted on
January 16, 2025
Updated on
January 16, 2025
Licensed under