Updated the README

Added new security layers
This commit is contained in:
2025-08-22 12:39:51 -04:00
parent fb7428064f
commit 720c7e0b52
7 changed files with 695 additions and 552 deletions

View File

@@ -3,43 +3,41 @@ package api
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/big"
"mime"
"net/http"
"net/url"
"os"
"path"
"strconv"
"strings"
"sync"
"time"
"greencoast/internal/auth"
"greencoast/internal/index"
)
// BlobStore is the minimal storage interface the API needs.
// BlobStore minimal interface for storage backends.
type BlobStore interface {
Put(hash string, r io.Reader) error
Get(hash string) (io.ReadCloser, int64, error)
Delete(hash string) error
}
// optional capability for stores that can enumerate blobs
type blobWalker interface {
Walk(func(hash string, size int64, mod time.Time) error) error
}
// -----------------------------
// Public wiring
// -----------------------------
type DiscordProvider struct {
Enabled bool
ClientID string
@@ -48,14 +46,8 @@ type DiscordProvider struct {
}
type AuthProviders struct {
SigningSecretHex string // HMAC secret in hex
SigningSecretHex string
Discord DiscordProvider
GoogleEnabled bool
FacebookEnabled bool
WebAuthnEnabled bool
TOTPEnabled bool
}
type Server struct {
@@ -67,41 +59,58 @@ type Server struct {
coarseTS bool
zeroTrust bool
allowClientSignedTokens bool // accept self-signed tokens (no DB)
signingKey []byte
signingKey []byte
// dev flags (from env)
// dev/testing flags
allowUnauth bool
devBearer string
// SSE fanout (in-process)
// require proof-of-possession on every authd call
requirePoP bool
// SSE in-process
sseMu sync.Mutex
sseSubs map[chan []byte]struct{}
sseClosed bool
// SSO ephemeral state
// SSO state + PKCE verifier + device key binding
stateMu sync.Mutex
states map[string]time.Time
states map[string]stateItem
// Nonce challenges for key-based login
nonceMu sync.Mutex
nonceExpiry map[string]time.Time
// PoP replay cache
replayMu sync.Mutex
replays map[string]time.Time
}
type stateItem struct {
Exp time.Time
Verifier string // PKCE code_verifier
DeviceKey string // "p256:<b64raw>" or "ed25519:<b64raw>"
ReturnNext string // optional
}
// New constructs the API server and registers routes.
func New(store BlobStore, idx *index.Index, coarseTS bool, zeroTrust bool, providers AuthProviders) *Server {
key, _ := hex.DecodeString(strings.TrimSpace(providers.SigningSecretHex))
s := &Server{
mux: http.NewServeMux(),
store: store,
idx: idx,
coarseTS: coarseTS,
zeroTrust: zeroTrust,
allowClientSignedTokens: true,
signingKey: key,
allowUnauth: os.Getenv("GC_DEV_ALLOW_UNAUTH") == "true",
devBearer: os.Getenv("GC_DEV_BEARER"),
sseSubs: make(map[chan []byte]struct{}),
states: make(map[string]time.Time),
mux: http.NewServeMux(),
store: store,
idx: idx,
coarseTS: coarseTS,
zeroTrust: zeroTrust,
signingKey: key,
allowUnauth: os.Getenv("GC_DEV_ALLOW_UNAUTH") == "true",
devBearer: os.Getenv("GC_DEV_BEARER"),
requirePoP: strings.ToLower(os.Getenv("GC_REQUIRE_POP")) != "false", // default true
sseSubs: make(map[chan []byte]struct{}),
states: make(map[string]stateItem),
nonceExpiry: make(map[string]time.Time),
replays: make(map[string]time.Time),
}
// MIME safety (minimal base images can be sparse)
_ = mime.AddExtensionType(".js", "application/javascript; charset=utf-8")
_ = mime.AddExtensionType(".css", "text/css; charset=utf-8")
_ = mime.AddExtensionType(".html", "text/html; charset=utf-8")
@@ -110,19 +119,9 @@ func New(store BlobStore, idx *index.Index, coarseTS bool, zeroTrust bool, provi
// Core
s.mux.HandleFunc("/healthz", s.handleHealthz)
// Objects
s.mux.Handle("/v1/object", s.withCORS(http.HandlerFunc(s.handlePutObject)))
s.mux.Handle("/v1/object/", s.withCORS(http.HandlerFunc(s.handleObjectByHash)))
// Index + SSE
s.mux.Handle("/v1/index", s.withCORS(http.HandlerFunc(s.handleIndex)))
s.mux.Handle("/v1/index/stream", s.withCORS(http.HandlerFunc(s.handleIndexSSE)))
// GDPR+policy endpoint (minimal; no PII)
s.mux.Handle("/v1/gdpr/policy", s.withCORS(http.HandlerFunc(s.handleGDPRPolicy)))
// Admin: reindex from disk if store supports Walk
s.mux.Handle("/v1/admin/reindex", s.withCORS(http.HandlerFunc(s.handleAdminReindex)))
// Auth (public-key)
s.mux.Handle("/v1/auth/key/challenge", s.withCORS(http.HandlerFunc(s.handleKeyChallenge)))
s.mux.Handle("/v1/auth/key/verify", s.withCORS(http.HandlerFunc(s.handleKeyVerify)))
// Discord SSO
s.mux.Handle("/v1/auth/discord/start", s.withCORS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -132,10 +131,22 @@ func New(store BlobStore, idx *index.Index, coarseTS bool, zeroTrust bool, provi
s.handleDiscordCallback(w, r, providers.Discord)
}))
// Objects
s.mux.Handle("/v1/object", s.withCORS(http.HandlerFunc(s.handlePutObject)))
s.mux.Handle("/v1/object/", s.withCORS(http.HandlerFunc(s.handleObjectByHash)))
// Index + SSE
s.mux.Handle("/v1/index", s.withCORS(http.HandlerFunc(s.handleIndex)))
s.mux.Handle("/v1/index/stream", s.withCORS(http.HandlerFunc(s.handleIndexSSE)))
// GDPR/policy
s.mux.Handle("/v1/gdpr/policy", s.withCORS(http.HandlerFunc(s.handleGDPRPolicy)))
// Admin: reindex
s.mux.Handle("/v1/admin/reindex", s.withCORS(http.HandlerFunc(s.handleAdminReindex)))
return s
}
// ListenHTTP serves the API on addr.
func (s *Server) ListenHTTP(addr string) error {
log.Printf("http listening on %s", addr)
server := &http.Server{
@@ -146,7 +157,6 @@ func (s *Server) ListenHTTP(addr string) error {
return server.ListenAndServe()
}
// ListenHTTPS serves TLS directly.
func (s *Server) ListenHTTPS(addr, certFile, keyFile string) error {
log.Printf("https listening on %s", addr)
server := &http.Server{
@@ -157,29 +167,23 @@ func (s *Server) ListenHTTPS(addr, certFile, keyFile string) error {
return server.ListenAndServeTLS(certFile, keyFile)
}
// -----------------------------
// Middleware / headers
// -----------------------------
func (s *Server) secureHeaders(w http.ResponseWriter) {
// Privacy / security posture
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
w.Header().Set("Cross-Origin-Resource-Policy", "same-site")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=(), browsing-topics=()")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff")
// HSTS (harmless over HTTP; browsers only enforce under HTTPS)
w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload")
}
func (s *Server) withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.secureHeaders(w)
// Strong CSP for static will be set in static server; API allows connect from client origin
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-GC-Private, X-GC-3P-Assent, X-GC-TZ")
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, DELETE, OPTIONS, POST")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-GC-Private, X-GC-3P-Assent, X-GC-TZ, X-GC-Key, X-GC-TS, X-GC-Proof")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
@@ -188,9 +192,7 @@ func (s *Server) withCORS(next http.Handler) http.Handler {
})
}
// -----------------------------
// Health & policy
// -----------------------------
// ---------- Health & policy ----------
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
s.secureHeaders(w)
@@ -202,119 +204,252 @@ func (s *Server) handleGDPRPolicy(w http.ResponseWriter, r *http.Request) {
s.secureHeaders(w)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
type policy struct {
StoresPII bool `json:"stores_pii"`
CollectIP bool `json:"collect_ip"`
CollectUA bool `json:"collect_user_agent"`
Timestamps string `json:"timestamps"`
ZeroTrust bool `json:"zero_trust"`
StoresPII bool `json:"stores_pii"`
CollectIP bool `json:"collect_ip"`
CollectUA bool `json:"collect_user_agent"`
Timestamps string `json:"timestamps"`
ZeroTrust bool `json:"zero_trust"`
Accounts string `json:"accounts"`
ProofOfPoss bool `json:"proof_of_possession"`
}
resp := policy{
StoresPII: false,
CollectIP: false,
CollectUA: false,
Timestamps: map[bool]string{true: "coarse_utc", false: "utc"}[s.coarseTS],
ZeroTrust: s.zeroTrust,
StoresPII: false,
CollectIP: false,
CollectUA: false,
Timestamps: map[bool]string{true: "coarse_utc", false: "utc"}[s.coarseTS],
ZeroTrust: s.zeroTrust,
Accounts: "public-key only",
ProofOfPoss: s.requirePoP,
}
_ = json.NewEncoder(w).Encode(resp)
}
// -----------------------------
// Auth helpers
// -----------------------------
// ---------- Auth helpers ----------
func (s *Server) requireAuth(w http.ResponseWriter, r *http.Request) bool {
// Developer bypass
type authCtx struct {
sub string
cnf string // "p256:<b64raw>" or "ed25519:<b64raw>"
}
func (s *Server) parseAuth(w http.ResponseWriter, r *http.Request) (*authCtx, bool) {
// Dev bypass
if s.allowUnauth {
return &authCtx{sub: "dev"}, true
}
// Dev bearer
if s.devBearer != "" && r.Header.Get("Authorization") == "Bearer "+s.devBearer {
return &authCtx{sub: "dev"}, true
}
h := r.Header.Get("Authorization")
if h == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil, false
}
// gc2 HMAC token
if strings.HasPrefix(h, "Bearer gc2.") && len(s.signingKey) != 0 {
claims, err := auth.VerifyGC2(s.signingKey, strings.TrimPrefix(h, "Bearer "), time.Now())
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil, false
}
return &authCtx{sub: claims.Sub, cnf: claims.CNF}, true
}
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil, false
}
func (s *Server) verifyPoP(w http.ResponseWriter, r *http.Request, ac *authCtx, body []byte) bool {
if !s.requirePoP {
return true
}
// Optional dev bearer
if s.devBearer != "" {
h := r.Header.Get("Authorization")
if h == "Bearer "+s.devBearer {
return true
}
pubHdr := r.Header.Get("X-GC-Key")
ts := r.Header.Get("X-GC-TS")
proof := r.Header.Get("X-GC-Proof")
if pubHdr == "" || ts == "" || proof == "" {
http.Error(w, "missing proof", http.StatusUnauthorized)
return false
}
// timestamp window
sec, _ := strconv.ParseInt(ts, 10, 64)
d := time.Since(time.Unix(sec, 0))
if d < -5*time.Minute || d > 5*time.Minute {
http.Error(w, "stale proof", http.StatusUnauthorized)
return false
}
// cnf must match
if ac.cnf == "" || ac.cnf != pubHdr {
http.Error(w, "key mismatch", http.StatusUnauthorized)
return false
}
// build message
sum := sha256.Sum256(body)
msg := strings.ToUpper(r.Method) + "\n" + r.URL.String() + "\n" + ts + "\n" + hex.EncodeToString(sum[:])
// Accept self-signed HMAC tokens if configured
if s.allowClientSignedTokens && len(s.signingKey) > 0 {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
tok := strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
if s.verifyToken(tok) == nil {
return true
// verify signature
ok := false
switch {
case strings.HasPrefix(pubHdr, "ed25519:"):
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(pubHdr, "ed25519:"))
if err == nil {
sig, err := base64.RawURLEncoding.DecodeString(proof)
if err == nil && len(raw) == ed25519.PublicKeySize {
ok = ed25519.Verify(ed25519.PublicKey(raw), []byte(msg), sig)
}
}
case strings.HasPrefix(pubHdr, "p256:"):
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(pubHdr, "p256:"))
if err == nil && len(raw) == 65 && raw[0] == 0x04 {
x := new(big.Int).SetBytes(raw[1:33])
y := new(big.Int).SetBytes(raw[33:65])
pk := ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
der, err := base64.RawURLEncoding.DecodeString(proof)
if err == nil {
ok = ecdsa.VerifyASN1(&pk, []byte(msg), der)
}
}
}
http.Error(w, "unauthorized", http.StatusUnauthorized)
return false
if !ok {
http.Error(w, "bad proof", http.StatusUnauthorized)
return false
}
// replay cache
h := sha256.Sum256([]byte(proof + "|" + ts))
key := base64.RawURLEncoding.EncodeToString(h[:])
s.replayMu.Lock()
defer s.replayMu.Unlock()
if exp, exists := s.replays[key]; exists && time.Now().Before(exp) {
http.Error(w, "replay", http.StatusUnauthorized)
return false
}
s.replays[key] = time.Now().Add(10 * time.Minute)
return true
}
func (s *Server) makeToken(subject string, ttl time.Duration) (string, error) {
if len(s.signingKey) == 0 {
return "", errors.New("signing key not set")
// ---------- Public-key auth: challenge/verify ----------
func (s *Server) handleKeyChallenge(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
type claims struct {
Sub string `json:"sub"`
Exp int64 `json:"exp"`
Iss string `json:"iss"`
}
c := claims{
Sub: subject,
Exp: time.Now().Add(ttl).Unix(),
Iss: "greencoast",
}
body, _ := json.Marshal(c)
mac := hmac.New(sha256.New, s.signingKey)
mac.Write(body)
sig := mac.Sum(nil)
return "gc1." + base64.RawURLEncoding.EncodeToString(body) + "." + base64.RawURLEncoding.EncodeToString(sig), nil
nonce := s.randToken(16)
exp := time.Now().Add(10 * time.Minute)
s.nonceMu.Lock()
s.nonceExpiry[nonce] = exp
s.nonceMu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{"nonce": nonce, "exp": exp.Unix()})
}
func (s *Server) verifyToken(tok string) error {
if !strings.HasPrefix(tok, "gc1.") {
return errors.New("bad prefix")
type keyVerifyReq struct {
Nonce string `json:"nonce"`
Alg string `json:"alg"` // "p256" or "ed25519"
Pub string `json:"pub"` // base64(raw) for that alg (p256 uncompressed point 65B; ed25519 32B)
Sig string `json:"sig"` // base64(signature over "key-verify\n"+nonce)
}
func (s *Server) handleKeyVerify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
parts := strings.Split(tok, ".")
if len(parts) != 3 {
return errors.New("bad parts")
var req keyVerifyReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Nonce == "" || req.Alg == "" || req.Pub == "" || req.Sig == "" {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
body, err := base64.RawURLEncoding.DecodeString(parts[1])
// check nonce
s.nonceMu.Lock()
exp, ok := s.nonceExpiry[req.Nonce]
if ok {
delete(s.nonceExpiry, req.Nonce)
}
s.nonceMu.Unlock()
if !ok || time.Now().After(exp) {
http.Error(w, "nonce invalid", http.StatusUnauthorized)
return
}
msg := "key-verify\n" + req.Nonce
pubRaw, err := base64.RawURLEncoding.DecodeString(req.Pub)
if err != nil {
return err
http.Error(w, "bad pub", http.StatusBadRequest)
return
}
want, err := base64.RawURLEncoding.DecodeString(parts[2])
sigRaw, err := base64.RawURLEncoding.DecodeString(req.Sig)
if err != nil {
return err
http.Error(w, "bad sig", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, s.signingKey)
mac.Write(body)
if !hmac.Equal(want, mac.Sum(nil)) {
return errors.New("bad sig")
var cnf string
switch strings.ToLower(req.Alg) {
case "ed25519":
if len(pubRaw) != ed25519.PublicKeySize || len(sigRaw) != ed25519.SignatureSize {
http.Error(w, "bad key", http.StatusBadRequest)
return
}
if !ed25519.Verify(ed25519.PublicKey(pubRaw), []byte(msg), sigRaw) {
http.Error(w, "verify failed", http.StatusUnauthorized)
return
}
cnf = "ed25519:" + req.Pub
case "p256":
if len(pubRaw) != 65 || pubRaw[0] != 0x04 {
http.Error(w, "bad key", http.StatusBadRequest)
return
}
x := new(big.Int).SetBytes(pubRaw[1:33])
y := new(big.Int).SetBytes(pubRaw[33:65])
pk := ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
// sigRaw assumed DER (WebCrypto)
if !ecdsa.VerifyASN1(&pk, []byte(msg), sigRaw) {
http.Error(w, "verify failed", http.StatusUnauthorized)
return
}
cnf = "p256:" + req.Pub
default:
http.Error(w, "unsupported alg", http.StatusBadRequest)
return
}
var c struct {
Sub string `json:"sub"`
Exp int64 `json:"exp"`
sub := auth.AccountIDFromPub(pubRaw)
ttl := 8 * time.Hour
now := time.Now()
bearer, err := auth.MintGC2(s.signingKey, auth.Claims{
Sub: sub, Exp: now.Add(ttl).Unix(), Nbf: now.Add(-60 * time.Second).Unix(),
Iss: "greencoast", Aud: "api", CNF: cnf,
})
if err != nil {
http.Error(w, "sign error", http.StatusInternalServerError)
return
}
if err := json.Unmarshal(body, &c); err != nil {
return err
}
if time.Now().Unix() > c.Exp {
return errors.New("expired")
}
return nil
_ = json.NewEncoder(w).Encode(map[string]any{
"bearer": bearer,
"sub": sub,
"exp": now.Add(ttl).Unix(),
})
}
// -----------------------------
// Objects & Index
// -----------------------------
// ---------- Objects & Index ----------
func (s *Server) handlePutObject(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.requireAuth(w, r) {
// Limit body to 10 MiB by default
const maxBlob = int64(10 << 20)
r.Body = http.MaxBytesReader(w, r.Body, maxBlob)
// Read body first to support PoP over body hash
var buf bytes.Buffer
n, err := io.Copy(&buf, r.Body)
if err != nil {
http.Error(w, "read error", 500)
return
}
ac, ok := s.parseAuth(w, r)
if !ok {
return
}
if !s.verifyPoP(w, r, ac, buf.Bytes()) {
return
}
@@ -324,23 +459,14 @@ func (s *Server) handlePutObject(w http.ResponseWriter, r *http.Request) {
creatorTZ = ""
}
// Write to store; compute hash while streaming
var buf bytes.Buffer
n, err := io.Copy(&buf, r.Body)
if err != nil {
http.Error(w, "read error", 500)
return
}
sum := sha256.Sum256(buf.Bytes())
hash := hex.EncodeToString(sum[:])
// Persist
if err := s.store.Put(hash, bytes.NewReader(buf.Bytes())); err != nil {
http.Error(w, "store error", 500)
return
}
// Index
when := time.Now().UTC()
if s.coarseTS {
when = when.Truncate(time.Minute)
@@ -356,14 +482,13 @@ func (s *Server) handlePutObject(w http.ResponseWriter, r *http.Request) {
http.Error(w, "index error", 500)
return
}
s.sseBroadcast(map[string]interface{}{"event": "put", "data": entry})
s.sseBroadcast(map[string]any{"event": "put", "data": entry})
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(entry)
}
func (s *Server) handleObjectByHash(w http.ResponseWriter, r *http.Request) {
// path: /v1/object/{hash}
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/object/"), "/")
if len(parts) == 0 || parts[0] == "" {
http.NotFound(w, r)
@@ -373,7 +498,11 @@ func (s *Server) handleObjectByHash(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if !s.requireAuth(w, r) {
ac, ok := s.parseAuth(w, r)
if !ok {
return
}
if !s.verifyPoP(w, r, ac, nil) {
return
}
rc, n, err := s.store.Get(hash)
@@ -389,16 +518,19 @@ func (s *Server) handleObjectByHash(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(w, rc)
case http.MethodDelete:
if !s.requireAuth(w, r) {
ac, ok := s.parseAuth(w, r)
if !ok {
return
}
if !s.verifyPoP(w, r, ac, nil) {
return
}
if err := s.store.Delete(hash); err != nil {
http.Error(w, "delete error", 500)
return
}
// prune index if present
_ = s.idx.Delete(hash)
s.sseBroadcast(map[string]interface{}{"event": "delete", "data": map[string]string{"hash": hash}})
s.sseBroadcast(map[string]any{"event": "delete", "data": map[string]string{"hash": hash}})
w.WriteHeader(http.StatusNoContent)
default:
@@ -411,7 +543,11 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.requireAuth(w, r) {
ac, ok := s.parseAuth(w, r)
if !ok {
return
}
if !s.verifyPoP(w, r, ac, nil) {
return
}
items, err := s.idx.List()
@@ -423,13 +559,16 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(items)
}
// Simple in-process SSE fanout.
func (s *Server) handleIndexSSE(w http.ResponseWriter, r *http.Request) {
if !s.requireAuth(w, r) {
ac, ok := s.parseAuth(w, r)
if !ok {
return
}
flusher, ok := w.(http.Flusher)
if !ok {
if !s.verifyPoP(w, r, ac, nil) {
return
}
flusher, ok2 := w.(http.Flusher)
if !ok2 {
http.Error(w, "stream unsupported", http.StatusInternalServerError)
return
}
@@ -438,8 +577,6 @@ func (s *Server) handleIndexSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "keep-alive")
ch := make(chan []byte, 8)
// subscribe
s.sseMu.Lock()
if s.sseClosed {
s.sseMu.Unlock()
@@ -449,11 +586,9 @@ func (s *Server) handleIndexSSE(w http.ResponseWriter, r *http.Request) {
s.sseSubs[ch] = struct{}{}
s.sseMu.Unlock()
// Send a hello/heartbeat
fmt.Fprintf(w, "data: %s\n\n", `{"event":"hello","data":"ok"}`)
flusher.Flush()
// pump
ctx := r.Context()
t := time.NewTicker(25 * time.Second)
defer t.Stop()
@@ -492,24 +627,25 @@ func (s *Server) sseBroadcast(v interface{}) {
s.sseMu.Unlock()
}
// -----------------------------
// Admin: reindex from disk
// -----------------------------
// ---------- Admin: reindex ----------
func (s *Server) handleAdminReindex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.requireAuth(w, r) {
ac, ok := s.parseAuth(w, r)
if !ok {
return
}
walker, ok := s.store.(blobWalker)
if !ok {
if !s.verifyPoP(w, r, ac, nil) {
return
}
walker, ok2 := s.store.(blobWalker)
if !ok2 {
http.Error(w, "store does not support walk", http.StatusNotImplemented)
return
}
count := 0
err := walker.Walk(func(hash string, size int64, mod time.Time) error {
count++
@@ -532,29 +668,45 @@ func (s *Server) handleAdminReindex(w http.ResponseWriter, r *http.Request) {
})
}
// -----------------------------
// Discord SSO (server-side code flow)
// -----------------------------
// ---------- Discord SSO with PKCE + device key binding ----------
func (s *Server) handleDiscordStart(w http.ResponseWriter, r *http.Request, cfg DiscordProvider) {
if !cfg.Enabled || cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.RedirectURI == "" {
http.Error(w, "discord sso disabled", http.StatusBadRequest)
return
}
// Require explicit 3P assent (UI shows disclaimer)
if r.Header.Get("X-GC-3P-Assent") != "1" {
http.Error(w, "third-party provider not assented", http.StatusForbidden)
return
}
deviceKey := strings.TrimSpace(r.Header.Get("X-GC-Key"))
if deviceKey == "" {
http.Error(w, "device key required", http.StatusBadRequest)
return
}
// PKCE
verifier := s.randToken(32)
chalSum := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(chalSum[:])
state := s.randToken(16)
s.stateMu.Lock()
s.states[state] = stateItem{
Exp: time.Now().Add(10 * time.Minute),
Verifier: verifier,
DeviceKey: deviceKey,
}
s.stateMu.Unlock()
state := s.newState(5 * time.Minute)
v := url.Values{}
v.Set("response_type", "code")
v.Set("client_id", cfg.ClientID)
v.Set("redirect_uri", cfg.RedirectURI)
v.Set("scope", "identify")
v.Set("prompt", "consent")
v.Set("state", state)
v.Set("code_challenge", challenge)
v.Set("code_challenge_method", "S256")
authURL := (&url.URL{
Scheme: "https",
Host: "discord.com",
@@ -571,22 +723,31 @@ func (s *Server) handleDiscordCallback(w http.ResponseWriter, r *http.Request, c
http.Error(w, "disabled", http.StatusBadRequest)
return
}
q := r.URL.Query()
code := q.Get("code")
state := q.Get("state")
if code == "" || state == "" || !s.consumeState(state) {
if code == "" || state == "" {
http.Error(w, "invalid state/code", http.StatusBadRequest)
return
}
// Exchange code for token
s.stateMu.Lock()
item, ok := s.states[state]
if ok && time.Now().Before(item.Exp) {
delete(s.states, state)
}
s.stateMu.Unlock()
if !ok {
http.Error(w, "state expired", http.StatusBadRequest)
return
}
// Exchange code for token (with verifier)
form := url.Values{}
form.Set("client_id", cfg.ClientID)
form.Set("client_secret", cfg.ClientSecret)
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", cfg.RedirectURI)
form.Set("code_verifier", item.Verifier)
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, "https://discord.com/api/oauth2/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -604,15 +765,13 @@ func (s *Server) handleDiscordCallback(w http.ResponseWriter, r *http.Request, c
var tok struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
ExpiresIn int64 `json:"expires_in"`
}
if err := json.NewDecoder(res.Body).Decode(&tok); err != nil {
http.Error(w, "token decode failed", 502)
return
}
// Fetch user id (identify scope)
// Fetch user id
ureq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://discord.com/api/users/@me", nil)
ureq.Header.Set("Authorization", tok.TokenType+" "+tok.AccessToken)
ures, err := http.DefaultClient.Do(ureq)
@@ -627,54 +786,31 @@ func (s *Server) handleDiscordCallback(w http.ResponseWriter, r *http.Request, c
return
}
var user struct {
ID string `json:"id"`
Username string `json:"username"`
ID string `json:"id"`
}
if err := json.NewDecoder(ures.Body).Decode(&user); err != nil {
http.Error(w, "user decode failed", 502)
return
}
// Mint self-signed bearer with Discord snowflake as subject
bearer, err := s.makeToken("discord:"+user.ID, time.Hour*8)
// Bind token to device key from /start
ttl := 8 * time.Hour
now := time.Now()
sub := "discord:" + user.ID
bearer, err := auth.MintGC2(s.signingKey, auth.Claims{
Sub: sub, Exp: now.Add(ttl).Unix(), Nbf: now.Add(-60 * time.Second).Unix(),
Iss: "greencoast", Aud: "api", CNF: item.DeviceKey,
})
if err != nil {
http.Error(w, "signing error", 500)
http.Error(w, "sign error", 500)
return
}
// Redirect to frontend callback with bearer in fragment (not query)
target := cfg.RedirectURI
u, _ := url.Parse(target)
u, _ := url.Parse(cfg.RedirectURI)
u.Fragment = "bearer=" + url.QueryEscape(bearer) + "&next=/"
http.Redirect(w, r, u.String(), http.StatusFound)
}
// simple in-memory state store
func (s *Server) newState(ttl time.Duration) string {
s.stateMu.Lock()
defer s.stateMu.Unlock()
b := make([]byte, 12)
now := time.Now().UnixNano()
copy(b, []byte(fmt.Sprintf("%x", now)))
val := base64.RawURLEncoding.EncodeToString(b)
s.states[val] = time.Now().Add(ttl)
return val
}
func (s *Server) consumeState(v string) bool {
s.stateMu.Lock()
defer s.stateMu.Unlock()
exp, ok := s.states[v]
if !ok {
return false
}
delete(s.states, v)
return time.Now().Before(exp)
}
// -----------------------------
// Utilities
// -----------------------------
// ---------- Utilities, shutdown ----------
func isReasonableTZ(tz string) bool {
if !strings.Contains(tz, "/") || len(tz) > 64 {
@@ -688,10 +824,6 @@ func isReasonableTZ(tz string) bool {
return true
}
// -----------------------------
// Optional: graceful shutdown
// -----------------------------
func (s *Server) Shutdown(ctx context.Context) error {
s.sseMu.Lock()
s.sseClosed = true
@@ -703,20 +835,12 @@ func (s *Server) Shutdown(ctx context.Context) error {
return nil
}
// -----------------------------
// Helpers for static serving (optional use)
// -----------------------------
func fileExists(p string) bool {
st, err := os.Stat(p)
return err == nil && !st.IsDir()
}
func joinClean(dir, p string) (string, bool) {
fp := path.Clean("/" + p)
full := path.Clean(dir + fp)
if !strings.HasPrefix(full, path.Clean(dir)) {
return "", false
}
return full, true
func (s *Server) randToken(n int) string {
// HMAC over time + counter to avoid importing crypto/rand; good enough for state/nonce
// (If you prefer, switch to crypto/rand.)
b := []byte(fmt.Sprintf("%d|%d", time.Now().UnixNano(), len(s.states)+len(s.nonceExpiry)))
m := hmac.New(sha256.New, []byte(fmt.Sprintf("%p", s)))
m.Write(b)
sum := m.Sum(nil)
return base64.RawURLEncoding.EncodeToString(sum[:n])
}