内部API的安全防护怎么搞?密码学中有答案

Table of Contents

前言

事情的起因是公司之前的CDN服务是通过腾讯云的COSFS来做的,它的好处是可以像使用本地文件系统一样直接操作腾讯云对象存储中的对象,但后来因为性能等因素,我花时间把上传文件到CDN的功能用SDK重写了(其实可能比搭个COSFS还简单呢)。

前端同事恰好也有图床的使用需求,就想让我给他们开个API,这样他们就可以直接通过代码上传文件了,而不用每次都找后端同事帮忙。这件事本身没什么难度,唯一的问题是这个API的安全方面如何保证,至少不能让外人勿用。

分析

其它业务上的API都是用的用户登录后的token及用户的权限进行验证,眼下这种用于开发需求的API虽然也可以用同样的方式来做,但一方面不够方便(上传个图还要先登录,想想就麻烦),另一方面安全性也还是差些(是不是所有登录的用户都能调用呢?如果要再加权限的限制,也是麻烦)。

恰好自己上份工作是做区块链相关的,有一些密码学的基础知识,所以很自然想到用签名验签的方式来做安全验证。

先简单的解释一下密码学基础知识,常用的加密方式有两大类,一种是对称加密,即加密和解密都是相同的秘钥; 另一种是非对称加密,秘钥有公私钥之分,公钥是用私钥生成的。签名是指要私钥对一段信息的Hash加密,验签是指用私钥对应的公钥来验证一段信息的签名是否和信息匹配。非对称加密原理的保证了签名只能来自于私钥,而只有对应在公钥才能解签。(如果你对这些原理感兴趣,可以自行搜索相关文章哈)

实现

我们的后端是用的golang,前端是用NodeJS来实现的。非对称加密的实现方式有好几种,考虑到多端调试的成本,同时不想引入过多的第三方包,我这里选择了更加常用的RSA。下面将分为后端和前端两个部分来分别说明。

后端实现

1 生成密钥。方法有很多,可以ssh的工具来生成,这是是用代码来生成.

func GenerateKey(bits int) (*rsa.PrivateKey, *rsa.PublicKey, error) {
	private, err := rsa.GenerateKey(rand.Reader, bits)
	if err != nil {
		return nil, nil, err
	}
	return private, &private.PublicKey, nil
}

然后把密钥导出成base64的字符串,方便保存和使用。

func EncodePrivateKey(private *rsa.PrivateKey) []byte {
	return pem.EncodeToMemory(&pem.Block{
		Bytes: x509.MarshalPKCS1PrivateKey(private),
		Type:  "RSA PRIVATE KEY",
	})
}

func EncodePublicKey(public *rsa.PublicKey) ([]byte, error) {
	publicBytes, err := x509.MarshalPKIXPublicKey(public)
	if err != nil {
		return nil, err
	}
	return pem.EncodeToMemory(&pem.Block{
		Bytes: publicBytes,
		Type:  "PUBLIC KEY",
	}), nil
}

代码的话,没什么花头,唯一需要注意的是Type不要乱填,这可是标准哈! pem也是最常用的的密钥的编码方式。

2 签名解签。

func SignWithSha256Base64(data string, prvKeyBytes []byte) (string, error) {
	block, _ := pem.Decode(prvKeyBytes)
	if block == nil {
		return "", errors.New("fail to decode private key")
	}

	privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
	if err != nil {
		return "", err
	}
	h := sha256.New()
	h.Write([]byte([]byte(data)))
	hash := h.Sum(nil)
	signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hash[:])
	if err != nil {
		return "", err
	}
	out := base64.StdEncoding.EncodeToString(signature)
	return out, nil
}

func VerySignWithSha256Base64(originalData, signData string, pubKeyBytes[]byte) (bool, error) {
	sign, err := base64.StdEncoding.DecodeString(signData)
	if err != nil {
		return false ,err
	}
	block, _ := pem.Decode(pubKeyBytes)
	if block == nil {
		return false, errors.New("fail to decode public key")
	}

	pub, err := x509.ParsePKIXPublicKey(block.Bytes)
	if err != nil {
		return false, err
	}
	hash := sha256.New()
	hash.Write([]byte(originalData))
	err = rsa.VerifyPKCS1v15(pub.(*rsa.PublicKey), crypto.SHA256, hash.Sum(nil), sign)
	return err == nil, err
}

