From d1c60fe44ed0901972e46c2542d7ba934be63e72 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 18 Feb 2026 20:15:31 -0800 Subject: [PATCH] Add wallet session hardening across API and web surfaces --- backend/secretapi/.env.example | 2 + backend/secretapi/README.md | 16 +++ backend/secretapi/app.go | 39 ++++++++ backend/secretapi/app_test.go | 83 ++++++++++++++++ backend/secretapi/config.go | 7 ++ backend/secretapi/marketplace.go | 9 ++ backend/secretapi/models.go | 13 +++ backend/secretapi/session_auth.go | 97 +++++++++++++++++++ backend/secretapi/store.go | 73 ++++++++++++++ docs/api/examples/secret-system.examples.md | 9 +- docs/api/governance-installer.openapi.yaml | 3 + docs/api/marketplace.openapi.yaml | 3 + docs/api/member-channel.openapi.yaml | 3 + docs/api/secret-system.openapi.yaml | 9 +- .../member-channel-backend-checklist.md | 4 +- public/index.html | 19 +++- public/store/index.html | 39 +++++++- 17 files changed, 419 insertions(+), 9 deletions(-) create mode 100644 backend/secretapi/session_auth.go diff --git a/backend/secretapi/.env.example b/backend/secretapi/.env.example index f664c85..077c7e4 100644 --- a/backend/secretapi/.env.example +++ b/backend/secretapi/.env.example @@ -9,6 +9,8 @@ SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION=false SECRET_API_INTENT_TTL_SECONDS=900 SECRET_API_QUOTE_TTL_SECONDS=900 +SECRET_API_WALLET_SESSION_TTL_SECONDS=2592000 +SECRET_API_REQUIRE_WALLET_SESSION=false SECRET_API_DOMAIN_NAME=EDUT Designation SECRET_API_VERIFYING_CONTRACT=0x0000000000000000000000000000000000000000 SECRET_API_MEMBERSHIP_CONTRACT=0x0000000000000000000000000000000000000000 diff --git a/backend/secretapi/README.md b/backend/secretapi/README.md index eb1060b..08d5922 100644 --- a/backend/secretapi/README.md +++ b/backend/secretapi/README.md @@ -57,6 +57,20 @@ Copy `.env.example` in this folder and set contract/runtime values before deploy - `POST /member/channel/events/{event_id}/ack` - `POST /member/channel/support/ticket` +## Wallet Session Hardening + +`POST /secret/wallet/verify` now issues a wallet session token: + +1. Response fields: `session_token`, `session_expires_at` +2. Response headers: `X-Edut-Session`, `X-Edut-Session-Expires-At` + +When `SECRET_API_REQUIRE_WALLET_SESSION=true`, wallet-scoped control-plane endpoints fail closed unless a valid session token is provided via: + +1. `Authorization: Bearer ` +2. `X-Edut-Session: ` + +Covered endpoints include marketplace checkout/entitlements, governance install/lease actions, and member-channel calls. + ## Sponsorship Behavior Membership quote supports ownership wallet and distinct payer wallet: @@ -113,6 +127,8 @@ Policy gates: - `SECRET_API_INTENT_TTL_SECONDS` (default `900`) - `SECRET_API_QUOTE_TTL_SECONDS` (default `900`) +- `SECRET_API_WALLET_SESSION_TTL_SECONDS` (default `2592000`) +- `SECRET_API_REQUIRE_WALLET_SESSION` (default `false`; set `true` for launch hardening) - `SECRET_API_DOMAIN_NAME` - `SECRET_API_VERIFYING_CONTRACT` - `SECRET_API_MEMBERSHIP_CONTRACT` diff --git a/backend/secretapi/app.go b/backend/secretapi/app.go index 34dd1f9..72cfbfb 100644 --- a/backend/secretapi/app.go +++ b/backend/secretapi/app.go @@ -212,12 +212,21 @@ func (a *app) handleWalletVerify(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to store verification status") return } + session, err := a.issueWalletSession(r.Context(), rec.Address, rec.Code) + if err != nil { + writeErrorCode(w, http.StatusInternalServerError, "session_issue_failed", "failed to issue wallet session") + return + } + w.Header().Set(sessionHeaderToken, session.SessionToken) + w.Header().Set(sessionHeaderExpiresAt, session.ExpiresAt.UTC().Format(time.RFC3339Nano)) writeJSON(w, http.StatusOK, walletVerifyResponse{ Status: "signature_verified", DesignationCode: rec.Code, DisplayToken: rec.DisplayToken, VerifiedAt: now.Format(time.RFC3339Nano), + SessionToken: session.SessionToken, + SessionExpires: session.ExpiresAt.UTC().Format(time.RFC3339Nano), }) } @@ -560,6 +569,9 @@ func (a *app) handleGovernanceInstallToken(w http.ResponseWriter, r *http.Reques writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) return } + if !a.enforceWalletSession(w, r, wallet) { + return + } if strings.TrimSpace(req.DeviceID) == "" || strings.TrimSpace(req.Platform) == "" || strings.TrimSpace(req.LauncherVersion) == "" { writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id, platform, launcher_version required") return @@ -659,6 +671,9 @@ func (a *app) handleGovernanceInstallConfirm(w http.ResponseWriter, r *http.Requ writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) return } + if !a.enforceWalletSession(w, r, wallet) { + return + } if strings.TrimSpace(req.InstallToken) == "" || strings.TrimSpace(req.DeviceID) == "" { writeErrorCode(w, http.StatusBadRequest, "invalid_request", "install_token and device_id required") return @@ -772,6 +787,9 @@ func (a *app) handleGovernanceInstallStatus(w http.ResponseWriter, r *http.Reque writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) return } + if !a.enforceWalletSession(w, r, wallet) { + return + } membershipStatus := "none" identityAssurance := assuranceNone @@ -843,6 +861,9 @@ func (a *app) handleGovernanceLeaseHeartbeat(w http.ResponseWriter, r *http.Requ writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) return } + if !a.enforceWalletSession(w, r, wallet) { + return + } principal, err := a.store.getGovernancePrincipal(r.Context(), wallet) if err != nil { writeErrorCode(w, http.StatusForbidden, "principal_missing", "principal state missing") @@ -886,6 +907,9 @@ func (a *app) handleGovernanceOfflineRenew(w http.ResponseWriter, r *http.Reques writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) return } + if !a.enforceWalletSession(w, r, wallet) { + return + } principal, err := a.store.getGovernancePrincipal(r.Context(), wallet) if err != nil { writeErrorCode(w, http.StatusForbidden, "principal_missing", "principal state missing") @@ -944,6 +968,9 @@ func (a *app) handleMemberChannelDeviceRegister(w http.ResponseWriter, r *http.R writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) return } + if !a.enforceWalletSession(w, r, wallet) { + return + } if req.ChainID != a.cfg.ChainID { writeErrorCode(w, http.StatusBadRequest, "unsupported_chain_id", fmt.Sprintf("unsupported chain_id: %d", req.ChainID)) return @@ -1022,6 +1049,9 @@ func (a *app) handleMemberChannelDeviceUnregister(w http.ResponseWriter, r *http writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) return } + if !a.enforceWalletSession(w, r, wallet) { + return + } req.DeviceID = strings.TrimSpace(req.DeviceID) if req.DeviceID == "" { writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id required") @@ -1052,6 +1082,9 @@ func (a *app) handleMemberChannelEvents(w http.ResponseWriter, r *http.Request) writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) return } + if !a.enforceWalletSession(w, r, wallet) { + return + } deviceID := strings.TrimSpace(r.URL.Query().Get("device_id")) if deviceID == "" { writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id required") @@ -1151,6 +1184,9 @@ func (a *app) handleMemberChannelEventAck(w http.ResponseWriter, r *http.Request writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) return } + if !a.enforceWalletSession(w, r, wallet) { + return + } deviceID := strings.TrimSpace(req.DeviceID) if deviceID == "" { writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id required") @@ -1202,6 +1238,9 @@ func (a *app) handleMemberChannelSupportTicket(w http.ResponseWriter, r *http.Re writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) return } + if !a.enforceWalletSession(w, r, wallet) { + return + } req.OrgRootID = strings.TrimSpace(req.OrgRootID) req.PrincipalID = strings.TrimSpace(req.PrincipalID) req.Category = strings.TrimSpace(req.Category) diff --git a/backend/secretapi/app_test.go b/backend/secretapi/app_test.go index 3b6ff51..2a0d036 100644 --- a/backend/secretapi/app_test.go +++ b/backend/secretapi/app_test.go @@ -1144,6 +1144,72 @@ func TestMarketplaceSoloCoreScopeValidation(t *testing.T) { } } +func TestWalletSessionRequiredForMarketplaceQuote(t *testing.T) { + a, _, cleanup := newTestApp(t) + defer cleanup() + a.cfg.RequireWalletSession = true + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { + t.Fatalf("seed active membership: %v", err) + } + + errResp := postJSONExpect[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ + Wallet: ownerAddr, + OfferID: offerIDSoloCore, + PrincipalRole: "org_root_owner", + }, http.StatusUnauthorized) + if code := errResp["code"]; code != "wallet_session_required" { + t.Fatalf("expected wallet_session_required, got %+v", errResp) + } + + session, err := a.issueWalletSession(context.Background(), ownerAddr, "1234567890123") + if err != nil { + t.Fatalf("issue wallet session: %v", err) + } + quote := postJSONExpectWithHeaders[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ + Wallet: ownerAddr, + OfferID: offerIDSoloCore, + PrincipalRole: "org_root_owner", + }, http.StatusOK, map[string]string{ + sessionHeaderToken: session.SessionToken, + }) + if strings.TrimSpace(quote.QuoteID) == "" { + t.Fatalf("expected quote id with valid wallet session") + } +} + +func TestWalletSessionMismatchBlocked(t *testing.T) { + a, _, cleanup := newTestApp(t) + defer cleanup() + a.cfg.RequireWalletSession = true + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { + t.Fatalf("seed active membership: %v", err) + } + + otherKey := mustKey(t) + otherAddr := strings.ToLower(crypto.PubkeyToAddress(otherKey.PublicKey).Hex()) + session, err := a.issueWalletSession(context.Background(), otherAddr, "9999999999999") + if err != nil { + t.Fatalf("issue wallet session: %v", err) + } + + errResp := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ + Wallet: ownerAddr, + OfferID: offerIDSoloCore, + PrincipalRole: "org_root_owner", + }, http.StatusForbidden, map[string]string{ + sessionHeaderToken: session.SessionToken, + }) + if code := errResp["code"]; code != "wallet_session_mismatch" { + t.Fatalf("expected wallet_session_mismatch, got %+v", errResp) + } +} + type tWalletIntentResponse struct { IntentID string `json:"intent_id"` DesignationCode string `json:"designation_code"` @@ -1155,6 +1221,7 @@ type tWalletIntentResponse struct { type tWalletVerifyResponse struct { Status string `json:"status"` DesignationCode string `json:"designation_code"` + SessionToken string `json:"session_token"` } func newTestApp(t *testing.T) (*app, Config, func()) { @@ -1214,6 +1281,11 @@ func seedMembershipWithAssurance(ctx context.Context, st *store, wallet, assuran } func postJSONExpect[T any](t *testing.T, a *app, path string, payload any, expectStatus int) T { + t.Helper() + return postJSONExpectWithHeaders[T](t, a, path, payload, expectStatus, nil) +} + +func postJSONExpectWithHeaders[T any](t *testing.T, a *app, path string, payload any, expectStatus int, headers map[string]string) T { t.Helper() body, err := json.Marshal(payload) if err != nil { @@ -1222,6 +1294,9 @@ func postJSONExpect[T any](t *testing.T, a *app, path string, payload any, expec req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", "https://edut.ai") + for k, v := range headers { + req.Header.Set(k, v) + } rec := httptest.NewRecorder() a.routes().ServeHTTP(rec, req) if rec.Code != expectStatus { @@ -1238,9 +1313,17 @@ func postJSONExpect[T any](t *testing.T, a *app, path string, payload any, expec } func getJSONExpect[T any](t *testing.T, a *app, path string, expectStatus int) T { + t.Helper() + return getJSONExpectWithHeaders[T](t, a, path, expectStatus, nil) +} + +func getJSONExpectWithHeaders[T any](t *testing.T, a *app, path string, expectStatus int, headers map[string]string) T { t.Helper() req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("Origin", "https://edut.ai") + for k, v := range headers { + req.Header.Set(k, v) + } rec := httptest.NewRecorder() a.routes().ServeHTTP(rec, req) if rec.Code != expectStatus { diff --git a/backend/secretapi/config.go b/backend/secretapi/config.go index 0145db2..32a563a 100644 --- a/backend/secretapi/config.go +++ b/backend/secretapi/config.go @@ -15,6 +15,8 @@ type Config struct { MemberPollIntervalSec int IntentTTL time.Duration QuoteTTL time.Duration + WalletSessionTTL time.Duration + RequireWalletSession bool InstallTokenTTL time.Duration LeaseTTL time.Duration OfflineRenewTTL time.Duration @@ -45,6 +47,8 @@ func loadConfig() Config { MemberPollIntervalSec: envInt("SECRET_API_MEMBER_POLL_INTERVAL_SECONDS", 30), IntentTTL: time.Duration(envInt("SECRET_API_INTENT_TTL_SECONDS", 900)) * time.Second, QuoteTTL: time.Duration(envInt("SECRET_API_QUOTE_TTL_SECONDS", 900)) * time.Second, + WalletSessionTTL: time.Duration(envInt("SECRET_API_WALLET_SESSION_TTL_SECONDS", 2592000)) * time.Second, + RequireWalletSession: envBool("SECRET_API_REQUIRE_WALLET_SESSION", false), InstallTokenTTL: time.Duration(envInt("SECRET_API_INSTALL_TOKEN_TTL_SECONDS", 900)) * time.Second, LeaseTTL: time.Duration(envInt("SECRET_API_LEASE_TTL_SECONDS", 3600)) * time.Second, OfflineRenewTTL: time.Duration(envInt("SECRET_API_OFFLINE_RENEW_TTL_SECONDS", 2592000)) * time.Second, @@ -72,6 +76,9 @@ func (c Config) Validate() error { if c.ChainID <= 0 { return fmt.Errorf("SECRET_API_CHAIN_ID must be positive") } + if c.WalletSessionTTL <= 0 { + return fmt.Errorf("SECRET_API_WALLET_SESSION_TTL_SECONDS must be positive") + } if c.RequireOnchainTxVerify && strings.TrimSpace(c.ChainRPCURL) == "" { return fmt.Errorf("SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION requires SECRET_API_CHAIN_RPC_URL") } diff --git a/backend/secretapi/marketplace.go b/backend/secretapi/marketplace.go index c3bddb5..bceed65 100644 --- a/backend/secretapi/marketplace.go +++ b/backend/secretapi/marketplace.go @@ -224,6 +224,9 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) return } + if !a.enforceWalletSession(w, r, wallet) { + return + } offer, err := a.marketplaceOfferByID(req.OfferID) if err != nil { writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found") @@ -469,6 +472,9 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) return } + if !a.enforceWalletSession(w, r, wallet) { + return + } payerWallet := "" if strings.TrimSpace(req.PayerWallet) != "" { payerWallet, err = normalizeAddress(req.PayerWallet) @@ -652,6 +658,9 @@ func (a *app) handleMarketplaceEntitlements(w http.ResponseWriter, r *http.Reque writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) return } + if !a.enforceWalletSession(w, r, wallet) { + return + } records, err := a.store.listMarketplaceEntitlementsByWallet(r.Context(), wallet) if err != nil { writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to list entitlements") diff --git a/backend/secretapi/models.go b/backend/secretapi/models.go index 9583486..6bd366b 100644 --- a/backend/secretapi/models.go +++ b/backend/secretapi/models.go @@ -33,6 +33,8 @@ type walletVerifyResponse struct { DesignationCode string `json:"designation_code"` DisplayToken string `json:"display_token"` VerifiedAt string `json:"verified_at"` + SessionToken string `json:"session_token,omitempty"` + SessionExpires string `json:"session_expires_at,omitempty"` } type membershipQuoteRequest struct { @@ -135,6 +137,17 @@ type quoteRecord struct { SponsorOrgRootID string } +type walletSessionRecord struct { + SessionToken string + Wallet string + DesignationCode string + ChainID int64 + IssuedAt time.Time + ExpiresAt time.Time + LastSeenAt *time.Time + RevokedAt *time.Time +} + type governanceInstallTokenRequest struct { Wallet string `json:"wallet"` OrgRootID string `json:"org_root_id,omitempty"` diff --git a/backend/secretapi/session_auth.go b/backend/secretapi/session_auth.go new file mode 100644 index 0000000..315c052 --- /dev/null +++ b/backend/secretapi/session_auth.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "net/http" + "strings" + "time" +) + +const ( + sessionHeaderToken = "X-Edut-Session" + sessionHeaderExpiresAt = "X-Edut-Session-Expires-At" +) + +func (a *app) issueWalletSession(ctx context.Context, wallet, designationCode string) (walletSessionRecord, error) { + token, err := randomHex(24) + if err != nil { + return walletSessionRecord{}, err + } + now := time.Now().UTC() + rec := walletSessionRecord{ + SessionToken: token, + Wallet: strings.ToLower(strings.TrimSpace(wallet)), + DesignationCode: strings.TrimSpace(designationCode), + ChainID: a.cfg.ChainID, + IssuedAt: now, + ExpiresAt: now.Add(a.cfg.WalletSessionTTL), + LastSeenAt: &now, + } + if err := a.store.putWalletSession(ctx, rec); err != nil { + return walletSessionRecord{}, err + } + return rec, nil +} + +func sessionTokenFromRequest(r *http.Request) string { + auth := strings.TrimSpace(r.Header.Get("Authorization")) + if auth != "" { + lower := strings.ToLower(auth) + if strings.HasPrefix(lower, "bearer ") { + token := strings.TrimSpace(auth[len("Bearer "):]) + if token != "" { + return token + } + } + } + if token := strings.TrimSpace(r.Header.Get(sessionHeaderToken)); token != "" { + return token + } + return strings.TrimSpace(r.URL.Query().Get("session_token")) +} + +func (a *app) enforceWalletSession(w http.ResponseWriter, r *http.Request, wallet string) bool { + sessionToken := sessionTokenFromRequest(r) + if sessionToken == "" { + if a.cfg.RequireWalletSession { + writeErrorCode(w, http.StatusUnauthorized, "wallet_session_required", "wallet session required") + return false + } + return true + } + + rec, err := a.store.getWalletSession(r.Context(), sessionToken) + if err != nil { + if err == errNotFound { + writeErrorCode(w, http.StatusUnauthorized, "wallet_session_invalid", "wallet session not found") + return false + } + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve wallet session") + return false + } + if rec.RevokedAt != nil { + writeErrorCode(w, http.StatusUnauthorized, "wallet_session_revoked", "wallet session revoked") + return false + } + if rec.ChainID != a.cfg.ChainID { + writeErrorCode(w, http.StatusUnauthorized, "wallet_session_invalid", "wallet session chain mismatch") + return false + } + now := time.Now().UTC() + if !now.Before(rec.ExpiresAt.UTC()) { + writeErrorCode(w, http.StatusUnauthorized, "wallet_session_expired", "wallet session expired") + return false + } + if !strings.EqualFold(strings.TrimSpace(rec.Wallet), strings.TrimSpace(wallet)) { + writeErrorCode(w, http.StatusForbidden, "wallet_session_mismatch", "wallet session does not match requested wallet") + return false + } + if err := a.store.touchWalletSession(r.Context(), rec.SessionToken, now); err != nil && err != errNotFound { + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to update wallet session") + return false + } + + w.Header().Set(sessionHeaderToken, rec.SessionToken) + w.Header().Set(sessionHeaderExpiresAt, rec.ExpiresAt.UTC().Format(time.RFC3339Nano)) + return true +} diff --git a/backend/secretapi/store.go b/backend/secretapi/store.go index 02ed93a..a3b394d 100644 --- a/backend/secretapi/store.go +++ b/backend/secretapi/store.go @@ -61,6 +61,18 @@ func (s *store) migrate(ctx context.Context) error { );`, `CREATE INDEX IF NOT EXISTS idx_designations_intent ON designations(intent_id);`, `CREATE INDEX IF NOT EXISTS idx_designations_address ON designations(address);`, + `CREATE TABLE IF NOT EXISTS wallet_sessions ( + session_token TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + designation_code TEXT NOT NULL, + chain_id INTEGER NOT NULL, + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + last_seen_at TEXT, + revoked_at TEXT + );`, + `CREATE INDEX IF NOT EXISTS idx_wallet_sessions_wallet ON wallet_sessions(wallet);`, + `CREATE INDEX IF NOT EXISTS idx_wallet_sessions_expires ON wallet_sessions(expires_at);`, `CREATE TABLE IF NOT EXISTS quotes ( quote_id TEXT PRIMARY KEY, designation_code TEXT NOT NULL, @@ -380,6 +392,67 @@ func scanDesignation(row interface{ Scan(dest ...any) error }) (designationRecor return rec, nil } +func (s *store) putWalletSession(ctx context.Context, rec walletSessionRecord) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO wallet_sessions ( + session_token, wallet, designation_code, chain_id, issued_at, expires_at, last_seen_at, revoked_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_token) DO UPDATE SET + wallet=excluded.wallet, + designation_code=excluded.designation_code, + chain_id=excluded.chain_id, + issued_at=excluded.issued_at, + expires_at=excluded.expires_at, + last_seen_at=excluded.last_seen_at, + revoked_at=excluded.revoked_at + `, strings.TrimSpace(rec.SessionToken), strings.ToLower(strings.TrimSpace(rec.Wallet)), strings.TrimSpace(rec.DesignationCode), rec.ChainID, rec.IssuedAt.UTC().Format(time.RFC3339Nano), rec.ExpiresAt.UTC().Format(time.RFC3339Nano), formatNullableTime(rec.LastSeenAt), formatNullableTime(rec.RevokedAt)) + return err +} + +func (s *store) getWalletSession(ctx context.Context, token string) (walletSessionRecord, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT session_token, wallet, designation_code, chain_id, issued_at, expires_at, last_seen_at, revoked_at + FROM wallet_sessions + WHERE session_token = ? + `, strings.TrimSpace(token)) + var rec walletSessionRecord + var issuedAt sql.NullString + var expiresAt sql.NullString + var lastSeenAt sql.NullString + var revokedAt sql.NullString + err := row.Scan(&rec.SessionToken, &rec.Wallet, &rec.DesignationCode, &rec.ChainID, &issuedAt, &expiresAt, &lastSeenAt, &revokedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return walletSessionRecord{}, errNotFound + } + return walletSessionRecord{}, err + } + rec.IssuedAt = parseRFC3339Nullable(issuedAt) + rec.ExpiresAt = parseRFC3339Nullable(expiresAt) + rec.LastSeenAt = parseRFC3339Ptr(lastSeenAt) + rec.RevokedAt = parseRFC3339Ptr(revokedAt) + return rec, nil +} + +func (s *store) touchWalletSession(ctx context.Context, token string, touchedAt time.Time) error { + res, err := s.db.ExecContext(ctx, ` + UPDATE wallet_sessions + SET last_seen_at = ? + WHERE session_token = ? + `, touchedAt.UTC().Format(time.RFC3339Nano), strings.TrimSpace(token)) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return errNotFound + } + return nil +} + func (s *store) putQuote(ctx context.Context, quote quoteRecord) error { _, err := s.db.ExecContext(ctx, ` INSERT INTO quotes ( diff --git a/docs/api/examples/secret-system.examples.md b/docs/api/examples/secret-system.examples.md index 8cd910f..0f90d8d 100644 --- a/docs/api/examples/secret-system.examples.md +++ b/docs/api/examples/secret-system.examples.md @@ -59,10 +59,17 @@ Success (`200`): "status": "signature_verified", "designation_code": "0217073045482", "display_token": "0217-0730-4548-2", - "verified_at": "2026-02-17T07:31:12Z" + "verified_at": "2026-02-17T07:31:12Z", + "session_token": "9f2c50f8a0f5d8d0b0efc4fa665e4032f31bb0c4c4f31b8c", + "session_expires_at": "2026-03-18T07:31:12Z" } ``` +Response headers also include: + +1. `X-Edut-Session: ` +2. `X-Edut-Session-Expires-At: ` + Error (`400` intent expired): ```json diff --git a/docs/api/governance-installer.openapi.yaml b/docs/api/governance-installer.openapi.yaml index db0f3c1..e7297b6 100644 --- a/docs/api/governance-installer.openapi.yaml +++ b/docs/api/governance-installer.openapi.yaml @@ -129,6 +129,9 @@ components: type: http scheme: bearer bearerFormat: EDUT-WALLET-SESSION + description: | + Wallet session token issued by `POST /secret/wallet/verify`. + Send as `Authorization: Bearer ` (preferred) or `X-Edut-Session: `. schemas: InstallTokenRequest: type: object diff --git a/docs/api/marketplace.openapi.yaml b/docs/api/marketplace.openapi.yaml index 0716799..a7e518e 100644 --- a/docs/api/marketplace.openapi.yaml +++ b/docs/api/marketplace.openapi.yaml @@ -105,6 +105,9 @@ components: type: http scheme: bearer bearerFormat: EDUT-APP-SESSION + description: | + Wallet session token issued by `POST /secret/wallet/verify`. + Send as `Authorization: Bearer ` (preferred) or `X-Edut-Session: `. schemas: Offer: type: object diff --git a/docs/api/member-channel.openapi.yaml b/docs/api/member-channel.openapi.yaml index 27f46f3..3b93561 100644 --- a/docs/api/member-channel.openapi.yaml +++ b/docs/api/member-channel.openapi.yaml @@ -156,6 +156,9 @@ components: type: http scheme: bearer bearerFormat: EDUT-WALLET-SESSION + description: | + Wallet session token issued by `POST /secret/wallet/verify`. + Send as `Authorization: Bearer ` (preferred) or `X-Edut-Session: `. schemas: DeviceRegisterRequest: type: object diff --git a/docs/api/secret-system.openapi.yaml b/docs/api/secret-system.openapi.yaml index 9ba0829..d11d5a9 100644 --- a/docs/api/secret-system.openapi.yaml +++ b/docs/api/secret-system.openapi.yaml @@ -147,7 +147,7 @@ components: type: string WalletVerifyResponse: type: object - required: [status, designation_code, display_token, verified_at] + required: [status, designation_code, display_token, verified_at, session_token, session_expires_at] properties: status: type: string @@ -159,6 +159,13 @@ components: verified_at: type: string format: date-time + session_token: + type: string + description: Wallet-scoped app session token used by marketplace/member/governance APIs. + session_expires_at: + type: string + format: date-time + description: Session token expiry timestamp. MembershipQuoteRequest: type: object required: [designation_code, address, chain_id] diff --git a/docs/handoff/member-channel-backend-checklist.md b/docs/handoff/member-channel-backend-checklist.md index 7d4621d..0e15079 100644 --- a/docs/handoff/member-channel-backend-checklist.md +++ b/docs/handoff/member-channel-backend-checklist.md @@ -4,8 +4,8 @@ This checklist defines backend requirements for app-native member communication. Implementation status: -1. Local reference implementation exists in `/Users/vsg/Documents/VSG Codex/web/backend/secretapi` (sqlite-backed) for register/unregister/events/ack/support. -2. Production deployment + wallet-session auth hardening still required before launch. +1. Local and deployed reference implementation exists in `/Users/vsg/Documents/VSG Codex/web/backend/secretapi` (sqlite-backed) for register/unregister/events/ack/support. +2. Wallet-session hardening is implemented via session tokens from `/secret/wallet/verify`; launch should set `SECRET_API_REQUIRE_WALLET_SESSION=true` to enforce fail-closed behavior. ## Required Endpoints diff --git a/public/index.html b/public/index.html index 78fa822..fb3c8ff 100644 --- a/public/index.html +++ b/public/index.html @@ -470,6 +470,8 @@ const flowState = { firstInteraction: false, stage: 'idle', currentIntent: null, + sessionToken: '', + sessionExpiresAt: '', }; const continueAction = document.getElementById('continue-action'); @@ -515,6 +517,8 @@ function getStoredAcknowledgement() { try { const parsed = JSON.parse(stateRaw); if (parsed && (parsed.code || parsed.token)) { + flowState.sessionToken = typeof parsed.session_token === 'string' ? parsed.session_token : ''; + flowState.sessionExpiresAt = typeof parsed.session_expires_at === 'string' ? parsed.session_expires_at : ''; return parsed; } } catch (err) { @@ -606,11 +610,16 @@ function formatQuoteDisplay(quote) { } async function postJSON(url, payload) { + const headers = { + 'Content-Type': 'application/json', + }; + if (flowState.sessionToken) { + headers['X-Edut-Session'] = flowState.sessionToken; + headers.Authorization = 'Bearer ' + flowState.sessionToken; + } const res = await fetch(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers, body: JSON.stringify(payload), }); if (!res.ok) { @@ -773,6 +782,8 @@ async function startWalletFlow() { chain_id: chainId, signature, }); + flowState.sessionToken = verification.session_token || ''; + flowState.sessionExpiresAt = verification.session_expires_at || ''; setFlowStatus('membership_quoting', 'Preparing membership mint...', false); const quote = await postJSON('/secret/membership/quote', { @@ -824,6 +835,8 @@ async function startWalletFlow() { wallet: address, chain_id: chainId, membership_tx_hash: txHash, + session_token: flowState.sessionToken || '', + session_expires_at: flowState.sessionExpiresAt || '', }; saveAcknowledgement(ackState); renderAcknowledged(ackState); diff --git a/public/store/index.html b/public/store/index.html index 0079aeb..d4f9ccd 100644 --- a/public/store/index.html +++ b/public/store/index.html @@ -216,6 +216,8 @@ source: 'live', offers: [], selectedOfferId: null, + sessionToken: '', + sessionWallet: null, }; const walletLabel = document.getElementById('wallet-label'); @@ -386,8 +388,37 @@ } } + function hydrateSessionFromAcknowledgement() { + const raw = localStorage.getItem('edut_ack_state'); + if (!raw) return; + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return; + if (typeof parsed.session_token === 'string') { + state.sessionToken = parsed.session_token.trim(); + } + if (typeof parsed.wallet === 'string') { + state.sessionWallet = parsed.wallet.trim(); + } + } catch (err) { + // ignore malformed local cache + } + } + + function authHeaders(extra) { + const headers = Object.assign({}, extra || {}); + if (state.sessionToken) { + headers['X-Edut-Session'] = state.sessionToken; + headers.Authorization = 'Bearer ' + state.sessionToken; + } + return headers; + } + async function fetchJson(url) { - const response = await fetch(url, { method: 'GET' }); + const response = await fetch(url, { + method: 'GET', + headers: authHeaders(), + }); if (!response.ok) { throw new Error('HTTP ' + response.status); } @@ -479,6 +510,9 @@ } state.wallet = accounts[0]; state.ownershipProof = null; + if (state.sessionWallet && state.wallet && state.sessionWallet.toLowerCase() !== state.wallet.toLowerCase()) { + state.sessionToken = ''; + } setLog('Wallet connected: ' + abbreviateWallet(state.wallet) + '.'); await refreshMembershipState(); } catch (err) { @@ -584,7 +618,7 @@ const response = await fetch('/marketplace/checkout/quote', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(payload), }); @@ -634,6 +668,7 @@ }); applyGateState(); + hydrateSessionFromAcknowledgement(); if (!internalPreview) { disableInteractiveStore('Preview mode is disabled on public web.'); return;