From 5fed8a6c8813d6574340eb4437a332670d264dc6 Mon Sep 17 00:00:00 2001 From: anonpenguin23 Date: Fri, 13 Feb 2026 07:38:54 +0200 Subject: [PATCH] Fixed firewall problem with anyone rellay and added authentication with root wallet --- .gitignore | 4 +- cmd/gateway/config.go | 11 + go.mod | 2 + go.sum | 4 + migrations/017_phantom_auth_sessions.sql | 21 + pkg/auth/phantom.go | 195 ++++++ pkg/auth/rootwallet.go | 229 +++++++ pkg/auth/simple_auth.go | 52 +- pkg/cli/auth_commands.go | 77 ++- pkg/cli/production/upgrade/orchestrator.go | 71 ++- pkg/gateway/auth/solana_nft.go | 601 +++++++++++++++++++ pkg/gateway/config.go | 5 + pkg/gateway/gateway.go | 13 + pkg/gateway/handlers/auth/apikey_handler.go | 10 +- pkg/gateway/handlers/auth/handlers.go | 8 + pkg/gateway/handlers/auth/phantom_handler.go | 318 ++++++++++ pkg/gateway/handlers/auth/wallet_handler.go | 325 ---------- pkg/gateway/middleware.go | 7 +- pkg/gateway/routes.go | 7 +- pkg/node/gateway.go | 29 +- scripts/patches/fix-anyone-relay.sh | 130 ++++ 21 files changed, 1740 insertions(+), 379 deletions(-) create mode 100644 migrations/017_phantom_auth_sessions.sql create mode 100644 pkg/auth/phantom.go create mode 100644 pkg/auth/rootwallet.go create mode 100644 pkg/gateway/auth/solana_nft.go create mode 100644 pkg/gateway/handlers/auth/phantom_handler.go create mode 100755 scripts/patches/fix-anyone-relay.sh diff --git a/.gitignore b/.gitignore index 6137656..e0f6ea4 100644 --- a/.gitignore +++ b/.gitignore @@ -107,4 +107,6 @@ terms-agreement cli ./inspector -results/ \ No newline at end of file +results/ + +phantom-auth/ \ No newline at end of file diff --git a/cmd/gateway/config.go b/cmd/gateway/config.go index 3983f2c..331233b 100644 --- a/cmd/gateway/config.go +++ b/cmd/gateway/config.go @@ -192,6 +192,17 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config { cfg.IPFSReplicationFactor = y.IPFSReplicationFactor } + // Phantom Solana auth (from env vars) + if v := os.Getenv("PHANTOM_AUTH_URL"); v != "" { + cfg.PhantomAuthURL = v + } + if v := os.Getenv("SOLANA_RPC_URL"); v != "" { + cfg.SolanaRPCURL = v + } + if v := os.Getenv("NFT_COLLECTION_ADDRESS"); v != "" { + cfg.NFTCollectionAddress = v + } + // Validate configuration if errs := cfg.ValidateConfig(); len(errs) > 0 { fmt.Fprintf(os.Stderr, "\nGateway configuration errors (%d):\n", len(errs)) diff --git a/go.mod b/go.mod index aeba8be..7db3df6 100644 --- a/go.mod +++ b/go.mod @@ -176,6 +176,7 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mdp/qrterminal/v3 v3.2.1 // indirect github.com/miekg/dns v1.1.70 // indirect github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect @@ -315,6 +316,7 @@ require ( k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect lukechampine.com/blake3 v1.4.1 // indirect + rsc.io/qr v0.2.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/mcs-api v0.3.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index 8c64240..33c12a2 100644 --- a/go.sum +++ b/go.sum @@ -485,6 +485,8 @@ github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxU github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= github.com/miekg/dns v1.1.31/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= @@ -1149,6 +1151,8 @@ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/mcs-api v0.3.0 h1:LjRvgzjMrvO1904GP6XBJSnIX221DJMyQlZOYt9LAnM= diff --git a/migrations/017_phantom_auth_sessions.sql b/migrations/017_phantom_auth_sessions.sql new file mode 100644 index 0000000..8d07eed --- /dev/null +++ b/migrations/017_phantom_auth_sessions.sql @@ -0,0 +1,21 @@ +-- Migration 017: Phantom auth sessions for QR code + deep link authentication +-- Stores session state for the CLI-to-phone relay pattern via the gateway + +BEGIN; + +CREATE TABLE IF NOT EXISTS phantom_auth_sessions ( + id TEXT PRIMARY KEY, + namespace TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + wallet TEXT, + api_key TEXT, + error_message TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_phantom_sessions_status ON phantom_auth_sessions(status); + +INSERT OR IGNORE INTO schema_migrations(version) VALUES (17); + +COMMIT; diff --git a/pkg/auth/phantom.go b/pkg/auth/phantom.go new file mode 100644 index 0000000..62d073b --- /dev/null +++ b/pkg/auth/phantom.go @@ -0,0 +1,195 @@ +package auth + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/DeBrosOfficial/network/pkg/tlsutil" + qrterminal "github.com/mdp/qrterminal/v3" +) + +// PhantomSession represents a phantom auth session from the gateway. +type PhantomSession struct { + SessionID string `json:"session_id"` + AuthURL string `json:"auth_url"` + ExpiresAt string `json:"expires_at"` +} + +// PhantomSessionStatus represents the polled status of a phantom auth session. +type PhantomSessionStatus struct { + SessionID string `json:"session_id"` + Status string `json:"status"` + Wallet string `json:"wallet"` + APIKey string `json:"api_key"` + Namespace string `json:"namespace"` + Error string `json:"error"` +} + +// PerformPhantomAuthentication runs the Phantom Solana auth flow: +// 1. Prompt for namespace +// 2. Create session via gateway +// 3. Display QR code in terminal +// 4. Poll for completion +// 5. Return credentials +func PerformPhantomAuthentication(gatewayURL, namespace string) (*Credentials, error) { + reader := bufio.NewReader(os.Stdin) + + fmt.Println("\n🟣 Phantom Wallet Authentication (Solana)") + fmt.Println("==========================================") + fmt.Println("Requires an NFT from the authorized collection.") + + // Prompt for namespace if empty + if namespace == "" { + for { + fmt.Print("Enter namespace (required): ") + nsInput, err := reader.ReadString('\n') + if err != nil { + return nil, fmt.Errorf("failed to read namespace: %w", err) + } + namespace = strings.TrimSpace(nsInput) + if namespace != "" { + break + } + fmt.Println("Namespace cannot be empty.") + } + } + + domain := extractDomainFromURL(gatewayURL) + client := tlsutil.NewHTTPClientForDomain(30*time.Second, domain) + + // 1. Create phantom session + fmt.Println("\nCreating authentication session...") + session, err := createPhantomSession(client, gatewayURL, namespace) + if err != nil { + return nil, fmt.Errorf("failed to create session: %w", err) + } + + // 2. Display QR code + fmt.Println("\nScan this QR code with your phone to authenticate:") + fmt.Println() + qrterminal.GenerateWithConfig(session.AuthURL, qrterminal.Config{ + Level: qrterminal.M, + Writer: os.Stdout, + BlackChar: qrterminal.BLACK, + WhiteChar: qrterminal.WHITE, + QuietZone: 1, + }) + fmt.Println() + fmt.Printf("Or open this URL on your phone:\n%s\n\n", session.AuthURL) + fmt.Println("Waiting for authentication... (timeout: 5 minutes)") + + // 3. Poll for completion + creds, err := pollPhantomSession(client, gatewayURL, session.SessionID) + if err != nil { + return nil, err + } + + // Set namespace and build namespace URL + creds.Namespace = namespace + if domain := extractDomainFromURL(gatewayURL); domain != "" { + creds.NamespaceURL = fmt.Sprintf("https://ns-%s.%s", namespace, domain) + } + + fmt.Printf("\n🎉 Authentication successful!\n") + truncatedKey := creds.APIKey + if len(truncatedKey) > 8 { + truncatedKey = truncatedKey[:8] + "..." + } + fmt.Printf("📝 API Key: %s\n", truncatedKey) + + return creds, nil +} + +// createPhantomSession creates a new phantom auth session via the gateway. +func createPhantomSession(client *http.Client, gatewayURL, namespace string) (*PhantomSession, error) { + reqBody := map[string]string{ + "namespace": namespace, + } + payload, err := json.Marshal(reqBody) + if err != nil { + return nil, err + } + + resp, err := client.Post(gatewayURL+"/v1/auth/phantom/session", "application/json", bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("failed to call gateway: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("gateway returned status %d: %s", resp.StatusCode, string(body)) + } + + var session PhantomSession + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &session, nil +} + +// pollPhantomSession polls the gateway for session completion. +func pollPhantomSession(client *http.Client, gatewayURL, sessionID string) (*Credentials, error) { + pollInterval := 2 * time.Second + maxDuration := 5 * time.Minute + deadline := time.Now().Add(maxDuration) + + spinnerChars := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + spinnerIdx := 0 + + for time.Now().Before(deadline) { + resp, err := client.Get(gatewayURL + "/v1/auth/phantom/session/" + sessionID) + if err != nil { + time.Sleep(pollInterval) + continue + } + + var status PhantomSessionStatus + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + resp.Body.Close() + time.Sleep(pollInterval) + continue + } + resp.Body.Close() + + switch status.Status { + case "completed": + fmt.Printf("\r✅ Authenticated! \n") + return &Credentials{ + APIKey: status.APIKey, + Wallet: status.Wallet, + UserID: status.Wallet, + IssuedAt: time.Now(), + }, nil + + case "failed": + fmt.Printf("\r❌ Authentication failed \n") + errMsg := status.Error + if errMsg == "" { + errMsg = "unknown error" + } + return nil, fmt.Errorf("authentication failed: %s", errMsg) + + case "expired": + fmt.Printf("\r⏰ Session expired \n") + return nil, fmt.Errorf("authentication session expired") + + case "pending": + fmt.Printf("\r%s Waiting for phone authentication... ", spinnerChars[spinnerIdx%len(spinnerChars)]) + spinnerIdx++ + } + + time.Sleep(pollInterval) + } + + fmt.Printf("\r⏰ Timeout \n") + return nil, fmt.Errorf("authentication timed out after 5 minutes") +} diff --git a/pkg/auth/rootwallet.go b/pkg/auth/rootwallet.go new file mode 100644 index 0000000..1518b70 --- /dev/null +++ b/pkg/auth/rootwallet.go @@ -0,0 +1,229 @@ +package auth + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" + "time" + + "github.com/DeBrosOfficial/network/pkg/tlsutil" +) + +// IsRootWalletInstalled checks if the `rw` CLI is available in PATH +func IsRootWalletInstalled() bool { + _, err := exec.LookPath("rw") + return err == nil +} + +// getRootWalletAddress gets the EVM address from the RootWallet keystore +func getRootWalletAddress() (string, error) { + cmd := exec.Command("rw", "address", "--chain", "evm") + cmd.Stderr = os.Stderr + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get address from rw: %w", err) + } + addr := strings.TrimSpace(string(out)) + if addr == "" { + return "", fmt.Errorf("rw returned empty address — run 'rw init' first") + } + return addr, nil +} + +// signWithRootWallet signs a message using RootWallet's EVM key. +// Stdin is passed through so the user can enter their password if the session is expired. +func signWithRootWallet(message string) (string, error) { + cmd := exec.Command("rw", "sign", message, "--chain", "evm") + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to sign with rw: %w", err) + } + sig := strings.TrimSpace(string(out)) + if sig == "" { + return "", fmt.Errorf("rw returned empty signature") + } + return sig, nil +} + +// PerformRootWalletAuthentication performs a challenge-response authentication flow +// using the RootWallet CLI to sign a gateway-issued nonce +func PerformRootWalletAuthentication(gatewayURL, namespace string) (*Credentials, error) { + reader := bufio.NewReader(os.Stdin) + + fmt.Println("\n🔐 RootWallet Authentication") + fmt.Println("=============================") + + // 1. Get wallet address from RootWallet + fmt.Println("⏳ Reading wallet address from RootWallet...") + wallet, err := getRootWalletAddress() + if err != nil { + return nil, fmt.Errorf("failed to get wallet address: %w", err) + } + + if !ValidateWalletAddress(wallet) { + return nil, fmt.Errorf("invalid wallet address from rw: %s", wallet) + } + + fmt.Printf("✅ Wallet: %s\n", wallet) + + // 2. Prompt for namespace if not provided + if namespace == "" { + for { + fmt.Print("Enter namespace (required): ") + nsInput, err := reader.ReadString('\n') + if err != nil { + return nil, fmt.Errorf("failed to read namespace: %w", err) + } + + namespace = strings.TrimSpace(nsInput) + if namespace != "" { + break + } + fmt.Println("⚠️ Namespace cannot be empty. Please enter a namespace.") + } + } + fmt.Printf("✅ Namespace: %s\n", namespace) + + // 3. Request challenge nonce from gateway + fmt.Println("⏳ Requesting authentication challenge...") + domain := extractDomainFromURL(gatewayURL) + client := tlsutil.NewHTTPClientForDomain(30*time.Second, domain) + + nonce, err := requestChallenge(client, gatewayURL, wallet, namespace) + if err != nil { + return nil, fmt.Errorf("failed to get challenge: %w", err) + } + + // 4. Sign the nonce with RootWallet + fmt.Println("⏳ Signing challenge with RootWallet...") + signature, err := signWithRootWallet(nonce) + if err != nil { + return nil, fmt.Errorf("failed to sign challenge: %w", err) + } + fmt.Println("✅ Challenge signed") + + // 5. Verify signature with gateway + fmt.Println("⏳ Verifying signature with gateway...") + creds, err := verifySignature(client, gatewayURL, wallet, nonce, signature, namespace) + if err != nil { + return nil, fmt.Errorf("failed to verify signature: %w", err) + } + + fmt.Printf("\n🎉 Authentication successful!\n") + fmt.Printf("🏢 Namespace: %s\n", creds.Namespace) + + return creds, nil +} + +// requestChallenge sends POST /v1/auth/challenge and returns the nonce +func requestChallenge(client *http.Client, gatewayURL, wallet, namespace string) (string, error) { + reqBody := map[string]string{ + "wallet": wallet, + "namespace": namespace, + } + + payload, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + resp, err := client.Post(gatewayURL+"/v1/auth/challenge", "application/json", bytes.NewReader(payload)) + if err != nil { + return "", fmt.Errorf("failed to call gateway: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("gateway returned status %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Nonce string `json:"nonce"` + Wallet string `json:"wallet"` + Namespace string `json:"namespace"` + ExpiresAt string `json:"expires_at"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + if result.Nonce == "" { + return "", fmt.Errorf("no nonce in challenge response") + } + + return result.Nonce, nil +} + +// verifySignature sends POST /v1/auth/verify and returns credentials +func verifySignature(client *http.Client, gatewayURL, wallet, nonce, signature, namespace string) (*Credentials, error) { + reqBody := map[string]string{ + "wallet": wallet, + "nonce": nonce, + "signature": signature, + "namespace": namespace, + "chain_type": "ETH", + } + + payload, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + resp, err := client.Post(gatewayURL+"/v1/auth/verify", "application/json", bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("failed to call gateway: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("gateway returned status %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + Subject string `json:"subject"` + Namespace string `json:"namespace"` + APIKey string `json:"api_key"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if result.APIKey == "" { + return nil, fmt.Errorf("no api_key in verify response") + } + + // Build namespace gateway URL + namespaceURL := "" + if d := extractDomainFromURL(gatewayURL); d != "" { + namespaceURL = fmt.Sprintf("https://ns-%s.%s", namespace, d) + } + + creds := &Credentials{ + APIKey: result.APIKey, + RefreshToken: result.RefreshToken, + Namespace: result.Namespace, + UserID: result.Subject, + Wallet: result.Subject, + IssuedAt: time.Now(), + NamespaceURL: namespaceURL, + } + + if result.ExpiresIn > 0 { + creds.ExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second) + } + + return creds, nil +} diff --git a/pkg/auth/simple_auth.go b/pkg/auth/simple_auth.go index 8d4cfa9..058ee5f 100644 --- a/pkg/auth/simple_auth.go +++ b/pkg/auth/simple_auth.go @@ -15,8 +15,9 @@ import ( ) // PerformSimpleAuthentication performs a simple authentication flow where the user -// provides a wallet address and receives an API key without signature verification -func PerformSimpleAuthentication(gatewayURL, wallet, namespace string) (*Credentials, error) { +// provides a wallet address and receives an API key without signature verification. +// Requires an existing valid API key (convenience re-auth only). +func PerformSimpleAuthentication(gatewayURL, wallet, namespace, existingAPIKey string) (*Credentials, error) { reader := bufio.NewReader(os.Stdin) fmt.Println("\n🔐 Simple Wallet Authentication") @@ -67,7 +68,7 @@ func PerformSimpleAuthentication(gatewayURL, wallet, namespace string) (*Credent fmt.Println("⏳ Requesting API key from gateway...") // Request API key from gateway - apiKey, err := requestAPIKeyFromGateway(gatewayURL, wallet, namespace) + apiKey, err := requestAPIKeyFromGateway(gatewayURL, wallet, namespace, existingAPIKey) if err != nil { return nil, fmt.Errorf("failed to request API key: %w", err) } @@ -89,14 +90,18 @@ func PerformSimpleAuthentication(gatewayURL, wallet, namespace string) (*Credent } fmt.Printf("\n🎉 Authentication successful!\n") - fmt.Printf("📝 API Key: %s\n", creds.APIKey) + truncatedKey := creds.APIKey + if len(truncatedKey) > 8 { + truncatedKey = truncatedKey[:8] + "..." + } + fmt.Printf("📝 API Key: %s\n", truncatedKey) return creds, nil } // requestAPIKeyFromGateway calls the gateway's simple-key endpoint to generate an API key // For non-default namespaces, this may trigger cluster provisioning and require polling -func requestAPIKeyFromGateway(gatewayURL, wallet, namespace string) (string, error) { +func requestAPIKeyFromGateway(gatewayURL, wallet, namespace, existingAPIKey string) (string, error) { reqBody := map[string]string{ "wallet": wallet, "namespace": namespace, @@ -114,7 +119,16 @@ func requestAPIKeyFromGateway(gatewayURL, wallet, namespace string) (string, err domain := extractDomainFromURL(gatewayURL) client := tlsutil.NewHTTPClientForDomain(30*time.Second, domain) - resp, err := client.Post(endpoint, "application/json", bytes.NewReader(payload)) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if existingAPIKey != "" { + req.Header.Set("X-API-Key", existingAPIKey) + } + + resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("failed to call gateway: %w", err) } @@ -122,7 +136,7 @@ func requestAPIKeyFromGateway(gatewayURL, wallet, namespace string) (string, err // Handle 202 Accepted - namespace cluster is being provisioned if resp.StatusCode == http.StatusAccepted { - return handleProvisioningResponse(gatewayURL, client, resp, wallet, namespace) + return handleProvisioningResponse(gatewayURL, client, resp, wallet, namespace, existingAPIKey) } if resp.StatusCode != http.StatusOK { @@ -144,7 +158,7 @@ func requestAPIKeyFromGateway(gatewayURL, wallet, namespace string) (string, err } // handleProvisioningResponse handles 202 Accepted responses when namespace cluster provisioning is needed -func handleProvisioningResponse(gatewayURL string, client *http.Client, resp *http.Response, wallet, namespace string) (string, error) { +func handleProvisioningResponse(gatewayURL string, client *http.Client, resp *http.Response, wallet, namespace, existingAPIKey string) (string, error) { var provResp map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&provResp); err != nil { return "", fmt.Errorf("failed to decode provisioning response: %w", err) @@ -177,7 +191,7 @@ func handleProvisioningResponse(gatewayURL string, client *http.Client, resp *ht fmt.Println("\n✅ Namespace cluster ready!") fmt.Println("⏳ Retrieving API key...") - return retryAPIKeyRequest(gatewayURL, client, wallet, namespace) + return retryAPIKeyRequest(gatewayURL, client, wallet, namespace, existingAPIKey) } // pollProvisioningStatus polls the status endpoint until the cluster is ready @@ -185,6 +199,13 @@ func pollProvisioningStatus(gatewayURL string, client *http.Client, pollURL stri // Build full poll URL if it's a relative path if strings.HasPrefix(pollURL, "/") { pollURL = gatewayURL + pollURL + } else { + // Validate that absolute poll URLs point to the same gateway domain + gatewayDomain := extractDomainFromURL(gatewayURL) + pollDomain := extractDomainFromURL(pollURL) + if gatewayDomain != pollDomain { + return fmt.Errorf("poll URL domain mismatch: expected %s, got %s", gatewayDomain, pollDomain) + } } maxAttempts := 120 // 10 minutes (5 seconds per poll) @@ -260,7 +281,7 @@ func pollProvisioningStatus(gatewayURL string, client *http.Client, pollURL stri } // retryAPIKeyRequest retries the API key request after cluster provisioning -func retryAPIKeyRequest(gatewayURL string, client *http.Client, wallet, namespace string) (string, error) { +func retryAPIKeyRequest(gatewayURL string, client *http.Client, wallet, namespace, existingAPIKey string) (string, error) { reqBody := map[string]string{ "wallet": wallet, "namespace": namespace, @@ -273,7 +294,16 @@ func retryAPIKeyRequest(gatewayURL string, client *http.Client, wallet, namespac endpoint := gatewayURL + "/v1/auth/simple-key" - resp, err := client.Post(endpoint, "application/json", bytes.NewReader(payload)) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if existingAPIKey != "" { + req.Header.Set("X-API-Key", existingAPIKey) + } + + resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("failed to call gateway: %w", err) } diff --git a/pkg/cli/auth_commands.go b/pkg/cli/auth_commands.go index f5e675d..a7c954b 100644 --- a/pkg/cli/auth_commands.go +++ b/pkg/cli/auth_commands.go @@ -21,11 +21,13 @@ func HandleAuthCommand(args []string) { switch subcommand { case "login": var wallet, namespace string + var simple bool fs := flag.NewFlagSet("auth login", flag.ExitOnError) - fs.StringVar(&wallet, "wallet", "", "Wallet address (0x...)") + fs.StringVar(&wallet, "wallet", "", "Wallet address (implies --simple)") fs.StringVar(&namespace, "namespace", "", "Namespace name") + fs.BoolVar(&simple, "simple", false, "Use simple auth without signature verification") _ = fs.Parse(args[1:]) - handleAuthLogin(wallet, namespace) + handleAuthLogin(wallet, namespace, simple) case "logout": handleAuthLogout() case "whoami": @@ -47,30 +49,37 @@ func showAuthHelp() { fmt.Printf("🔐 Authentication Commands\n\n") fmt.Printf("Usage: orama auth \n\n") fmt.Printf("Subcommands:\n") - fmt.Printf(" login - Authenticate by providing your wallet address\n") + fmt.Printf(" login - Authenticate with RootWallet (default) or simple auth\n") fmt.Printf(" logout - Clear stored credentials\n") fmt.Printf(" whoami - Show current authentication status\n") fmt.Printf(" status - Show detailed authentication info\n") fmt.Printf(" list - List all stored credentials for current environment\n") fmt.Printf(" switch - Switch between stored credentials\n\n") + fmt.Printf("Login Flags:\n") + fmt.Printf(" --namespace - Target namespace\n") + fmt.Printf(" --simple - Use simple auth (no signature, dev only)\n") + fmt.Printf(" --wallet <0x...> - Wallet address (implies --simple)\n\n") fmt.Printf("Examples:\n") - fmt.Printf(" orama auth login # Enter wallet address interactively\n") - fmt.Printf(" orama auth login --wallet 0x... --namespace myns # Non-interactive\n") - fmt.Printf(" orama auth whoami # Check who you're logged in as\n") - fmt.Printf(" orama auth status # View detailed authentication info\n") - fmt.Printf(" orama auth logout # Clear all stored credentials\n\n") + fmt.Printf(" orama auth login # Sign with RootWallet (default)\n") + fmt.Printf(" orama auth login --namespace myns # Sign with RootWallet + namespace\n") + fmt.Printf(" orama auth login --simple # Simple auth (no signature)\n") + fmt.Printf(" orama auth whoami # Check who you're logged in as\n") + fmt.Printf(" orama auth logout # Clear all stored credentials\n\n") fmt.Printf("Environment Variables:\n") fmt.Printf(" DEBROS_GATEWAY_URL - Gateway URL (overrides environment config)\n\n") - fmt.Printf("Authentication Flow:\n") + fmt.Printf("Authentication Flow (RootWallet):\n") fmt.Printf(" 1. Run 'orama auth login'\n") - fmt.Printf(" 2. Enter your wallet address when prompted\n") - fmt.Printf(" 3. Enter your namespace (or press Enter for 'default')\n") - fmt.Printf(" 4. An API key will be generated and saved to ~/.orama/credentials.json\n\n") - fmt.Printf("Note: Authentication uses the currently active environment.\n") + fmt.Printf(" 2. Your wallet address is read from RootWallet automatically\n") + fmt.Printf(" 3. Enter your namespace when prompted\n") + fmt.Printf(" 4. A challenge nonce is signed with your wallet key\n") + fmt.Printf(" 5. Credentials are saved to ~/.orama/credentials.json\n\n") + fmt.Printf("Note: Requires RootWallet CLI (rw) in PATH.\n") + fmt.Printf(" Install: cd rootwallet/cli && ./install.sh\n") + fmt.Printf(" Authentication uses the currently active environment.\n") fmt.Printf(" Use 'orama env current' to see your active environment.\n") } -func handleAuthLogin(wallet, namespace string) { +func handleAuthLogin(wallet, namespace string, simple bool) { // Get gateway URL from active environment gatewayURL := getGatewayURL() @@ -129,8 +138,43 @@ func handleAuthLogin(wallet, namespace string) { } } - // Perform simple authentication to add a new credential - creds, err := auth.PerformSimpleAuthentication(gatewayURL, wallet, namespace) + // Choose authentication method + var creds *auth.Credentials + reader := bufio.NewReader(os.Stdin) + + if simple || wallet != "" { + // Explicit simple auth — requires existing credentials + existingCreds := store.GetDefaultCredential(gatewayURL) + if existingCreds == nil || !existingCreds.IsValid() { + fmt.Fprintf(os.Stderr, "❌ Simple auth requires existing credentials. Authenticate with RootWallet or Phantom first.\n") + os.Exit(1) + } + creds, err = auth.PerformSimpleAuthentication(gatewayURL, wallet, namespace, existingCreds.APIKey) + } else { + // Show auth method selection + fmt.Println("How would you like to authenticate?") + fmt.Println(" 1. RootWallet (EVM signature)") + fmt.Println(" 2. Phantom (Solana + NFT required)") + fmt.Print("\nSelect [1/2]: ") + + choice, _ := reader.ReadString('\n') + choice = strings.TrimSpace(choice) + + switch choice { + case "2": + creds, err = auth.PerformPhantomAuthentication(gatewayURL, namespace) + default: + // Default to RootWallet + if auth.IsRootWalletInstalled() { + creds, err = auth.PerformRootWalletAuthentication(gatewayURL, namespace) + } else { + fmt.Println("\n⚠️ RootWallet CLI (rw) not found in PATH.") + fmt.Println(" Install it: cd rootwallet/cli && ./install.sh") + os.Exit(1) + } + } + } + if err != nil { fmt.Fprintf(os.Stderr, "❌ Authentication failed: %v\n", err) os.Exit(1) @@ -155,7 +199,6 @@ func handleAuthLogin(wallet, namespace string) { fmt.Printf("📁 Credentials saved to: %s\n", credsPath) fmt.Printf("🎯 Wallet: %s\n", creds.Wallet) fmt.Printf("🏢 Namespace: %s\n", creds.Namespace) - fmt.Printf("🔑 API Key: %s\n", creds.APIKey) if creds.NamespaceURL != "" { fmt.Printf("🌐 Namespace URL: %s\n", creds.NamespaceURL) } diff --git a/pkg/cli/production/upgrade/orchestrator.go b/pkg/cli/production/upgrade/orchestrator.go index a297172..3268610 100644 --- a/pkg/cli/production/upgrade/orchestrator.go +++ b/pkg/cli/production/upgrade/orchestrator.go @@ -47,7 +47,7 @@ func NewOrchestrator(flags *Flags) *Orchestrator { setup := production.NewProductionSetup(oramaHome, os.Stdout, flags.Force, branch, flags.NoPull, flags.SkipChecks, flags.PreBuilt) setup.SetNameserver(isNameserver) - // Configure Anyone mode (flag > saved preference) + // Configure Anyone mode (flag > saved preference > auto-detect) if flags.AnyoneRelay { setup.SetAnyoneRelayConfig(&production.AnyoneRelayConfig{ Enabled: true, @@ -71,6 +71,20 @@ func NewOrchestrator(flags *Flags) *Orchestrator { Enabled: true, ORPort: orPort, }) + } else if detectAnyoneRelay(oramaDir) { + // Auto-detect: relay is installed but preferences weren't saved. + // This happens when upgrading from older versions that didn't persist + // the anyone_relay preference, or when preferences.yaml was reset. + orPort := detectAnyoneORPort(oramaDir) + setup.SetAnyoneRelayConfig(&production.AnyoneRelayConfig{ + Enabled: true, + ORPort: orPort, + }) + // Save the detected preference for future upgrades + prefs.AnyoneRelay = true + prefs.AnyoneORPort = orPort + _ = production.SavePreferences(oramaDir, prefs) + fmt.Printf(" Auto-detected Anyone relay (ORPort: %d), saved to preferences\n", orPort) } else if flags.AnyoneClient || prefs.AnyoneClient { setup.SetAnyoneClient(true) } @@ -648,15 +662,13 @@ func (o *Orchestrator) restartServices() error { // Get services to restart services := utils.GetProductionServices() - // Re-enable namespace services BEFORE restarting debros-node. - // orama prod stop disables them, and debros-node's PartOf= dependency + // Re-enable all services BEFORE restarting them. + // orama prod stop disables services, and debros-node's PartOf= dependency // won't propagate restart to disabled services. We must re-enable first - // so that namespace gateways restart with the updated binary. + // so that all services restart with the updated binary. for _, svc := range services { - if strings.Contains(svc, "@") { - if err := exec.Command("systemctl", "enable", svc).Run(); err != nil { - fmt.Printf(" ⚠️ Warning: Failed to re-enable %s: %v\n", svc, err) - } + if err := exec.Command("systemctl", "enable", svc).Run(); err != nil { + fmt.Printf(" ⚠️ Warning: Failed to re-enable %s: %v\n", svc, err) } } @@ -803,3 +815,46 @@ func (o *Orchestrator) waitForClusterHealth(timeout time.Duration) error { return fmt.Errorf("timeout waiting for cluster to become healthy") } + +// detectAnyoneRelay checks if an Anyone relay is installed on this node +// by looking for the systemd service file or the anonrc config file. +func detectAnyoneRelay(oramaDir string) bool { + // Check if systemd service file exists + if _, err := os.Stat("/etc/systemd/system/debros-anyone-relay.service"); err == nil { + return true + } + // Check if anonrc config exists + if _, err := os.Stat(filepath.Join(oramaDir, "anyone", "anonrc")); err == nil { + return true + } + if _, err := os.Stat("/etc/anon/anonrc"); err == nil { + return true + } + return false +} + +// detectAnyoneORPort reads the configured ORPort from anonrc, defaulting to 9001. +func detectAnyoneORPort(oramaDir string) int { + for _, path := range []string{ + filepath.Join(oramaDir, "anyone", "anonrc"), + "/etc/anon/anonrc", + } { + data, err := os.ReadFile(path) + if err != nil { + continue + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "ORPort ") { + parts := strings.Fields(line) + if len(parts) >= 2 { + port := 0 + if _, err := fmt.Sscanf(parts[1], "%d", &port); err == nil && port > 0 { + return port + } + } + } + } + } + return 9001 +} diff --git a/pkg/gateway/auth/solana_nft.go b/pkg/gateway/auth/solana_nft.go new file mode 100644 index 0000000..f253b98 --- /dev/null +++ b/pkg/gateway/auth/solana_nft.go @@ -0,0 +1,601 @@ +package auth + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "strings" + "time" +) + +const ( + // Solana Token Program ID + tokenProgramID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + // Metaplex Token Metadata Program ID + metaplexProgramID = "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s" +) + +// SolanaNFTVerifier verifies NFT ownership on Solana via JSON-RPC. +type SolanaNFTVerifier struct { + rpcURL string + collectionAddress string + httpClient *http.Client +} + +// NewSolanaNFTVerifier creates a new verifier for the given collection. +func NewSolanaNFTVerifier(rpcURL, collectionAddress string) *SolanaNFTVerifier { + return &SolanaNFTVerifier{ + rpcURL: rpcURL, + collectionAddress: collectionAddress, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// VerifyNFTOwnership checks if the wallet owns at least one NFT from the configured collection. +func (v *SolanaNFTVerifier) VerifyNFTOwnership(ctx context.Context, walletAddress string) (bool, error) { + // 1. Get all token accounts owned by the wallet + tokenAccounts, err := v.getTokenAccountsByOwner(ctx, walletAddress) + if err != nil { + return false, fmt.Errorf("failed to get token accounts: %w", err) + } + + // 2. Filter for NFT-like accounts (amount == 1, decimals == 0) + var mints []string + for _, ta := range tokenAccounts { + if ta.amount == "1" && ta.decimals == 0 { + mints = append(mints, ta.mint) + } + } + + if len(mints) == 0 { + return false, nil + } + + // Cap mints to prevent excessive RPC calls from wallets with many tokens + const maxMints = 500 + if len(mints) > maxMints { + mints = mints[:maxMints] + } + + // 3. Derive metadata PDA for each mint + metaplexProgram, err := base58Decode(metaplexProgramID) + if err != nil { + return false, fmt.Errorf("failed to decode metaplex program: %w", err) + } + + var pdas []string + for _, mint := range mints { + mintBytes, err := base58Decode(mint) + if err != nil || len(mintBytes) != 32 { + continue + } + pda, err := findProgramAddress( + [][]byte{[]byte("metadata"), metaplexProgram, mintBytes}, + metaplexProgram, + ) + if err != nil { + continue + } + pdas = append(pdas, base58Encode(pda)) + } + + if len(pdas) == 0 { + return false, nil + } + + // 4. Batch fetch metadata accounts (max 100 per call) + targetCollection, err := base58Decode(v.collectionAddress) + if err != nil { + return false, fmt.Errorf("failed to decode collection address: %w", err) + } + + for i := 0; i < len(pdas); i += 100 { + end := i + 100 + if end > len(pdas) { + end = len(pdas) + } + batch := pdas[i:end] + + accounts, err := v.getMultipleAccounts(ctx, batch) + if err != nil { + return false, fmt.Errorf("failed to get metadata accounts: %w", err) + } + + for _, acct := range accounts { + if acct == nil { + continue + } + collKey, verified := parseMetaplexCollection(acct) + if verified && bytes.Equal(collKey, targetCollection) { + return true, nil + } + } + } + + return false, nil +} + +// tokenAccountInfo holds parsed SPL token account data. +type tokenAccountInfo struct { + mint string + amount string + decimals int +} + +// getTokenAccountsByOwner fetches all SPL token accounts for a wallet. +func (v *SolanaNFTVerifier) getTokenAccountsByOwner(ctx context.Context, wallet string) ([]tokenAccountInfo, error) { + params := []interface{}{ + wallet, + map[string]string{"programId": tokenProgramID}, + map[string]string{"encoding": "jsonParsed"}, + } + + result, err := v.rpcCall(ctx, "getTokenAccountsByOwner", params) + if err != nil { + return nil, err + } + + // Parse the result + resultMap, ok := result.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("unexpected result format") + } + + valueArr, ok := resultMap["value"].([]interface{}) + if !ok { + return nil, nil + } + + var accounts []tokenAccountInfo + for _, item := range valueArr { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + account, ok := itemMap["account"].(map[string]interface{}) + if !ok { + continue + } + data, ok := account["data"].(map[string]interface{}) + if !ok { + continue + } + parsed, ok := data["parsed"].(map[string]interface{}) + if !ok { + continue + } + info, ok := parsed["info"].(map[string]interface{}) + if !ok { + continue + } + + mint, _ := info["mint"].(string) + tokenAmount, ok := info["tokenAmount"].(map[string]interface{}) + if !ok { + continue + } + amount, _ := tokenAmount["amount"].(string) + decimals, _ := tokenAmount["decimals"].(float64) + + if mint != "" && amount != "" { + accounts = append(accounts, tokenAccountInfo{ + mint: mint, + amount: amount, + decimals: int(decimals), + }) + } + } + + return accounts, nil +} + +// getMultipleAccounts fetches multiple accounts by their addresses. +// Returns raw account data (base64-decoded) for each address, nil for missing accounts. +func (v *SolanaNFTVerifier) getMultipleAccounts(ctx context.Context, addresses []string) ([][]byte, error) { + params := []interface{}{ + addresses, + map[string]string{"encoding": "base64"}, + } + + result, err := v.rpcCall(ctx, "getMultipleAccounts", params) + if err != nil { + return nil, err + } + + resultMap, ok := result.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("unexpected result format") + } + + valueArr, ok := resultMap["value"].([]interface{}) + if !ok { + return nil, nil + } + + accounts := make([][]byte, len(valueArr)) + for i, item := range valueArr { + if item == nil { + continue + } + acct, ok := item.(map[string]interface{}) + if !ok { + continue + } + dataArr, ok := acct["data"].([]interface{}) + if !ok || len(dataArr) < 1 { + continue + } + dataStr, ok := dataArr[0].(string) + if !ok { + continue + } + decoded, err := base64.StdEncoding.DecodeString(dataStr) + if err != nil { + continue + } + accounts[i] = decoded + } + + return accounts, nil +} + +// rpcCall executes a Solana JSON-RPC call. +func (v *SolanaNFTVerifier) rpcCall(ctx context.Context, method string, params []interface{}) (interface{}, error) { + reqBody := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + } + + payload, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal RPC request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", v.rpcURL, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("failed to create RPC request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := v.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("RPC request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("RPC returned HTTP %d", resp.StatusCode) + } + + // Limit response size to 10MB to prevent memory exhaustion + body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) + if err != nil { + return nil, fmt.Errorf("failed to read RPC response: %w", err) + } + + var rpcResp struct { + Result interface{} `json:"result"` + Error map[string]interface{} `json:"error"` + } + if err := json.Unmarshal(body, &rpcResp); err != nil { + return nil, fmt.Errorf("failed to parse RPC response: %w", err) + } + + if rpcResp.Error != nil { + msg, _ := rpcResp.Error["message"].(string) + return nil, fmt.Errorf("RPC error: %s", msg) + } + + return rpcResp.Result, nil +} + +// parseMetaplexCollection extracts the collection key and verified flag from +// Borsh-encoded Metaplex metadata account data. +// +// Metaplex Token Metadata v1 layout (simplified): +// - [0]: key (1 byte, should be 4 for MetadataV1) +// - [1..33]: update_authority (32 bytes) +// - [33..65]: mint (32 bytes) +// - [65..]: name (4-byte len prefix + UTF-8, borsh string) +// - then: symbol (borsh string) +// - then: uri (borsh string) +// - then: seller_fee_basis_points (u16, 2 bytes) +// - then: creators (Option>) +// - then: primary_sale_happened (bool, 1 byte) +// - then: is_mutable (bool, 1 byte) +// - then: edition_nonce (Option) +// - then: token_standard (Option) +// - then: collection (Option) +// - Collection: { verified: bool(1), key: Pubkey(32) } +func parseMetaplexCollection(data []byte) (collectionKey []byte, verified bool) { + if len(data) < 66 { + return nil, false + } + + // Validate metadata key byte (must be 4 = MetadataV1) + if data[0] != 4 { + return nil, false + } + + // Skip: key(1) + update_authority(32) + mint(32) + offset := 65 + + // Skip name (borsh string: 4-byte LE length + bytes) + offset, _ = skipBorshString(data, offset) + if offset < 0 { + return nil, false + } + + // Skip symbol + offset, _ = skipBorshString(data, offset) + if offset < 0 { + return nil, false + } + + // Skip uri + offset, _ = skipBorshString(data, offset) + if offset < 0 { + return nil, false + } + + // Skip seller_fee_basis_points (u16) + offset += 2 + if offset > len(data) { + return nil, false + } + + // Skip creators (Option>) + // Option: 1 byte (0 = None, 1 = Some) + if offset >= len(data) { + return nil, false + } + if data[offset] == 1 { + offset++ // skip option byte + if offset+4 > len(data) { + return nil, false + } + numCreators := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + // Solana limits creators to 5, but be generous with 20 + if numCreators > 20 { + return nil, false + } + // Each Creator: pubkey(32) + verified(1) + share(1) = 34 bytes + creatorBytes := numCreators * 34 + if offset+creatorBytes > len(data) { + return nil, false + } + offset += creatorBytes + } else { + offset++ // skip None byte + } + + if offset >= len(data) { + return nil, false + } + + // Skip primary_sale_happened (bool) + offset++ + if offset >= len(data) { + return nil, false + } + + // Skip is_mutable (bool) + offset++ + if offset >= len(data) { + return nil, false + } + + // Skip edition_nonce (Option) + if offset >= len(data) { + return nil, false + } + if data[offset] == 1 { + offset += 2 // option byte + u8 + } else { + offset++ // None + } + + // Skip token_standard (Option) + if offset >= len(data) { + return nil, false + } + if data[offset] == 1 { + offset += 2 + } else { + offset++ + } + + // Collection (Option) + if offset >= len(data) { + return nil, false + } + if data[offset] == 0 { + // No collection + return nil, false + } + offset++ // skip option byte + + // Collection: verified(1 byte bool) + key(32 bytes) + if offset+33 > len(data) { + return nil, false + } + verified = data[offset] == 1 + offset++ + collectionKey = data[offset : offset+32] + + return collectionKey, verified +} + +// skipBorshString skips a Borsh-encoded string (4-byte LE length + bytes) at the given offset. +// Returns the new offset, or -1 if the data is too short. +func skipBorshString(data []byte, offset int) (int, string) { + if offset+4 > len(data) { + return -1, "" + } + strLen := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + if offset+strLen > len(data) { + return -1, "" + } + s := string(data[offset : offset+strLen]) + return offset + strLen, s +} + +// findProgramAddress derives a Solana Program Derived Address (PDA). +// It finds the first valid PDA by trying bump seeds from 255 down to 0. +func findProgramAddress(seeds [][]byte, programID []byte) ([]byte, error) { + for bump := byte(255); ; bump-- { + candidate := derivePDA(seeds, bump, programID) + if !isOnCurve(candidate) { + return candidate, nil + } + if bump == 0 { + break + } + } + return nil, fmt.Errorf("could not find valid PDA") +} + +// derivePDA computes SHA256(seeds || bump || programID || "ProgramDerivedAddress"). +func derivePDA(seeds [][]byte, bump byte, programID []byte) []byte { + h := sha256.New() + for _, seed := range seeds { + h.Write(seed) + } + h.Write([]byte{bump}) + h.Write(programID) + h.Write([]byte("ProgramDerivedAddress")) + return h.Sum(nil) +} + +// isOnCurve checks if a 32-byte key is on the Ed25519 curve. +// PDAs must NOT be on the curve (they have no private key). +// This uses a simplified check based on the Ed25519 point decompression. +func isOnCurve(key []byte) bool { + if len(key) != 32 { + return false + } + + // Ed25519 field prime: p = 2^255 - 19 + p := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 255), big.NewInt(19)) + + // Extract y coordinate (little-endian, clear top bit) + yBytes := make([]byte, 32) + copy(yBytes, key) + yBytes[31] &= 0x7f + + // Reverse for big-endian + for i, j := 0, len(yBytes)-1; i < j; i, j = i+1, j-1 { + yBytes[i], yBytes[j] = yBytes[j], yBytes[i] + } + + y := new(big.Int).SetBytes(yBytes) + if y.Cmp(p) >= 0 { + return false + } + + // Compute u = y^2 - 1 + y2 := new(big.Int).Mul(y, y) + y2.Mod(y2, p) + u := new(big.Int).Sub(y2, big.NewInt(1)) + u.Mod(u, p) + if u.Sign() < 0 { + u.Add(u, p) + } + + // d = -121665/121666 mod p + d := new(big.Int).SetInt64(121666) + d.ModInverse(d, p) + d.Mul(d, big.NewInt(-121665)) + d.Mod(d, p) + if d.Sign() < 0 { + d.Add(d, p) + } + + // Compute v = d*y^2 + 1 + v := new(big.Int).Mul(d, y2) + v.Mod(v, p) + v.Add(v, big.NewInt(1)) + v.Mod(v, p) + + // Check if u/v is a quadratic residue mod p + // x^2 = u * v^{-1} mod p + vInv := new(big.Int).ModInverse(v, p) + if vInv == nil { + return false + } + x2 := new(big.Int).Mul(u, vInv) + x2.Mod(x2, p) + + // Euler criterion: x2^((p-1)/2) mod p == 1 means QR + exp := new(big.Int).Sub(p, big.NewInt(1)) + exp.Rsh(exp, 1) + result := new(big.Int).Exp(x2, exp, p) + + return result.Cmp(big.NewInt(1)) == 0 || x2.Sign() == 0 +} + +// base58Decode decodes a base58-encoded string (same as Service.Base58Decode but standalone). +func base58Decode(input string) ([]byte, error) { + const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + answer := big.NewInt(0) + j := big.NewInt(1) + for i := len(input) - 1; i >= 0; i-- { + tmp := strings.IndexByte(alphabet, input[i]) + if tmp == -1 { + return nil, fmt.Errorf("invalid base58 character") + } + idx := big.NewInt(int64(tmp)) + tmp1 := new(big.Int).Mul(idx, j) + answer.Add(answer, tmp1) + j.Mul(j, big.NewInt(58)) + } + res := answer.Bytes() + for i := 0; i < len(input) && input[i] == alphabet[0]; i++ { + res = append([]byte{0}, res...) + } + return res, nil +} + +// base58Encode encodes bytes to base58. +func base58Encode(input []byte) string { + const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + + x := new(big.Int).SetBytes(input) + base := big.NewInt(58) + zero := big.NewInt(0) + mod := new(big.Int) + + var result []byte + for x.Cmp(zero) > 0 { + x.DivMod(x, base, mod) + result = append(result, alphabet[mod.Int64()]) + } + + // Leading zeros + for _, b := range input { + if b != 0 { + break + } + result = append(result, alphabet[0]) + } + + // Reverse + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] + } + + return string(result) +} diff --git a/pkg/gateway/config.go b/pkg/gateway/config.go index 9384513..6a7cbcf 100644 --- a/pkg/gateway/config.go +++ b/pkg/gateway/config.go @@ -41,4 +41,9 @@ type Config struct { // WireGuard mesh configuration ClusterSecret string // Cluster secret for authenticating internal WireGuard peer exchange + + // Phantom Solana auth configuration + PhantomAuthURL string // URL of the deployed Phantom auth React app + SolanaRPCURL string // Solana RPC endpoint for NFT verification + NFTCollectionAddress string // Required NFT collection address for Phantom auth } diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index b2bf25a..a4b52bc 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -321,6 +321,19 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) { cfg.ClientNamespace, gw.withInternalAuth, ) + + // Configure Phantom Solana auth if env vars are set + if cfg.PhantomAuthURL != "" { + var solanaVerifier *auth.SolanaNFTVerifier + if cfg.SolanaRPCURL != "" && cfg.NFTCollectionAddress != "" { + solanaVerifier = auth.NewSolanaNFTVerifier(cfg.SolanaRPCURL, cfg.NFTCollectionAddress) + logger.ComponentInfo(logging.ComponentGeneral, "Solana NFT verifier configured", + zap.String("collection", cfg.NFTCollectionAddress)) + } + gw.authHandlers.SetPhantomConfig(cfg.PhantomAuthURL, solanaVerifier) + logger.ComponentInfo(logging.ComponentGeneral, "Phantom auth configured", + zap.String("auth_url", cfg.PhantomAuthURL)) + } } // Initialize middleware cache (60s TTL for auth/routing lookups) diff --git a/pkg/gateway/handlers/auth/apikey_handler.go b/pkg/gateway/handlers/auth/apikey_handler.go index ed434f8..1cafcd7 100644 --- a/pkg/gateway/handlers/auth/apikey_handler.go +++ b/pkg/gateway/handlers/auth/apikey_handler.go @@ -116,10 +116,11 @@ func (h *Handlers) IssueAPIKeyHandler(w http.ResponseWriter, r *http.Request) { } // SimpleAPIKeyHandler generates an API key without signature verification. -// This is a simplified flow for development/testing purposes. +// Requires an existing valid API key (convenience re-auth only, not standalone). // // POST /v1/auth/simple-key // Request body: SimpleAPIKeyRequest +// Headers: X-API-Key or Authorization required // Response: { "api_key", "namespace", "wallet", "created" } func (h *Handlers) SimpleAPIKeyHandler(w http.ResponseWriter, r *http.Request) { if h.authService == nil { @@ -131,6 +132,13 @@ func (h *Handlers) SimpleAPIKeyHandler(w http.ResponseWriter, r *http.Request) { return } + // Require existing API key — simple auth is a convenience shortcut, not standalone + existingKey, _ := r.Context().Value(CtxKeyAPIKey).(string) + if existingKey == "" { + writeError(w, http.StatusUnauthorized, "simple auth requires an existing API key") + return + } + var req SimpleAPIKeyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json body") diff --git a/pkg/gateway/handlers/auth/handlers.go b/pkg/gateway/handlers/auth/handlers.go index b1589d9..94b61dc 100644 --- a/pkg/gateway/handlers/auth/handlers.go +++ b/pkg/gateway/handlers/auth/handlers.go @@ -56,6 +56,8 @@ type Handlers struct { defaultNS string internalAuthFn func(context.Context) context.Context clusterProvisioner ClusterProvisioner // Optional: for namespace cluster provisioning + phantomAuthURL string // URL of the Phantom auth React app + solanaVerifier *authsvc.SolanaNFTVerifier // Server-side NFT ownership verifier } // NewHandlers creates a new authentication handlers instance @@ -80,6 +82,12 @@ func (h *Handlers) SetClusterProvisioner(cp ClusterProvisioner) { h.clusterProvisioner = cp } +// SetPhantomConfig sets the Phantom auth app URL and Solana NFT verifier +func (h *Handlers) SetPhantomConfig(authURL string, verifier *authsvc.SolanaNFTVerifier) { + h.phantomAuthURL = authURL + h.solanaVerifier = verifier +} + // markNonceUsed marks a nonce as used in the database func (h *Handlers) markNonceUsed(ctx context.Context, namespaceID interface{}, wallet, nonce string) { if h.netClient == nil { diff --git a/pkg/gateway/handlers/auth/phantom_handler.go b/pkg/gateway/handlers/auth/phantom_handler.go new file mode 100644 index 0000000..942b7bd --- /dev/null +++ b/pkg/gateway/handlers/auth/phantom_handler.go @@ -0,0 +1,318 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +var ( + sessionIDRegex = regexp.MustCompile(`^[a-f0-9]{64}$`) + namespaceRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]?$`) +) + +// PhantomSessionHandler creates a new Phantom auth session. +// The CLI calls this to get a session ID and auth URL, then displays a QR code. +// +// POST /v1/auth/phantom/session +// Request body: { "namespace": "myns" } +// Response: { "session_id", "auth_url", "expires_at" } +func (h *Handlers) PhantomSessionHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if h.phantomAuthURL == "" { + writeError(w, http.StatusServiceUnavailable, "phantom auth not configured") + return + } + + var req struct { + Namespace string `json:"namespace"` + } + r.Body = http.MaxBytesReader(w, r.Body, 1024) + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + + namespace := strings.TrimSpace(req.Namespace) + if namespace == "" { + namespace = h.defaultNS + if namespace == "" { + namespace = "default" + } + } + if !namespaceRegex.MatchString(namespace) { + writeError(w, http.StatusBadRequest, "invalid namespace format") + return + } + + // Generate session ID + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + writeError(w, http.StatusInternalServerError, "failed to generate session ID") + return + } + sessionID := hex.EncodeToString(buf) + expiresAt := time.Now().Add(5 * time.Minute) + + // Store session in DB + ctx := r.Context() + internalCtx := h.internalAuthFn(ctx) + db := h.netClient.Database() + + _, err := db.Query(internalCtx, + "INSERT INTO phantom_auth_sessions(id, namespace, status, expires_at) VALUES (?, ?, 'pending', ?)", + sessionID, namespace, expiresAt.UTC().Format("2006-01-02 15:04:05"), + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create session") + return + } + + // Build the auth URL that the phone will open + gatewayURL := r.Header.Get("X-Forwarded-Proto") + "://" + r.Header.Get("X-Forwarded-Host") + if gatewayURL == "://" { + // Fallback: construct from request + scheme := "https" + if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") == "" { + scheme = "http" + } + gatewayURL = scheme + "://" + r.Host + } + + authURL := fmt.Sprintf("%s/?session=%s&gateway=%s&namespace=%s", + h.phantomAuthURL, sessionID, url.QueryEscape(gatewayURL), url.QueryEscape(namespace)) + + writeJSON(w, http.StatusOK, map[string]any{ + "session_id": sessionID, + "auth_url": authURL, + "expires_at": expiresAt.UTC().Format(time.RFC3339), + }) +} + +// PhantomSessionStatusHandler returns the current status of a Phantom auth session. +// The CLI polls this endpoint every 2 seconds waiting for completion. +// +// GET /v1/auth/phantom/session/{id} +// Response: { "session_id", "status", "wallet", "api_key", "namespace" } +func (h *Handlers) PhantomSessionStatusHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + // Extract session ID from URL path: /v1/auth/phantom/session/{id} + sessionID := strings.TrimPrefix(r.URL.Path, "/v1/auth/phantom/session/") + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" || !sessionIDRegex.MatchString(sessionID) { + writeError(w, http.StatusBadRequest, "invalid session_id format") + return + } + + ctx := r.Context() + internalCtx := h.internalAuthFn(ctx) + db := h.netClient.Database() + + res, err := db.Query(internalCtx, + "SELECT id, namespace, status, wallet, api_key, error_message, expires_at FROM phantom_auth_sessions WHERE id = ? LIMIT 1", + sessionID, + ) + if err != nil || res == nil || res.Count == 0 { + writeError(w, http.StatusNotFound, "session not found") + return + } + + row, ok := res.Rows[0].([]interface{}) + if !ok || len(row) < 7 { + writeError(w, http.StatusInternalServerError, "invalid session data") + return + } + + status := getString(row[2]) + wallet := getString(row[3]) + apiKey := getString(row[4]) + errorMsg := getString(row[5]) + expiresAtStr := getString(row[6]) + namespace := getString(row[1]) + + // Check expiration if still pending + if status == "pending" { + if expiresAt, err := time.Parse("2006-01-02 15:04:05", expiresAtStr); err == nil { + if time.Now().UTC().After(expiresAt) { + status = "expired" + // Update in DB + _, _ = db.Query(internalCtx, + "UPDATE phantom_auth_sessions SET status = 'expired' WHERE id = ? AND status = 'pending'", + sessionID, + ) + } + } + } + + resp := map[string]any{ + "session_id": sessionID, + "status": status, + "namespace": namespace, + } + if wallet != "" { + resp["wallet"] = wallet + } + if apiKey != "" { + resp["api_key"] = apiKey + } + if errorMsg != "" { + resp["error"] = errorMsg + } + + writeJSON(w, http.StatusOK, resp) +} + +// PhantomCompleteHandler completes Phantom authentication. +// Called by the React auth app after the user signs with Phantom. +// +// POST /v1/auth/phantom/complete +// Request body: { "session_id", "wallet", "nonce", "signature", "namespace" } +// Response: { "success": true } +func (h *Handlers) PhantomCompleteHandler(w http.ResponseWriter, r *http.Request) { + if h.authService == nil { + writeError(w, http.StatusServiceUnavailable, "auth service not initialized") + return + } + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req struct { + SessionID string `json:"session_id"` + Wallet string `json:"wallet"` + Nonce string `json:"nonce"` + Signature string `json:"signature"` + Namespace string `json:"namespace"` + } + r.Body = http.MaxBytesReader(w, r.Body, 4096) + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + + if req.SessionID == "" || req.Wallet == "" || req.Nonce == "" || req.Signature == "" { + writeError(w, http.StatusBadRequest, "session_id, wallet, nonce and signature are required") + return + } + + if !sessionIDRegex.MatchString(req.SessionID) { + writeError(w, http.StatusBadRequest, "invalid session_id format") + return + } + + ctx := r.Context() + internalCtx := h.internalAuthFn(ctx) + db := h.netClient.Database() + + // Validate session exists, is pending, and not expired + res, err := db.Query(internalCtx, + "SELECT status, expires_at FROM phantom_auth_sessions WHERE id = ? LIMIT 1", + req.SessionID, + ) + if err != nil || res == nil || res.Count == 0 { + writeError(w, http.StatusNotFound, "session not found") + return + } + + row, ok := res.Rows[0].([]interface{}) + if !ok || len(row) < 2 { + writeError(w, http.StatusInternalServerError, "invalid session data") + return + } + + status := getString(row[0]) + expiresAtStr := getString(row[1]) + + if status != "pending" { + writeError(w, http.StatusConflict, "session is not pending (status: "+status+")") + return + } + + if expiresAt, err := time.Parse("2006-01-02 15:04:05", expiresAtStr); err == nil { + if time.Now().UTC().After(expiresAt) { + _, _ = db.Query(internalCtx, + "UPDATE phantom_auth_sessions SET status = 'expired' WHERE id = ?", + req.SessionID, + ) + writeError(w, http.StatusGone, "session expired") + return + } + } + + // Verify Ed25519 signature (Solana) + verified, err := h.authService.VerifySignature(ctx, req.Wallet, req.Nonce, req.Signature, "SOL") + if err != nil || !verified { + h.updateSessionFailed(internalCtx, db, req.SessionID, "signature verification failed") + writeError(w, http.StatusUnauthorized, "signature verification failed") + return + } + + // Mark nonce used + namespace := strings.TrimSpace(req.Namespace) + if namespace == "" { + namespace = "default" + } + nsID, _ := h.resolveNamespace(ctx, namespace) + h.markNonceUsed(ctx, nsID, strings.ToLower(req.Wallet), req.Nonce) + + // Verify NFT ownership (server-side) + if h.solanaVerifier != nil { + owns, err := h.solanaVerifier.VerifyNFTOwnership(ctx, req.Wallet) + if err != nil { + h.updateSessionFailed(internalCtx, db, req.SessionID, "NFT verification error: "+err.Error()) + writeError(w, http.StatusInternalServerError, "NFT verification failed") + return + } + if !owns { + h.updateSessionFailed(internalCtx, db, req.SessionID, "wallet does not own required NFT") + writeError(w, http.StatusForbidden, "wallet does not own an NFT from the required collection") + return + } + } + + // Issue API key + apiKey, err := h.authService.GetOrCreateAPIKey(ctx, req.Wallet, namespace) + if err != nil { + h.updateSessionFailed(internalCtx, db, req.SessionID, "failed to issue API key") + writeError(w, http.StatusInternalServerError, "failed to issue API key") + return + } + + // Update session to completed (AND status = 'pending' prevents race condition) + _, _ = db.Query(internalCtx, + "UPDATE phantom_auth_sessions SET status = 'completed', wallet = ?, api_key = ? WHERE id = ? AND status = 'pending'", + strings.ToLower(req.Wallet), apiKey, req.SessionID, + ) + + writeJSON(w, http.StatusOK, map[string]any{ + "success": true, + }) +} + +// updateSessionFailed marks a session as failed with an error message. +func (h *Handlers) updateSessionFailed(ctx context.Context, db DatabaseClient, sessionID, errMsg string) { + _, _ = db.Query(ctx, "UPDATE phantom_auth_sessions SET status = 'failed', error_message = ? WHERE id = ?", errMsg, sessionID) +} + +// getString extracts a string from an interface value. +func getString(v interface{}) string { + if s, ok := v.(string); ok { + return s + } + return "" +} diff --git a/pkg/gateway/handlers/auth/wallet_handler.go b/pkg/gateway/handlers/auth/wallet_handler.go index 673cd1f..436dab1 100644 --- a/pkg/gateway/handlers/auth/wallet_handler.go +++ b/pkg/gateway/handlers/auth/wallet_handler.go @@ -2,7 +2,6 @@ package auth import ( "encoding/json" - "fmt" "net/http" "strings" @@ -118,327 +117,3 @@ func (h *Handlers) RegisterHandler(w http.ResponseWriter, r *http.Request) { }) } -// LoginPageHandler serves the wallet authentication login page. -// This provides an interactive HTML page for wallet-based authentication -// using MetaMask or other Web3 wallet providers. -// -// GET /v1/auth/login?callback= -// Query params: callback (required) - URL to redirect after successful auth -// Response: HTML page with wallet connection UI -func (h *Handlers) LoginPageHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - callbackURL := r.URL.Query().Get("callback") - if callbackURL == "" { - writeError(w, http.StatusBadRequest, "callback parameter is required") - return - } - - // Get default namespace - ns := strings.TrimSpace(h.defaultNS) - if ns == "" { - ns = "default" - } - - w.Header().Set("Content-Type", "text/html") - w.WriteHeader(http.StatusOK) - - html := fmt.Sprintf(` - - - - - DeBros Network - Wallet Authentication - - - -
- -

