# 生物辨識：為什麼伺服器只存一把公鑰

Chance Lu

付款密碼可以雜湊，那**指紋和臉**呢？很多人的第一直覺是「也雜湊一下存起來吧」。這個直覺會把整件事帶往錯誤的方向——因為伺服器**從頭到尾就不該拿到**任何生物特徵。

🖐️

正確答案出乎意料地簡單：伺服器只存**一把公鑰**和一個 credential id。這張表就算整個外洩，攻擊者拿到的是「印鑑」，**刻不出「印章」**。

🎯

讀完這篇，你要能用自己的話回答：為什麼樣板不離開手機、每次的隨機 challenge 在防什麼、**FIDO2 是機制而 WebAuthn 只是其中一種介面**、自己用原生 API 刻會少掉什麼，以及公鑰為什麼原樣存。

## ✎就愛考你

這幾題就是本文的重點，也是讀完你該能**用自己的話**答出來的問題。先想想現在答不答得出來，然後**帶著它們往下讀**——遇到答案所在的段落就特別留意；讀完再回來點「看答案」對照。

1. 生物辨識登入時，伺服器資料庫裡到底存了什麼？外洩後攻擊者能拿來做什麼？
    
**只存一把公鑰和一個 credential id；外洩後攻擊者拿到的是「印鑑」，刻不出「印章」。**
    - 指紋／臉的**樣板永遠不離開手機的安全晶片**，伺服器從頭到尾沒看過。
    - 私鑰也鎖在晶片裡，只有生物辨識通過時才能拿來簽名。
    - 公鑰的用途只有一個：**驗證簽章**。拿到它偽造不出簽章。
2. 伺服器每次驗證都丟一個新的隨機 challenge，是在防什麼？如果重複用同一個會怎樣？
    
**防重放攻擊：讓「錄下來的一次成功簽章」下次就沒用。**
    - 裝置簽的是「這次的 challenge」；下次 challenge 不同，舊簽章就對不上。
    - WebAuthn 要求 challenge 由伺服器隨機產生、**必須有足夠隨機性**（MUST），並**建議至少 16 bytes**（SHOULD）。
    - 重複用同一個 ＝ 攔到一次簽章就能一直登入。
3. 同樣是「公鑰挑戰-回應」，走 WebAuthn API 跟自己用手機原生 API 刻，差在哪？
    
**機制一樣，但走標準你「免費」拿到一整包檢查；自己刻，那包東西要自己補或自己放棄。**
    - 走標準免費得到：**RP ID 綁定**、**origin 檢查**、標準 **attestation**、`authenticatorData` 裡的 **UV／UP 旗標與 signCount**。
    - 自己刻：伺服器手上就只剩「**用公鑰對一段隨機 challenge 驗簽**」這一件事。
4. PIN 一定要雜湊才能存，公鑰卻是原樣存。為什麼公鑰不用雜湊？
    
**因為公鑰本來就不是秘密，而且雜湊過就驗不了簽章了。**
    - PIN 是**秘密**，驗證只需要「一不一樣」，所以能壞掉就壞掉。
    - 公鑰是拿來**做數學運算**的（橢圓曲線上的一個點）；雜湊會把它變成一串沒有結構的 bytes，**再也算不動驗簽**。
    - 公鑰欄位要的不是**保密**，是**防竄改**。

## 1印章與印鑑卡：一對鑰匙怎麼分工

**一句話：私鑰是印章、公鑰是印鑑卡；印章留在手機晶片，印鑑卡交給伺服器。**

先把新名詞拆開：**非對稱金鑰對**就是一對鑰匙，**私鑰**能「簽名」，**公鑰**只能「驗證那個簽名是不是私鑰簽的」。

🧧

    **私鑰＝印章**
    
在手機的安全晶片裡產生，**出不來**。而且晶片被設定成「生物辨識通過才准用這把私鑰」。

🗂️

    **公鑰＝銀行的印鑑卡**
    
連同一個 **credential id**（識別「哪一把」）送到伺服器存起來。拿印鑑卡只能核對，**沒辦法反刻出章**。

指紋在這裡只是**解鎖印章的開關**，從頭到尾沒有被送到任何地方。三份權威來源講的是同一件事：

🍎

**Apple 平台安全指南：**Face ID／Touch ID 樣板由 Secure Enclave 加密儲存，「這些資料**永不離開裝置**，不會送給 Apple，也不包含在備份裡」；比對也在 Secure Enclave 內完成。

