Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
319 changes: 319 additions & 0 deletions rfd/0155-scoped-webauthn-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
---
authors: Brian Joerger (bjoerger@goteleport.com)
state: draft
---

# RFD 155 - Scoped Webauthn Credentials

## Required Approvers

- Engineering: @rosstimothy && @codingllama
- Product: @klizhentas || @xinding33
- Security: @jentfoo

## What

Add and enforce scope for webauthn credentials.

## Why

Today, when a client performs a webauthn ceremony, the resulting webauthn
credentials can be used to perform actions other than what the client initially
intended.

For example, webauthn credentials intended for per-session MFA could be used
instead for login. This means that if an attacker gets a hold of a user's
webauthn credentials before the client consumes them, an attacker with the
user's password could login as the user. Adding scope to the webauthn
credentials would prevent the attacker from using the stolen webauthn
credentials freely.

Adding scope also enables us to allow webauthn credentials to be reused within
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate the effort to minimize the scope of an MFA. However I think the tracked reuse complicates this:

  • More writes to the backend as each use needs to be atomically decremented, that atomic nature now becoming very important to this update
  • The client has no guarantees that reuse is exactly as intended anyways, instead this mostly provides an automatic expiration mechanism (based on usage)

As a possible simplification we could have a reuse boolean, which would not always be allowed (similar to how you currently define reuse is not always allowed). This flag would mean only MFA actions which don't allow reuse would require a single update to track. In other cases we would rely on short expiration, but in technically allow unlimited reuse during that period.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am ok with removing this, I just wanted to show it in case we had strong opinions to have it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding to this, we could have clients tell the server when they are done using a challenge, so it can be cleaned up. We may not even need the reuse bool, as some kinds can't be reused and others (like admin challenges) can use the client signal for the early delete.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to omit this from the RFD, but I'll investigate this during implementation as a nice to have.

specific scopes. For example, for admin actions it may make sense to allow the
client to reuse a single webauthn credential for a string of requests that are
functionally a single action, such as creating a new user and their corresponding
invite token.

## Details

### Security

