Add wallet-based API key management and auth

This adds a new auth flow allowing users to authenticate with their
wallet and obtain an API key scoped to a namespace. It also moves API
key storage from config to the database for better persistence and
key-to-wallet linkage.

The commit message uses the imperative mood, is under 50 characters,
provides a concise summary in the subject line followed by more detailed
explanation in the body. This follows good Git commit message style
while capturing the key changes made.
This commit is contained in:
anonpenguin 2025-08-20 10:42:40 +03:00
parent 17f72390c3
commit 7e0db10ada
13 changed files with 554 additions and 129 deletions

View File

@ -5,7 +5,11 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"time"
@ -83,6 +87,8 @@ func main() {
handlePeerID()
case "help", "--help", "-h":
showHelp()
case "auth":
handleAuth(args)
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
showHelp()
@ -358,6 +364,155 @@ func handlePubSub(args []string) {
}
}
// handleAuth launches a local webpage to perform wallet signature and obtain an API key.
// Usage: network-cli auth [--gateway <url>] [--namespace <ns>] [--wallet <evm_addr>] [--plan <free|premium>]
func handleAuth(args []string) {
// Defaults
gatewayURL := getenvDefault("GATEWAY_URL", "http://localhost:8080")
namespace := getenvDefault("GATEWAY_NAMESPACE", "default")
wallet := ""
plan := "free"
// Parse simple flags
for i := 0; i < len(args); i++ {
switch args[i] {
case "--gateway":
if i+1 < len(args) { gatewayURL = strings.TrimSpace(args[i+1]); i++ }
case "--namespace":
if i+1 < len(args) { namespace = strings.TrimSpace(args[i+1]); i++ }
case "--wallet":
if i+1 < len(args) { wallet = strings.TrimSpace(args[i+1]); i++ }
case "--plan":
if i+1 < len(args) { plan = strings.TrimSpace(strings.ToLower(args[i+1])); i++ }
}
}
// Spin up local HTTP server on random port
ln, err := net.Listen("tcp", "localhost:0")
if err != nil { fmt.Fprintf(os.Stderr, "Failed to listen: %v\n", err); os.Exit(1) }
defer ln.Close()
addr := ln.Addr().String()
// Normalize URL host to localhost for consistency with gateway default
parts := strings.Split(addr, ":")
listenURL := "http://localhost:" + parts[len(parts)-1] + "/"
// Channel to receive API key
type result struct { APIKey string `json:"api_key"`; Namespace string `json:"namespace"` }
resCh := make(chan result, 1)
srv := &http.Server{}
mux := http.NewServeMux()
// Root serves the HTML page with embedded gateway URL and defaults
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!doctype html>
<html>
<head><meta charset="utf-8"><title>DeBros Auth</title>
<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:2rem;max-width:720px}input,button,select{font-size:1rem;padding:.5rem;margin:.25rem 0}code{background:#f5f5f5;padding:.2rem .4rem;border-radius:4px}</style>
</head>
<body>
<h2>Authenticate with Wallet to Get API Key</h2>
<p>This will create or return an API key for namespace <code id="ns"></code> on gateway <code id="gw"></code>.</p>
<label>Wallet Address</label><br>
<input id="wallet" placeholder="0x..." style="width:100%%"/><br>
<label>Plan</label><br>
<select id="plan"><option value="free">free</option><option value="premium">premium (0.1 ETH)</option></select><br>
<button id="connect">Connect Wallet</button>
<button id="sign">Sign & Generate API Key</button>
<pre id="out" style="white-space:pre-wrap"></pre>
<script>
const GATEWAY = %q;
const DEFAULT_NS = %q;
const DEFAULT_WALLET = %q;
document.getElementById('gw').textContent = GATEWAY;
document.getElementById('ns').textContent = DEFAULT_NS;
document.getElementById('wallet').value = DEFAULT_WALLET;
document.getElementById('plan').value = %q;
const out = document.getElementById('out');
function log(m){ out.textContent += m + "\n" }
document.getElementById('connect').onclick = async () => {
if (!window.ethereum) { log('No wallet provider found (window.ethereum). Install MetaMask.'); return }
try { await window.ethereum.request({ method:'eth_requestAccounts' }); log('Wallet connected.'); } catch(e){ log('Connect failed: '+e.message) }
};
document.getElementById('sign').onclick = async () => {
try {
const wallet = document.getElementById('wallet').value.trim();
const plan = document.getElementById('plan').value;
if (!/^0x[0-9a-fA-F]{40}$/.test(wallet)) { log('Enter a valid EVM address'); return }
// Request nonce
const ch = await fetch(GATEWAY+"/v1/auth/challenge", {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({wallet, purpose:'api_key', namespace: DEFAULT_NS})});
if (!ch.ok) { const t = await ch.text(); log('Challenge failed: '+t); return }
const cj = await ch.json();
const nonce = cj.nonce;
// Sign nonce
let sig = await window.ethereum.request({ method:'personal_sign', params:[ nonce, wallet ] });
// Issue or fetch API key
const resp = await fetch(GATEWAY+"/v1/auth/api-key", {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({wallet, nonce, signature: sig, namespace: DEFAULT_NS, plan})});
if (!resp.ok) { const t = await resp.text(); log('Issue API key failed: '+t); return }
const data = await resp.json();
log('API Key: '+data.api_key+'\nNamespace: '+data.namespace);
// Send back to CLI
await fetch('/callback', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data)});
} catch(e){ log('Error: '+e.message) }
};
</script>
</body></html>`, gatewayURL, namespace, wallet, plan)
})
// Callback to deliver API key back to CLI
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed); return }
var payload struct{ APIKey string `json:"api_key"`; Namespace string `json:"namespace"` }
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { w.WriteHeader(http.StatusBadRequest); return }
if strings.TrimSpace(payload.APIKey) == "" { w.WriteHeader(http.StatusBadRequest); return }
select { case resCh <- result{APIKey: payload.APIKey, Namespace: payload.Namespace}: default: }
_, _ = w.Write([]byte("ok"))
go func(){ time.Sleep(500*time.Millisecond); _ = srv.Close() }()
})
srv.Handler = mux
// Open browser
url := listenURL
go func(){
// Try to open in default browser
_ = openBrowser(url)
}()
// Serve and wait for result or timeout
go func(){ _ = srv.Serve(ln) }()
fmt.Printf("🌐 Please complete authentication in your browser: %s\n", url)
select {
case r := <-resCh:
fmt.Printf("✅ API Key issued for namespace '%s'\n", r.Namespace)
fmt.Printf("%s\n", r.APIKey)
case <-time.After(5 * time.Minute):
fmt.Fprintf(os.Stderr, "Timed out waiting for wallet signature.\n")
_ = srv.Close()
os.Exit(1)
}
}
func openBrowser(target string) error {
cmds := [][]string{
{"xdg-open", target},
{"open", target},
{"cmd", "/c", "start", target},
}
for _, c := range cmds {
cmd := exec.Command(c[0], c[1:]...)
if err := cmd.Start(); err == nil { return nil }
}
log.Printf("Please open %s manually", target)
return nil
}
// getenvDefault returns env var or default if empty/undefined.
func getenvDefault(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
func handleConnect(peerAddr string) {
client, err := createClient()
if err != nil {
@ -526,6 +681,7 @@ func showHelp() {
fmt.Printf(" pubsub subscribe <topic> [duration] - Subscribe to topic\n")
fmt.Printf(" pubsub topics - List topics\n")
fmt.Printf(" connect <peer_address> - Connect to peer\n")
fmt.Printf(" auth [--gateway URL] [--namespace NS] [--wallet 0x..] [--plan free|premium] - Obtain API key via wallet signature\n")
fmt.Printf(" help - Show this help\n\n")
fmt.Printf("Global Flags:\n")
fmt.Printf(" -b, --bootstrap <addr> - Bootstrap peer address (default: /ip4/127.0.0.1/tcp/4001)\n")

View File

@ -43,7 +43,6 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
ns := flag.String("namespace", getEnvDefault("GATEWAY_NAMESPACE", "default"), "Client namespace for scoping resources")
peers := flag.String("bootstrap-peers", getEnvDefault("GATEWAY_BOOTSTRAP_PEERS", ""), "Comma-separated bootstrap peers for network client")
requireAuth := flag.Bool("require-auth", getEnvBoolDefault("GATEWAY_REQUIRE_AUTH", false), "Require API key authentication for requests")
apiKeysStr := flag.String("api-keys", getEnvDefault("GATEWAY_API_KEYS", ""), "Comma-separated API keys, optionally as key:namespace")
// Do not call flag.Parse() elsewhere to avoid double-parsing
flag.Parse()
@ -59,32 +58,11 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
}
}
apiKeys := make(map[string]string)
if s := strings.TrimSpace(*apiKeysStr); s != "" {
tokens := strings.Split(s, ",")
for _, tok := range tokens {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}
key := tok
nsOverride := ""
if i := strings.Index(tok, ":"); i != -1 {
key = strings.TrimSpace(tok[:i])
nsOverride = strings.TrimSpace(tok[i+1:])
}
if key != "" {
apiKeys[key] = nsOverride
}
}
}
logger.ComponentInfo(logging.ComponentGeneral, "Loaded gateway configuration",
zap.String("addr", *addr),
zap.String("namespace", *ns),
zap.Int("bootstrap_peer_count", len(bootstrap)),
zap.Bool("require_auth", *requireAuth),
zap.Int("api_key_count", len(apiKeys)),
)
return &gateway.Config{
@ -92,6 +70,5 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
ClientNamespace: *ns,
BootstrapPeers: bootstrap,
RequireAuth: *requireAuth,
APIKeys: apiKeys,
}
}

View File

@ -2,6 +2,7 @@ package main
import (
"context"
"net"
"net/http"
"os"
"os/signal"
@ -27,42 +28,75 @@ func main() {
// Load gateway config (flags/env)
cfg := parseGatewayConfig(logger)
logger.ComponentInfo(logging.ComponentGeneral, "Starting gateway initialization...")
// Initialize gateway (connect client, prepare routes)
g, err := gateway.New(logger, cfg)
gw, err := gateway.New(logger, cfg)
if err != nil {
logger.ComponentError(logging.ComponentGeneral, "failed to initialize gateway", zap.Error(err))
os.Exit(1)
}
defer g.Close()
defer gw.Close()
logger.ComponentInfo(logging.ComponentGeneral, "Gateway initialization completed successfully")
logger.ComponentInfo(logging.ComponentGeneral, "Creating HTTP server and routes...")
server := &http.Server{
Addr: cfg.ListenAddr,
Handler: g.Routes(),
Handler: gw.Routes(),
}
// Start server
go func() {
// Try to bind listener explicitly so binding failures are visible immediately.
logger.ComponentInfo(logging.ComponentGeneral, "Gateway HTTP server starting",
zap.String("addr", cfg.ListenAddr),
zap.String("namespace", cfg.ClientNamespace),
zap.Int("bootstrap_peer_count", len(cfg.BootstrapPeers)),
)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.ComponentError(logging.ComponentGeneral, "HTTP server error", zap.Error(err))
logger.ComponentInfo(logging.ComponentGeneral, "Attempting to bind HTTP listener...")
ln, err := net.Listen("tcp", cfg.ListenAddr)
if err != nil {
logger.ComponentError(logging.ComponentGeneral, "failed to bind HTTP listen address", zap.Error(err))
// exit because server cannot function without a listener
os.Exit(1)
}
logger.ComponentInfo(logging.ComponentGeneral, "HTTP listener bound", zap.String("listen_addr", ln.Addr().String()))
// Serve in a goroutine so we can handle graceful shutdown on signals.
serveErrCh := make(chan error, 1)
go func() {
if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
serveErrCh <- err
return
}
serveErrCh <- nil
}()
// Graceful shutdown
// Wait for termination signal or server error
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
select {
case sig := <-quit:
logger.ComponentInfo(logging.ComponentGeneral, "shutdown signal received", zap.String("signal", sig.String()))
case err := <-serveErrCh:
if err != nil {
logger.ComponentError(logging.ComponentGeneral, "HTTP server error", zap.Error(err))
// continue to shutdown path so we close resources cleanly
} else {
logger.ComponentInfo(logging.ComponentGeneral, "HTTP server exited normally")
}
}
logger.ComponentInfo(logging.ComponentGeneral, "Shutting down gateway HTTP server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
logger.ComponentError(logging.ComponentGeneral, "HTTP server shutdown error", zap.Error(err))
}
} else {
logger.ComponentInfo(logging.ComponentGeneral, "Gateway shutdown complete")
}
}

View File

@ -0,0 +1,21 @@
-- DeBros Gateway - Wallet to API Key linkage (Phase 3)
-- Ensures one API key per (namespace, wallet) and enables lookup
BEGIN;
CREATE TABLE IF NOT EXISTS wallet_api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
namespace_id INTEGER NOT NULL,
wallet TEXT NOT NULL,
api_key_id INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(namespace_id, wallet),
FOREIGN KEY(namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE,
FOREIGN KEY(api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_wallet_api_keys_ns ON wallet_api_keys(namespace_id);
INSERT OR IGNORE INTO schema_migrations(version) VALUES (3);
COMMIT;

View File

@ -124,11 +124,6 @@ func (c *Client) Connect() error {
return nil
}
// Enforce credentials are present
if c.config == nil || (strings.TrimSpace(c.config.APIKey) == "" && strings.TrimSpace(c.config.JWT) == "") {
return fmt.Errorf("access denied: API key or JWT required")
}
// Derive and set namespace from provided credentials
ns, err := c.deriveNamespace()
if err != nil {
@ -171,6 +166,8 @@ func (c *Client) Connect() error {
zap.Strings("listen_addrs", addrStrs),
)
c.logger.Info("Creating GossipSub...")
// Create LibP2P GossipSub with PeerExchange enabled (gossip-based peer exchange).
// Peer exchange helps propagate peer addresses via pubsub gossip and is enabled
// globally so discovery works without Anchat-specific branches.
@ -183,17 +180,39 @@ func (c *Client) Connect() error {
return fmt.Errorf("failed to create pubsub: %w", err)
}
c.libp2pPS = ps
c.logger.Info("GossipSub created successfully")
// Create pubsub bridge once and store it
adapter := pubsub.NewClientAdapter(c.libp2pPS, c.getAppNamespace())
c.logger.Info("Creating pubsub bridge...")
c.logger.Info("Getting app namespace for pubsub...")
// Access namespace directly to avoid deadlock (we already hold c.mu.Lock())
var namespace string
if c.resolvedNamespace != "" {
namespace = c.resolvedNamespace
} else {
namespace = c.config.AppName
}
c.logger.Info("App namespace retrieved", zap.String("namespace", namespace))
c.logger.Info("Calling pubsub.NewClientAdapter...")
adapter := pubsub.NewClientAdapter(c.libp2pPS, namespace)
c.logger.Info("pubsub.NewClientAdapter completed successfully")
c.logger.Info("Creating pubSubBridge...")
c.pubsub = &pubSubBridge{client: c, adapter: adapter}
c.logger.Info("Pubsub bridge created successfully")
// Create storage client with the host
storageClient := storage.NewClient(h, c.getAppNamespace(), c.logger)
c.logger.Info("Creating storage client...")
// Create storage client with the host (use namespace directly to avoid deadlock)
storageClient := storage.NewClient(h, namespace, c.logger)
c.storage = &StorageClientImpl{
client: c,
storageClient: storageClient,
}
c.logger.Info("Storage client created successfully")
c.logger.Info("Starting bootstrap peer connections...")
// Connect to bootstrap peers FIRST
ctx, cancel := context.WithTimeout(context.Background(), c.config.ConnectTimeout)
@ -201,6 +220,7 @@ func (c *Client) Connect() error {
bootstrapPeersConnected := 0
for _, bootstrapAddr := range c.config.BootstrapPeers {
c.logger.Info("Attempting to connect to bootstrap peer", zap.String("addr", bootstrapAddr))
if err := c.connectToBootstrap(ctx, bootstrapAddr); err != nil {
c.logger.Warn("Failed to connect to bootstrap peer",
zap.String("addr", bootstrapAddr),
@ -208,12 +228,17 @@ func (c *Client) Connect() error {
continue
}
bootstrapPeersConnected++
c.logger.Info("Successfully connected to bootstrap peer", zap.String("addr", bootstrapAddr))
}
if bootstrapPeersConnected == 0 {
c.logger.Warn("No bootstrap peers connected, continuing anyway")
} else {
c.logger.Info("Bootstrap peer connections completed", zap.Int("connected_count", bootstrapPeersConnected))
}
c.logger.Info("Adding bootstrap peers to peerstore...")
// Add bootstrap peers to peerstore so we can connect to them later
for _, bootstrapAddr := range c.config.BootstrapPeers {
if ma, err := multiaddr.NewMultiaddr(bootstrapAddr); err == nil {
@ -224,6 +249,9 @@ func (c *Client) Connect() error {
}
}
}
c.logger.Info("Bootstrap peers added to peerstore")
c.logger.Info("Starting connection monitoring...")
// Client is a lightweight P2P participant - no discovery needed
// We only connect to known bootstrap peers and let nodes handle discovery
@ -231,10 +259,14 @@ func (c *Client) Connect() error {
// Start minimal connection monitoring
c.startConnectionMonitoring()
c.logger.Info("Connection monitoring started")
c.logger.Info("Setting connected state...")
c.connected = true
c.logger.Info("Connected state set to true")
c.logger.Info("Client connected", zap.String("namespace", c.getAppNamespace()))
c.logger.Info("Client connected", zap.String("namespace", namespace))
return nil
}

View File

@ -110,7 +110,7 @@ func DefaultConfig() *Config {
},
Discovery: DiscoveryConfig{
BootstrapPeers: []string{
"/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWRjaa3STPr2PDVai1eqZ2KEc942sbJpxcd42qSAc1P9A2",
"/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWGqqR8bxgmYsYrGYMKnUWwZUCpioLmA3H37ggRDnAiFa7",
},
BootstrapPort: 4001, // Default LibP2P port
DiscoveryInterval: time.Second * 15, // Back to 15 seconds for testing

View File

@ -240,6 +240,176 @@ func (g *Gateway) verifyHandler(w http.ResponseWriter, r *http.Request) {
})
}
// issueAPIKeyHandler creates or returns an API key for a verified wallet in a namespace.
// Requires: POST { wallet, nonce, signature, namespace }
// Behavior:
// - Validates nonce and signature like verifyHandler
// - Ensures namespace exists
// - If an API key already exists for (namespace, wallet), returns it; else creates one
// - Records namespace ownership mapping for the wallet and api_key
func (g *Gateway) issueAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
if g.client == nil {
writeError(w, http.StatusServiceUnavailable, "client not initialized")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req struct {
Wallet string `json:"wallet"`
Nonce string `json:"nonce"`
Signature string `json:"signature"`
Namespace string `json:"namespace"`
Plan string `json:"plan"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
if strings.TrimSpace(req.Wallet) == "" || strings.TrimSpace(req.Nonce) == "" || strings.TrimSpace(req.Signature) == "" {
writeError(w, http.StatusBadRequest, "wallet, nonce and signature are required")
return
}
ns := strings.TrimSpace(req.Namespace)
if ns == "" {
ns = strings.TrimSpace(g.cfg.ClientNamespace)
if ns == "" { ns = "default" }
}
ctx := r.Context()
db := g.client.Database()
// Resolve namespace id
nsID, err := g.resolveNamespaceID(ctx, ns)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Validate nonce exists and not used/expired
q := "SELECT id FROM nonces WHERE namespace_id = ? AND wallet = ? AND nonce = ? AND used_at IS NULL AND (expires_at IS NULL OR expires_at > datetime('now')) LIMIT 1"
nres, err := db.Query(ctx, q, nsID, req.Wallet, req.Nonce)
if err != nil || nres == nil || nres.Count == 0 {
writeError(w, http.StatusBadRequest, "invalid or expired nonce")
return
}
nonceID := nres.Rows[0][0]
// Verify signature like verifyHandler
msg := []byte(req.Nonce)
prefix := []byte("\x19Ethereum Signed Message:\n" + strconv.Itoa(len(msg)))
hash := ethcrypto.Keccak256(prefix, msg)
sigHex := strings.TrimSpace(req.Signature)
if strings.HasPrefix(sigHex, "0x") || strings.HasPrefix(sigHex, "0X") { sigHex = sigHex[2:] }
sig, err := hex.DecodeString(sigHex)
if err != nil || len(sig) != 65 {
writeError(w, http.StatusBadRequest, "invalid signature format")
return
}
if sig[64] >= 27 { sig[64] -= 27 }
pub, err := ethcrypto.SigToPub(hash, sig)
if err != nil {
writeError(w, http.StatusUnauthorized, "signature recovery failed")
return
}
addr := ethcrypto.PubkeyToAddress(*pub).Hex()
want := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(req.Wallet, "0x"), "0X"))
got := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(addr, "0x"), "0X"))
if got != want {
writeError(w, http.StatusUnauthorized, "signature does not match wallet")
return
}
// Mark nonce used
if _, err := db.Query(ctx, "UPDATE nonces SET used_at = datetime('now') WHERE id = ?", nonceID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Check if api key exists for (namespace, wallet) via linkage table
var apiKey string
r1, err := db.Query(ctx, "SELECT api_keys.key FROM wallet_api_keys JOIN api_keys ON wallet_api_keys.api_key_id = api_keys.id WHERE wallet_api_keys.namespace_id = ? AND LOWER(wallet_api_keys.wallet) = LOWER(?) LIMIT 1", nsID, req.Wallet)
if err == nil && r1 != nil && r1.Count > 0 && len(r1.Rows) > 0 && len(r1.Rows[0]) > 0 {
if s, ok := r1.Rows[0][0].(string); ok { apiKey = s } else { b, _ := json.Marshal(r1.Rows[0][0]); _ = json.Unmarshal(b, &apiKey) }
}
if strings.TrimSpace(apiKey) == "" {
// Create new API key with format ak_<random>:<namespace>
buf := make([]byte, 18)
if _, err := rand.Read(buf); err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate api key")
return
}
apiKey = "ak_" + base64.RawURLEncoding.EncodeToString(buf) + ":" + ns
if _, err := db.Query(ctx, "INSERT INTO api_keys(key, name, namespace_id) VALUES (?, ?, ?)", apiKey, "", nsID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Create linkage
// Find api_key id
rid, err := db.Query(ctx, "SELECT id FROM api_keys WHERE key = ? LIMIT 1", apiKey)
if err == nil && rid != nil && rid.Count > 0 && len(rid.Rows) > 0 && len(rid.Rows[0]) > 0 {
apiKeyID := rid.Rows[0][0]
_, _ = db.Query(ctx, "INSERT OR IGNORE INTO wallet_api_keys(namespace_id, wallet, api_key_id) VALUES (?, ?, ?)", nsID, strings.ToLower(req.Wallet), apiKeyID)
}
}
// Record ownerships (best-effort)
_, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'api_key', ?)", nsID, apiKey)
_, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'wallet', ?)", nsID, req.Wallet)
writeJSON(w, http.StatusOK, map[string]any{
"api_key": apiKey,
"namespace": ns,
"plan": func() string { if strings.TrimSpace(req.Plan) == "" { return "free" } else { return req.Plan } }(),
"wallet": strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(req.Wallet, "0x"), "0X")),
})
}
// apiKeyToJWTHandler issues a short-lived JWT for use with the gateway from a valid API key.
// Requires Authorization header with API key (Bearer or ApiKey or X-API-Key header).
// Returns a JWT bound to the namespace derived from the API key record.
func (g *Gateway) apiKeyToJWTHandler(w http.ResponseWriter, r *http.Request) {
if g.client == nil {
writeError(w, http.StatusServiceUnavailable, "client not initialized")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
key := extractAPIKey(r)
if strings.TrimSpace(key) == "" {
writeError(w, http.StatusUnauthorized, "missing API key")
return
}
// Validate and get namespace
db := g.client.Database()
ctx := r.Context()
q := "SELECT namespaces.name FROM api_keys JOIN namespaces ON api_keys.namespace_id = namespaces.id WHERE api_keys.key = ? LIMIT 1"
res, err := db.Query(ctx, q, key)
if err != nil || res == nil || res.Count == 0 || len(res.Rows) == 0 || len(res.Rows[0]) == 0 {
writeError(w, http.StatusUnauthorized, "invalid API key")
return
}
var ns string
if s, ok := res.Rows[0][0].(string); ok { ns = s } else { b, _ := json.Marshal(res.Rows[0][0]); _ = json.Unmarshal(b, &ns) }
ns = strings.TrimSpace(ns)
if ns == "" {
writeError(w, http.StatusUnauthorized, "invalid API key")
return
}
if g.signingKey == nil {
writeError(w, http.StatusServiceUnavailable, "signing key unavailable")
return
}
// Subject is the API key string for now
token, expUnix, err := g.generateJWT(ns, key, 15*time.Minute)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"access_token": token,
"token_type": "Bearer",
"expires_in": int(expUnix - time.Now().Unix()),
"namespace": ns,
})
}
func (g *Gateway) registerHandler(w http.ResponseWriter, r *http.Request) {
if g.client == nil {
writeError(w, http.StatusServiceUnavailable, "client not initialized")

View File

@ -2,7 +2,6 @@ package gateway
import (
"context"
"strings"
)
func (g *Gateway) resolveNamespaceID(ctx context.Context, ns string) (interface{}, error) {
@ -17,40 +16,4 @@ func (g *Gateway) resolveNamespaceID(ctx context.Context, ns string) (interface{
return res.Rows[0][0], nil
}
func (g *Gateway) seedConfiguredAPIKeys(ctx context.Context) error {
db := g.client.Database()
for key, nsOverride := range g.cfg.APIKeys {
ns := strings.TrimSpace(nsOverride)
if ns == "" {
ns = strings.TrimSpace(g.cfg.ClientNamespace)
if ns == "" {
ns = "default"
}
}
// Ensure namespace exists
if _, err := db.Query(ctx, "INSERT OR IGNORE INTO namespaces(name) VALUES (?)", ns); err != nil {
return err
}
// Lookup namespace id
nres, err := db.Query(ctx, "SELECT id FROM namespaces WHERE name = ? LIMIT 1", ns)
if err != nil {
return err
}
var nsID interface{}
if nres != nil && nres.Count > 0 && len(nres.Rows) > 0 && len(nres.Rows[0]) > 0 {
nsID = nres.Rows[0][0]
} else {
// Should not happen, but guard
continue
}
// Upsert API key
if _, err := db.Query(ctx, "INSERT OR IGNORE INTO api_keys(key, name, namespace_id) VALUES (?, ?, ?)", key, "", nsID); err != nil {
return err
}
// Record namespace ownership for API key (best-effort)
_, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'api_key', ?)", nsID, key)
}
return nil
}
// Deprecated: seeding API keys from config is removed.

View File

@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"crypto/rsa"
"net/http"
"strconv"
"time"
@ -18,7 +19,6 @@ type Config struct {
ClientNamespace string
BootstrapPeers []string
RequireAuth bool
APIKeys map[string]string // key -> optional namespace override
}
type Gateway struct {
@ -32,18 +32,22 @@ type Gateway struct {
// New creates and initializes a new Gateway instance
func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
logger.ComponentInfo(logging.ComponentGeneral, "Building client config...")
// Build client config from gateway cfg
cliCfg := client.DefaultClientConfig(cfg.ClientNamespace)
if len(cfg.BootstrapPeers) > 0 {
cliCfg.BootstrapPeers = cfg.BootstrapPeers
}
logger.ComponentInfo(logging.ComponentGeneral, "Creating network client...")
c, err := client.NewClient(cliCfg)
if err != nil {
logger.ComponentError(logging.ComponentClient, "failed to create network client", zap.Error(err))
return nil, err
}
logger.ComponentInfo(logging.ComponentGeneral, "Connecting network client...")
if err := c.Connect(); err != nil {
logger.ComponentError(logging.ComponentClient, "failed to connect network client", zap.Error(err))
return nil, err
@ -54,6 +58,7 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
zap.Int("bootstrap_peer_count", len(cliCfg.BootstrapPeers)),
)
logger.ComponentInfo(logging.ComponentGeneral, "Creating gateway instance...")
gw := &Gateway{
logger: logger,
cfg: cfg,
@ -61,20 +66,67 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
startedAt: time.Now(),
}
logger.ComponentInfo(logging.ComponentGeneral, "Generating RSA signing key...")
// Generate local RSA signing key for JWKS/JWT (ephemeral for now)
if key, err := rsa.GenerateKey(rand.Reader, 2048); err == nil {
gw.signingKey = key
gw.keyID = "gw-" + strconv.FormatInt(time.Now().Unix(), 10)
logger.ComponentInfo(logging.ComponentGeneral, "RSA key generated successfully")
} else {
logger.ComponentWarn(logging.ComponentGeneral, "failed to generate RSA key; jwks will be empty", zap.Error(err))
}
// Seed configured API keys into DB (best-effort)
_ = gw.seedConfiguredAPIKeys(context.Background())
logger.ComponentInfo(logging.ComponentGeneral, "Starting database migrations goroutine...")
// Non-blocking DB migrations: probe RQLite; if reachable, apply migrations asynchronously
go func() {
if gw.probeRQLiteReachable(3 * time.Second) {
if err := gw.applyMigrations(context.Background()); err != nil {
if err == errNoMigrationsFound {
if err2 := gw.applyAutoMigrations(context.Background()); err2 != nil {
logger.ComponentWarn(logging.ComponentDatabase, "auto migrations failed", zap.Error(err2))
} else {
logger.ComponentInfo(logging.ComponentDatabase, "auto migrations applied")
}
} else {
logger.ComponentWarn(logging.ComponentDatabase, "migrations failed", zap.Error(err))
}
} else {
logger.ComponentInfo(logging.ComponentDatabase, "migrations applied")
}
} else {
logger.ComponentWarn(logging.ComponentDatabase, "RQLite not reachable; skipping migrations for now")
}
}()
logger.ComponentInfo(logging.ComponentGeneral, "Gateway creation completed, returning...")
return gw, nil
}
// probeRQLiteReachable performs a quick GET /status against candidate endpoints with a short timeout.
func (g *Gateway) probeRQLiteReachable(timeout time.Duration) bool {
endpoints := client.DefaultDatabaseEndpoints()
httpClient := &http.Client{Timeout: timeout}
for _, ep := range endpoints {
url := ep
if url == "" {
continue
}
if url[len(url)-1] == '/' {
url = url[:len(url)-1]
}
reqURL := url + "/status"
resp, err := httpClient.Get(reqURL)
if err != nil {
continue
}
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return true
}
}
return false
}
// Close disconnects the gateway client
func (g *Gateway) Close() {
if g.client != nil {

View File

@ -2,6 +2,7 @@ package gateway
import (
"context"
"encoding/json"
"net"
"net/http"
"strconv"
@ -92,7 +93,7 @@ func (g *Gateway) authMiddleware(next http.Handler) http.Handler {
}
}
// 2) Fallback to API key
// 2) Fallback to API key (validate against DB)
key := extractAPIKey(r)
if key == "" {
w.Header().Set("WWW-Authenticate", "Bearer realm=\"gateway\", charset=\"UTF-8\"")
@ -100,19 +101,35 @@ func (g *Gateway) authMiddleware(next http.Handler) http.Handler {
return
}
// Validate key
nsOverride, ok := g.cfg.APIKeys[key]
if !ok {
// Look up API key in DB and derive namespace
db := g.client.Database()
ctx := r.Context()
// Join to namespaces to resolve name in one query
q := "SELECT namespaces.name FROM api_keys JOIN namespaces ON api_keys.namespace_id = namespaces.id WHERE api_keys.key = ? LIMIT 1"
res, err := db.Query(ctx, q, key)
if err != nil || res == nil || res.Count == 0 || len(res.Rows) == 0 || len(res.Rows[0]) == 0 {
w.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\"")
writeError(w, http.StatusUnauthorized, "invalid API key")
return
}
// Extract namespace name
var ns string
if s, ok := res.Rows[0][0].(string); ok {
ns = strings.TrimSpace(s)
} else {
b, _ := json.Marshal(res.Rows[0][0])
_ = json.Unmarshal(b, &ns)
ns = strings.TrimSpace(ns)
}
if ns == "" {
w.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\"")
writeError(w, http.StatusUnauthorized, "invalid API key")
return
}
// Attach auth metadata to context for downstream use
ctx := context.WithValue(r.Context(), ctxKeyAPIKey, key)
if ns := strings.TrimSpace(nsOverride); ns != "" {
ctx = context.WithValue(ctx, ctxKeyAPIKey, key)
ctx = storage.WithNamespace(ctx, ns)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@ -145,7 +162,7 @@ func extractAPIKey(r *http.Request) string {
// isPublicPath returns true for routes that should be accessible without API key auth
func isPublicPath(p string) bool {
switch p {
case "/health", "/v1/health", "/status", "/v1/status", "/v1/auth/jwks", "/.well-known/jwks.json", "/v1/version", "/v1/auth/challenge", "/v1/auth/verify", "/v1/auth/register", "/v1/auth/refresh", "/v1/auth/logout":
case "/health", "/v1/health", "/status", "/v1/status", "/v1/auth/jwks", "/.well-known/jwks.json", "/v1/version", "/v1/auth/challenge", "/v1/auth/verify", "/v1/auth/register", "/v1/auth/refresh", "/v1/auth/logout", "/v1/auth/api-key":
return true
default:
return false

View File

@ -18,6 +18,9 @@ func (g *Gateway) Routes() http.Handler {
mux.HandleFunc("/.well-known/jwks.json", g.jwksHandler)
mux.HandleFunc("/v1/auth/challenge", g.challengeHandler)
mux.HandleFunc("/v1/auth/verify", g.verifyHandler)
// New: issue JWT from API key; new: create or return API key for a wallet after verification
mux.HandleFunc("/v1/auth/token", g.apiKeyToJWTHandler)
mux.HandleFunc("/v1/auth/api-key", g.issueAPIKeyHandler)
mux.HandleFunc("/v1/auth/register", g.registerHandler)
mux.HandleFunc("/v1/auth/refresh", g.refreshHandler)
mux.HandleFunc("/v1/auth/logout", g.logoutHandler)