Secure Wallet Authentication

- -
- 📁 Namespace: %s -
- -
-
1Connect Your Wallet
-

Click the button below to connect your Ethereum wallet (MetaMask, WalletConnect, etc.)

-
- -
-
2Sign Authentication Message
-

Your wallet will prompt you to sign a message to prove your identity. This is free and secure.

-
- -
-
3Get Your API Key
-

After signing, you'll receive an API key to access the DeBros Network.

-
- -
-
- -
-
-

Processing authentication...

-
- - - -
- - - -`, ns, callbackURL, ns) - - fmt.Fprint(w, html) -} diff --git a/pkg/gateway/middleware.go b/pkg/gateway/middleware.go index 83fafc8..cf9d45c 100644 --- a/pkg/gateway/middleware.go +++ b/pkg/gateway/middleware.go @@ -449,8 +449,13 @@ func isPublicPath(p string) bool { return true } + // Phantom auth endpoints are public (session creation, status polling, completion) + if strings.HasPrefix(p, "/v1/auth/phantom/") { + return true + } + switch p { - case "/health", "/v1/health", "/status", "/v1/status", "/v1/auth/jwks", "/.well-known/jwks.json", "/v1/version", "/v1/auth/login", "/v1/auth/challenge", "/v1/auth/verify", "/v1/auth/register", "/v1/auth/refresh", "/v1/auth/logout", "/v1/auth/api-key", "/v1/auth/simple-key", "/v1/network/status", "/v1/network/peers", "/v1/internal/tls/check", "/v1/internal/acme/present", "/v1/internal/acme/cleanup", "/v1/internal/ping": + 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", "/v1/network/status", "/v1/network/peers", "/v1/internal/tls/check", "/v1/internal/acme/present", "/v1/internal/acme/cleanup", "/v1/internal/ping": return true default: // Also exempt namespace status polling endpoint diff --git a/pkg/gateway/routes.go b/pkg/gateway/routes.go index 0fac8df..3835039 100644 --- a/pkg/gateway/routes.go +++ b/pkg/gateway/routes.go @@ -48,10 +48,9 @@ func (g *Gateway) Routes() http.Handler { mux.HandleFunc("/v1/auth/jwks", g.authService.JWKSHandler) mux.HandleFunc("/.well-known/jwks.json", g.authService.JWKSHandler) if g.authHandlers != nil { - mux.HandleFunc("/v1/auth/login", g.authHandlers.LoginPageHandler) mux.HandleFunc("/v1/auth/challenge", g.authHandlers.ChallengeHandler) mux.HandleFunc("/v1/auth/verify", g.authHandlers.VerifyHandler) - // New: issue JWT from API key; new: create or return API key for a wallet after verification + // Issue JWT from API key; create or return API key for a wallet after verification mux.HandleFunc("/v1/auth/token", g.authHandlers.APIKeyToJWTHandler) mux.HandleFunc("/v1/auth/api-key", g.authHandlers.IssueAPIKeyHandler) mux.HandleFunc("/v1/auth/simple-key", g.authHandlers.SimpleAPIKeyHandler) @@ -59,6 +58,10 @@ func (g *Gateway) Routes() http.Handler { mux.HandleFunc("/v1/auth/refresh", g.authHandlers.RefreshHandler) mux.HandleFunc("/v1/auth/logout", g.authHandlers.LogoutHandler) mux.HandleFunc("/v1/auth/whoami", g.authHandlers.WhoamiHandler) + // Phantom Solana auth (QR code + deep link) + mux.HandleFunc("/v1/auth/phantom/session", g.authHandlers.PhantomSessionHandler) + mux.HandleFunc("/v1/auth/phantom/session/", g.authHandlers.PhantomSessionStatusHandler) + mux.HandleFunc("/v1/auth/phantom/complete", g.authHandlers.PhantomCompleteHandler) } // rqlite ORM HTTP gateway (mounts /v1/rqlite/* endpoints) diff --git a/pkg/node/gateway.go b/pkg/node/gateway.go index 91b55fe..136be43 100644 --- a/pkg/node/gateway.go +++ b/pkg/node/gateway.go @@ -45,19 +45,22 @@ func (n *Node) startHTTPGateway(ctx context.Context) error { } gwCfg := &gateway.Config{ - ListenAddr: n.config.HTTPGateway.ListenAddr, - ClientNamespace: n.config.HTTPGateway.ClientNamespace, - BootstrapPeers: n.config.Discovery.BootstrapPeers, - NodePeerID: loadNodePeerIDFromIdentity(n.config.Node.DataDir), - RQLiteDSN: n.config.HTTPGateway.RQLiteDSN, - OlricServers: n.config.HTTPGateway.OlricServers, - OlricTimeout: n.config.HTTPGateway.OlricTimeout, - IPFSClusterAPIURL: n.config.HTTPGateway.IPFSClusterAPIURL, - IPFSAPIURL: n.config.HTTPGateway.IPFSAPIURL, - IPFSTimeout: n.config.HTTPGateway.IPFSTimeout, - BaseDomain: n.config.HTTPGateway.BaseDomain, - DataDir: oramaDir, - ClusterSecret: clusterSecret, + ListenAddr: n.config.HTTPGateway.ListenAddr, + ClientNamespace: n.config.HTTPGateway.ClientNamespace, + BootstrapPeers: n.config.Discovery.BootstrapPeers, + NodePeerID: loadNodePeerIDFromIdentity(n.config.Node.DataDir), + RQLiteDSN: n.config.HTTPGateway.RQLiteDSN, + OlricServers: n.config.HTTPGateway.OlricServers, + OlricTimeout: n.config.HTTPGateway.OlricTimeout, + IPFSClusterAPIURL: n.config.HTTPGateway.IPFSClusterAPIURL, + IPFSAPIURL: n.config.HTTPGateway.IPFSAPIURL, + IPFSTimeout: n.config.HTTPGateway.IPFSTimeout, + BaseDomain: n.config.HTTPGateway.BaseDomain, + DataDir: oramaDir, + ClusterSecret: clusterSecret, + PhantomAuthURL: os.Getenv("PHANTOM_AUTH_URL"), + SolanaRPCURL: os.Getenv("SOLANA_RPC_URL"), + NFTCollectionAddress: os.Getenv("NFT_COLLECTION_ADDRESS"), } apiGateway, err := gateway.New(gatewayLogger, gwCfg) diff --git a/scripts/patches/fix-anyone-relay.sh b/scripts/patches/fix-anyone-relay.sh new file mode 100755 index 0000000..af84418 --- /dev/null +++ b/scripts/patches/fix-anyone-relay.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# +# Patch: Fix Anyone relay after orama upgrade. +# +# After orama upgrade, the firewall reset drops the ORPort 9001 rule because +# preferences.yaml didn't have anyone_relay=true. This patch: +# 1. Opens port 9001/tcp in UFW +# 2. Re-enables debros-anyone-relay (survives reboot) +# 3. Saves anyone_relay preference so future upgrades preserve the rule +# +# Usage: +# scripts/patches/fix-anyone-relay.sh --devnet +# scripts/patches/fix-anyone-relay.sh --testnet +# +set -euo pipefail + +ENV="" +for arg in "$@"; do + case "$arg" in + --devnet) ENV="devnet" ;; + --testnet) ENV="testnet" ;; + -h|--help) + echo "Usage: scripts/patches/fix-anyone-relay.sh --devnet|--testnet" + exit 0 + ;; + *) echo "Unknown flag: $arg" >&2; exit 1 ;; + esac +done + +if [[ -z "$ENV" ]]; then + echo "ERROR: specify --devnet or --testnet" >&2 + exit 1 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CONF="$ROOT_DIR/scripts/remote-nodes.conf" +[[ -f "$CONF" ]] || { echo "ERROR: Missing $CONF" >&2; exit 1; } + +SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o PreferredAuthentications=publickey,password) + +fix_node() { + local user_host="$1" + local password="$2" + local ssh_key="$3" + + # The remote script: + # 1. Check if anyone relay service exists, skip if not + # 2. Open ORPort 9001 in UFW + # 3. Enable the service (auto-start on boot) + # 4. Update preferences.yaml with anyone_relay: true + local cmd + cmd=$(cat <<'REMOTE' +set -e +PREFS="/home/debros/.orama/preferences.yaml" + +# Only patch nodes that have the Anyone relay service installed +if [ ! -f /etc/systemd/system/debros-anyone-relay.service ]; then + echo "SKIP_NO_RELAY" + exit 0 +fi + +# 1. Open ORPort 9001 in UFW +sudo ufw allow 9001/tcp >/dev/null 2>&1 + +# 2. Enable the service so it survives reboot +sudo systemctl enable debros-anyone-relay >/dev/null 2>&1 + +# 3. Restart the service if not running +if ! systemctl is-active --quiet debros-anyone-relay; then + sudo systemctl start debros-anyone-relay >/dev/null 2>&1 +fi + +# 4. Save anyone_relay preference if missing +if [ -f "$PREFS" ]; then + if ! grep -q "anyone_relay:" "$PREFS"; then + echo "anyone_relay: true" | sudo tee -a "$PREFS" >/dev/null + echo "anyone_orport: 9001" | sudo tee -a "$PREFS" >/dev/null + elif grep -q "anyone_relay: false" "$PREFS"; then + sudo sed -i 's/anyone_relay: false/anyone_relay: true/' "$PREFS" + if ! grep -q "anyone_orport:" "$PREFS"; then + echo "anyone_orport: 9001" | sudo tee -a "$PREFS" >/dev/null + fi + fi +fi + +echo "PATCH_OK" +REMOTE +) + + local result + if [[ -n "$ssh_key" ]]; then + expanded_key="${ssh_key/#\~/$HOME}" + result=$(ssh -n "${SSH_OPTS[@]}" -i "$expanded_key" "$user_host" "$cmd" 2>&1) + else + result=$(sshpass -p "$password" ssh -n "${SSH_OPTS[@]}" -o PubkeyAuthentication=no "$user_host" "$cmd" 2>&1) + fi + + if echo "$result" | grep -q "PATCH_OK"; then + echo " OK $user_host — UFW 9001/tcp opened, service enabled, prefs saved" + elif echo "$result" | grep -q "SKIP_NO_RELAY"; then + echo " SKIP $user_host — no Anyone relay installed" + else + echo " ERR $user_host: $result" + fi +} + +# Parse ALL nodes from conf (both node and nameserver roles) +# The fix_node function skips nodes without the relay service installed +HOSTS=() +PASSES=() +KEYS=() + +while IFS='|' read -r env host pass role key; do + [[ -z "$env" || "$env" == \#* ]] && continue + env="${env%%#*}" + env="$(echo "$env" | xargs)" + [[ "$env" != "$ENV" ]] && continue + HOSTS+=("$host") + PASSES+=("$pass") + KEYS+=("${key:-}") +done < "$CONF" + +echo "== fix-anyone-relay ($ENV) — checking ${#HOSTS[@]} nodes ==" + +for i in "${!HOSTS[@]}"; do + fix_node "${HOSTS[$i]}" "${PASSES[$i]}" "${KEYS[$i]}" & +done + +wait +echo "Done."