🌐

**W3C WebAuthn §14.3：**生物辨識由裝置端的驗證器內部完成，「生物資料**不會揭露給伺服器**」；伺服器只看得到一個「已驗過人」的旗標。

📏

**NIST SP 800-63B §3.2.3：**生物比對可在裝置端或中央做，但「中央驗證方遭大規模攻擊的風險更高，比對**應該**在本機執行」。

所以伺服器不存指紋不是「懶得存」，是**架構上就不該拿到**——拿到就變成大規模外洩的單點。

> **一個必須點名的缺口：**驗簽只能證明「這個簽章是那把私鑰簽的」，證明不了「那把私鑰真的鎖在晶片裡、真的被指紋守著」。要驗金鑰的「出身」得靠 **attestation**，這是系列第三篇的主題。

💡

這段一句話：伺服器不驗「你是誰」，只驗「**你手上那把只有本人能解鎖的私鑰，能不能簽出對的簽章**」。

## 2一次一道考題：challenge 與伺服器的四項檢查

**一句話：每次登入伺服器出一道新考題（challenge），裝置用私鑰作答，伺服器用公鑰改考卷。**

下面這張圖是每一次生物辨識付款背後發生的事。物件都不動，只有「當下在做哪一步」的光暈會依序點亮；點下方按鈕可下載 SVG。

  伺服器
  手上只有：公鑰 + credential id

  手機上的 App
  只是傳話筒，碰不到私鑰

  安全晶片
  Secure Enclave / StrongBox

  ① 丟一個隨機 challenge
  建議至少 16 bytes、只用一次、伺服器先記著

  ② 請晶片用私鑰簽這份 challenge
  BiometricPrompt + CryptoObject／SecKey 簽章

  ③ 指紋／臉在晶片內比對
  通過了，晶片才拿私鑰簽名
  樣板、私鑰都不離開晶片

  ④ 回傳簽名（＋authenticatorData、clientDataJSON）
  App 只是轉交，看得到但改不了

  ⑤ 用存的公鑰驗簽
  再比對 challenge 是不是剛發的那個

  資料庫外洩 → 攻擊者只拿到「印鑑」（公鑰），刻不出「印章」（私鑰）

下載 SVG

### 伺服器到底在檢查什麼

對照 W3C WebAuthn 的規定，伺服器（規範裡叫 **Relying Party／RP**，就是你的伺服器）在驗證階段的核心檢查有四項：

1. **challenge 一致**：回傳的 challenge 必須等於剛剛發出去的那個。
2. **來源正確**：`origin` 是自己預期的、`rpIdHash` 是自己 RP ID（服務網域名）的 SHA-256。
3. **簽章有效**：用註冊時存的公鑰，驗證簽章確實是對該筆資料簽的。
4. **簽章計數**（signCount）沒有倒退——倒退可能代表私鑰被複製。

**challenge 自己的規矩**（WebAuthn §13.4.3）：必須（MUST）由伺服器在自己信任的環境隨機產生、必須有足夠的隨機性讓人猜不到；規範建議（SHOULD）**至少 16 bytes**。

另外三條來自 Google 的 passkey 伺服器端指南：登入後（成功或失敗）**立即捨棄**、**不可重複接受同一個 challenge**、過一段時間也要作廢（該文建議 timeout 預設 5 分鐘、最多 10 分鐘）。

為什麼這幾條加起來就擋掉重放？因為攻擊者側錄到的簽章，是對**那一題**簽的。下次伺服器出新題目，舊答案對不上；而那一題早就作廢了。

> WebAuthn 的原話是：回來的值不符就一律拒絕——「容忍不一致會破壞整個協定的安全性」。

💡

這段一句話：challenge ＝ **一次性的考題**。一次性是它唯一的價值，重複用就等於沒有。

## 3FIDO2 是機制，WebAuthn 只是其中一種介面

**一句話：「公鑰挑戰-回應」是機制，WebAuthn 是實作這個機制的一組 API——機制可以脫離那組 API 單獨存在。**

先把三個名字釘好，都只是名字，不難：

