NextMyDay/internal/core/httpx/presession.go
Claus Lohmar 6d421b61fd feat(setup): gate first run with a printed setup code and a first-run page
The real topology is an LXC container (11020) behind Proxmox + Zoraxy, so
the SSH tunnel is impractical: it lands on the container gateway, not
loopback. D-025 is re-revised and D-044 adds the general gate.

- core/setup: a Gate (gate.go) holding the 128-bit setup code (generated at
  first run, or pinned by config `setup_code`) plus a signed unlock cookie
  (`myday_setup`) that carries a keyed one-way FINGERPRINT of the code, never
  the code itself. The code is single-use (retired on the first success) and
  the comparison ignores case/separators.
- The unlock action (`POST /setup?do=setup&action=unlock`) is CSRF-protected
  (A15), rate-limited (the /setup A3 class) and audited; a wrong or absent
  code gets the same generic 403 page.
- The door (server.Deps.SetupGate): in setup mode the wizard is served to
  EITHER the direct tunnel (gate a) OR an unlocked request (gate b); `GET /`
  and an un-unlocked `GET /setup` render a friendly first-run page with the
  code form — never a bare 404 — and every other route stays 404.
- cmd/myday prints an unmistakable startup banner: setup mode, the wizard
  URL, the code, where to read it (journalctl / pct exec 11020) and the
  restart that adopts the persisted secrets.
- httpx.Signer.Fingerprint backs the unlock cookie; config gains `setup_code`.

Tests cover the boilerplate at `/` and `/setup` (200, not 404), the unlock
(code → wizard; wrong/absent → generic, 429 under the limit), single-use, the
CSRF requirement, access-log redaction, and that a non-/setup route is still
404. Gate green (build · vet · test -race); live-smoked against the binary.
2026-10-15 06:33:13 +00:00

156 lines
5 KiB
Go

package httpx
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"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))
}
// Fingerprint returns a keyed, ONE-WAY digest of value as a hex string. Unlike
// Sign it is not reversible: the digest reveals nothing about value, so it is
// safe to hand a client a signed fingerprint of a secret (the D-044 setup code)
// without leaking the secret itself.
func (s *Signer) Fingerprint(value string) string {
m := hmac.New(sha256.New, s.key)
m.Write([]byte("myday.fingerprint.v1:"))
m.Write([]byte(value))
return hex.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 != ""
}