package api import ( "bytes" "crypto/ecdsa" "crypto/elliptic" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "math/big" "net/http" "os" "sort" "strconv" "strings" "sync" "time" "greencoast/internal/index" ) // ---------- Storage & server types ---------- type BlobStore interface { Put(hash string, r io.Reader) error Get(hash string) (io.ReadCloser, int64, error) Delete(hash string) error Walk(func(hash string, size int64, mod time.Time) error) error } type DiscordProvider struct { Enabled bool ClientID string ClientSecret string RedirectURI string } type AuthProviders struct { SigningSecretHex string Discord DiscordProvider } type Server struct { store BlobStore idx *index.Index zeroTrust bool coarseTS bool encRequired bool requirePOP bool signingKey []byte discord DiscordProvider // rate limiter rl *rateLimiter // device-key challenge cache chMu sync.Mutex chal map[string]time.Time // nonce -> expires // replay cache for PoP proofs rpMu sync.Mutex replay map[string]time.Time // proof b64 -> expires // SSE subscribers sseMu sync.Mutex sseSub map[chan string]struct{} } func New(store BlobStore, idx *index.Index, coarseTS, zeroTrust bool, providers AuthProviders, encRequired bool) *Server { key, _ := hex.DecodeString(strings.TrimSpace(providers.SigningSecretHex)) s := &Server{ store: store, idx: idx, zeroTrust: zeroTrust, coarseTS: coarseTS, encRequired: encRequired, requirePOP: true, signingKey: key, discord: providers.Discord, chal: make(map[string]time.Time), replay: make(map[string]time.Time), sseSub: make(map[chan string]struct{}), } // Default limiter: ~2 req/sec, burst 20, 10 min idle eviction s.rl = newRateLimiter(2.0, 20, 10*time.Minute) return s } func (s *Server) ListenHTTP(addr string) error { mux := s.routes() log.Printf("http listening on %s", addr) return http.ListenAndServe(addr, s.cors(mux)) } func (s *Server) ListenHTTPS(addr, certFile, keyFile string) error { mux := s.routes() log.Printf("https listening on %s", addr) return http.ListenAndServeTLS(addr, certFile, keyFile, s.cors(mux)) } // ---------- Routing / CORS ---------- func (s *Server) routes() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/healthz", s.handleHealth) // Device-key auth (no PoP) mux.HandleFunc("/v1/auth/key/challenge", s.handleAuthKeyChallenge) mux.HandleFunc("/v1/auth/key/verify", s.handleAuthKeyVerify) // Objects mux.HandleFunc("/v1/object", s.handlePutObject) // PUT (requires bearer+PoP) mux.HandleFunc("/v1/object/", s.handleObjectByHash) // GET/DELETE (DELETE requires bearer+PoP) // Index mux.HandleFunc("/v1/index", s.handleIndexList) mux.HandleFunc("/v1/index/stream", s.handleIndexStream) // Admin (requires admin device sub) mux.HandleFunc("/v1/admin/reindex", s.handleAdminReindex) // GDPR mux.HandleFunc("/v1/gdpr/policy", s.handleGDPRPolicy) // Session panic wipe (no auth; clears browser caches) mux.HandleFunc("/v1/session/clear", s.handleSessionClear) return mux } func (s *Server) cors(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // CORS w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS") 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") // Security 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") w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") // Preflight is free (don’t rate-limit OPTIONS) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } // Rate limit (by device key CNF when available; else IP) key := "" if b := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "); b != "" { if ac, err := s.parseBearer(b); err == nil && ac.CNF != "" { key = ac.CNF } } if key == "" { key = "ip:" + clientIP(r) } if !s.rl.allow(key) { w.Header().Set("Retry-After", "5") http.Error(w, "rate limited", http.StatusTooManyRequests) return } next.ServeHTTP(w, r) }) } func (s *Server) handleSessionClear(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Clear-Site-Data", `"cache","storage"`) w.WriteHeader(http.StatusNoContent) } // ---------- Helpers ---------- func b64u(b []byte) string { return strings.TrimRight(base64.URLEncoding.EncodeToString(b), "=") } func b64ud(s string) ([]byte, error) { if m := len(s) % 4; m != 0 { s += strings.Repeat("=", 4-m) } return base64.URLEncoding.DecodeString(s) } func nowUTC() time.Time { return time.Now().UTC() } // Admin allow-list: env GC_ADMIN_SUBS="sub1,sub2" func (s *Server) isAdmin(ac authContext) bool { raw := strings.TrimSpace(os.Getenv("GC_ADMIN_SUBS")) if raw == "" || ac.CNF == "" { return false } sub := thumbprintFromCNF(ac.CNF) for _, x := range strings.Split(raw, ",") { if sub == strings.TrimSpace(x) { return true } } return false } // ---------- Health ---------- func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = w.Write([]byte("ok")) } // ---------- Device-key auth ---------- func (s *Server) handleAuthKeyChallenge(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // Nonce (32B hex) valid 5 minutes sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%d", time.Now().UnixNano(), time.Now().Unix()))) nonce := hex.EncodeToString(sum[:]) exp := nowUTC().Add(5 * time.Minute) s.chMu.Lock() for k, v := range s.chal { if nowUTC().After(v) { delete(s.chal, k) } } s.chal[nonce] = exp s.chMu.Unlock() w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{ "nonce": nonce, "exp": exp.Format(time.RFC3339Nano), }) } type keyVerifyReq struct { Nonce string `json:"nonce"` Alg string `json:"alg"` // "p256" Pub string `json:"pub"` // base64url raw (uncompressed point, 65 bytes) Sig string `json:"sig"` // base64url DER(ECDSA) or raw r||s (64B) } func (s *Server) handleAuthKeyVerify(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } writeErr := func(code int, msg string) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) log.Printf("auth.key.verify %d %s", code, msg) } if len(s.signingKey) < 32 { writeErr(http.StatusServiceUnavailable, "server not configured (signing key)") return } var req keyVerifyReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(http.StatusBadRequest, "bad json") return } // nonce s.chMu.Lock() exp, ok := s.chal[req.Nonce] if ok { delete(s.chal, req.Nonce) } s.chMu.Unlock() if !ok || nowUTC().After(exp) { writeErr(http.StatusUnauthorized, "nonce expired") return } // params if strings.ToLower(req.Alg) != "p256" || req.Pub == "" || req.Sig == "" { writeErr(http.StatusBadRequest, "bad params") return } pubRaw, err := b64ud(req.Pub) if err != nil || len(pubRaw) != 65 || pubRaw[0] != 0x04 { writeErr(http.StatusBadRequest, "bad pub") return } sig, err := b64ud(req.Sig) if err != nil { writeErr(http.StatusBadRequest, "bad sig") return } msg := []byte("key-verify\n" + req.Nonce) x := new(big.Int).SetBytes(pubRaw[1:33]) y := new(big.Int).SetBytes(pubRaw[33:]) ek := &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y} if !ecdsaVerify(ek, msg, sig) { writeErr(http.StatusUnauthorized, "verify failed") return } // mint token expTok := nowUTC().Add(8 * time.Hour) token, err := s.gc2Mint("p256:"+req.Pub, expTok) if err != nil { writeErr(http.StatusInternalServerError, "token mint error") return } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{ "bearer": token, "sub": thumbprint(pubRaw), "exp": expTok.Format(time.RFC3339Nano), }) } // ecdsaVerify accepts WebCrypto ECDSA signatures in either DER or raw (r||s) format. func ecdsaVerify(pub *ecdsa.PublicKey, msg []byte, sig []byte) bool { h := sha256.Sum256(msg) // Try DER first if R, S, ok := parseECDSADER(sig); ok { return ecdsa.Verify(pub, h[:], R, S) } // Try raw JOSE-style: 64 bytes r||s if len(sig) == 64 { R := new(big.Int).SetBytes(sig[:32]) S := new(big.Int).SetBytes(sig[32:]) return ecdsa.Verify(pub, h[:], R, S) } return false } // Minimal DER parser tolerant to long-form lengths and leading 0x00. func parseECDSADER(der []byte) (*big.Int, *big.Int, bool) { if len(der) < 8 || der[0] != 0x30 { return nil, nil, false } i := 1 // sequence len if i >= len(der) { return nil, nil, false } var seqLen int if der[i]&0x80 != 0 { n := int(der[i] & 0x7f) i++ if n == 0 || i+n > len(der) { return nil, nil, false } for j := 0; j < n; j++ { seqLen = (seqLen << 8) | int(der[i+j]) } i += n } else { seqLen = int(der[i]) i++ } _ = seqLen // not strictly needed // INTEGER R if i >= len(der) || der[i] != 0x02 { return nil, nil, false } i++ if i >= len(der) { return nil, nil, false } rLen := int(der[i]) i++ if rLen <= 0 || i+rLen > len(der) { return nil, nil, false } rb := der[i : i+rLen] i += rLen // INTEGER S if i >= len(der) || der[i] != 0x02 { return nil, nil, false } i++ if i >= len(der) { return nil, nil, false } sLen := int(der[i]) i++ if sLen <= 0 || i+sLen > len(der) { return nil, nil, false } sb := der[i : i+sLen] R := new(big.Int).SetBytes(rb) S := new(big.Int).SetBytes(sb) return R, S, true } // ---------- PoP / bearer ---------- type authContext struct { Bearer string CNF string // "p256:" } func (s *Server) parseBearer(tok string) (authContext, error) { var ac authContext if tok == "" { return ac, errors.New("no token") } parts := strings.Split(tok, ".") if len(parts) != 3 { return ac, errors.New("bad token") } hb, pb, sb := parts[0], parts[1], parts[2] sigData := hb + "." + pb sig, err := b64ud(sb) if err != nil { return ac, errors.New("bad sig") } mac := hmac.New(sha256.New, s.signingKey) _, _ = mac.Write([]byte(sigData)) if !hmac.Equal(mac.Sum(nil), sig) { return ac, errors.New("sig mismatch") } var hdr struct { Alg string `json:"alg"` Typ string `json:"typ"` } var pl struct { Sub string `json:"sub"` CNF string `json:"cnf"` Iat int64 `json:"iat"` Exp int64 `json:"exp"` Jti string `json:"jti"` } if err := json.Unmarshal(mustB64ud(hb), &hdr); err != nil { return ac, err } if err := json.Unmarshal(mustB64ud(pb), &pl); err != nil { return ac, err } if hdr.Typ != "GC2" { return ac, errors.New("typ") } if nowUTC().Unix() >= pl.Exp { return ac, errors.New("expired") } ac.Bearer = tok ac.CNF = pl.CNF return ac, nil } func (s *Server) gc2Mint(cnf string, exp time.Time) (string, error) { if len(s.signingKey) < 32 { return "", errors.New("signing key missing") } hdr := map[string]string{"alg": "HS256", "typ": "GC2"} iat := nowUTC().Unix() sum := sha256.Sum256([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))) jti := b64u(sum[:16]) pl := map[string]any{ "sub": thumbprintFromCNF(cnf), "cnf": cnf, "iat": iat, "exp": exp.Unix(), "jti": jti, } hb := b64u(mustJSON(hdr)) pb := b64u(mustJSON(pl)) sigData := hb + "." + pb mac := hmac.New(sha256.New, s.signingKey) _, _ = mac.Write([]byte(sigData)) sb := b64u(mac.Sum(nil)) return sigData + "." + sb, nil } func mustB64ud(s string) []byte { b, _ := b64ud(s); return b } func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b } func thumbprint(pubRaw []byte) string { sum := sha256.Sum256(pubRaw) return hex.EncodeToString(sum[:8]) } func thumbprintFromCNF(cnf string) string { if strings.HasPrefix(cnf, "p256:") { raw, err := b64ud(strings.TrimPrefix(cnf, "p256:")) if err == nil { return thumbprint(raw) } } return "unknown" } func (s *Server) verifyPoP(w http.ResponseWriter, r *http.Request, ac authContext, body []byte) bool { if !s.requirePOP { return true } if ac.Bearer == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return false } pubH := strings.TrimSpace(r.Header.Get("X-GC-Key")) tsStr := strings.TrimSpace(r.Header.Get("X-GC-TS")) proof := strings.TrimSpace(r.Header.Get("X-GC-Proof")) if pubH == "" || tsStr == "" || proof == "" { http.Error(w, "missing pop", http.StatusUnauthorized) return false } if ac.CNF != pubH { http.Error(w, "cnf mismatch", http.StatusUnauthorized) return false } ts, err := strconv.ParseInt(tsStr, 10, 64) if err != nil { http.Error(w, "bad ts", http.StatusUnauthorized) return false } now := nowUTC().Unix() // Tight window: ±120s if ts < now-120 || ts > now+120 { http.Error(w, "ts window", http.StatusUnauthorized) return false } // Replay cache s.rpMu.Lock() for k, v := range s.replay { if nowUTC().After(v) { delete(s.replay, k) } } if _, ok := s.replay[proof]; ok { s.rpMu.Unlock() http.Error(w, "replay", http.StatusUnauthorized) return false } s.replay[proof] = nowUTC().Add(2 * time.Minute) s.rpMu.Unlock() // Verify signature over METHOD \n PATH \n TS \n SHA256(bodyHex) pathOnly := r.URL.Path d := sha256.Sum256(body) msg := []byte(strings.ToUpper(r.Method) + "\n" + pathOnly + "\n" + tsStr + "\n" + hex.EncodeToString(d[:])) if !strings.HasPrefix(pubH, "p256:") { http.Error(w, "unsupported key", http.StatusUnauthorized) return false } pubRaw, err := b64ud(strings.TrimPrefix(pubH, "p256:")) if err != nil || len(pubRaw) != 65 || pubRaw[0] != 0x04 { http.Error(w, "bad key", http.StatusUnauthorized) return false } sig, err := b64ud(proof) if err != nil { http.Error(w, "bad proof", http.StatusUnauthorized) return false } x := new(big.Int).SetBytes(pubRaw[1:33]) y := new(big.Int).SetBytes(pubRaw[33:]) ek := &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y} if !ecdsaVerify(ek, msg, sig) { http.Error(w, "pop verify", http.StatusUnauthorized) return false } return true } // ---------- Objects ---------- func isReasonableTZ(tz string) bool { return len(tz) >= 3 && len(tz) <= 64 && !strings.ContainsAny(tz, "\r\n\t") } func (s *Server) handlePutObject(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } const maxBlob = int64(10 << 20) r.Body = http.MaxBytesReader(w, r.Body, maxBlob) var buf bytes.Buffer n64, err := io.Copy(&buf, r.Body) if err != nil { http.Error(w, "read error", http.StatusInternalServerError) return } // bearer + PoP ac, _ := s.parseBearer(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) if !s.verifyPoP(w, r, ac, buf.Bytes()) { return } isPrivate := r.Header.Get("X-GC-Private") == "1" if s.encRequired && !isPrivate { http.Error(w, "plaintext disabled on this shard", http.StatusBadRequest) return } creatorTZ := strings.TrimSpace(r.Header.Get("X-GC-TZ")) if creatorTZ != "" && !isReasonableTZ(creatorTZ) { creatorTZ = "" } sum := sha256.Sum256(buf.Bytes()) hash := hex.EncodeToString(sum[:]) if err := s.store.Put(hash, bytes.NewReader(buf.Bytes())); err != nil { http.Error(w, "store error", http.StatusInternalServerError) return } when := time.Now().UTC() if s.coarseTS { when = when.Truncate(time.Minute) } ent := index.Entry{ Hash: hash, Bytes: n64, StoredAt: when.Format(time.RFC3339Nano), Private: isPrivate, CreatorTZ: creatorTZ, } if err := s.idx.Put(ent); err != nil { http.Error(w, "index error", http.StatusInternalServerError) return } s.sseBroadcastJSON(map[string]any{"event": "put", "data": ent}) w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(ent) } func (s *Server) handleObjectByHash(w http.ResponseWriter, r *http.Request) { hash := strings.TrimPrefix(r.URL.Path, "/v1/object/") if hash == "" { http.NotFound(w, r) return } switch r.Method { case http.MethodGet: ac, _ := s.parseBearer(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) // Enforce PoP only if a bearer is presented if ac.Bearer != "" && !s.verifyPoP(w, r, ac, nil) { return } rc, size, err := s.store.Get(hash) if err != nil || rc == nil { http.NotFound(w, r) return } defer rc.Close() w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) _, _ = io.Copy(w, rc) case http.MethodDelete: ac, _ := s.parseBearer(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) if !s.verifyPoP(w, r, ac, nil) { return } if err := s.store.Delete(hash); err != nil { http.Error(w, "delete error", http.StatusInternalServerError) return } _ = s.idx.Delete(hash) s.sseBroadcastJSON(map[string]any{"event": "delete", "data": map[string]string{"hash": hash}}) w.WriteHeader(http.StatusNoContent) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } // ---------- Index / SSE ---------- func (s *Server) handleIndexList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } ac, _ := s.parseBearer(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) if ac.Bearer != "" && !s.verifyPoP(w, r, ac, nil) { return } ents := s.idx.All() sort.Slice(ents, func(i, j int) bool { // Newest first; StoredAt is RFC3339Nano string return ents[i].StoredAt > ents[j].StoredAt }) w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(ents) } func (s *Server) handleIndexStream(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } ac, _ := s.parseBearer(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) if ac.Bearer != "" && !s.verifyPoP(w, r, ac, nil) { return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-store") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "stream unsupported", http.StatusInternalServerError) return } ch := make(chan string, 8) s.sseMu.Lock() s.sseSub[ch] = struct{}{} s.sseMu.Unlock() defer func() { s.sseMu.Lock() delete(s.sseSub, ch) close(ch) s.sseMu.Unlock() }() ctx := r.Context() for { select { case msg := <-ch: _, _ = io.WriteString(w, "data: "+msg+"\n\n") flusher.Flush() case <-time.After(60 * time.Second): _, _ = io.WriteString(w, ": keepalive\n\n") flusher.Flush() case <-ctx.Done(): return } } } func (s *Server) sseBroadcastJSON(v any) { b, _ := json.Marshal(v) s.sseMu.Lock() for ch := range s.sseSub { select { case ch <- string(b): default: } } s.sseMu.Unlock() } // ---------- Admin ---------- func (s *Server) handleAdminReindex(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } ac, _ := s.parseBearer(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) if !s.verifyPoP(w, r, ac, nil) || !s.isAdmin(ac) { http.Error(w, "forbidden", http.StatusForbidden) return } var walked, indexed int64 err := s.store.Walk(func(hash string, size int64, mod time.Time) error { ent := index.Entry{ Hash: hash, Bytes: size, StoredAt: mod.UTC().Format(time.RFC3339Nano), Private: false, } if err := s.idx.Put(ent); err == nil { indexed++ } walked++ return nil }) if err != nil { http.Error(w, "walk error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{ "walked": walked, "indexed": indexed, }) } // ---------- GDPR ---------- func (s *Server) handleGDPRPolicy(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } type posture struct { PII bool `json:"pii"` LogsIPs bool `json:"logs_ips"` LogsUAs bool `json:"logs_user_agents"` Timestamps string `json:"timestamps"` EncryptionAtRest string `json:"encryption_at_rest"` } p := posture{ PII: false, LogsIPs: false, LogsUAs: false, Timestamps: func() string { if s.coarseTS { return "UTC (coarse)" } return "UTC" }(), EncryptionAtRest: func() string { if s.encRequired { return "required (client-side)" } return "optional (client-side)" }(), } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(p) }