go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/encryptedcookies/internal/session.go (about)

     1  // Copyright 2021 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package internal
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"fmt"
    21  	"net/http"
    22  	"strings"
    23  	"time"
    24  
    25  	"github.com/google/tink/go/aead"
    26  	"github.com/google/tink/go/insecurecleartextkeyset"
    27  	"github.com/google/tink/go/keyset"
    28  	"github.com/google/tink/go/tink"
    29  
    30  	"go.chromium.org/luci/common/data/rand/mathrand"
    31  	"go.chromium.org/luci/common/errors"
    32  
    33  	"go.chromium.org/luci/server/encryptedcookies/internal/encryptedcookiespb"
    34  	"go.chromium.org/luci/server/encryptedcookies/session"
    35  	"go.chromium.org/luci/server/encryptedcookies/session/sessionpb"
    36  )
    37  
    38  const (
    39  	// SessionCookieName is the name of the session cookie.
    40  	SessionCookieName = "LUCISID"
    41  
    42  	// UnlimitedCookiePath is a path to set the cookie on by default.
    43  	UnlimitedCookiePath = "/"
    44  
    45  	// LimitedCookiePath is a path to set the cookie on when limiting exposure.
    46  	LimitedCookiePath = "/auth/openid/"
    47  
    48  	// rawCookiePrefix is prepended to the encrypted cookie value to give us
    49  	// an ability to identify it in logs (if it leaks) and to version its
    50  	// encryption/encoding scheme format.
    51  	rawCookiePrefix = "lcsd_"
    52  
    53  	// sessionCookieMaxAge is max-age of the session cookie.
    54  	//
    55  	// We do not expire *cookies* themselves. Session still can expire if the
    56  	// refresh tokens backing them expire or get revoked. A session cookie
    57  	// pointing to an expired session is ignored and opportunistically gets
    58  	// removed.
    59  	sessionCookieMaxAge = 60 * 60 * 24 * 365 * 20 // 20 years ~= infinity
    60  
    61  	// refreshMaxTTL defines what sessions are considered "definitely fresh".
    62  	//
    63  	// If a session's TTL (time till next NextRefresh) is longer than this, we
    64  	// won't try to refresh it. Passing this threshold enables the probabilistic
    65  	// early expiration check. See ShouldRefreshSession().
    66  	//
    67  	// We assume the typical session TTL period to be 1h.
    68  	refreshMaxTTL = 20 * time.Minute
    69  
    70  	// refreshMinTTL defines what sessions needs to be refreshed ASAP.
    71  	//
    72  	// If a session's TTL (time till next NextRefresh) is smaller than this, we
    73  	// *must* refresh it.
    74  	//
    75  	// E.g. if the session *really* expires in 1h, we must be refreshing it after
    76  	// >50 min TTL mark. This is needed to make sure short-lived tokens are usable
    77  	// within the request handler (as long as its runtime is under 10 min). We
    78  	// don't refresh tokens mid-way through the handler.
    79  	//
    80  	// The session is also probabilistically refreshed sooner if its TTL is less
    81  	// than refreshMaxTTL. This avoids a potential stampede from parallel URL
    82  	// Fetch browser calls when the actual deadline comes.
    83  	refreshMinTTL = 10 * time.Minute
    84  
    85  	// refreshExpMean is a parameter of the exponential distribution for
    86  	// the probabilistic early expiration check.
    87  	refreshExpMean = time.Minute
    88  )
    89  
    90  // NewSessionCookie generates a new session cookie (in a clear text form).
    91  //
    92  // Generates the per-session encryption key and puts it into the produced
    93  // cookie. Returns the AEAD primitive that can be used to encrypt things using
    94  // the new per-session key.
    95  func NewSessionCookie(id session.ID) (*encryptedcookiespb.SessionCookie, tink.AEAD) {
    96  	kh, err := keyset.NewHandle(aead.AES256GCMKeyTemplate())
    97  	if err != nil {
    98  		panic(fmt.Sprintf("could not generate session encryption key: %s", err))
    99  	}
   100  
   101  	// We'll encrypt the entire SessionCookie proto, so it is OK to use clear text
   102  	// key there.
   103  	buf := &bytes.Buffer{}
   104  	if err := insecurecleartextkeyset.Write(kh, keyset.NewBinaryWriter(buf)); err != nil {
   105  		panic(fmt.Sprintf("could not encrypt the session encryption key: %s", err))
   106  	}
   107  
   108  	// We just generated an AEAD keyset, it must be compatible with AEAD algo.
   109  	a, err := aead.New(kh)
   110  	if err != nil {
   111  		panic(fmt.Sprintf("the keyset unexpectedly doesn't have AEAD primitive: %s", err))
   112  	}
   113  
   114  	return &encryptedcookiespb.SessionCookie{
   115  		SessionId: id,
   116  		Keyset:    buf.Bytes(),
   117  	}, a
   118  }
   119  
   120  // EncryptSessionCookie produces the session cookie with prepopulated fields.
   121  //
   122  // The caller still needs to fill in at least `Path` field.
   123  func EncryptSessionCookie(aead tink.AEAD, pb *encryptedcookiespb.SessionCookie) (*http.Cookie, error) {
   124  	enc, err := encryptB64(aead, pb, aeadContextSessionCookie)
   125  	if err != nil {
   126  		return nil, err
   127  	}
   128  	return &http.Cookie{
   129  		Name:     SessionCookieName,
   130  		Value:    rawCookiePrefix + enc,
   131  		HttpOnly: true, // no access from Javascript
   132  		MaxAge:   sessionCookieMaxAge,
   133  	}, nil
   134  }
   135  
   136  // DecryptSessionCookie decrypts the encrypted session cookie.
   137  func DecryptSessionCookie(aead tink.AEAD, c *http.Cookie) (*encryptedcookiespb.SessionCookie, error) {
   138  	if !strings.HasPrefix(c.Value, rawCookiePrefix) {
   139  		return nil, errors.Reason("the value doesn't start with prefix %q", rawCookiePrefix).Err()
   140  	}
   141  	enc := strings.TrimPrefix(c.Value, rawCookiePrefix)
   142  	cookie := &encryptedcookiespb.SessionCookie{}
   143  	if err := decryptB64(aead, enc, cookie, aeadContextSessionCookie); err != nil {
   144  		return nil, err
   145  	}
   146  	return cookie, nil
   147  }
   148  
   149  // UnsealPrivate decrypts the private part of the session using the key from
   150  // the cookie.
   151  //
   152  // Returns the instantiated per-session AEAD primitive.
   153  func UnsealPrivate(c *encryptedcookiespb.SessionCookie, s *sessionpb.Session) (*sessionpb.Private, tink.AEAD, error) {
   154  	kh, err := insecurecleartextkeyset.Read(keyset.NewBinaryReader(bytes.NewReader(c.Keyset)))
   155  	if err != nil {
   156  		return nil, nil, errors.Annotate(err, "failed to deserialize per-session keyset").Err()
   157  	}
   158  	ae, err := aead.New(kh)
   159  	if err != nil {
   160  		return nil, nil, errors.Annotate(err, "failed to instantiate per-session AEAD").Err()
   161  	}
   162  	private, err := DecryptPrivate(ae, s.EncryptedPrivate)
   163  	if err != nil {
   164  		return nil, nil, errors.Annotate(err, "failed to decrypt the private part of the session").Err()
   165  	}
   166  	return private, ae, nil
   167  }
   168  
   169  // ShouldRefreshSession returns true if we should refresh the session now.
   170  //
   171  // The decision in based on `ttl`, which is a duration till the hard session
   172  // staleness deadline. We attempt to refresh the session sooner.
   173  func ShouldRefreshSession(ctx context.Context, ttl time.Duration) bool {
   174  	switch {
   175  	case ttl > refreshMaxTTL:
   176  		return false // too soon to refresh
   177  	case ttl < refreshMinTTL:
   178  		return true // definitely need to refresh
   179  	default:
   180  		threshold := time.Duration(mathrand.ExpFloat64(ctx) * float64(refreshExpMean))
   181  		return ttl-refreshMinTTL < threshold
   182  	}
   183  }