Add wallet session hardening across API and web surfaces
This commit is contained in:
parent
311104fbb8
commit
d1c60fe44e
@ -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
|
||||
|
||||
@ -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 <session_token>`
|
||||
2. `X-Edut-Session: <session_token>`
|
||||
|
||||
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`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"`
|
||||
|
||||
97
backend/secretapi/session_auth.go
Normal file
97
backend/secretapi/session_auth.go
Normal 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
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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: <session_token>`
|
||||
2. `X-Edut-Session-Expires-At: <session_expires_at>`
|
||||
|
||||
Error (`400` intent expired):
|
||||
|
||||
```json
|
||||
|
||||
@ -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 <token>` (preferred) or `X-Edut-Session: <token>`.
|
||||
schemas:
|
||||
InstallTokenRequest:
|
||||
type: object
|
||||
|
||||
@ -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 <token>` (preferred) or `X-Edut-Session: <token>`.
|
||||
schemas:
|
||||
Offer:
|
||||
type: object
|
||||
|
||||
@ -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 <token>` (preferred) or `X-Edut-Session: <token>`.
|
||||
schemas:
|
||||
DeviceRegisterRequest:
|
||||
type: object
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user