Enforcing scope for webauthn credentials strictly improves security, as it is a
purely restrictive change that prevents certain attack vectors, as described
in [Why](#why).

On the other hand, allowing clients to reuse webauthn credentials could have
negative security implications if not handled carefully. Specifically, the
client and server must clearly define:

1. The webauthn credential's scope is provided by the client
2. Reuse is requested by the client
3. Reuse is permitted for the action - server enforced
4. The expiration of the credentials - server enforced (5 minutes)

Note: Sensitive operations like "login" and "recovery" must never allow reuse.

### Proto

```diff
// webauthn.proto

message SessionData {
bytes challenge = 1 [(gogoproto.jsontag) = "challenge,omitempty"];
bytes user_id = 2 [(gogoproto.jsontag) = "userId,omitempty"];
repeated bytes allow_credentials = 3 [(gogoproto.jsontag) = "allowCredentials,omitempty"];
bool resident_key = 4 [(gogoproto.jsontag) = "residentKey,omitempty"];
string user_verification = 5 [(gogoproto.jsontag) = "userVerification,omitempty"];

+ // Scope authorized by this webauthn session.
+ ChallengeScope scope = 6 [(gogoproto.jsontag) = "scope,omitempty"];
+ // AllowReuse indicates that this session can be used multiple times for
+ // authentication, until the session expires.
+ bool AllowReuse = 7 [(gogoproto.jsontag) = "allow_reuse,omitempty"];
}



+// Scope is a scope authorized by a webauthn challenge resolution.
+//
+// Note: New scopes can be added to cover new use cases, or to split existing
+// scopes into more granular scopes. For example, passwordless and headless login
+// could be included under the "login" scope, but splitting them into their own
+// scopes improves the security of each.
+enum ChallengeScope {
+ SCOPE_UNSPECIFIED = 0;
+ // Standard webauthn login.
+ SCOPE_LOGIN = 1;
+ // Passwordless webauthn login.
+ SCOPE_PASSWORDLESS_LOGIN = 2;
Comment on lines +85 to +88
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious, why do these need to be distinct scopes?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My 2c: they don't have to be distinct, but I think it's a good distinction. The passwordless challenge is a tad more powerful than the login one, as it covers multiple factors at once. It also only happens in very specific situations (initial user login), whereas the SCOPE_LOGIN equivalent is used for re-authentication (pre RFD 155, that is).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like I said I was more curious why the two are distinct and not advocating for them to be unified. I'd be happy with a comment elaborating on the differences.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not exactly necessary, but it comes out naturally in the code, replacing passwordless bool in many methods with just scope, and I see no reason to not have this scope.

+ // MFA device management.
+ SCOPE_MANAGE_DEVICES = 3;
+ // Account recovery.
+ SCOPE_RECOVERY = 4;
+ // Used for per-session MFA and moderated session presence checks.
+ SCOPE_SESSION = 5;
+ // Headless login approval.
+ SCOPE_HEADLESS = 6;
+ // Used for various administrative actions, such as adding, updating, or
+ // deleting administrative resources (users, roles, etc.).
+ //
+ // Note: this scope should not be used for new MFA capabilities that have
Comment thread
Joerger marked this conversation as resolved.
Outdated
+ // more precise scope. Instead, new scopes should be added. This scope may
+ // also be split into multiple smaller scopes in the future.
+ SCOPE_ADMIN_ACTION = 7;
+}

// authservice.proto

message CreateAuthenticateChallengeRequest {
oneof Request {
UserCredentials UserCredentials = 1 [(gogoproto.jsontag) = "user_credentials,omitempty"];
string RecoveryStartTokenID = 2 [(gogoproto.jsontag) = "recovery_start_token_id,omitempty"];
ContextUser ContextUser = 3 [(gogoproto.jsontag) = "context_user,omitempty"];
Passwordless Passwordless = 4 [(gogoproto.jsontag) = "passwordless,omitempty"];
}
IsMFARequiredRequest MFARequiredCheck = 5 [(gogoproto.jsontag) = "mfa_required_check,omitempty"];

+ // Scope is a authorization scope for this MFA challenge.
+ // Required. Only applies to webauthn challenges.
+ webauthn.ChallengeScope Scope = 6 [(gogoproto.jsontag) = "scope,omitempty"];
+ // AllowReuse means webauthn credentials resolved from this challenge can be
+ // reused for a short span of time before the challenge expires.
+ //
+ // Reuse is only permitted for specific actions by the discretion of the server.
+ // See the server implementation for details.
+ bool AllowReuse = 7 [(gogoproto.jsontag) = "allow_reuse,omitempty"];
}
```

### Client changes

Clients will be expected to provide a `Scope` when requesting an MFA challenge
through `rpc CreateAuthenticateChallenge`. For specific login and device
management endpoints, the scope will be automatically set on the server side.

Clients can optionally provide `AllowReuse=true` if the client wants to reuse
the resulting webauthn credentials for multiple requests. However, the server
will only permit reuse for a select number of actions. See
[the server implementation details](#reuse).

### Server changes

#### Scope

When verifying a webauthn credential against the user's stored webauthn
challenge, the Auth Server will check that the stored scope matches the scope
that the Auth server is verifying for. For example, if the Auth server is
verifying a webauthn credential for scope "login", but webauthn challenge
stored for the user has scope "headless", the verification will fail.

#### Reuse

If an MFA action does not allow reuse, the Auth Server will validate that the
webauthn challenge has `AllowReuse=false`.

Additionally, challenges with `AllowReuse=true` will not be deleted immediately,
instead letting them expire in the backend after 5 minutes.

Initially, reuse will only be permitted for the following admin action RPCs:
Comment thread
Joerger marked this conversation as resolved.
Comment thread
Joerger marked this conversation as resolved.

- `rpc CreateUser`
- `rpc UpdateUser`
- `rpc UpsertUser`
- `rpc CreateRole`
- `rpc UpdateRole`
- `rpc UpsertRoleV2`
- `rpc UpsertRole`
- `rpc CreateUserGroup`
- `rpc UpdateUserGroup`
- `rpc CreateResetPasswordToken`
- `rpc SetClusterNetworkingConfig`
- `rpc SetSessionRecordingConfig`
- `rpc SetAuthPreference`
- `rpc SetNetworkRestrictions`
- `rpc CreateOIDCConnector`
- `rpc UpdateOIDCConnector`
- `rpc UpsertOIDCConnector`
Comment thread
Joerger marked this conversation as resolved.
- `rpc UpsertSAMLConnector`
Comment thread
Joerger marked this conversation as resolved.
- `rpc CreateSAMLConnector`
- `rpc UpdateSAMLConnector`
- `rpc UpsertSAMLConnector`
- `rpc CreateGithubConnector`
- `rpc UpdateGithubConnector`
- `rpc UpsertGithubConnector`
Comment thread
Joerger marked this conversation as resolved.
- `rpc CreateSAMLIdPServiceProvider`
- `rpc UpdateSAMLIdPServiceProvider`
- `rpc UpsertAccessList`
- `rpc UpsertAccessListWithMembers`
- `rpc UpsertTokenV2`
- `rpc CreateTokenV2`
- `rpc GenerateToken`
- `rpc CreateBot`
- `rpc UpdateBot`
- `rpc UpsertBot`
- `rpc DeleteBot`

These RPCs are currently used for "bulk" requests such as:

- `tctl create -f multiple-resources.yaml` which can perform several
updates and creates at once.
- `tctl users add` which creates a user and a reset password token for the user.

##### When to extend reuse

The list above should be kept to a minimum, but can be extended on a case by
case basis.

In many cases, it would be best to create new endpoints which contain the full
string of actions when feasible. For example, we could replace our current add
user flow, which calls `CreateUser` and `CreatePasswordToken`, to call a single
`AddUser` endpoint which does both operations.

Other times, it may be overly cumbersome or complicated to move an operation
into a single endpoint. For example, it does not currently seem worth it to
create a single `UpsertResources` endpoint to handle all cases of
`tctl create -f multiple-resources.yaml`. Instead we call individual CRUD
endpoints for each resource contained in the file, reusing the same Webauthn
challenge along the way.

Warning: Reuse should not be extended to sensitive operations, including all
of the non admin action scopes laid out above and the following admin action
endpoints:

- Account recovery management
- `rpc ChangeUserAuthentication`
- `rpc StartAccountRecovery`
- `rpc VerifyAccountRecovery`
- `rpc CompleteAccountRecovery`
- `rpc CreateAccountRecoveryCodes`
- Dynamic access
- `rpc CreateAccessRequest`
- `rpc SetAccessRequestState`
- `rpc SubmitAccessReview`
- `rpc AccessRequestPromote`
- `rpc CreateAccessListReview`
- CA management
- `http rotateCertAuthority`
- `http rotateExternalCertAuthority`
- `rpc UpsertCertAuthority`
- `rpc DeleteCertAuthority`
- `rpc DeleteCertAuthority`
- Certificate generation
- `rpc GenerateHostCert`
- `rpc GenerateHostCerts`
- `rpc GenerateUserCerts`
- `http createWebSession`
- `http deleteWebSession`

#### Expiration
Comment thread
Joerger marked this conversation as resolved.

Webauthn challenges are always set to expire after 5 minutes. However, as we've
seen with Dynamo DB, these expirations are not always strictly respected.
Therefore, the Auth server will start checking the expiration of stored
webauthn challenges. If the challenge is past it's expiration, the Auth server
will delete it from the backend explicitly.
Comment thread
rosstimothy marked this conversation as resolved.

Note: This change should be backported since this is an existing issue for
unconsumed webauthn challenges.

### UX

There should be no changes to UX, other than reducing the number of MFA taps
required for certain admin actions from several down to one.

### Backward Compatibility

In order to maintain backwards compatibility with old clients, scope will only
be enforced opportunistically when provided by the client until the next major
version after this feature is released.

However, any new features tied to scope, such as webauthn credential reuse,
will only be permitted to clients that do provide scope.

### Audit Events

Currently, MFA audit details are quite limited. We only emit the MFA device used
for some MFA actions, such as login. We will add 2 new audit events for better
coverage.

```proto
// events.proto
message CreateMFAAuthChallenge {
Metadata Metadata = 1;
UserMetadata User = 2;
webauthn.Scope Scope = 3;
bool AllowReuse = 4;
}

message ValidateMFAAuthResponse {
Metadata Metadata = 1;
UserMetadata User = 2;
MFADeviceMetadata Device = 3;
webauthn.Scope Scope = 4;
bool AllowReuse = 5;
}
```

### Product Usage

```proto
// MFAVerificationEvent is emitted when an MFA verification event is completed.
message MFAVerificationEvent {
// anonymized
string user_name = 1;
// the mfa device type used for verification. e.g. Webauthn, U2F, or TOTP.
Comment thread
Joerger marked this conversation as resolved.
// Matches api/types.MFADevice.MFAType.
string mfa_device_type = 2;
string scope = 3;
}
```

## Additional

### TOTP

This RFD does not cover adding scope for TOTP. However, if necessary, we could
extend this RFD to TOTP by creating some TOTP session data in the backend to
match the webauthn session data flow. This TOTP session would just hold the
scope and whether reuse is allowed. This TOTP session would replace the
existing "Used TOTP token" we store in the backend.