Skip to content

Commit 22e9872

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 c7544b5 commit 22e9872

File tree

6 files changed

+171
-33
lines changed

6 files changed

+171
-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: 109 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,100 @@ 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+
hashIterations = 250000
93+
94+
// algorithmSha512 indicates the sha512 hashing algorithm.
95+
algorithmSha512 = "sha512"
96+
)
97+
98+
// Set encodes the raw value as a PBKDF2 key.
99+
func (t *Token) Set(value string) error {
100+
salt := make([]byte, saltLength)
101+
102+
if n, err := rand.Read(salt); err != nil {
103+
return err
104+
} else if n != saltLength {
105+
return fmt.Errorf("%w: unable to create salt for token hashing", ErrReadLength)
106+
}
107+
108+
key, err := pbkdf2.Key(sha512.New, value, salt, hashIterations, sha512.Size)
109+
if err != nil {
110+
return err
111+
}
112+
113+
encoding := algorithmSha512 + "$" + base64.RawURLEncoding.EncodeToString(salt) + "$" + strconv.Itoa(hashIterations) + "$" + base64.RawURLEncoding.EncodeToString(key)
114+
115+
*t = Token(encoding)
116+
117+
return nil
118+
}
119+
120+
// Validate checks the provided value matches the hashed value in persistent
121+
// storage.
122+
func (t *Token) Validate(value string) error {
123+
encoding := string(*t)
124+
125+
// TODO: this aids in the transition and can be removed soon after.
126+
// Like 3 months after (by default) given the lifetime of service
127+
// account tokens.
128+
if !strings.Contains(encoding, "$") {
129+
if value != encoding {
130+
return fmt.Errorf("%w: plantext token values do not match", ErrValidation)
131+
}
132+
133+
return nil
134+
}
135+
136+
parts := strings.Split(encoding, "$")
137+
138+
if len(parts) != 4 {
139+
return fmt.Errorf("%w: token encoding malformed", ErrValidation)
140+
}
141+
142+
algorithm := parts[0]
143+
if algorithm != algorithmSha512 {
144+
return fmt.Errorf("%w: unsupported hash algorithm %s", ErrValidation, algorithm)
145+
}
146+
147+
salt, err := base64.RawURLEncoding.DecodeString(parts[1])
148+
if err != nil {
149+
return err
150+
}
151+
152+
iterations, err := strconv.Atoi(parts[2])
153+
if err != nil {
154+
return err
155+
}
156+
157+
hash, err := base64.RawURLEncoding.DecodeString(parts[3])
158+
if err != nil {
159+
return err
160+
}
161+
162+
key, err := pbkdf2.Key(sha512.New, value, salt, iterations, sha512.Size)
163+
if err != nil {
164+
return err
165+
}
166+
167+
if !bytes.Equal(hash, key) {
168+
return fmt.Errorf("%w: token mismatch", ErrValidation)
169+
}
170+
171+
return nil
172+
}
173+
174+
// Clear resets a token.
175+
func (t *Token) Clear() {
176+
*t = Token("")
177+
}

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
@@ -97,7 +97,7 @@ func convert(in *unikornv1.ServiceAccount, groups *unikornv1.GroupList) *openapi
9797

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

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

109-
out.Status.AccessToken = &in.Spec.AccessToken
109+
out.Status.AccessToken = &accessToken
110110

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

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

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

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

263-
return convertCreate(resource, groups), nil
266+
return convertCreate(resource, groups, tokens.AccessToken), nil
264267
}
265268

266269
// Get retrieves information about a service account.
@@ -371,7 +374,10 @@ func (c *Client) Rotate(ctx context.Context, organizationID, serviceAccountID st
371374

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

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

387-
return convertCreate(updated, groups), nil
393+
return convertCreate(updated, groups, tokens.AccessToken), nil
388394
}
389395

390396
// 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)