diff --git a/backend/secretapi/app.go b/backend/secretapi/app.go index 4547156..9b4411d 100644 --- a/backend/secretapi/app.go +++ b/backend/secretapi/app.go @@ -525,6 +525,15 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "quote context mismatch") return } + if existingCode, lookupErr := a.store.getDesignationCodeByMembershipTxHash(r.Context(), req.TxHash); lookupErr == nil { + if !strings.EqualFold(existingCode, quote.DesignationCode) { + writeErrorCode(w, http.StatusConflict, "tx_hash_replay", "tx hash already used for a different membership confirmation") + return + } + } else if lookupErr != errNotFound { + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve membership tx hash reuse") + return + } if a.cfg.RequireOnchainTxVerify && strings.TrimSpace(a.cfg.ChainRPCURL) == "" { writeErrorCode(w, http.StatusServiceUnavailable, "chain_verification_unavailable", "chain rpc required for membership confirmation") return diff --git a/backend/secretapi/app_test.go b/backend/secretapi/app_test.go index f21d01a..657b459 100644 --- a/backend/secretapi/app_test.go +++ b/backend/secretapi/app_test.go @@ -446,6 +446,81 @@ func TestMembershipConfirmRejectsAlreadyConfirmedQuote(t *testing.T) { }, http.StatusConflict) } +func TestMembershipConfirmRejectsTxHashReplayAcrossDesignations(t *testing.T) { + a, cfg, cleanup := newTestApp(t) + defer cleanup() + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + + signVerify := func(intent tWalletIntentResponse) tWalletVerifyResponse { + issuedAt, err := time.Parse(time.RFC3339Nano, intent.IssuedAt) + if err != nil { + t.Fatalf("parse issued_at: %v", err) + } + td := buildTypedData(cfg, designationRecord{ + Code: intent.DesignationCode, + DisplayToken: intent.DisplayToken, + Nonce: intent.Nonce, + IssuedAt: issuedAt, + Origin: "https://edut.ai", + }) + sig := signTypedData(t, ownerKey, td) + return postJSONExpect[tWalletVerifyResponse](t, a, "/secret/wallet/verify", walletVerifyRequest{ + IntentID: intent.IntentID, + Address: ownerAddr, + ChainID: cfg.ChainID, + Signature: sig, + }, http.StatusOK) + } + + intent1 := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{ + Address: ownerAddr, + Origin: "https://edut.ai", + Locale: "en", + ChainID: cfg.ChainID, + }, http.StatusOK) + verify1 := signVerify(intent1) + quote1 := postJSONExpect[membershipQuoteResponse](t, a, "/secret/membership/quote", membershipQuoteRequest{ + DesignationCode: verify1.DesignationCode, + Address: ownerAddr, + ChainID: cfg.ChainID, + }, http.StatusOK) + + replayedTxHash := "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + _ = postJSONExpect[membershipConfirmResponse](t, a, "/secret/membership/confirm", membershipConfirmRequest{ + DesignationCode: verify1.DesignationCode, + QuoteID: quote1.QuoteID, + TxHash: replayedTxHash, + Address: ownerAddr, + ChainID: cfg.ChainID, + }, http.StatusOK) + + intent2 := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{ + Address: ownerAddr, + Origin: "https://edut.ai", + Locale: "en", + ChainID: cfg.ChainID, + }, http.StatusOK) + verify2 := signVerify(intent2) + quote2 := postJSONExpect[membershipQuoteResponse](t, a, "/secret/membership/quote", membershipQuoteRequest{ + DesignationCode: verify2.DesignationCode, + Address: ownerAddr, + ChainID: cfg.ChainID, + }, http.StatusOK) + + errResp := postJSONExpect[map[string]string](t, a, "/secret/membership/confirm", membershipConfirmRequest{ + DesignationCode: verify2.DesignationCode, + QuoteID: quote2.QuoteID, + TxHash: replayedTxHash, + Address: ownerAddr, + ChainID: cfg.ChainID, + }, http.StatusConflict) + if code := errResp["code"]; code != "tx_hash_replay" { + t.Fatalf("expected tx_hash_replay, got %+v", errResp) + } +} + func TestGovernanceInstallOwnerOnlyAndConfirm(t *testing.T) { a, cfg, cleanup := newTestApp(t) defer cleanup() @@ -1060,6 +1135,64 @@ func TestMarketplaceCheckoutConfirmRejectsPayerWalletMismatch(t *testing.T) { } } +func TestMarketplaceCheckoutConfirmRejectsTxHashReplayAcrossQuotes(t *testing.T) { + a, cfg, cleanup := newTestApp(t) + defer cleanup() + + ownerAKey := mustKey(t) + ownerAAddr := strings.ToLower(crypto.PubkeyToAddress(ownerAKey.PublicKey).Hex()) + ownerBKey := mustKey(t) + ownerBAddr := strings.ToLower(crypto.PubkeyToAddress(ownerBKey.PublicKey).Hex()) + if err := seedActiveMembership(context.Background(), a.store, ownerAAddr); err != nil { + t.Fatalf("seed owner A membership: %v", err) + } + if err := seedActiveMembership(context.Background(), a.store, ownerBAddr); err != nil { + t.Fatalf("seed owner B membership: %v", err) + } + + quoteA := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ + Wallet: ownerAAddr, + OfferID: offerIDWorkspaceCore, + OrgRootID: "org.marketplace.replay.a", + PrincipalID: "human.owner.a", + PrincipalRole: "org_root_owner", + }, http.StatusOK) + + replayedTxHash := "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + _ = postJSONExpect[marketplaceCheckoutConfirmResponse](t, a, "/marketplace/checkout/confirm", marketplaceCheckoutConfirmRequest{ + QuoteID: quoteA.QuoteID, + Wallet: ownerAAddr, + OfferID: offerIDWorkspaceCore, + OrgRootID: "org.marketplace.replay.a", + PrincipalID: "human.owner.a", + PrincipalRole: "org_root_owner", + TxHash: replayedTxHash, + ChainID: cfg.ChainID, + }, http.StatusOK) + + quoteB := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ + Wallet: ownerBAddr, + OfferID: offerIDWorkspaceCore, + OrgRootID: "org.marketplace.replay.b", + PrincipalID: "human.owner.b", + PrincipalRole: "org_root_owner", + }, http.StatusOK) + + errResp := postJSONExpect[map[string]string](t, a, "/marketplace/checkout/confirm", marketplaceCheckoutConfirmRequest{ + QuoteID: quoteB.QuoteID, + Wallet: ownerBAddr, + OfferID: offerIDWorkspaceCore, + OrgRootID: "org.marketplace.replay.b", + PrincipalID: "human.owner.b", + PrincipalRole: "org_root_owner", + TxHash: replayedTxHash, + ChainID: cfg.ChainID, + }, http.StatusConflict) + if code := errResp["code"]; code != "tx_hash_replay" { + t.Fatalf("expected tx_hash_replay, got %+v", errResp) + } +} + func TestMarketplaceConfirmRequiresChainRPCWhenStrictVerificationEnabled(t *testing.T) { a, cfg, cleanup := newTestApp(t) defer cleanup() diff --git a/backend/secretapi/marketplace.go b/backend/secretapi/marketplace.go index 8812e5f..5204afd 100644 --- a/backend/secretapi/marketplace.go +++ b/backend/secretapi/marketplace.go @@ -548,6 +548,15 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "workspace_id mismatch") return } + if existingQuoteID, lookupErr := a.store.getMarketplaceQuoteIDByConfirmedTxHash(r.Context(), req.TxHash); lookupErr == nil { + if !strings.EqualFold(existingQuoteID, quote.QuoteID) { + writeErrorCode(w, http.StatusConflict, "tx_hash_replay", "tx hash already used for a different checkout confirmation") + return + } + } else if lookupErr != errNotFound { + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve checkout tx hash reuse") + return + } if payerWallet != "" && !strings.EqualFold(payerWallet, quote.PayerWallet) { writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "payer_wallet mismatch") return diff --git a/backend/secretapi/store.go b/backend/secretapi/store.go index 50a36a0..0707ad3 100644 --- a/backend/secretapi/store.go +++ b/backend/secretapi/store.go @@ -61,6 +61,7 @@ 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 INDEX IF NOT EXISTS idx_designations_membership_tx_hash ON designations(membership_tx_hash);`, `CREATE TABLE IF NOT EXISTS wallet_sessions ( session_token TEXT PRIMARY KEY, wallet TEXT NOT NULL, @@ -95,6 +96,7 @@ func (s *store) migrate(ctx context.Context) error { FOREIGN KEY(designation_code) REFERENCES designations(code) );`, `CREATE INDEX IF NOT EXISTS idx_quotes_designation ON quotes(designation_code);`, + `CREATE INDEX IF NOT EXISTS idx_quotes_confirmed_tx_hash ON quotes(confirmed_tx_hash);`, `CREATE TABLE IF NOT EXISTS marketplace_quotes ( quote_id TEXT PRIMARY KEY, wallet TEXT NOT NULL, @@ -122,6 +124,7 @@ func (s *store) migrate(ctx context.Context) error { confirmed_tx_hash TEXT );`, `CREATE INDEX IF NOT EXISTS idx_marketplace_quotes_wallet ON marketplace_quotes(wallet);`, + `CREATE INDEX IF NOT EXISTS idx_marketplace_quotes_confirmed_tx_hash ON marketplace_quotes(confirmed_tx_hash);`, `CREATE TABLE IF NOT EXISTS marketplace_entitlements ( entitlement_id TEXT PRIMARY KEY, quote_id TEXT NOT NULL UNIQUE, @@ -564,6 +567,24 @@ func (s *store) getQuote(ctx context.Context, quoteID string) (quoteRecord, erro return rec, nil } +func (s *store) getDesignationCodeByMembershipTxHash(ctx context.Context, txHash string) (string, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT code + FROM designations + WHERE LOWER(COALESCE(membership_tx_hash, '')) = ? + ORDER BY activated_at DESC + LIMIT 1 + `, strings.ToLower(strings.TrimSpace(txHash))) + var code sql.NullString + if err := row.Scan(&code); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", errNotFound + } + return "", err + } + return strings.TrimSpace(code.String), nil +} + func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteRecord) error { _, err := s.db.ExecContext(ctx, ` INSERT INTO marketplace_quotes ( @@ -684,6 +705,24 @@ func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (market return rec, nil } +func (s *store) getMarketplaceQuoteIDByConfirmedTxHash(ctx context.Context, txHash string) (string, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT quote_id + FROM marketplace_quotes + WHERE LOWER(COALESCE(confirmed_tx_hash, '')) = ? + ORDER BY confirmed_at DESC + LIMIT 1 + `, strings.ToLower(strings.TrimSpace(txHash))) + var quoteID sql.NullString + if err := row.Scan("eID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", errNotFound + } + return "", err + } + return strings.TrimSpace(quoteID.String), nil +} + func (s *store) putMarketplaceEntitlement(ctx context.Context, ent marketplaceEntitlementRecord) error { _, err := s.db.ExecContext(ctx, ` INSERT INTO marketplace_entitlements (