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.
144 lines
4.6 KiB
Go
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 != ""
|
|
}
|