『既然很幸運不需要為生存掙扎, 那就該盡量過多種的生活比較不枉費自己掌握的資源。』
在今年年初的時候,W3C 正式將 Web Authentication (WebAuthn) 列入正式標準,開發者可以透過 API 啟動 FIDO 2 驗證,讓使用者透過行動裝置、指紋辨識或硬體安全金鑰來登入帳號,不需輸入密碼!
雖然目前支援度還不高,但既然已成為標準,沒理由不來認識一下何謂 Web Authentication,以及我們可以如何使用。今天就一起來了解我們能如何使用 Web Authentication,增加網站安全性,並免除密碼帶給我們的諸多麻煩。
不管是手機 application 或是網站,只要是需要登入操作的,勢必都會提供一個頁面讓你註冊帳號與密碼,接著你才能拿著這組帳密登入進行使用。這是我們習以為常的使用方式,但隨之而來的問題大家也是見怪不怪:
而大約有三成左右危機意識較高的使用者會啟用 2FA (2 factor authentication),但這何嘗不是為使用上帶來更多不便?
因此 WebAuthn 就是希望來解決這些問題,為我們使用者提供一個除了密碼登入以外的選項。
聽到所謂免密碼登入,應該很容易聯想到我們透過 SSH 連進遠端 Server 時,大多也會設定免密碼登入,透過的是 Public-key cryptography 或又稱 asymmetric cryptography,也就是利用產生一組非對稱的加密金鑰:private key
與 public key
,又可稱作 credential
,讓使用者自己保管 private key,而服務端利用 public key 來驗證使用者的身份。
WebAuthn 也是利用相同的加密原理來達成免密碼的 Authentication。
其實很多人會誤解,以為 WebAuthn 指的就是上述利用非對稱加密來達到 authentication 的技術,但實際上,真正規範這項技術的是 FIDO2,FIDO2 是 FIDO Alliance 的最新規範,裡面包含了一系列技術,讓使用者能透過一些普通的設備,輕易的與線上服務進行 authentication,無論是 Mobile 或是 Desktop。
FIDO2 規範包含了 WebAuthn 與 Client-to-Authenticator Protocol (CTAP)
ref: fidoalliance.org
也就是說,WebAuthn 其實是 FIDO2 的一部分,用來規範標準的 Web API 讓瀏覽器實作。
目前支援的瀏覽器有 Chrome, Firefox, Edge 與 Safari preview 版。另外也可以再多參考 Duo 的文章。
我們稍微解釋一下上圖:
當使用者像應用程序要求註冊或登入時,使用 FIDO2 的服務端 (RP App Server) 會告知瀏覽器說,我們現在要用 FIDO Authentication,而瀏覽器則會使用 WebAuthn API,透過 CTAP protocol 來與 Authenticator 溝通,存取到需要的資訊並產生 public key 傳回給服務端。
這邊出現兩個新名詞:RP app server 與 Authenticator。
所謂的 RP (Relying Party),描寫的就是負責註冊與認證使用者的組織或是服務,基本上我覺得就是你的伺服器。
而 Authenticator 就是負責產生 credential 的軟硬體,例如:TouchID, YubiKey 等等。TouchID 這種存在於設備內部的,屬於 "platform" type,而 Yubikey 這類外部硬體設施則屬於 "cross-platform" type。
接著我們可以開始來看看如何使用 Web Authentication API 來註冊使用者 (參考至 Duo 的 webauthn.guide:
navigator.credentials.create()
首先,Server 利用 navigator.credentials.create()
來產生 client 的 credential
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions
});
publicKeyCredentialCreationOptions
中有一堆 required 與 optional 的欄位需要填寫:
// should generate from server
const challenge = new Uint8Array(32);
window.crypto.getRandomValues(challenge);
const userID = 'Kosv9fPtkDoh4Oz7Yq/pVgWHS8HhdlCto5cR0aBoVMw=';
const id = Uint8Array.from(window.atob(userID), c=>c.charCodeAt(0));
const publicKeyCredentialCreationOptions = {
challenge,
rp: {
name: "Tech Bridge",
id: "techbridge.inc",
},
user: {
id,
name: "[email protected]",
displayName: "Arvin",
},
pubKeyCredParams: [{alg: -7, type: "public-key"}],
authenticatorSelection: {
authenticatorAttachment: "platform",
},
timeout: 60000,
attestation: "direct"
};
challenge
:challenge 是由 server 產生的一個 buffer,裡面含有一串隨機加密過的 bytes,用來防止 "replay attacks"。這邊單純 demo,用 Unit8Array
與 window.crypto.getRandomValues
做個示範。
rp
:先前解釋過,代表 "relying party",指的就是註冊與認證使用者的網站。其中,id
一定要是目前網站的 domain 的 subset,像是如果是從 techbridge.cc
觸發 WebAuthn 認證,那這邊的 id 就得填寫 techbridge.cc
。
user
:就是目前想要註冊的使用者的資訊,這邊的 id
很重要,authenticator 會用這個 id 來與 credential 做連結,這樣之後才能透過一樣的 id
與一樣的 autenticator 去認證你是同個 user。而這資訊可能會被存在 authenticator 中,依據不同載具與實作會有不同,但是因為有這可能性存在,所以一般不建議 id 內藏有可以認出你本身的相關個人資訊,像是生日等等的。
pubKeyCredParams
:這個 array 用來描述 Server 支援哪些 types 的 public key (signing algorithms) 。目前 FIDO2 servers 授權支援 RS1、RS256 與 ES256。 alg
的數字對應可以從 COSE 去找。
authenticatorSelection (optional)
:可以用來限制 Relying parties 支援的 authenticators 種類,像這邊我指定 platform,就是 Touch ID 之類內建的 authenticators。
timeout (optional)
:以 milliseconds 為單位,表示說如果使用者在這段 timeout 時間內沒有回應 prompt 的話,就會回傳認證失敗。
attestation (optional)
:attestation data 是從 authenticator 回傳的內容,server 可以用這個選項來決定要跟 authenticators 拿多少資訊,有三種選項:none、indirect 與 direct。none 指的是 server 不在意 attestation 資料;indirect 表示願意只拿匿名的 attestation 資料;而 direct 代表要求 authenticator 回傳所有 attestation data。
現在打開瀏覽器的 console,把上面那段 code 複製上去,然後你會發現什麼事都沒發生?!
因為上面那段 code 當中,我們在 rp
中的 id 有指定 techbridge.inc
,而這並非你目前所在的 domain,因此無法啟用 WebAuthn。
修改一下,將 rp
中的 id
移除,再試一次:
就可以 credentials 了!
另外,因為我們在 authenticatorSelection.authenticatorAttachment
中有指定 platform
,所以當我們一請求 credential 後,Chrome 會直接跳出 prompt 要求使用 TouchID(上面出現 codepen.io 是因為我在 codepen.io 的頁面 console 測試:
但如果今天你沒有指定,或是指定為 cross-platform
,則會顯示如下兩種 prompt:
cross-platform:
會要求你插入實體 usb key。
不指定:
你有兩種選項可以選擇,使用 TouchID 或是實體 usb key。
從剛剛的範例可以看到,呼叫 navigator.credentials.create
後會回傳一個 credential 物件:
console.log(credential);
PublicKeyCredential {
id: 'ADSUllKQmbqdGtpu4sjseh4cg2TxSvrbcHDTBsv4NSSX9...',
rawId: ArrayBuffer(59),
response: AuthenticatorAttestationResponse {
clientDataJSON: ArrayBuffer(121),
attestationObject: ArrayBuffer(306),
},
type: 'public-key'
}
其中:
id
:代表產生的 credential,在對 user 進行 authentication 時,用此 id 來 identify user credential,是 base64-encoded string。
rawId
:同上,只是是 binary form。
response.clientDataJSON
:是瀏覽器產生的一組資料,包含 origin、challenge 等等,這個資料重要在於可以用來防止 phishing attemp。此外,內容是由 authenticator 加密過的。
response.attestationObject
:包含了 credential public key、optional attestation certificate 與其他 metadata,用 CBOR encoded 過的 binary data。
拿到 PublicKeyCredential 後,就可以送到 Server 端去進行驗證與後續動作。
WebAuthn 規範中有描述了一系列驗證 registration data 的程序,而實作部分則與你用什麼語言有所不同,可以參考 Duo Labs 的 Go 與 Python 版本。
這邊以 JavaScript 為例說明:
首先,Server 端需要先解析 clientDataJSON:
// decode the clientDataJSON into a utf-8 string
const utf8Decoder = new TextDecoder('utf-8');
const decodedClientData = utf8Decoder.decode(
credential.response.clientDataJSON)
// parse the string as an object
const clientDataObj = JSON.parse(decodedClientData);
console.log(clientDataObj)
{
challenge: "ZVTQWf9y7JkEjKFH-iWiKL4FTtTrQJwhYt2kiJQlcM8"
origin: "https://codepen.io" // again, 因為我在 codepen.io 的頁面 console 測試,所以 origin 才會是這個
type: "webauthn.create"
}
這邊我們拿出 challenge、origin 與 type 來驗證,challenge 應該要與當初 Server 產生的一致、origin 要正確,且 type 要確定為 create,才能代表是在註冊使用者。如此來避免釣魚與 replay attacks。
接著,處理 attestationObject,由於是由 CBOR encode,所以需要額外找 lib 來 decode:
// 需要找個 CBOR lib 來 decode
const decodedAttestationObj = CBOR.decode(credential.response.attestationObject);
console.log(decodedAttestationObj);
{
authData: Uint8Array(196),
fmt: "fido-u2f",
attStmt: {
sig: Uint8Array(70),
x5c: Array(1),
},
}
解碼出來後的 attestationObject
含有幾項資訊:
最後可以從 authData 中取得更多資料:credentialId、publicKeyBytes、publicKeyObject 等等:
const {authData} = decodedAttestationObject;
// get the credential ID
const credentialId = authData.slice(55, credentialIdLength);
// get the public key object
const publicKeyBytes = authData.slice(55 + credentialIdLength);
// the publicKeyBytes are encoded again as CBOR
const publicKeyObject = CBOR.decode(publicKeyBytes.buffer);
整個 Validation process 完成後,Server 就能將 publicKeyBytes 與 credentialId 存進資料庫,與使用者關聯起來。
到這邊為止,我們走完了 Registering
的流程。而剛剛都是直接在 console 貼入程式碼,如果想看稍微”真實“一點的範例,可以到 https://herrjemand.github.io/FIDO2WebAuthnSeries/WebAuthnIntro/makeCredExample.html 這個網址玩玩看,點選 button 註冊的流程。
接著,再努力一下,看看 Authenticating
,也就是登入時要走的步驟吧!
Authenticated 的過程中,使用者會用其持有的 private key 簽上一個 signature 到 assertion 上頭,並傳給 server,server 則利用 public key 來驗證該 signature。
navigator.credentials.get()
Authentication 的過程在於使用者要證明他們擁有註冊時所提交的 key pair 中的 private key。證明方法為利用 navigator.credentials.get()
取得註冊時的 credential,並附上 signature。
const credential = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions
});
const challenge = new Uint8Array(32);
window.crypto.getRandomValues(challenge);
const publicKeyCredentialRequestOptions = {
challenge,
allowCredentials: [{
id: credentialId, // from registration
type: 'public-key',
transports: ['usb', 'ble', 'nfc'],
}],
timeout: 60000,
}
const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions
});
跟註冊時比較不同的是 allowCredentials
,這欄位是 Servers 端想要瀏覽器提供的 credentials 資訊,用來認證使用者,其中 id 為註冊時獲取的 credentialId。此外也能指定要用哪種方式傳輸 credentials (usb、bluetooth 或 NFC)。你可以填入多個 credentialId,authenticator 會找出他認識的來使用。
navigator.credentials.get
取得的 assertion
也是一個 PublicKeyCredential
object,跟註冊時的差別在於,這次我們拿到的多了 signature,少了 public key。
console.log(assertion);
PublicKeyCredential {
id: 'ADSUllKQmbqdGtpu4sjseh4cg2TxSvrbcHDTBsv4NSSX9...',
rawId: ArrayBuffer(59),
response: AuthenticatorAssertionResponse {
authenticatorData: ArrayBuffer(191),
clientDataJSON: ArrayBuffer(118),
signature: ArrayBuffer(70),
userHandle: ArrayBuffer(10),
},
type: 'public-key'
}
基本結構都差不多,多出來的 signature
是由 private key 與此 credential 所產生,在 server 端可以用 public key 來驗證此 signature。
userHandle
則是 authenticator 提供的使用者 id,也是註冊時使用的那組。在 Server 端可以用來關聯使用者。
拿到 assertion 後,可以送往 server 進行驗證,server 會用註冊時的 public key 來驗證 signature。
一樣,Server 端實作有各種語言版本:Go 與 Python,等等。
這邊簡單舉例:
首先,從資料庫中取得 credential。接著利用 public key 和 client 傳來的 signature 與 authenticator data 和 SHA-256 hash 過的 cliendDataJSON 做驗證。
const storedCredential = await getCredentialFromDatabase(userHandle, credentialId);
const signedData = (authenticatorDataBytes + hashedClientDataJSON);
const signatureIsValid = storedCredential.publicKey.verify(signature, signedData);
if (signatureIsValid) {
return "Nice! User is authenticated!";
} else {
return "Oops, verification failed."
}
這樣我們就把一段無密碼登入的註冊與登入流程都走完了!一樣,可以到 https://herrjemand.github.io/FIDO2WebAuthnSeries/WebAuthnIntro/PasswordlessExample.html 來玩玩看實際一點的範例。
今天花了點時間把 Web Authentication 的概念與大致的實作方式瞭解了一番,雖然才剛成為標準,離全部瀏覽器都支援還需要一點時間,但最新版的瀏覽器幾乎都已支援(至少桌機版),想必不久的未來,passwordless 的登入會是一種趨勢。
這邊再多提供幾個網站資源供大家參考,裡面有更為豐富的範例程式,以及詳細的 API 與情境解說: