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