package server import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "encoding/hex" "errors" "fmt" "io/fs" "os" "path/filepath" "strings" ) // sessionKeyName is the file holding the key that seals session cookies. It // sits beside the token file and is written just as tightly: anyone who can // read it can mint a session for any token they already know. const sessionKeyName = "session.key" // sessionKeyPerm matches the token file rather than the rest of the data // directory, which is group-writable by design. const sessionKeyPerm fs.FileMode = 0o600 // sealer turns a token into an opaque cookie value and back. // // The point is not to defend the cookie from its own browser - a stolen cookie // is a working session either way, exactly as it was when the token sat there // in the clear. The point is that the token itself no longer appears in it, so // reading the cookie jar over someone's shoulder, or in a screenshot of a // developer console, does not hand over a credential that also works against // the API from anywhere else. type sealer struct { aead cipher.AEAD } // newSealer loads the key at path, creating it on first run. // // A key that is present but unusable is an error rather than a reason to // generate a new one: silently replacing it would log out every session, and // an operator who wants that can delete the file and say so. func newSealer(path string) (*sealer, error) { key, err := readSessionKey(path) if errors.Is(err, os.ErrNotExist) { if key, err = createSessionKey(path); err != nil { return nil, err } } else if err != nil { return nil, err } block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("session key: %w", err) } aead, err := cipher.NewGCM(block) if err != nil { return nil, fmt.Errorf("session key: %w", err) } return &sealer{aead: aead}, nil } func sessionKeyPath(dataDir string) string { return filepath.Join(dataDir, sessionKeyName) } func readSessionKey(path string) ([]byte, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } key, err := hex.DecodeString(strings.TrimSpace(string(raw))) if err != nil { return nil, fmt.Errorf("%s is not a hex key: %w", path, err) } if len(key) != 32 { return nil, fmt.Errorf("%s holds a %d-byte key, want 32", path, len(key)) } return key, nil } // createSessionKey writes a new key, refusing to clobber one that appeared in // the meantime: two servers started at once must not end up with the file // holding the key only one of them is using. func createSessionKey(path string) ([]byte, error) { key := make([]byte, 32) if _, err := rand.Read(key); err != nil { return nil, fmt.Errorf("generating a session key: %w", err) } f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, sessionKeyPerm) if err != nil { if errors.Is(err, os.ErrExist) { return readSessionKey(path) } return nil, fmt.Errorf("creating %s: %w", path, err) } defer f.Close() if _, err := fmt.Fprintf(f, "%x\n", key); err != nil { return nil, fmt.Errorf("writing %s: %w", path, err) } // The umask may have widened the mode; say what it has to be. if err := f.Chmod(sessionKeyPerm); err != nil { return nil, fmt.Errorf("securing %s: %w", path, err) } return key, f.Sync() } // seal returns the cookie value carrying token. func (s *sealer) seal(token string) (string, error) { nonce := make([]byte, s.aead.NonceSize()) if _, err := rand.Read(nonce); err != nil { return "", err } sealed := s.aead.Seal(nonce, nonce, []byte(token), nil) return base64.RawURLEncoding.EncodeToString(sealed), nil } // open recovers the token from a cookie value. Anything that does not decrypt // is treated as absent: a cookie from an older format or another key is not an // error to report, it is simply not a session. func (s *sealer) open(value string) string { raw, err := base64.RawURLEncoding.DecodeString(value) if err != nil || len(raw) < s.aead.NonceSize() { return "" } nonce, body := raw[:s.aead.NonceSize()], raw[s.aead.NonceSize():] token, err := s.aead.Open(nil, nonce, body, nil) if err != nil { return "" } return string(token) }