mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -1,22 +1,22 @@
|
||||
package workspaceapps_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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()
|
||||
func generateSecret(t *testing.T, size int) []byte {
|
||||
t.Helper()
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
secret := make([]byte, size)
|
||||
_, err := rand.Read(secret)
|
||||
require.NoError(t, err)
|
||||
return secret
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user