diff --git a/lib/auth/auth_login_test.go b/lib/auth/auth_login_test.go index 6355cbc8555d3..dc41aba905e67 100644 --- a/lib/auth/auth_login_test.go +++ b/lib/auth/auth_login_test.go @@ -440,7 +440,8 @@ func TestServer_AuthenticateUser_mfaDevices(t *testing.T) { // Solve challenge (client-side) resp, err := test.solveChallenge(challenge) authReq := AuthenticateUserRequest{ - Username: username, + Username: username, + PublicKey: []byte(sshPubKey), } require.NoError(t, err) @@ -463,7 +464,6 @@ func TestServer_AuthenticateUser_mfaDevices(t *testing.T) { t.Run(test.name+"/ssh", makeRun(func(s *Server, req AuthenticateUserRequest) error { _, err := s.AuthenticateSSHUser(ctx, AuthenticateSSHRequest{ AuthenticateUserRequest: req, - PublicKey: []byte(sshPubKey), TTL: 24 * time.Hour, }) return err @@ -560,10 +560,10 @@ func TestServer_Authenticate_passwordless(t *testing.T) { authenticate: func(t *testing.T, resp *wanlib.CredentialAssertionResponse) { loginResp, err := proxyClient.AuthenticateSSHUser(ctx, AuthenticateSSHRequest{ AuthenticateUserRequest: AuthenticateUserRequest{ - Webauthn: resp, + Webauthn: resp, + PublicKey: []byte(sshPubKey), }, - PublicKey: []byte(sshPubKey), - TTL: 24 * time.Hour, + TTL: 24 * time.Hour, }) require.NoError(t, err, "Failed to perform passwordless authentication") require.NotNil(t, loginResp, "SSH response nil") @@ -587,11 +587,11 @@ func TestServer_Authenticate_passwordless(t *testing.T) { // Fail a login attempt so have a non-empty list of attempts. _, err := proxyClient.AuthenticateSSHUser(ctx, AuthenticateSSHRequest{ AuthenticateUserRequest: AuthenticateUserRequest{ - Username: user, - Webauthn: &wanlib.CredentialAssertionResponse{}, // bad response + Username: user, + Webauthn: &wanlib.CredentialAssertionResponse{}, // bad response + PublicKey: []byte(sshPubKey), }, - PublicKey: []byte(sshPubKey), - TTL: 24 * time.Hour, + TTL: 24 * time.Hour, }) require.True(t, trace.IsAccessDenied(err), "got err = %v, want AccessDenied") attempts, err := authServer.GetUserLoginAttempts(user) @@ -667,7 +667,9 @@ func TestServer_Authenticate_nonPasswordlessRequiresUsername(t *testing.T) { mfaResp, err := test.dev.SolveAuthn(mfaChallenge) require.NoError(t, err) - var req AuthenticateUserRequest + req := AuthenticateUserRequest{ + PublicKey: []byte(sshPubKey), + } switch { case mfaResp.GetWebauthn() != nil: req.Webauthn = wanlib.CredentialAssertionResponseFromProto(mfaResp.GetWebauthn()) @@ -681,7 +683,6 @@ func TestServer_Authenticate_nonPasswordlessRequiresUsername(t *testing.T) { // SSH. _, err = proxyClient.AuthenticateSSHUser(ctx, AuthenticateSSHRequest{ AuthenticateUserRequest: req, - PublicKey: []byte(sshPubKey), TTL: 24 * time.Hour, }) require.Error(t, err, "SSH authentication expected fail (missing username)") diff --git a/lib/auth/auth_test.go b/lib/auth/auth_test.go index a5aa9bd35b926..c759dfc8dee51 100644 --- a/lib/auth/auth_test.go +++ b/lib/auth/auth_test.go @@ -286,10 +286,10 @@ func TestAuthenticateSSHUser(t *testing.T) { // Login to the root cluster. resp, err := s.a.AuthenticateSSHUser(ctx, AuthenticateSSHRequest{ AuthenticateUserRequest: AuthenticateUserRequest{ - Username: user, - Pass: &PassCreds{Password: pass}, + Username: user, + Pass: &PassCreds{Password: pass}, + PublicKey: pub, }, - PublicKey: pub, TTL: time.Hour, RouteToCluster: s.clusterName.GetClusterName(), }) @@ -325,10 +325,10 @@ func TestAuthenticateSSHUser(t *testing.T) { // Login to the leaf cluster. resp, err = s.a.AuthenticateSSHUser(ctx, AuthenticateSSHRequest{ AuthenticateUserRequest: AuthenticateUserRequest{ - Username: user, - Pass: &PassCreds{Password: pass}, + Username: user, + Pass: &PassCreds{Password: pass}, + PublicKey: pub, }, - PublicKey: pub, TTL: time.Hour, RouteToCluster: "leaf.localhost", KubernetesCluster: "leaf-kube-cluster", @@ -373,10 +373,10 @@ func TestAuthenticateSSHUser(t *testing.T) { // Login specifying a valid kube cluster. It should appear in the TLS cert. resp, err = s.a.AuthenticateSSHUser(ctx, AuthenticateSSHRequest{ AuthenticateUserRequest: AuthenticateUserRequest{ - Username: user, - Pass: &PassCreds{Password: pass}, + Username: user, + Pass: &PassCreds{Password: pass}, + PublicKey: pub, }, - PublicKey: pub, TTL: time.Hour, RouteToCluster: s.clusterName.GetClusterName(), KubernetesCluster: "root-kube-cluster", @@ -405,10 +405,10 @@ func TestAuthenticateSSHUser(t *testing.T) { // automatically. resp, err = s.a.AuthenticateSSHUser(ctx, AuthenticateSSHRequest{ AuthenticateUserRequest: AuthenticateUserRequest{ - Username: user, - Pass: &PassCreds{Password: pass}, + Username: user, + Pass: &PassCreds{Password: pass}, + PublicKey: pub, }, - PublicKey: pub, TTL: time.Hour, RouteToCluster: s.clusterName.GetClusterName(), // Intentionally empty, auth server should default to a registered @@ -450,10 +450,10 @@ func TestAuthenticateSSHUser(t *testing.T) { // Login specifying a valid kube cluster. It should appear in the TLS cert. resp, err = s.a.AuthenticateSSHUser(ctx, AuthenticateSSHRequest{ AuthenticateUserRequest: AuthenticateUserRequest{ - Username: user, - Pass: &PassCreds{Password: pass}, + Username: user, + Pass: &PassCreds{Password: pass}, + PublicKey: pub, }, - PublicKey: pub, TTL: time.Hour, RouteToCluster: s.clusterName.GetClusterName(), KubernetesCluster: "root-kube-cluster", @@ -482,10 +482,10 @@ func TestAuthenticateSSHUser(t *testing.T) { // automatically. resp, err = s.a.AuthenticateSSHUser(ctx, AuthenticateSSHRequest{ AuthenticateUserRequest: AuthenticateUserRequest{ - Username: user, - Pass: &PassCreds{Password: pass}, + Username: user, + Pass: &PassCreds{Password: pass}, + PublicKey: pub, }, - PublicKey: pub, TTL: time.Hour, RouteToCluster: s.clusterName.GetClusterName(), // Intentionally empty, auth server should default to a registered @@ -515,10 +515,10 @@ func TestAuthenticateSSHUser(t *testing.T) { // Login specifying an invalid kube cluster. This should fail. _, err = s.a.AuthenticateSSHUser(ctx, AuthenticateSSHRequest{ AuthenticateUserRequest: AuthenticateUserRequest{ - Username: user, - Pass: &PassCreds{Password: pass}, + Username: user, + Pass: &PassCreds{Password: pass}, + PublicKey: pub, }, - PublicKey: pub, TTL: time.Hour, RouteToCluster: s.clusterName.GetClusterName(), KubernetesCluster: "invalid-kube-cluster", @@ -1155,9 +1155,9 @@ func TestServer_AugmentContextUserCertificates(t *testing.T) { Pass: &PassCreds{ Password: []byte(pass), }, + PublicKey: pub, }, - PublicKey: pub, - TTL: 1 * time.Hour, + TTL: 1 * time.Hour, }) require.NoError(t, err, "AuthenticateSSHUser failed") @@ -1312,9 +1312,9 @@ func TestServer_AugmentContextUserCertificates_errors(t *testing.T) { Pass: &PassCreds{ Password: []byte(pass), }, + PublicKey: ssh.MarshalAuthorizedKey(sPubKey), }, - PublicKey: ssh.MarshalAuthorizedKey(sPubKey), - TTL: 1 * time.Hour, + TTL: 1 * time.Hour, }) require.NoError(t, err, "AuthenticateSSHUser(%q) failed", user) @@ -1779,10 +1779,10 @@ func TestGenerateUserCertIPPinning(t *testing.T) { baseAuthRequest := AuthenticateSSHRequest{ AuthenticateUserRequest: AuthenticateUserRequest{ - Pass: &PassCreds{Password: pass}, + Pass: &PassCreds{Password: pass}, + PublicKey: pub, }, TTL: time.Hour, - PublicKey: pub, RouteToCluster: s.clusterName.GetClusterName(), } diff --git a/lib/auth/methods.go b/lib/auth/methods.go index b505309f9b647..9cc024ca0ee79 100644 --- a/lib/auth/methods.go +++ b/lib/auth/methods.go @@ -50,6 +50,8 @@ const ( type AuthenticateUserRequest struct { // Username is a username Username string `json:"username"` + // PublicKey is a public key in ssh authorized_keys format + PublicKey []byte `json:"public_key"` // Pass is a password used in local authentication schemes Pass *PassCreds `json:"pass,omitempty"` // Webauthn is a signed credential assertion, used in MFA authentication @@ -60,6 +62,8 @@ type AuthenticateUserRequest struct { Session *SessionCreds `json:"session,omitempty"` // ClientMetadata includes forwarded information about a client ClientMetadata *ForwardedClientMetadata `json:"client_metadata,omitempty"` + // HeadlessAuthenticationID is the ID for a headless authentication resource. + HeadlessAuthenticationID string `json:"headless_authentication_id"` } // ForwardedClientMetadata can be used by the proxy web API to forward information about @@ -103,10 +107,10 @@ type SessionCreds struct { // AuthenticateUser authenticates user based on the request type. // Returns the username of the authenticated user. -func (s *Server) AuthenticateUser(req AuthenticateUserRequest) (string, error) { +func (s *Server) AuthenticateUser(ctx context.Context, req AuthenticateUserRequest) (string, error) { user := req.Username - mfaDev, actualUser, err := s.authenticateUser(context.TODO(), req) + mfaDev, actualUser, err := s.authenticateUser(ctx, req) // err is handled below. switch { case user != "" && actualUser != "" && user != actualUser: @@ -339,7 +343,7 @@ func (s *Server) AuthenticateWebUser(ctx context.Context, req AuthenticateUserRe return session, nil } - actualUser, err := s.AuthenticateUser(req) + actualUser, err := s.AuthenticateUser(ctx, req) if err != nil { return nil, trace.Wrap(err) } @@ -362,8 +366,6 @@ func (s *Server) AuthenticateWebUser(ctx context.Context, req AuthenticateUserRe type AuthenticateSSHRequest struct { // AuthenticateUserRequest is a request with credentials AuthenticateUserRequest - // PublicKey is a public key in ssh authorized_keys format - PublicKey []byte `json:"public_key"` // TTL is a requested TTL for certificates to be issues TTL time.Duration `json:"ttl"` // CompatibilityMode sets certificate compatibility mode with old SSH clients @@ -465,7 +467,7 @@ func (s *Server) AuthenticateSSHUser(ctx context.Context, req AuthenticateSSHReq return nil, trace.Wrap(err) } - actualUser, err := s.AuthenticateUser(req.AuthenticateUserRequest) + actualUser, err := s.AuthenticateUser(ctx, req.AuthenticateUserRequest) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/auth/methods_test.go b/lib/auth/methods_test.go index ef2924d965cb6..0b691a04e13ba 100644 --- a/lib/auth/methods_test.go +++ b/lib/auth/methods_test.go @@ -17,6 +17,7 @@ limitations under the License. package auth import ( + "context" "strings" "testing" @@ -27,6 +28,7 @@ import ( ) func TestServerAuthenticateUserUserAgentTrim(t *testing.T) { + ctx := context.Background() emitter := &eventstest.MockEmitter{} r := AuthenticateUserRequest{ ClientMetadata: &ForwardedClientMetadata{ @@ -34,7 +36,7 @@ func TestServerAuthenticateUserUserAgentTrim(t *testing.T) { }, } // Ignoring the error here because we really just care that the event was logged. - (&Server{emitter: emitter}).AuthenticateUser(r) + (&Server{emitter: emitter}).AuthenticateUser(ctx, r) event := emitter.LastEvent() loginEvent, ok := event.(*apievents.UserLogin) require.True(t, ok) diff --git a/lib/auth/tls_test.go b/lib/auth/tls_test.go index 691b7f6c26552..36c1384a96f84 100644 --- a/lib/auth/tls_test.go +++ b/lib/auth/tls_test.go @@ -2399,10 +2399,10 @@ func TestCertificateFormat(t *testing.T) { Pass: &PassCreds{ Password: pass, }, + PublicKey: pub, }, CompatibilityMode: ts.inClientCertificateFormat, TTL: apidefaults.CertDuration, - PublicKey: pub, }) require.NoError(t, err) @@ -2685,8 +2685,8 @@ func TestLoginNoLocalAuth(t *testing.T) { Pass: &PassCreds{ Password: pass, }, + PublicKey: pub, }, - PublicKey: pub, }) require.True(t, trace.IsAccessDenied(err)) } diff --git a/lib/client/weblogin.go b/lib/client/weblogin.go index 370674ce17260..6916e42a22ba3 100644 --- a/lib/client/weblogin.go +++ b/lib/client/weblogin.go @@ -139,6 +139,8 @@ type CreateSSHCertReq struct { Password string `json:"password"` // OTPToken is second factor token OTPToken string `json:"otp_token"` + // HeadlessAuthenticationID is a headless authentication resource id. + HeadlessAuthenticationID string `json:"headless_id"` // PubKey is a public key user wishes to sign PubKey []byte `json:"pub_key"` // TTL is a desired TTL for the cert (max is still capped by server, @@ -281,6 +283,16 @@ type SSHLoginPasswordless struct { CustomPrompt wancli.LoginPrompt } +type SSHLoginHeadless struct { + SSHLogin + + // User is the login username. + User string + + // HeadlessAuthenticationID is a headless authentication request ID. + HeadlessAuthenticationID string +} + // initClient creates a new client to the HTTPS web proxy. func initClient(proxyAddr string, insecure bool, pool *x509.CertPool, extraHeaders map[string]string) (*WebClient, *url.URL, error) { log := logrus.WithFields(logrus.Fields{ @@ -416,6 +428,39 @@ func SSHAgentLogin(ctx context.Context, login SSHLoginDirect) (*auth.SSHLoginRes return out, nil } +// SSHAgentHeadlessLogin begins the headless login ceremony, returning new user certificates if successful. +func SSHAgentHeadlessLogin(ctx context.Context, login SSHLoginHeadless) (*auth.SSHLoginResponse, error) { + clt, _, err := initClient(login.ProxyAddr, login.Insecure, login.Pool, login.ExtraHeaders) + if err != nil { + return nil, trace.Wrap(err) + } + + // This request will block until the headless login is approved. + clt.Client.HTTPClient().Timeout = defaults.CallbackTimeout + + re, err := clt.PostJSON(ctx, clt.Endpoint("webapi", "ssh", "certs"), CreateSSHCertReq{ + User: login.User, + HeadlessAuthenticationID: login.HeadlessAuthenticationID, + PubKey: login.PubKey, + TTL: login.TTL, + Compatibility: login.Compatibility, + RouteToCluster: login.RouteToCluster, + KubernetesCluster: login.KubernetesCluster, + AttestationStatement: login.AttestationStatement, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + var out *auth.SSHLoginResponse + err = json.Unmarshal(re.Bytes(), &out) + if err != nil { + return nil, trace.Wrap(err) + } + + return out, nil +} + // SSHAgentPasswordlessLogin requests a passwordless MFA challenge via the proxy. // weblogin.CustomPrompt (or a default prompt) is used for interaction with the // end user. diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index f97222c4c274d..0089f64de3a06 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -581,7 +581,7 @@ func (h *Handler) bindDefaultEndpoints() { h.POST("/webapi/users/privilege/token", h.WithAuth(h.createPrivilegeTokenHandle)) // Issues SSH temp certificates based on 2FA access creds - h.POST("/webapi/ssh/certs", httplib.MakeHandler(h.createSSHCert)) + h.POST("/webapi/ssh/certs", h.WithLimiter(h.createSSHCert)) // list available sites h.GET("/webapi/sites", h.WithAuth(h.getClusters)) @@ -3111,28 +3111,47 @@ func (h *Handler) createSSHCert(w http.ResponseWriter, r *http.Request, p httpro } authClient := h.cfg.ProxyClient + cap, err := authClient.GetAuthPreference(r.Context()) if err != nil { return nil, trace.Wrap(err) } - clientMeta := clientMetaFromReq(r) - - var cert *auth.SSHLoginResponse + authReq := auth.AuthenticateUserRequest{ + Username: req.User, + PublicKey: req.PubKey, + ClientMetadata: clientMetaFromReq(r), + } switch cap.GetSecondFactor() { case constants.SecondFactorOff: - cert, err = h.auth.GetCertificateWithoutOTP(r.Context(), req, clientMeta) + authReq.Pass = &auth.PassCreds{ + Password: []byte(req.Password), + } case constants.SecondFactorOTP, constants.SecondFactorOn, constants.SecondFactorOptional: - cert, err = h.auth.GetCertificateWithOTP(r.Context(), req, clientMeta) + authReq.OTP = &auth.OTPCreds{ + Password: []byte(req.Password), + Token: req.OTPToken, + } + case constants.SecondFactorWebauthn: + // WebAuthn only supports this endpoint for headless login. + authReq.HeadlessAuthenticationID = req.HeadlessAuthenticationID default: - return nil, trace.AccessDenied("unknown second factor type: %q", cap.GetSecondFactor()) + return nil, trace.AccessDenied("unsupported second factor type: %q", cap.GetSecondFactor()) } + + loginResp, err := authClient.AuthenticateSSHUser(r.Context(), auth.AuthenticateSSHRequest{ + AuthenticateUserRequest: authReq, + CompatibilityMode: req.Compatibility, + TTL: req.TTL, + RouteToCluster: req.RouteToCluster, + KubernetesCluster: req.KubernetesCluster, + AttestationStatement: req.AttestationStatement, + }) if err != nil { return nil, trace.Wrap(err) } - - return cert, nil + return loginResp, nil } // validateTrustedCluster validates the token for a trusted cluster and returns it's own host and user certificate authority. diff --git a/lib/web/sessions.go b/lib/web/sessions.go index 7221ae9950462..3c147bcae714c 100644 --- a/lib/web/sessions.go +++ b/lib/web/sessions.go @@ -754,56 +754,13 @@ func (s *sessionCache) AuthenticateWebUser( return s.proxyClient.AuthenticateWebUser(ctx, authReq) } -// GetCertificateWithoutOTP returns a new user certificate for the specified request. -func (s *sessionCache) GetCertificateWithoutOTP( - ctx context.Context, c client.CreateSSHCertReq, clientMeta *auth.ForwardedClientMetadata, -) (*auth.SSHLoginResponse, error) { - return s.proxyClient.AuthenticateSSHUser(ctx, auth.AuthenticateSSHRequest{ - AuthenticateUserRequest: auth.AuthenticateUserRequest{ - Username: c.User, - Pass: &auth.PassCreds{ - Password: []byte(c.Password), - }, - ClientMetadata: clientMeta, - }, - PublicKey: c.PubKey, - CompatibilityMode: c.Compatibility, - TTL: c.TTL, - RouteToCluster: c.RouteToCluster, - KubernetesCluster: c.KubernetesCluster, - AttestationStatement: c.AttestationStatement, - }) -} - -// GetCertificateWithOTP returns a new user certificate for the specified request. -// The request is used with the given OTP token. -func (s *sessionCache) GetCertificateWithOTP( - ctx context.Context, c client.CreateSSHCertReq, clientMeta *auth.ForwardedClientMetadata, -) (*auth.SSHLoginResponse, error) { - return s.proxyClient.AuthenticateSSHUser(ctx, auth.AuthenticateSSHRequest{ - AuthenticateUserRequest: auth.AuthenticateUserRequest{ - Username: c.User, - OTP: &auth.OTPCreds{ - Password: []byte(c.Password), - Token: c.OTPToken, - }, - ClientMetadata: clientMeta, - }, - PublicKey: c.PubKey, - CompatibilityMode: c.Compatibility, - TTL: c.TTL, - RouteToCluster: c.RouteToCluster, - KubernetesCluster: c.KubernetesCluster, - AttestationStatement: c.AttestationStatement, - }) -} - func (s *sessionCache) AuthenticateSSHUser( ctx context.Context, c client.AuthenticateSSHUserRequest, clientMeta *auth.ForwardedClientMetadata, ) (*auth.SSHLoginResponse, error) { authReq := auth.AuthenticateUserRequest{ Username: c.User, ClientMetadata: clientMeta, + PublicKey: c.PubKey, } if c.Password != "" { authReq.Pass = &auth.PassCreds{Password: []byte(c.Password)} @@ -819,7 +776,6 @@ func (s *sessionCache) AuthenticateSSHUser( } return s.proxyClient.AuthenticateSSHUser(ctx, auth.AuthenticateSSHRequest{ AuthenticateUserRequest: authReq, - PublicKey: c.PubKey, CompatibilityMode: c.Compatibility, TTL: c.TTL, RouteToCluster: c.RouteToCluster, diff --git a/rfd/0105-headless-authentication.md b/rfd/0105-headless-authentication.md index c012cbc3b419d..380dc8bfbf29d 100644 --- a/rfd/0105-headless-authentication.md +++ b/rfd/0105-headless-authentication.md @@ -258,36 +258,49 @@ enum HeadlessAuthenticationState { ### Server changes -#### `POST /webapi/login/headless` +#### `POST /webapi/certs/ssh` -This endpoint is used to initiate headless login. Like other login endpoints, this endpoint is not authenticated and can be called by anyone with access to the Teleport Proxy address. +This is an existing endpoint used by a user to login with `tsh` or Teleport Connect. We will add the `HeadlessAuthenticationID` field to switch to headless authentication instead of password/otp. -```go -type HeadlessAuthenticationRequest struct { - SSHLogin - // User is a teleport username. - User string `json:"user"` -} +Like other login endpoints, this endpoint is not authenticated and can be called by anyone with access to the Teleport Proxy address. -// SSHLogin contains common SSH login parameters. -type SSHLogin struct { - // ProxyAddr is the target proxy address - ProxyAddr string - // PubKey is SSH public key to sign - PubKey []byte - ... +```go +type CreateSSHCertReq struct { + // User is a teleport username + User string `json:"user"` + // Password is user's pass + Password string `json:"password"` + // OTPToken is second factor token + OTPToken string `json:"otp_token"` + // HeadlessAuthenticationID is a headless authentication resource id. + HeadlessAuthenticationID string `json:"headless_id"` + // PubKey is a public key user wishes to sign + PubKey []byte `json:"pub_key"` + // TTL is a desired TTL for the cert (max is still capped by server, + // however user can shorten the time) + TTL time.Duration `json:"ttl"` + // Compatibility specifies OpenSSH compatibility flags. + Compatibility string `json:"compatibility,omitempty"` + // RouteToCluster is an optional cluster name to route the response + // credentials to. + RouteToCluster string + // KubernetesCluster is an optional k8s cluster name to route the response + // credentials to. + KubernetesCluster string + // AttestationStatement is an attestation statement associated with the given public key. + AttestationStatement *keys.AttestationStatement `json:"attestation_statement,omitempty"` } // SSHLoginResponse is a user login response type SSHLoginResponse struct { - // Username contains the username for the login certificates - Username string `json:"username"` - // Cert is a PEM encoded SSH certificate signed by SSH certificate authority - Cert []byte `json:"cert"` - // TLSCertPEM is a PEM encoded TLS certificate signed by TLS certificate authority - TLSCert []byte `json:"tls_cert"` - // HostSigners is a list of signing host public keys trusted by proxy - HostSigners []TrustedCerts `json:"host_signers"` + // Username contains the username for the login certificates + Username string `json:"username"` + // Cert is a PEM encoded SSH certificate signed by SSH certificate authority + Cert []byte `json:"cert"` + // TLSCertPEM is a PEM encoded TLS certificate signed by TLS certificate authority + TLSCert []byte `json:"tls_cert"` + // HostSigners is a list of signing host public keys trusted by proxy + HostSigners []TrustedCerts `json:"host_signers"` } ```