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 != "" }