- **FIDO Alliance**：制定規格的組織。
- **FIDO2**：FIDO Alliance 的原話是「commonly known as FIDO2 (WebAuthn and CTAP)」——**WebAuthn ＋ CTAP** 兩份標準合起來的俗稱。
- **WebAuthn**：W3C 規範，摘要第一句就說「This specification defines an **API**…」——一組由瀏覽器／平台提供的 API，外加伺服器端該怎麼驗的規則。
- **passkey**：這整套東西的通俗名。

換句話說，**WebAuthn 規定的是「網頁怎麼叫用這套機制」**。那如果你不是網頁呢？

### 同一套機制，也可以用原生 API 直接刻

手機 App 完全可以**不經過 WebAuthn**，直接用 iOS Secure Enclave／Android Keystore 的原生 API 做出同一個機制：裝置產金鑰對 → 生物辨識才准用私鑰 → 伺服器發 challenge → 裝置簽 → 伺服器驗簽。

這條路是**成立的**，密碼學上就是同一件事。但代價要看清楚：**WebAuthn 幫你做掉的一整包檢查，這條路上一個都沒有。**

| 能力 | 走 WebAuthn／passkey | 用原生 API 自己刻 |
| --- | --- | --- |
| **RP ID 綁定**<br>（憑證綁死某個網域，換個站就用不了） | 內建 規範明訂驗證器會檢查請求的 RP ID「exactly matches」 | 沒有 晶片不知道這段 bytes 要簽給誰，防釣魚得靠 App 自己只連自家伺服器 |
| **origin 檢查** | 內建 client 把 `origin` 寫進 `clientDataJSON`，伺服器逐一比對 | 沒有 連 `clientDataJSON` 這個東西都不存在 |
| **`authenticatorData`**<br>（`rpIdHash`、UP／UV 旗標、`signCount`） | 內建 每次驗證都拿得到 | 沒有 伺服器收不到「裝置說它驗過人了」這個訊號 |
| **attestation**（證明金鑰出身） | 有標準格式 註冊時一起回來 | 要自己接 Android Key Attestation／Apple App Attest，不接就等於沒有 |
| **跨裝置同步** | 可以 規範連備份資格／備份狀態旗標都定義好了 | 不可能 Secure Enclave 金鑰連匯出都做不到，換手機就是重新註冊 |
| **伺服器要做的事** | 一串檢查：challenge、origin、rpIdHash、UP／UV、signCount、簽章 | 基本上只剩一件：**用存的公鑰對一段 challenge 驗簽** |

最後一列看起來像好消息（伺服器超簡單），其實是這條路最大的風險所在：**伺服器少掉的那些檢查並沒有消失，只是變成「你相信 App 端有做」。**

💡

這段一句話：**FIDO2 是機制，WebAuthn 是網頁用的那個介面。**用原生 API 刻同一套機制完全可行，但那一整包檢查要自己補或自己放棄。

## 4標準版與自刻版，簽的不是同一串 bytes

**一句話：兩條路的「被簽資料」根本不同——搞混的後果是簽章永遠驗不過。**

|  | 標準 WebAuthn／passkey | 原生 API 自刻版 |
| --- | --- | --- |
| **裝置簽的是什麼** | `authenticatorData || SHA-256(clientDataJSON)` | **伺服器發的那段 challenge 本身**（或你自訂的一段結構） |
| **challenge 在哪** | 藏在 `clientDataJSON` 裡，**不是直接被簽的那串** | 就是被簽的那串 |
| **伺服器怎麼驗** | 拆 `clientDataJSON` → 比 challenge／origin → 比 `rpIdHash` → 看 UP／UV → 比 `signCount` → 最後才驗簽 | 用公鑰對 challenge 驗簽，就這樣 |

Google 的 passkey 伺服器端指南把標準版講得很直白：驗證器簽的是「**`authenticatorData` 與『包含挑戰的 `clientDataJSON`』之雜湊的串接**」。

驗簽需要三樣東西——**簽章、公鑰、原始被簽資料**，外加知道它用的是哪個演算法。

⚠️

**拿標準 library 去驗自刻的簽章，永遠驗不過**——因為 library 會自己去組 `authenticatorData || hash`。反過來，也別以為自刻版有 `authenticatorData` 給你的那些保證。

### signCount 與 RP ID：自刻版沒有對應物

