最近在看 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 用來 Verify3. 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
→ VerifyServer 不需要 Private Key
DeviceId 跟 Device Key 的差別
DeviceId 比較像一個識別值
Client 傳什麼,Server 就拿什麼來判斷
Device Key 不太一樣
因為 Server 會要求 Client 用 Private Key 簽一份資料,再拿之前保存的 Public Key 驗
所以不是只相信一個值
而是多確認一次
這個 Client 現在是不是還持有原本那把 Private KeyPublic Key 本來就是公開的
就算拿到 Public Key,也沒有 Private Key 可以簽出有效 Signature
所以真正有差別的還是 Private Key
目前還不是完整 Challenge-Response
現在 Demo 簽的是固定 "Hello 您好"
所以目前主要只是先把 Private Key Sign,Public Key Verify 這件事測通
真的做 Challenge-Response 時,Server 應該每次產生新的 nonce,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
---
The bug existed in all possible states.
Until I ran the code.
