ente/web/docs/webauthn-passkeys.md
2024-04-03 13:39:44 +05:30

467 lines
19 KiB
Markdown

# Passkeys on Ente
Passkeys is a colloquial term for a relatively new authentication standard
called [WebAuthn](https://en.wikipedia.org/wiki/WebAuthn). Now rolled out to all
major browsers and operating systems, it uses asymmetric cryptography to
authenticate the user with a server using replay-attack resistant signatures.
These processes are usually abstracted from the user through biometric prompts,
such as Touch ID/Face ID/Optic ID, Fingerprint Unlock and Windows Hello. These
passkeys can also be securely synced by major password managers, such as
Bitwarden and 1Password, although the syncing experience can greatly vary due to
some operating system restrictions.
## Terms
| Term | Definition |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Credential | Passkey. It is an asymmetric keypair identified by a unique ID generated by the client. |
| Authenticator | A software-based implementation or physical device capable of storing and using credentials to authenticate. |
| Relying Party | Us. We rely on the user's authenticator to verify their identity through a cryptographic signature. We prove this signature through the credential's public key that we store. |
| Ceremony | An analogy referring to the multiple steps involved in authenticating or registering a credential with a relying party. Ceremonies have a "begin" and a "finish". Information must be persisted between these two steps. |
## Getting to the passkeys manager
As of Feb 2024, Ente clients have a button to navigate to a WebView of Ente
Accounts. Ente Accounts allows users to add and manage their registered
passkeys.
❗ Your WebView MUST invoke the operating-system's default browser, or an
equivalent browser with matching API parity. Otherwise, the user will not be
able to register or use registered WebAuthn credentials.
### Accounts-Specific Session Token
When a user clicks this button, the client sends a request for an
Accounts-specific JWT session token as shown below. **The Ente Accounts API is
restricted to this type of session token, so the user session token cannot be
used.** This restriction is a byproduct of the enablement for automatic login.
#### GET /users/accounts-token
##### Headers
| Name | Type | Value |
| ------------ | ------ | ------------------------------------------------ |
| X-Auth-Token | string | The user session token. It is encoded in base64. |
##### Response Body (JSON)
| Key | Type | Value |
| ------------- | ------ | ----------------------------------------------------------------- |
| accountsToken | string | The Accounts-specific JWT session token. It is encoded in base64. |
### Automatically logging into Accounts
Clients open a WebView with the URL
`https://accounts.ente.io/accounts-handoff?token=<accountsToken>&package=<app package name>`.
This page will appear like a normal loading screen to the user, but in the
background, the app parses the token and package for usage in subsequent
Accounts-related API calls.
If valid, the user will be automatically redirected to the passkeys management
page. Otherwise, they will be required to login with their Ente credentials.
## Registering a WebAuthn credential
### Requesting publicKey options (begin)
The registration ceremony starts in the browser. When the user clicks the "Add
new passkey" button, a request is sent to the server for "public key" creation
options. Although named "public key" options, they actually define customizable
parameters for the entire credential creation process. They're like an
instructional sheet that defines exactly what we want. As of the creation of
this document, the plan is to restrict user authenticators to cross-platform
ones, like hardware keys. Platform authenticators, such as TPM, are not portable
and are prone to loss.
On the server side, the WebAuthn library generates this information based on
data provided from a `webauthn.User` interface. As a result, we satisfy this
interface by creating a type with methods returning information from the
database. Information stored in the database about credentials are all
pre-processed using base64 where necessary.
```go
type PasskeyUser struct {
*ente.User
repo *Repository
}
func (u *PasskeyUser) WebAuthnID() []byte {
b, _ := byteMarshaller.ConvertInt64ToByte(u.ID)
return b
}
func (u *PasskeyUser) WebAuthnName() string {
return u.Email
}
func (u *PasskeyUser) WebAuthnDisplayName() string {
return u.Name
}
func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential {
creds, err := u.repo.GetUserPasskeyCredentials(u.ID)
if err != nil {
return []webauthn.Credential{}
}
return creds
}
```
#### GET /passkeys/registration/begin
##### Headers
| Name | Type | Value |
| ------------ | ------ | ------------------------------------------------ |
| X-Auth-Token | string | The user session token. It is encoded in base64. |
##### Response Body (JSON)
| Key | Type | Value |
| --------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| options | object | The credential creation options that will be provided to the browser. |
| sessionID | string (uuidv4) | The identifier the server uses to persist metadata about the registration ceremony, like the user ID and challenge to prevent replay attacks. |
```json
{
"options": {
"publicKey": {
"rp": {
"name": "Ente",
"id": "accounts.ente.io"
},
"user": {
"name": "james@example.org",
"displayName": "",
"id": "AAWdgssasAY"
},
"challenge": "xYVv1V08dgrsU_4k5niEkFcfIGbwPauWKPBARS6C6Dg",
"pubKeyCredParams": [
{
"type": "public-key",
"alg": -7
},
{
"type": "public-key",
"alg": -35
},
{
"type": "public-key",
"alg": -36
},
{
"type": "public-key",
"alg": -257
},
{
"type": "public-key",
"alg": -258
},
{
"type": "public-key",
"alg": -259
},
{
"type": "public-key",
"alg": -37
},
{
"type": "public-key",
"alg": -38
},
{
"type": "public-key",
"alg": -39
},
{
"type": "public-key",
"alg": -8
}
],
"timeout": 300000,
"authenticatorSelection": {
"requireResidentKey": false,
"userVerification": "preferred"
}
}
},
"sessionID": "0a8442d7-8580-4391-8ac3-4a75d6a7f115"
}
```
### Pre-processing the options before registration
Even though the server generates these options, the browser still doesn't
understand them. For interoperability, the server's WebAuthn library returns
binary data in base64, like IDs and the challenge. However, the browser requires
this data back in binary.
We just have to decode the base64 fields back into `Uint8Array`.
```ts
const options = response.options;
options.publicKey.challenge = _sodium.from_base64(options.publicKey.challenge);
options.publicKey.user.id = _sodium.from_base64(options.publicKey.user.id);
```
### Creating the credential
We use `navigator.credentials.create` with these options to generate the
credential. At this point, the user will see a prompt to decide where to save
this credential, and probably a biometric authentication gate depending on the
platform.
```ts
const newCredential = await navigator.credentials.create(options);
```
### Sending the public key to the server (finish)
The browser returns the newly created credential with a bunch of binary fields,
so we have to encode them into base64 for transport to the server.
```ts
const attestationObjectB64 = _sodium.to_base64(
new Uint8Array(credential.response.attestationObject),
_sodium.base64_variants.URLSAFE_NO_PADDING
);
const clientDataJSONB64 = _sodium.to_base64(
new Uint8Array(credential.response.clientDataJSON),
_sodium.base64_variants.URLSAFE_NO_PADDING
```
Attestation object contains information about the nature of the credential, like
what device it was generated on. Client data JSON contains metadata about the
credential, like where it is registered to.
After pre-processing, the client sends the public key to the server so it can
verify future signatures during authentication.
#### POST /passkeys/registration/finish
When the server receives the new public key credential, it pre-processes the
JSON objects so they can fit within the database. This includes base64 encoding
`[]byte` slices and their encompassing arrays or objects.
```go
// Convert the PublicKey to base64
publicKeyB64 := base64.StdEncoding.EncodeToString(cred.PublicKey)
// Convert the Transports slice to a comma-separated string
var transports []string
for _, t := range cred.Transport {
transports = append(transports, string(t))
}
authenticatorTransports := strings.Join(transports, ",")
// Marshal the Flags to JSON
credentialFlags, err := json.Marshal(cred.Flags)
if err != nil {
return nil, err
}
// Marshal the Authenticator to JSON and encode AAGUID to base64
authenticatorMap := map[string]interface{}{
"AAGUID": base64.StdEncoding.EncodeToString(cred.Authenticator.AAGUID),
"SignCount": cred.Authenticator.SignCount,
"CloneWarning": cred.Authenticator.CloneWarning,
"Attachment": cred.Authenticator.Attachment,
}
authenticatorJSON, err := json.Marshal(authenticatorMap)
if err != nil {
return nil, err
}
// convert cred.ID into base64
credID := base64.StdEncoding.EncodeToString(cred.ID)
```
On retrieval, this process is effectively the opposite.
#### Query Parameters
| Key | Value |
| ------------ | ------------------------------------------------------------------------------------------------------- |
| friendlyName | The user's entered name for their credential. It helps them identify it in the dashboard in the future. |
| sessionID | The server's identifier for this registration ceremony instance, as returned from the begin step. |
##### Headers
| Name | Type | Value |
| ------------ | ------ | ------------------------------------------------ |
| X-Auth-Token | string | The user session token. It is encoded in base64. |
##### Request Body (JSON)
| Key | Type | Value |
| -------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| id | string | Base64 encoded client generated identifier for the credential. |
| rawId | string | Base64 encoded client generated identifier for the credential that can be derived from the browser's rawId field, but can also just be set to id. |
| type | string | The type of credential. |
| response | object | Contains attestationObject and clientDataJSON fields that were encoded prior to request. |
**Example**
```json
{
id: credential.id,
rawId: credential.id,
type: credential.type,
response: {
attestationObject: attestationObjectB64,
clientDataJSON: clientDataJSONB64,
},
}
```
## Authenticating with a credential
Passkeys have been integrated into the existing two-factor ceremony. When
logging in via SRP or verifying an email OTT, the server checks if the user has
any number of credentials setup or has 2FA TOTP enabled. If the user has setup
at least one credential, they will be served a `passkeySessionID` which will
initiate the authentication ceremony.
```tsx
const {
// ...
twoFactorSessionID,
passkeySessionID,
} = await loginViaSRP(srpAttributes, kek);
setIsFirstLogin(true);
if (passkeySessionID) {
// ...
}
```
The client should redirect the user to Accounts with this session ID to prompt
credential authentication. We use Accounts as the central WebAuthn hub because
credentials are locked to an FQDN.
```tsx
window.location.href = `${getAccountsURL()}/passkeys/flow?passkeySessionID=${passkeySessionID}&redirect=${
window.location.origin
}/passkeys/finish`;
```
### Requesting publicKey options (begin)
#### GET /users/two-factor/passkeys/begin
##### Query Parameters
| Key | Value |
| --------- | ------------------------------------------------------------------------- |
| sessionID | The `passkeySessionID` returned from SRP login or email OTT verification. |
##### Response Body (JSON)
**Example**
```json
{
"ceremonySessionID": "98a80fbd-c484-4f3b-a139-c43faf4b171f",
"options": {
"publicKey": {
"challenge": "dF-mmdZSBxP6Z7OhZrmQ4h-k-BkuuX6ERnW_ckYdkvc",
"timeout": 300000,
"rpId": "accounts.ente.io",
"allowCredentials": [
{
"type": "public-key",
"id": "lGfY8iSVjdAsqGKzWv3mkAesRfo",
"transports": [""]
}
],
"userVerification": "preferred"
}
}
}
```
| Key | Type | Value |
| ----------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ceremonySessionID | string | The server identifier for the authentication session. |
| options | object | publicKey options that define which WebAuthn credentials are valid. These credentials can be safely shared with the user because they do not contain any personally identifiable information. |
### Pre-processing the options before retrieval
The browser requires `Uint8Array` versions of the `options` challenge and
credential IDs.
```ts
publicKey.challenge = _sodium.from_base64(
publicKey.challenge,
_sodium.base64_variants.URLSAFE_NO_PADDING,
);
publicKey.allowCredentials?.forEach(function (listItem: any) {
listItem.id = _sodium.from_base64(
listItem.id,
_sodium.base64_variants.URLSAFE_NO_PADDING,
);
});
```
### Retrieving the credential
```ts
const credential = await navigator.credentials.get({
publicKey: options,
});
```
### Pre-processing the credential metadata and signature before authentication
Before sending the public key and signature to the server, their outputs must be
encoded into Base64.
```ts
authenticatorData: _sodium.to_base64(
new Uint8Array(credential.response.authenticatorData),
_sodium.base64_variants.URLSAFE_NO_PADDING
),
clientDataJSON: _sodium.to_base64(
new Uint8Array(credential.response.clientDataJSON),
_sodium.base64_variants.URLSAFE_NO_PADDING
),
signature: _sodium.to_base64(
new Uint8Array(credential.response.signature),
_sodium.base64_variants.URLSAFE_NO_PADDING
),
userHandle: _sodium.to_base64(
new Uint8Array(credential.response.userHandle),
_sodium.base64_variants.URLSAFE_NO_PADDING
),
```
### Sending the credential metadata and signature to the server (finish)
#### POST /users/two-factor/passkeys/finish
##### Query Parameters
| Key | Value |
| ----------------- | ---------------------------------------------------------------------------------------- |
| ceremonySessionID | The `ceremonySessionID` identifier from the begin step. |
| sessionID | The `passkeySessionID` identifier from the SRP login or email OTT verification response. |
##### Request Body (JSON)
| Key | Type | Value |
| -------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| id | string | Base64 encoded client generated identifier for the credential. |
| rawId | string | Base64 encoded client generated identifier for the credential that can be derived from the browser's rawId field, but can also just be set to id. |
| type | string | The type of credential. |
| response | object | Contains authenticatorData, clientDataJSON, signature and userHandle fields that were encoded prior to request. |
##### Response Body (JSON)
| Key | Type | Value |
| -------------- | ------ | ------------------------------------------- |
| id | int64 | The user's ID. |
| keyAttributes | object | Contains user encryption metadata. |
| encryptedToken | string | The encrypted user session token in Base64. |