466 lines
19 KiB
Markdown
466 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. |
|