feat: enable key rotation (#15066)

This PR contains the remaining logic necessary to hook up key rotation
to the product.
This commit is contained in:
Jon Ayers
2024-10-25 17:14:35 +01:00
committed by GitHub
parent ccfffc6911
commit cd890aa3a0
54 changed files with 1412 additions and 1129 deletions

View File

@ -3,6 +3,7 @@ package apptest
import (
"bufio"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
@ -408,6 +409,67 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
assertWorkspaceLastUsedAtNotUpdated(t, appDetails)
})
t.Run("BadJWT", func(t *testing.T) {
t.Parallel()
appDetails := setupProxyTest(t, nil)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
u := appDetails.PathAppURL(appDetails.Apps.Owner)
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, proxyTestAppBody, string(body))
require.Equal(t, http.StatusOK, resp.StatusCode)
appTokenCookie := findCookie(resp.Cookies(), codersdk.SignedAppTokenCookie)
require.NotNil(t, appTokenCookie, "no signed app token cookie in response")
require.Equal(t, appTokenCookie.Path, u.Path, "incorrect path on app token cookie")
object, err := jose.ParseSigned(appTokenCookie.Value)
require.NoError(t, err)
require.Len(t, object.Signatures, 1)
// Parse the payload.
var tok workspaceapps.SignedToken
//nolint:gosec
err = json.Unmarshal(object.UnsafePayloadWithoutVerification(), &tok)
require.NoError(t, err)
appTokenClient := appDetails.AppClient(t)
apiKey := appTokenClient.SessionToken()
appTokenClient.SetSessionToken("")
appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil)
require.NoError(t, err)
// Sign the token with an old-style key.
appTokenCookie.Value = generateBadJWT(t, tok)
appTokenClient.HTTPClient.Jar.SetCookies(u,
[]*http.Cookie{
appTokenCookie,
{
Name: codersdk.PathAppSessionTokenCookie,
Value: apiKey,
},
},
)
resp, err = requestWithRetries(ctx, t, appTokenClient, http.MethodGet, u.String(), nil)
require.NoError(t, err)
defer resp.Body.Close()
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, proxyTestAppBody, string(body))
require.Equal(t, http.StatusOK, resp.StatusCode)
assertWorkspaceLastUsedAtUpdated(t, appDetails)
// Since the old token is invalid, the signed app token cookie should have a new value.
newTokenCookie := findCookie(resp.Cookies(), codersdk.SignedAppTokenCookie)
require.NotEqual(t, appTokenCookie.Value, newTokenCookie.Value)
})
})
t.Run("WorkspaceApplicationAuth", func(t *testing.T) {
@ -463,7 +525,7 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
appClient.SetSessionToken("")
// Try to load the application without authentication.
u := c.appURL
u := *c.appURL
u.Path = path.Join(u.Path, "/test")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
require.NoError(t, err)
@ -500,7 +562,7 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
// Copy the query parameters and then check equality.
u.RawQuery = gotLocation.RawQuery
require.Equal(t, u, gotLocation)
require.Equal(t, u, *gotLocation)
// Verify the API key is set.
encryptedAPIKey := gotLocation.Query().Get(workspaceapps.SubdomainProxyAPIKeyParam)
@ -580,6 +642,38 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("BadJWE", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
currentKeyStr := appDetails.SDKClient.SessionToken()
appClient := appDetails.AppClient(t)
appClient.SetSessionToken("")
u := *c.appURL
u.Path = path.Join(u.Path, "/test")
badToken := generateBadJWE(t, workspaceapps.EncryptedAPIKeyPayload{
APIKey: currentKeyStr,
})
u.RawQuery = (url.Values{
workspaceapps.SubdomainProxyAPIKeyParam: {badToken},
}).Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
require.NoError(t, err)
var resp *http.Response
resp, err = doWithRetries(t, appClient, req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(body), "Could not decrypt API key. Please remove the query parameter and try again.")
})
}
})
})
@ -1077,6 +1171,68 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
assertWorkspaceLastUsedAtNotUpdated(t, appDetails)
})
})
t.Run("BadJWT", func(t *testing.T) {
t.Parallel()
appDetails := setupProxyTest(t, nil)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
u := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, proxyTestAppBody, string(body))
require.Equal(t, http.StatusOK, resp.StatusCode)
appTokenCookie := findCookie(resp.Cookies(), codersdk.SignedAppTokenCookie)
require.NotNil(t, appTokenCookie, "no signed token cookie in response")
require.Equal(t, appTokenCookie.Path, "/", "incorrect path on signed token cookie")
object, err := jose.ParseSigned(appTokenCookie.Value)
require.NoError(t, err)
require.Len(t, object.Signatures, 1)
// Parse the payload.
var tok workspaceapps.SignedToken
//nolint:gosec
err = json.Unmarshal(object.UnsafePayloadWithoutVerification(), &tok)
require.NoError(t, err)
appTokenClient := appDetails.AppClient(t)
apiKey := appTokenClient.SessionToken()
appTokenClient.SetSessionToken("")
appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil)
require.NoError(t, err)
// Sign the token with an old-style key.
appTokenCookie.Value = generateBadJWT(t, tok)
appTokenClient.HTTPClient.Jar.SetCookies(u,
[]*http.Cookie{
appTokenCookie,
{
Name: codersdk.SubdomainAppSessionTokenCookie,
Value: apiKey,
},
},
)
// We should still be able to successfully proxy.
resp, err = requestWithRetries(ctx, t, appTokenClient, http.MethodGet, u.String(), nil)
require.NoError(t, err)
defer resp.Body.Close()
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, proxyTestAppBody, string(body))
require.Equal(t, http.StatusOK, resp.StatusCode)
assertWorkspaceLastUsedAtUpdated(t, appDetails)
// Since the old token is invalid, the signed app token cookie should have a new value.
newTokenCookie := findCookie(resp.Cookies(), codersdk.SignedAppTokenCookie)
require.NotEqual(t, appTokenCookie.Value, newTokenCookie.Value)
})
})
t.Run("PortSharing", func(t *testing.T) {
@ -1789,3 +1945,57 @@ func assertWorkspaceLastUsedAtNotUpdated(t testing.TB, details *Details) {
require.NoError(t, err)
require.Equal(t, before.LastUsedAt, after.LastUsedAt, "workspace LastUsedAt updated when it should not have been")
}
func generateBadJWE(t *testing.T, claims interface{}) string {
t.Helper()
var buf [32]byte
_, err := rand.Read(buf[:])
require.NoError(t, err)
encrypt, err := jose.NewEncrypter(
jose.A256GCM,
jose.Recipient{
Algorithm: jose.A256GCMKW,
Key: buf[:],
}, &jose.EncrypterOptions{
Compression: jose.DEFLATE,
},
)
require.NoError(t, err)
payload, err := json.Marshal(claims)
require.NoError(t, err)
signed, err := encrypt.Encrypt(payload)
require.NoError(t, err)
compact, err := signed.CompactSerialize()
require.NoError(t, err)
return compact
}
// generateBadJWT generates a JWT with a random key. It's intended to emulate the old-style JWT's we generated.
func generateBadJWT(t *testing.T, claims interface{}) string {
t.Helper()
var buf [64]byte
_, err := rand.Read(buf[:])
require.NoError(t, err)
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: jose.HS512,
Key: buf[:],
}, nil)
require.NoError(t, err)
payload, err := json.Marshal(claims)
require.NoError(t, err)
signed, err := signer.Sign(payload)
require.NoError(t, err)
compact, err := signed.CompactSerialize()
require.NoError(t, err)
return compact
}
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
for _, cookie := range cookies {
if cookie.Name == name {
return cookie
}
}
return nil
}

