Harden secretapi sessions and entitlement quote gating
Some checks are pending
check / secretapi (push) Waiting to run

This commit is contained in:
Joshua 2026-02-19 12:45:46 -08:00
parent 70c54b1283
commit b15e13fda5
11 changed files with 156 additions and 31 deletions

View File

@ -10,7 +10,7 @@ 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_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_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

@ -135,13 +135,18 @@ 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_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_DOMAIN_NAME`
- `SECRET_API_VERIFYING_CONTRACT` - `SECRET_API_VERIFYING_CONTRACT`
- `SECRET_API_MEMBERSHIP_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_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 ### Governance install

View File

@ -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) { func TestMembershipConfirmRejectsAlreadyConfirmedQuote(t *testing.T) {
a, cfg, cleanup := newTestApp(t) a, cfg, cleanup := newTestApp(t)
defer cleanup() 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) { func TestMarketplaceWorkspaceAddOnRequiresCoreEntitlement(t *testing.T) {
a, _, cleanup := newTestApp(t) a, _, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
@ -1545,6 +1630,8 @@ func newTestApp(t *testing.T) (*app, Config, func()) {
cfg.GovernancePackageURL = "https://cdn.test/edutd.tar.gz" cfg.GovernancePackageURL = "https://cdn.test/edutd.tar.gz"
cfg.GovernancePackageSig = "sig-test" cfg.GovernancePackageSig = "sig-test"
cfg.GovernanceRuntimeVersion = "0.2.0" cfg.GovernanceRuntimeVersion = "0.2.0"
cfg.RequireWalletSession = false
cfg.EntitlementContract = "0x1111111111111111111111111111111111111111"
st, err := openStore(cfg.DBPath) st, err := openStore(cfg.DBPath)
if err != nil { if err != nil {
t.Fatalf("open store: %v", err) t.Fatalf("open store: %v", err)

View File

@ -49,7 +49,7 @@ func loadConfig() Config {
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, 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, 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,

View File

@ -3,7 +3,6 @@ package main
import "testing" import "testing"
func TestConfigValidateAllowsDefaultLocalConfig(t *testing.T) { func TestConfigValidateAllowsDefaultLocalConfig(t *testing.T) {
t.Parallel()
cfg := loadConfig() cfg := loadConfig()
cfg.ChainID = 84532 cfg.ChainID = 84532
cfg.RequireOnchainTxVerify = false cfg.RequireOnchainTxVerify = false
@ -14,7 +13,6 @@ func TestConfigValidateAllowsDefaultLocalConfig(t *testing.T) {
} }
func TestConfigValidateRejectsStrictVerificationWithoutRPC(t *testing.T) { func TestConfigValidateRejectsStrictVerificationWithoutRPC(t *testing.T) {
t.Parallel()
cfg := loadConfig() cfg := loadConfig()
cfg.RequireOnchainTxVerify = true cfg.RequireOnchainTxVerify = true
cfg.ChainRPCURL = "" cfg.ChainRPCURL = ""
@ -24,7 +22,6 @@ func TestConfigValidateRejectsStrictVerificationWithoutRPC(t *testing.T) {
} }
func TestConfigValidateRejectsNonPositiveChainID(t *testing.T) { func TestConfigValidateRejectsNonPositiveChainID(t *testing.T) {
t.Parallel()
cfg := loadConfig() cfg := loadConfig()
cfg.ChainID = 0 cfg.ChainID = 0
if err := cfg.Validate(); err == nil { if err := cfg.Validate(); err == nil {
@ -32,29 +29,54 @@ func TestConfigValidateRejectsNonPositiveChainID(t *testing.T) {
} }
} }
func TestConfigValidateRejectsNonUSDCCurrency(t *testing.T) { func TestConfigValidateAllowsETHWhenDecimalsMatch(t *testing.T) {
t.Parallel()
cfg := loadConfig() cfg := loadConfig()
cfg.MintCurrency = "ETH" cfg.MintCurrency = "ETH"
if err := cfg.Validate(); err == nil { cfg.MintDecimals = 18
t.Fatalf("expected mint currency validation failure") cfg.MintAmountAtomic = "1"
if err := cfg.Validate(); err != nil {
t.Fatalf("expected ETH config to validate, got %v", err)
} }
} }
func TestConfigValidateRejectsNonSixMintDecimals(t *testing.T) { func TestConfigValidateRejectsETHWhenDecimalsMismatch(t *testing.T) {
t.Parallel()
cfg := loadConfig() 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 cfg.MintDecimals = 18
if err := cfg.Validate(); err == nil { 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) { func TestConfigValidateRejectsInvalidMintAmount(t *testing.T) {
t.Parallel()
cfg := loadConfig() cfg := loadConfig()
cfg.MintAmountAtomic = "not-a-number" cfg.MintAmountAtomic = "not-a-number"
if err := cfg.Validate(); err == nil { if err := cfg.Validate(); err == nil {
t.Fatalf("expected mint amount validation failure") 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")
}
}

View File

@ -402,19 +402,19 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to generate quote id") writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to generate quote id")
return return
} }
txTo := strings.ToLower(strings.TrimSpace(a.cfg.MembershipContract)) entitlementContract := strings.ToLower(strings.TrimSpace(a.cfg.EntitlementContract))
txData := "0x" if entitlementContract == "" || strings.EqualFold(entitlementContract, "0x0000000000000000000000000000000000000000") {
writeErrorCode(w, http.StatusServiceUnavailable, "entitlement_contract_unconfigured", "entitlement contract is not configured")
return
}
txTo := entitlementContract
txValueHex := "0x0" txValueHex := "0x0"
if entitlementContract := strings.ToLower(strings.TrimSpace(a.cfg.EntitlementContract)); entitlementContract != "" && txData, calldataErr := encodePurchaseEntitlementCalldata(offer.OfferID, wallet, orgRootID, workspaceID)
!strings.EqualFold(entitlementContract, "0x0000000000000000000000000000000000000000") {
entitlementCalldata, calldataErr := encodePurchaseEntitlementCalldata(offer.OfferID, wallet, orgRootID, workspaceID)
if calldataErr != nil { if calldataErr != nil {
writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to encode checkout transaction") writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to encode checkout transaction")
return return
} }
txTo = entitlementContract
txData = entitlementCalldata
}
now := time.Now().UTC() now := time.Now().UTC()
expiresAt := now.Add(a.cfg.QuoteTTL) expiresAt := now.Add(a.cfg.QuoteTTL)

View File

@ -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 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 FROM designations
WHERE address = ? 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 LIMIT 1
`, strings.ToLower(strings.TrimSpace(address))) `, strings.ToLower(strings.TrimSpace(address)))
return scanDesignation(row) return scanDesignation(row)

View File

@ -8,9 +8,9 @@
"mint_currency_mode": "ETH_TEST", "mint_currency_mode": "ETH_TEST",
"mint_amount_atomic": "1", "mint_amount_atomic": "1",
"usdc_contract": "0x0000000000000000000000000000000000000000", "usdc_contract": "0x0000000000000000000000000000000000000000",
"source": "api.edut.dev runtime environment",
"version": "v1", "version": "v1",
"notes": [ "notes": [
"Entitlement contract deploy tx succeeded on Base Sepolia.", "Entitlement contract deployment requires explicit offer-seeding verification before checkout e2e."
"Offer seeding call reverted during first pass; retry required before checkout e2e."
] ]
} }

View File

@ -21,6 +21,7 @@ Current test-mode settings:
1. Base Sepolia (`SECRET_API_CHAIN_ID=84532`) 1. Base Sepolia (`SECRET_API_CHAIN_ID=84532`)
2. ETH quote mode (`SECRET_API_MINT_CURRENCY=ETH`) for low-friction Sepolia smoke validation 2. ETH quote mode (`SECRET_API_MINT_CURRENCY=ETH`) for low-friction Sepolia smoke validation
3. Membership contract wired to `0x3EEb3342751D1Cfc0F90C9393e0B1cd5AcE6FfD8` 3. Membership contract wired to `0x3EEb3342751D1Cfc0F90C9393e0B1cd5AcE6FfD8`
4. Wallet session enforcement enabled by default (`SECRET_API_REQUIRE_WALLET_SESSION=true`)
## Build Targets ## Build Targets
@ -57,6 +58,8 @@ Critical values before launch:
- `SECRET_API_GOV_POLICY_HASH` - `SECRET_API_GOV_POLICY_HASH`
6. Member channel polling: 6. Member channel polling:
- `SECRET_API_MEMBER_POLL_INTERVAL_SECONDS` - `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) ## Systemd Deployment (Hetzner/VPS)

View File

@ -19,6 +19,7 @@ All marketplace endpoints require authenticated app/session context.
3. Entitlement state must default fail-closed for unknown values. 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. 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. 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 ## Store Dependency Mapping

View File

@ -66,7 +66,8 @@ Remaining in this repo:
1. Wire live store checkout flow to production marketplace APIs when available. 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). 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): Cross-repo dependencies (kernel/backend/contracts):