- **`signCount`**：規範原文說它的用途是「aid Relying Parties in detecting cloned authenticators」——幫伺服器發現私鑰被複製（正常只會遞增，倒退就可疑）。自刻版沒有這個欄位，**就沒有這個偵測能力**。緩解的理由是晶片私鑰本來就匯不出來，複製門檻很高（**這句是推論，不是規範保證**）。
- **RP ID**：規範讓憑證「scope 到」某個網域，而且**驗證器自己會檢查**——這正是 passkey 天生防釣魚的機制。自刻版沒有這層綁定：晶片只負責簽，不管這段 bytes 要送去哪裡。

💡

這段一句話：**標準版簽的是 `authenticatorData || SHA-256(clientDataJSON)`，自刻版簽的是 challenge 本身。**先確認你在哪條路上，再寫驗簽。

想看程式碼？.NET 伺服器端驗簽範例（含 iOS 公鑰格式的坑）

假設註冊時你已經把公鑰轉成 X.509 **SubjectPublicKeyInfo（SPKI）DER** 格式存進資料庫。WebAuthn 原生給的是 **COSE** 格式——COSE 與 SPKI-DER 只是**兩種打包公鑰的格式，內容是同一把鑰匙**，轉換交給 library。ES256 的簽章在 WebAuthn §6.5.5 明定為 ASN.1 DER。

```csharp
// .NET 5+：ECDsa.VerifyData(..., DSASignatureFormat) 需 .NET 5；ImportSubjectPublicKeyInfo 需 .NET Core 3.0+
using System.Security.Cryptography;
using System.Text.Json;

public static class BiometricVerifier
{
    // ① 發 challenge：隨機、至少 16 bytes（WebAuthn 建議），存起來、用一次就丟
    public static byte[] NewChallenge() => RandomNumberGenerator.GetBytes(32);

    // ② 收到裝置回覆後驗簽
    public static bool VerifyAssertion(
        byte[] storedPublicKeySpkiDer,   // 註冊時存的公鑰（SubjectPublicKeyInfo, DER）
        byte[] authenticatorData,        // 裝置回傳
        byte[] clientDataJson,           // 裝置回傳（含 challenge、origin、type）
        byte[] signatureDer,             // 裝置回傳（ES256 → ASN.1 DER）
        byte[] expectedChallenge)        // 剛剛發出去、暫存在伺服器的那個
    {
        // (a) challenge 必須等於我們剛發的那個 —— 防重放
        using var doc = JsonDocument.Parse(clientDataJson);
        byte[] gotChallenge = Base64UrlDecode(doc.RootElement.GetProperty("challenge").GetString()!);
        if (!CryptographicOperations.FixedTimeEquals(gotChallenge, expectedChallenge)) return false;
        // 實務上還要驗 type == "webauthn.get"、origin、rpIdHash、UV 旗標、signCount

        // (b) 被簽的資料 = authenticatorData || SHA-256(clientDataJSON)
        byte[] clientDataHash = SHA256.HashData(clientDataJson);
        byte[] signed = new byte[authenticatorData.Length + clientDataHash.Length];
        authenticatorData.CopyTo(signed, 0);
        clientDataHash.CopyTo(signed, authenticatorData.Length);

        // (c) 用存的公鑰驗簽（ES256 = ECDSA P-256 + SHA-256）
        using var ecdsa = ECDsa.Create();
        ecdsa.ImportSubjectPublicKeyInfo(storedPublicKeySpkiDer, out _);
        return ecdsa.VerifyData(signed, signatureDer, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
    }

    static byte[] Base64UrlDecode(string s)
    {
        string b64 = s.Replace('-', '+').Replace('_', '/');
        return Convert.FromBase64String(b64.PadRight(b64.Length + (4 - b64.Length % 4) % 4, '='));
    }
}
```

**公鑰格式的跨平台坑。**同一把 P-256 公鑰，各平台吐出來的打包格式不一樣：

- **.NET 伺服器端**要的是 SPKI 的 DER 編碼——就是 `ImportSubjectPublicKeyInfo` 吃的東西。
- **Android**：Java 的 `PublicKey.getEncoded()` 對公鑰的主要編碼格式就是 `"X.509"`，也就是 `SubjectPublicKeyInfo`。**直接丟給 .NET 就能用。**
- **iOS**：`SecKeyCopyExternalRepresentation` 給的是 **ANSI X9.63** 的 `04 || X || Y`，P-256 剛好 65 bytes，**沒有「這是哪條曲線」的標頭**，直接丟給 .NET 會炸。

