go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/appengine/gaemiddleware/aead.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 gaemiddleware
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"fmt"
    21  	"os"
    22  	"path/filepath"
    23  	"strings"
    24  	"sync"
    25  	"time"
    26  
    27  	secretmanager "cloud.google.com/go/secretmanager/apiv1"
    28  	"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
    29  	"google.golang.org/api/option"
    30  	"google.golang.org/appengine"
    31  
    32  	"github.com/google/tink/go/aead"
    33  	"github.com/google/tink/go/insecurecleartextkeyset"
    34  	"github.com/google/tink/go/keyset"
    35  	"github.com/google/tink/go/tink"
    36  
    37  	"go.chromium.org/luci/common/clock"
    38  	"go.chromium.org/luci/common/errors"
    39  	"go.chromium.org/luci/common/logging"
    40  	"go.chromium.org/luci/server/auth"
    41  	"go.chromium.org/luci/server/caching"
    42  )
    43  
    44  var (
    45  	cachedAEAD          = caching.RegisterCacheSlot()
    46  	settingsCheckPeriod = time.Minute
    47  	rotationCheckPeriod = time.Hour
    48  )
    49  
    50  // AEADProvider loads the primary encryption key from Google Secret Manager.
    51  //
    52  // If it is not configured and we are running on a dev server, generates a new
    53  // phony one. If it is not configured and we are running in production, returns
    54  // nil to indicate AEAD is not available.
    55  //
    56  // If the key is configured, but can't be loaded, returns a tink.AEAD
    57  // implementation that returns errors in all its methods.
    58  func AEADProvider(ctx context.Context) tink.AEAD {
    59  	s := fetchCachedSettings(ctx)
    60  	if s.EncryptionKey == "" {
    61  		if appengine.IsDevAppServer() {
    62  			return useDevServerKey(ctx)
    63  		}
    64  		return nil
    65  	}
    66  	state, err := cachedAEAD.Fetch(ctx, func(prev any) (updated any, exp time.Duration, err error) {
    67  		state, _ := prev.(*aeadCachedState)
    68  		if state == nil || state.keyPath != s.EncryptionKey {
    69  			state = &aeadCachedState{keyPath: s.EncryptionKey}
    70  		}
    71  		err = state.refresh(ctx)
    72  		return state, settingsCheckPeriod, err
    73  	})
    74  	if err != nil {
    75  		return brokenAEAD{err}
    76  	}
    77  	return state.(*aeadCachedState).aead
    78  }
    79  
    80  type brokenAEAD struct {
    81  	err error
    82  }
    83  
    84  func (b brokenAEAD) Encrypt(_, _ []byte) ([]byte, error) { return nil, b.err }
    85  func (b brokenAEAD) Decrypt(_, _ []byte) ([]byte, error) { return nil, b.err }
    86  
    87  ////////////////////////////////////////////////////////////////////////////////
    88  // Prod caching helpers.
    89  
    90  type aeadCachedState struct {
    91  	keyPath       string
    92  	rotationCheck time.Time
    93  	aead          tink.AEAD
    94  }
    95  
    96  func (s *aeadCachedState) refresh(ctx context.Context) error {
    97  	if s.aead != nil && clock.Now(ctx).Before(s.rotationCheck) {
    98  		return nil // have the key and it is fresh enough
    99  	}
   100  
   101  	// Fetch the secret blob from the Secret Manager.
   102  	chunks := strings.Split(strings.TrimPrefix(s.keyPath, "sm://"), "/")
   103  	if len(chunks) != 2 {
   104  		logging.Errorf(ctx, "Bad encryption key URI %q", s.keyPath)
   105  		return errors.Reason("bad secret URI %q", s.keyPath).Err()
   106  	}
   107  	blob, err := fetchSecret(ctx, chunks[0], chunks[1])
   108  	if err != nil {
   109  		logging.Errorf(ctx, "Failed to load Google Secret Manager secret %s: %s", s.keyPath, err)
   110  		return err
   111  	}
   112  
   113  	// Construct tink.AEAD out of it.
   114  	kh, err := insecurecleartextkeyset.Read(keyset.NewJSONReader(bytes.NewReader(blob)))
   115  	if err != nil {
   116  		logging.Errorf(ctx, "Secret %q doesn't contain a valid Tink keyset: %s", s.keyPath, err)
   117  		return err
   118  	}
   119  	a, err := aead.New(kh)
   120  	if err != nil {
   121  		logging.Errorf(ctx, "Secret %q doesn't contain an AEAD Tink key: %s", s.keyPath, err)
   122  		return err
   123  	}
   124  
   125  	// Record when we should check it again in case it is rotated.
   126  	s.aead = a
   127  	s.rotationCheck = clock.Now(ctx).Add(rotationCheckPeriod)
   128  	return nil
   129  }
   130  
   131  func fetchSecret(ctx context.Context, project, secret string) ([]byte, error) {
   132  	ts, err := auth.GetTokenSource(ctx, auth.AsSelf, auth.WithScopes(auth.CloudOAuthScopes...))
   133  	if err != nil {
   134  		return nil, errors.Annotate(err, "failed to get OAuth2 token source").Err()
   135  	}
   136  
   137  	client, err := secretmanager.NewClient(ctx, option.WithTokenSource(ts))
   138  	if err != nil {
   139  		return nil, errors.Annotate(err, "failed to setup Secret Manager client").Err()
   140  	}
   141  	defer client.Close()
   142  
   143  	latest, err := client.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{
   144  		Name: fmt.Sprintf("projects/%s/secrets/%s/versions/latest", project, secret),
   145  	})
   146  	if err != nil {
   147  		return nil, err
   148  	}
   149  	logging.Infof(ctx, "Loaded secret %q", latest.Name)
   150  	return latest.Payload.Data, nil
   151  }
   152  
   153  ////////////////////////////////////////////////////////////////////////////////
   154  // Dev server helpers.
   155  
   156  var devServerLock sync.Mutex
   157  
   158  func useDevServerKey(ctx context.Context) tink.AEAD {
   159  	devServerLock.Lock()
   160  	defer devServerLock.Unlock()
   161  
   162  	path := filepath.Join(os.TempDir(), "luci-insecure-dev-tink-aead-key.json")
   163  
   164  	// Try to load an existing key.
   165  	switch key, err := loadDevServerKey(path); {
   166  	case err == nil:
   167  		return key
   168  	case !os.IsNotExist(err):
   169  		logging.Warningf(ctx, "Ignoring bad dev server Tink key %s: %s", path, err)
   170  	}
   171  
   172  	// Generate the new key.
   173  	kh, err := keyset.NewHandle(aead.AES256GCMKeyTemplate())
   174  	if err != nil {
   175  		panic(err) // e.g. no entropy
   176  	}
   177  	out, err := aead.New(kh)
   178  	if err != nil {
   179  		panic(err) // not really possible
   180  	}
   181  	buf := &bytes.Buffer{}
   182  	if err = insecurecleartextkeyset.Write(kh, keyset.NewJSONWriter(buf)); err != nil {
   183  		panic(err) // not really possible
   184  	}
   185  
   186  	// Store it so encrypted blobs survive the dev server restart.
   187  	logging.Infof(ctx, "Generated new dev server Tink key at %s", path)
   188  	if err := os.WriteFile(path, buf.Bytes(), 0600); err != nil {
   189  		logging.Warningf(ctx, "Failed to store dev server Tink key %s: %s", path, err)
   190  	}
   191  
   192  	return out
   193  }
   194  
   195  func loadDevServerKey(path string) (tink.AEAD, error) {
   196  	blob, err := os.ReadFile(path)
   197  	if err != nil {
   198  		return nil, err
   199  	}
   200  	kh, err := insecurecleartextkeyset.Read(keyset.NewJSONReader(bytes.NewReader(blob)))
   201  	if err != nil {
   202  		return nil, err
   203  	}
   204  	return aead.New(kh)
   205  }