View File

@ -13,11 +13,15 @@ import (
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
"github.com/go-jose/go-jose/v4/jwt"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/cryptokeys"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
@ -35,12 +39,20 @@ type DBTokenProvider struct {
DeploymentValues *codersdk.DeploymentValues
OAuth2Configs *httpmw.OAuth2Configs
WorkspaceAgentInactiveTimeout time.Duration
SigningKey SecurityKey
Keycache cryptokeys.SigningKeycache
}
var _ SignedTokenProvider = &DBTokenProvider{}
func NewDBTokenProvider(log slog.Logger, accessURL *url.URL, authz rbac.Authorizer, db database.Store, cfg *codersdk.DeploymentValues, oauth2Cfgs *httpmw.OAuth2Configs, workspaceAgentInactiveTimeout time.Duration, signingKey SecurityKey) SignedTokenProvider {
func NewDBTokenProvider(log slog.Logger,
accessURL *url.URL,
authz rbac.Authorizer,
db database.Store,
cfg *codersdk.DeploymentValues,
oauth2Cfgs *httpmw.OAuth2Configs,
workspaceAgentInactiveTimeout time.Duration,
signer cryptokeys.SigningKeycache,
) SignedTokenProvider {
if workspaceAgentInactiveTimeout == 0 {
workspaceAgentInactiveTimeout = 1 * time.Minute
}
@ -53,12 +65,12 @@ func NewDBTokenProvider(log slog.Logger, accessURL *url.URL, authz rbac.Authoriz
DeploymentValues: cfg,
OAuth2Configs: oauth2Cfgs,
WorkspaceAgentInactiveTimeout: workspaceAgentInactiveTimeout,
SigningKey: signingKey,
Keycache: signer,
}
}
func (p *DBTokenProvider) FromRequest(r *http.Request) (*SignedToken, bool) {
return FromRequest(r, p.SigningKey)
return FromRequest(r, p.Keycache)
}
func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *http.Request, issueReq IssueTokenRequest) (*SignedToken, string, bool) {
@ -70,7 +82,7 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *
dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx)
appReq := issueReq.AppRequest.Normalize()
err := appReq.Validate()
err := appReq.Check()
if err != nil {
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "invalid app request")
return nil, "", false
@ -210,9 +222,11 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *
return nil, "", false
}
token.RegisteredClaims = jwtutils.RegisteredClaims{
Expiry: jwt.NewNumericDate(time.Now().Add(DefaultTokenExpiry)),
}
// Sign the token.
token.Expiry = time.Now().Add(DefaultTokenExpiry)
tokenStr, err := p.SigningKey.SignToken(token)
tokenStr, err := jwtutils.Sign(ctx, p.Keycache, token)
if err != nil {
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "generate token")
return nil, "", false

