WebCrypto

JavaScript WebCrypto + ASP.NET Core 實作 ECDSA Signature 驗證

作者

本文分享一個簡單的 Demo,演示如何利用瀏覽器的 WebCrypto API 產生 ECDSA 金鑰對,將公鑰傳至 Server,並由 Client 使用私鑰對資料簽名,Server 則用公鑰驗證,以證明 Client 持有對應的私鑰,進而強化設備身份驗證。

JavaScript WebCrypto + ASP.NET Core 實作 ECDSA Signature 驗證

最近在看 API Client 驗證這一塊

一般除了 Token 之外,可能還會再加 DeviceId、Fingerprint 之類的資訊,用來判斷是不是原本那台 Client

但這些東西本質上還是 Client 傳一個值給 Server

如果真的想確認 Client 還是不是原本那個,就可以再多一層 Key Proof

這次先做一個很簡單的 Demo

1. Browser 產生 ECDSA Key Pair
2. Public Key 傳給 Server 記住
3. Browser 用 Private Key 簽 Hello 您好
4. Server 用 Public Key Verify

先把最基本的 Sign / Verify 跑通就好

1. Browser 產生 Key Pair

前端直接使用 WebCrypto:

const keyPair = await crypto.subtle.generateKey(
    {
        name: "ECDSA",
        namedCurve: "P-256"
    },
    false,
    ["sign", "verify"]
);

這邊使用 ECDSA P-256

Private Key 留在 Browser,Public Key 則傳給 Server

extractable 設成 false,所以 Private Key 不提供一般方式 export 出來,除非他電腦可以被盜用比較底層的東西,不然純靠 javascript 理論上應該是拿不到的,當然這是理論上

Server 這次只是 Demo,所以先直接放記憶體:

private static string? SavedPublicKey;

Register 收到之後就記起來:

public JsonResult OnPostRegister([FromBody] RegisterDto dto)
{
    // 沒帶公鑰就直接退回。
    if (dto is null || string.IsNullOrWhiteSpace(dto.PublicKey))
        return new JsonResult(new { ok = false, message = "沒收到 Public Key。" });

    // 記住它。之後驗簽都用這把。
    SavedPublicKey = dto.PublicKey;
    return new JsonResult(new { ok = true, message = "Server 已記住 Public Key。" });
}

正式環境當然不會用 static,這一篇只是寫一個最小可行的測試

2. Browser 用 Private Key Sign

第二個動作就直接簽固定文字:

Hello 您好

JavaScript:

const data = new TextEncoder().encode("Hello 您好");

const signature = await crypto.subtle.sign(
    {
        name: "ECDSA",
        hash: "SHA-256"
    },
    privateKey,
    data
);

Sign 完之後,把:

message
signature

送給 Server

Private Key 不需要送出去

這邊就是這次 Demo 最主要想確認的地方:

Private Key
→ Client 用來 Sign

Public Key
→ Server 用來 Verify

3. C# 用 Public Key Verify

Server 先把剛剛保存的 Public Key 匯入:

using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(SavedPublicKey), out _);

接著拿收到的 Message 跟 Signature:

var data = Encoding.UTF8.GetBytes(dto?.Message ?? "");
var sig = Convert.FromBase64String(dto?.Signature ?? "");

然後做 Verify:

var valid =
    TryVerify(ecdsa, data, sig, DSASignatureFormat.IeeeP1363FixedFieldConcatenation) ||
    TryVerify(ecdsa, data, sig, DSASignatureFormat.Rfc3279DerSequence);

這邊我 Demo 直接支援兩種 Signature Format

一種是 WebCrypto 常見的 raw r || s

另一種是 DER

所以 TryVerify() 就兩種都試

private static bool TryVerify(ECDsa ecdsa, byte[] data, byte[] signature, DSASignatureFormat format)
{
    try
    {
        return ecdsa.VerifyData(data, signature, HashAlgorithmName.SHA256, format);
    }
    catch (CryptographicException)
    {
        return false;
    }
}

驗過就是 true,驗不過就 false

整個流程其實就這樣

Private Key + Data
→ Signature

Public Key + Data + Signature
→ Verify

Server 不需要 Private Key

DeviceId 跟 Device Key 的差別

DeviceId 比較像一個識別值

Client 傳什麼,Server 就拿什麼來判斷

Device Key 不太一樣

因為 Server 會要求 Client 用 Private Key 簽一份資料,再拿之前保存的 Public Key 驗

所以不是只相信一個值

而是多確認一次

這個 Client 現在是不是還持有原本那把 Private Key

Public Key 本來就是公開的

就算拿到 Public Key,也沒有 Private Key 可以簽出有效 Signature

所以真正有差別的還是 Private Key

目前還不是完整 Challenge-Response

現在 Demo 簽的是固定

Hello 您好

所以目前主要只是先把

Private Key Sign
Public Key Verify

這件事測通

真的做 Challenge-Response 時,Server 應該每次產生新的 nonce

例如:

ABC123

Client 改成簽:

ABC123

Server Verify 完之後,這個 nonce 就作廢

下一次再產生新的

這樣之前的 Signature 就不能一直 Replay

如果再往正式 API 做,還可以把

HTTP Method
API Path
Nonce
Timestamp
Body Hash

一起簽進去

不過這部分先不展開

這篇先把最基本的 Device Key Sign / Verify 跑起來就好

另外 extractable = false 也不是代表 Private Key 絕對安全

如果網站本身有 XSS,惡意 JavaScript 還是可能直接呼叫

crypto.subtle.sign()

也就是 Key 拿不走,不代表不能被原本 Browser 拿來用

所以 XSS、CSP、Authorization、Rate Limit、CSRF 防護這些還是各做各的

我自己先這樣記

Fingerprint
→ 判斷看起來像不像原本那台

Device Key
→ 證明還持有原本那把 Private Key

目前這版其實還很單純,就是先把 Browser Sign、C# Verify 這條流程接起來,確認 Private Key 不用離開 Client,Server 只拿 Public Key 也可以完成驗證,後面如果真的要拿來做 API 驗證,再把固定的 Hello 您好 換成 Server nonce,順便把 Replay、Timestamp 跟 Request Signing 一起補進去,這樣就比較接近實際會用的版本,所以重要的 API 你偶爾就可以丟個驗證資料請 Client 是不是還是本人,當然請千萬記住 這防禦不了 XSS。