这里选用的Hash方法是Sha256,在签名和解签时都要用Sha256哦。

其实,加解密的相关代码在使用起来是比较固定的,但是一定是要记得使用的Hash方式和加解密的方法,要不然–嘿嘿–那真是调试到欲哭无泪呀。

3 经过一层封装,使用起来就很方便啦,下面测试一下。

func TestSignAndVerify(t *testing.T)  {
	sk, pk, _ := GenerateKey(1024)
	skBytes := EncodePrivateKey(sk)
	pkBytes, _ := EncodePublicKey(pk)
	fmt.Println(string(skBytes))
	fmt.Println(string(pkBytes))

	sig, err := SignWithSha256Base64("test", skBytes)
	if err != nil{
		fmt.Printf("%+v", err)
	}
	fmt.Println(sig)

	success, err := VerySignWithSha256Base64("test", sig, pkBytes)
	if success {
		fmt.Println("pass")
	} else {
		fmt.Printf("%+v", err)
	}
}

上述代码可在ksloveyuan/rsautil查看。

4 接着,我用echo写了一个简单的server端。

package main

import (
	"github.com/ksloveyuan/rsautil"
	"net/http"
	"github.com/labstack/echo"
)

const PublicKey  = `-----BEGIN PUBLIC KEY----- 公钥 -----END PUBLIC KEY-----``

type VerifyArgs struct {
	Content string  `json:"content" description:"" binding:"required" `
	Signature string  `json:"signature" description:"" binding:"required" `
}

func main() {
	e := echo.New()

	e.POST("/verify", func(c echo.Context) error {
		args:= VerifyArgs{}
		if err := c.Bind(&args); err !=nil{
			return c.String(http.StatusBadRequest, "参数不正确")
		}

		message := ""
		if success, _ := rsautil.VerySignWithSha256Base64(args.Content, args.Signature, []byte(PublicKey)); success{
			message = "success"
		} else {
			message = "fail"
		}

		return c.String(http.StatusOK, message)
	})

	e.Logger.Fatal(e.Start(":1323"))
}

前端实现

前端的主要工作是发起请求,同时附带请求参数的签名。

let crypto = require('crypto')
let request = require('request')

let sk = `-----BEGIN RSA PRIVATE KEY-----对应的私钥-----END RSA PRIVATE KEY-----`

function sendRequest ({ content, signature }) {
    var bodyData = { content, signature }
    return new Promise((resolve, reject) => {
        request.post({ url: 'http://localhost:1323/verify', body: bodyData, json: true }, function optionalCallback (
            err,
            httpResponse,
            body
        ) {
            if (err) {
                reject()
                return console.error('upload failed:', err)
            }

            console.log('Upload successful!', body)
            resolve()
        })
    })
}

async function action() {
    let signer = crypto.createSign('RSA-SHA256')

    let content = 'test_test_test'
    signer.update(content)

    let privateKey = {key: sk, format:"pem", type:"pkcs1"}
    let signature = signer.sign(privateKey, 'base64')

    console.log(signature)

    await sendRequest({ content, signature })
}

action()

其中request第三方包,crypto是nodejs自带的库。

代码中需要注意的地方有两点:

  1. 选用的签名方法一定要是RSA-SHA256,否则的话,和后端就对不上了。
  2. 使用的私钥的格式参数虽然是默认参数,但最好显示指定哈。

以上demo的完整代码可在ksloveyuan/ApiSecurityDemo中查看,欢迎star哈。

写在最后

关于私钥的保存,明文HardCode在代码里自然是一个安全隐患。

这一点,一方面可以保存在文件里,使用时读取,密钥的安全由保管的人负责(话说ssh密钥登录就是这样);或者是对私钥再做一层AES的加密,每次使用时输入AES加密的Keyword。

话说回来,安全攻防是没有尽头的,主要还是要看要保证的安全级别而定。

公司前端的同事,对目前的安全保证已经很满意了。