diff --git a/.gitignore b/.gitignore index df89fce..a134151 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,12 @@ # build output /neverpay +/neverpay-seeddemo /dist/ # runtime data (sqlite db, uploaded files) /data/ /data-demo/ +/demo-data/ /embed-demo-site/ neverpay.env # generated wallet recovery sheet — secret, never commit (written into the data dir) diff --git a/DEPLOY.md b/DEPLOY.md index 6a2183b..82263da 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -54,7 +54,7 @@ receipts, download links, and the embed script. | `NEVERPAY_ADDR` | Listen address (default `:8080`; Docker `0.0.0.0:8080`) | | `NEVERPAY_DATA_DIR` | DB + uploaded files + `neverpay.env` (default `./data`) | | `NEVERPAY_BASE_URL` | Public URL (https or `.onion`); used in links & embed | -| `NEVERPAY_ADMIN_PASSWORD` | Admin password (else a random one is logged on first run) | +| `NEVERPAY_ADMIN_PASSWORD` | Admin password, min 12 chars (else a random 96-bit one is logged on first run) | | `NEVERPAY_ENV_FILE` | Override the env-file path | | **Receive keys (watch-only)** | | | `NEVERPAY_EVM_XPUB` | xpub for ETH + USDC (`m/44'/60'/0'/0`) | diff --git a/README.md b/README.md index e61a981..b1eb2da 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ buyer picks a coin → unique address + live status → payment confirms Default theme — buyers see your store name, logo, and accent color. +**[See the full guided demo →](demo/)** — every screen of the buyer and seller journeys. + ## Why @@ -190,6 +192,7 @@ warranty. Issues and contributions welcome. | Doc | What's in it | | --- | --- | +| [demo/](./demo/) | Guided screenshot walkthrough of the buyer and seller journeys; reproduce it locally with `go run ./cmd/seeddemo` | | [USAGE.md](./USAGE.md) | Zero-to-selling walkthrough: setup wizard, wallets per coin, products, embed, webhooks | | [DEPLOY.md](./DEPLOY.md) | Deployment, full env reference, Tor/.onion hosting, backups | | [sdk/](./sdk/) | License-verification SDKs + `NVPAY1` token / `/api/v1` reference | diff --git a/SECURITY.md b/SECURITY.md index 3a8a70c..715ceda 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,12 +20,21 @@ exploitable bugs until a fix is out. attributable and addresses aren't reused. **Admin auth** -- bcrypt password hashing; 256-bit random session tokens; sessions expire (7d) - and are validated server-side on every request. +- bcrypt password hashing (cost 12); 256-bit random session tokens; sessions + expire (7d) and are validated server-side on every request. - Session cookie is `HttpOnly`, `SameSite=Lax`, and `Secure` when served over HTTPS (`NEVERPAY_BASE_URL=https://…`). -- `POST /admin/login` is rate-limited (10/min/IP) to blunt brute force; bcrypt - cost further throttles guessing. +- Password policy: a 12-character minimum plus a small common-password blocklist, + enforced by `neverpay setup` and for `NEVERPAY_ADMIN_PASSWORD` on first run. The + auto-generated default password is 96-bit. (An *already-configured* install with + a now-sub-policy `NEVERPAY_ADMIN_PASSWORD` is not bricked on upgrade — it logs a + warning and keeps the existing password.) +- `POST /admin/login` is rate-limited (10/min/IP) **and** brute-force throttled: + consecutive failures from an IP trigger an escalating, auto-expiring cooldown + (capped at 15 min), with a fixed ~750 ms delay on every failed attempt. A correct + password is always accepted — even from a throttled IP — and clears the cooldown, + so the throttle only delays repeated wrong guesses. Active throttling is visible + on `/admin/status`. **License API (`/api/v1`)** - Unauthenticated by design — the signed license token *is* the credential, and @@ -75,6 +84,12 @@ exploitable bugs until a fix is out. - **Unbounded activation rows when a key has no seat limit** (`seats = 0`). A holder of a valid key could create many HWID activations. Bounded by the API rate limit; set a seat limit for keys that need hard caps. +- **Login rate-limit/throttle is keyed by source IP.** On a shared egress (Tor + exit, CGNAT, VPN — set `NEVERPAY_TRUST_PROXY=1` behind a reverse proxy to get + per-client IPs) the operator and an attacker share one key. The brute-force + throttle never blocks a *correct* password (it's verified before the cooldown + is consulted), but a flood from a shared IP can still hit the coarse 10/min/IP + request limit; a logged-in operator (7-day session) is unaffected. - **Stale pending orders / expired sessions** accumulate as rows (rejected on read). Negligible at single-operator scale; prune via DB maintenance if needed. - **EVM detection reads current balance**, not cumulative received. Safe given diff --git a/cmd/seeddemo/main.go b/cmd/seeddemo/main.go new file mode 100644 index 0000000..cdcec6e --- /dev/null +++ b/cmd/seeddemo/main.go @@ -0,0 +1,590 @@ +// Command seeddemo populates a neverpay data directory with realistic demo +// content — products with thumbnails and license plans, orders across every +// lifecycle state, issued/revoked/expired license keys with seat activations, +// and webhook endpoints — so all features can be demoed and screenshotted. +// +// It writes only to the data dir you point it at (NEVERPAY_DATA_DIR, default +// ./demo-data) and uses the real internal packages, so the schema, money math, +// signing key, and address derivation all match a live instance. Run it against +// a FRESH data dir, then start the server with the same NEVERPAY_DATA_DIR. +// +// NEVERPAY_DATA_DIR=./demo-data go run ./cmd/seeddemo +package main + +import ( + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/hex" + "fmt" + "hash/fnv" + "image" + "image/color" + "image/png" + "log" + "math" + "os" + "path/filepath" + "time" + + "neverpay/internal/db" + "neverpay/internal/license" + "neverpay/internal/wallet" +) + +func main() { + dataDir := os.Getenv("NEVERPAY_DATA_DIR") + if dataDir == "" { + dataDir = "./demo-data" + } + imagesDir := filepath.Join(dataDir, "images") + filesDir := filepath.Join(dataDir, "files") + must(os.MkdirAll(imagesDir, 0o755), "mkdir images") + must(os.MkdirAll(filesDir, 0o755), "mkdir files") + + d, err := db.Open(filepath.Join(dataDir, "neverpay.db")) + must(err, "open db") + defer d.Close() + + if n := count(d, "SELECT COUNT(*) FROM products"); n > 0 { + log.Fatalf("refusing to seed: %s already has %d products — point NEVERPAY_DATA_DIR at a fresh dir", dataDir, n) + } + + priv := seedWalletAndKeys(d) + seedBranding(d) + seedWebhooks(d) + products := seedProducts(d, imagesDir, filesDir) + seedOrdersAndKeys(d, priv, products) + + fmt.Println("\n────────────────────────────────────────────") + fmt.Printf("Seeded demo store in %s\n", dataDir) + fmt.Printf(" products: %d orders: %d keys: %d webhooks: %d\n", + count(d, "SELECT COUNT(*) FROM products"), + count(d, "SELECT COUNT(*) FROM orders"), + count(d, "SELECT COUNT(*) FROM license_keys"), + count(d, "SELECT COUNT(*) FROM webhook_endpoints")) + fmt.Println("Now run the server against this dir, e.g.:") + fmt.Printf(" NEVERPAY_DATA_DIR=%s NEVERPAY_ADDR=:8099 \\\n", dataDir) + fmt.Println(" NEVERPAY_BASE_URL=http://localhost:8099 NEVERPAY_ADMIN_PASSWORD=demo-admin-passphrase ./neverpay") + fmt.Println("────────────────────────────────────────────") +} + +// seedWalletAndKeys generates a demo HD wallet (so all 7 coins are enabled) and +// the Ed25519 license signing key, persisting both the way the server expects so +// it reuses them on boot. Returns the signing private key for minting demo keys. +func seedWalletAndKeys(d *db.DB) []byte { + mnemonic, err := wallet.NewMnemonic(128) + must(err, "mnemonic") + seed := wallet.MnemonicToSeed(mnemonic, "") + + evm, err := wallet.GenerateEVM(seed) + must(err, "evm") + btc, err := wallet.GenerateBTC(seed) + must(err, "btc") + ltc, err := wallet.GenerateLTC(seed) + must(err, "ltc") + tron, err := wallet.GenerateTron(seed) + must(err, "tron") + _, xrpAcct, err := wallet.GenerateXRP() + must(err, "xrp") + _, xlmAcct, err := wallet.GenerateXLM() + must(err, "xlm") + + set(d, "evm_xpub", evm.ExtKey) + set(d, "bitcoin_xpub", btc.ExtKey) + set(d, "litecoin_xpub", ltc.ExtKey) + set(d, "tron_xpub", tron.ExtKey) + set(d, "xrpl_xpub", xrpAcct) + set(d, "stellar_xpub", xlmAcct) + + lseed, pub, err := license.GenerateSeed() + must(err, "license seed") + set(d, "license_seed", base64.RawURLEncoding.EncodeToString(lseed)) + set(d, "license_pub", base64.RawURLEncoding.EncodeToString(pub)) + return lseed +} + +func seedBranding(d *db.DB) { + set(d, "brand_name", "Lumen Tools") + set(d, "brand_accent", "#36d399") + set(d, "brand_logo", "") +} + +func seedWebhooks(d *db.DB) { + must(d.CreateWebhookEndpoint(&db.WebhookEndpoint{ + URL: "https://api.lumentools.dev/neverpay/webhook", Secret: "whsec_" + randHex(16), Active: true, Events: "*", + }), "webhook 1") + must(d.CreateWebhookEndpoint(&db.WebhookEndpoint{ + URL: "https://hooks.zapier.com/hooks/catch/2841/license", Secret: "whsec_" + randHex(16), Active: true, Events: "order.paid,order.fulfilled", + }), "webhook 2") +} + +// ---- products ---- + +type plan struct { + label string + days int64 + dollars int64 +} + +type prodSpec struct { + slug, name, desc string + dollars int64 // single price / fallback + licenseType string + seats int64 // 0 = unlimited / n/a + expiryDays int64 // legacy single-price expiry; 0 = perpetual + unlisted bool + plans []plan +} + +func seedProducts(d *db.DB, imagesDir, filesDir string) map[string]*db.Product { + specs := []prodSpec{ + { + slug: "hedge-cli", name: "Hedge — Encrypted Notes CLI", + desc: "A fast, local-first encrypted notes tool for your terminal. Age-encrypted vault, fuzzy search, git sync. Single binary, no account.", + dollars: 39, licenseType: db.LicenseSigned, + plans: []plan{{"1 year of updates", 365, 39}, {"Lifetime", 0, 99}}, + }, + { + slug: "pixelsmith", name: "Pixelsmith — Sprite & Pixel-Art Studio", + desc: "Desktop sprite editor with onion-skinning, tilemaps, and palette tooling. Online activation with a 3-device seat limit per license.", + dollars: 59, licenseType: db.LicenseActivation, seats: 3, + plans: []plan{{"1 year", 365, 59}, {"Lifetime", 0, 129}}, + }, + { + slug: "conduit-mock", name: "Conduit — API Mock Server", + desc: "Spin up a realistic mock HTTP API from an OpenAPI spec in seconds. Offline-verifiable perpetual license; no phone-home.", + dollars: 49, licenseType: db.LicenseSigned, expiryDays: 0, + }, + { + slug: "sentry-tail", name: "Sentry Tail — Team Log Viewer (5 seats)", + desc: "Tail, filter, and alert on logs across your fleet. Team license with 5 activation seats; monthly or yearly.", + dollars: 190, licenseType: db.LicenseActivation, seats: 5, + plans: []plan{{"Monthly", 30, 19}, {"Yearly (2 months free)", 365, 190}}, + }, + { + slug: "asciicast", name: "ASCIIcast — Terminal Recorder", + desc: "Record and share terminal sessions as lightweight, replayable casts. Free download, no license key required.", + dollars: 0, licenseType: db.LicenseNone, + }, + { + slug: "hyperlint-pro", name: "Hyperlint Pro — Polyglot Linter", + desc: "One linter for 20+ languages with autofix and CI presets. Sold via direct link to existing customers (unlisted).", + dollars: 45, licenseType: db.LicenseSigned, unlisted: true, + plans: []plan{{"6 months", 180, 25}, {"1 year", 365, 45}, {"2 years", 730, 80}}, + }, + } + + out := map[string]*db.Product{} + for _, s := range specs { + imgName := s.slug + ".png" + imgStored := randHex(16) + ".png" + must(os.WriteFile(filepath.Join(imagesDir, imgStored), thumbnail(s.slug, s.name), 0o644), "write thumb") + + var filePath, fileName string + if s.licenseType != db.LicenseNone || s.slug == "asciicast" { + fileName = s.slug + "-v1.0.0.zip" + filePath = randHex(16) + ".zip" + must(os.WriteFile(filepath.Join(filesDir, filePath), + []byte(fmt.Sprintf("demo build of %s — not a real archive\n", s.name)), 0o644), "write file") + } + + p := &db.Product{ + Slug: s.slug, Name: s.name, Description: s.desc, + PriceCents: s.dollars * 100, Currency: "USD", + FileSource: "local", FilePath: filePath, FileName: fileName, + Active: true, Unlisted: s.unlisted, DownloadWindowHours: 72, + LicenseType: s.licenseType, ImagePath: imgStored, ImageName: imgName, + } + if s.seats > 0 { + p.LicenseSeats = db.NullInt(s.seats) + } + if s.expiryDays > 0 { + p.LicenseExpiryDays = db.NullInt(s.expiryDays) + } + must(d.CreateProduct(p), "create product "+s.slug) + + if len(s.plans) > 0 { + var plans []*db.LicensePlan + for _, pl := range s.plans { + plans = append(plans, &db.LicensePlan{Label: pl.label, PeriodDays: pl.days, PriceCents: pl.dollars * 100}) + } + must(d.ReplacePlans(p.ID, plans), "plans "+s.slug) + } + out[s.slug] = p + fmt.Printf(" product %-14s id=%d\n", s.slug, p.ID) + } + return out +} + +// ---- orders + keys + activations ---- + +type orderSpec struct { + slug string + asset string // BTC, ETH, USDC, LTC, XRP, XLM + status string + email string + daysAgo int + periodDays int64 // chosen plan period (key expiry); 0 = perpetual + confs int64 // for "confirming" + amount string // crypto amount expected (decimal string) + rate string // USD rate at quote + priceCents int64 + received string // for underpaid/overpaid + overpaid bool + revoked bool // revoke the issued key + expiredKey bool // issue a key already expired + activations []string // HWID labels for activation-type keys +} + +func seedOrdersAndKeys(d *db.DB, lseed []byte, products map[string]*db.Product) { + priv := license.PrivFromSeed(lseed) + specs := []orderSpec{ + // Fulfilled — the bread and butter, across coins/products. + {slug: "hedge-cli", asset: "BTC", status: db.StatusFulfilled, email: "ada@example.com", daysAgo: 18, periodDays: 0, amount: "0.00081120", rate: "61050.00", priceCents: 9900}, + {slug: "pixelsmith", asset: "ETH", status: db.StatusFulfilled, email: "grace@example.com", daysAgo: 12, periodDays: 365, amount: "0.0193", rate: "3340.00", priceCents: 5900, activations: []string{"Grace's iMac", "Studio Wacom PC"}}, + {slug: "conduit-mock", asset: "USDC", status: db.StatusFulfilled, email: "linus@example.com", daysAgo: 9, periodDays: 0, amount: "49.00", rate: "1.00", priceCents: 4900}, + {slug: "sentry-tail", asset: "BTC", status: db.StatusFulfilled, email: "ops@acme.io", daysAgo: 7, periodDays: 365, amount: "0.00311300", rate: "61050.00", priceCents: 19000, activations: []string{"ci-runner-01", "ci-runner-02", "bastion", "dev-laptop", "grafana-box"}}, + {slug: "hyperlint-pro", asset: "LTC", status: db.StatusFulfilled, email: "margaret@example.com", daysAgo: 5, periodDays: 365, amount: "0.642", rate: "70.10", priceCents: 4500}, + // A fulfilled key we then revoke (chargeback / abuse demo). + {slug: "hedge-cli", asset: "BTC", status: db.StatusFulfilled, email: "refunded@example.com", daysAgo: 21, periodDays: 365, amount: "0.00063880", rate: "61050.00", priceCents: 3900, revoked: true}, + // A fulfilled key that has already expired (subscription lapsed). + {slug: "sentry-tail", asset: "ETH", status: db.StatusFulfilled, email: "trial@example.com", daysAgo: 40, periodDays: 30, amount: "0.0057", rate: "3340.00", priceCents: 1900, expiredKey: true, activations: []string{"old-laptop"}}, + // Paid, awaiting fulfillment (watcher will mint on next tick in real life). + {slug: "pixelsmith", asset: "USDC", status: db.StatusPaid, email: "newbuyer@example.com", daysAgo: 0, periodDays: 0, amount: "129.00", rate: "1.00", priceCents: 12900}, + // In-flight payment states. + {slug: "conduit-mock", asset: "BTC", status: db.StatusConfirming, email: "waiting@example.com", daysAgo: 0, confs: 1, amount: "0.00080260", rate: "61050.00", priceCents: 4900}, + {slug: "hedge-cli", asset: "BTC", status: db.StatusPending, email: "alice@example.com", daysAgo: 0, amount: "0.00063880", rate: "61050.00", priceCents: 3900}, + {slug: "hyperlint-pro", asset: "XRP", status: db.StatusPending, email: "bob@example.com", daysAgo: 0, amount: "84.50", rate: "0.5325", priceCents: 4500}, + {slug: "sentry-tail", asset: "BTC", status: db.StatusUnderpaid, email: "short@example.com", daysAgo: 0, amount: "0.00311300", rate: "61050.00", priceCents: 19000, received: "0.00150000"}, + // Terminal non-success states. + {slug: "pixelsmith", asset: "ETH", status: db.StatusExpired, email: "gone@example.com", daysAgo: 2, amount: "0.0177", rate: "3340.00", priceCents: 5900}, + {slug: "conduit-mock", asset: "USDC", status: db.StatusCancelled, email: "cancelled@example.com", daysAgo: 3, amount: "49.00", rate: "1.00", priceCents: 4900}, + } + + idx := uint32(0) + keys := 0 + for _, s := range specs { + p := products[s.slug] + if p == nil { + log.Fatalf("order references unknown product %q", s.slug) + } + asset := s.asset + idx++ + addr, tag := payAddress(d, asset, idx) + + now := time.Now() + // In-flight orders must keep a FUTURE expiry, or the watcher expires them + // on its next tick (it leaves 0-funded, unexpired orders in place). + created := now.AddDate(0, 0, -s.daysAgo).Add(-3 * time.Hour) + expires := created.Add(time.Hour) + quote := created.Add(15 * time.Minute) + if inflight(s.status) { + created = now.Add(-7 * time.Minute) + expires = now.Add(53 * time.Minute) + quote = now.Add(8 * time.Minute) + } + o := &db.Order{ + PublicID: randHex(8), ProductID: p.ID, Status: s.status, + Asset: asset, Chain: chainOf(asset), PriceCents: s.priceCents, Currency: "USD", + AmountExpected: s.amount, RateUSD: s.rate, PayAddress: addr, PayTag: tag, + DerivationIndex: db.NullInt(int64(idx)), Email: s.email, + ExpiresAt: db.NullTime(expires), + QuoteExpiresAt: db.NullTime(quote), + } + if s.periodDays > 0 { + o.LicensePeriodDays = db.NullInt(s.periodDays) + } + must(d.CreateOrder(o), "create order") + exec(d, `UPDATE orders SET created_at=? WHERE id=?`, db.FormatTime(created), o.ID) + + switch s.status { + case db.StatusConfirming: + exec(d, `UPDATE orders SET amount_received=?, confirmations=?, txid=? WHERE id=?`, + s.amount, s.confs, randHex(32), o.ID) + case db.StatusUnderpaid: + exec(d, `UPDATE orders SET amount_received=?, txid=? WHERE id=?`, s.received, randHex(32), o.ID) + case db.StatusPaid: + exec(d, `UPDATE orders SET amount_received=?, confirmations=3, paid_at=?, txid=? WHERE id=?`, + s.amount, db.FormatTime(created.Add(20*time.Minute)), randHex(32), o.ID) + case db.StatusFulfilled: + paid := created.Add(20 * time.Minute) + exec(d, `UPDATE orders SET amount_received=?, confirmations=6, paid_at=?, overpaid=?, txid=? WHERE id=?`, + s.amount, db.FormatTime(paid), boolToInt(s.overpaid), randHex(32), o.ID) + + issued := paid + var expiresAt sql.NullString + if p.IssuesKey() { + claims := license.Claims{Product: p.Slug, Order: o.PublicID, Issued: issued.Unix()} + period := s.periodDays + if s.expiredKey { + // Force an already-lapsed key regardless of plan length. + issued = now.AddDate(0, 0, -400) + claims.Issued = issued.Unix() + period = 30 + } + if period > 0 { + exp := issued.AddDate(0, 0, int(period)) + claims.Expires = exp.Unix() + expiresAt = db.NullTime(exp) + } + if p.LicenseSeats.Valid { + claims.Seats = int(p.LicenseSeats.Int64) + } + tok, err := license.Sign(priv, claims) + must(err, "sign key") + k := &db.LicenseKey{OrderID: o.ID, ProductID: p.ID, Key: tok, ExpiresAt: expiresAt, Seats: p.LicenseSeats} + must(d.CreateLicenseKey(k), "create key") + keys++ + if s.revoked { + must(d.SetKeyRevoked(k.ID, true), "revoke") + } + for _, label := range s.activations { + must(d.CreateActivation(&db.Activation{LicenseKeyID: k.ID, HWID: "hwid-" + randHex(8), Label: label}), "activation") + } + } + dlExp := paid.Add(time.Duration(p.DownloadWindowHours) * time.Hour) + must(d.MarkFulfilled(o.ID, randHex(20), db.FormatTime(dlExp)), "fulfill") + } + fmt.Printf(" order %-10s %-9s %-5s -> %s\n", o.PublicID, s.status, asset, s.slug) + } + fmt.Printf(" issued %d license keys\n", keys) +} + +// payAddress derives a realistic receive address (and tag, for account chains) +// for the given asset from the demo wallet stored in settings. +func payAddress(d *db.DB, asset string, idx uint32) (addr, tag string) { + switch asset { + case "BTC": + a, err := wallet.DeriveUTXOAddress(getSet(d, "bitcoin_xpub"), idx, "bitcoin") + must(err, "btc addr") + return a, "" + case "LTC": + a, err := wallet.DeriveUTXOAddress(getSet(d, "litecoin_xpub"), idx, "litecoin") + must(err, "ltc addr") + return a, "" + case "ETH", "USDC": + a, err := wallet.DeriveEVMAddress(getSet(d, "evm_xpub"), idx) + must(err, "evm addr") + return a, "" + case "XRP": + x, err := wallet.XRPXAddress(getSet(d, "xrpl_xpub"), idx, true) + must(err, "xrp xaddr") + return x, fmt.Sprintf("%d", idx) + case "XLM": + m, err := wallet.XLMMuxedAddress(getSet(d, "stellar_xpub"), uint64(idx)) + must(err, "xlm muxed") + return m, fmt.Sprintf("%d", idx) + } + return "demo-address", "" +} + +// inflight reports whether the watcher still polls this order (so it needs a +// future expiry to survive on screen). +func inflight(status string) bool { + switch status { + case db.StatusPending, db.StatusDetected, db.StatusConfirming, db.StatusUnderpaid: + return true + } + return false +} + +func chainOf(asset string) string { + switch asset { + case "BTC": + return "bitcoin" + case "LTC": + return "litecoin" + case "ETH": + return "ethereum" + case "USDC": + return "base" + case "XRP": + return "xrpl" + case "XLM": + return "stellar" + } + return "" +} + +// ---- thumbnail generator (pure Go, no fonts): a per-product duotone gradient +// with a stylised "terminal window" motif — on-theme for dev tools. ---- + +func thumbnail(slug, name string) []byte { + const w, h = 1200, 750 + img := image.NewRGBA(image.Rect(0, 0, w, h)) + + pairs := [][2]color.RGBA{ + {{11, 61, 58, 255}, {44, 232, 160, 255}}, // teal + {{30, 27, 75, 255}, {129, 140, 248, 255}}, // indigo + {{59, 47, 11, 255}, {244, 183, 62, 255}}, // amber + {{59, 11, 34, 255}, {251, 113, 133, 255}}, // rose + {{8, 48, 59, 255}, {34, 211, 238, 255}}, // cyan + {{42, 11, 59, 255}, {192, 132, 252, 255}}, // violet + } + hsh := fnv.New32a() + hsh.Write([]byte(slug)) + pr := pairs[int(hsh.Sum32())%len(pairs)] + a, b := pr[0], pr[1] + + // diagonal gradient + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + t := float64(x+y) / float64(w+h) + img.SetRGBA(x, y, lerp(a, b, t)) + } + } + + // terminal window panel + pw, ph := int(float64(w)*0.66), int(float64(h)*0.6) + px, py := (w-pw)/2, (h-ph)/2+10 + panel := color.RGBA{13, 18, 16, 245} + roundRect(img, px, py, pw, ph, 26, panel) + // title bar + roundRectTop(img, px, py, pw, 56, 26, color.RGBA{20, 28, 25, 255}) + // traffic-light dots + dot(img, px+30, py+28, 9, color.RGBA{255, 95, 86, 255}) + dot(img, px+58, py+28, 9, color.RGBA{255, 189, 46, 255}) + dot(img, px+86, py+28, 9, color.RGBA{39, 201, 63, 255}) + // "code" lines (accent + muted) + muted := color.RGBA{58, 74, 68, 255} + lineY := py + 92 + for i, frac := range []float64{0.55, 0.40, 0.72, 0.30, 0.6} { + c := muted + if i%2 == 0 { + c = b + } + fillRect(img, px+34, lineY, int(float64(pw-68)*frac), 14, c) + lineY += 30 + } + + var buf pngBuffer + if err := png.Encode(&buf, img); err != nil { + log.Fatalf("png: %v", err) + } + return buf.Bytes() +} + +func lerp(a, b color.RGBA, t float64) color.RGBA { + if t < 0 { + t = 0 + } else if t > 1 { + t = 1 + } + li := func(x, y uint8) uint8 { return uint8(float64(x) + (float64(y)-float64(x))*t) } + return color.RGBA{li(a.R, b.R), li(a.G, b.G), li(a.B, b.B), 255} +} + +func fillRect(img *image.RGBA, x, y, w, h int, c color.RGBA) { + for j := y; j < y+h; j++ { + for i := x; i < x+w; i++ { + img.SetRGBA(i, j, c) + } + } +} + +func roundRect(img *image.RGBA, x, y, w, h, r int, c color.RGBA) { + for j := y; j < y+h; j++ { + for i := x; i < x+w; i++ { + if inRounded(i, j, x, y, w, h, r) { + img.SetRGBA(i, j, c) + } + } + } +} + +// roundRectTop draws a rectangle with only the top corners rounded. +func roundRectTop(img *image.RGBA, x, y, w, h, r int, c color.RGBA) { + for j := y; j < y+h; j++ { + for i := x; i < x+w; i++ { + topLeft := i < x+r && j < y+r && dist(i, j, x+r, y+r) > float64(r) + topRight := i > x+w-r && j < y+r && dist(i, j, x+w-r, y+r) > float64(r) + if !topLeft && !topRight { + img.SetRGBA(i, j, c) + } + } + } +} + +func inRounded(i, j, x, y, w, h, r int) bool { + switch { + case i < x+r && j < y+r: + return dist(i, j, x+r, y+r) <= float64(r) + case i > x+w-r && j < y+r: + return dist(i, j, x+w-r, y+r) <= float64(r) + case i < x+r && j > y+h-r: + return dist(i, j, x+r, y+h-r) <= float64(r) + case i > x+w-r && j > y+h-r: + return dist(i, j, x+w-r, y+h-r) <= float64(r) + } + return true +} + +func dot(img *image.RGBA, cx, cy, r int, c color.RGBA) { + for j := cy - r; j <= cy+r; j++ { + for i := cx - r; i <= cx+r; i++ { + if dist(i, j, cx, cy) <= float64(r) { + img.SetRGBA(i, j, c) + } + } + } +} + +func dist(x1, y1, x2, y2 int) float64 { + dx, dy := float64(x1-x2), float64(y1-y2) + return math.Sqrt(dx*dx + dy*dy) +} + +// ---- small helpers ---- + +// pngBuffer is a tiny io.Writer-backed buffer (avoids importing bytes twice). +type pngBuffer struct{ b []byte } + +func (p *pngBuffer) Write(d []byte) (int, error) { p.b = append(p.b, d...); return len(d), nil } +func (p *pngBuffer) Bytes() []byte { return p.b } + +func set(d *db.DB, k, v string) { must(d.SetSetting(k, v), "set "+k) } + +func getSet(d *db.DB, k string) string { + v, ok, err := d.GetSetting(k) + must(err, "get "+k) + if !ok { + log.Fatalf("missing setting %q", k) + } + return v +} + +func exec(d *db.DB, q string, args ...any) { + if _, err := d.Exec(q, args...); err != nil { + log.Fatalf("exec %q: %v", q, err) + } +} + +func count(d *db.DB, q string) int { + var n int + if err := d.QueryRow(q).Scan(&n); err != nil { + log.Fatalf("count: %v", err) + } + return n +} + +func randHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + log.Fatalf("rand: %v", err) + } + return hex.EncodeToString(b) +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func must(err error, ctx string) { + if err != nil { + log.Fatalf("%s: %v", ctx, err) + } +} diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..d245e1a --- /dev/null +++ b/demo/README.md @@ -0,0 +1,174 @@ +# neverpay — guided demo + +A complete walkthrough of neverpay from both sides of the counter: the **buyer** +purchasing a licensed download with crypto, and the **seller** running the store. + +Every screenshot below is from a real, running instance seeded with the demo +store **"Lumen Tools"** — a fictional indie software studio selling developer +tools. The data spans all 7 supported coins, every license type, and every order +state, so nothing here is mocked up. + +## Run this demo yourself + +The demo data is generated by a small tool that uses the real database, wallet, +and signing code — so it's identical to a live instance, just pre-populated. + +```sh +# 1. Build the binary and the seeder +make build +go build -o ./neverpay-seeddemo ./cmd/seeddemo + +# 2. Seed a FRESH data dir (products, orders, keys, webhooks, a demo HD wallet) +NEVERPAY_DATA_DIR=./demo-data ./neverpay-seeddemo + +# 3. Run the server against it +NEVERPAY_DATA_DIR=./demo-data NEVERPAY_ADDR=:8099 \ + NEVERPAY_BASE_URL=http://localhost:8099 \ + NEVERPAY_ADMIN_PASSWORD=demo-admin-passphrase \ + NEVERPAY_ETH_RPC=https://ethereum-rpc.publicnode.com \ + NEVERPAY_BASE_RPC=https://base-rpc.publicnode.com \ + ./neverpay +``` + +> The two `*_RPC` vars are optional — they just let the EVM chains (ETH, USDC) +> report as reachable on the status page. BTC/LTC/USDT/XRP/XLM use built-in +> public endpoints out of the box. + +Then open . The admin console is at + — password **`demo-admin-passphrase`**. + +> The seeded wallet is a throwaway generated on the spot; never send real funds +> to the demo addresses. The pending orders show real, correctly-derived +> receive addresses purely so the payment page looks authentic. + +--- + +## Customer journey + +### 1. Storefront + +The public catalog. Listed products show a thumbnail, name, and price. Unlisted +products (sold via direct link only) are hidden here. + +![Storefront](screenshots/01-storefront.png) + +### 2. Product page + +Each product has its own page with a description and — if it offers multiple +durations — a list of license plans. A discreet **admin** link lives in the +footer so the operator can always find the console. + +![Product page](screenshots/02-product.png) + +### 3. Checkout + +The buyer picks a license plan (which sets the price *and* the key's validity +period) and a coin. The chosen coin doesn't change the price — neverpay quotes +the crypto amount live at the current rate. No account, no card, no KYC. + +![Checkout — pick a plan and a coin](screenshots/03-checkout.png) + +### 4. Payment page (awaiting payment) + +The buyer is shown **one unique pay-to address** for their order, the exact +amount, and a QR code. neverpay watches the chain and advances the order through +`pending → detected → confirming → paid` automatically. + +![Payment page — BTC, pending](screenshots/04-payment-pending.png) + +For the account-based chains (XRP, XLM) there's no fresh address per order, so +neverpay encodes the order's tag/memo directly into an **X-address / muxed +address** — the buyer still just pastes one string, no separate memo field to +get wrong. + +![Payment page — XRP X-address](screenshots/05-payment-xrp.png) + +### 5. Fulfilled — download + license key + +Once the payment confirms, the order flips to **fulfilled**: the buyer gets a +time-windowed download link and, for licensed products, their signed +`NVPAY1.…` license key. The same details are emailed as a receipt. + +![Fulfilled order — download and license key](screenshots/06-order-fulfilled.png) + +That key is **offline-verifiable** with the store's public key alone (see the +`sdk/` folder and the in-admin *Integrate* page) — the buyer's software can +check it with no phone-home, which is what makes it hard to tamper with. + +--- + +## Admin journey + +### 6. Login + +The console is protected by a bcrypt-hashed password, server-side sessions, and +a per-IP brute-force throttle (escalating, auto-expiring cooldown). + +![Admin login](screenshots/10-admin-login.png) + +### 7. Dashboard + +At-a-glance product count and a backup reminder — the SQLite database holds your +license **signing seed**, so backing it up matters. + +![Dashboard](screenshots/11-admin-dashboard.png) + +### 8. Products + +Create, edit, list, or unlist products. Each can be a plain file download, a +signed-key product, or an online-activation product with a seat limit. + +![Products list](screenshots/12-admin-products.png) + +### 9. Edit a product (license type + plans) + +The license section sets the type, optional seat limit, and the **license +plans** — the priced durations buyers choose at checkout (leave empty for a +single key at the product price). + +![Edit product — license and plans](screenshots/13-admin-product-edit.png) + +### 10. Orders + +Every order with its status, coin, amount, and buyer email. The demo includes +fulfilled, paid, confirming, pending, underpaid, expired, and cancelled orders. + +![Orders list](screenshots/14-admin-orders.png) + +### 11. License keys + +Issued keys with their product, order, seat usage, and revocation state. Revoke +a key here (e.g. on a chargeback) and the hosted verify/activate API rejects it +immediately; offline verification still works for un-revoked keys. + +![License keys](screenshots/15-admin-keys.png) + +### 12. Integrate your software + +A copy-paste integration page: the store's public key and API base URL, ready- +to-paste SDK snippets (Go · JS · Python · C), the HTTP API reference, the token +format, and the per-product licensing matrix. + +![Integrate](screenshots/16-admin-integration.png) + +### 13. Status / health + +A live operability check: per-chain RPC/explorer reachability and block height, +the signing key, email sender, webhook endpoints, base-URL/HTTPS sanity, and +active login-throttling — everything you need to confirm a self-hosted instance +is healthy without spending real funds. + +![Status](screenshots/17-admin-status.png) + +### 14. Settings + +Branding (store name, accent, logo), a test-email button, and webhook endpoint +management with per-endpoint test delivery. + +![Settings](screenshots/18-admin-settings.png) + +--- + +Built as one static Go binary — embedded SQLite, embedded UI, no runtime, no +separate database. See the top-level [README](../README.md), [DEPLOY.md](../DEPLOY.md), +and [SECURITY.md](../SECURITY.md). diff --git a/demo/screenshots/01-storefront.png b/demo/screenshots/01-storefront.png new file mode 100644 index 0000000..8e65816 Binary files /dev/null and b/demo/screenshots/01-storefront.png differ diff --git a/demo/screenshots/02-product.png b/demo/screenshots/02-product.png new file mode 100644 index 0000000..731715b Binary files /dev/null and b/demo/screenshots/02-product.png differ diff --git a/demo/screenshots/03-checkout.png b/demo/screenshots/03-checkout.png new file mode 100644 index 0000000..3f80f0d Binary files /dev/null and b/demo/screenshots/03-checkout.png differ diff --git a/demo/screenshots/04-payment-pending.png b/demo/screenshots/04-payment-pending.png new file mode 100644 index 0000000..429c9e2 Binary files /dev/null and b/demo/screenshots/04-payment-pending.png differ diff --git a/demo/screenshots/05-payment-xrp.png b/demo/screenshots/05-payment-xrp.png new file mode 100644 index 0000000..b9721e4 Binary files /dev/null and b/demo/screenshots/05-payment-xrp.png differ diff --git a/demo/screenshots/06-order-fulfilled.png b/demo/screenshots/06-order-fulfilled.png new file mode 100644 index 0000000..04ca61e Binary files /dev/null and b/demo/screenshots/06-order-fulfilled.png differ diff --git a/demo/screenshots/10-admin-login.png b/demo/screenshots/10-admin-login.png new file mode 100644 index 0000000..96ffef1 Binary files /dev/null and b/demo/screenshots/10-admin-login.png differ diff --git a/demo/screenshots/11-admin-dashboard.png b/demo/screenshots/11-admin-dashboard.png new file mode 100644 index 0000000..ee6d0a6 Binary files /dev/null and b/demo/screenshots/11-admin-dashboard.png differ diff --git a/demo/screenshots/12-admin-products.png b/demo/screenshots/12-admin-products.png new file mode 100644 index 0000000..6365e98 Binary files /dev/null and b/demo/screenshots/12-admin-products.png differ diff --git a/demo/screenshots/13-admin-product-edit.png b/demo/screenshots/13-admin-product-edit.png new file mode 100644 index 0000000..2454289 Binary files /dev/null and b/demo/screenshots/13-admin-product-edit.png differ diff --git a/demo/screenshots/14-admin-orders.png b/demo/screenshots/14-admin-orders.png new file mode 100644 index 0000000..b3a26e9 Binary files /dev/null and b/demo/screenshots/14-admin-orders.png differ diff --git a/demo/screenshots/15-admin-keys.png b/demo/screenshots/15-admin-keys.png new file mode 100644 index 0000000..47477d0 Binary files /dev/null and b/demo/screenshots/15-admin-keys.png differ diff --git a/demo/screenshots/16-admin-integration.png b/demo/screenshots/16-admin-integration.png new file mode 100644 index 0000000..8515a33 Binary files /dev/null and b/demo/screenshots/16-admin-integration.png differ diff --git a/demo/screenshots/17-admin-status.png b/demo/screenshots/17-admin-status.png new file mode 100644 index 0000000..7980369 Binary files /dev/null and b/demo/screenshots/17-admin-status.png differ diff --git a/demo/screenshots/18-admin-settings.png b/demo/screenshots/18-admin-settings.png new file mode 100644 index 0000000..87289ea Binary files /dev/null and b/demo/screenshots/18-admin-settings.png differ diff --git a/docs/screenshots/checkout.png b/docs/screenshots/checkout.png index 76a860c..429c9e2 100644 Binary files a/docs/screenshots/checkout.png and b/docs/screenshots/checkout.png differ diff --git a/docs/screenshots/license.png b/docs/screenshots/license.png index 926b4e1..04ca61e 100644 Binary files a/docs/screenshots/license.png and b/docs/screenshots/license.png differ diff --git a/docs/screenshots/plans.png b/docs/screenshots/plans.png index 36c3ecd..3f80f0d 100644 Binary files a/docs/screenshots/plans.png and b/docs/screenshots/plans.png differ diff --git a/docs/screenshots/storefront.png b/docs/screenshots/storefront.png index 6edcc56..8e65816 100644 Binary files a/docs/screenshots/storefront.png and b/docs/screenshots/storefront.png differ diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 91289e3..4648c28 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -3,6 +3,8 @@ package auth import ( "crypto/rand" "encoding/hex" + "fmt" + "log" "net/http" "time" @@ -17,8 +19,16 @@ const ( adminHashSetting = "admin_password_hash" sqliteTimeLayout = "2006-01-02 15:04:05" generatedPwdBytes = 12 + bcryptCost = 12 // raise the per-guess cost over bcrypt.DefaultCost (10) ) +// dummyHash is a fixed, valid cost-12 bcrypt hash. When no admin password is +// configured yet, Login still compares against it so the "no hash set" path +// takes the same time as a wrong-password path — closing a configured/ +// unconfigured timing oracle. It hashes a throwaway string; nothing verifies +// against it successfully. +const dummyHash = "$2a$12$0jtM3GlJjPWJ2/zKsUKRy.I4xjiG10OFpOnBceUn5mSWMh2uDmUbG" + // Auth handles admin authentication and session management. type Auth struct { DB *db.DB @@ -30,7 +40,22 @@ type Auth struct { // generates a random one and returns it so the caller can surface it to the // operator. Returns "" when no new password was generated. func EnsureAdmin(d *db.DB, envPwd string) (generated string, err error) { + _, hashExists, gerr := d.GetSetting(adminHashSetting) + if gerr != nil { + return "", gerr + } + if envPwd != "" { + if verr := ValidatePassword(envPwd); verr != nil { + // Never brick an established install: if a working admin password is + // already set, keep it and just refuse to (re)set from a now + // non-compliant env value. The policy is still enforced on first run. + if hashExists { + log.Printf("warning: NEVERPAY_ADMIN_PASSWORD does not meet the password policy (%v); keeping the existing admin password — set a compliant one to change it", verr) + return "", nil + } + return "", fmt.Errorf("NEVERPAY_ADMIN_PASSWORD rejected: %w", verr) + } hash, herr := hashPassword(envPwd) if herr != nil { return "", herr @@ -38,9 +63,7 @@ func EnsureAdmin(d *db.DB, envPwd string) (generated string, err error) { return "", d.SetSetting(adminHashSetting, hash) } - if _, ok, gerr := d.GetSetting(adminHashSetting); gerr != nil { - return "", gerr - } else if ok { + if hashExists { return "", nil // already configured } @@ -62,7 +85,13 @@ func (a *Auth) Login(w http.ResponseWriter, password string) (bool, error) { if err != nil { return false, err } - if !ok || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { + if !ok { + // No admin configured yet: still spend a bcrypt comparison so this path + // is timing-indistinguishable from a wrong password (no config oracle). + _ = bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(password)) + return false, nil + } + if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { return false, nil } @@ -121,7 +150,7 @@ func (a *Auth) authed(r *http.Request) bool { } func hashPassword(pw string) (string, error) { - b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + b, err := bcrypt.GenerateFromPassword([]byte(pw), bcryptCost) return string(b), err } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..e3ec6bc --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,94 @@ +package auth + +import ( + "net/http/httptest" + "path/filepath" + "testing" + + "neverpay/internal/db" +) + +func testDB(t *testing.T) *db.DB { + t.Helper() + d, err := db.Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + return d +} + +func TestEnsureAdminRejectsWeakEnvPassword(t *testing.T) { + d := testDB(t) + if _, err := EnsureAdmin(d, "weak"); err == nil { + t.Fatal("EnsureAdmin should reject a weak env password") + } + // Nothing should have been stored. + if _, ok, _ := d.GetSetting(adminHashSetting); ok { + t.Fatal("a rejected password must not be persisted") + } +} + +func TestEnsureAdminAcceptsStrongEnvPassword(t *testing.T) { + d := testDB(t) + gen, err := EnsureAdmin(d, "a-strong-operator-passphrase") + if err != nil { + t.Fatalf("strong password rejected: %v", err) + } + if gen != "" { + t.Fatal("env password should not return a generated one") + } + a := &Auth{DB: d} + w := httptest.NewRecorder() + if ok, _ := a.Login(w, "a-strong-operator-passphrase"); !ok { + t.Fatal("correct password should log in") + } + w2 := httptest.NewRecorder() + if ok, _ := a.Login(w2, "wrong-password-entirely"); ok { + t.Fatal("wrong password must not log in") + } +} + +// An established install (hash already set) must not be bricked by a now +// non-compliant NEVERPAY_ADMIN_PASSWORD: EnsureAdmin keeps the working hash and +// returns no error rather than aborting boot. +func TestEnsureAdminKeepsExistingHashOnWeakEnv(t *testing.T) { + d := testDB(t) + if _, err := EnsureAdmin(d, "a-strong-operator-passphrase"); err != nil { + t.Fatalf("initial strong set failed: %v", err) + } + if _, err := EnsureAdmin(d, "weak"); err != nil { + t.Fatalf("weak env with an existing hash must not error, got %v", err) + } + // The original password still works (hash was not overwritten or cleared). + a := &Auth{DB: d} + w := httptest.NewRecorder() + if ok, _ := a.Login(w, "a-strong-operator-passphrase"); !ok { + t.Fatal("existing admin password should still log in after a weak env reset attempt") + } +} + +func TestEnsureAdminGeneratesStrongDefault(t *testing.T) { + d := testDB(t) + gen, err := EnsureAdmin(d, "") + if err != nil { + t.Fatal(err) + } + if gen == "" { + t.Fatal("expected a generated password when none configured") + } + if err := ValidatePassword(gen); err != nil { + t.Fatalf("generated password should satisfy the policy: %v", err) + } +} + +// Login against an unconfigured store must not panic and must return false +// (the dummy-compare path). +func TestLoginNoAdminConfigured(t *testing.T) { + d := testDB(t) + a := &Auth{DB: d} + w := httptest.NewRecorder() + if ok, err := a.Login(w, "anything"); ok || err != nil { + t.Fatalf("unconfigured login want (false,nil), got (%v,%v)", ok, err) + } +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..79730e5 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,85 @@ +package auth + +import ( + "fmt" + "strings" +) + +// MinPasswordLen is the floor for an admin password. Short or common passwords +// are the only credential weak enough to be brute-forceable online (the +// auto-generated default is 96-bit and infeasible), so the policy targets them. +const MinPasswordLen = 12 + +// commonPasswords is a small, embedded blocklist of predictable choices. The +// length floor already rejects the classic short ones ("password", "qwerty", +// "admin"), so this focuses on common choices that clear 12 characters, plus +// product-obvious guesses. Compared case-insensitively against the whole +// password; deliberately kept short and dependency-free. +var commonPasswords = map[string]bool{ + "password1234": true, + "passwordpassword": true, + "123456789012": true, + "1234567890123": true, + "qwertyuiop12": true, + "qwertyuiop123": true, + "qwerty123456": true, + "administrator": true, + "adminadmin12": true, + "letmein12345": true, + "iloveyou1234": true, + "welcome12345": true, + "changeme1234": true, + "trustno1trust": true, + "password12345": true, + "abcdefghijkl": true, + "aaaaaaaaaaaa": true, + "000000000000": true, + "111111111111": true, + "monkey123456": true, + "dragon123456": true, + "superman1234": true, + "baseball1234": true, + "football1234": true, + "sunshine1234": true, + "princess1234": true, + "qazwsxedcrfv": true, + "1qaz2wsx3edc": true, + "passw0rd1234": true, +} + +// ValidatePassword enforces the admin-password policy: a length floor, a +// common-password blocklist, and rejection of degenerate strings (a single +// repeated character, or the product name as the password). It is the single +// shared policy used by both interactive setup and EnsureAdmin. +func ValidatePassword(pw string) error { + // Measure the meaningful content: trailing/leading whitespace must not pad a + // trivial secret up to the floor. + if len(strings.TrimSpace(pw)) < MinPasswordLen { + return fmt.Errorf("use at least %d characters", MinPasswordLen) + } + low := strings.ToLower(strings.TrimSpace(pw)) + if commonPasswords[low] { + return fmt.Errorf("that password is too common — pick something less guessable") + } + if strings.Contains(low, "neverpay") { + return fmt.Errorf("don't put the product name in the password") + } + if isSingleRune(pw) { + return fmt.Errorf("don't use a single repeated character") + } + return nil +} + +// isSingleRune reports whether s is one character repeated (e.g. "aaaaaaaaaaaa"). +func isSingleRune(s string) bool { + if s == "" { + return false + } + rs := []rune(s) + for _, r := range rs[1:] { + if r != rs[0] { + return false + } + } + return true +} diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 0000000..a514a38 --- /dev/null +++ b/internal/auth/password_test.go @@ -0,0 +1,42 @@ +package auth + +import ( + "strings" + "testing" +) + +func TestValidatePassword(t *testing.T) { + cases := []struct { + name string + pw string + ok bool + }{ + {"too short", "short123", false}, + {"exactly min ok", "abcdefghij12", true}, + {"long passphrase ok", "correct-horse-battery-staple", true}, + {"common long", "passwordpassword", false}, + {"common numeric", "123456789012", false}, + {"single repeated rune", "aaaaaaaaaaaa", false}, + {"contains product name", "neverpay-store-1", false}, + {"contains product name caps", "MyNeverPayAdmin99", false}, + {"empty", "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := ValidatePassword(c.pw) + if c.ok && err != nil { + t.Fatalf("expected %q to be valid, got %v", c.pw, err) + } + if !c.ok && err == nil { + t.Fatalf("expected %q to be rejected", c.pw) + } + }) + } +} + +func TestValidatePasswordMessageMentionsLength(t *testing.T) { + err := ValidatePassword("ab") + if err == nil || !strings.Contains(err.Error(), "characters") { + t.Fatalf("short-password error should mention length, got %v", err) + } +} diff --git a/internal/auth/throttle.go b/internal/auth/throttle.go new file mode 100644 index 0000000..b382f4c --- /dev/null +++ b/internal/auth/throttle.go @@ -0,0 +1,190 @@ +package auth + +import ( + "crypto/rand" + "math/big" + "sync" + "time" +) + +// DefaultFailDelay is the base delay applied on the failed-login path. It keeps +// response timing flat (independent of which internal branch failed) and caps +// the online guess rate to roughly one per second even before the cooldown +// threshold trips. Tests construct a throttle with a zero delay. +const DefaultFailDelay = 750 * time.Millisecond + +// throttle policy. softLimit consecutive failures are answered immediately (a +// human fat-fingering their password is not punished); beyond it, an escalating +// but hard-capped cooldown kicks in. Everything is per-IP and in-memory. +const ( + softLimit = 5 + decayAfter = 15 * time.Minute // idle window after which an IP's fail count resets + maxCooldown = 15 * time.Minute // hard ceiling on any single cooldown — never "permanent" + maxEntries = 4096 // eviction trigger, mirrors the web request limiter +) + +// backoffSteps are the cooldown durations for the 1st, 2nd, … failure PAST the +// soft limit. After they run out, maxCooldown applies. +var backoffSteps = []time.Duration{ + 5 * time.Second, + 15 * time.Second, + 60 * time.Second, + 300 * time.Second, +} + +type entry struct { + fails int + cooldownUntil time.Time + lastSeen time.Time +} + +// LoginThrottle is a per-IP failed-login throttle: it counts consecutive +// failures per client IP, escalates a capped cooldown past a soft limit, and +// wipes all penalty for an IP the instant a correct password is seen. +// +// Operator-safety is structural, not incidental: state is keyed by IP (an +// attacker hammering from their own IP only cools down their own IP, never the +// operator's), cooldowns are hard-capped and auto-expire on the wall clock with +// no manual unlock, and a correct password Resets the IP immediately. There is +// deliberately NO global/account-wide lock — that would let a single attacker +// deny the lone admin. See SECURITY.md. +type LoginThrottle struct { + mu sync.Mutex + byIP map[string]*entry + failDelay time.Duration +} + +// NewLoginThrottle builds a throttle. failDelay is the base failed-path delay +// (use auth.DefaultFailDelay in production, 0 in tests). +func NewLoginThrottle(failDelay time.Duration) *LoginThrottle { + return &LoginThrottle{byIP: map[string]*entry{}, failDelay: failDelay} +} + +// Allow reports whether ip may attempt a login now. While cooling down it +// returns (false, remaining). A long-idle entry decays back to zero so an +// operator who walked away always starts clean. +func (t *LoginThrottle) Allow(ip string, now time.Time) (ok bool, retryAfter time.Duration) { + t.mu.Lock() + defer t.mu.Unlock() + + e := t.byIP[ip] + if e == nil { + return true, 0 + } + // Decay: an entry that is well past its cooldown AND idle beyond the decay + // window is forgotten entirely. + if now.After(e.cooldownUntil) && now.Sub(e.lastSeen) > decayAfter { + delete(t.byIP, ip) + return true, 0 + } + if now.Before(e.cooldownUntil) { + return false, e.cooldownUntil.Sub(now) + } + return true, 0 +} + +// Fail records one failed attempt for ip and (re)arms the cooldown once the +// soft limit is exceeded. +func (t *LoginThrottle) Fail(ip string, now time.Time) { + t.mu.Lock() + defer t.mu.Unlock() + t.evict(now) + + e := t.byIP[ip] + if e == nil { + e = &entry{} + t.byIP[ip] = e + } + e.fails++ + e.lastSeen = now + if d := backoff(e.fails); d > 0 { + e.cooldownUntil = now.Add(d) + } +} + +// Reset clears all penalty for ip. Called on a CORRECT password. Because the +// login handler verifies the password BEFORE consulting the cooldown, a correct +// password is always accepted — even from a currently-throttled IP (operator and +// attacker can share a Tor/NAT egress) — and Reset then wipes the penalty. +func (t *LoginThrottle) Reset(ip string) { + t.mu.Lock() + delete(t.byIP, ip) + t.mu.Unlock() +} + +// Delay returns the failed-path delay (base + small jitter), or 0 if disabled. +// Jitter avoids a perfectly fixed timing fingerprint without leaking state. +func (t *LoginThrottle) Delay() time.Duration { + if t.failDelay <= 0 { + return 0 + } + n, err := rand.Int(rand.Reader, big.NewInt(int64(150*time.Millisecond))) + if err != nil { + return t.failDelay + } + return t.failDelay + time.Duration(n.Int64()) +} + +// ThrottleEntry is a read-only snapshot of a currently-cooling-down IP, for the +// /admin/status visibility panel. +type ThrottleEntry struct { + IP string + Fails int + Remaining time.Duration +} + +// Snapshot returns the IPs currently in cooldown, so the operator can see an +// attack in progress on a page they already visit. +func (t *LoginThrottle) Snapshot(now time.Time) []ThrottleEntry { + t.mu.Lock() + defer t.mu.Unlock() + var out []ThrottleEntry + for ip, e := range t.byIP { + if now.Before(e.cooldownUntil) { + out = append(out, ThrottleEntry{ + IP: ip, + Fails: e.fails, + Remaining: e.cooldownUntil.Sub(now).Round(time.Second), + }) + } + } + return out +} + +// backoff is the cooldown for a given consecutive-failure count: zero until the +// soft limit, then escalating and hard-capped at maxCooldown. +func backoff(fails int) time.Duration { + over := fails - softLimit + if over <= 0 { + return 0 + } + if over-1 < len(backoffSteps) { + return backoffSteps[over-1] + } + return maxCooldown +} + +// evict keeps the map bounded. Called under the lock from Fail. By default the +// key is the real transport peer (X-Forwarded-For is only trusted behind +// NEVERPAY_TRUST_PROXY), so reaching the cap requires a large real botnet. It +// first drops fully-decayed entries; if a flood of distinct still-hot IPs keeps +// it over the cap, it forgets arbitrary entries until under it — dropping a +// throttled entry only forgives a would-be attacker, so this is safe. +func (t *LoginThrottle) evict(now time.Time) { + if len(t.byIP) <= maxEntries { + return + } + for k, e := range t.byIP { + if now.After(e.cooldownUntil) && now.Sub(e.lastSeen) > decayAfter { + delete(t.byIP, k) + } + } + // Hard bound: map iteration order is randomized, giving effectively random + // eviction of the still-hot remainder. + for k := range t.byIP { + if len(t.byIP) <= maxEntries { + break + } + delete(t.byIP, k) + } +} diff --git a/internal/auth/throttle_test.go b/internal/auth/throttle_test.go new file mode 100644 index 0000000..0b58fc7 --- /dev/null +++ b/internal/auth/throttle_test.go @@ -0,0 +1,127 @@ +package auth + +import ( + "testing" + "time" +) + +// base time for deterministic tests; throttle methods take an explicit `now`. +var t0 = time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + +func TestThrottleNoCooldownBelowSoftLimit(t *testing.T) { + th := NewLoginThrottle(0) + for i := 0; i < softLimit; i++ { + if ok, _ := th.Allow("1.2.3.4", t0); !ok { + t.Fatalf("attempt %d should be allowed below soft limit", i+1) + } + th.Fail("1.2.3.4", t0) + } + // Still allowed exactly at the soft limit (cooldown only arms past it). + if ok, _ := th.Allow("1.2.3.4", t0); !ok { + t.Fatal("should still be allowed at soft limit") + } +} + +func TestThrottleArmsAndExpires(t *testing.T) { + th := NewLoginThrottle(0) + // Exceed the soft limit by one → first cooldown step. + for i := 0; i < softLimit+1; i++ { + th.Fail("ip", t0) + } + ok, retry := th.Allow("ip", t0) + if ok { + t.Fatal("should be cooling down after exceeding soft limit") + } + if retry != backoffSteps[0] { + t.Fatalf("retry = %v, want %v", retry, backoffSteps[0]) + } + // After the cooldown elapses, allowed again. + if ok, _ := th.Allow("ip", t0.Add(backoffSteps[0]+time.Second)); !ok { + t.Fatal("should be allowed after cooldown elapses") + } +} + +func TestThrottleCooldownEscalatesAndCaps(t *testing.T) { + th := NewLoginThrottle(0) + var last time.Duration + for i := 0; i < softLimit+20; i++ { + th.Fail("ip", t0) + _, retry := th.Allow("ip", t0) + if retry > maxCooldown { + t.Fatalf("cooldown %v exceeded cap %v", retry, maxCooldown) + } + if retry < last { + t.Fatalf("cooldown went backwards: %v then %v", last, retry) + } + last = retry + } + if last != maxCooldown { + t.Fatalf("final cooldown = %v, want cap %v", last, maxCooldown) + } +} + +func TestThrottleResetOnSuccess(t *testing.T) { + th := NewLoginThrottle(0) + for i := 0; i < softLimit+5; i++ { + th.Fail("ip", t0) + } + if ok, _ := th.Allow("ip", t0); ok { + t.Fatal("precondition: should be cooling down") + } + th.Reset("ip") // correct password + ok, retry := th.Allow("ip", t0) + if !ok || retry != 0 { + t.Fatalf("after reset want (true,0), got (%v,%v)", ok, retry) + } +} + +// Operator-safety: an attacker hammering from their IP must never lock out the +// operator on a different IP. +func TestThrottlePerIPIsolation(t *testing.T) { + th := NewLoginThrottle(0) + for i := 0; i < softLimit+10; i++ { + th.Fail("attacker", t0) + } + if ok, _ := th.Allow("attacker", t0); ok { + t.Fatal("attacker IP should be cooling down") + } + if ok, _ := th.Allow("operator", t0); !ok { + t.Fatal("operator IP must be unaffected by attacker's failures") + } +} + +func TestThrottleDecayResetsIdleEntry(t *testing.T) { + th := NewLoginThrottle(0) + for i := 0; i < softLimit+1; i++ { + th.Fail("ip", t0) + } + // Long after the cooldown AND idle past the decay window → forgotten. + later := t0.Add(maxCooldown + decayAfter + time.Minute) + if ok, retry := th.Allow("ip", later); !ok || retry != 0 { + t.Fatalf("idle entry should decay to clean, got (%v,%v)", ok, retry) + } +} + +func TestThrottleSnapshot(t *testing.T) { + th := NewLoginThrottle(0) + for i := 0; i < softLimit+1; i++ { + th.Fail("9.9.9.9", t0) + } + snap := th.Snapshot(t0) + if len(snap) != 1 || snap[0].IP != "9.9.9.9" || snap[0].Remaining <= 0 { + t.Fatalf("unexpected snapshot: %+v", snap) + } + // Not cooling down → absent from snapshot. + if s := th.Snapshot(t0.Add(maxCooldown + time.Minute)); len(s) != 0 { + t.Fatalf("expired cooldown should not appear in snapshot, got %+v", s) + } +} + +func TestDelayZeroWhenDisabled(t *testing.T) { + if d := NewLoginThrottle(0).Delay(); d != 0 { + t.Fatalf("disabled delay should be 0, got %v", d) + } + if d := NewLoginThrottle(DefaultFailDelay).Delay(); d < DefaultFailDelay { + t.Fatalf("enabled delay should be >= base, got %v", d) + } +} diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go index 70856ea..0bd3ea6 100644 --- a/internal/web/handlers_admin.go +++ b/internal/web/handlers_admin.go @@ -3,12 +3,14 @@ package web import ( "database/sql" "errors" + "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" + "time" "neverpay/internal/db" ) @@ -20,16 +22,48 @@ func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r, s.cfg.TrustProxy) + + // Verify the password BEFORE consulting the throttle, so a correct password + // is always accepted — even from a currently-cooling-down IP. On a shared + // egress (Tor exit, CGNAT, VPN) the operator and an attacker share one IP; + // gating before the check would let the attacker lock the operator out. + // The upstream per-IP request limiter (10/min) bounds the bcrypt cost here. ok, err := s.auth.Login(w, r.FormValue("password")) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - if !ok { - s.render(w, http.StatusUnauthorized, "login", map[string]any{"Error": "Incorrect password."}) + if ok { + s.loginThrottle.Reset(ip) // wipes any cooldown for this IP + http.Redirect(w, r, "/admin/", http.StatusSeeOther) + return + } + + // Wrong password: record the failure, slow the response (flat timing), and + // surface the cooldown once repeated failures have armed it. + now := time.Now() + s.loginThrottle.Fail(ip, now) + time.Sleep(s.loginThrottle.Delay()) + if allowed, retry := s.loginThrottle.Allow(ip, now); !allowed { + secs := retryAfterSeconds(retry) + w.Header().Set("Retry-After", strconv.Itoa(secs)) + s.render(w, http.StatusTooManyRequests, "login", map[string]any{ + "Error": fmt.Sprintf("Too many failed attempts. Try again in %ds.", secs), + }) return } - http.Redirect(w, r, "/admin/", http.StatusSeeOther) + s.render(w, http.StatusUnauthorized, "login", map[string]any{"Error": "Incorrect password."}) +} + +// retryAfterSeconds renders a still-positive cooldown as a whole number of +// seconds, rounding UP and never below 1 (so a sub-second tail never reports 0). +func retryAfterSeconds(d time.Duration) int { + secs := int((d + time.Second - 1) / time.Second) + if secs < 1 { + secs = 1 + } + return secs } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/handlers_status_admin.go b/internal/web/handlers_status_admin.go index e17c2a4..1a0609c 100644 --- a/internal/web/handlers_status_admin.go +++ b/internal/web/handlers_status_admin.go @@ -14,6 +14,7 @@ import ( "time" "neverpay/internal/assets" + "neverpay/internal/auth" "neverpay/internal/db" "neverpay/internal/email" "neverpay/internal/webhook" @@ -39,6 +40,7 @@ type statusView struct { DBPath string BaseURLWarning string HTTPSWarning string + Throttled []auth.ThrottleEntry } // handleStatus renders a live operability check: per-chain RPC/explorer @@ -87,6 +89,7 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { v.Endpoints = eps } v.BaseURLWarning, v.HTTPSWarning = s.baseURLDiagnostics(r) + v.Throttled = s.loginThrottle.Snapshot(time.Now()) s.render(w, http.StatusOK, "status", map[string]any{"S": v}) } diff --git a/internal/web/login_test.go b/internal/web/login_test.go new file mode 100644 index 0000000..32663fa --- /dev/null +++ b/internal/web/login_test.go @@ -0,0 +1,121 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "neverpay/internal/auth" + "neverpay/internal/config" + "neverpay/internal/db" +) + +const testAdminPw = "test-admin-passphrase" + +func newLoginServer(t *testing.T) *Server { + t.Helper() + database, err := db.Open(filepath.Join(t.TempDir(), "login.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + if _, err := auth.EnsureAdmin(database, testAdminPw); err != nil { + t.Fatal(err) + } + return &Server{ + cfg: config.Config{}, + db: database, + auth: &auth.Auth{DB: database}, + loginThrottle: auth.NewLoginThrottle(0), // no delay in tests + tmpl: buildTemplates(), + } +} + +func postLogin(s *Server, ip, pw string) *httptest.ResponseRecorder { + form := url.Values{"password": {pw}} + r := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.RemoteAddr = ip + ":40000" + w := httptest.NewRecorder() + s.handleLoginSubmit(w, r) + return w +} + +func TestLoginThrottleLocksOutAfterRepeatedFailures(t *testing.T) { + s := newLoginServer(t) + const ip = "203.0.113.7" + + // Wrong passwords up to and including one past the soft limit arm a cooldown. + for i := 0; i < 7; i++ { + w := postLogin(s, ip, "nope-not-the-password") + if w.Code == http.StatusTooManyRequests { + // Once we hit the cooldown, assert the Retry-After header is set. + if w.Header().Get("Retry-After") == "" { + t.Fatal("429 response should carry a Retry-After header") + } + return + } + if w.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d: want 401, got %d", i+1, w.Code) + } + } + t.Fatal("expected a 429 cooldown after repeated failures") +} + +func TestLoginCorrectPasswordSucceedsAndResets(t *testing.T) { + s := newLoginServer(t) + const ip = "198.51.100.9" + + // A couple of failures (below the soft limit → no cooldown yet). + postLogin(s, ip, "wrong1") + postLogin(s, ip, "wrong2") + + w := postLogin(s, ip, testAdminPw) + if w.Code != http.StatusSeeOther { + t.Fatalf("correct password want 303, got %d", w.Code) + } + // Throttle for this IP must be wiped after success. + if snap := s.loginThrottle.Snapshot(time.Now()); len(snap) != 0 { + t.Fatalf("throttle should be reset after success, got %+v", snap) + } +} + +// The shared-egress case: even when an IP is actively cooling down, a correct +// password must still be accepted and clear the throttle (operator and attacker +// can share one Tor/NAT egress IP). +func TestLoginCorrectPasswordWinsWhileThrottled(t *testing.T) { + s := newLoginServer(t) + const ip = "203.0.113.50" + + for i := 0; i < 6; i++ { // 6 > softLimit(5) → cooldown armed + postLogin(s, ip, "wrong-guess") + } + if snap := s.loginThrottle.Snapshot(time.Now()); len(snap) == 0 { + t.Fatal("precondition: IP should be cooling down after 6 failures") + } + + w := postLogin(s, ip, testAdminPw) // correct password, same throttled IP + if w.Code != http.StatusSeeOther { + t.Fatalf("correct password must win even while throttled, got %d", w.Code) + } + if snap := s.loginThrottle.Snapshot(time.Now()); len(snap) != 0 { + t.Fatalf("success must clear the cooldown, got %+v", snap) + } +} + +func TestLoginAttackerDoesNotLockOutOperatorIP(t *testing.T) { + s := newLoginServer(t) + // Attacker hammers from one IP until locked out. + for i := 0; i < 12; i++ { + postLogin(s, "10.0.0.1", "guess") + } + // Operator on a different IP logs in fine. + w := postLogin(s, "10.0.0.2", testAdminPw) + if w.Code != http.StatusSeeOther { + t.Fatalf("operator on a clean IP should log in (303), got %d", w.Code) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 36bc6ac..0cb49f8 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -32,36 +32,38 @@ type Deps struct { // Server holds dependencies and the configured router. type Server struct { - cfg config.Config - db *db.DB - auth *auth.Auth - rates rates.Provider - pay payment.Provider - mailer email.Sender - hooks *webhook.Dispatcher - xpubs map[string]string - signKey ed25519.PrivateKey - pubKey ed25519.PublicKey - tmpl map[string]*template.Template - partials map[string]*template.Template - mux http.Handler + cfg config.Config + db *db.DB + auth *auth.Auth + rates rates.Provider + pay payment.Provider + mailer email.Sender + hooks *webhook.Dispatcher + xpubs map[string]string + signKey ed25519.PrivateKey + pubKey ed25519.PublicKey + loginThrottle *auth.LoginThrottle + tmpl map[string]*template.Template + partials map[string]*template.Template + mux http.Handler } // New builds a Server with all routes wired up. func New(d Deps) *Server { s := &Server{ - cfg: d.Cfg, - db: d.DB, - auth: d.Auth, - rates: d.Rates, - pay: d.Pay, - mailer: d.Mailer, - hooks: d.Hooks, - xpubs: d.Xpubs, - signKey: d.SignKey, - pubKey: d.PubKey, - tmpl: buildTemplates(), - partials: buildPartials(), + cfg: d.Cfg, + db: d.DB, + auth: d.Auth, + rates: d.Rates, + pay: d.Pay, + mailer: d.Mailer, + hooks: d.Hooks, + xpubs: d.Xpubs, + signKey: d.SignKey, + pubKey: d.PubKey, + loginThrottle: auth.NewLoginThrottle(auth.DefaultFailDelay), + tmpl: buildTemplates(), + partials: buildPartials(), } s.mux = s.routes() return s diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 66feea1..5306e2e 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -191,6 +191,12 @@ fieldset.group legend { .planrow select { min-width: 110px; } .btn.small { padding: 6px 12px; font-size: 12px; } +/* license-plans group header: a normal-case label + helper line that sits + inside the License fieldset (NOT the uppercase/tracked .fieldlabel micro-label) */ +.plans-head { display: flex; flex-direction: column; gap: 4px; margin-top: 4px; } +.plans-title { font-weight: 600; font-size: 13px; } /* matches .form label */ +.plans-help { margin: 0; font-size: 13px; line-height: 1.5; } /* .muted supplies color */ + /* buyer-facing plan picker */ .plans-pick { display: flex; flex-direction: column; gap: 10px; } /* high specificity to beat `.form label { flex-direction: column }` */ @@ -281,7 +287,6 @@ code { .payrow { display: flex; gap: 22px; align-items: flex-start; margin: 18px 0; flex-wrap: wrap; } .qr { background: #fff; padding: 10px; border-radius: var(--radius); border: 1px solid var(--border-hi); } .paydetails { flex: 1; min-width: 220px; } -.fieldlabel { font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--text-dim); margin-bottom: 6px; } .addr, .key { display: block; word-break: break-all; background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; @@ -312,6 +317,13 @@ code { padding: 28px 24px 40px; border-top: 1px solid var(--border); margin-top: 40px; } .footer .dot { color: var(--accent); margin: 0 8px; } +.footer .adminlink { color: var(--text-dim); text-decoration: none; } +.footer .adminlink:hover { color: var(--accent); } + +/* ---- storefront empty state ---- */ +.empty { margin-top: 32px; } +.empty .owner-hint { font-size: 13px; color: var(--text-dim); margin-top: 6px; } +.empty .owner-hint a { color: var(--accent); } /* ---- keyframes ---- */ /* embedded (iframe) mode: drop the chrome, tighten the container */ diff --git a/internal/web/templates/landing.html b/internal/web/templates/landing.html index ce99159..1ea5aac 100644 --- a/internal/web/templates/landing.html +++ b/internal/web/templates/landing.html @@ -16,6 +16,9 @@

neverpay

{{end}} {{else}} -

No products are available yet.

+
+

No products are available yet.

+

Are you the store owner? Sign in at /admin to add your first product.

+
{{end}} {{end}} diff --git a/internal/web/templates/layout_public.html b/internal/web/templates/layout_public.html index 6c75f8c..d3092ad 100644 --- a/internal/web/templates/layout_public.html +++ b/internal/web/templates/layout_public.html @@ -18,7 +18,7 @@ {{template "body" .}}
- paid in crypto·no middleman·tor-friendly·your keys, your coins + paid in crypto·no middleman·tor-friendly·your keys, your coins·admin
diff --git a/internal/web/templates/product_form.html b/internal/web/templates/product_form.html index 22c197e..c3c11eb 100644 --- a/internal/web/templates/product_form.html +++ b/internal/web/templates/product_form.html @@ -62,7 +62,10 @@

{{if .Product.ID}}Edit product{{else}}New product{{end}}

-
License plans — durations the buyer chooses at checkout. Leave empty for a single key at the price above.
+
+ License plans +

Durations the buyer chooses at checkout. Leave empty for a single key at the price above.

+
{{range .Plans}}
diff --git a/internal/web/templates/status.html b/internal/web/templates/status.html index 4937897..0672ac8 100644 --- a/internal/web/templates/status.html +++ b/internal/web/templates/status.html @@ -61,6 +61,27 @@

