Skip to content

Commit 225afe9

Browse files
committed
Add Token Hashing
Reduces the attack surface by obfuscating tokens using SHA512 and PBKDF2, thus they are only ever persisted client side, we continue to maintain a plaintext in-memory cache for performace reasons. Fixes #253
1 parent 35338f2 commit 225afe9

File tree

6 files changed

+173
-33
lines changed

6 files changed

+173
-33
lines changed

charts/identity/crds/identity.unikorn-cloud.org_users.yaml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ spec:
5656
properties:
5757
accessToken:
5858
description: |-
59-
AccessToken s the access token currently issued for the
60-
session.
59+
AccessToken is the access token currently issued for the
60+
session. This field is opaque, and may be hashed for security.
6161
type: string
6262
authorizationCodeID:
6363
description: |-
@@ -74,7 +74,8 @@ spec:
7474
refreshToken:
7575
description: |-
7676
RefreshToken is the single-use refresh token currently
77-
issued for the session.
77+
issued for the session. This field is opaque, and may be hashed
78+
for security.
7879
type: string
7980
required:
8081
- accessToken

pkg/apis/unikorn/v1alpha1/helpers.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,16 @@ limitations under the License.
1717
package v1alpha1
1818

1919
import (
20+
"bytes"
21+
"crypto/pbkdf2"
22+
"crypto/rand"
23+
"crypto/sha512"
24+
"encoding/base64"
2025
"errors"
26+
"fmt"
2127
"slices"
28+
"strconv"
29+
"strings"
2230

2331
unikornv1core "github.com/unikorn-cloud/core/pkg/apis/unikorn/v1alpha1"
2432

@@ -28,6 +36,10 @@ import (
2836

2937
var (
3038
ErrReference = errors.New("resource reference error")
39+
40+
ErrReadLength = errors.New("read length not as expected")
41+
42+
ErrValidation = errors.New("validation error")
3143
)
3244

3345
// Paused implements the ReconcilePauser interface.
@@ -66,3 +78,102 @@ func (u *User) Session(clientID string) (*UserSession, error) {
6678

6779
return &u.Spec.Sessions[index], nil
6880
}
81+
82+
const (
83+
// saltLength is the number of bytes of cryptographically random data
84+
// for salted hashing. 128 bits is recommended by NIST.
85+
// See https://en.wikipedia.org/wiki/PBKDF2 for more details.
86+
saltLength = 16
87+
88+
// hashIterations defines the number of iterations for a salted hash
89+
// function. The bigger the number, the harder to exhaustively search
90+
// but also the longer it takes to compute.
91+
// See https://en.wikipedia.org/wiki/PBKDF2 for more details.
92+
// However... we are just hashing a JWE encoded token, not a password,
93+
// thus it's already a millin times harder to guess than a password!
94+
hashIterations = 1
95+
96+
// algorithmSha512 indicates the sha512 hashing algorithm.
97+
algorithmSha512 = "sha512"
98+
)
99+
100+
// Set encodes the raw value as a PBKDF2 key.
101+
func (t *Token) Set(value string) error {
102+
salt := make([]byte, saltLength)
103+
104+
if n, err := rand.Read(salt); err != nil {
105+
return err
106+
} else if n != saltLength {
107+
return fmt.Errorf("%w: unable to create salt for token hashing", ErrReadLength)
108+
}
109+
110+
key, err := pbkdf2.Key(sha512.New, value, salt, hashIterations, sha512.Size)
111+
if err != nil {
112+
return err
113+
}
114+
115+
encoding := algorithmSha512 + "$" + base64.RawURLEncoding.EncodeToString(salt) + "$" + strconv.Itoa(hashIterations) + "$" + base64.RawURLEncoding.EncodeToString(key)
116+
117+
*t = Token(encoding)
118+
119+
return nil
120+
}
121+
122+
// Validate checks the provided value matches the hashed value in persistent
123+
// storage.
124+
func (t *Token) Validate(value string) error {
125+
encoding := string(*t)
126+
127+
// TODO: this aids in the transition and can be removed soon after.
128+
// Like 3 months after (by default) given the lifetime of service
129+
// account tokens.
130+
if !strings.Contains(encoding, "$") {
131+
if value != encoding {
132+
return fmt.Errorf("%w: plantext token values do not match", ErrValidation)
133+
}
134+
135+
return nil
136+
}
137+
138+
parts := strings.Split(encoding, "$")
139+
140+
if len(parts) != 4 {
141+
return fmt.Errorf("%w: token encoding malformed", ErrValidation)
142+
}
143+
144+
algorithm := parts[0]
145+
if algorithm != algorithmSha512 {
146+
return fmt.Errorf("%w: unsupported hash algorithm %s", ErrValidation, algorithm)
147+
}
148+
149+
salt, err := base64.RawURLEncoding.DecodeString(parts[1])
150+
if err != nil {
151+
return err
152+
}
153+
154+
iterations, err := strconv.Atoi(parts[2])
155+
if err != nil {
156+
return err
157+
}
158+
159+
hash, err := base64.RawURLEncoding.DecodeString(parts[3])
160+
if err != nil {
161+
return err
162+
}
163+
164+
key, err := pbkdf2.Key(sha512.New, value, salt, iterations, sha512.Size)
165+
if err != nil {
166+
return err
167+
}
168+
169+
if !bytes.Equal(hash, key) {
170+
return fmt.Errorf("%w: token mismatch", ErrValidation)
171+
}
172+
173+
return nil
174+
}
175+
176+
// Clear resets a token.
177+
func (t *Token) Clear() {
178+
*t = Token("")
179+
}

pkg/apis/unikorn/v1alpha1/types.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -310,18 +310,22 @@ type UserSignup struct {
310310
ClientID string `json:"clientID"`
311311
}
312312

313+
// Token abstracts token storage in a CRD, with possible obfuscation.
314+
type Token string
315+
313316
type UserSession struct {
314317
// ClientID is the client the session is bound to.
315318
ClientID string `json:"clientID"`
316319
// AuthorizationCodeID is the authorization code ID used to generate
317320
// the tokens.
318321
AuthorizationCodeID string `json:"authorizationCodeID"`
319-
// AccessToken s the access token currently issued for the
320-
// session.
321-
AccessToken string `json:"accessToken"`
322+
// AccessToken is the access token currently issued for the
323+
// session. This field is opaque, and may be hashed for security.
324+
AccessToken Token `json:"accessToken"`
322325
// RefreshToken is the single-use refresh token currently
323-
// issued for the session.
324-
RefreshToken string `json:"refreshToken"`
326+
// issued for the session. This field is opaque, and may be hashed
327+
// for security.
328+
RefreshToken Token `json:"refreshToken"`
325329
// LastAuthentication records when the user last authenticated.
326330
LastAuthentication *metav1.Time `json:"lastAuthentication,omitempty"`
327331
}
@@ -382,7 +386,7 @@ type ServiceAccountSpec struct {
382386
Tags unikornv1core.TagList `json:"tags,omitempty"`
383387
// AccessToken is the encrypted access token that is valid for this
384388
// service acocunt.
385-
AccessToken string `json:"accessToken"`
389+
AccessToken Token `json:"accessToken"`
386390
// Expiry is a hint as to when the issued token will exipre.
387391
// The access token itself is the source of truth, provided the private key is
388392
// still around, so this is a fallback, as well as a cache to improve API read

pkg/handler/serviceaccounts/client.go

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ func convert(in *unikornv1.ServiceAccount, groups *unikornv1.GroupList) *openapi
9696

9797
// convertCreate converts from Kubernetes into OpenAPi for create/update requests that
9898
// have extra information e.g. the access token.
99-
func convertCreate(in *unikornv1.ServiceAccount, groups *unikornv1.GroupList) *openapi.ServiceAccountCreate {
99+
func convertCreate(in *unikornv1.ServiceAccount, groups *unikornv1.GroupList, accessToken string) *openapi.ServiceAccountCreate {
100100
temp := convert(in, groups)
101101

102102
out := &openapi.ServiceAccountCreate{
@@ -105,7 +105,7 @@ func convertCreate(in *unikornv1.ServiceAccount, groups *unikornv1.GroupList) *o
105105
Status: temp.Status,
106106
}
107107

108-
out.Status.AccessToken = &in.Spec.AccessToken
108+
out.Status.AccessToken = &accessToken
109109

110110
return out
111111
}
@@ -163,14 +163,6 @@ func (c *Client) generate(ctx context.Context, organization *organizations.Meta,
163163
},
164164
}
165165

166-
tokens, err := c.generateAccessToken(ctx, organization, out.Name)
167-
if err != nil {
168-
return nil, errors.OAuth2ServerError("unable to issue access token").WithError(err)
169-
}
170-
171-
out.Spec.Expiry = &metav1.Time{Time: tokens.Expiry}
172-
out.Spec.AccessToken = tokens.AccessToken
173-
174166
return out, nil
175167
}
176168

@@ -246,6 +238,17 @@ func (c *Client) Create(ctx context.Context, organizationID string, request *ope
246238
return nil, errors.OAuth2ServerError("failed to generate service account").WithError(err)
247239
}
248240

241+
tokens, err := c.generateAccessToken(ctx, organization, resource.Name)
242+
if err != nil {
243+
return nil, errors.OAuth2ServerError("unable to issue access token").WithError(err)
244+
}
245+
246+
resource.Spec.Expiry = &metav1.Time{Time: tokens.Expiry}
247+
248+
if err := resource.Spec.AccessToken.Set(tokens.AccessToken); err != nil {
249+
return nil, errors.OAuth2ServerError("failed to set access token").WithError(err)
250+
}
251+
249252
if err := c.client.Create(ctx, resource); err != nil {
250253
return nil, errors.OAuth2ServerError("failed to create service account").WithError(err)
251254
}
@@ -259,7 +262,7 @@ func (c *Client) Create(ctx context.Context, organizationID string, request *ope
259262
return nil, err
260263
}
261264

262-
return convertCreate(resource, groups), nil
265+
return convertCreate(resource, groups, tokens.AccessToken), nil
263266
}
264267

265268
// Get retrieves information about a service account.
@@ -370,7 +373,10 @@ func (c *Client) Rotate(ctx context.Context, organizationID, serviceAccountID st
370373

371374
updated := current.DeepCopy()
372375
updated.Spec.Expiry = &metav1.Time{Time: tokens.Expiry}
373-
updated.Spec.AccessToken = tokens.AccessToken
376+
377+
if err := updated.Spec.AccessToken.Set(tokens.AccessToken); err != nil {
378+
return nil, errors.OAuth2ServerError("failed to set access token").WithError(err)
379+
}
374380

375381
if err := c.client.Patch(ctx, updated, client.MergeFrom(current)); err != nil {
376382
return nil, errors.OAuth2ServerError("failed to patch group").WithError(err)
@@ -383,7 +389,7 @@ func (c *Client) Rotate(ctx context.Context, organizationID, serviceAccountID st
383389
return nil, err
384390
}
385391

386-
return convertCreate(updated, groups), nil
392+
return convertCreate(updated, groups, tokens.AccessToken), nil
387393
}
388394

389395
// Delete removes the service account and revokes the access token.

pkg/oauth2/oauth2.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1639,15 +1639,15 @@ func (a *Authenticator) validateRefreshToken(ctx context.Context, r *http.Reques
16391639
return errors.OAuth2InvalidGrant("no active session for user found")
16401640
}
16411641

1642-
if user.Spec.Sessions[index].RefreshToken != refreshToken {
1643-
return errors.OAuth2InvalidGrant("refresh token reuse")
1642+
if err := user.Spec.Sessions[index].RefreshToken.Validate(refreshToken); err != nil {
1643+
return errors.OAuth2InvalidGrant("refresh token invalid or possible reuse").WithError(err)
16441644
}
16451645

16461646
// Things can still go wrong between here and issuing the new token, so invalidate
16471647
// the session now rather than relying on the reissue doing it for us.
16481648
a.InvalidateToken(ctx, user.Spec.Sessions[index].AccessToken)
16491649

1650-
user.Spec.Sessions[index].RefreshToken = ""
1650+
user.Spec.Sessions[index].RefreshToken.Clear()
16511651

16521652
if err := a.client.Update(ctx, user); err != nil {
16531653
return errors.OAuth2ServerError("failed to revoke user session").WithError(err)

pkg/oauth2/tokens.go

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,14 @@ func (a *Authenticator) updateSession(ctx context.Context, user *unikornv1.User,
164164
a.InvalidateToken(ctx, session.AccessToken)
165165
}
166166

167-
session.AccessToken = tokens.AccessToken
167+
if err := session.AccessToken.Set(tokens.AccessToken); err != nil {
168+
return time.Time{}, err
169+
}
168170

169171
if tokens.RefreshToken != nil {
170-
session.RefreshToken = *tokens.RefreshToken
172+
if err := session.RefreshToken.Set(*tokens.RefreshToken); err != nil {
173+
return time.Time{}, err
174+
}
171175
}
172176

173177
if info.Interactive {
@@ -352,8 +356,8 @@ func (a *Authenticator) verifyServiceAccount(ctx context.Context, info *VerifyIn
352356
return err
353357
}
354358

355-
if info.Token != serviceAccount.Spec.AccessToken {
356-
return fmt.Errorf("%w: service account token invalid", ErrTokenVerification)
359+
if err := serviceAccount.Spec.AccessToken.Validate(info.Token); err != nil {
360+
return fmt.Errorf("%w: service account token invalid", err)
357361
}
358362

359363
return nil
@@ -379,15 +383,29 @@ func (a *Authenticator) verifyUserSession(ctx context.Context, info *VerifyInfo,
379383
return fmt.Errorf("%w: no active session for token", ErrTokenVerification)
380384
}
381385

382-
if user.Spec.Sessions[index].AccessToken != info.Token {
383-
return fmt.Errorf("%w: token invalid for active session", ErrTokenVerification)
386+
if err := user.Spec.Sessions[index].AccessToken.Validate(info.Token); err != nil {
387+
return fmt.Errorf("%w: token invalid for active session", err)
384388
}
385389

386390
return nil
387391
}
388392

389393
// InvalidateToken immediately invalidates the token so it's unusable again.
390394
// TODO: this only considers caching in the identity service, it's still usable.
391-
func (a *Authenticator) InvalidateToken(ctx context.Context, token string) {
392-
a.tokenCache.Remove(token)
395+
func (a *Authenticator) InvalidateToken(ctx context.Context, token unikornv1.Token) {
396+
// TODO: This needs a bit of a rethink, we cache the raw access token in memory
397+
// to avoid the decryption penalty, however the token stored in persistent storage
398+
// is hashed, thus we need to do a potentially costly exhaustive search.
399+
for _, key := range a.tokenCache.Keys() {
400+
t, ok := key.(string)
401+
if !ok {
402+
continue
403+
}
404+
405+
if err := token.Validate(t); err != nil {
406+
continue
407+
}
408+
409+
a.tokenCache.Remove(key)
410+
}
393411
}

0 commit comments

Comments
 (0)