Block tx-hash replay across membership and checkout confirms
Some checks are pending
check / secretapi (push) Waiting to run

This commit is contained in:
Joshua 2026-02-19 14:23:57 -08:00
parent f3326a81fa
commit 0040620649
4 changed files with 190 additions and 0 deletions

View File

@ -525,6 +525,15 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "quote context mismatch") writeError(w, http.StatusBadRequest, "quote context mismatch")
return 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) == "" { if a.cfg.RequireOnchainTxVerify && strings.TrimSpace(a.cfg.ChainRPCURL) == "" {
writeErrorCode(w, http.StatusServiceUnavailable, "chain_verification_unavailable", "chain rpc required for membership confirmation") writeErrorCode(w, http.StatusServiceUnavailable, "chain_verification_unavailable", "chain rpc required for membership confirmation")
return return

View File

@ -446,6 +446,81 @@ func TestMembershipConfirmRejectsAlreadyConfirmedQuote(t *testing.T) {
}, http.StatusConflict) }, 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) { func TestGovernanceInstallOwnerOnlyAndConfirm(t *testing.T) {
a, cfg, cleanup := newTestApp(t) a, cfg, cleanup := newTestApp(t)
defer cleanup() 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) { func TestMarketplaceConfirmRequiresChainRPCWhenStrictVerificationEnabled(t *testing.T) {
a, cfg, cleanup := newTestApp(t) a, cfg, cleanup := newTestApp(t)
defer cleanup() defer cleanup()

View File

@ -548,6 +548,15 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "workspace_id mismatch") writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "workspace_id mismatch")
return 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) { if payerWallet != "" && !strings.EqualFold(payerWallet, quote.PayerWallet) {
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "payer_wallet mismatch") writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "payer_wallet mismatch")
return return

View File

@ -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_intent ON designations(intent_id);`,
`CREATE INDEX IF NOT EXISTS idx_designations_address ON designations(address);`, `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 ( `CREATE TABLE IF NOT EXISTS wallet_sessions (
session_token TEXT PRIMARY KEY, session_token TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
@ -95,6 +96,7 @@ func (s *store) migrate(ctx context.Context) error {
FOREIGN KEY(designation_code) REFERENCES designations(code) 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_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 ( `CREATE TABLE IF NOT EXISTS marketplace_quotes (
quote_id TEXT PRIMARY KEY, quote_id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
@ -122,6 +124,7 @@ func (s *store) migrate(ctx context.Context) error {
confirmed_tx_hash TEXT 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_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 ( `CREATE TABLE IF NOT EXISTS marketplace_entitlements (
entitlement_id TEXT PRIMARY KEY, entitlement_id TEXT PRIMARY KEY,
quote_id TEXT NOT NULL UNIQUE, quote_id TEXT NOT NULL UNIQUE,
@ -564,6 +567,24 @@ func (s *store) getQuote(ctx context.Context, quoteID string) (quoteRecord, erro
return rec, nil 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 { func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteRecord) error {
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO marketplace_quotes ( INSERT INTO marketplace_quotes (
@ -684,6 +705,24 @@ func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (market
return rec, nil 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(&quoteID); 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 { func (s *store) putMarketplaceEntitlement(ctx context.Context, ent marketplaceEntitlementRecord) error {
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO marketplace_entitlements ( INSERT INTO marketplace_entitlements (