Block tx-hash replay across membership and checkout confirms
Some checks are pending
check / secretapi (push) Waiting to run
Some checks are pending
check / secretapi (push) Waiting to run
This commit is contained in:
parent
f3326a81fa
commit
0040620649
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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("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 {
|
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 (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user