Encrypt cookies
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user