carlos / platform Public

gate: password rules, HMAC cookie keyed off the hash, limiter and login page

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 017594e68a59076800b803fce7d445e1a6601035 parent e864652
6 files changed, +659 −0
  • internal/gate/cookie.go +59 −0
  • internal/gate/gate.go +166 −0
  • internal/gate/gate_test.go +274 −0
  • internal/gate/limiter.go +64 −0
  • internal/gate/login.html +55 −0
  • internal/gate/page.go +41 −0
diff --git a/internal/gate/cookie.go b/internal/gate/cookie.go
new file mode 100644
index 000000000..88f15480b
--- /dev/null
+++ b/internal/gate/cookie.go
@@ -0,0 +1,59 @@
+package gate
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// The cookie is v1.<expiry unix>.<base64url(HMAC-SHA256(key, host "\n" expiry))>
+// with key derived from the stored bcrypt hash and nothing else. That is
+// what makes the design stateless and gives it its one rotation rule:
+// change or clear the password and every cookie minted under the old one
+// stops verifying at once, on every box, with no key to mint or mirror.
+// Anyone who can read the hash can mint a cookie — root on the box and a
+// bucket reader, who already hold the tenant's sealed secrets (spec §4.3).
+
+const cookieVersion = "v1"
+
+func cookieKey(hash string) []byte {
+ m := hmac.New(sha256.New, []byte("carlos-gate-cookie-v1"))
+ m.Write([]byte(hash))
+ return m.Sum(nil)
+}
+
+func cookieMAC(key []byte, host string, expiry int64) []byte {
+ m := hmac.New(sha256.New, key)
+ m.Write([]byte(host))
+ m.Write([]byte{'\n'})
+ m.Write([]byte(strconv.FormatInt(expiry, 10)))
+ return m.Sum(nil)
+}
+
+// Mint produces a cookie value for host, valid until expiry.
+func Mint(host, hash string, expiry time.Time) string {
+ exp := expiry.Unix()
+ mac := cookieMAC(cookieKey(hash), host, exp)
+ return cookieVersion + "." + strconv.FormatInt(exp, 10) + "." + base64.RawURLEncoding.EncodeToString(mac)
+}
+
+// Verify reports whether value was minted for host under hash and has not
+// expired at now. One HMAC and a constant-time compare; no bcrypt.
+func Verify(value, host, hash string, now time.Time) bool {
+ parts := strings.Split(value, ".")
+ if len(parts) != 3 || parts[0] != cookieVersion {
+ return false
+ }
+ exp, err := strconv.ParseInt(parts[1], 10, 64)
+ if err != nil || exp <= now.Unix() {
+ return false
+ }
+ got, err := base64.RawURLEncoding.DecodeString(parts[2])
+ if err != nil {
+ return false
+ }
+ return hmac.Equal(got, cookieMAC(cookieKey(hash), host, exp))
+}
diff --git a/internal/gate/gate.go b/internal/gate/gate.go
new file mode 100644
index 000000000..160921145
--- /dev/null
+++ b/internal/gate/gate.go
@@ -0,0 +1,166 @@
+// Package gate is the password curtain the edge holds in front of a gated
+// host: one shared password per hostname, a neutral page that asks for it,
+// and a cookie that remembers the answer for a week. Spec:
+// docs/superpowers/specs/2026-09-07-password-gate-design.md.
+//
+// The edge calls Admit once per request to a route whose registry row
+// carries a gate hash. Everything else here — the rules the console
+// applies before hashing, the cookie's shape, the attempt limiter — is
+// owned by this package so the two console doors and the edge cannot
+// drift from one another.
+package gate
+
+import (
+ "fmt"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+const (
+ // CookieName is the browser cookie a passed gate sets; stripped from
+ // the request before it reaches the app.
+ CookieName = "carlos_gate"
+ // LoginPath is the form's action on a gated host. Ordinary on every
+ // other host and for every other method.
+ LoginPath = "/.carlos/gate"
+ // Lifetime is how long a cookie stays valid; the expiry is inside the
+ // value, so a stolen cookie is bounded even if the browser keeps it.
+ Lifetime = 7 * 24 * time.Hour
+
+ bcryptCost = 10
+ minPassword = 8
+ maxPassword = 72 // bcrypt's input limit; longer is refused, never truncated
+ maxFormBody = 4 << 10
+)
+
+// CheckPassword is the door's rule, applied by the console before hashing.
+func CheckPassword(p string) error {
+ if len(p) < minPassword {
+ return fmt.Errorf("password must be at least %d characters", minPassword)
+ }
+ if len(p) > maxPassword {
+ return fmt.Errorf("password must be at most %d bytes", maxPassword)
+ }
+ return nil
+}
+
+// Hash is bcrypt at cost 10. The console calls it once per set; the edge
+// never does (it compares on the login POST and verifies an HMAC cookie
+// on every other request).
+func Hash(p string) (string, error) {
+ if err := CheckPassword(p); err != nil {
+ return "", err
+ }
+ b, err := bcrypt.GenerateFromPassword([]byte(p), bcryptCost)
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
+
+func comparePassword(hash, password string) bool {
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
+}
+
+// Gate is the per-edge instance: one limiter shared by every gated host on
+// the box (keys carry the host), a clock and a comparer for tests.
+type Gate struct {
+ limiter *Limiter
+ now func() time.Time
+ compare func(hash, password string) bool
+}
+
+// New is the edge's constructor. There is no configuration.
+func New() *Gate {
+ g := &Gate{now: time.Now, compare: comparePassword}
+ g.limiter = NewLimiter(func() time.Time { return g.now() })
+ return g
+}
+
+// Admit decides one request to a gated host. host is the route's own
+// hostname (the row's, not the request's — an alias and a path-routed
+// prefix must agree on which key signs), hash the row's gate.
+//
+// true: the request carries a valid cookie, now stripped from the Cookie
+// header; the caller proxies it exactly as it would have. false: Admit has
+// written the whole response — the login page (401), the redirect after a
+// successful POST (303), or the too-many-attempts page (429).
+func (g *Gate) Admit(w http.ResponseWriter, r *http.Request, host, hash string) bool {
+ if r.Method == http.MethodPost && r.URL.Path == LoginPath {
+ g.login(w, r, host, hash)
+ return false
+ }
+ if c, err := r.Cookie(CookieName); err == nil && Verify(c.Value, host, hash, g.now()) {
+ stripCookie(r)
+ return true
+ }
+ writePage(w, r, host, http.StatusUnauthorized, "", safeNext(r.URL.RequestURI()))
+ return false
+}
+
+func (g *Gate) login(w http.ResponseWriter, r *http.Request, host, hash string) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxFormBody)
+ if err := r.ParseForm(); err != nil {
+ writePage(w, r, host, http.StatusUnauthorized, msgWrong, "/")
+ return
+ }
+ next := safeNext(r.PostForm.Get("next"))
+ if !g.limiter.Allow(clientKey(r) + "\x1f" + host) {
+ w.Header().Set("Retry-After", "60")
+ writePage(w, r, host, http.StatusTooManyRequests, msgTooMany, next)
+ return
+ }
+ if !g.compare(hash, r.PostForm.Get("password")) {
+ writePage(w, r, host, http.StatusUnauthorized, msgWrong, next)
+ return
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: CookieName,
+ Value: Mint(host, hash, g.now().Add(Lifetime)),
+ Path: "/",
+ MaxAge: int(Lifetime / time.Second),
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+ w.Header().Set("Cache-Control", "no-store")
+ http.Redirect(w, r, next, http.StatusSeeOther)
+}
+
+// safeNext is the entire open-redirect surface: a path on this host, or "/".
+func safeNext(next string) string {
+ if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
+ return "/"
+ }
+ return next
+}
+
+// clientKey is the remote address without its port. A backhauled request's
+// address is the sibling edge's, which folds every hop from that edge into
+// one bucket — stricter, never looser, and named in the spec (§4.2).
+func clientKey(r *http.Request) string {
+ if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
+ return h
+ }
+ return r.RemoteAddr
+}
+
+// stripCookie removes the gate cookie from the request so the app behind
+// the curtain never sees it. Other cookies pass through in their order.
+func stripCookie(r *http.Request) {
+ cookies := r.Cookies()
+ r.Header.Del("Cookie")
+ parts := make([]string, 0, len(cookies))
+ for _, c := range cookies {
+ if c.Name != CookieName {
+ parts = append(parts, c.Name+"="+c.Value)
+ }
+ }
+ if len(parts) > 0 {
+ r.Header.Set("Cookie", strings.Join(parts, "; "))
+ }
+}
diff --git a/internal/gate/gate_test.go b/internal/gate/gate_test.go
new file mode 100644
index 000000000..879763d09
--- /dev/null
+++ b/internal/gate/gate_test.go
@@ -0,0 +1,274 @@
+package gate
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestCheckPassword(t *testing.T) {
+ if err := CheckPassword("short7!"); err == nil {
+ t.Fatal("7 chars accepted")
+ }
+ if err := CheckPassword(strings.Repeat("x", 73)); err == nil {
+ t.Fatal("73 bytes accepted — bcrypt would silently truncate")
+ }
+ if err := CheckPassword("longenough"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestHashThenVerifyPassword(t *testing.T) {
+ h, err := Hash("correct horse")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(h, "$2a$10$") {
+ t.Fatalf("hash = %q, want bcrypt cost 10", h)
+ }
+ if !comparePassword(h, "correct horse") || comparePassword(h, "wrong horse") {
+ t.Fatal("comparePassword disagrees with Hash")
+ }
+}
+
+func TestCookieRoundTrip(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0)
+ v := Mint("site.example", "$2a$10$hash", now.Add(Lifetime))
+ if !Verify(v, "site.example", "$2a$10$hash", now) {
+ t.Fatal("fresh cookie refused")
+ }
+ if Verify(v, "other.example", "$2a$10$hash", now) {
+ t.Fatal("cookie accepted for another host")
+ }
+ if Verify(v, "site.example", "$2a$10$rotated", now) {
+ t.Fatal("cookie accepted after the password changed")
+ }
+ if Verify(v, "site.example", "$2a$10$hash", now.Add(Lifetime+time.Second)) {
+ t.Fatal("expired cookie accepted")
+ }
+ if Verify("v1.garbage", "site.example", "$2a$10$hash", now) || Verify("", "site.example", "$2a$10$hash", now) {
+ t.Fatal("malformed cookie accepted")
+ }
+}
+
+func TestLimiterTenPerMinute(t *testing.T) {
+ now := time.Unix(0, 0)
+ l := NewLimiter(func() time.Time { return now })
+ for i := 0; i < 10; i++ {
+ if !l.Allow("k") {
+ t.Fatalf("attempt %d refused", i+1)
+ }
+ }
+ if l.Allow("k") {
+ t.Fatal("11th attempt allowed")
+ }
+ if !l.Allow("other") {
+ t.Fatal("another key shares the bucket")
+ }
+ now = now.Add(6 * time.Second)
+ if !l.Allow("k") {
+ t.Fatal("no refill after 6s")
+ }
+}
+
+func testGate(hash string) *Gate {
+ g := New()
+ g.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
+ return g
+}
+
+func TestAdmitNoCookieIsTheLoginPage(t *testing.T) {
+ g := testGate("")
+ h, _ := Hash("correct horse")
+ for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodHead} {
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest(method, "https://site.example/docs?x=1", nil)
+ if g.Admit(rec, r, "site.example", h) {
+ t.Fatalf("%s without a cookie admitted", method)
+ }
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("%s status = %d, want 401", method, rec.Code)
+ }
+ if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
+ t.Fatalf("Cache-Control = %q", cc)
+ }
+ if rt := rec.Header().Get("X-Robots-Tag"); rt != "noindex, nofollow" {
+ t.Fatalf("X-Robots-Tag = %q", rt)
+ }
+ body := rec.Body.String()
+ if method == http.MethodHead {
+ if body != "" {
+ t.Fatal("HEAD carried a body")
+ }
+ continue
+ }
+ if !strings.Contains(body, "Password required") || !strings.Contains(body, "site.example") {
+ t.Fatalf("page missing copy: %s", body)
+ }
+ if !strings.Contains(body, `name="next" value="/docs?x=1"`) {
+ t.Fatalf("page does not carry next: %s", body)
+ }
+ if !strings.Contains(body, `action="/.carlos/gate"`) {
+ t.Fatalf("form action wrong: %s", body)
+ }
+ }
+}
+
+func postLogin(g *Gate, host, hash, password, next string) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder()
+ form := url.Values{"password": {password}, "next": {next}}
+ r := httptest.NewRequest(http.MethodPost, "https://"+host+LoginPath, strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.RemoteAddr = "203.0.113.9:4242"
+ g.Admit(rec, r, host, hash)
+ return rec
+}
+
+func TestAdmitRightPasswordSetsCookieAndRedirects(t *testing.T) {
+ g := testGate("")
+ h, _ := Hash("correct horse")
+ rec := postLogin(g, "site.example", h, "correct horse", "/docs?x=1")
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/docs?x=1" {
+ t.Fatalf("got %d -> %q, want 303 -> /docs?x=1", rec.Code, rec.Header().Get("Location"))
+ }
+ var c *http.Cookie
+ for _, k := range rec.Result().Cookies() {
+ if k.Name == CookieName {
+ c = k
+ }
+ }
+ if c == nil {
+ t.Fatal("no gate cookie set")
+ }
+ if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.Path != "/" || c.MaxAge != int(Lifetime/time.Second) {
+ t.Fatalf("cookie attributes: %+v", c)
+ }
+ if !Verify(c.Value, "site.example", h, g.now()) {
+ t.Fatal("minted cookie does not verify")
+ }
+ // And the cookie is admitted, and stripped from what the app sees.
+ rec2 := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "https://site.example/", nil)
+ r.Header.Set("Cookie", "theme=dark; "+CookieName+"="+c.Value+"; other=1")
+ if !g.Admit(rec2, r, "site.example", h) {
+ t.Fatal("valid cookie refused")
+ }
+ if got := r.Header.Get("Cookie"); got != "theme=dark; other=1" {
+ t.Fatalf("upstream Cookie = %q, want the gate cookie stripped", got)
+ }
+}
+
+func TestAdmitBadNextBecomesRoot(t *testing.T) {
+ g := testGate("")
+ h, _ := Hash("correct horse")
+ for _, next := range []string{"//evil.example/", "https://evil.example/", "docs", ""} {
+ rec := postLogin(g, "site.example", h, "correct horse", next)
+ if loc := rec.Header().Get("Location"); loc != "/" {
+ t.Fatalf("next %q -> Location %q, want /", next, loc)
+ }
+ }
+}
+
+func TestAdmitWrongPasswordThenLimit(t *testing.T) {
+ g := testGate("")
+ h, _ := Hash("correct horse")
+ compares := 0
+ g.compare = func(hash, password string) bool { compares++; return false }
+ for i := 0; i < 10; i++ {
+ rec := postLogin(g, "site.example", h, "nope nope", "/")
+ if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "That password is not right.") {
+ t.Fatalf("attempt %d: %d %s", i+1, rec.Code, rec.Body.String())
+ }
+ }
+ rec := postLogin(g, "site.example", h, "nope nope", "/")
+ if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") != "60" {
+ t.Fatalf("11th attempt: %d Retry-After=%q, want 429/60", rec.Code, rec.Header().Get("Retry-After"))
+ }
+ if !strings.Contains(rec.Body.String(), "Too many attempts. Try again in a minute.") {
+ t.Fatalf("429 page copy: %s", rec.Body.String())
+ }
+ if compares != 10 {
+ t.Fatalf("bcrypt compared %d times, want 10 — the limiter must refuse BEFORE comparing", compares)
+ }
+}
+
+func TestLoginPathOnGetIsJustAGatedRequest(t *testing.T) {
+ g := testGate("")
+ h, _ := Hash("correct horse")
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "https://site.example"+LoginPath, nil)
+ if g.Admit(rec, r, "site.example", h) || rec.Code != http.StatusUnauthorized {
+ t.Fatalf("GET %s: admitted=%v code=%d", LoginPath, false, rec.Code)
+ }
+}
+
+// safeNext is the whole open-redirect surface, so pin it directly rather
+// than only through the three shapes the POST test happens to send.
+func TestSafeNextIsAPathOnThisHostOrRoot(t *testing.T) {
+ for _, bad := range []string{"//evil.example/", `/\evil.example/`, `/\/evil.example/`, "https://evil.example/", "javascript:alert(1)", `\\evil.example\`, "", "docs", "?x=1", "#frag"} {
+ if got := safeNext(bad); got != "/" {
+ t.Errorf("safeNext(%q) = %q, want /", bad, got)
+ }
+ }
+ for _, ok := range []string{"/", "/docs?x=1", "/a/b#c"} {
+ if got := safeNext(ok); got != ok {
+ t.Errorf("safeNext(%q) = %q, want it unchanged", ok, got)
+ }
+ }
+}
+
+// The MAC covers the expiry, so pushing the expiry out on a stolen cookie
+// must not buy another week.
+func TestCookieForgedExpiryAndTamperedMAC(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0)
+ v := Mint("site.example", "$2a$10$hash", now.Add(Lifetime))
+ parts := strings.Split(v, ".")
+ if Verify(parts[0]+".9999999999."+parts[2], "site.example", "$2a$10$hash", now) {
+ t.Fatal("expiry moved forward without re-signing was accepted")
+ }
+ if Verify(v+".x", "site.example", "$2a$10$hash", now) {
+ t.Fatal("a fourth field was accepted")
+ }
+ if Verify("v2."+parts[1]+"."+parts[2], "site.example", "$2a$10$hash", now) {
+ t.Fatal("another version was accepted")
+ }
+ if Verify(parts[0]+"."+parts[1]+".!!!", "site.example", "$2a$10$hash", now) {
+ t.Fatal("a non-base64 MAC was accepted")
+ }
+}
+
+func TestStripCookieDropsTheHeaderWhenTheGateCookieIsTheOnlyOne(t *testing.T) {
+ r := httptest.NewRequest(http.MethodGet, "https://site.example/", nil)
+ r.Header.Set("Cookie", CookieName+"=x")
+ stripCookie(r)
+ if _, ok := r.Header["Cookie"]; ok {
+ t.Fatalf("Cookie header survived as %q", r.Header.Get("Cookie"))
+ }
+}
+
+// A body over maxFormBody fails to parse; that must cost no bcrypt and no
+// limiter token, so a flood of junk cannot lock a tenant out of their own
+// site.
+func TestOversizedFormBodyIsRefusedWithoutSpendingAnAttempt(t *testing.T) {
+ g := testGate("")
+ h, _ := Hash("correct horse")
+ compares := 0
+ g.compare = func(hash, password string) bool { compares++; return true }
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodPost, "https://site.example"+LoginPath, strings.NewReader("password="+strings.Repeat("a", 64<<10)))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.RemoteAddr = "203.0.113.9:4242"
+ g.Admit(rec, r, "site.example", h)
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("oversized body -> %d, want 401", rec.Code)
+ }
+ if compares != 0 {
+ t.Fatal("an unparseable body reached bcrypt")
+ }
+ if !g.limiter.Allow(clientKey(r) + "\x1f" + "site.example") {
+ t.Fatal("an unparseable body spent an attempt")
+ }
+}
diff --git a/internal/gate/limiter.go b/internal/gate/limiter.go
new file mode 100644
index 000000000..a96294b08
--- /dev/null
+++ b/internal/gate/limiter.go
@@ -0,0 +1,64 @@
+package gate
+
+import (
+ "sync"
+ "time"
+)
+
+const (
+ limitBurst = 10
+ limitPeriod = time.Minute
+ limitPrune = 10_000
+)
+
+// Limiter is a per-key token bucket: limitBurst attempts, refilling at
+// limitBurst per limitPeriod. bcrypt at cost 10 already costs tens of
+// milliseconds a comparison; this exists so the CPU is spent on people and
+// not on a script.
+type Limiter struct {
+ mu sync.Mutex
+ now func() time.Time
+ buckets map[string]*bucket
+}
+
+type bucket struct {
+ tokens float64
+ last time.Time
+}
+
+func NewLimiter(now func() time.Time) *Limiter {
+ return &Limiter{now: now, buckets: map[string]*bucket{}}
+}
+
+// Allow spends one token for key, reporting whether one was available.
+func (l *Limiter) Allow(key string) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ now := l.now()
+ b, ok := l.buckets[key]
+ if !ok {
+ if len(l.buckets) >= limitPrune {
+ l.prune(now)
+ }
+ b = &bucket{tokens: limitBurst, last: now}
+ l.buckets[key] = b
+ }
+ refill := now.Sub(b.last).Seconds() * (limitBurst / limitPeriod.Seconds())
+ b.tokens = min(limitBurst, b.tokens+refill)
+ b.last = now
+ if b.tokens < 1 {
+ return false
+ }
+ b.tokens--
+ return true
+}
+
+// prune drops every bucket that has been idle long enough to be full again;
+// keeping it would change no answer.
+func (l *Limiter) prune(now time.Time) {
+ for k, b := range l.buckets {
+ if now.Sub(b.last) >= limitPeriod {
+ delete(l.buckets, k)
+ }
+ }
+}
diff --git a/internal/gate/login.html b/internal/gate/login.html
new file mode 100644
index 000000000..670bdd09c
--- /dev/null
+++ b/internal/gate/login.html
@@ -0,0 +1,55 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="robots" content="noindex, nofollow">
+<title>Password required</title>
+<style>
+:root {
+ --bg: #ffffff; --ink: #1d2029; --muted: #64707f; --accent: #0f5e85;
+ --accent-soft: rgba(15, 94, 133, 0.09); --rule: #e7e9f0; --bad: #b3403a;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #14151c; --ink: #eef0f6; --muted: #93a4ba; --accent: #5db8dd;
+ --accent-soft: rgba(93, 184, 221, 0.12); --rule: #2a2e38; --bad: #e07b76;
+ }
+}
+* { margin: 0; padding: 0; box-sizing: border-box; }
+body {
+ background: var(--bg); color: var(--ink);
+ font: 400 1rem/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
+ min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1.25rem;
+}
+main { width: 100%; max-width: 22rem; }
+h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
+p { color: var(--muted); margin-bottom: 1.25rem; }
+p code { color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.95em; }
+label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 0.35rem; }
+input[type=password] {
+ width: 100%; font: inherit; color: var(--ink); background: var(--bg);
+ border: 1px solid var(--rule); border-radius: 0.4rem; padding: 0.55rem 0.7rem; margin-bottom: 0.9rem;
+}
+input[type=password]:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
+button {
+ width: 100%; font: inherit; font-weight: 600; color: var(--bg); background: var(--accent);
+ border: 0; border-radius: 0.4rem; padding: 0.6rem 0.9rem; cursor: pointer;
+}
+.error { color: var(--bad); margin: 0 0 0.9rem; }
+</style>
+</head>
+<body>
+<main>
+ <h1>Password required</h1>
+ <p><code>{{.Host}}</code> is private. Enter the password to continue.</p>
+ <form method="post" action="{{.Action}}">
+ <input type="hidden" name="next" value="{{.Next}}">
+ {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
+ <label for="password">Password</label>
+ <input type="password" id="password" name="password" autocomplete="current-password" autofocus required>
+ <button type="submit">Continue</button>
+ </form>
+</main>
+</body>
+</html>
diff --git a/internal/gate/page.go b/internal/gate/page.go
new file mode 100644
index 000000000..973c28c36
--- /dev/null
+++ b/internal/gate/page.go
@@ -0,0 +1,41 @@
+package gate
+
+import (
+ _ "embed"
+ "html/template"
+ "log"
+ "net/http"
+)
+
+//go:embed login.html
+var loginHTML string
+
+var loginTmpl = template.Must(template.New("login").Parse(loginHTML))
+
+const (
+ msgWrong = "That password is not right."
+ msgTooMany = "Too many attempts. Try again in a minute."
+)
+
+type pageData struct {
+ Host string
+ Error string
+ Next string
+ Action string
+}
+
+// writePage renders the login page at status. Every page this package
+// writes is no-store and noindex: it is a curtain, not content.
+func writePage(w http.ResponseWriter, r *http.Request, host string, status int, msg, next string) {
+ h := w.Header()
+ h.Set("Cache-Control", "no-store")
+ h.Set("X-Robots-Tag", "noindex, nofollow")
+ h.Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if r.Method == http.MethodHead {
+ return
+ }
+ if err := loginTmpl.Execute(w, pageData{Host: host, Error: msg, Next: next, Action: LoginPath}); err != nil {
+ log.Printf("gate: render login page for %s: %v", host, err)
+ }
+}