免認證的授權:無密碼的應用程式安全
認證與授權解決的是不同的問題
大多數應用程式都以一道關卡開始。在做任何事之前,使用者必須確立身分:輸入電子郵件、收取驗證碼、設定密碼,或點擊魔法連結。這一步是認證,即對照中心權威保存的紀錄,確認某人是誰的過程。
授權回答的是另一個問題:這個人被允許做什麼?團隊常把兩者打包成單一的「登入」流程,但它們是不同的操作。把它們分開,會浮現一個有用的洞見。
伺服器無需知道使用者是誰,就能執行他被允許做的事。
JustCall 正建立在這一分離之上。沒有註冊,沒有帳號資料表,沒有密碼,伺服器上也沒有個人資料。授權支撐著整個系統,而傳統意義上的認證並不存在。讓這一切成為可能的是三個構件:私鑰、公鑰,以及一個我們稱之為主金鑰的受委派角色。
公鑰身分如何取代帳號
你第一次開啟 JustCall 時,瀏覽器在本機產生一個祕密,且從不傳輸它。它從這個祕密衍生出一對金鑰,公開的那一半成為你的使用者 ID:
// The private key is generated on your device. The server never sees it.
privateKey = crypto.randomUUID();
// The PUBLIC half, encoded as text, becomes your user ID.
userId = await derivePublicUserId(privateKey);
識別碼就是公鑰本身,而不是指向它的一列資料庫紀錄。這一個決定,消除了傳統帳號系統背負的多項負擔:
- 沒有註冊步驟。伺服器在第一次看到某個 ID 時,就開始接受用它簽名的請求。
- 沒有電子郵件或電話號碼。ID 衍生自你裝置上的隨機祕密,而非任何個人資訊。
- 沒有密碼。沒有需要儲存、雜湊或外洩的共享祕密,因為伺服器只持有公鑰。
- 沒有密碼重設流程,也沒有撞庫,因為沒有可供攻擊的可重用憑證。
你從不向伺服器透露身分。你以密碼學方式證明自己持有某把公鑰背後的私鑰,這個證明就足以作為授權。
伺服器如何授權一個請求
沒有工作階段也沒有登入權杖,每個請求都攜帶自己的證明。用戶端建構一段酬載,用私鑰簽名,再把兩者一起送出。在伺服器端,授權歸結為一次簽名驗證,沒有工作階段查找,也沒有對憑證庫的查詢:
const isValid = await verifyPayload(
request.userId, // the public key = the claimed identity
request.payload, // exactly what was signed
request.signature,
);
if (!isValid) {
return jsonResponse({ message: "invalid signature" }, 401);
}
一個有效簽名讓請求在一次操作中既真實又已授權。攻擊者無法為一個自己不持有私鑰的 ID 偽造請求,因為他無法產生相符的簽名。持有私鑰即是權限。
主金鑰:不共享祕密即可連結裝置
「一台裝置持有唯一金鑰」的模型,在有人新增第二台裝置時就會失效。如果你的手機持有你身分的唯一金鑰,那你的筆電對系統而言就是陌生人。
主金鑰解決了這一點。第一台裝置成為主裝置,持有你的 ID 由之衍生的原始祕密。每台新增裝置都產生自己獨立的金鑰對。新裝置無法以你的名義簽名,而主金鑰從不在裝置之間複製。連結透過委派完成:主裝置簽署一張簡短的憑證,把新裝置的公鑰繫結到你的 ID。
// Only the master can authorize a new device.
const authorization = {
action: "device-authorization",
userId: identity.userId,
devicePublicKey: newDevice.devicePublicKey, // the new device's PUBLIC key
role: newDevice.role,
createdAt: Date.now(),
};
const signature = await signPayload(identity.privateKey, authorization);
此後,新裝置用自己的金鑰簽署自己的請求,伺服器則對照主裝置擔保過的那把金鑰來驗證。主裝置頒發了一份憑證,卻從不暴露自己的祕密,正如憑證頒發機構為一個自己並不持有私鑰的網站擔保。
由此你獲得了正式環境權限系統的完整詞彙:角色、委派、撤銷與最小權限,全部透過簽名表達。主裝置可以重新命名或撤銷任何已連結裝置,被撤銷的裝置在下一次簽名驗證時失敗,其活動連線隨即關閉。
無帳號的伺服器儲存什麼
看看資料庫裡到底有什麼。每個帳號持有使用者選擇的顯示名稱、一個可選頭像,以及一份公鑰裝置清單。僅此而已。沒有電子郵件、電話號碼、密碼雜湊或安全問題,也沒有私鑰,私鑰從不離開使用者的裝置。
設想整個資料庫外洩這一經典入侵情境。攻擊者得到的是一組公鑰(按設計就是公開的,沒有對應私鑰便毫無用處),以及若干使用者自選的顯示名稱。他無法冒充任何人,因為冒充需要一把伺服器從未儲存的私鑰。他無法把使用者與密碼外洩庫比對,因為根本不存在密碼。他無法藉「你的電子郵件」進行釣魚,因為從未蒐集過電子郵件。
系統無法外洩它從不持有的資料。保護個人資訊最可靠的辦法是不去蒐集它,而一個 ID 即公鑰的身分模型讓這句話成為字面事實。
為何無密碼授權能降低外洩風險
直接比較攻擊面。一個傳統的電子郵件加密碼應用程式必須防守憑證資料庫、密碼重設流程、電子郵件與簡訊驗證碼下發、工作階段權杖,以及一批它如今要負責的受監管個人資料。每一項都是帳號接管與資料外洩事件的反覆來源。
公鑰模型只需防守一件事:使用者保管好自己的金鑰。這是真實的取捨,因為沒有中心權威能重設遺失的金鑰。作為交換,上一份清單上的每一項都消失了。伺服器被攻破的影響收窄到攻擊者能讀取公鑰和顯示名稱,而這些資訊本就從不敏感。
在你自己的應用程式中運用公鑰授權
這一模式遠不止適用於通話應用程式:
- 讓身分成為一對金鑰,讓使用者 ID 成為公鑰。在用戶端產生,永不傳輸私鑰那一半,並讓帳號在伺服器第一次看到有效簽名時誕生。
- 透過驗證簽名而非檢查工作階段來授權。每個動作都攜帶一段酬載和一個簽名。對正規化的位元組簽名,使用戶端和伺服器對「簽了什麼」達成一致。
- 用主金鑰去委派,而非去共享。當某個主體需要把信任擴展到第二台裝置、一位隊友或一個 API 用戶端時,讓根金鑰為一把新金鑰簽署一張有範圍、可撤銷的憑證。根祕密從不移動。
- 只儲存公開材料,這樣「被外洩了會怎樣」的答案始終平淡無奇。
上線前有幾條約束值得留意。金鑰保管成為核心問題,因此正式環境系統會加入復原手段,例如社交復原、可選的加密備份或硬體金鑰。簽名請求需要時間戳記或 nonce 來防重放。由於沒有身分,反垃圾的工作從電子郵件驗證轉向速率限制或邀請制。這些都不會動搖該模型。它們是任何系統都有的尋常課題,只是中間不再坐著一個密碼資料庫。
常見問題
應用程式如何能在不認證的情況下授權使用者?它把持有私鑰當作權限。公鑰充當使用者 ID,每個動作都由對應的私鑰簽名,伺服器對照公鑰驗證該簽名。使用者證明自己被允許行動,卻不向一個中心帳號系統證明身分。
無帳號模型是否不如電子郵件加密碼安全?在大多數方面更安全。沒有可外洩的密碼資料庫,沒有可濫用的重設流程,也沒有可遺失的個人資料。取捨在於金鑰保管:如果使用者在沒有備份的情況下弄丟金鑰,沒有中心權威能復原帳號。
認證和授權有什麼區別?認證確立你是誰。授權確立你被允許做什麼。大多數系統把它們合在一起,但一對金鑰讓你能做到後者而無需前者。
如果 JustCall 的伺服器被攻破會怎樣?攻擊者會找到公鑰和顯示名稱。公鑰本就該公開,沒有那些從不離開你裝置的私鑰便毫無用處。沒有可竊取的密碼、電子郵件或電話號碼。
沒有帳號能用多台裝置嗎?可以。第一台裝置作為主裝置,簽署一張憑證,把每台新裝置的公鑰繫結到你的 ID。主祕密從不複製,主裝置可隨時撤銷任何裝置。