乾淨的解法是伺服器端直接從 65 bytes 組出公鑰，因為 X 和 Y 就明擺在那裡：

```csharp
// ECDsa.Create(ECParameters) 是 .NET Core 2.0+ 的標準 API。
static ECDsa FromX963P256(byte[] x963)
{
    if (x963.Length != 65 || x963[0] != 0x04)
        throw new ArgumentException("不是未壓縮的 P-256 公鑰點");

    var p = new ECParameters
    {
        Curve = ECCurve.NamedCurves.nistP256,
        Q = new ECPoint { X = x963[1..33], Y = x963[33..65] }
    };
    p.Validate();               // 順手檢查這個點真的在曲線上
    return ECDsa.Create(p);
}
// 之後要存成通用格式也很容易：ecdsa.ExportSubjectPublicKeyInfo()
```

實務建議是**在伺服器入口就正規化**：不管 App 傳來的是 SPKI 還是裸的 X9.63，一律轉成 SPKI DER 再存，驗簽程式就只剩一種路徑。

## 5公鑰為什麼是「原樣存」，不像 PIN 那樣雜湊

**一句話：秘密才需要壞掉；公鑰不是秘密，而且壞掉就不能算了。**

第一個理由是分類問題：**PIN 是秘密，公鑰不是。**FIDO 的說法是，線上服務從頭到尾只看得到公鑰和簽章——沒有秘密，自然沒有「要不要壞掉」的問題。

第二個理由更硬，純技術：**雜湊過就驗不了簽章了。**

驗簽不是「比對兩串東西一不一樣」，而是拿公鑰去**做一段數學運算**——ES256 的公鑰其實是 P-256 曲線上的一個座標點 `(X, Y)`，驗簽演算法要用到這個結構。

雜湊會把它輾成一串**沒有結構、也算不回去**的 bytes，運算就做不下去了。

那公鑰欄位就可以隨便放嗎？也不是——它要的保護**不是機密性，是完整性**：

👀

    **被看到：還好**
    
攻擊者拿到公鑰**偽造不出簽章**（沒有私鑰），傷害有限。

✏️

    **被改掉：這才是要防的**
    
誰能把你資料庫裡的公鑰換成他自己的，他就能用自己的私鑰堂堂正正登入。（**這是從「驗簽只信任那一欄」推出來的結論**，不是引用哪條規範。）

💡

這段一句話：公鑰要防的是**被改**，不是被看——這跟 PIN 的威脅模型正好相反。

## 6澄清：「生物辨識 ＝ FIDO + salt」哪裡對、哪裡不對

**一句話：「FIDO」對，「salt」該換成「challenge」。**

兩個東西都是「隨機值」，功能卻完全不同：

|  | salt（鹽） | challenge（一次性隨機數） |
| --- | --- | --- |
| 屬於哪套機制 | 密碼**雜湊儲存** | 公開金鑰**簽章驗證** |
| 什麼時候產生 | 設定密碼時產生**一次**，之後固定 | **每次**驗證都產生新的 |
| 存多久 | 永久，跟雜湊放同一列 | 只活這一次流程，用完即丟 |
| 防什麼 | 對照表攻擊、一次撞多人 | **重放攻擊** |
| 誰用它 | 伺服器算雜湊時混進去 | 裝置拿私鑰簽它，伺服器比對它 |

那規範裡有沒有真的出現「salt」、可能被記成這樣？我翻了 W3C WebAuthn Level 3 全文：字面上出現很多次，但絕大多數在附錄的測試向量裡。

**規範性正文裡，salt 只跟兩個主題有關，都不是登入驗證本身：**

- **prf 擴充（§10.1.4）**：選用擴充，讓伺服器順便從裝置拿到一段「只有這把憑證算得出來的 32-byte 亂數」，用途是**派生加密金鑰**。規範文字確實把輸入命名為 `salt1`／`salt2`，底層對應 CTAP2 的 hmac-secret 擴充。
- **隱私建議（§14）**：提醒不要用可猜的個資雜湊當使用者識別，「除非雜湊有加私有的 salt」——一般性建議，跟驗證流程無關。

另一個更可能的記混來源就是 **challenge 本身**：「每次一個新的隨機值丟給裝置」聽起來很像鹽，但它是防重放的一次性隨機數。

**第三個來源最有力，而且已確認：**舊版 NIST SP 800-63B（rev.3，§5.1.1.2）真的把伺服器側密鑰叫做 **「secret salt」**，rev.4 才改口叫「secret key」。

