Add wallet session hardening across API and web surfaces

This commit is contained in:
Joshua 2026-02-18 20:15:31 -08:00
parent 311104fbb8
commit d1c60fe44e
17 changed files with 419 additions and 9 deletions

View File

@ -9,6 +9,8 @@ SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION=false
SECRET_API_INTENT_TTL_SECONDS=900 SECRET_API_INTENT_TTL_SECONDS=900
SECRET_API_QUOTE_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_DOMAIN_NAME=EDUT Designation
SECRET_API_VERIFYING_CONTRACT=0x0000000000000000000000000000000000000000 SECRET_API_VERIFYING_CONTRACT=0x0000000000000000000000000000000000000000
SECRET_API_MEMBERSHIP_CONTRACT=0x0000000000000000000000000000000000000000 SECRET_API_MEMBERSHIP_CONTRACT=0x0000000000000000000000000000000000000000

View File

@ -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/events/{event_id}/ack`
- `POST /member/channel/support/ticket` - `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 <session_token>`
2. `X-Edut-Session: <session_token>`
Covered endpoints include marketplace checkout/entitlements, governance install/lease actions, and member-channel calls.
## Sponsorship Behavior ## Sponsorship Behavior
Membership quote supports ownership wallet and distinct payer wallet: Membership quote supports ownership wallet and distinct payer wallet:
@ -113,6 +127,8 @@ Policy gates:
- `SECRET_API_INTENT_TTL_SECONDS` (default `900`) - `SECRET_API_INTENT_TTL_SECONDS` (default `900`)
- `SECRET_API_QUOTE_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_DOMAIN_NAME`
- `SECRET_API_VERIFYING_CONTRACT` - `SECRET_API_VERIFYING_CONTRACT`
- `SECRET_API_MEMBERSHIP_CONTRACT` - `SECRET_API_MEMBERSHIP_CONTRACT`

View File

@ -212,12 +212,21 @@ func (a *app) handleWalletVerify(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "failed to store verification status") writeError(w, http.StatusInternalServerError, "failed to store verification status")
return 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{ writeJSON(w, http.StatusOK, walletVerifyResponse{
Status: "signature_verified", Status: "signature_verified",
DesignationCode: rec.Code, DesignationCode: rec.Code,
DisplayToken: rec.DisplayToken, DisplayToken: rec.DisplayToken,
VerifiedAt: now.Format(time.RFC3339Nano), 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()) writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return return
} }
if !a.enforceWalletSession(w, r, wallet) {
return
}
if strings.TrimSpace(req.DeviceID) == "" || strings.TrimSpace(req.Platform) == "" || strings.TrimSpace(req.LauncherVersion) == "" { 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") writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id, platform, launcher_version required")
return return
@ -659,6 +671,9 @@ func (a *app) handleGovernanceInstallConfirm(w http.ResponseWriter, r *http.Requ
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return return
} }
if !a.enforceWalletSession(w, r, wallet) {
return
}
if strings.TrimSpace(req.InstallToken) == "" || strings.TrimSpace(req.DeviceID) == "" { if strings.TrimSpace(req.InstallToken) == "" || strings.TrimSpace(req.DeviceID) == "" {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "install_token and device_id required") writeErrorCode(w, http.StatusBadRequest, "invalid_request", "install_token and device_id required")
return return
@ -772,6 +787,9 @@ func (a *app) handleGovernanceInstallStatus(w http.ResponseWriter, r *http.Reque
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return return
} }
if !a.enforceWalletSession(w, r, wallet) {
return
}
membershipStatus := "none" membershipStatus := "none"
identityAssurance := assuranceNone identityAssurance := assuranceNone
@ -843,6 +861,9 @@ func (a *app) handleGovernanceLeaseHeartbeat(w http.ResponseWriter, r *http.Requ
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return return
} }
if !a.enforceWalletSession(w, r, wallet) {
return
}
principal, err := a.store.getGovernancePrincipal(r.Context(), wallet) principal, err := a.store.getGovernancePrincipal(r.Context(), wallet)
if err != nil { if err != nil {
writeErrorCode(w, http.StatusForbidden, "principal_missing", "principal state missing") 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()) writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return return
} }
if !a.enforceWalletSession(w, r, wallet) {
return
}
principal, err := a.store.getGovernancePrincipal(r.Context(), wallet) principal, err := a.store.getGovernancePrincipal(r.Context(), wallet)
if err != nil { if err != nil {
writeErrorCode(w, http.StatusForbidden, "principal_missing", "principal state missing") 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()) writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return return
} }
if !a.enforceWalletSession(w, r, wallet) {
return
}
if req.ChainID != a.cfg.ChainID { if req.ChainID != a.cfg.ChainID {
writeErrorCode(w, http.StatusBadRequest, "unsupported_chain_id", fmt.Sprintf("unsupported chain_id: %d", req.ChainID)) writeErrorCode(w, http.StatusBadRequest, "unsupported_chain_id", fmt.Sprintf("unsupported chain_id: %d", req.ChainID))
return return
@ -1022,6 +1049,9 @@ func (a *app) handleMemberChannelDeviceUnregister(w http.ResponseWriter, r *http
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return return
} }
if !a.enforceWalletSession(w, r, wallet) {
return
}
req.DeviceID = strings.TrimSpace(req.DeviceID) req.DeviceID = strings.TrimSpace(req.DeviceID)
if req.DeviceID == "" { if req.DeviceID == "" {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id required") 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()) writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return return
} }
if !a.enforceWalletSession(w, r, wallet) {
return
}
deviceID := strings.TrimSpace(r.URL.Query().Get("device_id")) deviceID := strings.TrimSpace(r.URL.Query().Get("device_id"))
if deviceID == "" { if deviceID == "" {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id required") 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()) writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return return
} }
if !a.enforceWalletSession(w, r, wallet) {
return
}
deviceID := strings.TrimSpace(req.DeviceID) deviceID := strings.TrimSpace(req.DeviceID)
if deviceID == "" { if deviceID == "" {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id required") 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()) writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return return
} }
if !a.enforceWalletSession(w, r, wallet) {
return
}
req.OrgRootID = strings.TrimSpace(req.OrgRootID) req.OrgRootID = strings.TrimSpace(req.OrgRootID)
req.PrincipalID = strings.TrimSpace(req.PrincipalID) req.PrincipalID = strings.TrimSpace(req.PrincipalID)
req.Category = strings.TrimSpace(req.Category) req.Category = strings.TrimSpace(req.Category)

View File

@ -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 { type tWalletIntentResponse struct {
IntentID string `json:"intent_id"` IntentID string `json:"intent_id"`
DesignationCode string `json:"designation_code"` DesignationCode string `json:"designation_code"`
@ -1155,6 +1221,7 @@ type tWalletIntentResponse struct {
type tWalletVerifyResponse struct { type tWalletVerifyResponse struct {
Status string `json:"status"` Status string `json:"status"`
DesignationCode string `json:"designation_code"` DesignationCode string `json:"designation_code"`
SessionToken string `json:"session_token"`
} }
func newTestApp(t *testing.T) (*app, Config, func()) { 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 { 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() t.Helper()
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil { 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 := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "https://edut.ai") req.Header.Set("Origin", "https://edut.ai")
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
a.routes().ServeHTTP(rec, req) a.routes().ServeHTTP(rec, req)
if rec.Code != expectStatus { 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 { 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() t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil) req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Origin", "https://edut.ai") req.Header.Set("Origin", "https://edut.ai")
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
a.routes().ServeHTTP(rec, req) a.routes().ServeHTTP(rec, req)
if rec.Code != expectStatus { if rec.Code != expectStatus {

View File

@ -15,6 +15,8 @@ type Config struct {
MemberPollIntervalSec int MemberPollIntervalSec int
IntentTTL time.Duration IntentTTL time.Duration
QuoteTTL time.Duration QuoteTTL time.Duration
WalletSessionTTL time.Duration
RequireWalletSession bool
InstallTokenTTL time.Duration InstallTokenTTL time.Duration
LeaseTTL time.Duration LeaseTTL time.Duration
OfflineRenewTTL time.Duration OfflineRenewTTL time.Duration
@ -45,6 +47,8 @@ func loadConfig() Config {
MemberPollIntervalSec: envInt("SECRET_API_MEMBER_POLL_INTERVAL_SECONDS", 30), MemberPollIntervalSec: envInt("SECRET_API_MEMBER_POLL_INTERVAL_SECONDS", 30),
IntentTTL: time.Duration(envInt("SECRET_API_INTENT_TTL_SECONDS", 900)) * time.Second, IntentTTL: time.Duration(envInt("SECRET_API_INTENT_TTL_SECONDS", 900)) * time.Second,
QuoteTTL: time.Duration(envInt("SECRET_API_QUOTE_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, 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, 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, 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 { if c.ChainID <= 0 {
return fmt.Errorf("SECRET_API_CHAIN_ID must be positive") 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) == "" { if c.RequireOnchainTxVerify && strings.TrimSpace(c.ChainRPCURL) == "" {
return fmt.Errorf("SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION requires SECRET_API_CHAIN_RPC_URL") return fmt.Errorf("SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION requires SECRET_API_CHAIN_RPC_URL")
} }

View File

@ -224,6 +224,9 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return return
} }
if !a.enforceWalletSession(w, r, wallet) {
return
}
offer, err := a.marketplaceOfferByID(req.OfferID) offer, err := a.marketplaceOfferByID(req.OfferID)
if err != nil { if err != nil {
writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found") 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()) writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return return
} }
if !a.enforceWalletSession(w, r, wallet) {
return
}
payerWallet := "" payerWallet := ""
if strings.TrimSpace(req.PayerWallet) != "" { if strings.TrimSpace(req.PayerWallet) != "" {
payerWallet, err = normalizeAddress(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()) writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return return
} }
if !a.enforceWalletSession(w, r, wallet) {
return
}
records, err := a.store.listMarketplaceEntitlementsByWallet(r.Context(), wallet) records, err := a.store.listMarketplaceEntitlementsByWallet(r.Context(), wallet)
if err != nil { if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to list entitlements") writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to list entitlements")

View File

@ -33,6 +33,8 @@ type walletVerifyResponse struct {
DesignationCode string `json:"designation_code"` DesignationCode string `json:"designation_code"`
DisplayToken string `json:"display_token"` DisplayToken string `json:"display_token"`
VerifiedAt string `json:"verified_at"` VerifiedAt string `json:"verified_at"`
SessionToken string `json:"session_token,omitempty"`
SessionExpires string `json:"session_expires_at,omitempty"`
} }
type membershipQuoteRequest struct { type membershipQuoteRequest struct {
@ -135,6 +137,17 @@ type quoteRecord struct {
SponsorOrgRootID string 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 { type governanceInstallTokenRequest struct {
Wallet string `json:"wallet"` Wallet string `json:"wallet"`
OrgRootID string `json:"org_root_id,omitempty"` OrgRootID string `json:"org_root_id,omitempty"`

View File

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

View File

@ -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_intent ON designations(intent_id);`,
`CREATE INDEX IF NOT EXISTS idx_designations_address ON designations(address);`, `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 ( `CREATE TABLE IF NOT EXISTS quotes (
quote_id TEXT PRIMARY KEY, quote_id TEXT PRIMARY KEY,
designation_code TEXT NOT NULL, designation_code TEXT NOT NULL,
@ -380,6 +392,67 @@ func scanDesignation(row interface{ Scan(dest ...any) error }) (designationRecor
return rec, nil 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 { func (s *store) putQuote(ctx context.Context, quote quoteRecord) error {
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO quotes ( INSERT INTO quotes (

View File

@ -59,10 +59,17 @@ Success (`200`):
"status": "signature_verified", "status": "signature_verified",
"designation_code": "0217073045482", "designation_code": "0217073045482",
"display_token": "0217-0730-4548-2", "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: <session_token>`
2. `X-Edut-Session-Expires-At: <session_expires_at>`
Error (`400` intent expired): Error (`400` intent expired):
```json ```json

View File

@ -129,6 +129,9 @@ components:
type: http type: http
scheme: bearer scheme: bearer
bearerFormat: EDUT-WALLET-SESSION bearerFormat: EDUT-WALLET-SESSION
description: |
Wallet session token issued by `POST /secret/wallet/verify`.
Send as `Authorization: Bearer <token>` (preferred) or `X-Edut-Session: <token>`.
schemas: schemas:
InstallTokenRequest: InstallTokenRequest:
type: object type: object

View File

@ -105,6 +105,9 @@ components:
type: http type: http
scheme: bearer scheme: bearer
bearerFormat: EDUT-APP-SESSION bearerFormat: EDUT-APP-SESSION
description: |
Wallet session token issued by `POST /secret/wallet/verify`.
Send as `Authorization: Bearer <token>` (preferred) or `X-Edut-Session: <token>`.
schemas: schemas:
Offer: Offer:
type: object type: object

View File

@ -156,6 +156,9 @@ components:
type: http type: http
scheme: bearer scheme: bearer
bearerFormat: EDUT-WALLET-SESSION bearerFormat: EDUT-WALLET-SESSION
description: |
Wallet session token issued by `POST /secret/wallet/verify`.
Send as `Authorization: Bearer <token>` (preferred) or `X-Edut-Session: <token>`.
schemas: schemas:
DeviceRegisterRequest: DeviceRegisterRequest:
type: object type: object

View File

@ -147,7 +147,7 @@ components:
type: string type: string
WalletVerifyResponse: WalletVerifyResponse:
type: object type: object
required: [status, designation_code, display_token, verified_at] required: [status, designation_code, display_token, verified_at, session_token, session_expires_at]
properties: properties:
status: status:
type: string type: string
@ -159,6 +159,13 @@ components:
verified_at: verified_at:
type: string type: string
format: date-time 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: MembershipQuoteRequest:
type: object type: object
required: [designation_code, address, chain_id] required: [designation_code, address, chain_id]

View File

@ -4,8 +4,8 @@ This checklist defines backend requirements for app-native member communication.
Implementation status: Implementation status:
1. Local reference implementation exists in `/Users/vsg/Documents/VSG Codex/web/backend/secretapi` (sqlite-backed) for register/unregister/events/ack/support. 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. Production deployment + wallet-session auth hardening still required before launch. 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 ## Required Endpoints

View File

@ -470,6 +470,8 @@ const flowState = {
firstInteraction: false, firstInteraction: false,
stage: 'idle', stage: 'idle',
currentIntent: null, currentIntent: null,
sessionToken: '',
sessionExpiresAt: '',
}; };
const continueAction = document.getElementById('continue-action'); const continueAction = document.getElementById('continue-action');
@ -515,6 +517,8 @@ function getStoredAcknowledgement() {
try { try {
const parsed = JSON.parse(stateRaw); const parsed = JSON.parse(stateRaw);
if (parsed && (parsed.code || parsed.token)) { 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; return parsed;
} }
} catch (err) { } catch (err) {
@ -606,11 +610,16 @@ function formatQuoteDisplay(quote) {
} }
async function postJSON(url, payload) { 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, { const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!res.ok) { if (!res.ok) {
@ -773,6 +782,8 @@ async function startWalletFlow() {
chain_id: chainId, chain_id: chainId,
signature, signature,
}); });
flowState.sessionToken = verification.session_token || '';
flowState.sessionExpiresAt = verification.session_expires_at || '';
setFlowStatus('membership_quoting', 'Preparing membership mint...', false); setFlowStatus('membership_quoting', 'Preparing membership mint...', false);
const quote = await postJSON('/secret/membership/quote', { const quote = await postJSON('/secret/membership/quote', {
@ -824,6 +835,8 @@ async function startWalletFlow() {
wallet: address, wallet: address,
chain_id: chainId, chain_id: chainId,
membership_tx_hash: txHash, membership_tx_hash: txHash,
session_token: flowState.sessionToken || '',
session_expires_at: flowState.sessionExpiresAt || '',
}; };
saveAcknowledgement(ackState); saveAcknowledgement(ackState);
renderAcknowledged(ackState); renderAcknowledged(ackState);

View File

@ -216,6 +216,8 @@
source: 'live', source: 'live',
offers: [], offers: [],
selectedOfferId: null, selectedOfferId: null,
sessionToken: '',
sessionWallet: null,
}; };
const walletLabel = document.getElementById('wallet-label'); 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) { async function fetchJson(url) {
const response = await fetch(url, { method: 'GET' }); const response = await fetch(url, {
method: 'GET',
headers: authHeaders(),
});
if (!response.ok) { if (!response.ok) {
throw new Error('HTTP ' + response.status); throw new Error('HTTP ' + response.status);
} }
@ -479,6 +510,9 @@
} }
state.wallet = accounts[0]; state.wallet = accounts[0];
state.ownershipProof = null; state.ownershipProof = null;
if (state.sessionWallet && state.wallet && state.sessionWallet.toLowerCase() !== state.wallet.toLowerCase()) {
state.sessionToken = '';
}
setLog('Wallet connected: ' + abbreviateWallet(state.wallet) + '.'); setLog('Wallet connected: ' + abbreviateWallet(state.wallet) + '.');
await refreshMembershipState(); await refreshMembershipState();
} catch (err) { } catch (err) {
@ -584,7 +618,7 @@
const response = await fetch('/marketplace/checkout/quote', { const response = await fetch('/marketplace/checkout/quote', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
@ -634,6 +668,7 @@
}); });
applyGateState(); applyGateState();
hydrateSessionFromAcknowledgement();
if (!internalPreview) { if (!internalPreview) {
disableInteractiveStore('Preview mode is disabled on public web.'); disableInteractiveStore('Preview mode is disabled on public web.');
return; return;