NextMyDay/internal/core/httpx/presession.go
Claus Lohmar f0927fb0f0 feat(server): gate /setup by the direct tunnel and trust the proxy scheme
Implements D-025 (revised 2026-10-15) and D-043.

Setup gate (D-025):
- Remove the bind refusal (CheckSetupBind/ErrNonLoopbackSetup/server.IsLoopback);
  Serve binds any address, so the app can listen on 0.0.0.0:8080 behind Zoraxy.
- The gate is now per request and strict (httpx.SetupRequestAllowed): a loopback
  TCP peer AND no X-Forwarded-For — the direct SSH tunnel. Anything else is 404
  before dispatch; an unparseable peer fails closed (AC-7/AC-10). The raw XFF is
  read before ClientIP SETS it (A6).

Proxy scheme (D-043):
- httpx.RequestIsHTTPS resolves the scheme per request: direct TLS, or a trusted
  proxy's X-Forwarded-Proto. The door carries it on the request context
  (httpx.WithSecure) so the session cookie, its __Host- prefix, the pre-session
  and ceremony cookies, and HSTS all agree (AC-13).
- Over plain http (the tunnel) cookies stay bare and non-Secure, which fixes the
  bootstrap from an https:// config.example.yaml; Config.IsSecure() is removed
  as now dead.

Tests: rewrite the setup-gate tests for AC-7/AC-10, add AC-13, and unit-test
SetupRequestAllowed / RequestIsHTTPS.
2026-10-15 05:40:36 +00:00

144 lines
4.6 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.
//
// Its cookie's `Secure` flag is resolved PER REQUEST from the door (D-043): the
// cookie is Secure behind a TLS-terminating trusted proxy and on a direct TLS
// connection, and non-Secure over the plain-http loopback bootstrap, where a
// Secure cookie would break the wizard.
type PreSessionCSRF struct {
signer *Signer
}
// NewPreSessionCSRF builds the helper from `secrets.cookie_secret`.
func NewPreSessionCSRF(secret []byte) (*PreSessionCSRF, error) {
s, err := NewSigner(secret)
if err != nil {
return nil, err
}
return &PreSessionCSRF{signer: s}, 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: SecureFromRequest(r),
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 != ""
}