134 lines
4.1 KiB
Go
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)
|
|
}
|