From 041b18f651d0c76c696d83624ad294041eace725 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 8 Jan 2026 07:37:58 -0800 Subject: [PATCH] Add session package for encrypted cookie management Uses gorilla/securecookie with keys derived via HKDF. 30-day TTL, HttpOnly, Secure, SameSiteStrict cookies. --- internal/session/session.go | 145 ++++++++++++++++++++++ internal/session/session_test.go | 204 +++++++++++++++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 internal/session/session.go create mode 100644 internal/session/session_test.go diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..1a0c5c9 --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,145 @@ +// Package session provides encrypted session cookie management. +package session + +import ( + "errors" + "net/http" + "time" + + "github.com/gorilla/securecookie" + + "sneak.berlin/go/pixa/internal/seal" +) + +// Session configuration constants. +const ( + CookieName = "pixa_session" + SessionTTL = 30 * 24 * time.Hour // 30 days + + // HKDF salts for key derivation + hashKeySalt = "pixa-session-hash-v1" + blockKeySalt = "pixa-session-block-v1" +) + +// Errors returned by session operations. +var ( + ErrInvalidSession = errors.New("invalid or expired session") + ErrNoSession = errors.New("no session cookie present") +) + +// Data contains the session payload stored in the encrypted cookie. +type Data struct { + Authenticated bool `json:"auth"` + CreatedAt time.Time `json:"created"` + ExpiresAt time.Time `json:"expires"` +} + +// Manager handles session creation and validation using encrypted cookies. +type Manager struct { + sc *securecookie.SecureCookie + secure bool // Set Secure flag on cookies (should be true in production) + sameSite http.SameSite +} + +// NewManager creates a session manager with keys derived from the signing key. +// Set secure=true in production to require HTTPS for cookies. +func NewManager(signingKey string, secure bool) (*Manager, error) { + masterKey := []byte(signingKey) + + // Derive separate keys for HMAC (hash) and encryption (block) + hashKey, err := seal.DeriveKey(masterKey, hashKeySalt) + if err != nil { + return nil, err + } + + blockKey, err := seal.DeriveKey(masterKey, blockKeySalt) + if err != nil { + return nil, err + } + + sc := securecookie.New(hashKey[:], blockKey[:]) + sc.MaxAge(int(SessionTTL.Seconds())) + + return &Manager{ + sc: sc, + secure: secure, + sameSite: http.SameSiteStrictMode, + }, nil +} + +// CreateSession creates a new authenticated session and sets the cookie. +func (m *Manager) CreateSession(w http.ResponseWriter) error { + now := time.Now() + data := &Data{ + Authenticated: true, + CreatedAt: now, + ExpiresAt: now.Add(SessionTTL), + } + + encoded, err := m.sc.Encode(CookieName, data) + if err != nil { + return err + } + + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: encoded, + Path: "/", + MaxAge: int(SessionTTL.Seconds()), + HttpOnly: true, + Secure: m.secure, + SameSite: m.sameSite, + }) + + return nil +} + +// ValidateSession checks if the request has a valid session cookie. +// Returns the session data if valid, or an error if invalid/missing. +func (m *Manager) ValidateSession(r *http.Request) (*Data, error) { + cookie, err := r.Cookie(CookieName) + if err != nil { + if errors.Is(err, http.ErrNoCookie) { + return nil, ErrNoSession + } + + return nil, err + } + + var data Data + if err := m.sc.Decode(CookieName, cookie.Value, &data); err != nil { + return nil, ErrInvalidSession + } + + // Check if session has expired (defense in depth - cookie MaxAge should handle this) + if time.Now().After(data.ExpiresAt) { + return nil, ErrInvalidSession + } + + if !data.Authenticated { + return nil, ErrInvalidSession + } + + return &data, nil +} + +// ClearSession removes the session cookie. +func (m *Manager) ClearSession(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: "", + Path: "/", + MaxAge: -1, // Delete immediately + HttpOnly: true, + Secure: m.secure, + SameSite: m.sameSite, + }) +} + +// IsAuthenticated is a convenience method that returns true if the request +// has a valid authenticated session. +func (m *Manager) IsAuthenticated(r *http.Request) bool { + data, err := m.ValidateSession(r) + + return err == nil && data != nil && data.Authenticated +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 0000000..20bc55b --- /dev/null +++ b/internal/session/session_test.go @@ -0,0 +1,204 @@ +package session + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestManager_CreateAndValidate(t *testing.T) { + mgr, err := NewManager("test-signing-key-12345", false) + if err != nil { + t.Fatalf("NewManager() error = %v", err) + } + + // Create a session + w := httptest.NewRecorder() + if err := mgr.CreateSession(w); err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + // Extract the cookie from response + resp := w.Result() + cookies := resp.Cookies() + if len(cookies) == 0 { + t.Fatal("CreateSession() did not set a cookie") + } + + var sessionCookie *http.Cookie + for _, c := range cookies { + if c.Name == CookieName { + sessionCookie = c + break + } + } + + if sessionCookie == nil { + t.Fatalf("CreateSession() did not set cookie named %q", CookieName) + } + + // Validate the session + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie) + + data, err := mgr.ValidateSession(req) + if err != nil { + t.Fatalf("ValidateSession() error = %v", err) + } + + if !data.Authenticated { + t.Error("ValidateSession() returned unauthenticated session") + } + + if data.ExpiresAt.Before(time.Now()) { + t.Error("ValidateSession() returned already-expired session") + } +} + +func TestManager_ValidateSession_NoCookie(t *testing.T) { + mgr, _ := NewManager("test-signing-key-12345", false) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + + _, err := mgr.ValidateSession(req) + if err == nil { + t.Error("ValidateSession() should fail with no cookie") + } + + if err != ErrNoSession { + t.Errorf("ValidateSession() error = %v, want %v", err, ErrNoSession) + } +} + +func TestManager_ValidateSession_TamperedCookie(t *testing.T) { + mgr, _ := NewManager("test-signing-key-12345", false) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{ + Name: CookieName, + Value: "tampered-invalid-cookie-value", + }) + + _, err := mgr.ValidateSession(req) + if err == nil { + t.Error("ValidateSession() should fail with tampered cookie") + } + + if err != ErrInvalidSession { + t.Errorf("ValidateSession() error = %v, want %v", err, ErrInvalidSession) + } +} + +func TestManager_ValidateSession_WrongKey(t *testing.T) { + mgr1, _ := NewManager("signing-key-1", false) + mgr2, _ := NewManager("signing-key-2", false) + + // Create session with mgr1 + w := httptest.NewRecorder() + _ = mgr1.CreateSession(w) + + resp := w.Result() + var sessionCookie *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == CookieName { + sessionCookie = c + break + } + } + + // Try to validate with mgr2 (different key) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie) + + _, err := mgr2.ValidateSession(req) + if err == nil { + t.Error("ValidateSession() should fail with different signing key") + } +} + +func TestManager_ClearSession(t *testing.T) { + mgr, _ := NewManager("test-signing-key-12345", false) + + w := httptest.NewRecorder() + mgr.ClearSession(w) + + resp := w.Result() + cookies := resp.Cookies() + + var sessionCookie *http.Cookie + for _, c := range cookies { + if c.Name == CookieName { + sessionCookie = c + break + } + } + + if sessionCookie == nil { + t.Fatal("ClearSession() did not set a cookie") + } + + if sessionCookie.MaxAge != -1 { + t.Errorf("ClearSession() cookie MaxAge = %d, want -1", sessionCookie.MaxAge) + } +} + +func TestManager_IsAuthenticated(t *testing.T) { + mgr, _ := NewManager("test-signing-key-12345", false) + + // No session - should return false + req := httptest.NewRequest(http.MethodGet, "/", nil) + if mgr.IsAuthenticated(req) { + t.Error("IsAuthenticated() should return false with no session") + } + + // Create session + w := httptest.NewRecorder() + _ = mgr.CreateSession(w) + + resp := w.Result() + var sessionCookie *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == CookieName { + sessionCookie = c + break + } + } + + // With valid session - should return true + req = httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie) + + if !mgr.IsAuthenticated(req) { + t.Error("IsAuthenticated() should return true with valid session") + } +} + +func TestManager_CookieAttributes(t *testing.T) { + // Test with secure=true + mgr, _ := NewManager("test-key", true) + + w := httptest.NewRecorder() + _ = mgr.CreateSession(w) + + resp := w.Result() + var sessionCookie *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == CookieName { + sessionCookie = c + break + } + } + + if !sessionCookie.HttpOnly { + t.Error("Cookie should have HttpOnly flag") + } + + if !sessionCookie.Secure { + t.Error("Cookie should have Secure flag when manager created with secure=true") + } + + if sessionCookie.SameSite != http.SameSiteStrictMode { + t.Errorf("Cookie SameSite = %v, want %v", sessionCookie.SameSite, http.SameSiteStrictMode) + } +}