Webhooks

{{end}} +
+

Login security

+ {{if .Throttled}} +
⚠ Failed-login throttling is active — someone is repeatedly guessing the admin password.
+ + + + {{range .Throttled}} + + {{end}} + +
Source IPFailed attemptsCooling down for
{{.IP}}{{.Fails}}{{.Remaining}}
+

Each source IP is throttled independently with an escalating, auto-expiring + cooldown (capped at 15 minutes). A correct password is always accepted — even from a throttled IP — and + clears the cooldown, so the throttle only ever delays repeated wrong guesses.

+ {{else}} +

✓ No login attacks in progress. Admin logins are bcrypt-hashed and rate-limited with per-IP + brute-force throttling.

+ {{end}} +
+

Backup

diff --git a/internal/web/watcher_test.go b/internal/web/watcher_test.go index 1b72b71..1345e94 100644 --- a/internal/web/watcher_test.go +++ b/internal/web/watcher_test.go @@ -6,6 +6,7 @@ import ( "testing" "neverpay/internal/assets" + "neverpay/internal/auth" "neverpay/internal/db" "neverpay/internal/license" "neverpay/internal/money" @@ -21,7 +22,7 @@ func newTestServer(t *testing.T) (*Server, *payment.Mock) { t.Cleanup(func() { database.Close() }) seed, pub, _ := license.GenerateSeed() mock := payment.NewMock() - return &Server{db: database, pay: mock, signKey: license.PrivFromSeed(seed), pubKey: pub}, mock + return &Server{db: database, pay: mock, signKey: license.PrivFromSeed(seed), pubKey: pub, loginThrottle: auth.NewLoginThrottle(0)}, mock } func seedOrder(t *testing.T, s *Server, pid string, a assets.Asset, amount string) *db.Order { diff --git a/setup.go b/setup.go index e467598..02d4e8e 100644 --- a/setup.go +++ b/setup.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" + "neverpay/internal/auth" + "github.com/charmbracelet/huh" "golang.org/x/term" ) @@ -137,9 +139,9 @@ func mainForm(a *setupAnswers) *huh.Form { Validate(validURL). Value(&a.BaseURL), huh.NewInput().Title("Admin password"). - Description("Logs you in at /admin. Pick something strong — there is no recovery."). + Description("Logs you in at /admin. At least 12 characters — there is no recovery."). EchoMode(huh.EchoModePassword). - Validate(minLen(8)). + Validate(auth.ValidatePassword). Value(&a.AdminPwd), ), @@ -269,15 +271,6 @@ func requiredPrefix(label, prefix string) func(string) error { } } -func minLen(n int) func(string) error { - return func(s string) error { - if len(strings.TrimSpace(s)) < n { - return fmt.Errorf("use at least %d characters", n) - } - return nil - } -} - func validURL(s string) error { s = strings.TrimSpace(s) if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") {