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 }