Fixed firewall problem with anyone rellay and added authentication with root wallet

This commit is contained in:
anonpenguin23 2026-02-13 07:38:54 +02:00
parent 1d186706f6
commit 5fed8a6c88
21 changed files with 1740 additions and 379 deletions

2
.gitignore vendored
View File

@ -108,3 +108,5 @@ cli
./inspector ./inspector
results/ results/
phantom-auth/

View File

@ -192,6 +192,17 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
cfg.IPFSReplicationFactor = y.IPFSReplicationFactor 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 // Validate configuration
if errs := cfg.ValidateConfig(); len(errs) > 0 { if errs := cfg.ValidateConfig(); len(errs) > 0 {
fmt.Fprintf(os.Stderr, "\nGateway configuration errors (%d):\n", len(errs)) fmt.Fprintf(os.Stderr, "\nGateway configuration errors (%d):\n", len(errs))

2
go.mod
View File

@ -176,6 +176,7 @@ require (
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // 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/miekg/dns v1.1.70 // indirect
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // 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/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
lukechampine.com/blake3 v1.4.1 // 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/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
sigs.k8s.io/mcs-api v0.3.0 // indirect sigs.k8s.io/mcs-api v0.3.0 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect

4
go.sum
View File

@ -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.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 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= 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/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.31/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= 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= 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 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 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 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/mcs-api v0.3.0 h1:LjRvgzjMrvO1904GP6XBJSnIX221DJMyQlZOYt9LAnM= sigs.k8s.io/mcs-api v0.3.0 h1:LjRvgzjMrvO1904GP6XBJSnIX221DJMyQlZOYt9LAnM=

View File

@ -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;

195
pkg/auth/phantom.go Normal file
View File

@ -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")
}

229
pkg/auth/rootwallet.go Normal file
View File

@ -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
}

View File

@ -15,8 +15,9 @@ import (
) )
// PerformSimpleAuthentication performs a simple authentication flow where the user // PerformSimpleAuthentication performs a simple authentication flow where the user
// provides a wallet address and receives an API key without signature verification // provides a wallet address and receives an API key without signature verification.
func PerformSimpleAuthentication(gatewayURL, wallet, namespace string) (*Credentials, error) { // Requires an existing valid API key (convenience re-auth only).
func PerformSimpleAuthentication(gatewayURL, wallet, namespace, existingAPIKey string) (*Credentials, error) {
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
fmt.Println("\n🔐 Simple Wallet Authentication") fmt.Println("\n🔐 Simple Wallet Authentication")
@ -67,7 +68,7 @@ func PerformSimpleAuthentication(gatewayURL, wallet, namespace string) (*Credent
fmt.Println("⏳ Requesting API key from gateway...") fmt.Println("⏳ Requesting API key from gateway...")
// Request 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 { if err != nil {
return nil, fmt.Errorf("failed to request API key: %w", err) 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("\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 return creds, nil
} }
// requestAPIKeyFromGateway calls the gateway's simple-key endpoint to generate an API key // 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 // 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{ reqBody := map[string]string{
"wallet": wallet, "wallet": wallet,
"namespace": namespace, "namespace": namespace,
@ -114,7 +119,16 @@ func requestAPIKeyFromGateway(gatewayURL, wallet, namespace string) (string, err
domain := extractDomainFromURL(gatewayURL) domain := extractDomainFromURL(gatewayURL)
client := tlsutil.NewHTTPClientForDomain(30*time.Second, domain) 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 { if err != nil {
return "", fmt.Errorf("failed to call gateway: %w", err) 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 // Handle 202 Accepted - namespace cluster is being provisioned
if resp.StatusCode == http.StatusAccepted { 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 { 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 // 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{} var provResp map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&provResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&provResp); err != nil {
return "", fmt.Errorf("failed to decode provisioning response: %w", err) 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("\n✅ Namespace cluster ready!")
fmt.Println("⏳ Retrieving API key...") 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 // 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 // Build full poll URL if it's a relative path
if strings.HasPrefix(pollURL, "/") { if strings.HasPrefix(pollURL, "/") {
pollURL = gatewayURL + 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) 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 // 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{ reqBody := map[string]string{
"wallet": wallet, "wallet": wallet,
"namespace": namespace, "namespace": namespace,
@ -273,7 +294,16 @@ func retryAPIKeyRequest(gatewayURL string, client *http.Client, wallet, namespac
endpoint := gatewayURL + "/v1/auth/simple-key" 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 { if err != nil {
return "", fmt.Errorf("failed to call gateway: %w", err) return "", fmt.Errorf("failed to call gateway: %w", err)
} }

View File

@ -21,11 +21,13 @@ func HandleAuthCommand(args []string) {
switch subcommand { switch subcommand {
case "login": case "login":
var wallet, namespace string var wallet, namespace string
var simple bool
fs := flag.NewFlagSet("auth login", flag.ExitOnError) 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.StringVar(&namespace, "namespace", "", "Namespace name")
fs.BoolVar(&simple, "simple", false, "Use simple auth without signature verification")
_ = fs.Parse(args[1:]) _ = fs.Parse(args[1:])
handleAuthLogin(wallet, namespace) handleAuthLogin(wallet, namespace, simple)
case "logout": case "logout":
handleAuthLogout() handleAuthLogout()
case "whoami": case "whoami":
@ -47,30 +49,37 @@ func showAuthHelp() {
fmt.Printf("🔐 Authentication Commands\n\n") fmt.Printf("🔐 Authentication Commands\n\n")
fmt.Printf("Usage: orama auth <subcommand>\n\n") fmt.Printf("Usage: orama auth <subcommand>\n\n")
fmt.Printf("Subcommands:\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(" logout - Clear stored credentials\n")
fmt.Printf(" whoami - Show current authentication status\n") fmt.Printf(" whoami - Show current authentication status\n")
fmt.Printf(" status - Show detailed authentication info\n") fmt.Printf(" status - Show detailed authentication info\n")
fmt.Printf(" list - List all stored credentials for current environment\n") fmt.Printf(" list - List all stored credentials for current environment\n")
fmt.Printf(" switch - Switch between stored credentials\n\n") fmt.Printf(" switch - Switch between stored credentials\n\n")
fmt.Printf("Login Flags:\n")
fmt.Printf(" --namespace <name> - 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("Examples:\n")
fmt.Printf(" orama auth login # Enter wallet address interactively\n") fmt.Printf(" orama auth login # Sign with RootWallet (default)\n")
fmt.Printf(" orama auth login --wallet 0x... --namespace myns # Non-interactive\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 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 logout # Clear all stored credentials\n\n")
fmt.Printf("Environment Variables:\n") fmt.Printf("Environment Variables:\n")
fmt.Printf(" DEBROS_GATEWAY_URL - Gateway URL (overrides environment config)\n\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(" 1. Run 'orama auth login'\n")
fmt.Printf(" 2. Enter your wallet address when prompted\n") fmt.Printf(" 2. Your wallet address is read from RootWallet automatically\n")
fmt.Printf(" 3. Enter your namespace (or press Enter for 'default')\n") fmt.Printf(" 3. Enter your namespace when prompted\n")
fmt.Printf(" 4. An API key will be generated and saved to ~/.orama/credentials.json\n\n") fmt.Printf(" 4. A challenge nonce is signed with your wallet key\n")
fmt.Printf("Note: Authentication uses the currently active environment.\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") 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 // Get gateway URL from active environment
gatewayURL := getGatewayURL() gatewayURL := getGatewayURL()
@ -129,8 +138,43 @@ func handleAuthLogin(wallet, namespace string) {
} }
} }
// Perform simple authentication to add a new credential // Choose authentication method
creds, err := auth.PerformSimpleAuthentication(gatewayURL, wallet, namespace) 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 { if err != nil {
fmt.Fprintf(os.Stderr, "❌ Authentication failed: %v\n", err) fmt.Fprintf(os.Stderr, "❌ Authentication failed: %v\n", err)
os.Exit(1) os.Exit(1)
@ -155,7 +199,6 @@ func handleAuthLogin(wallet, namespace string) {
fmt.Printf("📁 Credentials saved to: %s\n", credsPath) fmt.Printf("📁 Credentials saved to: %s\n", credsPath)
fmt.Printf("🎯 Wallet: %s\n", creds.Wallet) fmt.Printf("🎯 Wallet: %s\n", creds.Wallet)
fmt.Printf("🏢 Namespace: %s\n", creds.Namespace) fmt.Printf("🏢 Namespace: %s\n", creds.Namespace)
fmt.Printf("🔑 API Key: %s\n", creds.APIKey)
if creds.NamespaceURL != "" { if creds.NamespaceURL != "" {
fmt.Printf("🌐 Namespace URL: %s\n", creds.NamespaceURL) fmt.Printf("🌐 Namespace URL: %s\n", creds.NamespaceURL)
} }

View File

@ -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 := production.NewProductionSetup(oramaHome, os.Stdout, flags.Force, branch, flags.NoPull, flags.SkipChecks, flags.PreBuilt)
setup.SetNameserver(isNameserver) setup.SetNameserver(isNameserver)
// Configure Anyone mode (flag > saved preference) // Configure Anyone mode (flag > saved preference > auto-detect)
if flags.AnyoneRelay { if flags.AnyoneRelay {
setup.SetAnyoneRelayConfig(&production.AnyoneRelayConfig{ setup.SetAnyoneRelayConfig(&production.AnyoneRelayConfig{
Enabled: true, Enabled: true,
@ -71,6 +71,20 @@ func NewOrchestrator(flags *Flags) *Orchestrator {
Enabled: true, Enabled: true,
ORPort: orPort, 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 { } else if flags.AnyoneClient || prefs.AnyoneClient {
setup.SetAnyoneClient(true) setup.SetAnyoneClient(true)
} }
@ -648,17 +662,15 @@ func (o *Orchestrator) restartServices() error {
// Get services to restart // Get services to restart
services := utils.GetProductionServices() services := utils.GetProductionServices()
// Re-enable namespace services BEFORE restarting debros-node. // Re-enable all services BEFORE restarting them.
// orama prod stop disables them, and debros-node's PartOf= dependency // orama prod stop disables services, and debros-node's PartOf= dependency
// won't propagate restart to disabled services. We must re-enable first // 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 { for _, svc := range services {
if strings.Contains(svc, "@") {
if err := exec.Command("systemctl", "enable", svc).Run(); err != nil { if err := exec.Command("systemctl", "enable", svc).Run(); err != nil {
fmt.Printf(" ⚠️ Warning: Failed to re-enable %s: %v\n", svc, err) fmt.Printf(" ⚠️ Warning: Failed to re-enable %s: %v\n", svc, err)
} }
} }
}
// If this is a nameserver, also restart CoreDNS and Caddy // If this is a nameserver, also restart CoreDNS and Caddy
if o.setup.IsNameserver() { if o.setup.IsNameserver() {
@ -803,3 +815,46 @@ func (o *Orchestrator) waitForClusterHealth(timeout time.Duration) error {
return fmt.Errorf("timeout waiting for cluster to become healthy") 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
}

View File

@ -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<Vec<Creator>>)
// - then: primary_sale_happened (bool, 1 byte)
// - then: is_mutable (bool, 1 byte)
// - then: edition_nonce (Option<u8>)
// - then: token_standard (Option<u8>)
// - then: collection (Option<Collection>)
// - 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<Vec<Creator>>)
// 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<u8>)
if offset >= len(data) {
return nil, false
}
if data[offset] == 1 {
offset += 2 // option byte + u8
} else {
offset++ // None
}
// Skip token_standard (Option<u8>)
if offset >= len(data) {
return nil, false
}
if data[offset] == 1 {
offset += 2
} else {
offset++
}
// Collection (Option<Collection>)
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)
}

View File

@ -41,4 +41,9 @@ type Config struct {
// WireGuard mesh configuration // WireGuard mesh configuration
ClusterSecret string // Cluster secret for authenticating internal WireGuard peer exchange 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
} }

View File

@ -321,6 +321,19 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
cfg.ClientNamespace, cfg.ClientNamespace,
gw.withInternalAuth, 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) // Initialize middleware cache (60s TTL for auth/routing lookups)

View File

@ -116,10 +116,11 @@ func (h *Handlers) IssueAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
} }
// SimpleAPIKeyHandler generates an API key without signature verification. // 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 // POST /v1/auth/simple-key
// Request body: SimpleAPIKeyRequest // Request body: SimpleAPIKeyRequest
// Headers: X-API-Key or Authorization required
// Response: { "api_key", "namespace", "wallet", "created" } // Response: { "api_key", "namespace", "wallet", "created" }
func (h *Handlers) SimpleAPIKeyHandler(w http.ResponseWriter, r *http.Request) { func (h *Handlers) SimpleAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
if h.authService == nil { if h.authService == nil {
@ -131,6 +132,13 @@ func (h *Handlers) SimpleAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
return 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 var req SimpleAPIKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body") writeError(w, http.StatusBadRequest, "invalid json body")

View File

@ -56,6 +56,8 @@ type Handlers struct {
defaultNS string defaultNS string
internalAuthFn func(context.Context) context.Context internalAuthFn func(context.Context) context.Context
clusterProvisioner ClusterProvisioner // Optional: for namespace cluster provisioning 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 // NewHandlers creates a new authentication handlers instance
@ -80,6 +82,12 @@ func (h *Handlers) SetClusterProvisioner(cp ClusterProvisioner) {
h.clusterProvisioner = cp 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 // markNonceUsed marks a nonce as used in the database
func (h *Handlers) markNonceUsed(ctx context.Context, namespaceID interface{}, wallet, nonce string) { func (h *Handlers) markNonceUsed(ctx context.Context, namespaceID interface{}, wallet, nonce string) {
if h.netClient == nil { if h.netClient == nil {

View File

@ -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 ""
}

View File

@ -2,7 +2,6 @@ package auth
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strings" "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=<url>
// 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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DeBros Network - Wallet Authentication</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
margin: 0;
padding: 20px;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
padding: 40px;
max-width: 500px;
width: 100%%;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: bold;
color: #667eea;
margin-bottom: 10px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
}
.step {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
text-align: left;
}
.step-number {
background: #667eea;
color: white;
border-radius: 50%%;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-right: 10px;
}
button {
background: #667eea;
color: white;
border: none;
border-radius: 8px;
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin: 10px;
}
button:hover {
background: #5a67d8;
transform: translateY(-1px);
}
button:disabled {
background: #cbd5e0;
cursor: not-allowed;
transform: none;
}
.error {
background: #fed7d7;
color: #e53e3e;
padding: 12px;
border-radius: 8px;
margin: 20px 0;
display: none;
}
.success {
background: #c6f6d5;
color: #2f855a;
padding: 12px;
border-radius: 8px;
margin: 20px 0;
display: none;
}
.loading {
display: none;
margin: 20px 0;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0%% { transform: rotate(0deg); }
100%% { transform: rotate(360deg); }
}
.namespace-info {
background: #e6fffa;
border: 1px solid #81e6d9;
border-radius: 8px;
padding: 15px;
margin: 20px 0;
}
.code {
font-family: 'Monaco', 'Menlo', monospace;
background: #f7fafc;
padding: 4px 8px;
border-radius: 4px;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">🌐 DeBros Network</div>
<p class="subtitle">Secure Wallet Authentication</p>
<div class="namespace-info">
<strong>📁 Namespace:</strong> <span class="code">%s</span>
</div>
<div class="step">
<div><span class="step-number">1</span><strong>Connect Your Wallet</strong></div>
<p>Click the button below to connect your Ethereum wallet (MetaMask, WalletConnect, etc.)</p>
</div>
<div class="step">
<div><span class="step-number">2</span><strong>Sign Authentication Message</strong></div>
<p>Your wallet will prompt you to sign a message to prove your identity. This is free and secure.</p>
</div>
<div class="step">
<div><span class="step-number">3</span><strong>Get Your API Key</strong></div>
<p>After signing, you'll receive an API key to access the DeBros Network.</p>
</div>
<div class="error" id="error"></div>
<div class="success" id="success"></div>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>Processing authentication...</p>
</div>
<button onclick="connectWallet()" id="connectBtn">🔗 Connect Wallet</button>
<button onclick="window.close()" style="background: #718096;"> Cancel</button>
</div>
<script>
const callbackURL = '%s';
const namespace = '%s';
let walletAddress = null;
async function connectWallet() {
const btn = document.getElementById('connectBtn');
const loading = document.getElementById('loading');
const error = document.getElementById('error');
const success = document.getElementById('success');
try {
btn.disabled = true;
loading.style.display = 'block';
error.style.display = 'none';
success.style.display = 'none';
// Check if MetaMask is available
if (typeof window.ethereum === 'undefined') {
throw new Error('Please install MetaMask or another Ethereum wallet');
}
// Request account access
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts'
});
if (accounts.length === 0) {
throw new Error('No wallet accounts found');
}
walletAddress = accounts[0];
console.log('Connected to wallet:', walletAddress);
// Step 1: Get challenge nonce
const challengeResponse = await fetch('/v1/auth/challenge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
wallet: walletAddress,
purpose: 'api_key_generation',
namespace: namespace
})
});
if (!challengeResponse.ok) {
const errorData = await challengeResponse.json();
throw new Error(errorData.error || 'Failed to get challenge');
}
const challengeData = await challengeResponse.json();
const nonce = challengeData.nonce;
console.log('Received challenge nonce:', nonce);
// Step 2: Sign the nonce
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [nonce, walletAddress]
});
console.log('Signature obtained:', signature);
// Step 3: Get API key
const apiKeyResponse = await fetch('/v1/auth/api-key', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
wallet: walletAddress,
nonce: nonce,
signature: signature,
namespace: namespace
})
});
if (!apiKeyResponse.ok) {
const errorData = await apiKeyResponse.json();
throw new Error(errorData.error || 'Failed to get API key');
}
const apiKeyData = await apiKeyResponse.json();
console.log('API key received:', apiKeyData);
loading.style.display = 'none';
success.innerHTML = ' Authentication successful! Redirecting...';
success.style.display = 'block';
// Redirect to callback URL with credentials
const params = new URLSearchParams({
api_key: apiKeyData.api_key,
namespace: apiKeyData.namespace,
wallet: apiKeyData.wallet,
plan: apiKeyData.plan || 'free'
});
const redirectURL = callbackURL + '?' + params.toString();
console.log('Redirecting to:', redirectURL);
setTimeout(() => {
window.location.href = redirectURL;
}, 1500);
} catch (err) {
console.error('Authentication error:', err);
loading.style.display = 'none';
error.innerHTML = ' ' + err.message;
error.style.display = 'block';
btn.disabled = false;
}
}
// Auto-detect if wallet is already connected
window.addEventListener('load', async () => {
if (typeof window.ethereum !== 'undefined') {
try {
const accounts = await window.ethereum.request({ method: 'eth_accounts' });
if (accounts.length > 0) {
const btn = document.getElementById('connectBtn');
btn.innerHTML = '🔗 Continue with ' + accounts[0].slice(0, 6) + '...' + accounts[0].slice(-4);
}
} catch (err) {
console.log('Could not get accounts:', err);
}
}
});
</script>
</body>
</html>`, ns, callbackURL, ns)
fmt.Fprint(w, html)
}

View File

@ -449,8 +449,13 @@ func isPublicPath(p string) bool {
return true return true
} }
// Phantom auth endpoints are public (session creation, status polling, completion)
if strings.HasPrefix(p, "/v1/auth/phantom/") {
return true
}
switch p { 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 return true
default: default:
// Also exempt namespace status polling endpoint // Also exempt namespace status polling endpoint

View File

@ -48,10 +48,9 @@ func (g *Gateway) Routes() http.Handler {
mux.HandleFunc("/v1/auth/jwks", g.authService.JWKSHandler) mux.HandleFunc("/v1/auth/jwks", g.authService.JWKSHandler)
mux.HandleFunc("/.well-known/jwks.json", g.authService.JWKSHandler) mux.HandleFunc("/.well-known/jwks.json", g.authService.JWKSHandler)
if g.authHandlers != nil { if g.authHandlers != nil {
mux.HandleFunc("/v1/auth/login", g.authHandlers.LoginPageHandler)
mux.HandleFunc("/v1/auth/challenge", g.authHandlers.ChallengeHandler) mux.HandleFunc("/v1/auth/challenge", g.authHandlers.ChallengeHandler)
mux.HandleFunc("/v1/auth/verify", g.authHandlers.VerifyHandler) 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/token", g.authHandlers.APIKeyToJWTHandler)
mux.HandleFunc("/v1/auth/api-key", g.authHandlers.IssueAPIKeyHandler) mux.HandleFunc("/v1/auth/api-key", g.authHandlers.IssueAPIKeyHandler)
mux.HandleFunc("/v1/auth/simple-key", g.authHandlers.SimpleAPIKeyHandler) 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/refresh", g.authHandlers.RefreshHandler)
mux.HandleFunc("/v1/auth/logout", g.authHandlers.LogoutHandler) mux.HandleFunc("/v1/auth/logout", g.authHandlers.LogoutHandler)
mux.HandleFunc("/v1/auth/whoami", g.authHandlers.WhoamiHandler) 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) // rqlite ORM HTTP gateway (mounts /v1/rqlite/* endpoints)

View File

@ -58,6 +58,9 @@ func (n *Node) startHTTPGateway(ctx context.Context) error {
BaseDomain: n.config.HTTPGateway.BaseDomain, BaseDomain: n.config.HTTPGateway.BaseDomain,
DataDir: oramaDir, DataDir: oramaDir,
ClusterSecret: clusterSecret, 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) apiGateway, err := gateway.New(gatewayLogger, gwCfg)

View File

@ -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."