借用 SSH 金鑰實現網站無密碼登入
| | | 0 | |
用慣 SSH 金鑰免密碼登入 SSH/SFTP/SCP 後,就很難回去每次登入要敲密碼的日子~ (註:若覺得沒密碼少一道關卡不安心,還可加碼用實體金鑰) 若登入網站或呼叫 WebAPI 時也能比照辦理,那該有多好?
SSH 的身分認證原理是在 ~/.ssh 目錄產生一組 Ed25519 公私鑰,id_ed25519 是私鑰檔,有密碼保護且不需外流;而 id_ed25519.pub 則是可公開散佈的公鑰,可預先傳給連線對象。登入時,用私鑰對特定內容進行簽章,伺服器端則用用公鑰驗證簽章真偽,確認登入者身分。
最近要寫個 WebAPI,覺得用 API Key 存在竊聽及伺服器端洩漏風險,想升級仿效 SSH 等級的公私鑰驗證,另外建立金鑰太麻煩,何不直接共用 SSH/SCP 在用的現成 Ed25519 金鑰就好?
註:這種設計適合客戶端數量少且固定的情境,類似 SSH 客戶端情境,無法適用所有應用場景,請自行評估。
原本的天真計劃是用 BouncyCastle.Cryptography 的 OpenSshPrivateKeyUtilities.ParsePrivateKeyBlob(...) 讀取 id_ed25519 私鑰檔,簽章後用 OpenSshPublicKeyUtilities.ParsePublicKey(...) 讀取 id_ed25519.pub 公鑰檔就能輕鬆搞定,忘了 OpenSSH 的層層安全防護!
OpenSSH 的 id_ed25519 私鑰檔處於加密狀態,需先輸入保護密碼解密才能使用,而這段由私鑰管理員 ssh-agent 負責,呼叫端不需要也無法存取私鑰本體。延伸閱讀:OpenSSH for Windows 之 ssh-agent 安裝與私鑰保存探密
Windows 的 ssh-agent 採用 Named Pipe 傳輸,訊息格式則依循標準 SSH Agent Protocol,實作不難但有些背景知識,如果不是全部甩給 AI 寫,可以了解一下。
SSH Public Key Line Format
id_ed25519.pub公鑰檔案的內容只有一行,格式如ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF5i1vQ9gXm0Fp3XnZy8Jb4PZ9qK9v7v3GZ7vN3mZn9k [email protected](以下簡稱公鑰字串),共分為三段,第一段是金鑰型別,第二段是 Base64 編碼的 SSH Public Key Blob,第三段是 Comment 方便識別。
SSH Public Key Blob 為一段 byte[],由兩個 SSH 字串組成,每個 SSH 字串最前面 4 個 Byte 為 uint32 宣告資料長度,接著該長度的 byte[] 資料本體。+---------------------------+ | uint32 = 11 | +---------------------------+ | "ssh-ed25519" | +---------------------------+ | uint32 = 32 | +---------------------------+ | 32-byte public key | +---------------------------+ssh-agent 可能管理多支金鑰,故簽章時需要提供公鑰字串以指定用哪一支金鑰進行簽章;驗證簽章時當然也必須提供金鑰字串,以便從 SSH Public Key Blob 取出公鑰本體進行驗證。
SSH Agent Protocol
Windows SSH Agent 可透過 NamedPipe 地址\\.\pipe\openssh-ssh-agent存取,對其寫入及讀取 byte[] 資料,而傳輸資料的格式則依循 SSH Agent Protocol,我們只會用到四種請求或回應:- RequestIdentities (11) - 請求公鑰清單客戶端發送此訊息,向 Agent 詢問目前記憶體中載入的所有金鑰
請求內容:總長度 (uint32):整個封包扣除長度欄位後的位元組數(此處固定為 1)/ 訊息代碼 (byte):固定為 11 (SSH2_AGENTC_REQUEST_IDENTITIES) - IdentitiesAnswer (12) - 回傳公鑰清單Agent 收到代碼 11 後的回應,包含金鑰數量與各金鑰的詳細資訊
回應內容:總長度 (uint32) / 訊息代碼 (byte):固定為 12 (SSH2_AGENT_IDENTITIES_ANSWER) / 金鑰筆數 (uint32):目前 Agent 內持有的金鑰總數(假設為 N)/ 金鑰陣列(重複 N 次): [ Key Blob (string):公開金鑰的二進位編碼 / Comment (string):金鑰註解 ] - SignRequest (13) - 要求簽章客戶端發送欲簽署的資料,要求 Agent 使用特定私鑰進行數位簽章。
請求內容:總長度 (uint32) / 訊息代碼 (byte):固定為 13 (SSH2_AGENTC_SIGN_REQUEST) / Key Blob (string):目標公鑰的 SSH Public Key Blob (告知 Agent 要用哪一把私鑰) / Data (string):要簽署的原始資料或雜湊值 / Flags (uint32):簽章控制旗標(例如:指示 RSA 金鑰必須使用 SHA-256 或 SHA-512 演算法) - SignResponse (14) - 回傳簽章Agent 完成數位簽章後的回應(若失敗則會回傳 SSH_AGENT_FAILURE 代碼 5)
回應內容:總長度 (uint32) / 訊息代碼 (byte):固定為 14 (SSH2_AGENT_SIGN_RESPONSE) / Signature Blob (string):包含完整簽章資料的字串,結構為 [ Format (string):簽章格式名稱(例如 ssh-rsa、rsa-sha2-256) + Signature (string):數位簽章二進位內容 ]
- RequestIdentities (11) - 請求公鑰清單客戶端發送此訊息,向 Agent 詢問目前記憶體中載入的所有金鑰
AI 時代,觀念搞懂了,寫完程式只在談笑之間,實作用 Named Pipe 與 Windows SSH Agent 的程式範例如下,是這個認證機制的重點:
using System.Buffers.Binary;
using System.IO.Pipes;
using System.Security.Cryptography;
using System.Text;
using Org.BouncyCastle.Crypto.Parameters;
public static class SshAgentSigner
{
private const string AgentPipeName = "openssh-ssh-agent";
// SSH2_AGENTC_REQUEST_IDENTITIES(11)取得已載入公鑰清單
private const byte RequestIdentities = 11;
// SSH2_AGENT_IDENTITIES_ANSWER(12)回傳已載入公鑰清單,傳回金鑰筆數、每個金鑰的 blob 與 comment
private const byte IdentitiesAnswer = 12;
// SSH2_AGENTC_SIGN_REQUEST(13)要求簽章,傳入公鑰 blob、要簽署的資料、flags
private const byte SignRequest = 13;
// SSH2_AGENT_SIGN_RESPONSE(14)回傳簽章,傳回簽章格式、簽章內容
private const byte SignResponse = 14;
public static byte[] Sign(byte[] data) => Sign(SshSignTool.ReadPublicKeyLine(), data);
public static byte[] Sign(string publicKey, byte[] data)
{
ArgumentNullException.ThrowIfNull(publicKey);
ArgumentNullException.ThrowIfNull(data);
// publicKey 可能是 id_ed25519.pub 的完整路徑,或是公鑰檔案內容
if (File.Exists(publicKey))
publicKey = SshSignTool.ReadPublicKeyLine(publicKey);
var publicKeyBlob = SshSignTool.ExtractPublicKeyBlob(publicKey);
// 連線 NamedPipe 地址 \\.\pipe\openssh-ssh-agent,使用 SSH Agent Protocol 與 ssh-agent 通訊
using var agent = new NamedPipeClientStream(
".", AgentPipeName, PipeDirection.InOut, PipeOptions.None);
// 連線逾時 2 秒
agent.Connect(2_000);
// 取得已載入的金鑰清單,比對是否有 id_ed25519.pub 相符的金鑰
WriteAgentMessage(agent, [RequestIdentities]);
var identities = ReadAgentMessage(agent);
if (identities.Length == 0 || identities[0] != IdentitiesAnswer)
{
throw new InvalidOperationException("ssh-agent 未回傳金鑰清單");
}
var identitiesOffset = 1;
// 取得金鑰筆數
var identityCount = SshSignTool.ReadSshUInt32(identities, ref identitiesOffset);
var matchingKeyFound = false;
// 逐一比對已載入金鑰
for (var i = 0; i < identityCount; i++)
{
// 依序讀取金鑰 Blob、Comment
var keyBlob = SshSignTool.ReadSshString(identities, ref identitiesOffset);
_ = SshSignTool.ReadSshString(identities, ref identitiesOffset); // comment
// 比對此金鑰是否為指定的金鑰
matchingKeyFound |= CryptographicOperations.FixedTimeEquals(keyBlob, publicKeyBlob);
}
if (!matchingKeyFound)
{
throw new InvalidOperationException("ssh-agent 未載入指定金鑰");
}
using var request = new MemoryStream();
request.WriteByte(SignRequest);
WriteSshString(request, publicKeyBlob);
WriteSshString(request, data);
WriteUInt32(request, 0); // Ed25519 不需要額外 flags
WriteAgentMessage(agent, request.ToArray());
var response = ReadAgentMessage(agent);
if (response.Length == 0 || response[0] != SignResponse)
{
throw new InvalidOperationException("ssh-agent 拒絕簽章要求");
}
var responseOffset = 1;
var signatureBlob = SshSignTool.ReadSshString(response, ref responseOffset);
var signatureOffset = 0;
// 讀取簽章格式
var algorithm = Encoding.ASCII.GetString(SshSignTool.ReadSshString(signatureBlob, ref signatureOffset));
// 讀取簽章內容
var signature = SshSignTool.ReadSshString(signatureBlob, ref signatureOffset);
if (algorithm != "ssh-ed25519" || signature.Length != Ed25519PrivateKeyParameters.SignatureSize)
{
throw new InvalidOperationException("ssh-agent 回傳的不是有效 Ed25519 簽章");
}
return signature;
}
// 寫入 SSH Agent Protocol 訊息
private static void WriteAgentMessage(Stream stream, byte[] payload)
{
WriteUInt32(stream, checked((uint)payload.Length));
stream.Write(payload);
stream.Flush();
}
// 讀取 SSH Agent Protocol 訊息
private static byte[] ReadAgentMessage(Stream stream)
{
var lengthBuffer = ReadExactly(stream, sizeof(uint));
var length = BinaryPrimitives.ReadUInt32BigEndian(lengthBuffer);
if (length > 1024 * 1024)
{
throw new InvalidOperationException("ssh-agent 回應過長");
}
return ReadExactly(stream, checked((int)length));
}
// 寫入 SSH Wire Format 字串
private static void WriteSshString(Stream stream, byte[] value)
{
WriteUInt32(stream, checked((uint)value.Length));
stream.Write(value);
}
// 寫入 UInt32 (Big-Endian 編碼)
private static void WriteUInt32(Stream stream, uint value)
{
// 使用 Span<byte> 以避免額外的陣列配置,並使用 stackalloc 分配在堆疊上
Span<byte> buffer = stackalloc byte[sizeof(uint)];
// .NET 預設的數字轉 byte[] 在 x86/x64 採用 Little-Endian 編碼
// 此處使用 Big-Endian API 以符合 SSH Wire Format 規範
BinaryPrimitives.WriteUInt32BigEndian(buffer, value);
stream.Write(buffer);
}
// 讀取指定長度 byte[]
private static byte[] ReadExactly(Stream stream, int length)
{
var buffer = new byte[length];
stream.ReadExactly(buffer);
return buffer;
}
}
關於驗證身分的做法,我設計了附加電子簽名的認證字串,格式為 <帳號名稱> <時間戳> <電子簽章> 包含三部分:帳號名稱讓伺服器找到該帳號對應的公開金鑰,時間戳證明此簽名為即時產生(避免重送攻擊 Replay Attack),而電子簽章為使用者使用私鑰對 <帳號名稱> <時間戳> 產生的電子簽章。
伺服器端接收到認證字串後,由帳號名稱找出該帳號的公開金鑰,驗證簽名正確且時間戳為剛剛產生(例如:不早於 5 秒之前),不是重送攻擊,則認定使用者擁有私鑰是本人無誤,宣告登入成功。
註:時間戳檢查無法阻擋太快的重送攻擊,若想避免可再加上 Nonce 拋棄式隨機數,用一次就作廢。如此需在伺服器保存一段時間內用過的 Nonce 進行比對。
我先寫了一支超簡單的 ASP.NET WebForm 驗證可以用 OpenSSH 預設的個人金鑰 (~/.ssh/ed25519) 完成身分驗證:
<%@ Page Language="C#" %>
<%@ Import Namespace="Org.BouncyCastle.Crypto.Signers" %>
<%@ Import Namespace="Org.BouncyCastle.Crypto.Parameters" %>
<%@ Import Namespace="System" %>
<%@ Import Namespace="System.Globalization" %>
<%@ Import Namespace="System.Linq" %>
<%@ Import Namespace="System.Text" %>
<script runat="server">
static string[] pubKeyLines = new string[]
{
"ssh-ed25519 AAAAC...BVF jeffrey"
};
protected void Page_Load(object sender, EventArgs e)
{
var auth = Request.Headers["Authorization"] ?? Request["auth"];
if (string.IsNullOrEmpty(auth))
{
Response.StatusCode = 403;
Response.Write("Missing authentication header");
return;
}
var p = auth.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (p.Length != 3)
{
Response.StatusCode = 403;
Response.Write("Invalid authentication header");
return;
}
var signer = p[0];
var data = p[1];
var signature = p[2];
DateTime time;
// 第二欄為日期時間,與現在時間只容許 5 秒誤差,防止重放攻擊
if (!DateTime.TryParseExact(data, "yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out time) || Math.Abs((DateTime.UtcNow - time.ToUniversalTime()).TotalSeconds) > 5)
{
Response.StatusCode = 403;
Response.Write("Invalid or expired timestamp - " + data);
return;
}
var pubKeyLine = pubKeyLines.FirstOrDefault(x => x.EndsWith(signer));
if (pubKeyLine == null)
{
Response.StatusCode = 403;
Response.Write("Invalid signer");
return;
}
if (Verify(pubKeyLine, Encoding.UTF8.GetBytes($"{signer} {data}"), Convert.FromBase64String(signature)))
{
Response.Write($"Welcome, {signer}!");
}
else
{
Response.StatusCode = 403;
Response.Write("Invalid signature");
}
}
public static byte[] ExtractPublicKey(string publicKeyLine)
{
// 註:省略檢查並預設為 ssh-ed25519 公鑰簡化程式碼
var publicKeyParts = publicKeyLine.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
var publicKeyBlob = Convert.FromBase64String(publicKeyParts[1]);
// 取最後 32 bytes 為公鑰
var publicKey = new byte[32];
Array.Copy(publicKeyBlob, publicKeyBlob.Length - 32, publicKey, 0, 32);
return publicKey;
}
// 傳入公鑰字串、資料與簽章,使用 Ed25519 演算法驗證簽章,回傳驗證結果
public static bool Verify(string publicKeyLine, byte[] data, byte[] signature) => Verify(ExtractPublicKey(publicKeyLine), data, signature);
// 傳入公鑰、資料與簽章,使用 Ed25519 演算法驗證簽章,回傳驗證結果
public static bool Verify(byte[] publicKeyBytes, byte[] data, byte[] signature)
{
var publicKey = new Ed25519PublicKeyParameters(publicKeyBytes, 0);
var verifier = new Ed25519Signer();
verifier.Init(false, publicKey);
verifier.BlockUpdate(data, 0, data.Length);
return verifier.VerifySignature(signature);
}
</script>
以下是驗證測試:
using System.Text;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;
using Org.BouncyCastle.Crypto.Utilities;
var message = args.Length > 0 ? string.Join(' ', args) : "BouncyCastle Ed25519 signing test";
try
{
// 由 ~/.ssh/id_ed25519.pub 讀取預設個人公鑰字串,並使用 ssh-agent 進行簽章
var publicKeyLine = SshSignTool.ReadPublicKeyLine();
var data = Encoding.UTF8.GetBytes(message);
var signature = SshAgentSigner.Sign(publicKeyLine, data);
// 使用 id_ed25519.pub 驗證簽章
var isValid = SshSignTool.Verify(publicKeyLine, data, signature);
Console.WriteLine($"訊息: {message}");
Console.WriteLine($"簽章 (Base64): {Convert.ToBase64String(signature)}");
Console.WriteLine($"使用 id_ed25519.pub 驗證: {(isValid ? "成功" : "失敗")}");
// 將簽章與訊息傳送至本地端 ASP.NET Web API 進行驗證
using var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Add("Authorization", $"{message} {Convert.ToBase64String(signature)}");
var response = await httpClient.GetAsync("http://localhost/aspnet/sshauth/");
var responseContent = await response.Content.ReadAsStringAsync();
Console.ForegroundColor = response.IsSuccessStatusCode ? ConsoleColor.Green : ConsoleColor.Red;
Console.WriteLine($"網站身分驗證結果: {responseContent}");
Console.ResetColor();
}
catch (Exception exception) when (exception is IOException or FormatException or ArgumentException or InvalidOperationException)
{
Console.Error.WriteLine($"Agent 簽章或驗證失敗: {exception.Message}");
}
測試成功~

可執行的客戶端程式範例我已放上 Github,若想測試記得先用 ssh-keygen -t ed25519 建立 SSH 個人金鑰並準備 ASPX 或其他程式語言寫的登入驗證網頁。
Use existing OpenSSH Ed25519 keys for WebAPI authentication via Windows ssh-agent, Named Pipe, SSH Agent Protocol signing, and server-side signature verification to replace API keys.
Comments
Be the first to post a comment