From b15e13fda52dc83bea3e8a45bcfbbdeff042820a Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 19 Feb 2026 12:45:46 -0800 Subject: [PATCH] Harden secretapi sessions and entitlement quote gating --- backend/secretapi/.env.example | 2 +- backend/secretapi/README.md | 11 ++- backend/secretapi/app_test.go | 87 +++++++++++++++++++ backend/secretapi/config.go | 2 +- backend/secretapi/config_test.go | 44 +++++++--- backend/secretapi/marketplace.go | 22 ++--- backend/secretapi/store.go | 8 +- .../contract-addresses.base-sepolia.json | 4 +- docs/deployment/secretapi-deploy.md | 3 + docs/handoff/marketplace-backend-checklist.md | 1 + docs/roadmap-status.md | 3 +- 11 files changed, 156 insertions(+), 31 deletions(-) diff --git a/backend/secretapi/.env.example b/backend/secretapi/.env.example index 077c7e4..d1a8c4e 100644 --- a/backend/secretapi/.env.example +++ b/backend/secretapi/.env.example @@ -10,7 +10,7 @@ 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_REQUIRE_WALLET_SESSION=true 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 430b55c..65bca20 100644 --- a/backend/secretapi/README.md +++ b/backend/secretapi/README.md @@ -135,13 +135,18 @@ 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_REQUIRE_WALLET_SESSION` (default `true`; set `false` only for controlled local harness/debug usage) - `SECRET_API_DOMAIN_NAME` - `SECRET_API_VERIFYING_CONTRACT` - `SECRET_API_MEMBERSHIP_CONTRACT` -- `SECRET_API_MINT_CURRENCY` (must be `USDC` in v1) +- `SECRET_API_MINT_CURRENCY` (`USDC` for launch; `ETH` allowed for Sepolia/test harness) - `SECRET_API_MINT_AMOUNT_ATOMIC` (default `100000000`) -- `SECRET_API_MINT_DECIMALS` (default `6`) +- `SECRET_API_MINT_DECIMALS` (must be `6` for `USDC`, `18` for `ETH`) + +### Marketplace + +- `SECRET_API_ENTITLEMENT_CONTRACT` must be configured to issue checkout quotes. +- Marketplace quote fails closed with `entitlement_contract_unconfigured` when unset/zero. ### Governance install diff --git a/backend/secretapi/app_test.go b/backend/secretapi/app_test.go index ed1d17c..f21d01a 100644 --- a/backend/secretapi/app_test.go +++ b/backend/secretapi/app_test.go @@ -329,6 +329,68 @@ func TestMembershipConfirmAcceptsOnrampAttestationAssurance(t *testing.T) { } } +func TestMembershipStatusPrefersActiveDesignationWhenNewerRecordIsUnactivated(t *testing.T) { + a, _, cleanup := newTestApp(t) + defer cleanup() + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + now := time.Now().UTC() + + activeIssuedAt := now.Add(-2 * time.Hour) + activeCode := "1111111111111" + if err := a.store.putDesignation(context.Background(), designationRecord{ + Code: activeCode, + DisplayToken: "1111-1111-1111-1", + IntentID: "intent-active", + Nonce: "nonce-active", + Origin: "https://edut.ai", + Locale: "en", + Address: ownerAddr, + ChainID: 84532, + IssuedAt: activeIssuedAt, + ExpiresAt: activeIssuedAt.Add(time.Hour), + VerifiedAt: &activeIssuedAt, + MembershipStatus: "active", + MembershipTxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ActivatedAt: &activeIssuedAt, + IdentityAssurance: assuranceCryptoDirect, + IdentityAttestedBy: "", + IdentityAttestationID: "", + }); err != nil { + t.Fatalf("seed active designation: %v", err) + } + + newerIssuedAt := now + if err := a.store.putDesignation(context.Background(), designationRecord{ + Code: "2222222222222", + DisplayToken: "2222-2222-2222-2", + IntentID: "intent-newer", + Nonce: "nonce-newer", + Origin: "https://edut.ai", + Locale: "en", + Address: ownerAddr, + ChainID: 84532, + IssuedAt: newerIssuedAt, + ExpiresAt: newerIssuedAt.Add(time.Hour), + VerifiedAt: &newerIssuedAt, + MembershipStatus: "none", + IdentityAssurance: assuranceNone, + IdentityAttestedBy: "", + IdentityAttestationID: "", + }); err != nil { + t.Fatalf("seed newer unactivated designation: %v", err) + } + + status := getJSONExpect[membershipStatusResponse](t, a, "/secret/membership/status?wallet="+ownerAddr, http.StatusOK) + if status.Status != "active" { + t.Fatalf("expected active status, got %+v", status) + } + if status.DesignationCode != activeCode { + t.Fatalf("expected active designation code %s, got %+v", activeCode, status) + } +} + func TestMembershipConfirmRejectsAlreadyConfirmedQuote(t *testing.T) { a, cfg, cleanup := newTestApp(t) defer cleanup() @@ -1062,6 +1124,29 @@ func TestMarketplaceQuoteUsesEntitlementContractTransactionWhenConfigured(t *tes } } +func TestMarketplaceQuoteFailsWhenEntitlementContractUnconfigured(t *testing.T) { + a, _, cleanup := newTestApp(t) + defer cleanup() + + a.cfg.EntitlementContract = "0x0000000000000000000000000000000000000000" + 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: offerIDWorkspaceCore, + OrgRootID: "org.marketplace.unconfigured", + PrincipalID: "human.owner", + PrincipalRole: "org_root_owner", + }, http.StatusServiceUnavailable) + if code := errResp["code"]; code != "entitlement_contract_unconfigured" { + t.Fatalf("expected entitlement_contract_unconfigured, got %+v", errResp) + } +} + func TestMarketplaceWorkspaceAddOnRequiresCoreEntitlement(t *testing.T) { a, _, cleanup := newTestApp(t) defer cleanup() @@ -1545,6 +1630,8 @@ func newTestApp(t *testing.T) (*app, Config, func()) { cfg.GovernancePackageURL = "https://cdn.test/edutd.tar.gz" cfg.GovernancePackageSig = "sig-test" cfg.GovernanceRuntimeVersion = "0.2.0" + cfg.RequireWalletSession = false + cfg.EntitlementContract = "0x1111111111111111111111111111111111111111" st, err := openStore(cfg.DBPath) if err != nil { t.Fatalf("open store: %v", err) diff --git a/backend/secretapi/config.go b/backend/secretapi/config.go index 8b2ace4..34eb991 100644 --- a/backend/secretapi/config.go +++ b/backend/secretapi/config.go @@ -49,7 +49,7 @@ func loadConfig() Config { 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), + RequireWalletSession: envBool("SECRET_API_REQUIRE_WALLET_SESSION", true), 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, diff --git a/backend/secretapi/config_test.go b/backend/secretapi/config_test.go index cacee1b..08e2698 100644 --- a/backend/secretapi/config_test.go +++ b/backend/secretapi/config_test.go @@ -3,7 +3,6 @@ package main import "testing" func TestConfigValidateAllowsDefaultLocalConfig(t *testing.T) { - t.Parallel() cfg := loadConfig() cfg.ChainID = 84532 cfg.RequireOnchainTxVerify = false @@ -14,7 +13,6 @@ func TestConfigValidateAllowsDefaultLocalConfig(t *testing.T) { } func TestConfigValidateRejectsStrictVerificationWithoutRPC(t *testing.T) { - t.Parallel() cfg := loadConfig() cfg.RequireOnchainTxVerify = true cfg.ChainRPCURL = "" @@ -24,7 +22,6 @@ func TestConfigValidateRejectsStrictVerificationWithoutRPC(t *testing.T) { } func TestConfigValidateRejectsNonPositiveChainID(t *testing.T) { - t.Parallel() cfg := loadConfig() cfg.ChainID = 0 if err := cfg.Validate(); err == nil { @@ -32,29 +29,54 @@ func TestConfigValidateRejectsNonPositiveChainID(t *testing.T) { } } -func TestConfigValidateRejectsNonUSDCCurrency(t *testing.T) { - t.Parallel() +func TestConfigValidateAllowsETHWhenDecimalsMatch(t *testing.T) { cfg := loadConfig() cfg.MintCurrency = "ETH" - if err := cfg.Validate(); err == nil { - t.Fatalf("expected mint currency validation failure") + cfg.MintDecimals = 18 + cfg.MintAmountAtomic = "1" + if err := cfg.Validate(); err != nil { + t.Fatalf("expected ETH config to validate, got %v", err) } } -func TestConfigValidateRejectsNonSixMintDecimals(t *testing.T) { - t.Parallel() +func TestConfigValidateRejectsETHWhenDecimalsMismatch(t *testing.T) { cfg := loadConfig() + cfg.MintCurrency = "ETH" + cfg.MintDecimals = 6 + if err := cfg.Validate(); err == nil { + t.Fatalf("expected ETH decimal validation failure") + } +} + +func TestConfigValidateRejectsUSDCWhenDecimalsMismatch(t *testing.T) { + cfg := loadConfig() + cfg.MintCurrency = "USDC" cfg.MintDecimals = 18 if err := cfg.Validate(); err == nil { - t.Fatalf("expected mint decimals validation failure") + t.Fatalf("expected USDC decimal validation failure") } } func TestConfigValidateRejectsInvalidMintAmount(t *testing.T) { - t.Parallel() cfg := loadConfig() cfg.MintAmountAtomic = "not-a-number" if err := cfg.Validate(); err == nil { t.Fatalf("expected mint amount validation failure") } } + +func TestLoadConfigDefaultsWalletSessionRequired(t *testing.T) { + t.Setenv("SECRET_API_REQUIRE_WALLET_SESSION", "") + cfg := loadConfig() + if !cfg.RequireWalletSession { + t.Fatalf("expected wallet session to be required by default") + } +} + +func TestLoadConfigWalletSessionCanBeDisabled(t *testing.T) { + t.Setenv("SECRET_API_REQUIRE_WALLET_SESSION", "false") + cfg := loadConfig() + if cfg.RequireWalletSession { + t.Fatalf("expected wallet session requirement to be disabled by env override") + } +} diff --git a/backend/secretapi/marketplace.go b/backend/secretapi/marketplace.go index 4216bb8..8812e5f 100644 --- a/backend/secretapi/marketplace.go +++ b/backend/secretapi/marketplace.go @@ -402,18 +402,18 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to generate quote id") return } - txTo := strings.ToLower(strings.TrimSpace(a.cfg.MembershipContract)) - txData := "0x" + entitlementContract := strings.ToLower(strings.TrimSpace(a.cfg.EntitlementContract)) + if entitlementContract == "" || strings.EqualFold(entitlementContract, "0x0000000000000000000000000000000000000000") { + writeErrorCode(w, http.StatusServiceUnavailable, "entitlement_contract_unconfigured", "entitlement contract is not configured") + return + } + + txTo := entitlementContract txValueHex := "0x0" - if entitlementContract := strings.ToLower(strings.TrimSpace(a.cfg.EntitlementContract)); entitlementContract != "" && - !strings.EqualFold(entitlementContract, "0x0000000000000000000000000000000000000000") { - entitlementCalldata, calldataErr := encodePurchaseEntitlementCalldata(offer.OfferID, wallet, orgRootID, workspaceID) - if calldataErr != nil { - writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to encode checkout transaction") - return - } - txTo = entitlementContract - txData = entitlementCalldata + txData, calldataErr := encodePurchaseEntitlementCalldata(offer.OfferID, wallet, orgRootID, workspaceID) + if calldataErr != nil { + writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to encode checkout transaction") + return } now := time.Now().UTC() diff --git a/backend/secretapi/store.go b/backend/secretapi/store.go index 98507fa..50a36a0 100644 --- a/backend/secretapi/store.go +++ b/backend/secretapi/store.go @@ -363,7 +363,13 @@ func (s *store) getDesignationByAddress(ctx context.Context, address string) (de SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at, identity_assurance_level, identity_attested_by, identity_attestation_id, identity_attested_at FROM designations WHERE address = ? - ORDER BY issued_at DESC + ORDER BY CASE LOWER(COALESCE(membership_status, 'none')) + WHEN 'active' THEN 0 + WHEN 'suspended' THEN 1 + WHEN 'revoked' THEN 2 + ELSE 3 + END, + issued_at DESC LIMIT 1 `, strings.ToLower(strings.TrimSpace(address))) return scanDesignation(row) diff --git a/docs/deployment/contract-addresses.base-sepolia.json b/docs/deployment/contract-addresses.base-sepolia.json index b36a635..ab4c08e 100644 --- a/docs/deployment/contract-addresses.base-sepolia.json +++ b/docs/deployment/contract-addresses.base-sepolia.json @@ -8,9 +8,9 @@ "mint_currency_mode": "ETH_TEST", "mint_amount_atomic": "1", "usdc_contract": "0x0000000000000000000000000000000000000000", + "source": "api.edut.dev runtime environment", "version": "v1", "notes": [ - "Entitlement contract deploy tx succeeded on Base Sepolia.", - "Offer seeding call reverted during first pass; retry required before checkout e2e." + "Entitlement contract deployment requires explicit offer-seeding verification before checkout e2e." ] } diff --git a/docs/deployment/secretapi-deploy.md b/docs/deployment/secretapi-deploy.md index d9a38f7..a8300e9 100644 --- a/docs/deployment/secretapi-deploy.md +++ b/docs/deployment/secretapi-deploy.md @@ -21,6 +21,7 @@ Current test-mode settings: 1. Base Sepolia (`SECRET_API_CHAIN_ID=84532`) 2. ETH quote mode (`SECRET_API_MINT_CURRENCY=ETH`) for low-friction Sepolia smoke validation 3. Membership contract wired to `0x3EEb3342751D1Cfc0F90C9393e0B1cd5AcE6FfD8` +4. Wallet session enforcement enabled by default (`SECRET_API_REQUIRE_WALLET_SESSION=true`) ## Build Targets @@ -57,6 +58,8 @@ Critical values before launch: - `SECRET_API_GOV_POLICY_HASH` 6. Member channel polling: - `SECRET_API_MEMBER_POLL_INTERVAL_SECONDS` +7. Marketplace contract wiring: + - `SECRET_API_ENTITLEMENT_CONTRACT` must be non-zero for checkout quote issuance ## Systemd Deployment (Hetzner/VPS) diff --git a/docs/handoff/marketplace-backend-checklist.md b/docs/handoff/marketplace-backend-checklist.md index 6b21601..8054d18 100644 --- a/docs/handoff/marketplace-backend-checklist.md +++ b/docs/handoff/marketplace-backend-checklist.md @@ -19,6 +19,7 @@ All marketplace endpoints require authenticated app/session context. 3. Entitlement state must default fail-closed for unknown values. 4. Quote/confirm must deny cross-boundary paid execution when `org_root_id` does not match active suite entitlement. 5. `availability_state=parked` must block paid execution paths. +6. Quote generation must fail closed when entitlement contract is unconfigured (`entitlement_contract_unconfigured`). ## Store Dependency Mapping diff --git a/docs/roadmap-status.md b/docs/roadmap-status.md index c73ffbb..48c6407 100644 --- a/docs/roadmap-status.md +++ b/docs/roadmap-status.md @@ -66,7 +66,8 @@ Remaining in this repo: 1. Wire live store checkout flow to production marketplace APIs when available. 2. Replace deployment templates with real contract addresses after chain deployment: `IN_PROGRESS` (Base Sepolia addresses captured in `docs/deployment/contract-addresses.base-sepolia.json`; mainnet pending). -3. Add launcher/governance install UI that consumes governance installer APIs. +3. Keep cross-repo address parity with `/Users/vsg/Documents/VSG Codex/contracts/deploy/runtime-addresses.base-sepolia.json`: `IN_PROGRESS`. +4. Add launcher/governance install UI that consumes governance installer APIs. Cross-repo dependencies (kernel/backend/contracts):