內部 API 的安全防護怎麼搞?密碼學中有答案

jeremy1127發表於2019-09-20

事情的起因是公司之前的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。

話說回來,安全攻防是沒有盡頭的,主要還是要看要保證的安全級別而定。

公司前端的同事,對目前的安全保證已經很滿意了。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章