Files
2026-09-13 11:45:15 +02:00

134 lines
4.1 KiB
Go

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)
}