如果你的記憶來自舊版 NIST 或引用它的教材，「salt」很可能是**記混成 pepper** 了——而 pepper 是 PIN 那一側的東西，跟 FIDO 無關。

💡

這段一句話：salt（以及舊版 NIST 所謂的 secret salt ＝ pepper）都留在 **PIN 那一側**；生物辨識這一側的隨機值叫 challenge。

## ★一句話帶走

**生物辨識這一側，伺服器沒有秘密可存——只有一把公鑰、一個 credential id，和每次臨時出的一道考題。**樣板與私鑰都鎖在手機晶片裡；安全性來自「私鑰出不來」＋「challenge 只用一次」，不是來自把什麼東西藏好。

## 🔗這系列的另外兩篇

[系列 1／3**6 位數 PIN 與九宮格圖形，到底該怎麼存** — 雜湊／salt／慢函數三個詞、為什麼光雜湊救不了 6 位數，以及三道防線。](https://docs.smartech.tw/n/6-位數-pin-與九宮格圖形-到底該怎麼存)
  [系列 3／3**「簽得出來就等於刷過臉」嗎？這條推論的前提** — 兩個旗標、attestation、加一張臉金鑰就死，以及兩個平台的差異。](https://docs.smartech.tw/n/簽得出來就等於刷過臉嗎-這條推論的前提)

## 📚參考來源

- [W3C Web Authentication Level 3](https://www.w3.org/TR/webauthn-3/)（§7.2 驗證步驟、§13.4.3 challenge 至少 16 bytes、§6.5.5 ES256 簽章為 ASN.1 DER、§6.1 `authenticatorData` 欄位表與 signCount、§10.1.4 prf 擴充的 salt1／salt2、§14.3 生物資料不揭露給 RP）
- [FIDO Alliance — FIDO2](https://fidoalliance.org/fido2/)（「commonly known as FIDO2 (WebAuthn and CTAP)」、「The WebAuthn standard covers the browser API that manages passkeys」）
- [FIDO Alliance／Passkey Central — How passkeys work](https://www.passkeycentral.org/introduction-to-passkeys/how-passkeys-work)（註冊產金鑰對、登入簽 challenge、線上服務只看得到公鑰和簽章）
- [Google for Developers — 在伺服器上驗證 passkey](https://developers.google.com/identity/passkeys/developer-guides/server-authentication?hl=zh-tw)（challenge 每次重產、用完即棄、不可重複接受、timeout 建議值；被簽資料是 `authenticatorData` 與 `clientDataJSON` 雜湊的串接）
- [Apple Platform Security — Optic ID, Face ID, and Touch ID security](https://support.apple.com/guide/security/optic-id-face-id-and-touch-id-security-sec067eb0c9e/web)（樣板由 Secure Enclave 加密儲存、永不離開裝置）
- [Apple Developer — Protecting keys with the Secure Enclave](https://developer.apple.com/documentation/security/protecting-keys-with-the-secure-enclave)（P-256、不可匯入、取公鑰）；[SecKeyCopyExternalRepresentation](https://developer.apple.com/documentation/security/seckeycopyexternalrepresentation%28_:_:%29)（EC 公鑰是 X9.63 的 `04 || X || Y`）
- [Android Developers — Keystore system](https://developer.android.com/privacy-and-security/keystore)（金鑰材料不進 App 行程）；[Oracle Java — `X509EncodedKeySpec`](https://docs.oracle.com/javase/8/docs/api/java/security/spec/X509EncodedKeySpec.html)（公鑰主要編碼格式為 `SubjectPublicKeyInfo`）
- [NIST SP 800-63B rev.4](https://pages.nist.gov/800-63-4/sp800-63b.html)（§3.2.3 生物比對應在本機執行、只能搭配實體驗證器作多因子）；[rev.3 §5.1.1.2](https://pages.nist.gov/800-63-3/sp800-63b.html)（把伺服器側密鑰稱為「secret salt」的原文）
- [Microsoft Learn — `ECDsa.ImportSubjectPublicKeyInfo`](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.ecdsa.importsubjectpublickeyinfo)；[ECDsa.VerifyData（含 DSASignatureFormat 多載，.NET 5+）](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.ecdsa.verifydata)；[ECDsa.Create(ECParameters)](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.ecdsa.create)
