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_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

View File

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

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) {
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)

View File

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

View File

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

View File

@ -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()

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
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)

View File

@ -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."
]
}

View File

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

View File

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

View File

@ -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):