NextMyDay/internal/core/httpx/presession.go
Claus Lohmar d11e14295b feat(core): add the store, identity plumbing and the door (SPEC-003/004)
- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded
  migration runner; each module's migrations are name-prefixed (D-012)
- audit: the append-only audit log; the actor threads via context (R-1)
- users: the user_id/GUID/email anchors (D-013)
- config: the YAML settings file (D-035) + validation
- httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security
  headers · redaction
- middleware: correlation id · audit · rate limiting
- mail: the invite/recovery mailer seam
- server: the core DOOR — the resolution switch, the dispatch registry
  (RegisterModule), the role gates, /setup · enroll/login · recovery flows,
  the reserved `static` workflow, and the loopback-only bootstrap (D-025)
2026-10-14 10:53:51 +00:00

140 lines
4.3 KiB
Go

package httpx
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strings"
)
// PreSessionCookieName is the cookie carrying the anonymous double-submit CSRF
// token (A15). It is issued on the enroll/login screens and verified on the
// anonymous actions — before any session exists (SPEC-006 §5).
const PreSessionCookieName = "myday_csrf"
// ErrNoSigningKey is returned when the signing secret is empty — fail closed.
var ErrNoSigningKey = errors.New("httpx: signing key is required")
// Signer signs and verifies opaque cookie values with HMAC-SHA256. It is the
// primitive behind the pre-session CSRF token and the WebAuthn ceremony state:
// both are tamper-evident without a server-side table. The key comes from
// `secrets.cookie_secret`.
type Signer struct{ key []byte }
// NewSigner builds a Signer from the configured key. An empty key fails closed.
func NewSigner(key []byte) (*Signer, error) {
if len(key) == 0 {
return nil, ErrNoSigningKey
}
k := make([]byte, len(key))
copy(k, key)
return &Signer{key: k}, nil
}
// Sign returns `base64url(value).base64url(HMAC)`. Encoding the value keeps the
// "." delimiter unambiguous even when value contains one.
func (s *Signer) Sign(value string) string {
payload := base64.RawURLEncoding.EncodeToString([]byte(value))
return payload + "." + s.mac(payload)
}
// Unsign verifies a token and returns the original value. A tampered or
// malformed token reports ok=false.
func (s *Signer) Unsign(token string) (value string, ok bool) {
payload, sig, found := strings.Cut(token, ".")
if !found || payload == "" || sig == "" {
return "", false
}
if !hmac.Equal([]byte(sig), []byte(s.mac(payload))) {
return "", false
}
raw, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return "", false
}
return string(raw), true
}
// mac is the HMAC over the domain-separated payload.
func (s *Signer) mac(payload string) string {
m := hmac.New(sha256.New, s.key)
m.Write([]byte("myday.sign.v1:"))
m.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
}
// PreSessionCSRF is the signed double-submit token for the anonymous
// enroll/login forms (A15). SPEC-003 §6's session-bound CSRF does not apply
// before a session exists, so this fills the gap: the server issues a signed
// random token in an HttpOnly cookie AND embeds the same token in the form; on
// submit both must match and the signature must verify.
type PreSessionCSRF struct {
signer *Signer
secure bool
}
// NewPreSessionCSRF builds the helper from `secrets.cookie_secret`.
func NewPreSessionCSRF(secret []byte, secure bool) (*PreSessionCSRF, error) {
s, err := NewSigner(secret)
if err != nil {
return nil, err
}
return &PreSessionCSRF{signer: s, secure: secure}, nil
}
// Signer exposes the underlying signer so the flows can sign their own
// short-lived ceremony state with the same key.
func (p *PreSessionCSRF) Signer() *Signer { return p.signer }
// Token mints a fresh signed token.
func (p *PreSessionCSRF) Token() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("httpx: csrf entropy: %w", err)
}
return p.signer.Sign(base64.RawURLEncoding.EncodeToString(b[:])), nil
}
// Ensure returns a valid token for the request, reusing the cookie when it is
// present and valid, else issuing a fresh one.
func (p *PreSessionCSRF) Ensure(w http.ResponseWriter, r *http.Request) (string, error) {
if c, err := r.Cookie(PreSessionCookieName); err == nil && c.Value != "" {
if v, ok := p.signer.Unsign(c.Value); ok && v != "" {
return c.Value, nil
}
}
tok, err := p.Token()
if err != nil {
return "", err
}
http.SetCookie(w, &http.Cookie{
Name: PreSessionCookieName,
Value: tok,
Path: "/",
HttpOnly: true,
Secure: p.secure,
SameSite: http.SameSiteLaxMode,
})
return tok, nil
}
// Valid verifies the double-submit: the submitted token must equal the cookie's
// value AND carry a valid signature. The comparison is constant-time.
func (p *PreSessionCSRF) Valid(r *http.Request, submitted string) bool {
if submitted == "" {
return false
}
c, err := r.Cookie(PreSessionCookieName)
if err != nil || c.Value == "" {
return false
}
if !hmac.Equal([]byte(c.Value), []byte(submitted)) {
return false
}
v, ok := p.signer.Unsign(submitted)
return ok && v != ""
}