View File

@ -13,6 +13,7 @@ import (
"testing"
"time"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -20,6 +21,7 @@ import (
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/coderd/workspaceapps"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk"
@ -94,8 +96,7 @@ func Test_ResolveRequest(t *testing.T) {
_ = closer.Close()
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
t.Cleanup(cancel)
ctx := testutil.Context(t, testutil.WaitMedium)
firstUser := coderdtest.CreateFirstUser(t, client)
me, err := client.User(ctx, codersdk.Me)
@ -276,15 +277,17 @@ func Test_ResolveRequest(t *testing.T) {
_ = w.Body.Close()
require.Equal(t, &workspaceapps.SignedToken{
RegisteredClaims: jwtutils.RegisteredClaims{
Expiry: jwt.NewNumericDate(token.Expiry.Time()),
},
Request: req,
Expiry: token.Expiry, // ignored to avoid flakiness
UserID: me.ID,
WorkspaceID: workspace.ID,
AgentID: agentID,
AppURL: appURL,
}, token)
require.NotZero(t, token.Expiry)
require.WithinDuration(t, time.Now().Add(workspaceapps.DefaultTokenExpiry), token.Expiry, time.Minute)
require.WithinDuration(t, time.Now().Add(workspaceapps.DefaultTokenExpiry), token.Expiry.Time(), time.Minute)
// Check that the token was set in the response and is valid.
require.Len(t, w.Cookies(), 1)
@ -292,10 +295,11 @@ func Test_ResolveRequest(t *testing.T) {
require.Equal(t, codersdk.SignedAppTokenCookie, cookie.Name)
require.Equal(t, req.BasePath, cookie.Path)
parsedToken, err := api.AppSecurityKey.VerifySignedToken(cookie.Value)
var parsedToken workspaceapps.SignedToken
err := jwtutils.Verify(ctx, api.AppSigningKeyCache, cookie.Value, &parsedToken)
require.NoError(t, err)
// normalize expiry
require.WithinDuration(t, token.Expiry, parsedToken.Expiry, 2*time.Second)
require.WithinDuration(t, token.Expiry.Time(), parsedToken.Expiry.Time(), 2*time.Second)
parsedToken.Expiry = token.Expiry
require.Equal(t, token, &parsedToken)
@ -314,7 +318,7 @@ func Test_ResolveRequest(t *testing.T) {
})
require.True(t, ok)
// normalize expiry
require.WithinDuration(t, token.Expiry, secondToken.Expiry, 2*time.Second)
require.WithinDuration(t, token.Expiry.Time(), secondToken.Expiry.Time(), 2*time.Second)
secondToken.Expiry = token.Expiry
require.Equal(t, token, secondToken)
}
@ -540,13 +544,16 @@ func Test_ResolveRequest(t *testing.T) {
// App name differs
AppSlugOrPort: appNamePublic,
}).Normalize(),
Expiry: time.Now().Add(time.Minute),
RegisteredClaims: jwtutils.RegisteredClaims{
Expiry: jwt.NewNumericDate(time.Now().Add(time.Minute)),
},
UserID: me.ID,
WorkspaceID: workspace.ID,
AgentID: agentID,
AppURL: appURL,
}
badTokenStr, err := api.AppSecurityKey.SignToken(badToken)
badTokenStr, err := jwtutils.Sign(ctx, api.AppSigningKeyCache, badToken)
require.NoError(t, err)
req := (workspaceapps.Request{
@ -589,7 +596,8 @@ func Test_ResolveRequest(t *testing.T) {
require.Len(t, cookies, 1)
require.Equal(t, cookies[0].Name, codersdk.SignedAppTokenCookie)
require.NotEqual(t, cookies[0].Value, badTokenStr)
parsedToken, err := api.AppSecurityKey.VerifySignedToken(cookies[0].Value)
var parsedToken workspaceapps.SignedToken
err = jwtutils.Verify(ctx, api.AppSigningKeyCache, cookies[0].Value, &parsedToken)
require.NoError(t, err)
require.Equal(t, appNameOwner, parsedToken.AppSlugOrPort)
})

View File

@ -38,7 +38,7 @@ type ResolveRequestOptions struct {
func ResolveRequest(rw http.ResponseWriter, r *http.Request, opts ResolveRequestOptions) (*SignedToken, bool) {
appReq := opts.AppRequest.Normalize()
err := appReq.Validate()
err := appReq.Check()
if err != nil {
// This is a 500 since it's a coder server or proxy that's making this
// request struct based on details from the request. The values should
@ -79,7 +79,7 @@ func ResolveRequest(rw http.ResponseWriter, r *http.Request, opts ResolveRequest
Name: codersdk.SignedAppTokenCookie,
Value: tokenStr,
Path: appReq.BasePath,
Expires: token.Expiry,
Expires: token.Expiry.Time(),
})
return token, true

View File

@ -11,17 +11,21 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/google/uuid"
"go.opentelemetry.io/otel/trace"
"nhooyr.io/websocket"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/coderd/cryptokeys"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
@ -97,8 +101,8 @@ type Server struct {
HostnameRegex *regexp.Regexp
RealIPConfig *httpmw.RealIPConfig
SignedTokenProvider SignedTokenProvider
AppSecurityKey SecurityKey
SignedTokenProvider SignedTokenProvider
APIKeyEncryptionKeycache cryptokeys.EncryptionKeycache
// DisablePathApps disables path-based apps. This is a security feature as path
// based apps share the same cookie as the dashboard, and are susceptible to XSS
@ -176,7 +180,10 @@ func (s *Server) handleAPIKeySmuggling(rw http.ResponseWriter, r *http.Request,
}
// Exchange the encoded API key for a real one.
token, err := s.AppSecurityKey.DecryptAPIKey(encryptedAPIKey)
var payload EncryptedAPIKeyPayload
err := jwtutils.Decrypt(ctx, s.APIKeyEncryptionKeycache, encryptedAPIKey, &payload, jwtutils.WithDecryptExpected(jwt.Expected{
Time: time.Now(),
}))
if err != nil {
s.Logger.Debug(ctx, "could not decrypt smuggled workspace app API key", slog.Error(err))
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
@ -225,7 +232,7 @@ func (s *Server) handleAPIKeySmuggling(rw http.ResponseWriter, r *http.Request,
// server using the wrong value.
http.SetCookie(rw, &http.Cookie{
Name: AppConnectSessionTokenCookieName(accessMethod),
Value: token,
Value: payload.APIKey,
Domain: domain,
Path: "/",
MaxAge: 0,

View File

@ -124,9 +124,9 @@ func (r Request) Normalize() Request {
return req
}
// Validate ensures the request is correct and contains the necessary
// Check ensures the request is correct and contains the necessary
// parameters.
func (r Request) Validate() error {
func (r Request) Check() error {
switch r.AccessMethod {
case AccessMethodPath, AccessMethodSubdomain, AccessMethodTerminal:
default:

View File

@ -279,7 +279,7 @@ func Test_RequestValidate(t *testing.T) {
if !c.noNormalize {
req = c.req.Normalize()
}
err := req.Validate()
err := req.Check()
if c.errContains == "" {
require.NoError(t, err)
} else {

View File

@ -1,35 +1,27 @@
package workspaceapps
import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-jose/go-jose/v3"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/cryptokeys"
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/codersdk"
)
const (
tokenSigningAlgorithm = jose.HS512
apiKeyEncryptionAlgorithm = jose.A256GCMKW
)
// SignedToken is the struct data contained inside a workspace app JWE. It
// contains the details of the workspace app that the token is valid for to
// avoid database queries.
type SignedToken struct {
jwtutils.RegisteredClaims
// Request details.
Request `json:"request"`
// Trusted resolved details.
Expiry time.Time `json:"expiry"` // set by GenerateToken if unset
UserID uuid.UUID `json:"user_id"`
WorkspaceID uuid.UUID `json:"workspace_id"`
AgentID uuid.UUID `json:"agent_id"`
@ -57,191 +49,32 @@ func (t SignedToken) MatchesRequest(req Request) bool {
t.AppSlugOrPort == req.AppSlugOrPort
}
// SecurityKey is used for signing and encrypting app tokens and API keys.
//
// The first 64 bytes of the key are used for signing tokens with HMAC-SHA256,
// and the last 32 bytes are used for encrypting API keys with AES-256-GCM.
// We use a single key for both operations to avoid having to store and manage
// two keys.
type SecurityKey [96]byte
func (k SecurityKey) IsZero() bool {
return k == SecurityKey{}
}
func (k SecurityKey) String() string {
return hex.EncodeToString(k[:])
}
func (k SecurityKey) signingKey() []byte {
return k[:64]
}
func (k SecurityKey) encryptionKey() []byte {
return k[64:]
}
func KeyFromString(str string) (SecurityKey, error) {
var key SecurityKey
decoded, err := hex.DecodeString(str)
if err != nil {
return key, xerrors.Errorf("decode key: %w", err)
}
if len(decoded) != len(key) {
return key, xerrors.Errorf("expected key to be %d bytes, got %d", len(key), len(decoded))
}
copy(key[:], decoded)
return key, nil
}
// SignToken generates a signed workspace app token with the given payload. If
// the payload doesn't have an expiry, it will be set to the current time plus
// the default expiry.
func (k SecurityKey) SignToken(payload SignedToken) (string, error) {
if payload.Expiry.IsZero() {
payload.Expiry = time.Now().Add(DefaultTokenExpiry)
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return "", xerrors.Errorf("marshal payload to JSON: %w", err)
}
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: tokenSigningAlgorithm,
Key: k.signingKey(),
}, nil)
if err != nil {
return "", xerrors.Errorf("create signer: %w", err)
}
signedObject, err := signer.Sign(payloadBytes)
if err != nil {
return "", xerrors.Errorf("sign payload: %w", err)
}
serialized, err := signedObject.CompactSerialize()
if err != nil {
return "", xerrors.Errorf("serialize JWS: %w", err)
}
return serialized, nil
}
// VerifySignedToken parses a signed workspace app token with the given key and
// returns the payload. If the token is invalid or expired, an error is
// returned.
func (k SecurityKey) VerifySignedToken(str string) (SignedToken, error) {
object, err := jose.ParseSigned(str)
if err != nil {
return SignedToken{}, xerrors.Errorf("parse JWS: %w", err)
}
if len(object.Signatures) != 1 {
return SignedToken{}, xerrors.New("expected 1 signature")
}
if object.Signatures[0].Header.Algorithm != string(tokenSigningAlgorithm) {
return SignedToken{}, xerrors.Errorf("expected token signing algorithm to be %q, got %q", tokenSigningAlgorithm, object.Signatures[0].Header.Algorithm)
}
output, err := object.Verify(k.signingKey())
if err != nil {
return SignedToken{}, xerrors.Errorf("verify JWS: %w", err)
}
var tok SignedToken
err = json.Unmarshal(output, &tok)
if err != nil {
return SignedToken{}, xerrors.Errorf("unmarshal payload: %w", err)
}
if tok.Expiry.Before(time.Now()) {
return SignedToken{}, xerrors.New("signed app token expired")
}
return tok, nil
}
type EncryptedAPIKeyPayload struct {
APIKey string `json:"api_key"`
ExpiresAt time.Time `json:"expires_at"`
jwtutils.RegisteredClaims
APIKey string `json:"api_key"`
}
// EncryptAPIKey encrypts an API key for subdomain token smuggling.
func (k SecurityKey) EncryptAPIKey(payload EncryptedAPIKeyPayload) (string, error) {
if payload.APIKey == "" {
return "", xerrors.New("API key is empty")
}
if payload.ExpiresAt.IsZero() {
// Very short expiry as these keys are only used once as part of an
// automatic redirection flow.
payload.ExpiresAt = dbtime.Now().Add(time.Minute)
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return "", xerrors.Errorf("marshal payload: %w", err)
}
// JWEs seem to apply a nonce themselves.
encrypter, err := jose.NewEncrypter(
jose.A256GCM,
jose.Recipient{
Algorithm: apiKeyEncryptionAlgorithm,
Key: k.encryptionKey(),
},
&jose.EncrypterOptions{
Compression: jose.DEFLATE,
},
)
if err != nil {
return "", xerrors.Errorf("initializer jose encrypter: %w", err)
}
encryptedObject, err := encrypter.Encrypt(payloadBytes)
if err != nil {
return "", xerrors.Errorf("encrypt jwe: %w", err)
}
encrypted := encryptedObject.FullSerialize()
return base64.RawURLEncoding.EncodeToString([]byte(encrypted)), nil
func (e *EncryptedAPIKeyPayload) Fill(now time.Time) {
e.Issuer = "coderd"
e.Audience = jwt.Audience{"wsproxy"}
e.Expiry = jwt.NewNumericDate(now.Add(time.Minute))
e.NotBefore = jwt.NewNumericDate(now.Add(-time.Minute))
}
// DecryptAPIKey undoes EncryptAPIKey and is used in the subdomain app handler.
func (k SecurityKey) DecryptAPIKey(encryptedAPIKey string) (string, error) {
encrypted, err := base64.RawURLEncoding.DecodeString(encryptedAPIKey)
if err != nil {
return "", xerrors.Errorf("base64 decode encrypted API key: %w", err)
func (e EncryptedAPIKeyPayload) Validate(ex jwt.Expected) error {
if e.NotBefore == nil {
return xerrors.Errorf("not before is required")
}
object, err := jose.ParseEncrypted(string(encrypted))
if err != nil {
return "", xerrors.Errorf("parse encrypted API key: %w", err)
}
if object.Header.Algorithm != string(apiKeyEncryptionAlgorithm) {
return "", xerrors.Errorf("expected API key encryption algorithm to be %q, got %q", apiKeyEncryptionAlgorithm, object.Header.Algorithm)
}
ex.Issuer = "coderd"
ex.AnyAudience = jwt.Audience{"wsproxy"}
// Decrypt using the hashed secret.
decrypted, err := object.Decrypt(k.encryptionKey())
if err != nil {
return "", xerrors.Errorf("decrypt API key: %w", err)
}
// Unmarshal the payload.
var payload EncryptedAPIKeyPayload
if err := json.Unmarshal(decrypted, &payload); err != nil {
return "", xerrors.Errorf("unmarshal decrypted payload: %w", err)
}
// Validate expiry.
if payload.ExpiresAt.Before(dbtime.Now()) {
return "", xerrors.New("encrypted API key expired")
}
return payload.APIKey, nil
return e.RegisteredClaims.Validate(ex)
}
// FromRequest returns the signed token from the request, if it exists and is
// valid. The caller must check that the token matches the request.
func FromRequest(r *http.Request, key SecurityKey) (*SignedToken, bool) {
func FromRequest(r *http.Request, mgr cryptokeys.SigningKeycache) (*SignedToken, bool) {
// Get all signed app tokens from the request. This includes the query
// parameter and all matching cookies sent with the request. If there are
// somehow multiple signed app token cookies, we want to try all of them
@ -270,8 +103,12 @@ func FromRequest(r *http.Request, key SecurityKey) (*SignedToken, bool) {
tokens = tokens[:4]
}
ctx := r.Context()
for _, tokenStr := range tokens {
token, err := key.VerifySignedToken(tokenStr)
var token SignedToken
err := jwtutils.Verify(ctx, mgr, tokenStr, &token, jwtutils.WithVerifyExpected(jwt.Expected{
Time: time.Now(),
}))
if err == nil {
req := token.Request.Normalize()
if hasQueryParam && req.AccessMethod != AccessMethodTerminal {
@ -280,7 +117,7 @@ func FromRequest(r *http.Request, key SecurityKey) (*SignedToken, bool) {
return nil, false
}
err := req.Validate()
err := req.Check()
if err == nil {
// The request has a valid signed app token, which is a valid
// token signed by us. The caller must check that it matches

View File

@ -1,22 +1,22 @@
package workspaceapps_test
import (
"fmt"
"crypto/rand"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/coder/coder/v2/codersdk"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/go-jose/go-jose/v3"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/coderd/workspaceapps"
"github.com/coder/coder/v2/cryptorand"
)
func Test_TokenMatchesRequest(t *testing.T) {
@ -283,129 +283,6 @@ func Test_TokenMatchesRequest(t *testing.T) {
}
}
func Test_GenerateToken(t *testing.T) {
t.Parallel()
t.Run("SetExpiry", func(t *testing.T) {
t.Parallel()
tokenStr, err := coderdtest.AppSecurityKey.SignToken(workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
Expiry: time.Time{},
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
AgentID: uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
AppURL: "http://127.0.0.1:8080",
})
require.NoError(t, err)
token, err := coderdtest.AppSecurityKey.VerifySignedToken(tokenStr)
require.NoError(t, err)
require.WithinDuration(t, time.Now().Add(time.Minute), token.Expiry, 15*time.Second)
})
future := time.Now().Add(time.Hour)
cases := []struct {
name string
token workspaceapps.SignedToken
parseErrContains string
}{
{
name: "OK1",
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
Expiry: future,
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
AgentID: uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
AppURL: "http://127.0.0.1:8080",
},
},
{
name: "OK2",
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
UsernameOrID: "oof",
WorkspaceNameOrID: "rab",
AgentNameOrID: "zab",
AppSlugOrPort: "xuq",
},
Expiry: future,
UserID: uuid.MustParse("6fa684a3-11aa-49fd-8512-ab527bd9b900"),
WorkspaceID: uuid.MustParse("b2d816cc-505c-441d-afdf-dae01781bc0b"),
AgentID: uuid.MustParse("6c4396e1-af88-4a8a-91a3-13ea54fc29fb"),
AppURL: "http://localhost:9090",
},
},
{
name: "Expired",
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
Expiry: time.Now().Add(-time.Hour),
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
AgentID: uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
AppURL: "http://127.0.0.1:8080",
},
parseErrContains: "token expired",
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
str, err := coderdtest.AppSecurityKey.SignToken(c.token)
require.NoError(t, err)
// Tokens aren't deterministic as they have a random nonce, so we
// can't compare them directly.
token, err := coderdtest.AppSecurityKey.VerifySignedToken(str)
if c.parseErrContains != "" {
require.Error(t, err)
require.ErrorContains(t, err, c.parseErrContains)
} else {
require.NoError(t, err)
// normalize the expiry
require.WithinDuration(t, c.token.Expiry, token.Expiry, 10*time.Second)
c.token.Expiry = token.Expiry
require.Equal(t, c.token, token)
}
})
}
}
func Test_FromRequest(t *testing.T) {
t.Parallel()
@ -419,7 +296,13 @@ func Test_FromRequest(t *testing.T) {
Value: "invalid",
})
ctx := testutil.Context(t, testutil.WaitShort)
signer := newSigner(t)
token := workspaceapps.SignedToken{
RegisteredClaims: jwtutils.RegisteredClaims{
Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
@ -429,7 +312,6 @@ func Test_FromRequest(t *testing.T) {
AgentNameOrID: "agent",
AppSlugOrPort: "app",
},
Expiry: time.Now().Add(time.Hour),
UserID: uuid.New(),
WorkspaceID: uuid.New(),
AgentID: uuid.New(),
@ -438,16 +320,15 @@ func Test_FromRequest(t *testing.T) {
// Add an expired cookie
expired := token
expired.Expiry = time.Now().Add(time.Hour * -1)
expiredStr, err := coderdtest.AppSecurityKey.SignToken(token)
expired.RegisteredClaims.Expiry = jwt.NewNumericDate(time.Now().Add(time.Hour * -1))
expiredStr, err := jwtutils.Sign(ctx, signer, expired)
require.NoError(t, err)
r.AddCookie(&http.Cookie{
Name: codersdk.SignedAppTokenCookie,
Value: expiredStr,
})
// Add a valid token
validStr, err := coderdtest.AppSecurityKey.SignToken(token)
validStr, err := jwtutils.Sign(ctx, signer, token)
require.NoError(t, err)
r.AddCookie(&http.Cookie{
@ -455,147 +336,27 @@ func Test_FromRequest(t *testing.T) {
Value: validStr,
})
signed, ok := workspaceapps.FromRequest(r, coderdtest.AppSecurityKey)
signed, ok := workspaceapps.FromRequest(r, signer)
require.True(t, ok, "expected a token to be found")
// Confirm it is the correct token.
require.Equal(t, signed.UserID, token.UserID)
})
}
// The ParseToken fn is tested quite thoroughly in the GenerateToken test as
// well.
func Test_ParseToken(t *testing.T) {
t.Parallel()
func newSigner(t *testing.T) jwtutils.StaticKey {
t.Helper()
t.Run("InvalidJWS", func(t *testing.T) {
t.Parallel()
token, err := coderdtest.AppSecurityKey.VerifySignedToken("invalid")
require.Error(t, err)
require.ErrorContains(t, err, "parse JWS")
require.Equal(t, workspaceapps.SignedToken{}, token)
})
t.Run("VerifySignature", func(t *testing.T) {
t.Parallel()
// Create a valid token using a different key.
var otherKey workspaceapps.SecurityKey
copy(otherKey[:], coderdtest.AppSecurityKey[:])
for i := range otherKey {
otherKey[i] ^= 0xff
}
require.NotEqual(t, coderdtest.AppSecurityKey, otherKey)
tokenStr, err := otherKey.SignToken(workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
Expiry: time.Now().Add(time.Hour),
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
AgentID: uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
AppURL: "http://127.0.0.1:8080",
})
require.NoError(t, err)
// Verify the token is invalid.
token, err := coderdtest.AppSecurityKey.VerifySignedToken(tokenStr)
require.Error(t, err)
require.ErrorContains(t, err, "verify JWS")
require.Equal(t, workspaceapps.SignedToken{}, token)
})
t.Run("InvalidBody", func(t *testing.T) {
t.Parallel()
// Create a signature for an invalid body.
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS512, Key: coderdtest.AppSecurityKey[:64]}, nil)
require.NoError(t, err)
signedObject, err := signer.Sign([]byte("hi"))
require.NoError(t, err)
serialized, err := signedObject.CompactSerialize()
require.NoError(t, err)
token, err := coderdtest.AppSecurityKey.VerifySignedToken(serialized)
require.Error(t, err)
require.ErrorContains(t, err, "unmarshal payload")
require.Equal(t, workspaceapps.SignedToken{}, token)
})
}
func TestAPIKeyEncryption(t *testing.T) {
t.Parallel()
genAPIKey := func(t *testing.T) string {
id, _ := cryptorand.String(10)
secret, _ := cryptorand.String(22)
return fmt.Sprintf("%s-%s", id, secret)
return jwtutils.StaticKey{
ID: "test",
Key: generateSecret(t, 64),
}
t.Run("OK", func(t *testing.T) {
t.Parallel()
key := genAPIKey(t)
encrypted, err := coderdtest.AppSecurityKey.EncryptAPIKey(workspaceapps.EncryptedAPIKeyPayload{
APIKey: key,
})
require.NoError(t, err)
decryptedKey, err := coderdtest.AppSecurityKey.DecryptAPIKey(encrypted)
require.NoError(t, err)
require.Equal(t, key, decryptedKey)
})
t.Run("Verifies", func(t *testing.T) {
t.Parallel()
t.Run("Expiry", func(t *testing.T) {
t.Parallel()
key := genAPIKey(t)
encrypted, err := coderdtest.AppSecurityKey.EncryptAPIKey(workspaceapps.EncryptedAPIKeyPayload{
APIKey: key,
ExpiresAt: dbtime.Now().Add(-1 * time.Hour),
})
require.NoError(t, err)
decryptedKey, err := coderdtest.AppSecurityKey.DecryptAPIKey(encrypted)
require.Error(t, err)
require.ErrorContains(t, err, "expired")
require.Empty(t, decryptedKey)
})
t.Run("EncryptionKey", func(t *testing.T) {
t.Parallel()
// Create a valid token using a different key.
var otherKey workspaceapps.SecurityKey
copy(otherKey[:], coderdtest.AppSecurityKey[:])
for i := range otherKey {
otherKey[i] ^= 0xff
}
require.NotEqual(t, coderdtest.AppSecurityKey, otherKey)
// Encrypt with the other key.
key := genAPIKey(t)
encrypted, err := otherKey.EncryptAPIKey(workspaceapps.EncryptedAPIKeyPayload{
APIKey: key,
})
require.NoError(t, err)
// Decrypt with the original key.
decryptedKey, err := coderdtest.AppSecurityKey.DecryptAPIKey(encrypted)
require.Error(t, err)
require.ErrorContains(t, err, "decrypt API key")
require.Empty(t, decryptedKey)
})
})
}
func generateSecret(t *testing.T, size int) []byte {
t.Helper()
secret := make([]byte, size)
_, err := rand.Read(secret)
require.NoError(t, err)
return secret
}