diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index fb87cb220..68821e377 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -198,7 +198,7 @@ jobs: PORT: "3002" NETWORK: localnet RPC_URL: http://localhost:8899 - MPP_SECRET_KEY: playground-ci-secret + MPP_SECRET_KEY: playground-ci-secret-padding-0123456789 run: go run ./examples/playground-api & - name: Wait for playground server working-directory: . diff --git a/go/examples/playground-api/main_test.go b/go/examples/playground-api/main_test.go index 57b0852fe..72e9cd92e 100644 --- a/go/examples/playground-api/main_test.go +++ b/go/examples/playground-api/main_test.go @@ -78,7 +78,7 @@ func newTestServer(t *testing.T) (*httptest.Server, *app) { network: "localnet", rpcURL: stub.URL, recipient: feePayer.PublicKey().String(), - secretKey: "playground-smoke-secret", + secretKey: "playground-smoke-secret-0123456789ab", feePayer: feePayer, rpcClient: rpc.New(stub.URL), repoRoot: t.TempDir(), // empty root: no docs generated, no SPA dist diff --git a/go/examples/playground-api/playground_e2e_test.go b/go/examples/playground-api/playground_e2e_test.go index 4088c35c8..710600a49 100644 --- a/go/examples/playground-api/playground_e2e_test.go +++ b/go/examples/playground-api/playground_e2e_test.go @@ -64,7 +64,7 @@ func TestPlaygroundSessionE2ESurfpool(t *testing.T) { network: "localnet", rpcURL: sandboxRPCURL(), recipient: feePayer.PublicKey().String(), - secretKey: "playground-e2e-secret", + secretKey: "playground-e2e-secret-0123456789abc", feePayer: feePayer, rpcClient: rpcClient, repoRoot: t.TempDir(), diff --git a/go/paycore/network_test.go b/go/paycore/network_test.go index eee96db42..3bc49b597 100644 --- a/go/paycore/network_test.go +++ b/go/paycore/network_test.go @@ -23,3 +23,26 @@ func TestParseSolanaNetwork(t *testing.T) { } } } + +// #37: the boot-time allowlist accepts only canonical slugs and rejects the +// legacy mainnet-beta spelling, empty, and unknown values. +func TestRequireKnownNetwork(t *testing.T) { + for _, ok := range []string{"mainnet", "devnet", "localnet"} { + if got, err := RequireKnownNetwork(ok); err != nil || string(got) != ok { + t.Fatalf("RequireKnownNetwork(%q) = (%q, %v), want accepted", ok, got, err) + } + } + for _, bad := range []string{"mainnet-beta", "testnet", "", "MAINNET"} { + if _, err := RequireKnownNetwork(bad); err == nil { + t.Fatalf("RequireKnownNetwork(%q) should be rejected", bad) + } + } +} + +func TestResolveMintCanonicalMainnet(t *testing.T) { + // The canonical "mainnet" slug must resolve known mints just like the + // legacy table key. + if got := ResolveMint("USDC", "mainnet"); got != USDCMainnetMint { + t.Fatalf("ResolveMint(USDC, mainnet) = %q, want %q", got, USDCMainnetMint) + } +} diff --git a/go/paycore/solana.go b/go/paycore/solana.go index 8a5c045e4..8b0e56401 100644 --- a/go/paycore/solana.go +++ b/go/paycore/solana.go @@ -5,7 +5,10 @@ // byte-identical across language SDKs. package paycore -import "strings" +import ( + "fmt" + "strings" +) // Solana program and well-known mint addresses used by the SDK. // Mirrors the constant tables in rust/src/protocol/solana.rs so the @@ -57,6 +60,26 @@ var token2022Stablecoins = map[string]struct{}{ "CASH": {}, } +// RequireKnownNetwork validates a network slug against the canonical +// allowlist {mainnet, devnet, localnet}. The legacy "mainnet-beta" spelling +// (any case) is rejected in favor of the canonical "mainnet"; an empty slug +// or any other value (e.g. "testnet") is also rejected. Mirrors the Rust +// reference validate_network (protocol/solana.rs) so a misconfigured server +// fails fast at boot rather than silently resolving an unknown slug to +// mainnet mints. Returns the canonical slug on success. +func RequireKnownNetwork(network string) (SolanaNetwork, error) { + if strings.TrimSpace(network) == "" { + return "", fmt.Errorf("network is required (one of %s, %s, %s)", NetworkMainnet, NetworkDevnet, NetworkLocalnet) + } + switch network { + case string(NetworkMainnet), string(NetworkDevnet), string(NetworkLocalnet): + return SolanaNetwork(network), nil + default: + return "", fmt.Errorf("unsupported network %q (must be one of %s, %s, %s; the legacy %q spelling is not accepted)", + network, NetworkMainnet, NetworkDevnet, NetworkLocalnet, "mainnet-beta") + } +} + // DefaultRPCURL returns the default RPC endpoint for a Solana network. func DefaultRPCURL(network string) string { switch network { @@ -69,6 +92,17 @@ func DefaultRPCURL(network string) string { } } +// mintNetworkKey folds a network slug onto the key used in the knownMints +// table. The table is keyed by the legacy "mainnet-beta" spelling for +// historical reasons; the canonical "mainnet" slug (and unknown slugs) map +// to it so the wire-format mint tables stay stable. +func mintNetworkKey(network string) string { + if string(ParseSolanaNetwork(network)) == string(NetworkMainnet) { + return "mainnet-beta" + } + return network +} + // ResolveMint converts a symbolic currency into a mint address. // Returns an empty string for native SOL. func ResolveMint(currency string, network string) string { @@ -78,7 +112,7 @@ func ResolveMint(currency string, network string) string { return "" } if mints, ok := knownMints[normalized]; ok { - if mint, ok := mints[network]; ok { + if mint, ok := mints[mintNetworkKey(network)]; ok { return mint } return mints["mainnet-beta"] diff --git a/go/paykit/paykit_test.go b/go/paykit/paykit_test.go index f39e137c6..19491874b 100644 --- a/go/paykit/paykit_test.go +++ b/go/paykit/paykit_test.go @@ -273,7 +273,7 @@ func mustClient(t *testing.T) *paykit.Client { Network: paykit.SolanaLocalnet, Preflight: disabled(), MPP: paykit.MPPConfig{ - ChallengeBindingSecret: []byte("test-secret"), + ChallengeBindingSecret: []byte("test-secret-key-0123456789abcdef"), }, }) if err != nil { diff --git a/go/protocols/mpp/client/audit_fixes_test.go b/go/protocols/mpp/client/audit_fixes_test.go new file mode 100644 index 000000000..e48e2bb00 --- /dev/null +++ b/go/protocols/mpp/client/audit_fixes_test.go @@ -0,0 +1,219 @@ +package client + +import ( + "context" + "strings" + "testing" + + solana "github.com/gagliardetto/solana-go" + + "github.com/solana-foundation/pay-kit/go/internal/testutil" + "github.com/solana-foundation/pay-kit/go/paycore" + "github.com/solana-foundation/pay-kit/go/paycore/solanatx" + core "github.com/solana-foundation/pay-kit/go/protocols/mpp/core" +) + +// solChallenge builds a minimal solana/charge challenge for the credential +// header builder tests, with optional expiry. +func solChallenge(t *testing.T, amount, network, expires string) core.PaymentChallenge { + t.Helper() + req, err := core.NewBase64URLJSONValue(map[string]any{ + "amount": amount, + "currency": "sol", + "recipient": testutil.NewPrivateKey().PublicKey().String(), + "methodDetails": map[string]any{"network": network}, + }) + if err != nil { + t.Fatalf("encode request: %v", err) + } + return core.NewChallengeWithSecretFull( + "binding-secret-0123456789abcdef01234567", + "realm", core.NewMethodName("solana"), core.NewIntentName("charge"), + req, expires, "", "", nil, + ) +} + +// --- #42 decimals required for SPL --- + +func TestBuildChargeRejectsMissingDecimalsForSPL(t *testing.T) { + rpcClient := testutil.NewFakeRPC() + signer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey().String() + mint := testutil.NewPrivateKey().PublicKey() + rpcClient.MintOwners[mint.String()] = solana.TokenProgramID + + _, err := BuildChargeTransaction(context.Background(), signer, rpcClient, "1000", mint.String(), recipient, paycore.MethodDetails{ + // Decimals omitted on purpose. + }, BuildOptions{}) + if err == nil || !strings.Contains(err.Error(), "decimals") { + t.Fatalf("expected missing-decimals rejection, got %v", err) + } +} + +// --- #26 unknown Token-2022 mint gated --- + +func TestBuildChargeRefusesUnknownToken2022WithoutOptIn(t *testing.T) { + rpcClient := testutil.NewFakeRPC() + signer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey().String() + mint := testutil.NewPrivateKey().PublicKey() + rpcClient.MintOwners[mint.String()] = solana.MustPublicKeyFromBase58(paycore.Token2022Program) + decimals := uint8(6) + + _, err := BuildChargeTransaction(context.Background(), signer, rpcClient, "1000", mint.String(), recipient, paycore.MethodDetails{ + Decimals: &decimals, + }, BuildOptions{}) + if err == nil || !strings.Contains(err.Error(), "Token-2022") { + t.Fatalf("expected unknown Token-2022 rejection, got %v", err) + } +} + +func TestBuildChargeAllowsUnknownVanillaTokenMint(t *testing.T) { + rpcClient := testutil.NewFakeRPC() + signer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey().String() + mint := testutil.NewPrivateKey().PublicKey() + rpcClient.MintOwners[mint.String()] = solana.TokenProgramID + decimals := uint8(6) + + if _, err := BuildChargeTransaction(context.Background(), signer, rpcClient, "1000", mint.String(), recipient, paycore.MethodDetails{ + Decimals: &decimals, + }, BuildOptions{}); err != nil { + t.Fatalf("expected unknown vanilla Token mint to be allowed: %v", err) + } +} + +// --- #20 split ATA only created when flagged --- + +func TestBuildChargeSplitATAOnlyWhenFlagged(t *testing.T) { + rpcClient := testutil.NewFakeRPC() + signer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey().String() + split := testutil.NewPrivateKey().PublicKey().String() + mint := testutil.NewPrivateKey().PublicKey() + rpcClient.MintOwners[mint.String()] = solana.TokenProgramID + decimals := uint8(6) + required := true + + // Flagged split => an ATA-create instruction is included. + payload, err := BuildChargeTransaction(context.Background(), signer, rpcClient, "1000", mint.String(), recipient, paycore.MethodDetails{ + Decimals: &decimals, + Splits: []paycore.Split{{Recipient: split, Amount: "100", AtaCreationRequired: &required}}, + }, BuildOptions{}) + if err != nil { + t.Fatalf("build flagged: %v", err) + } + flaggedCount := ataCreateCount(t, payload.Transaction) + + // Unflagged split => no ATA-create instruction in client-paid mode. + payload2, err := BuildChargeTransaction(context.Background(), signer, rpcClient, "1000", mint.String(), recipient, paycore.MethodDetails{ + Decimals: &decimals, + Splits: []paycore.Split{{Recipient: split, Amount: "100"}}, + }, BuildOptions{}) + if err != nil { + t.Fatalf("build unflagged: %v", err) + } + unflaggedCount := ataCreateCount(t, payload2.Transaction) + + if flaggedCount <= unflaggedCount { + t.Fatalf("expected flagged split to create more ATAs (%d) than unflagged (%d)", flaggedCount, unflaggedCount) + } + if unflaggedCount != 0 { + t.Fatalf("expected no split ATA creation when unflagged, got %d", unflaggedCount) + } +} + +func ataCreateCount(t *testing.T, encoded string) int { + t.Helper() + ataProgram := solana.MustPublicKeyFromBase58(paycore.AssociatedTokenProgram) + tx, err := solanatx.DecodeTransactionBase64(encoded) + if err != nil { + t.Fatalf("decode tx: %v", err) + } + count := 0 + for _, ix := range tx.Message.Instructions { + if tx.Message.AccountKeys[ix.ProgramIDIndex].Equals(ataProgram) { + count++ + } + } + return count +} + +// --- #10 max amount / expected network / expiry --- + +func TestBuildCredentialHeaderRejectsAmountAboveMax(t *testing.T) { + rpcClient := testutil.NewFakeRPC() + signer := testutil.NewPrivateKey() + challenge := solChallenge(t, "1000", "localnet", "") + _, err := BuildCredentialHeaderWithOptions(context.Background(), signer, rpcClient, challenge, BuildOptions{ + MaxAmountBaseUnits: 999, + }) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "maximum") { + t.Fatalf("expected amount-above-max rejection, got %v", err) + } +} + +func TestBuildCredentialHeaderAcceptsAmountAtMax(t *testing.T) { + rpcClient := testutil.NewFakeRPC() + signer := testutil.NewPrivateKey() + challenge := solChallenge(t, "1000", "localnet", "") + if _, err := BuildCredentialHeaderWithOptions(context.Background(), signer, rpcClient, challenge, BuildOptions{ + MaxAmountBaseUnits: 1000, + }); err != nil { + t.Fatalf("expected at-cap amount to be accepted: %v", err) + } +} + +func TestBuildCredentialHeaderRejectsUnexpectedNetwork(t *testing.T) { + rpcClient := testutil.NewFakeRPC() + signer := testutil.NewPrivateKey() + challenge := solChallenge(t, "1000", "devnet", "") + _, err := BuildCredentialHeaderWithOptions(context.Background(), signer, rpcClient, challenge, BuildOptions{ + ExpectedNetwork: "localnet", + }) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "network") { + t.Fatalf("expected network mismatch rejection, got %v", err) + } +} + +func TestBuildCredentialHeaderRejectsExpiredChallenge(t *testing.T) { + rpcClient := testutil.NewFakeRPC() + signer := testutil.NewPrivateKey() + challenge := solChallenge(t, "1000", "localnet", "2000-01-01T00:00:00Z") + _, err := BuildCredentialHeaderWithOptions(context.Background(), signer, rpcClient, challenge, BuildOptions{}) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "expired") { + t.Fatalf("expected expired-challenge rejection, got %v", err) + } +} + +// --- #17 method/intent gate --- + +func TestBuildCredentialHeaderRejectsNonSolanaMethod(t *testing.T) { + rpcClient := testutil.NewFakeRPC() + signer := testutil.NewPrivateKey() + req, _ := core.NewBase64URLJSONValue(map[string]any{ + "amount": "1000", "currency": "sol", + "recipient": testutil.NewPrivateKey().PublicKey().String(), + "methodDetails": map[string]any{"network": "localnet"}, + }) + challenge := core.NewChallengeWithSecret("binding-secret-0123456789abcdef01234567", "realm", + core.NewMethodName("stripe"), core.NewIntentName("charge"), req) + if _, err := BuildCredentialHeaderWithOptions(context.Background(), signer, rpcClient, challenge, BuildOptions{}); err == nil { + t.Fatal("expected non-solana method to be rejected") + } +} + +func TestBuildCredentialHeaderRejectsNonChargeIntent(t *testing.T) { + rpcClient := testutil.NewFakeRPC() + signer := testutil.NewPrivateKey() + req, _ := core.NewBase64URLJSONValue(map[string]any{ + "amount": "1000", "currency": "sol", + "recipient": testutil.NewPrivateKey().PublicKey().String(), + "methodDetails": map[string]any{"network": "localnet"}, + }) + challenge := core.NewChallengeWithSecret("binding-secret-0123456789abcdef01234567", "realm", + core.NewMethodName("solana"), core.NewIntentName("session"), req) + if _, err := BuildCredentialHeaderWithOptions(context.Background(), signer, rpcClient, challenge, BuildOptions{}); err == nil { + t.Fatal("expected non-charge intent to be rejected") + } +} diff --git a/go/protocols/mpp/client/charge.go b/go/protocols/mpp/client/charge.go index 9534d2632..a42789970 100644 --- a/go/protocols/mpp/client/charge.go +++ b/go/protocols/mpp/client/charge.go @@ -3,7 +3,9 @@ package client import ( "context" "encoding/json" + "fmt" "strings" + "time" solana "github.com/gagliardetto/solana-go" @@ -27,6 +29,24 @@ type BuildOptions struct { // yet hold a token account for the selected mint; the instruction is // idempotent so it is safe when the account already exists. CreateRecipientATA bool + + // MaxAmountBaseUnits, when non-zero, refuses to sign a challenge whose + // amount exceeds this cap (in token base units). Opt-in guard for + // auto-pay integrations where the server controls what gets signed + // against the user's wallet (#10). Zero means no cap. + MaxAmountBaseUnits uint64 + + // ExpectedNetwork, when non-empty, refuses to sign a challenge whose + // methodDetails.network does not match (compared canonically, so + // "mainnet"/"mainnet-beta" are equivalent). Opt-in network pin (#10). + ExpectedNetwork string + + // AllowUnknownToken2022, when true, permits signing transfers for a + // Token-2022 mint that is not a known stablecoin. Such mints can carry + // transfer hooks that execute arbitrary code on every transfer, so they + // are refused by default (#26). Vanilla Token-program mints are always + // allowed regardless of this flag. + AllowUnknownToken2022 bool } // BuildChargeTransaction creates a payment credential payload from challenge fields. @@ -118,10 +138,24 @@ func BuildChargeTransaction( if err != nil { return paycore.CredentialPayload{}, core.WrapError(core.ErrCodeRPC, "resolve token program", err) } - decimals := uint8(6) - if methodDetails.Decimals != nil { - decimals = *methodDetails.Decimals + // #26: refuse to sign an unknown Token-2022 mint unless explicitly + // allowed. Token-2022 mints can carry transfer hooks that run arbitrary + // code on every transfer; the vanilla Token program has no such hook so + // arbitrary Token-program mints stay first-class. + if tokenProgram.String() == paycore.Token2022Program && + paycore.StablecoinSymbol(currency) == "" && + !options.AllowUnknownToken2022 { + return paycore.CredentialPayload{}, core.NewError(core.ErrCodeInvalidConfig, + "refusing to sign an unknown Token-2022 mint (transfer-hook risk); set AllowUnknownToken2022 to override") + } + // #42: decimals are required for SPL charges (spec §7.2 marks them MUST + // be present for a mint). Defaulting to 6 would silently build a + // transfer at the wrong divisor for a non-6-decimal mint. + if methodDetails.Decimals == nil { + return paycore.CredentialPayload{}, core.NewError(core.ErrCodeInvalidConfig, + "methodDetails.decimals is required for SPL charges (spec §7.2)") } + decimals := *methodDetails.Decimals sourceATA, err := solanatx.FindAssociatedTokenAddressWithProgram(signer.PublicKey(), mint, tokenProgram) if err != nil { return paycore.CredentialPayload{}, err @@ -171,7 +205,10 @@ func BuildChargeTransaction( if err != nil { return paycore.CredentialPayload{}, err } - createTokenAccount := !useServerFeePayer || (split.AtaCreationRequired != nil && *split.AtaCreationRequired) + // #20: only create a split ATA when the challenge explicitly flags + // it. Creating one per split in client-paid mode let a hostile + // server attach N dust splits and drain N x ~0.002 SOL of rent. + createTokenAccount := split.AtaCreationRequired != nil && *split.AtaCreationRequired if err := addTransfer(splitKey, splitAmount, createTokenAccount); err != nil { return paycore.CredentialPayload{}, err } @@ -242,6 +279,23 @@ func BuildCredentialHeaderWithOptions( challenge core.PaymentChallenge, options BuildOptions, ) (string, error) { + // #17: refuse to sign a challenge that is not a solana/charge challenge + // before doing any work. The transport filters before calling, but this is + // the lower-level exported builder, so it must gate itself. + if challenge.Method != core.NewMethodName("solana") { + return "", core.NewError(core.ErrCodeInvalidMethod, + "challenge method is not \"solana\"") + } + if !challenge.Intent.IsCharge() { + return "", core.NewError(core.ErrCodeInvalidMethod, + "challenge intent is not a charge") + } + // #10: always refuse to sign an expired challenge. Challenges with no + // expiry are still accepted (the protocol allows omitting it). + if challenge.IsExpired(time.Now()) { + return "", core.NewError(core.ErrCodeChallengeExpired, + "refusing to sign an expired challenge") + } var request intents.ChargeRequest if err := challenge.Request.Decode(&request); err != nil { return "", err @@ -256,6 +310,26 @@ func BuildCredentialHeaderWithOptions( return "", err } } + // #10: opt-in max-amount cap. Compared in base units, matching how the + // server reasons about the amount. + if options.MaxAmountBaseUnits > 0 { + amount, err := request.ParseAmount() + if err != nil { + return "", err + } + if amount > options.MaxAmountBaseUnits { + return "", core.NewError(core.ErrCodeAmountMismatch, + fmt.Sprintf("challenge amount %d exceeds configured maximum %d", amount, options.MaxAmountBaseUnits)) + } + } + // #10: opt-in expected-network pin. Compared canonically so the legacy + // "mainnet-beta" spelling matches the canonical "mainnet". + if options.ExpectedNetwork != "" { + if paycore.ParseSolanaNetwork(details.Network) != paycore.ParseSolanaNetwork(options.ExpectedNetwork) { + return "", core.NewError(core.ErrCodeWrongNetwork, + fmt.Sprintf("challenge network %q does not match expected %q", details.Network, options.ExpectedNetwork)) + } + } options.ExternalID = request.ExternalID payload, err := BuildChargeTransaction(ctx, signer, rpcClient, request.Amount, request.Currency, request.Recipient, details, options) if err != nil { diff --git a/go/protocols/mpp/client/charge_test.go b/go/protocols/mpp/client/charge_test.go index 3c556b419..8e3576c44 100644 --- a/go/protocols/mpp/client/charge_test.go +++ b/go/protocols/mpp/client/charge_test.go @@ -255,7 +255,7 @@ func TestBuildChargeTransactionToken2022(t *testing.T) { payload, err := BuildChargeTransaction(context.Background(), signer, rpcClient, "1000", mint.String(), recipient, paycore.MethodDetails{ Decimals: &decimals, - }, BuildOptions{}) + }, BuildOptions{AllowUnknownToken2022: true}) if err != nil { t.Fatalf("build failed: %v", err) } @@ -335,9 +335,11 @@ func TestBuildChargeTransactionTokenWithSplits(t *testing.T) { if err != nil { t.Fatalf("decode failed: %v", err) } - // 2 compute budget + 1 primary transfer + 2 split instructions + 1 split memo = 6 - if len(tx.Message.Instructions) != 6 { - t.Fatalf("expected 6 instructions, got %d", len(tx.Message.Instructions)) + // 2 compute budget + 1 primary transfer + 1 split transfer + 1 split memo + // = 5. No split ATA-create: the split does not set ataCreationRequired, so + // after the #20 fix the client no longer auto-creates it in client-paid mode. + if len(tx.Message.Instructions) != 5 { + t.Fatalf("expected 5 instructions, got %d", len(tx.Message.Instructions)) } if !hasMemoText(memoTexts(t, tx), "platform fee") { t.Fatalf("expected split memo instruction") diff --git a/go/protocols/mpp/mpp_internal_test.go b/go/protocols/mpp/mpp_internal_test.go index 5055bf174..883a585ec 100644 --- a/go/protocols/mpp/mpp_internal_test.go +++ b/go/protocols/mpp/mpp_internal_test.go @@ -35,7 +35,7 @@ func testCfg() paykit.Config { Network: paykit.SolanaLocalnet, Stablecoins: []paykit.Stablecoin{paykit.USDC}, Operator: paykit.Operator{Signer: demo, Recipient: demo.Pubkey(), FeePayer: true}, - MPP: paykit.MPPConfig{Realm: "Unit", ChallengeBindingSecret: []byte("secret")}, + MPP: paykit.MPPConfig{Realm: "Unit", ChallengeBindingSecret: []byte("unit-test-binding-secret-0123456789abcdef")}, RPCURL: "https://example.invalid", // never dialed in these tests } } diff --git a/go/protocols/mpp/server/audit_fixes_test.go b/go/protocols/mpp/server/audit_fixes_test.go new file mode 100644 index 000000000..aa9050c93 --- /dev/null +++ b/go/protocols/mpp/server/audit_fixes_test.go @@ -0,0 +1,302 @@ +package server + +import ( + "context" + "strings" + "testing" + + solana "github.com/gagliardetto/solana-go" + + "github.com/solana-foundation/pay-kit/go/internal/testutil" + "github.com/solana-foundation/pay-kit/go/paycore" + core "github.com/solana-foundation/pay-kit/go/protocols/mpp/core" + "github.com/solana-foundation/pay-kit/go/protocols/mpp/intents" +) + +func mustDecodeRequest(t *testing.T, challenge core.PaymentChallenge) intents.ChargeRequest { + t.Helper() + var req intents.ChargeRequest + if err := challenge.Request.Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + return req +} + +func decodeChallengeDetails(t *testing.T, challenge core.PaymentChallenge, out *paycore.MethodDetails) error { + t.Helper() + var req intents.ChargeRequest + if err := challenge.Request.Decode(&req); err != nil { + return err + } + details, err := decodeMethodDetails(req.MethodDetails) + if err != nil { + return err + } + *out = details + return nil +} + +// validConfig returns a minimal Config that passes New() so individual tests +// can mutate a single field to exercise a specific guard. +func validConfig(t *testing.T) Config { + t.Helper() + return Config{ + Recipient: testutil.NewPrivateKey().PublicKey().String(), + Currency: "USDC", + Decimals: 6, + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: testutil.NewFakeRPC(), + Store: core.NewMemoryStore(), + } +} + +// --- #24 weak secret key --- + +func TestNewRejectsShortSecretKey(t *testing.T) { + cfg := validConfig(t) + cfg.SecretKey = strings.Repeat("a", minSecretKeyBytes-1) + if _, err := New(cfg); err == nil { + t.Fatal("expected rejection of short secret key") + } +} + +func TestNewAcceptsSecretKeyAtMinimumLength(t *testing.T) { + cfg := validConfig(t) + cfg.SecretKey = strings.Repeat("a", minSecretKeyBytes) + if _, err := New(cfg); err != nil { + t.Fatalf("expected at-minimum secret key to be accepted: %v", err) + } +} + +// --- #37 network allowlist --- + +func TestNewAcceptsCanonicalNetworks(t *testing.T) { + for _, network := range []string{"mainnet", "devnet", "localnet"} { + cfg := validConfig(t) + cfg.Network = network + // USDC resolves from the static table, no RPC fetch needed at boot. + if _, err := New(cfg); err != nil { + t.Fatalf("expected network %q to be accepted: %v", network, err) + } + } +} + +func TestNewRejectsMainnetBetaSlug(t *testing.T) { + cfg := validConfig(t) + cfg.Network = "mainnet-beta" + if _, err := New(cfg); err == nil { + t.Fatal("expected legacy mainnet-beta slug to be rejected") + } +} + +func TestNewRejectsUnknownNetwork(t *testing.T) { + cfg := validConfig(t) + cfg.Network = "testnet" + if _, err := New(cfg); err == nil { + t.Fatal("expected unknown network to be rejected") + } +} + +// --- #16 feePayer override without signer --- + +func TestChargeOptionsRejectsFeePayerWithoutSigner(t *testing.T) { + cfg := validConfig(t) + m, err := New(cfg) + if err != nil { + t.Fatalf("new: %v", err) + } + if _, err := m.ChargeWithOptions(context.Background(), "1.00", ChargeOptions{FeePayer: true}); err == nil { + t.Fatal("expected per-call feePayer override without signer to be rejected") + } +} + +func TestChargeOptionsFeePayerSucceedsWhenSignerConfigured(t *testing.T) { + cfg := validConfig(t) + cfg.Currency = "sol" + cfg.Decimals = 9 + cfg.FeePayerSigner = testutil.NewPrivateKey() + m, err := New(cfg) + if err != nil { + t.Fatalf("new: %v", err) + } + challenge, err := m.ChargeWithOptions(context.Background(), "0.001", ChargeOptions{FeePayer: true}) + if err != nil { + t.Fatalf("expected feePayer charge to succeed: %v", err) + } + var details paycore.MethodDetails + if err := decodeChallengeDetails(t, challenge, &details); err != nil { + t.Fatalf("decode details: %v", err) + } + if details.FeePayerKey == "" { + t.Fatal("expected feePayerKey to be populated") + } +} + +// --- #38 primary recipient in splits + ataCreationRequired --- + +func TestChargeRejectsPrimaryRecipientSplitWithATACreation(t *testing.T) { + cfg := validConfig(t) + // Use a raw mint-address currency so the ataCreationRequired SPL gate is + // otherwise satisfiable; the primary-in-splits check must fire first. + mint := testutil.NewPrivateKey().PublicKey() + cfg.RPC.(*testutil.FakeRPC).MintOwners[mint.String()] = solana.TokenProgramID + cfg.Currency = mint.String() + m, err := New(cfg) + if err != nil { + t.Fatalf("new: %v", err) + } + _, err = m.ChargeWithOptions(context.Background(), "1.00", ChargeOptions{ + Splits: []paycore.Split{ + {Recipient: cfg.Recipient, Amount: "1", AtaCreationRequired: boolp(true)}, + }, + }) + if err == nil || !strings.Contains(err.Error(), "primary recipient") { + t.Fatalf("expected primary-recipient ATA-recreate rejection, got %v", err) + } +} + +func TestChargeAllowsPrimaryRecipientSplitWithoutATACreation(t *testing.T) { + cfg := validConfig(t) + cfg.Currency = "sol" + cfg.Decimals = 9 + m, err := New(cfg) + if err != nil { + t.Fatalf("new: %v", err) + } + // The primary recipient may appear in splits as long as it does not opt + // into ataCreationRequired (legitimate merchant-takes-a-cut use case). + if _, err := m.ChargeWithOptions(context.Background(), "0.002", ChargeOptions{ + Splits: []paycore.Split{{Recipient: cfg.Recipient, Amount: "1"}}, + }); err != nil { + t.Fatalf("expected primary-in-splits without ATA-create to be allowed: %v", err) + } +} + +// --- #21 split validation at issuance --- + +func TestValidateSplits(t *testing.T) { + good := testutil.NewPrivateKey().PublicKey().String() + other := testutil.NewPrivateKey().PublicKey().String() + + if err := validateSplits([]paycore.Split{{Recipient: good, Amount: "1"}, {Recipient: other, Amount: "2"}}); err != nil { + t.Fatalf("valid set rejected: %v", err) + } + if err := validateSplits(nil); err != nil { + t.Fatalf("empty set rejected: %v", err) + } + if err := validateSplits([]paycore.Split{{Recipient: "not-a-pubkey", Amount: "1"}}); err == nil { + t.Fatal("expected invalid recipient rejection") + } + if err := validateSplits([]paycore.Split{{Recipient: good, Amount: "nope"}}); err == nil { + t.Fatal("expected unparseable amount rejection") + } + if err := validateSplits([]paycore.Split{{Recipient: good, Amount: "0"}}); err == nil { + t.Fatal("expected zero amount rejection") + } + if err := validateSplits([]paycore.Split{ + {Recipient: good, Amount: "18446744073709551615"}, + {Recipient: other, Amount: "1"}, + }); err == nil { + t.Fatal("expected overflow rejection") + } + if err := validateSplits([]paycore.Split{{Recipient: good, Amount: "1"}, {Recipient: good, Amount: "2"}}); err == nil { + t.Fatal("expected duplicate recipient rejection") + } + overMax := make([]paycore.Split, maxSplits+1) + for i := range overMax { + overMax[i] = paycore.Split{Recipient: testutil.NewPrivateKey().PublicKey().String(), Amount: "1"} + } + if err := validateSplits(overMax); err == nil { + t.Fatal("expected count-over-max rejection") + } +} + +// --- #28 token program resolution at boot --- + +func TestNewResolvesTokenProgramForKnownStablecoin(t *testing.T) { + cfg := validConfig(t) + cfg.Currency = "PYUSD" + m, err := New(cfg) + if err != nil { + t.Fatalf("new: %v", err) + } + if m.tokenProgram != paycore.Token2022Program { + t.Fatalf("expected PYUSD to resolve to Token-2022, got %q", m.tokenProgram) + } +} + +func TestNewResolvesArbitraryMintFromChain(t *testing.T) { + cfg := validConfig(t) + mint := testutil.NewPrivateKey().PublicKey() + cfg.RPC.(*testutil.FakeRPC).MintOwners[mint.String()] = solana.MustPublicKeyFromBase58(paycore.Token2022Program) + cfg.Currency = mint.String() + m, err := New(cfg) + if err != nil { + t.Fatalf("new: %v", err) + } + if m.tokenProgram != paycore.Token2022Program { + t.Fatalf("expected on-chain Token-2022 owner, got %q", m.tokenProgram) + } +} + +func TestNewRejectsArbitraryMintWithUnknownOwner(t *testing.T) { + cfg := validConfig(t) + mint := testutil.NewPrivateKey().PublicKey() + // Owner is some random program, not Token / Token-2022. + cfg.RPC.(*testutil.FakeRPC).MintOwners[mint.String()] = testutil.NewPrivateKey().PublicKey() + cfg.Currency = mint.String() + if _, err := New(cfg); err == nil { + t.Fatal("expected mint with non-token owner to be rejected at boot") + } +} + +func TestChargeEmitsTokenProgramForArbitraryMint(t *testing.T) { + cfg := validConfig(t) + mint := testutil.NewPrivateKey().PublicKey() + cfg.RPC.(*testutil.FakeRPC).MintOwners[mint.String()] = solana.MustPublicKeyFromBase58(paycore.Token2022Program) + cfg.Currency = mint.String() + m, err := New(cfg) + if err != nil { + t.Fatalf("new: %v", err) + } + challenge, err := m.Charge(context.Background(), "1.000000") + if err != nil { + t.Fatalf("charge: %v", err) + } + var details paycore.MethodDetails + if err := decodeChallengeDetails(t, challenge, &details); err != nil { + t.Fatalf("decode details: %v", err) + } + if details.TokenProgram != paycore.Token2022Program { + t.Fatalf("expected challenge to carry Token-2022 program for arbitrary mint, got %q", details.TokenProgram) + } +} + +// --- #5 push mode opt-in --- + +func TestPushModeRejectedByDefault(t *testing.T) { + cfg := validConfig(t) + cfg.Currency = "sol" + cfg.Decimals = 9 + m, err := New(cfg) + if err != nil { + t.Fatalf("new: %v", err) + } + challenge, err := m.Charge(context.Background(), "0.001") + if err != nil { + t.Fatalf("charge: %v", err) + } + credential, err := core.NewPaymentCredential(challenge.ToEcho(), map[string]string{ + "type": "signature", + "signature": "5jKh25biPsnrmLWXXuqKNH2Q67Q4UmVVx8Gf2wrS6VoCeyfGE9wKikjY7Q1GQQgmpQ3xy7wJX5U1rcz82q4R8Nkv", + }) + if err != nil { + t.Fatalf("credential: %v", err) + } + var expected = mustDecodeRequest(t, challenge) + _, err = m.VerifyCredentialWithExpected(context.Background(), credential, expected) + if err == nil || !strings.Contains(err.Error(), "push mode") { + t.Fatalf("expected push mode to be rejected by default, got %v", err) + } +} diff --git a/go/protocols/mpp/server/defaults.go b/go/protocols/mpp/server/defaults.go index 457ebff77..ff56e3381 100644 --- a/go/protocols/mpp/server/defaults.go +++ b/go/protocols/mpp/server/defaults.go @@ -7,12 +7,22 @@ // cross-language harness exercises identical behavior. package server -import "os" +import ( + "crypto/sha256" + "encoding/binary" + "fmt" + "os" +) // DetectRealm checks environment variables for a suitable realm value. // It iterates through common platform-specific variables before falling -// back to the default realm. -func DetectRealm() string { +// back to a realm derived from the recipient pubkey. The recipient is +// unique per merchant, so two servers that share MPP_SECRET_KEY but pay +// different recipients get different realms (and therefore different HMAC +// challenge IDs), which closes the cross-service replay window that a fixed +// shared default realm would open. Mirrors the Rust reference +// derive_default_realm. +func DetectRealm(recipient string) string { for _, key := range []string{ "MPP_REALM", "FLY_APP_NAME", "HEROKU_APP_NAME", "RAILWAY_SERVICE_NAME", "RENDER_SERVICE_NAME", @@ -22,7 +32,17 @@ func DetectRealm() string { return v } } - return defaultRealm + return deriveDefaultRealm(recipient) +} + +// deriveDefaultRealm hashes the recipient with SHA-256, takes the first 4 +// bytes as a big-endian u32 mod 10^8, and formats it as "App Id - #". +// Deterministic (restart-safe) and human-friendly. Mirrors the Rust +// reference derive_default_realm. +func deriveDefaultRealm(recipient string) string { + sum := sha256.Sum256([]byte(recipient)) + n := binary.BigEndian.Uint32(sum[:4]) % 100_000_000 + return fmt.Sprintf("App Id - #%d", n) } // DetectSecretKey reads the MPP_SECRET_KEY environment variable. diff --git a/go/protocols/mpp/server/defaults_test.go b/go/protocols/mpp/server/defaults_test.go index 9dd669aca..9c68c39ec 100644 --- a/go/protocols/mpp/server/defaults_test.go +++ b/go/protocols/mpp/server/defaults_test.go @@ -1,6 +1,11 @@ package server -import "testing" +import ( + "strings" + "testing" +) + +const testRealmRecipient = "8aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890aBcDeF" func TestDetectRealmPriority(t *testing.T) { envVars := []string{ @@ -13,25 +18,26 @@ func TestDetectRealmPriority(t *testing.T) { t.Setenv(key, "") } - if got := DetectRealm(); got != defaultRealm { - t.Fatalf("expected default realm %q, got %q", defaultRealm, got) + want := deriveDefaultRealm(testRealmRecipient) + if got := DetectRealm(testRealmRecipient); got != want { + t.Fatalf("expected derived realm %q, got %q", want, got) } // HOSTNAME should be used when it's the only one set. t.Setenv("HOSTNAME", "my-host") - if got := DetectRealm(); got != "my-host" { + if got := DetectRealm(testRealmRecipient); got != "my-host" { t.Fatalf("expected HOSTNAME, got %q", got) } // FLY_APP_NAME takes priority over HOSTNAME. t.Setenv("FLY_APP_NAME", "my-fly-app") - if got := DetectRealm(); got != "my-fly-app" { + if got := DetectRealm(testRealmRecipient); got != "my-fly-app" { t.Fatalf("expected FLY_APP_NAME, got %q", got) } // MPP_REALM takes highest priority. t.Setenv("MPP_REALM", "custom-realm") - if got := DetectRealm(); got != "custom-realm" { + if got := DetectRealm(testRealmRecipient); got != "custom-realm" { t.Fatalf("expected MPP_REALM, got %q", got) } } @@ -46,8 +52,25 @@ func TestDetectRealmFallback(t *testing.T) { t.Setenv(key, "") } - if got := DetectRealm(); got != defaultRealm { - t.Fatalf("expected %q, got %q", defaultRealm, got) + want := deriveDefaultRealm(testRealmRecipient) + if got := DetectRealm(testRealmRecipient); got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestDeriveDefaultRealm(t *testing.T) { + realm := deriveDefaultRealm(testRealmRecipient) + if !strings.HasPrefix(realm, "App Id - #") { + t.Fatalf("expected derived realm to start with %q, got %q", "App Id - #", realm) + } + // Deterministic for the same recipient (restart-safe). + if again := deriveDefaultRealm(testRealmRecipient); again != realm { + t.Fatalf("derive not deterministic: %q != %q", realm, again) + } + // Differs across recipients (closes the cross-service replay threat). + other := deriveDefaultRealm("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + if other == realm { + t.Fatalf("expected distinct realms for distinct recipients, both %q", realm) } } diff --git a/go/protocols/mpp/server/guards_test.go b/go/protocols/mpp/server/guards_test.go index 62376d50e..35c1507ef 100644 --- a/go/protocols/mpp/server/guards_test.go +++ b/go/protocols/mpp/server/guards_test.go @@ -113,7 +113,7 @@ func TestValidateComputeBudgetInstructions(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { tx := buildTxWithComputeBudgetIx(t, tc.data) - err := validateComputeBudgetInstructions(tx) + err := validateComputeBudgetInstructions(tx, false) if tc.wantErr { if err == nil { t.Fatalf("expected error, got nil") @@ -139,6 +139,26 @@ func TestValidateComputeBudgetInstructions(t *testing.T) { } } +// #25: in fee-sponsored mode the tight price cap applies; in client-paid mode +// the general 5M cap still applies. +func TestComputeUnitPriceFeeSponsoredCap(t *testing.T) { + // Just under the tight cap: accepted in fee-sponsored mode. + tx := buildTxWithComputeBudgetIx(t, encodeUnitPrice(maxComputeUnitPriceMicroLamportsFeeSponsored)) + if err := validateComputeBudgetInstructions(tx, true); err != nil { + t.Fatalf("at-tight-cap price should pass fee-sponsored: %v", err) + } + // One over the tight cap: rejected in fee-sponsored mode. + tx = buildTxWithComputeBudgetIx(t, encodeUnitPrice(maxComputeUnitPriceMicroLamportsFeeSponsored+1)) + if err := validateComputeBudgetInstructions(tx, true); err == nil { + t.Fatal("above-tight-cap price should be rejected fee-sponsored") + } + // The same above-tight value is fine in client-paid mode (general 5M cap). + tx = buildTxWithComputeBudgetIx(t, encodeUnitPrice(maxComputeUnitPriceMicroLamportsFeeSponsored+1)) + if err := validateComputeBudgetInstructions(tx, false); err != nil { + t.Fatalf("tight cap must not apply when client pays: %v", err) + } +} + // TestValidateComputeBudgetInstructions_NonComputeBudgetIgnored confirms // that instructions targeting other programs do not trip the validator. func TestValidateComputeBudgetInstructions_NonComputeBudgetIgnored(t *testing.T) { @@ -151,7 +171,7 @@ func TestValidateComputeBudgetInstructions_NonComputeBudgetIgnored(t *testing.T) }, }, } - if err := validateComputeBudgetInstructions(tx); err != nil { + if err := validateComputeBudgetInstructions(tx, false); err != nil { t.Fatalf("expected nil error for non-compute-budget instruction, got %v", err) } } @@ -228,7 +248,7 @@ func TestValidateComputeBudgetInstructions_OutOfRangeProgramIndex(t *testing.T) }, }, } - err := validateComputeBudgetInstructions(tx) + err := validateComputeBudgetInstructions(tx, false) if err == nil { t.Fatal("expected error for out-of-range ProgramIDIndex") } @@ -252,7 +272,7 @@ func TestValidateComputeBudgetInstructions_RejectsAccounts(t *testing.T) { }, }, } - err := validateComputeBudgetInstructions(tx) + err := validateComputeBudgetInstructions(tx, false) if err == nil { t.Fatal("expected error for compute-budget instruction with accounts") } diff --git a/go/protocols/mpp/server/parity_test.go b/go/protocols/mpp/server/parity_test.go index 8f536370a..5e7234de5 100644 --- a/go/protocols/mpp/server/parity_test.go +++ b/go/protocols/mpp/server/parity_test.go @@ -166,7 +166,7 @@ func TestVerifyRejectsATARequiredWithSymbolCurrency(t *testing.T) { func TestChargeRejectsATARequiredOnSOL(t *testing.T) { m := &Mpp{currency: "SOL", network: "mainnet-beta"} err := m.validateChargeOptions(ChargeOptions{ - Splits: []paycore.Split{{Recipient: "x", Amount: "1", AtaCreationRequired: boolp(true)}}, + Splits: []paycore.Split{{Recipient: testutil.NewPrivateKey().PublicKey().String(), Amount: "1", AtaCreationRequired: boolp(true)}}, }) if err == nil || !strings.Contains(err.Error(), "SPL token currency") { t.Fatalf("expected SOL ataCreationRequired rejection, got %v", err) @@ -178,7 +178,7 @@ func TestChargeRejectsATARequiredOnSOL(t *testing.T) { func TestChargeRejectsATARequiredOnSymbolCurrency(t *testing.T) { m := &Mpp{currency: "USDC", network: "mainnet-beta"} err := m.validateChargeOptions(ChargeOptions{ - Splits: []paycore.Split{{Recipient: "x", Amount: "1", AtaCreationRequired: boolp(true)}}, + Splits: []paycore.Split{{Recipient: testutil.NewPrivateKey().PublicKey().String(), Amount: "1", AtaCreationRequired: boolp(true)}}, }) if err == nil || !strings.Contains(err.Error(), "SPL token mint address") { t.Fatalf("expected symbol ataCreationRequired rejection, got %v", err) diff --git a/go/protocols/mpp/server/server.go b/go/protocols/mpp/server/server.go index a274aa79a..b66da9ba8 100644 --- a/go/protocols/mpp/server/server.go +++ b/go/protocols/mpp/server/server.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "math/bits" "os" "strings" "time" @@ -21,10 +22,15 @@ import ( ) const ( - defaultRealm = "MPP Payment" secretKeyEnvVar = "MPP_SECRET_KEY" consumedPrefix = "solana-charge:consumed:" + // minSecretKeyBytes is the minimum length of the HMAC-SHA256 secret + // that binds challenge IDs. 32 bytes matches NIST SP 800-107 guidance + // (key length >= hash output length) and the Rust reference's + // MIN_SECRET_KEY_BYTES. A weaker key lets an attacker forge challenges. + minSecretKeyBytes = 32 + // maxSplits caps the number of secondary recipients per charge. // Matches the limit enforced by every other server SDK (see the // rust reference in rust/src/server/charge.rs and the typescript @@ -38,6 +44,16 @@ const ( // pathological resource footprint. maxComputeUnitLimit uint32 = 200_000 maxComputeUnitPriceMicroLamports uint64 = 5_000_000 + + // maxComputeUnitPriceMicroLamportsFeeSponsored is the tight cap applied + // when the server is the fee payer. The server co-signs/broadcasts before + // it pays, so an attacker could otherwise pick any price up to the general + // 5M cap and bill the priority fee to the merchant. At limit 200_000 the + // worst-case priority fee is ceil(10_000 * 200_000 / 1_000_000) = 2_000 + // lamports (~20% of the per-signature base fee) — enough headroom for an + // honest client to bump priority during congestion. Mirrors the Rust + // reference MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED. + maxComputeUnitPriceMicroLamportsFeeSponsored uint64 = 10_000 ) // computeBudgetProgramID is the on-chain ID of the ComputeBudget program. @@ -56,6 +72,14 @@ type Config struct { FeePayerSigner solanatx.Signer Store core.Store RPC solanatx.RPCClient + + // AcceptPushMode opts in to accepting type="signature" (push mode) + // credentials, where the client broadcasts the transaction itself and + // presents only the resulting signature. Default false. Per spec §13.5 + // push mode trades "first accepted presentation wins" semantics that a + // server may not want to take on; leave it off unless the deployment + // understands that trade-off. + AcceptPushMode bool } // ChargeOptions customize challenge generation. @@ -81,6 +105,14 @@ type Mpp struct { html bool feePayerSigner solanatx.Signer store core.Store + // tokenProgram is the SPL token program this server's currency resolves + // to, pinned once at New() time. Empty for native SOL. For arbitrary + // mint-address currencies it is the on-chain mint owner; New() rejects a + // mint whose owner is neither Token nor Token-2022. Mirrors the Rust + // reference's boot-time resolve_server_token_program. + tokenProgram string + // acceptPushMode gates type="signature" credentials (spec §13.5). + acceptPushMode bool } // New creates a new server-side handler. @@ -98,6 +130,15 @@ func New(config Config) (*Mpp, error) { if config.SecretKey == "" { return nil, core.NewError(core.ErrCodeInvalidConfig, "missing secret key") } + // The secret key is the HMAC-SHA256 key binding challenge IDs; a weak key + // lets an attacker forge challenges. Enforce a 32-byte floor on BOTH the + // Config.SecretKey and the MPP_SECRET_KEY env-var paths (they share this + // gate because the env value is folded into config.SecretKey above). + if len(config.SecretKey) < minSecretKeyBytes { + return nil, core.NewError(core.ErrCodeInvalidConfig, + fmt.Sprintf("secret key must be at least %d bytes (got %d); use e.g. `openssl rand -base64 32`", + minSecretKeyBytes, len(config.SecretKey))) + } if config.Currency == "" { config.Currency = "USDC" } @@ -105,10 +146,20 @@ func New(config Config) (*Mpp, error) { config.Decimals = 6 } if config.Network == "" { - config.Network = "mainnet-beta" + config.Network = string(paycore.NetworkMainnet) } + // Reject unknown network slugs (and the legacy "mainnet-beta" spelling) at + // boot rather than silently resolving them to mainnet mints downstream. + canonicalNetwork, err := paycore.RequireKnownNetwork(config.Network) + if err != nil { + return nil, core.WrapError(core.ErrCodeInvalidConfig, "invalid network", err) + } + config.Network = string(canonicalNetwork) + // Derive a per-recipient default realm when none is configured (and reject + // an explicitly-empty realm). A shared literal default would let two + // servers sharing MPP_SECRET_KEY participate in one credential namespace. if config.Realm == "" { - config.Realm = DetectRealm() + config.Realm = DetectRealm(config.Recipient) } rpcURL := config.RPCURL if rpcURL == "" { @@ -120,6 +171,14 @@ func New(config Config) (*Mpp, error) { if config.Store == nil { config.Store = core.NewMemoryStore() } + // Resolve the token program once at boot. Known stablecoins answer from + // the static table; an arbitrary mint address is looked up on-chain and + // rejected if its owner is neither Token nor Token-2022. Native SOL has no + // token program. Mirrors the Rust reference resolve_server_token_program. + tokenProgram, err := resolveServerTokenProgram(context.Background(), config.RPC, config.Currency, config.Network) + if err != nil { + return nil, err + } return &Mpp{ rpc: config.RPC, secretKey: config.SecretKey, @@ -132,9 +191,39 @@ func New(config Config) (*Mpp, error) { html: config.HTML, feePayerSigner: config.FeePayerSigner, store: config.Store, + tokenProgram: tokenProgram, + acceptPushMode: config.AcceptPushMode, }, nil } +// resolveServerTokenProgram pins the SPL token program for the server's +// configured currency at boot. SOL has no token program (returns ""). A known +// stablecoin symbol or mint answers from the static table. An arbitrary mint +// address is resolved by fetching its on-chain owner; the owner MUST be the +// Token or Token-2022 program or boot fails. This stops the SDK from silently +// defaulting an arbitrary Token-2022 mint to legacy Token. Mirrors the Rust +// reference's resolve_server_token_program in Mpp::new. +func resolveServerTokenProgram(ctx context.Context, rpcClient solanatx.RPCClient, currency, network string) (string, error) { + if isNativeSOL(currency) { + return "", nil + } + if paycore.StablecoinSymbol(currency) != "" { + return paycore.DefaultTokenProgramForCurrency(currency, network), nil + } + // Arbitrary mint address: parse it and fetch the on-chain owner. + mint, err := solana.PublicKeyFromBase58(currency) + if err != nil { + return "", core.WrapError(core.ErrCodeInvalidConfig, + "currency must be a known stablecoin symbol or a valid SPL mint address", err) + } + program, err := solanatx.ResolveTokenProgram(ctx, rpcClient, mint, "") + if err != nil { + return "", core.WrapError(core.ErrCodeInvalidConfig, + "could not resolve token program for mint (owner must be Token or Token-2022)", err) + } + return program.String(), nil +} + // Charge creates a charge challenge from a human-readable amount. func (m *Mpp) Charge(ctx context.Context, amount string) (core.PaymentChallenge, error) { return m.ChargeWithOptions(ctx, amount, ChargeOptions{}) @@ -146,6 +235,33 @@ func (m *Mpp) Charge(ctx context.Context, amount string) (core.PaymentChallenge, // (charge.rs:307-335). Idempotent ATA creation is only meaningful for an // SPL token whose mint is known to the verifier. func (m *Mpp) validateChargeOptions(options ChargeOptions) error { + // #16: reject a per-call fee-payer override when no signer is configured. + // Emitting feePayer:true with no feePayerKey would be spec-violating + // (§7.2). Mirrors the Rust reference validate_charge_options gate. + if options.FeePayer && m.feePayerSigner == nil { + return core.NewError(core.ErrCodeInvalidConfig, + "fee payer sponsorship requires a configured FeePayerSigner") + } + // #21: validate the split set at issuance (count, recipient parse, + // positive amount, no overflow, no duplicate recipients) so invalid + // splits are rejected here rather than surfacing only at on-chain + // settlement. Mirrors the Rust reference validate_splits. + if err := validateSplits(options.Splits); err != nil { + return err + } + // #38: reject the fee-sponsored ATA-recreate drain shape — a split paid to + // the primary recipient with ataCreationRequired=true. This combination + // lets a server author a challenge that funds (re)creation of the primary + // recipient's ATA, exploitable as a slow drain by closing/recreating. + // Mirrors the Rust reference's early loop in validate_charge_options. + primary := m.recipient.String() + for _, split := range options.Splits { + if split.Recipient == primary && split.AtaCreationRequired != nil && *split.AtaCreationRequired { + return core.NewError(core.ErrCodeInvalidConfig, + "a split paid to the primary recipient must not set ataCreationRequired (fee-sponsored ATA-recreate drain)") + } + } + hasATACreation := false for _, split := range options.Splits { if split.AtaCreationRequired != nil && *split.AtaCreationRequired { @@ -170,6 +286,45 @@ func (m *Mpp) validateChargeOptions(options ChargeOptions) error { return nil } +// validateSplits validates a split set at challenge issuance: count must not +// exceed maxSplits, each recipient must parse as a pubkey, each amount must +// parse as a positive uint64, the aggregate must not overflow uint64, and no +// recipient may appear twice. Mirrors the Rust reference validate_splits in +// protocol/solana.rs. Invalid splits caught here would otherwise surface only +// at on-chain settlement. +func validateSplits(splits []paycore.Split) error { + if err := validateSplitsCount(splits); err != nil { + return err + } + seen := make(map[string]struct{}, len(splits)) + var total uint64 + for _, split := range splits { + if _, err := solana.PublicKeyFromBase58(split.Recipient); err != nil { + return core.NewError(core.ErrCodeInvalidPayload, + fmt.Sprintf("invalid split recipient %q: %v", split.Recipient, err)) + } + amount, err := intents.ChargeRequest{Amount: split.Amount}.ParseAmount() + if err != nil { + return core.NewError(core.ErrCodeInvalidPayload, + fmt.Sprintf("invalid split amount %q: %v", split.Amount, err)) + } + if amount == 0 { + return core.NewError(core.ErrCodeInvalidPayload, "split amount must be greater than zero") + } + sum, carry := bits.Add64(total, amount, 0) + if carry != 0 { + return core.NewError(core.ErrCodeInvalidPayload, "split amounts overflow uint64") + } + total = sum + if _, dup := seen[split.Recipient]; dup { + return core.NewError(core.ErrCodeInvalidPayload, + fmt.Sprintf("duplicate split recipient %q", split.Recipient)) + } + seen[split.Recipient] = struct{}{} + } + return nil +} + // ChargeWithOptions creates a challenge with optional fields. func (m *Mpp) ChargeWithOptions(ctx context.Context, amount string, options ChargeOptions) (core.PaymentChallenge, error) { if err := m.validateChargeOptions(options); err != nil { @@ -184,8 +339,13 @@ func (m *Mpp) ChargeWithOptions(ctx context.Context, amount string, options Char } if !isNativeSOL(m.currency) { details.Decimals = &m.decimals - if paycore.StablecoinSymbol(m.currency) != "" { - details.TokenProgram = paycore.DefaultTokenProgramForCurrency(m.currency, m.network) + // Emit the token program resolved once at boot (#28). For known + // stablecoins this is the static-table value; for an arbitrary mint + // it is the on-chain owner that New() verified is Token/Token-2022. + // This stops arbitrary Token-2022 mints from shipping with no/legacy + // token program. Mirrors the Rust reference. + if m.tokenProgram != "" { + details.TokenProgram = m.tokenProgram } } if options.FeePayer || m.feePayerSigner != nil { @@ -229,33 +389,19 @@ func (m *Mpp) ChargeWithOptions(ctx context.Context, amount string, options Char ), nil } -// VerifyCredential verifies either a transaction payload or a signature payload. -// -// This is the simple API and is appropriate for servers that only gate a single -// route. Servers that gate multiple routes at different prices on the same -// secret key MUST use VerifyCredentialWithExpected so the route's expected -// amount is compared to the credential's claimed amount; otherwise a -// credential issued for a cheaper route can be replayed at an expensive one. -// -// Even on the simple API, a Tier-2 pinned-field check enforces that the -// credential's method/intent/realm/currency/recipient match this Mpp's -// configuration — so cross-route replay across instances with different -// recipients/currencies is blocked, and only the per-call amount remains -// unpinned (which is what VerifyCredentialWithExpected covers). -func (m *Mpp) VerifyCredential(ctx context.Context, credential core.PaymentCredential) (core.Receipt, error) { - request, details, payload, err := m.verifyChallengeAndDecode(credential) - if err != nil { - return core.Receipt{}, err - } - return m.verifyPayload(ctx, credential, request, details, payload) -} - // VerifyCredentialWithExpected verifies a credential against the route's -// expected charge request. The amount, currency, and recipient on the -// credential's claimed challenge must match `expected`; afterward, settlement -// (transaction broadcast and on-chain checks) runs against `expected` — -// not against the credential's claims — so a credential built for a different -// route's request cannot succeed even if its other fields line up. +// expected charge request. This is the canonical settlement entry point: the +// simple VerifyCredential method was removed (audit #2) because it verified +// against the credential's own echoed amount, so a server with more than one +// priced route on a shared secret would accept a cheap credential at an +// expensive route. Callers MUST build `expected` from their static route +// configuration, not from the credential. +// +// The amount, currency, and recipient on the credential's claimed challenge +// must match `expected`; afterward, settlement (transaction broadcast and +// on-chain checks) runs against `expected` — not against the credential's +// claims — so a credential built for a different route's request cannot +// succeed even if its other fields line up. func (m *Mpp) VerifyCredentialWithExpected( ctx context.Context, credential core.PaymentCredential, @@ -396,6 +542,14 @@ func (m *Mpp) verifyPayload( case "transaction": return m.verifyTransaction(ctx, credential, request, details, payload) case "signature": + // #5: push mode (client broadcasts, presents only the signature) is + // off by default. Per spec §13.5 it carries "first accepted + // presentation wins" semantics a server must opt in to via + // Config.AcceptPushMode. + if !m.acceptPushMode { + return core.Receipt{}, core.NewError(core.ErrCodeInvalidPayload, + `type="signature" (push mode) credentials are not accepted; set Config.AcceptPushMode to enable (spec §13.5)`) + } if details.FeePayer != nil && *details.FeePayer { return core.Receipt{}, core.NewError(core.ErrCodeInvalidPayload, `type="signature" credentials cannot be used with fee sponsorship`) } @@ -431,7 +585,13 @@ func (m *Mpp) verifyTransaction( if len(tx.Message.AddressTableLookups) > 0 { return core.Receipt{}, core.NewError(core.ErrCodeInvalidPayload, "v0 transactions with address lookup tables are not supported") } - if err := validateComputeBudgetInstructions(tx); err != nil { + // The server is the fee payer precisely when a fee-payer signer is + // configured and the challenge pinned feePayer:true. In that mode apply + // the tight compute-unit-price cap (#25): the server co-signs/broadcasts + // before paying, so an unconstrained price would bill the priority fee to + // the merchant. Client-paid mode keeps the general 5M cap. + feeSponsored := m.feePayerSigner != nil && details.FeePayer != nil && *details.FeePayer + if err := validateComputeBudgetInstructions(tx, feeSponsored); err != nil { return core.Receipt{}, err } // Reject up-front if the client signed against the wrong network @@ -1117,7 +1277,11 @@ func resolveProgramID(tx *solana.Transaction, programIDIndex uint16) (solana.Pub // - discriminator 3 + u64 LE => SetComputeUnitPrice // // Matches rust/src/server/charge.rs validate_compute_budget_instruction. -func validateComputeBudgetInstructions(tx *solana.Transaction) error { +func validateComputeBudgetInstructions(tx *solana.Transaction, feeSponsored bool) error { + priceCap := maxComputeUnitPriceMicroLamports + if feeSponsored { + priceCap = maxComputeUnitPriceMicroLamportsFeeSponsored + } for _, ix := range tx.Message.Instructions { programID, err := resolveProgramID(tx, ix.ProgramIDIndex) if err != nil { @@ -1163,10 +1327,10 @@ func validateComputeBudgetInstructions(tx *solana.Transaction) error { } price := uint64(data[1]) | uint64(data[2])<<8 | uint64(data[3])<<16 | uint64(data[4])<<24 | uint64(data[5])<<32 | uint64(data[6])<<40 | uint64(data[7])<<48 | uint64(data[8])<<56 - if price > maxComputeUnitPriceMicroLamports { + if price > priceCap { return core.NewError( core.ErrCodeComputeBudgetExceeded, - fmt.Sprintf("compute unit price %d exceeds maximum %d", price, maxComputeUnitPriceMicroLamports), + fmt.Sprintf("compute unit price %d exceeds maximum %d", price, priceCap), ) } default: diff --git a/go/protocols/mpp/server/server_cross_route_test.go b/go/protocols/mpp/server/server_cross_route_test.go index 0425b7306..d25f5ff2d 100644 --- a/go/protocols/mpp/server/server_cross_route_test.go +++ b/go/protocols/mpp/server/server_cross_route_test.go @@ -61,7 +61,7 @@ func TestVerifyCredentialTier2RejectsTamperedRealm(t *testing.T) { resignEcho(cfg.SecretKey, &echo) cred := signatureCredentialFromEcho(t, echo) - _, err = handler.VerifyCredential(context.Background(), cred) + _, err = verifyCredentialEchoed(handler, context.Background(), cred) if err == nil { t.Fatalf("expected Tier-2 to reject tampered realm") } @@ -81,7 +81,7 @@ func TestVerifyCredentialTier2RejectsTamperedMethod(t *testing.T) { resignEcho(cfg.SecretKey, &echo) cred := signatureCredentialFromEcho(t, echo) - _, err = handler.VerifyCredential(context.Background(), cred) + _, err = verifyCredentialEchoed(handler, context.Background(), cred) if err == nil || !strings.Contains(strings.ToLower(err.Error()), "method") { t.Fatalf("expected method error, got: %v", err) } @@ -98,7 +98,7 @@ func TestVerifyCredentialTier2RejectsNonChargeIntent(t *testing.T) { resignEcho(cfg.SecretKey, &echo) cred := signatureCredentialFromEcho(t, echo) - _, err = handler.VerifyCredential(context.Background(), cred) + _, err = verifyCredentialEchoed(handler, context.Background(), cred) if err == nil || !strings.Contains(strings.ToLower(err.Error()), "intent") { t.Fatalf("expected intent error, got: %v", err) } @@ -125,7 +125,7 @@ func TestVerifyCredentialTier2RejectsTamperedCurrency(t *testing.T) { resignEcho(cfg.SecretKey, &echo) cred := signatureCredentialFromEcho(t, echo) - _, err = handler.VerifyCredential(context.Background(), cred) + _, err = verifyCredentialEchoed(handler, context.Background(), cred) if err == nil || !strings.Contains(strings.ToLower(err.Error()), "currency") { t.Fatalf("expected currency error, got: %v", err) } @@ -152,7 +152,7 @@ func TestVerifyCredentialTier2RejectsTamperedRecipient(t *testing.T) { resignEcho(cfg.SecretKey, &echo) cred := signatureCredentialFromEcho(t, echo) - _, err = handler.VerifyCredential(context.Background(), cred) + _, err = verifyCredentialEchoed(handler, context.Background(), cred) if err == nil || !strings.Contains(strings.ToLower(err.Error()), "recipient") { t.Fatalf("expected recipient error, got: %v", err) } @@ -229,6 +229,21 @@ func TestVerifyCredentialWithExpectedAcceptsMatchingRoute(t *testing.T) { } } +// verifyCredentialEchoed mirrors the behavior of the removed Mpp.VerifyCredential +// convenience method (audit #2): it decodes the credential's own echoed request +// and verifies against it. This is only safe in tests that construct the +// credential themselves with known values; production callers MUST use +// VerifyCredentialWithExpected with an expected request built from static route +// config. The Tier-2 pinned-field backstop runs before the expected comparison, +// so tamper tests still exercise it through this helper. +func verifyCredentialEchoed(handler *Mpp, ctx context.Context, credential core.PaymentCredential) (core.Receipt, error) { + var request intents.ChargeRequest + if err := credential.Challenge.Request.Decode(&request); err != nil { + return core.Receipt{}, err + } + return handler.VerifyCredentialWithExpected(ctx, credential, request) +} + // mppErrAs is a small wrapper to avoid pulling errors.As into every test. func mppErrAs(err error, target **core.Error) bool { if err == nil { diff --git a/go/protocols/mpp/server/server_replay_durability_test.go b/go/protocols/mpp/server/server_replay_durability_test.go index e18d63455..f2b999384 100644 --- a/go/protocols/mpp/server/server_replay_durability_test.go +++ b/go/protocols/mpp/server/server_replay_durability_test.go @@ -44,7 +44,7 @@ func TestReplayMarkerRetainedOnConfirmationTimeout(t *testing.T) { Currency: "sol", Decimals: 9, Network: "localnet", - SecretKey: "test-secret", + SecretKey: "test-secret-key-0123456789abcdef", RPC: rpcClient, Store: core.NewMemoryStore(), }) @@ -68,7 +68,7 @@ func TestReplayMarkerRetainedOnConfirmationTimeout(t *testing.T) { // First attempt: broadcast succeeds, confirmation times out. ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) defer cancel() - if _, err := handler.VerifyCredential(ctx, credential); err == nil { + if _, err := verifyCredentialEchoed(handler, ctx, credential); err == nil { t.Fatal("expected confirmation timeout to surface an error") } @@ -79,7 +79,7 @@ func TestReplayMarkerRetainedOnConfirmationTimeout(t *testing.T) { // Second attempt with the SAME credential MUST be rejected: the marker // must still be reserved because the original broadcast may land. - _, err = handler.VerifyCredential(context.Background(), credential) + _, err = verifyCredentialEchoed(handler, context.Background(), credential) if err == nil { t.Fatal("expected re-submission after broadcast+timeout to be rejected as consumed") } diff --git a/go/protocols/mpp/server/server_test.go b/go/protocols/mpp/server/server_test.go index 023f9429d..7820a55e6 100644 --- a/go/protocols/mpp/server/server_test.go +++ b/go/protocols/mpp/server/server_test.go @@ -32,7 +32,7 @@ func newTestMpp(t *testing.T) (*Mpp, *testutil.FakeRPC, testutilConfig) { cfg := testutilConfig{ Recipient: recipientSigner.PublicKey().String(), Client: testutil.NewPrivateKey(), - SecretKey: "test-secret", + SecretKey: "test-secret-key-0123456789abcdef", } handler, err := New(Config{ Recipient: cfg.Recipient, @@ -42,6 +42,9 @@ func newTestMpp(t *testing.T) (*Mpp, *testutil.FakeRPC, testutilConfig) { SecretKey: cfg.SecretKey, RPC: rpcClient, Store: core.NewMemoryStore(), + // Push-mode (type="signature") credentials are opt-in (#5); the shared + // fixture enables them so the signature-flow tests exercise settlement. + AcceptPushMode: true, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -96,7 +99,7 @@ func TestVerifyCredentialTransactionSuccess(t *testing.T) { if err != nil { t.Fatalf("parse authorization failed: %v", err) } - receipt, err := handler.VerifyCredential(context.Background(), credential) + receipt, err := verifyCredentialEchoed(handler, context.Background(), credential) if err != nil { t.Fatalf("verify failed: %v", err) } @@ -119,10 +122,10 @@ func TestVerifyCredentialSignatureReplayRejected(t *testing.T) { if err != nil { t.Fatalf("parse authorization failed: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err != nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err != nil { t.Fatalf("first verify failed: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected replay to be rejected") } } @@ -141,10 +144,10 @@ func TestVerifyCredentialTransactionReplayRejected(t *testing.T) { if err != nil { t.Fatalf("parse authorization failed: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err != nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err != nil { t.Fatalf("first verify failed: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected replay to be rejected") } } @@ -158,7 +161,7 @@ func TestVerifyCredentialRejectsSponsoredPushMode(t *testing.T) { Currency: "sol", Decimals: 9, Network: "localnet", - SecretKey: "test-secret", + SecretKey: "test-secret-key-0123456789abcdef", RPC: rpcClient, Store: core.NewMemoryStore(), FeePayerSigner: feePayer, @@ -177,7 +180,7 @@ func TestVerifyCredentialRejectsSponsoredPushMode(t *testing.T) { if err != nil { t.Fatalf("credential failed: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected sponsored push mode to fail") } } @@ -189,13 +192,14 @@ func TestVerifyCredentialTokenSignatureSuccess(t *testing.T) { mint := testutil.NewPrivateKey().PublicKey() rpcClient.MintOwners[mint.String()] = solana.TokenProgramID handler, err := New(Config{ - Recipient: recipient.PublicKey().String(), - Currency: mint.String(), - Decimals: 6, - Network: "localnet", - SecretKey: "test-secret", - RPC: rpcClient, - Store: core.NewMemoryStore(), + Recipient: recipient.PublicKey().String(), + Currency: mint.String(), + Decimals: 6, + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: rpcClient, + Store: core.NewMemoryStore(), + AcceptPushMode: true, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -212,7 +216,7 @@ func TestVerifyCredentialTokenSignatureSuccess(t *testing.T) { if err != nil { t.Fatalf("parse authorization failed: %v", err) } - receipt, err := handler.VerifyCredential(context.Background(), credential) + receipt, err := verifyCredentialEchoed(handler, context.Background(), credential) if err != nil { t.Fatalf("verify failed: %v", err) } @@ -228,13 +232,14 @@ func TestVerifyCredentialUSDCSymbolSignatureSuccess(t *testing.T) { usdcMint := solana.MustPublicKeyFromBase58("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") rpcClient.MintOwners[usdcMint.String()] = solana.TokenProgramID handler, err := New(Config{ - Recipient: recipient.PublicKey().String(), - Currency: "USDC", - Decimals: 6, - Network: "localnet", - SecretKey: "test-secret", - RPC: rpcClient, - Store: core.NewMemoryStore(), + Recipient: recipient.PublicKey().String(), + Currency: "USDC", + Decimals: 6, + Network: "localnet", + SecretKey: "test-secret-key-0123456789abcdef", + RPC: rpcClient, + Store: core.NewMemoryStore(), + AcceptPushMode: true, }) if err != nil { t.Fatalf("new mpp failed: %v", err) @@ -251,7 +256,7 @@ func TestVerifyCredentialUSDCSymbolSignatureSuccess(t *testing.T) { if err != nil { t.Fatalf("parse authorization failed: %v", err) } - receipt, err := handler.VerifyCredential(context.Background(), credential) + receipt, err := verifyCredentialEchoed(handler, context.Background(), credential) if err != nil { t.Fatalf("verify failed: %v", err) } @@ -519,7 +524,7 @@ func TestVerifyCredentialExpiredChallengeRejected(t *testing.T) { if err != nil { t.Fatalf("credential failed: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected expired challenge to fail") } } @@ -545,18 +550,29 @@ func TestNewMissingSecretKey(t *testing.T) { } func TestNewSecretKeyFromEnv(t *testing.T) { - t.Setenv("MPP_SECRET_KEY", "env-secret") + const envSecret = "env-secret-key-0123456789abcdef012345" + t.Setenv("MPP_SECRET_KEY", envSecret) recipient := testutil.NewPrivateKey().PublicKey().String() rpcClient := testutil.NewFakeRPC() handler, err := New(Config{Recipient: recipient, RPC: rpcClient}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if handler.secretKey != "env-secret" { + if handler.secretKey != envSecret { t.Fatalf("expected env secret, got %q", handler.secretKey) } } +func TestNewRejectsShortEnvSecretKey(t *testing.T) { + // The env-var path shares the >= 32-byte gate with Config.SecretKey (#24). + t.Setenv("MPP_SECRET_KEY", "too-short") + recipient := testutil.NewPrivateKey().PublicKey().String() + rpcClient := testutil.NewFakeRPC() + if _, err := New(Config{Recipient: recipient, RPC: rpcClient}); err == nil { + t.Fatal("expected error for short env secret key") + } +} + func TestChargeToken(t *testing.T) { rpcClient := testutil.NewFakeRPC() recipient := testutil.NewPrivateKey().PublicKey().String() @@ -565,7 +581,7 @@ func TestChargeToken(t *testing.T) { Currency: "USDC", Decimals: 6, Network: "localnet", - SecretKey: "test-secret", + SecretKey: "test-secret-key-0123456789abcdef", RPC: rpcClient, Store: core.NewMemoryStore(), }) @@ -606,10 +622,12 @@ func TestChargeWithOptionsDescriptionAndExternalID(t *testing.T) { func TestChargeWithOptionsSplits(t *testing.T) { handler, _, _ := newTestMpp(t) + vendor := testutil.NewPrivateKey().PublicKey().String() + processor := testutil.NewPrivateKey().PublicKey().String() challenge, err := handler.ChargeWithOptions(context.Background(), "1.00", ChargeOptions{ Splits: []paycore.Split{ - {Recipient: "VendorPayoutsWaLLetxxxxxxxxxxxxxxxxxxxxxx1111", Amount: "500000", Memo: "Vendor payout"}, - {Recipient: "ProcessorFeeWaLLetxxxxxxxxxxxxxxxxxxxxxxx1111", Amount: "29000"}, + {Recipient: vendor, Amount: "500000", Memo: "Vendor payout"}, + {Recipient: processor, Amount: "29000"}, }, }) if err != nil { @@ -668,7 +686,7 @@ func TestVerifyCredentialMissingPayloadType(t *testing.T) { if err != nil { t.Fatalf("credential failed: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected error for missing payload type") } } @@ -683,7 +701,7 @@ func TestVerifyCredentialMissingTransactionData(t *testing.T) { if err != nil { t.Fatalf("credential failed: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected error for missing transaction data") } } @@ -698,7 +716,7 @@ func TestVerifyCredentialSimulationFailure(t *testing.T) { credential, _ := core.ParseAuthorization(authHeader) // Make simulation fail rpcClient.SimulateErr = fmt.Errorf("simulation failed") - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected error for simulation failure") } } @@ -712,7 +730,7 @@ func TestVerifyCredentialSendFailure(t *testing.T) { } credential, _ := core.ParseAuthorization(authHeader) rpcClient.SendErr = fmt.Errorf("send failed") - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected error for send failure") } } @@ -726,7 +744,7 @@ func TestVerifyCredentialGetTxFailure(t *testing.T) { Currency: "sol", Decimals: 9, Network: "localnet", - SecretKey: "test-secret", + SecretKey: "test-secret-key-0123456789abcdef", RPC: rpcClient, Store: core.NewMemoryStore(), }) @@ -742,7 +760,7 @@ func TestVerifyCredentialGetTxFailure(t *testing.T) { credential, _ := core.ParseAuthorization(authHeader) // Make GetTransaction fail rpcClient.GetTxErr = fmt.Errorf("transaction not found") - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected error for get transaction failure") } } @@ -757,7 +775,7 @@ func TestVerifyCredentialMissingSignature(t *testing.T) { if err != nil { t.Fatalf("credential failed: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected error for missing signature") } } @@ -771,7 +789,7 @@ func TestChargeWithFeePayer(t *testing.T) { Currency: "sol", Decimals: 9, Network: "localnet", - SecretKey: "test-secret", + SecretKey: "test-secret-key-0123456789abcdef", RPC: rpcClient, Store: core.NewMemoryStore(), FeePayerSigner: feePayer, @@ -804,7 +822,7 @@ func TestNewWithDefaultValues(t *testing.T) { recipient := testutil.NewPrivateKey().PublicKey().String() handler, err := New(Config{ Recipient: recipient, - SecretKey: "test-secret", + SecretKey: "test-secret-key-0123456789abcdef", RPC: rpcClient, }) if err != nil { @@ -816,8 +834,8 @@ func TestNewWithDefaultValues(t *testing.T) { if handler.decimals != 6 { t.Fatalf("expected default decimals 6, got %d", handler.decimals) } - if handler.network != "mainnet-beta" { - t.Fatalf("expected default network mainnet-beta, got %q", handler.network) + if handler.network != "mainnet" { + t.Fatalf("expected default network mainnet, got %q", handler.network) } } @@ -837,8 +855,8 @@ func TestChargeKnownStablecoinTokenPrograms(t *testing.T) { Recipient: testutil.NewPrivateKey().PublicKey().String(), Currency: tt.currency, Decimals: 6, - Network: "mainnet-beta", - SecretKey: "test-secret", + Network: "mainnet", + SecretKey: "test-secret-key-0123456789abcdef", RPC: rpcClient, Store: core.NewMemoryStore(), }) @@ -874,7 +892,7 @@ func TestVerifyCredentialTokenTransactionSuccess(t *testing.T) { Currency: mint.String(), Decimals: 6, Network: "localnet", - SecretKey: "test-secret", + SecretKey: "test-secret-key-0123456789abcdef", RPC: rpcClient, Store: core.NewMemoryStore(), }) @@ -891,7 +909,7 @@ func TestVerifyCredentialTokenTransactionSuccess(t *testing.T) { t.Fatalf("build credential failed: %v", err) } credential, _ := core.ParseAuthorization(authHeader) - receipt, err := handler.VerifyCredential(context.Background(), credential) + receipt, err := verifyCredentialEchoed(handler, context.Background(), credential) if err != nil { t.Fatalf("verify failed: %v", err) } @@ -908,7 +926,7 @@ func TestVerifyCredentialSignatureSuccess(t *testing.T) { t.Fatalf("build credential failed: %v", err) } credential, _ := core.ParseAuthorization(authHeader) - receipt, err := handler.VerifyCredential(context.Background(), credential) + receipt, err := verifyCredentialEchoed(handler, context.Background(), credential) if err != nil { t.Fatalf("verify failed: %v", err) } @@ -922,7 +940,7 @@ func TestRPCURL(t *testing.T) { recipient := testutil.NewPrivateKey().PublicKey().String() handler, err := New(Config{ Recipient: recipient, - SecretKey: "secret", + SecretKey: "test-secret-key-0123456789abcdef", Network: "devnet", RPC: rpcClient, }) @@ -944,7 +962,7 @@ func TestVerifyCredentialTransactionWithFeePayerSigner(t *testing.T) { Currency: "sol", Decimals: 9, Network: "localnet", - SecretKey: "test-secret", + SecretKey: "test-secret-key-0123456789abcdef", RPC: rpcClient, Store: core.NewMemoryStore(), FeePayerSigner: feePayer, @@ -959,7 +977,7 @@ func TestVerifyCredentialTransactionWithFeePayerSigner(t *testing.T) { t.Fatalf("build credential failed: %v", err) } credential, _ := core.ParseAuthorization(authHeader) - receipt, err := handler.VerifyCredential(context.Background(), credential) + receipt, err := verifyCredentialEchoed(handler, context.Background(), credential) if err != nil { t.Fatalf("verify failed: %v", err) } @@ -985,7 +1003,7 @@ func TestVerifyCredentialRejectsTamperedTransferBeforeBroadcast(t *testing.T) { Currency: "sol", Decimals: 9, Network: "localnet", - SecretKey: "test-secret", + SecretKey: "test-secret-key-0123456789abcdef", RPC: rpcClient, Store: core.NewMemoryStore(), FeePayerSigner: feePayer, @@ -1053,7 +1071,7 @@ func TestVerifyCredentialRejectsTamperedTransferBeforeBroadcast(t *testing.T) { if err != nil { t.Fatalf("rebuild credential: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), tamperedCredential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), tamperedCredential); err == nil { t.Fatal("expected tampered transfer amount to be rejected pre-broadcast") } // Pre-broadcast rejection: the FakeRPC must not have observed any @@ -1079,7 +1097,7 @@ func TestVerifyCredentialChallengeMismatchRejected(t *testing.T) { if err != nil { t.Fatalf("credential failed: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected challenge mismatch to fail") } } @@ -1202,7 +1220,7 @@ func TestVerifyCredentialRejectsInvalidPayloadType(t *testing.T) { if err != nil { t.Fatalf("credential failed: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected invalid payload type to fail") } } @@ -1242,7 +1260,7 @@ func TestVerifyCredentialMalformedTransactionData(t *testing.T) { if err != nil { t.Fatalf("credential failed: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected error for malformed transaction data") } } @@ -1367,7 +1385,7 @@ func TestVerifyTransactionMissingTransaction(t *testing.T) { if err != nil { t.Fatalf("credential: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected missing transaction error") } } @@ -1385,7 +1403,7 @@ func TestVerifyTransactionInvalidBase64(t *testing.T) { if err != nil { t.Fatalf("credential: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected invalid base64 error") } } @@ -1402,7 +1420,7 @@ func TestVerifyTransactionUnknownPayloadType(t *testing.T) { if err != nil { t.Fatalf("credential: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected invalid payload type error") } } @@ -1420,7 +1438,7 @@ func TestVerifySignatureMissingSignature(t *testing.T) { if err != nil { t.Fatalf("credential: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected missing signature error") } } @@ -1438,7 +1456,7 @@ func TestVerifySignatureInvalidSignatureBase58(t *testing.T) { if err != nil { t.Fatalf("credential: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected invalid signature base58 error") } } @@ -1479,7 +1497,7 @@ func TestVerifyTransactionSimulateError(t *testing.T) { Currency: "sol", Decimals: 9, Network: "localnet", - SecretKey: "test-secret", + SecretKey: "test-secret-key-0123456789abcdef", RPC: wrapped, Store: core.NewMemoryStore(), }) @@ -1499,7 +1517,7 @@ func TestVerifyTransactionSimulateError(t *testing.T) { if err != nil { t.Fatalf("credential: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected simulate error") } } @@ -1520,7 +1538,7 @@ func TestVerifyTransactionSendError(t *testing.T) { Currency: "sol", Decimals: 9, Network: "localnet", - SecretKey: "test-secret", + SecretKey: "test-secret-key-0123456789abcdef", RPC: wrapped, Store: core.NewMemoryStore(), }) @@ -1540,7 +1558,7 @@ func TestVerifyTransactionSendError(t *testing.T) { if err != nil { t.Fatalf("credential: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected send error") } } @@ -1561,7 +1579,7 @@ func TestVerifyOnChainTransactionNotFound(t *testing.T) { Currency: "sol", Decimals: 9, Network: "localnet", - SecretKey: "test-secret", + SecretKey: "test-secret-key-0123456789abcdef", RPC: wrapped, Store: core.NewMemoryStore(), }) @@ -1580,7 +1598,7 @@ func TestVerifyOnChainTransactionNotFound(t *testing.T) { if err != nil { t.Fatalf("credential: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected transaction not found error") } } @@ -1605,7 +1623,7 @@ func TestVerifyTransactionStoreError(t *testing.T) { Currency: "sol", Decimals: 9, Network: "localnet", - SecretKey: "test-secret", + SecretKey: "test-secret-key-0123456789abcdef", RPC: rpcClient, Store: errStore{}, }) @@ -1625,7 +1643,7 @@ func TestVerifyTransactionStoreError(t *testing.T) { if err != nil { t.Fatalf("credential: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected store error") } } @@ -1638,7 +1656,7 @@ func TestVerifyTransactionMissingPrimarySignature(t *testing.T) { Currency: "sol", Decimals: 9, Network: "localnet", - SecretKey: "test-secret", + SecretKey: "test-secret-key-0123456789abcdef", RPC: rpcClient, Store: core.NewMemoryStore(), }) @@ -1662,7 +1680,7 @@ func TestVerifyTransactionMissingPrimarySignature(t *testing.T) { if err != nil { t.Fatalf("credential: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected missing primary signature error") } } @@ -1674,8 +1692,8 @@ func TestVerifyTransactionWrongNetworkBlockhash(t *testing.T) { Recipient: recipient.PublicKey().String(), Currency: "sol", Decimals: 9, - Network: "mainnet-beta", - SecretKey: "test-secret", + Network: "mainnet", + SecretKey: "test-secret-key-0123456789abcdef", RPC: rpcClient, Store: core.NewMemoryStore(), }) @@ -1701,7 +1719,7 @@ func TestVerifyTransactionWrongNetworkBlockhash(t *testing.T) { if err != nil { t.Fatalf("credential: %v", err) } - if _, err := handler.VerifyCredential(context.Background(), credential); err == nil { + if _, err := verifyCredentialEchoed(handler, context.Background(), credential); err == nil { t.Fatal("expected wrong network error") } } diff --git a/go/protocols/mpp/server/session_method.go b/go/protocols/mpp/server/session_method.go index d532b4969..156e0fb35 100644 --- a/go/protocols/mpp/server/session_method.go +++ b/go/protocols/mpp/server/session_method.go @@ -194,7 +194,7 @@ func NewSession(options SessionOptions) (*Session, error) { options.Network = "mainnet" } if options.Realm == "" { - options.Realm = DetectRealm() + options.Realm = DetectRealm(options.Recipient) } switch options.OpenTxSubmitter { case "": diff --git a/go/protocols/mpp/server/verify_prebroadcast.go b/go/protocols/mpp/server/verify_prebroadcast.go index 48deb395d..273b74cf5 100644 --- a/go/protocols/mpp/server/verify_prebroadcast.go +++ b/go/protocols/mpp/server/verify_prebroadcast.go @@ -12,7 +12,7 @@ import ( // VerifyChargeTransactionPreBroadcast runs the RPC-free pre-broadcast // verification a charge server applies to a credential transaction before it // co-signs, simulates, or broadcasts. It is the deterministic half of -// [Mpp.VerifyCredential]: the same split-count, address-lookup-table, +// [Mpp.VerifyCredentialWithExpected]: the same split-count, address-lookup-table, // compute-budget, network-blockhash, and transfer/memo/allowlist checks that // run in verifyTransaction up to the simulate/send boundary, with no HMAC // challenge step and no live RPC. @@ -42,7 +42,8 @@ func VerifyChargeTransactionPreBroadcast( if len(tx.Message.AddressTableLookups) > 0 { return core.NewError(core.ErrCodeInvalidPayload, "v0 transactions with address lookup tables are not supported") } - if err := validateComputeBudgetInstructions(tx); err != nil { + feeSponsored := details.FeePayer != nil && *details.FeePayer + if err := validateComputeBudgetInstructions(tx, feeSponsored); err != nil { return err } if err := CheckNetworkBlockhash(network, tx.Message.RecentBlockhash.String()); err != nil { diff --git a/go/protocols/mpp/wire/headers.go b/go/protocols/mpp/wire/headers.go index 405794a99..8af20914b 100644 --- a/go/protocols/mpp/wire/headers.go +++ b/go/protocols/mpp/wire/headers.go @@ -32,6 +32,13 @@ func ParseWWWAuthenticate(header string) (PaymentChallenge, error) { if !ok || requestRaw == "" { return PaymentChallenge{}, fmt.Errorf("missing %q field", "request") } + // Cap the base64url request param before decoding/JSON-parsing it, matching + // the credential (ParseAuthorization) and receipt (ParseReceipt) parsers. + // request is the only challenge field that drives O(n) decode + JSON-parse + // work; an oversized value would otherwise do unbounded work here. + if len(requestRaw) > maxTokenLen { + return PaymentChallenge{}, fmt.Errorf("request field exceeds maximum length of %d bytes", maxTokenLen) + } requestBytes, err := Base64URLDecode(requestRaw) if err != nil { return PaymentChallenge{}, fmt.Errorf("invalid request field: %w", err) diff --git a/go/protocols/mpp/wire/headers_test.go b/go/protocols/mpp/wire/headers_test.go index d777542a5..cd661a6f4 100644 --- a/go/protocols/mpp/wire/headers_test.go +++ b/go/protocols/mpp/wire/headers_test.go @@ -3,6 +3,7 @@ package wire import ( "encoding/json" "fmt" + "strings" "testing" ) @@ -307,6 +308,28 @@ func TestParseReceiptOversized(t *testing.T) { } } +// #9: the WWW-Authenticate request param must be capped at maxTokenLen before +// base64-decode + JSON-parse, matching the credential/receipt parsers. +func TestParseWWWAuthenticateRejectsOversizedRequestParam(t *testing.T) { + oversized := strings.Repeat("A", maxTokenLen+1) + header := fmt.Sprintf(`Payment id="abc", realm="r", method="solana", intent="charge", request="%s"`, oversized) + if _, err := ParseWWWAuthenticate(header); err == nil { + t.Fatal("expected error for oversized request param") + } +} + +func TestParseWWWAuthenticateAcceptsAtMaxRequestSize(t *testing.T) { + // A valid encoded request at/under the cap must NOT trip the size gate. + encoded := Base64URLEncode([]byte(`{"amount":"1","currency":"sol","recipient":"x"}`)) + if len(encoded) > maxTokenLen { + t.Fatalf("fixture request is unexpectedly large: %d", len(encoded)) + } + header := fmt.Sprintf(`Payment id="abc", realm="r", method="solana", intent="charge", request="%s"`, encoded) + if _, err := ParseWWWAuthenticate(header); err != nil { + t.Fatalf("expected at-cap request to parse, got %v", err) + } +} + func TestParseReceiptInvalidBase64(t *testing.T) { if _, err := ParseReceipt("!!!invalid!!!"); err == nil { t.Fatal("expected error for invalid base64") diff --git a/harness/go-server/main.go b/harness/go-server/main.go index f3ecff3d9..d9f16f67f 100644 --- a/harness/go-server/main.go +++ b/harness/go-server/main.go @@ -197,7 +197,11 @@ func mountMPP(mux *http.ServeMux, resourcePath, settlementHeader string) { if auth == "" { challenge, err := srv.ChargeWithOptions(r.Context(), amt, opts) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + if isIssuanceConfigError(err) { + writeMPP402ConfigError(w, err) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } return } writeMPP402(w, challenge, nil) @@ -205,7 +209,11 @@ func mountMPP(mux *http.ServeMux, resourcePath, settlementHeader string) { } challenge, err := srv.ChargeWithOptions(r.Context(), amt, opts) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + if isIssuanceConfigError(err) { + writeMPP402ConfigError(w, err) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } return } credential, err := core.ParseAuthorization(auth) @@ -302,6 +310,25 @@ func pow10(n int) int { return out } +// isIssuanceConfigError reports whether a ChargeWithOptions failure is a +// challenge-issuance config rejection that the conformance harness expects to +// surface as a 402-class outcome rather than a 500. Audit #21 promoted +// too-many-splits from a verify-time reject to a refuse-to-issue, so the +// harness now expects 402 here (see canonical-codes.ts `/too many splits/i`). +func isIssuanceConfigError(err error) bool { + return err != nil && strings.Contains(err.Error(), "too many splits") +} + +// writeMPP402ConfigError surfaces an issuance config rejection (no challenge to +// advertise) as the 402 the harness expects. +func writeMPP402ConfigError(w http.ResponseWriter, issueErr error) { + w.Header().Set("cache-control", "no-store") + w.Header().Set("content-type", "application/problem+json") + w.WriteHeader(http.StatusPaymentRequired) + _ = json.NewEncoder(w).Encode(errorcodes.NewPaymentRequiredBody( + errorcodes.CanonicalFromError(issueErr), issueErr.Error())) +} + // writeMPP402 emits the canonical L6 problem+json body shared across // every MPP server SDK. The verifier error is mapped to its canonical // code via errorcodes.CanonicalFromError so the cross-SDK fault matrix diff --git a/harness/php-server/server.php b/harness/php-server/server.php index afdf21fac..0161ebb18 100644 --- a/harness/php-server/server.php +++ b/harness/php-server/server.php @@ -111,7 +111,8 @@ function secret_key_from_json(string $raw): string $payTo = require_env('MPP_HARNESS_PAY_TO'); $mint = require_env('MPP_HARNESS_MINT'); $amountUnits = require_env('MPP_HARNESS_AMOUNT'); - $mppSecret = optional_env('MPP_HARNESS_SECRET_KEY', 'pay-kit-harness-secret'); + // Must be >= 32 bytes (audit #24); the default is a fixed harness value. + $mppSecret = optional_env('MPP_HARNESS_SECRET_KEY', 'pay-kit-harness-secret-0123456789abcdef'); $networkRaw = optional_env('MPP_HARNESS_NETWORK', 'localnet'); $resourcePath = optional_env('MPP_HARNESS_RESOURCE_PATH', '/paid'); $settlementHeader = optional_env('MPP_HARNESS_SETTLEMENT_HEADER', 'x-payment-settlement-signature'); @@ -156,6 +157,10 @@ function secret_key_from_json(string $raw): string network: $networkRaw, settlementHeader: $settlementHeader, replayStore: new FileStore(sys_get_temp_dir() . '/mpp-php-harness-replay-' . getmypid()), + // Audit #5 made push-mode credentials opt-in (default off). The + // charge-push conformance scenario drives this server in push mode, + // so enable acceptance only when the harness asks for it. + acceptPushMode: $paymentMode === 'push', ); } @@ -391,8 +396,24 @@ function psr7_from_socket(array $req): \Psr\Http\Message\ServerRequestInterface $protectedAmount = $isReplay && $replayAmount !== null ? (string) $replayAmount : $amountUnits; $request = build_charge_request($protectedAmount, $mint, $payTo, $networkRaw, $paymentMode, $handler->feePayerPubkey(), $splits); $authorization = $req['headers']['authorization'] ?? null; - $result = $handler->handle($authorization, $request); - write_response($conn, $result->status, $result->headers, $result->body); + try { + $result = $handler->handle($authorization, $request); + write_response($conn, $result->status, $result->headers, $result->body); + } catch (Throwable $issuanceError) { + // Audit #21 promoted too-many-splits from a verify-time reject to + // a refuse-to-issue at challenge construction. The charge-splits- + // too-many scenario expects the 402-class outcome, so surface that + // one specific construct-time rejection as a 402 (mirrors the + // TypeScript fixture's `challenge_unavailable` allowlist). Any other + // error still bubbles to the 500 handler below. + if (!preg_match('/too many splits/i', $issuanceError->getMessage())) { + throw $issuanceError; + } + write_response($conn, 402, ['content-type' => 'application/json'], [ + 'error' => 'challenge_unavailable', + 'message' => $issuanceError->getMessage(), + ]); + } } fclose($conn); } catch (Throwable $error) { diff --git a/harness/python-server/server.py b/harness/python-server/server.py index 020d18d23..078e45e50 100644 --- a/harness/python-server/server.py +++ b/harness/python-server/server.py @@ -419,7 +419,31 @@ def _issue_mpp_challenge( message: str = "Payment required", code: str = "payment_invalid", ) -> None: - challenge = adapter.handler.charge_with_options(amount, options) + try: + challenge = adapter.handler.charge_with_options(amount, options) + except PaymentError as exc: + # Audit #21 promoted too-many-splits to a refuse-to-issue. The + # conformance harness expects the 402-class outcome (no challenge to + # advertise), not a 500. Re-raise anything else. + if "too many splits" not in str(exc): + raise + invalid = canonical_code("payment_invalid") + self._send_json( + 402, + { + "type": f"https://paymentauth.org/problems/{invalid}", + "title": "Payment Required", + "status": 402, + "code": invalid, + "error": invalid, + "message": str(exc), + }, + extra_headers={ + "content-type": "application/problem+json", + "cache-control": "no-store", + }, + ) + return canonical = canonical_code(code) if code else "payment_invalid" body = { "type": f"https://paymentauth.org/problems/{canonical}", diff --git a/harness/ruby-server/server.rb b/harness/ruby-server/server.rb index 6e7b822f3..7448f8126 100644 --- a/harness/ruby-server/server.rb +++ b/harness/ruby-server/server.rb @@ -109,7 +109,9 @@ def optional_env(name, default) pay_to = require_env("MPP_HARNESS_PAY_TO") mint_raw = require_env("MPP_HARNESS_MINT") amount_raw = require_env("MPP_HARNESS_AMOUNT") - mpp_secret = optional_env("MPP_HARNESS_SECRET_KEY", "pay-kit-harness-secret") + # Default >= 32 bytes: the MPP server enforces a 32-byte minimum HMAC + # secret at boot (audit #24). + mpp_secret = optional_env("MPP_HARNESS_SECRET_KEY", "pay-kit-harness-secret-padding-000000") network_raw = optional_env("MPP_HARNESS_NETWORK", "localnet") resource_path = optional_env("MPP_HARNESS_RESOURCE_PATH", "/paid") settlement_header = optional_env("MPP_HARNESS_SETTLEMENT_HEADER", "x-payment-settlement-signature") diff --git a/kotlin/src/main/kotlin/com/solana/paykit/client/ChargeInterceptor.kt b/kotlin/src/main/kotlin/com/solana/paykit/client/ChargeInterceptor.kt index ffd9ef2bf..4a0c39688 100644 --- a/kotlin/src/main/kotlin/com/solana/paykit/client/ChargeInterceptor.kt +++ b/kotlin/src/main/kotlin/com/solana/paykit/client/ChargeInterceptor.kt @@ -4,6 +4,7 @@ import com.solana.paykit.paycore.MppException import com.solana.paykit.paycore.SolanaSigner import com.solana.paykit.protocols.mpp.client.BlockhashProvider import com.solana.paykit.protocols.mpp.client.Charge +import com.solana.paykit.protocols.mpp.client.ChargePolicy import com.solana.paykit.protocols.mpp.client.MintOwnerResolver import com.solana.paykit.protocols.mpp.core.MppHeaders import okhttp3.Response @@ -19,6 +20,12 @@ import okhttp3.Response * A 402 with no advertised challenge, or none targeting the Solana charge * scheme, throws [MppException.InvalidPaymentScheme] (the MPP charge contract: * a 402 here is a server protocol error, not an offer the client can decline). + * + * Because this is the auto-pay path — the user's wallet signs without a human + * reviewing the challenge — the [ChargePolicy] is enforced here (audit #10, + * #26): the always-on expired-challenge refusal in [Charge.buildCredentialHeader] + * always applies, and [policy] binds the opt-in max-amount, expected-network, + * and unknown-Token-2022 gates a caller configures. */ internal class ChargeInterceptor( private val signer: SolanaSigner, @@ -26,6 +33,7 @@ internal class ChargeInterceptor( private val computeUnitLimit: Int, private val computeUnitPrice: Long, private val mintOwnerResolver: MintOwnerResolver?, + private val policy: ChargePolicy = ChargePolicy.NONE, ) : PaymentInterceptor() { override fun buildCredential(response: Response, bodyText: String): PaymentCredentialHeader? { @@ -46,6 +54,7 @@ internal class ChargeInterceptor( computeUnitLimit = computeUnitLimit, computeUnitPrice = computeUnitPrice, mintOwnerResolver = resolver, + policy = policy, ) return PaymentCredentialHeader( headerName = AUTHORIZATION_HEADER, diff --git a/kotlin/src/main/kotlin/com/solana/paykit/client/PayKitClient.kt b/kotlin/src/main/kotlin/com/solana/paykit/client/PayKitClient.kt index e3bfa9156..8315d4071 100644 --- a/kotlin/src/main/kotlin/com/solana/paykit/client/PayKitClient.kt +++ b/kotlin/src/main/kotlin/com/solana/paykit/client/PayKitClient.kt @@ -2,6 +2,7 @@ package com.solana.paykit.client import com.solana.paykit.paycore.SolanaSigner import com.solana.paykit.protocols.mpp.client.BlockhashProvider +import com.solana.paykit.protocols.mpp.client.ChargePolicy import com.solana.paykit.protocols.mpp.client.MintOwnerResolver import com.solana.paykit.protocols.x402.client.exact.ChallengeSelection import com.solana.paykit.protocols.x402.client.exact.X402RpcClient @@ -143,12 +144,17 @@ class PayKitClient internal constructor( * @param mintOwnerResolver resolves the token program for arbitrary * mints; defaults to [blockhashProvider] when it also implements * [MintOwnerResolver]. + * @param policy auto-pay signing policy (audit #10, #26). The + * expired-challenge refusal always applies; [policy] adds the opt-in + * max-amount, expected-network, and unknown-Token-2022 gates. Defaults + * to no extra constraints. */ fun charge( blockhashProvider: BlockhashProvider, computeUnitLimit: Int = DEFAULT_COMPUTE_UNIT_LIMIT, computeUnitPrice: Long = DEFAULT_COMPUTE_UNIT_PRICE, mintOwnerResolver: MintOwnerResolver? = null, + policy: ChargePolicy = ChargePolicy.NONE, ): Builder = apply { interceptors.add( ChargeInterceptor( @@ -157,6 +163,7 @@ class PayKitClient internal constructor( computeUnitLimit = computeUnitLimit, computeUnitPrice = computeUnitPrice, mintOwnerResolver = mintOwnerResolver, + policy = policy, ), ) } diff --git a/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/client/Charge.kt b/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/client/Charge.kt index 2fd19b7ab..7558fd119 100644 --- a/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/client/Charge.kt +++ b/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/client/Charge.kt @@ -4,6 +4,7 @@ import com.solana.paykit.protocols.mpp.core.* import com.solana.paykit.paycore.* import java.math.BigInteger +import java.time.Instant import java.util.Base64 /** Builds a signed Solana transaction for a decoded MPP charge request. */ @@ -60,6 +61,34 @@ fun interface BlockhashProvider { fun fetchRecentBlockhash(): ByteArray } +/** + * Client-side policy gates for what the builder is willing to sign (audit #10, + * #26). All fields default to "no constraint" so a UI caller that reviews the + * challenge before signing is unaffected; auto-pay callers (e.g. + * [com.solana.paykit.client.ChargeInterceptor]) bind what may be signed against + * the user's wallet without a human in the loop. + * + * Mirrors the rust `BuildChargeTransactionOptions` policy fields: + * - [maxAmountBaseUnits] — refuse when the charge amount (base units) exceeds + * this cap. Equal-to-cap is allowed. + * - [expectedNetwork] — refuse when `methodDetails.network` does not match. + * - [allowUnknownToken2022] — opt in to signing arbitrary (non-stablecoin) + * Token-2022 mints, which can carry transfer hooks (default: refuse). + * + * Note: the always-on expired-challenge refusal is NOT an option here — it is + * enforced unconditionally in [buildCredentialHeader] (there is no opt-out). + */ +data class ChargePolicy( + val maxAmountBaseUnits: BigInteger? = null, + val expectedNetwork: String? = null, + val allowUnknownToken2022: Boolean = false, +) { + companion object { + /** A policy with no constraints — the builder signs whatever it gets. */ + val NONE = ChargePolicy() + } +} + /** Full charge transaction build pipeline. Mirrors the Rust spine. */ object Charge { private const val MAX_SPLITS = 8 @@ -93,6 +122,7 @@ object Charge { computeUnitLimit: Int = DEFAULT_COMPUTE_UNIT_LIMIT, computeUnitPrice: Long = DEFAULT_COMPUTE_UNIT_PRICE, mintOwnerResolver: MintOwnerResolver? = null, + policy: ChargePolicy = ChargePolicy.NONE, ): String { val built = buildUnsignedChargeMessage( walletPublicKey = PublicKey(signer.publicKeyBytes), @@ -101,6 +131,7 @@ object Charge { computeUnitLimit = computeUnitLimit, computeUnitPrice = computeUnitPrice, mintOwnerResolver = mintOwnerResolver, + policy = policy, ) val signature = signer.sign(built.messageBytes) val signerIndex = built.message.accountKeys.indexOfFirst { @@ -136,6 +167,7 @@ object Charge { computeUnitLimit: Int = DEFAULT_COMPUTE_UNIT_LIMIT, computeUnitPrice: Long = DEFAULT_COMPUTE_UNIT_PRICE, mintOwnerResolver: MintOwnerResolver? = null, + policy: ChargePolicy = ChargePolicy.NONE, ): ByteArray { val built = buildUnsignedChargeMessage( walletPublicKey = walletPublicKey, @@ -144,6 +176,7 @@ object Charge { computeUnitLimit = computeUnitLimit, computeUnitPrice = computeUnitPrice, mintOwnerResolver = mintOwnerResolver, + policy = policy, ) val signatures = MutableList(built.message.header.numRequiredSignatures) { null } return Transaction.serializeLegacyTransaction(built.message, signatures) @@ -168,6 +201,7 @@ object Charge { computeUnitLimit: Int, computeUnitPrice: Long, mintOwnerResolver: MintOwnerResolver?, + policy: ChargePolicy, ): UnsignedChargeMessage { // Base-unit amounts are u64 on the wire (Solana lamports / SPL token // amounts). A signed Long tops out at 2^63-1, so a legitimate amount @@ -183,10 +217,31 @@ object Charge { if (totalAmount.signum() <= 0) { throw MppException.InvalidTransaction("Amount must be positive: ${request.amount}") } + // Audit #10: opt-in max-amount cap. Auto-pay callers bind the largest + // charge they will sign without human review; UI callers leave it null. + // Equal-to-cap is allowed (mirrors the rust `request.amount > cap`). + policy.maxAmountBaseUnits?.let { cap -> + if (totalAmount > cap) { + throw MppException.InvalidTransaction( + "Charge amount $totalAmount exceeds max allowed $cap", + ) + } + } // Default a missing methodDetails to an empty block, matching the rust // client (`charge.rs` `unwrap_or_default`): an absent methodDetails is // a valid charge, not an error. val md = request.methodDetails ?: SolanaChargeMethodDetails() + // Audit #10: opt-in expected-network pin. Refuse when the challenge's + // declared network does not match what the auto-pay caller expects. + // A null `md.network` cannot satisfy a concrete expectation, so it is + // rejected too (the caller asked us to bind a specific network). + policy.expectedNetwork?.let { expected -> + if (md.network != expected) { + throw MppException.InvalidTransaction( + "Challenge network ${md.network ?: ""} does not match expected $expected", + ) + } + } val splits = md.splits ?: emptyList() if (splits.size > MAX_SPLITS) { throw MppException.InvalidTransaction("Too many splits (got ${splits.size}, max $MAX_SPLITS)") @@ -266,6 +321,7 @@ object Charge { splits = splits, feePayer = feePayerKey, mintOwnerResolver = mintOwnerResolver, + allowUnknownToken2022 = policy.allowUnknownToken2022, ) } else { buildSolInstructions( @@ -323,8 +379,21 @@ object Charge { computeUnitLimit: Int = DEFAULT_COMPUTE_UNIT_LIMIT, computeUnitPrice: Long = DEFAULT_COMPUTE_UNIT_PRICE, mintOwnerResolver: MintOwnerResolver? = null, + policy: ChargePolicy = ChargePolicy.NONE, + now: Instant = Instant.now(), ): String { challenge.requireSolanaCharge() + // Audit #10: ALWAYS-on expired-challenge refusal — there is no opt-out. + // The protocol's working trust model assumes a human reviews a challenge + // before signing; auto-pay agents break that, so we refuse to sign a + // challenge that has already expired. A challenge with no `expires` is + // still accepted (the spec allows omitting it; we have no anchor to + // check against). `now` is injectable for deterministic tests. + if (challenge.isExpired(now)) { + throw MppException.InvalidTransaction( + "refusing to sign expired challenge (expires=${challenge.expires})", + ) + } val request = challenge.chargeRequest() val transaction = buildChargeTransaction( signer = signer, @@ -333,6 +402,7 @@ object Charge { computeUnitLimit = computeUnitLimit, computeUnitPrice = computeUnitPrice, mintOwnerResolver = mintOwnerResolver, + policy = policy, ) return MppHeaders.formatAuthorization( PaymentCredential( @@ -384,17 +454,27 @@ object Charge { * {Token, Token-2022}. Fail closed when no resolver is available rather * than guessing the legacy Token program (which would mis-derive ATAs * for a Token-2022 mint and bind the wrong program on the wire). + * + * Audit #26: whenever the resolved program is Token-2022 AND [mint] is not + * a known stablecoin mint, REFUSE to sign unless [allowUnknownToken2022] is + * set. Token-2022 mints can carry transfer hooks that execute arbitrary + * code on every transfer, and a client signing an arbitrary Token-2022 mint + * has no way to know what those hooks do. The vanilla Token program has no + * hooks, so arbitrary Token-program mints stay first-class. The gate is on + * the Token-2022 axis, matching the rust client. */ private fun resolveTokenProgram( mint: String, methodDetails: SolanaChargeMethodDetails, mintOwnerResolver: MintOwnerResolver?, + allowUnknownToken2022: Boolean, ): String { val explicit = methodDetails.tokenProgram if (explicit != null) { if (explicit != Programs.TOKEN_PROGRAM && explicit != Programs.TOKEN_2022_PROGRAM) { throw MppException.InvalidTransaction("Unsupported token program: $explicit") } + gateUnknownToken2022(mint, explicit, allowUnknownToken2022) return explicit } // Known stablecoin mints carry a deterministic owner; answer from the @@ -417,9 +497,31 @@ object Charge { "mint $mint is owned by unsupported program $owner", ) } + gateUnknownToken2022(mint, owner, allowUnknownToken2022) return owner } + /** + * Refuses an unknown (non-stablecoin) Token-2022 mint unless the caller + * opted in (audit #26). A no-op for the legacy Token program and for known + * stablecoin mints (which we already trust). Known stablecoins are + * recognised via [stablecoinSymbol]; their token program is well-known and + * they carry no hostile transfer hooks. + */ + private fun gateUnknownToken2022( + mint: String, + tokenProgram: String, + allowUnknownToken2022: Boolean, + ) { + if (tokenProgram != Programs.TOKEN_2022_PROGRAM) return + if (stablecoinSymbol(mint) != null) return + if (allowUnknownToken2022) return + throw MppException.InvalidTransaction( + "refusing to sign unknown Token-2022 mint $mint (transfer-hook risk); " + + "set allowUnknownToken2022 = true to opt in", + ) + } + /** * Resolves a currency identifier (symbol or mint address) to a canonical * mint address, or null for native SOL. Exposed here so callers that @@ -498,12 +600,22 @@ object Charge { splits: List, feePayer: PublicKey?, mintOwnerResolver: MintOwnerResolver?, + allowUnknownToken2022: Boolean, ) { val mintKey = PublicKey.fromBase58(mint) val tokenProgram = PublicKey.fromBase58( - resolveTokenProgram(mint, methodDetails, mintOwnerResolver), + resolveTokenProgram(mint, methodDetails, mintOwnerResolver, allowUnknownToken2022), ) - val decimals = methodDetails.decimals ?: 6 + // Audit #42: spec §7.2 marks `decimals` as conditionally required — + // MUST be present for SPL (this branch). Defaulting a missing value to + // 6 silently signs a wrong `transferChecked` decimals byte / wrong + // divisor for any non-6-decimal mint, the worst failure mode for a + // signed transaction. Error instead of guessing (mirrors the rust + // client `ok_or(... "decimals is required for SPL")`). + val decimals = methodDetails.decimals + ?: throw MppException.InvalidTransaction( + "methodDetails.decimals is required for SPL charges (spec §7.2)", + ) val sourceAta = Pda.associatedTokenAddress(signerKey, mintKey, tokenProgram) val payer = feePayer ?: signerKey @@ -539,7 +651,14 @@ object Charge { val splitDest = PublicKey.fromBase58(split.recipient) val splitAmount = parseU64(split.amount) ?: throw MppException.InvalidTransaction("Invalid split amount: ${split.amount}") - val createAta = feePayer == null || split.ataCreationRequired == true + // Audit #20: create a split ATA only when the challenge explicitly + // flags it, in BOTH client-paid and fee-sponsored modes. The prior + // `feePayer == null || ...` auto-created an ATA for every split in + // client-paid mode regardless of the flag, letting a hostile server + // attach N dust splits to drain ~N×0.002 SOL of rent from the + // client. Mirrors the rust fix (`split.ata_creation_required == + // Some(true)`, flag-only). + val createAta = split.ataCreationRequired == true addSplTransfer(splitDest, splitAmount, createAta) addMemo(instructions, split.memo) } diff --git a/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/Headers.kt b/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/Headers.kt index ac40fc9bd..5b0f5f531 100644 --- a/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/Headers.kt +++ b/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/Headers.kt @@ -10,6 +10,17 @@ object MppHeaders { /** HTTP authentication scheme used by MPP. */ const val PAYMENT_SCHEME = "Payment" + /** + * Maximum byte length of the base64url `request` parameter before it is + * decoded and JSON-parsed. Mirrors the rust parser's `MAX_TOKEN_LEN = 16 * + * 1024` (audit #9): the `request` param is the only `WWW-Authenticate` + * field that drives O(n) base64-decode + JSON-parse cost, so an uncapped + * value lets a hostile server force proportionally large decode/parse work. + * Every other auth-param (id/realm/method/intent/expires/digest/opaque) is + * a short pass-through string. + */ + const val MAX_TOKEN_LEN = 16 * 1024 + private val json = Json { encodeDefaults = false explicitNulls = false @@ -114,6 +125,13 @@ object MppHeaders { val rest = paymentSchemePayload(header) val params = parseAuthParams(rest) val request = params["request"] ?: throw MppException.MissingField("request") + // Cap the request param before base64-decoding + JSON-parsing it + // (audit #9). Checked here (the parse entry point) and again in + // decodeChargeRequest so the cap holds regardless of how a challenge + // reaches the decode path. + if (request.length > MAX_TOKEN_LEN) { + throw MppException.InvalidHeader + } return PaymentChallenge( id = params["id"] ?: throw MppException.MissingField("id"), @@ -147,14 +165,22 @@ object MppHeaders { return "$PAYMENT_SCHEME $encoded" } - internal fun decodeChargeRequest(request: String): ChargeRequest = - try { + internal fun decodeChargeRequest(request: String): ChargeRequest { + // Cap the base64url payload before decode + JSON parse (audit #9). + // A PaymentChallenge can be constructed directly (e.g. in tests or by + // a caller that bypasses parseWWWAuthenticate), so the cap is enforced + // here too rather than relying solely on the parse-time check. + if (request.length > MAX_TOKEN_LEN) { + throw MppException.InvalidHeader + } + return try { json.decodeFromString(Base64Url.decode(request).decodeToString()) } catch (error: IllegalArgumentException) { throw MppException.InvalidJson(error) } catch (error: kotlinx.serialization.SerializationException) { throw MppException.InvalidJson(error) } + } private fun paymentSchemePayload(header: String): String { val trimmed = header.trim() diff --git a/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/Types.kt b/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/Types.kt index 7044d6f7b..0747fee53 100644 --- a/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/Types.kt +++ b/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/Types.kt @@ -3,6 +3,9 @@ package com.solana.paykit.protocols.mpp.core import com.solana.paykit.paycore.* import kotlinx.serialization.Serializable +import java.time.Instant +import java.time.OffsetDateTime +import java.time.format.DateTimeParseException // MppException lives in paycore (crypto primitives need it, and protocols need // crypto) and is brought into scope by the wildcard import above. No same-package @@ -24,6 +27,23 @@ data class PaymentChallenge( /** Decodes this challenge's request as a Solana charge request. */ fun chargeRequest(): ChargeRequest = MppHeaders.decodeChargeRequest(request) + /** + * Returns true if this challenge has an `expires` timestamp that is at or + * before [now] (audit #10). A challenge with no `expires` is never expired + * (the spec allows omitting it). FAIL-CLOSED: an `expires` value that is + * present but does not parse as an RFC3339 / ISO-8601 offset timestamp is + * treated as expired, so a malformed timestamp cannot bypass the refusal. + */ + fun isExpired(now: Instant = Instant.now()): Boolean { + val raw = expires ?: return false + val expiresAt = try { + OffsetDateTime.parse(raw).toInstant() + } catch (_: DateTimeParseException) { + return true + } + return !expiresAt.isAfter(now) + } + /** Fails unless this challenge targets the Solana charge intent. */ fun requireSolanaCharge() { if (method != "solana" || intent != "charge") { diff --git a/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/ChargeBuildTest.kt b/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/ChargeBuildTest.kt index 4b553f7ba..7fbc91bdf 100644 --- a/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/ChargeBuildTest.kt +++ b/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/ChargeBuildTest.kt @@ -264,6 +264,7 @@ class ChargeBuildTest { recipient = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", methodDetails = SolanaChargeMethodDetails( network = "mainnet", + decimals = 6, splits = listOf( SolanaChargeSplit( recipient = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", @@ -285,6 +286,7 @@ class ChargeBuildTest { recipient = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", methodDetails = SolanaChargeMethodDetails( network = "devnet", + decimals = 6, feePayer = true, feePayerKey = feePayer, ), @@ -429,9 +431,11 @@ class ChargeBuildTest { } @Test - fun acceptsExplicitToken2022Program() { - // A valid explicit Token-2022 program is accepted even when the mint - // is unknown (the explicit value short-circuits owner resolution). + fun acceptsExplicitToken2022ProgramWithOptIn() { + // A valid explicit Token-2022 program is accepted for an unknown mint + // only with the allowUnknownToken2022 opt-in (audit #26): the explicit + // value short-circuits owner resolution but still trips the unknown + // Token-2022 transfer-hook gate without the opt-in. val request = ChargeRequest( amount = "1000", currency = "So11111111111111111111111111111111111111112", @@ -442,7 +446,12 @@ class ChargeBuildTest { tokenProgram = Programs.TOKEN_2022_PROGRAM, ), ) - Charge.buildChargeTransaction(signer(), request, fixedBlockhash) + Charge.buildChargeTransaction( + signer(), + request, + fixedBlockhash, + policy = ChargePolicy(allowUnknownToken2022 = true), + ) } @Test @@ -479,11 +488,14 @@ class ChargeBuildTest { recipient = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", methodDetails = SolanaChargeMethodDetails(network = "mainnet", decimals = 6), ) + // Opt in: this test exercises owner-resolution, not the #26 gate. An + // unknown mint resolving to Token-2022 is refused without the opt-in. Charge.buildChargeTransaction( signer(), request, fixedBlockhash, mintOwnerResolver = resolver, + policy = ChargePolicy(allowUnknownToken2022 = true), ) assertEquals(arbitraryMint, queried) } @@ -677,4 +689,292 @@ class ChargeBuildTest { Charge.buildChargeTransaction(signer(), request, fixedBlockhash) } } + + // ── #42: SPL decimals required ────────────────────────────────────────── + + @Test + fun rejectsMissingDecimalsOnSplCharge() { + // Audit #42: a missing `decimals` on the SPL path must error rather + // than silently defaulting to 6 (a wrong transferChecked byte / divisor + // for any non-6-decimal mint). USDC resolves its token program from the + // static table so no resolver is needed; the failure is the missing + // decimals, proving the path reached buildSplInstructions. + val request = ChargeRequest( + amount = "1000", + currency = "USDC", + recipient = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", + methodDetails = SolanaChargeMethodDetails(network = "devnet"), + ) + assertFailsWith { + Charge.buildChargeTransaction(signer(), request, fixedBlockhash) + } + } + + @Test + fun solChargeDoesNotRequireDecimals() { + // The decimals requirement is SPL-only; a native SOL charge with no + // decimals must still build (the SOL path has no transferChecked byte). + val request = ChargeRequest( + amount = "1000", + currency = "SOL", + recipient = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", + methodDetails = SolanaChargeMethodDetails(network = "localnet"), + ) + Charge.buildChargeTransaction(signer(), request, fixedBlockhash) + } + + // ── #20: split ATA creation is flag-only ──────────────────────────────── + + @Test + fun clientPaidModeDoesNotAutoCreateSplitAtaWithoutFlag() { + // Audit #20: in client-paid mode (no fee payer) a split WITHOUT + // ataCreationRequired must NOT emit a create-ATA instruction. The prior + // `feePayer == null || ...` auto-created one per split. We witness this + // by comparing instruction counts: a flagged split (which binds the + // base58 mint and pays rent) emits one extra create-ATA instruction + // versus an unflagged split, all else equal. + val mint = Mints.USDC_MAINNET + fun build(ataRequired: Boolean?): Int { + val request = ChargeRequest( + amount = "1000", + currency = mint, + recipient = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", + methodDetails = SolanaChargeMethodDetails( + network = "mainnet", + decimals = 6, + splits = listOf( + SolanaChargeSplit( + recipient = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM", + amount = "100", + ataCreationRequired = ataRequired, + ), + ), + ), + ) + val raw = JBase64.getDecoder().decode( + Charge.buildChargeTransaction(signer(), request, fixedBlockhash), + ) + return raw.size + } + // The flagged build carries an extra create-ATA instruction, so its + // wire bytes are strictly larger than the unflagged build. + assertTrue( + build(ataRequired = true) > build(ataRequired = null), + "flagged split must add a create-ATA instruction the unflagged split omits", + ) + } + + // ── #26: unknown Token-2022 mints refused without opt-in ──────────────── + + @Test + fun refusesUnknownToken2022MintWithoutOptIn() { + // Audit #26: an arbitrary (non-stablecoin) mint resolving to Token-2022 + // must be refused unless the caller opts in, because Token-2022 mints + // can carry transfer hooks. + val resolver = MintOwnerResolver { Programs.TOKEN_2022_PROGRAM } + val request = ChargeRequest( + amount = "1000", + currency = "So11111111111111111111111111111111111111112", + recipient = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", + methodDetails = SolanaChargeMethodDetails(network = "mainnet", decimals = 6), + ) + assertFailsWith { + Charge.buildChargeTransaction( + signer(), + request, + fixedBlockhash, + mintOwnerResolver = resolver, + ) + } + } + + @Test + fun refusesUnknownToken2022MintViaExplicitProgramWithoutOptIn() { + // The gate also fires when the program is pinned via + // methodDetails.tokenProgram (the explicit-program branch), not only the + // owner-resolution branch. + val request = ChargeRequest( + amount = "1000", + currency = "So11111111111111111111111111111111111111112", + recipient = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", + methodDetails = SolanaChargeMethodDetails( + network = "mainnet", + decimals = 6, + tokenProgram = Programs.TOKEN_2022_PROGRAM, + ), + ) + assertFailsWith { + Charge.buildChargeTransaction(signer(), request, fixedBlockhash) + } + } + + @Test + fun allowsUnknownToken2022MintWithOptIn() { + // With the opt-in, the same unknown Token-2022 mint signs. + val resolver = MintOwnerResolver { Programs.TOKEN_2022_PROGRAM } + val request = ChargeRequest( + amount = "1000", + currency = "So11111111111111111111111111111111111111112", + recipient = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", + methodDetails = SolanaChargeMethodDetails(network = "mainnet", decimals = 6), + ) + Charge.buildChargeTransaction( + signer(), + request, + fixedBlockhash, + mintOwnerResolver = resolver, + policy = ChargePolicy(allowUnknownToken2022 = true), + ) + } + + @Test + fun doesNotGateKnownToken2022Stablecoin() { + // A KNOWN Token-2022 stablecoin (PYUSD) is never gated — no opt-in and + // no resolver needed. + val request = ChargeRequest( + amount = "1000", + currency = "PYUSD", + recipient = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", + methodDetails = SolanaChargeMethodDetails(network = "mainnet", decimals = 6), + ) + Charge.buildChargeTransaction(signer(), request, fixedBlockhash) + } + + @Test + fun doesNotGateUnknownVanillaTokenMint() { + // An unknown VANILLA Token-program mint stays first-class (no hooks on + // the legacy Token program) — no opt-in required. + val resolver = MintOwnerResolver { Programs.TOKEN_PROGRAM } + val request = ChargeRequest( + amount = "1000", + currency = "So11111111111111111111111111111111111111112", + recipient = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", + methodDetails = SolanaChargeMethodDetails(network = "mainnet", decimals = 6), + ) + Charge.buildChargeTransaction( + signer(), + request, + fixedBlockhash, + mintOwnerResolver = resolver, + ) + } + + // ── #10: expiry / max-amount / expected-network gates ─────────────────── + + private fun chargeChallenge( + expires: String? = null, + currency: String = "SOL", + amount: String = "1000", + network: String = "localnet", + ): PaymentChallenge { + val mdNetwork = """"network":"$network"""" + val requestJson = + """{"amount":"$amount","currency":"$currency","recipient":"CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY","methodDetails":{$mdNetwork,"recentBlockhash":"11111111111111111111111111111111"}}""" + return PaymentChallenge( + id = "abc", + realm = "MPP Payment", + method = "solana", + intent = "charge", + request = Base64Url.encode(requestJson.encodeToByteArray()), + expires = expires, + ) + } + + @Test + fun refusesExpiredChallenge() { + // Audit #10: always-on expiry refusal. A challenge whose `expires` is at + // or before `now` must be refused regardless of policy. + val challenge = chargeChallenge(expires = "2020-01-01T00:00:00Z") + assertFailsWith { + Charge.buildCredentialHeader( + signer(), + challenge, + fixedBlockhash, + now = java.time.Instant.parse("2026-06-15T00:00:00Z"), + ) + } + } + + @Test + fun acceptsFutureExpiryChallenge() { + val challenge = chargeChallenge(expires = "2099-01-01T00:00:00Z") + val header = Charge.buildCredentialHeader( + signer(), + challenge, + fixedBlockhash, + now = java.time.Instant.parse("2026-06-15T00:00:00Z"), + ) + assertTrue(header.startsWith("Payment ")) + } + + @Test + fun acceptsChallengeWithoutExpiry() { + // No `expires` means never expired (the spec allows omitting it). + val challenge = chargeChallenge(expires = null) + val header = Charge.buildCredentialHeader(signer(), challenge, fixedBlockhash) + assertTrue(header.startsWith("Payment ")) + } + + @Test + fun refusesMalformedExpiryFailClosed() { + // A present-but-unparseable expires is treated as expired (fail-closed). + val challenge = chargeChallenge(expires = "not-a-timestamp") + assertFailsWith { + Charge.buildCredentialHeader(signer(), challenge, fixedBlockhash) + } + } + + @Test + fun refusesAmountAboveMaxPolicy() { + // Audit #10: opt-in max-amount cap. amount=1000 > cap=999 → refuse. + val challenge = chargeChallenge(amount = "1000") + assertFailsWith { + Charge.buildCredentialHeader( + signer(), + challenge, + fixedBlockhash, + policy = ChargePolicy(maxAmountBaseUnits = java.math.BigInteger.valueOf(999)), + ) + } + } + + @Test + fun acceptsAmountAtMaxPolicy() { + // Equal-to-cap is allowed. + val challenge = chargeChallenge(amount = "1000") + val header = Charge.buildCredentialHeader( + signer(), + challenge, + fixedBlockhash, + policy = ChargePolicy(maxAmountBaseUnits = java.math.BigInteger.valueOf(1000)), + ) + assertTrue(header.startsWith("Payment ")) + } + + @Test + fun refusesUnexpectedNetworkPolicy() { + // Audit #10: opt-in expected-network pin. challenge network=localnet, + // expected=mainnet → refuse. + val challenge = chargeChallenge(network = "localnet") + assertFailsWith { + Charge.buildCredentialHeader( + signer(), + challenge, + fixedBlockhash, + policy = ChargePolicy(expectedNetwork = "mainnet"), + ) + } + } + + @Test + fun acceptsMatchingNetworkPolicy() { + val challenge = chargeChallenge(network = "localnet") + val header = Charge.buildCredentialHeader( + signer(), + challenge, + fixedBlockhash, + policy = ChargePolicy(expectedNetwork = "localnet"), + ) + assertTrue(header.startsWith("Payment ")) + } } diff --git a/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/HttpClientTest.kt b/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/HttpClientTest.kt index 24796110b..9d0220b63 100644 --- a/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/HttpClientTest.kt +++ b/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/client/HttpClientTest.kt @@ -193,7 +193,7 @@ class HttpClientTest { ( """{"amount":"1000","currency":"$arbitraryMint",""" + """"recipient":"CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY",""" + - """"methodDetails":{"network":"localnet",""" + + """"methodDetails":{"network":"localnet","decimals":6,""" + """"recentBlockhash":"11111111111111111111111111111111"}}""" ).encodeToByteArray(), ) @@ -239,7 +239,7 @@ class HttpClientTest { ( """{"amount":"1000","currency":"$arbitraryMint",""" + """"recipient":"CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY",""" + - """"methodDetails":{"network":"localnet",""" + + """"methodDetails":{"network":"localnet","decimals":6,""" + """"recentBlockhash":"11111111111111111111111111111111"}}""" ).encodeToByteArray(), ) diff --git a/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/core/HeadersTest.kt b/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/core/HeadersTest.kt index 3dd32eab0..743f79a4e 100644 --- a/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/core/HeadersTest.kt +++ b/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/core/HeadersTest.kt @@ -66,6 +66,55 @@ class HeadersTest { assertEquals(true, splits[1].ataCreationRequired) } + @Test + fun rejectsOversizedRequestParam() { + // Audit #9: the base64url `request` param is capped at MAX_TOKEN_LEN + // before it is decoded + JSON-parsed, so a hostile server cannot force + // proportionally large decode/parse work. One byte over the cap is + // rejected at parse time. + val oversized = "A".repeat(MppHeaders.MAX_TOKEN_LEN + 1) + val header = "Payment id=\"abc\", realm=\"api\", method=\"solana\", " + + "intent=\"charge\", request=\"$oversized\"" + assertFailsWith { + MppHeaders.parseWWWAuthenticate(header) + } + } + + @Test + fun acceptsRequestParamAtMaxSize() { + // Regression: a request param exactly at the cap must NOT trip the size + // gate. We pad a valid charge-request JSON's base64url up to the cap + // with trailing base64url chars; the size check runs before decode, so + // reaching parseWWWAuthenticate without an InvalidHeader proves the + // at-cap value passes the gate. (Decode/JSON validity is a separate + // concern exercised elsewhere.) + val base = validRequestB64() + val padded = base + "A".repeat(MppHeaders.MAX_TOKEN_LEN - base.length) + assertEquals(MppHeaders.MAX_TOKEN_LEN, padded.length) + val header = "Payment id=\"abc\", realm=\"api\", method=\"solana\", " + + "intent=\"charge\", request=\"$padded\"" + // The size gate must not fire. The padded base64 may or may not decode + // to valid JSON, but it will NOT throw InvalidHeader from the size cap. + try { + MppHeaders.parseWWWAuthenticate(header) + } catch (e: MppException.InvalidHeader) { + throw AssertionError("at-cap request param must not trip the size gate", e) + } catch (_: MppException) { + // Any other MppException (e.g. base64/JSON) is acceptable here — + // we are only asserting the size gate did not fire. + } + } + + @Test + fun decodeChargeRequestRejectsOversizedRequest() { + // The cap is also enforced in decodeChargeRequest for callers that + // bypass parseWWWAuthenticate (e.g. a directly-constructed challenge). + val oversized = "A".repeat(MppHeaders.MAX_TOKEN_LEN + 1) + assertFailsWith { + MppHeaders.decodeChargeRequest(oversized) + } + } + @Test fun splitsTabSeparatedChallenges() { val req = validRequestB64() diff --git a/lua/pay_kit/protocol/core/headers.lua b/lua/pay_kit/protocol/core/headers.lua index 2240f0b61..dd188e581 100644 --- a/lua/pay_kit/protocol/core/headers.lua +++ b/lua/pay_kit/protocol/core/headers.lua @@ -236,6 +236,14 @@ function M.parse_www_authenticate(header) if not params.request or params.request == '' then error('missing "request" field') end + -- Audit #9: cap the `request` parameter like the credential / receipt + -- parsers (parse_authorization / parse_receipt both cap at max_token_len). + -- `request` is the only WWW-Authenticate field that is base64url-decoded + -- AND JSON-parsed, so an oversized value drives unbounded decode + parse + -- work. Mirrors the Rust spine cap on this exact parameter. + if #params.request > max_token_len then + error('request field exceeds maximum length of ' .. max_token_len .. ' bytes') + end local request_bytes, decode_err = types.base64url_decode(params.request) if not request_bytes then error('invalid request field: ' .. decode_err) diff --git a/lua/pay_kit/protocols/mpp/server/charge_handler.lua b/lua/pay_kit/protocols/mpp/server/charge_handler.lua index 7e1faf56b..dc4649e6d 100644 --- a/lua/pay_kit/protocols/mpp/server/charge_handler.lua +++ b/lua/pay_kit/protocols/mpp/server/charge_handler.lua @@ -130,6 +130,12 @@ function M.new(config) rpc = config.rpc, network = config.network or 'mainnet', replay_store = config.replay_store, + -- Audit #5: push mode (type=signature credentials) is opt-in and default + -- OFF. Spec §13.5 accepts that push binds a confirmed tx to a challenge by + -- shape only ("first accepted presentation wins"); a server that does not + -- need push should not carry that trade-off. Mirrors the Rust spine's + -- default-off posture. + accept_push_mode = config.accept_push_mode or false, transaction_verifier = config.transaction_verifier, pull_transaction_signer = config.pull_transaction_signer, pull_blockhash_extractor = config.pull_blockhash_extractor, @@ -282,6 +288,10 @@ function Handler:settle(payload, request) if payload.type == 'transaction' then return self:settle_pull(payload.transaction, request) elseif payload.type == 'signature' then + -- Audit #5: reject push-mode credentials unless the operator opted in. + if not self.accept_push_mode then + verifier_error('Push-mode credentials are disabled on this server (enable accept_push_mode to opt in; spec §13.5)') + end return self:settle_push(payload.signature, request) end verifier_error('unsupported payload type: ' .. tostring(payload.type)) diff --git a/lua/pay_kit/protocols/mpp/server/init.lua b/lua/pay_kit/protocols/mpp/server/init.lua index d2440753d..70ba7adff 100644 --- a/lua/pay_kit/protocols/mpp/server/init.lua +++ b/lua/pay_kit/protocols/mpp/server/init.lua @@ -9,11 +9,26 @@ local store = require('pay_kit.protocols.mpp.store') local types = require('pay_kit.protocol.core.types') local uint = require('pay_kit.util.uint') +local crypto = require('pay_kit.util._mpp_crypto') + local M = {} -local DEFAULT_REALM = 'MPP Payment' +local MIN_SECRET_KEY_BYTES = 32 local CONSUMED_PREFIX = 'solana-charge:consumed:' +-- Audit #15: derive a per-recipient default realm instead of the static +-- shared `"MPP Payment"`. Two servers that share a secret but front +-- different recipients now land in different HMAC credential namespaces, so a +-- credential paid against server A cannot replay against server B. Mirrors the +-- Rust spine `derive_default_realm` (SHA-256 of the recipient, first 4 bytes +-- as a decimal id). Deterministic across restarts for the same recipient. +local function derive_default_realm(recipient) + local digest = crypto.sha256(recipient) + local b1, b2, b3, b4 = string.byte(digest, 1, 4) + local id = ((b1 * 16777216) + (b2 * 65536) + (b3 * 256) + b4) % 100000000 + return string.format('App Id - #%d', id) +end + local Server = {} Server.__index = Server @@ -64,19 +79,77 @@ function M.new(config) if secret_key == nil or secret_key == '' then error('missing secret key') end + -- Audit #24: the secret is the HMAC-SHA256 key binding challenge IDs. A + -- short / guessable key lets an attacker forge challenge IDs. Enforce a + -- 32-byte minimum on BOTH the config and the MPP_SECRET_KEY env paths + -- (NIST SP 800-107: HMAC key >= hash output length). Mirrors the Rust + -- spine `MIN_SECRET_KEY_BYTES = 32`. + if #secret_key < MIN_SECRET_KEY_BYTES then + error(string.format( + 'secret key must be at least %d bytes (got %d); generate one with `openssl rand -base64 32`', + MIN_SECRET_KEY_BYTES, #secret_key)) + end + -- Audit #37: reject any network outside the {mainnet, devnet, localnet} + -- allowlist at boot, before any RPC client / default URL is derived. A typo + -- or the legacy `mainnet-beta` alias used to silently resolve to mainnet + -- RPC. Mirrors the Rust spine `validate_network`. + local network = config.network or 'mainnet' + local net_ok, net_err = protocol.validate_network(network) + if not net_ok then + error(net_err) + end + -- Audit #15: explicit empty realm is rejected (an operator typo must not + -- silently re-introduce the shared-namespace threat). When unset, derive a + -- per-recipient default. + local realm + if config.realm ~= nil then + if type(config.realm) ~= 'string' or config.realm == '' then + error('realm must be a non-empty string when provided') + end + realm = config.realm + else + realm = derive_default_realm(config.recipient) + end + -- Audit #16: `feePayer = true` requires a fee-payer key (the server-side + -- signer's pubkey). Reject the inconsistent boot config so a spec-violating + -- `feePayer:true` challenge with no `feePayerKey` can never be issued. + -- Mirrors the Rust spine boot gate. + local fee_payer = bool_or_nil(config.fee_payer) + if fee_payer and (config.fee_payer_key == nil or config.fee_payer_key == '') then + error('fee_payer = true requires fee_payer_key (the server fee-payer pubkey)') + end local currency = config.currency or 'USDC' -- Default decimals: SOL uses 9, every SPL stablecoin in our table uses 6. -- Caller can still override explicitly. local default_decimals = is_native_sol(currency) and 9 or 6 + -- Audit #28 (part 2): resolve the token program at boot. Known stablecoins + -- resolve from the static table; an arbitrary mint requires an explicit + -- `config.token_program` or a `config.token_program_resolver` (on-chain + -- owner lookup) so we never silently assume legacy Token for a Token-2022 + -- mint. SOL resolves to nil. + local token_program + if not is_native_sol(currency) then + if config.token_program ~= nil and config.token_program ~= '' then + token_program = config.token_program + else + local resolved, tp_err = protocol.resolve_token_program( + currency, network, config.token_program_resolver) + if tp_err then + error(tp_err) + end + token_program = resolved + end + end local instance = { secret_key = secret_key, - realm = config.realm or DEFAULT_REALM, + realm = realm, recipient = config.recipient, currency = currency, decimals = config.decimals or default_decimals, - network = config.network or 'mainnet', - rpc_url = config.rpc_url or protocol.default_rpc_url(config.network or 'mainnet'), - fee_payer = bool_or_nil(config.fee_payer), + network = network, + token_program = token_program, + rpc_url = config.rpc_url or protocol.default_rpc_url(network), + fee_payer = fee_payer, fee_payer_key = config.fee_payer_key, store = config.store or store.memory(), verify_payment = config.verify_payment, @@ -84,7 +157,10 @@ function M.new(config) html = config.html or false, } if instance.verify_payment == nil and config.verifier_hooks ~= nil then - instance.verify_payment = solana_verify.new_signature_verifier(config.verifier_hooks) + -- Audit #5: push mode is opt-in (default off). Thread the server config + -- flag into the signature verifier factory. + instance.verify_payment = solana_verify.new_signature_verifier( + config.verifier_hooks, { accept_push_mode = config.accept_push_mode or false }) end return setmetatable(instance, Server) end @@ -108,12 +184,39 @@ function Server:charge_with_options(amount, options) error_codes.raise(error_codes.PAYMENT_INVALID, 'too many splits') end local split_total = '0' + local seen_recipients = {} for i = 1, #options.splits do - local split_amount = options.splits[i] and options.splits[i].amount + local split = options.splits[i] + local split_amount = split and split.amount + -- Audit #21: positive integer amount. `"0"` matches `^%d+$` but a + -- zero-amount split is meaningless and must be rejected. if type(split_amount) ~= 'string' or not split_amount:match('^%d+$') then error_codes.raise(error_codes.PAYMENT_INVALID, 'split.amount must be an integer string') end + if uint.compare(split_amount, '0') <= 0 then + error_codes.raise(error_codes.PAYMENT_INVALID, + 'split.amount must be greater than zero') + end + -- Audit #21: recipient must parse as a Solana pubkey. + if not protocol.is_pubkey(split.recipient) then + error_codes.raise(error_codes.PAYMENT_INVALID, + 'split.recipient must be a valid Solana address') + end + -- Audit #21: reject duplicate split recipients. + if seen_recipients[split.recipient] then + error_codes.raise(error_codes.PAYMENT_INVALID, + 'duplicate split recipient: ' .. tostring(split.recipient)) + end + seen_recipients[split.recipient] = true + -- Audit #38: the primary recipient may legitimately appear in splits, + -- but the combination primary-in-splits + ataCreationRequired in a + -- fee-sponsored flow is the ATA-recreate drain. Reject only that + -- combination (matches the Rust spine's narrow misconfig guard). + if split.recipient == self.recipient and split.ataCreationRequired == true then + error_codes.raise(error_codes.PAYMENT_INVALID, + 'primary recipient cannot appear in splits with ataCreationRequired = true') + end split_total = uint.add(split_total, split_amount) end if uint.compare(base_units, split_total) <= 0 then @@ -126,17 +229,28 @@ function Server:charge_with_options(amount, options) } if not is_native_sol(self.currency) then method_details.decimals = self.decimals + -- Audit #28: emit the token program resolved (and validated) at boot, + -- with a per-call override still honored. No silent legacy fallback for + -- arbitrary mints — `self.token_program` is nil only when boot could not + -- resolve it, which it would already have rejected for SPL. if options.token_program then method_details.tokenProgram = options.token_program - elseif protocol.stablecoin_symbol(self.currency) then - method_details.tokenProgram = protocol.default_token_program_for_currency(self.currency, self.network) + elseif self.token_program then + method_details.tokenProgram = self.token_program end end - if options.fee_payer or self.fee_payer then - method_details.feePayer = true - if options.fee_payer_key or self.fee_payer_key then - method_details.feePayerKey = options.fee_payer_key or self.fee_payer_key + -- Audit #16: a per-call fee-payer override must also carry a key, just like + -- the boot gate. Reject `feePayer:true` with no resolvable feePayerKey so a + -- spec-violating challenge can never be issued from this path either. + local want_fee_payer = options.fee_payer or self.fee_payer + if want_fee_payer then + local fee_payer_key = options.fee_payer_key or self.fee_payer_key + if fee_payer_key == nil or fee_payer_key == '' then + error_codes.raise(error_codes.PAYMENT_INVALID, + 'fee_payer requires a fee_payer_key') end + method_details.feePayer = true + method_details.feePayerKey = fee_payer_key end if options.splits then method_details.splits = options.splits diff --git a/lua/pay_kit/protocols/mpp/server/solana_verify.lua b/lua/pay_kit/protocols/mpp/server/solana_verify.lua index 73b68affb..4ba2fc551 100644 --- a/lua/pay_kit/protocols/mpp/server/solana_verify.lua +++ b/lua/pay_kit/protocols/mpp/server/solana_verify.lua @@ -20,6 +20,15 @@ local MEMO_PROGRAM = protocol.MEMO_PROGRAM -- Caps exist so a malicious client cannot price-out a server's transactions. local MAX_COMPUTE_UNIT_LIMIT = 200000 local MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 5000000 +-- Audit #25: in fee-sponsored pull mode the server co-signs BEFORE broadcast, +-- so the priority fee comes out of the server's fee-payer balance. The general +-- 5_000_000 cap × 200_000 limit = ~1_000_000 lamports (0.001 SOL) per charge, +-- ~200x the base fee — looped, a merchant drain. Apply a tight cap when the +-- server is the fee payer. Worst case at this cap: +-- ceil(10_000 × 200_000 / 1_000_000) = 2_000 lamports (~20% of the per-signature +-- base fee), still leaving honest clients room to bump priority during +-- congestion. Mirrors the Rust spine (server/charge.rs #25). +local MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED = 10000 local verify_sol_transfers local verify_spl_transfers local verify_memo_instructions @@ -31,6 +40,16 @@ local function is_native_sol(currency) return string.lower(currency or '') == 'sol' end +-- Audit #25: the server-side fee-payer signal. True precisely when the route is +-- server-fee-sponsored (feePayer=true), which is the authoritative server-side +-- flag the challenge issuer set — the same signal used by the fee-payer drain +-- guard in verify_instruction_allowlist and the B34 push-mode block. It is NOT +-- a client-supplied field on the credential. When true the server co-signs and +-- pays the priority fee, so the tight compute-unit-price cap must apply. +local function is_fee_sponsored(method_details) + return method_details ~= nil and method_details.feePayer == true +end + local function sum_split_amounts(splits) local total = '0' for _, split in ipairs(splits or {}) do @@ -156,7 +175,7 @@ local function verify_confirmed_transaction(reference, tx, request, method_detai verify_spl_transfers(instructions, request, method_details, hooks) end verify_memo_instructions(instructions, request, method_details) - verify_compute_budget(instructions) + verify_compute_budget(instructions, is_fee_sponsored(method_details)) verify_instruction_allowlist(instructions, request, method_details) return { @@ -191,7 +210,13 @@ end function verify_spl_transfers(instructions, request, method_details, hooks) local expected = build_expected_transfers(request) - local program_id = method_details.tokenProgram or protocol.default_token_program_for_currency(request.currency, method_details.network) + -- Prefer the challenge-embedded tokenProgram. When it is absent we resolve + -- from the known-stablecoin table only; an arbitrary mint with no embedded + -- tokenProgram resolves to `nil` (audit #28 — no silent legacy fallback) + -- and the supported-program guard below rejects it rather than deriving a + -- destination ATA against the wrong program. + local program_id = method_details.tokenProgram + or protocol.default_token_program_for_currency(request.currency, method_details.network) local mint = protocol.resolve_mint(request.currency, method_details.network) if program_id ~= TOKEN_PROGRAM and program_id ~= TOKEN_2022_PROGRAM then error_codes.raise(error_codes.PAYMENT_INVALID, 'unsupported token program: ' .. tostring(program_id)) @@ -273,7 +298,18 @@ end -- server-side hook is available; otherwise the bytes are read directly. -- 3. Compute-budget instructions we cannot classify fail closed; without a -- discriminator we cannot prove the instruction stays under the cap. -function verify_compute_budget(instructions) +-- +-- `fee_sponsored` is true precisely when the server acts as the fee payer +-- (feePayer route). In that mode the priority-fee cap tightens to +-- MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED (audit #25); when the +-- client pays its own gas the general cap applies (no merchant risk). The flag +-- is derived from the server-side fee-payer signal by the callers — never from +-- a client-supplied field. +function verify_compute_budget(instructions, fee_sponsored) + local price_cap = MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS + if fee_sponsored then + price_cap = MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED + end for _, ix in ipairs(instructions or {}) do -- Use resolve_program (forward-declared above) so an instruction -- that ships only the `computeBudget` alias still passes through @@ -292,7 +328,7 @@ function verify_compute_budget(instructions) handled = true elseif parsed_type == 'setComputeUnitPrice' or info.microLamports ~= nil then local price = tonumber(info.microLamports or 0) or 0 - if price > MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS then + if price > price_cap then error('compute unit price exceeds cap') end handled = true @@ -316,7 +352,7 @@ function verify_compute_budget(instructions) local low = b1 + b2 * 256 + b3 * 65536 + b4 * 16777216 local high = b5 + b6 * 256 + b7 * 65536 + b8 * 16777216 local price = low + high * 4294967296 - if price > MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS then + if price > price_cap then error('compute unit price exceeds cap') end handled = true @@ -520,8 +556,11 @@ function M.verify_transaction(context, hooks) error('parse_transaction result is missing message.instructions') end -- Pre-broadcast: compute-budget cap + instruction allowlist (incl. - -- fee-payer drain guard). Reject BEFORE any RPC call. - verify_compute_budget(pre_instructions) + -- fee-payer drain guard). Reject BEFORE any RPC call. In fee-sponsored pull + -- mode the server co-signs here, so the tight compute-unit-price cap (audit + -- #25) applies; is_fee_sponsored reads the server-side feePayer signal, never + -- a client-supplied field. + verify_compute_budget(pre_instructions, is_fee_sponsored(method_details)) verify_instruction_allowlist(pre_instructions, request, method_details) local signature = hooks.send_transaction(payload.transaction) @@ -563,11 +602,28 @@ function M.verify_transaction(context, hooks) return result end -function M.new_signature_verifier(hooks) +-- `hooks` carries the per-credential callbacks (fetch_transaction, +-- send_transaction, ...). Push mode (type=signature) is OPT-IN: pass +-- `hooks.accept_push_mode = true` (or a second `opts` table) to enable it. +-- +-- Audit #5: spec §13.5 ("Front-running (Push Mode)") accepts that push mode +-- binds a confirmed transaction to a challenge by shape only — two challenges +-- with identical terms can satisfy each other ("first accepted presentation +-- wins"). The base flow is spec-compliant, but the Rust spine defaults push +-- OFF so a server that does not need it does not carry that trade-off. We +-- mirror that: signature credentials are rejected unless the operator opts in. +-- The gate runs before B34 (push + fee-sponsored), so a default server returns +-- the disabled message; an opted-in fee-sponsored route still hits B34. +function M.new_signature_verifier(hooks, opts) + hooks = hooks or {} + local accept_push_mode = (opts and opts.accept_push_mode) or hooks.accept_push_mode or false return function(context) if context.payload.type == 'transaction' then return M.verify_transaction(context, hooks) end + if not accept_push_mode then + error('Push-mode credentials are disabled on this server (enable accept_push_mode to opt in; spec §13.5)') + end return M.verify_signature(context, hooks) end end diff --git a/lua/pay_kit/solana/mints.lua b/lua/pay_kit/solana/mints.lua index bb3670f16..875851f40 100644 --- a/lua/pay_kit/solana/mints.lua +++ b/lua/pay_kit/solana/mints.lua @@ -38,6 +38,32 @@ local TOKEN_PROGRAMS = { CASH = M.TOKEN_2022_PROGRAM, } +-- Audit #37: the canonical network slugs are exactly these three. The legacy +-- `mainnet-beta` spelling is the Solana RPC *hostname* convention only and is +-- NOT a valid network slug here (mirrors the Rust spine's `validate_network`, +-- which rejects `mainnet-beta`). `validate_network` is the boot-time allowlist +-- gate; `default_rpc_url` only ever sees a slug that has already passed it. +M.NETWORKS = { + mainnet = true, + devnet = true, + localnet = true, +} + +-- Validate a network slug against the allowlist. Returns `(true)` on success +-- and `(false, message)` on an unknown / empty slug so callers can raise with +-- their own error shape (mirrors Rust `validate_network`). +function M.validate_network(network) + if type(network) ~= 'string' or network == '' then + return false, 'network is required (one of mainnet, devnet, localnet)' + end + if not M.NETWORKS[network] then + return false, string.format( + "unsupported network '%s' (must be one of mainnet, devnet, localnet)", + tostring(network)) + end + return true +end + function M.default_rpc_url(network) if network == 'devnet' then return 'https://api.devnet.solana.com' @@ -51,9 +77,11 @@ function M.default_rpc_url(network) return 'https://api.mainnet-beta.solana.com' end --- Maintainer canonical for the mainnet slug is `mainnet`. The legacy --- `mainnet-beta` spelling is accepted as a backward-compatible alias and --- normalized to `mainnet`. Other networks pass through unchanged. +-- Maintainer canonical for the mainnet slug is `mainnet`. Mint-table lookups +-- key on the canonical slug; the boot-time `validate_network` gate already +-- rejects `mainnet-beta` and any unknown slug, so this only canonicalizes the +-- known three (and tolerates the legacy alias when called directly for a +-- table key, e.g. from the verifier with a methodDetails-supplied network). local function normalize_network(network) local lower = string.lower(network or '') if lower == 'mainnet' or lower == 'mainnet-beta' then @@ -91,12 +119,76 @@ function M.stablecoin_symbol(currency) return nil end +-- Return the token program for a KNOWN stablecoin currency (symbol or known +-- mint address). For an arbitrary mint address NOT in our table this returns +-- `nil` rather than silently guessing legacy Token. +-- +-- Audit #28 (part 2): the previous implementation fell back to legacy +-- `TOKEN_PROGRAM` for any unknown currency. An arbitrary Token-2022 mint would +-- then ship with the wrong token program (challenge issuance) or derive the +-- wrong destination ATA (verification). The Rust spine resolves the mint owner +-- on-chain at boot and rejects an unexpected owner; the Lua server boot path +-- has no synchronous account-fetch wired, so we fail closed instead of +-- guessing. Callers that CAN resolve the owner on-chain pass a resolver to +-- `M.resolve_token_program`. function M.default_token_program_for_currency(currency, network) local symbol = M.stablecoin_symbol(M.resolve_mint(currency, network)) or M.stablecoin_symbol(currency) if symbol then return TOKEN_PROGRAMS[symbol] end - return M.TOKEN_PROGRAM + return nil +end + +local base58 = require('pay_kit.solana.base58') + +-- True when `value` decodes as a 32-byte base58 string, i.e. a syntactically +-- valid Solana public key (mint or account address). Used both to gate +-- recipient parseability (audit #21) and to distinguish a known-symbol +-- currency from an arbitrary mint address (audit #28). +function M.is_pubkey(value) + if type(value) ~= 'string' or value == '' then + return false + end + local ok, decoded = pcall(base58.decode, value) + return ok and #decoded == 32 +end + +-- Resolve the token program for a charge currency, failing closed on an +-- unresolvable arbitrary mint instead of guessing legacy Token (audit #28). +-- +-- * `SOL` -> nil (native, no token program) +-- * known stablecoin symbol/mint -> static table answer +-- * arbitrary mint address -> `resolver(mint)` if a resolver is given +-- (e.g. an on-chain owner lookup), else a +-- `(nil, message)` rejection +-- * anything else -> `(nil, message)` rejection +-- +-- Returns `(program)` on success or `(nil, message)` so the caller raises with +-- its own error shape. The optional `resolver` is `function(mint) -> +-- program | nil` and MUST return one of the two token programs. +function M.resolve_token_program(currency, network, resolver) + if string.lower(currency or '') == 'sol' then + return nil + end + local known = M.default_token_program_for_currency(currency, network) + if known then + return known + end + if M.is_pubkey(currency) then + if type(resolver) == 'function' then + local program = resolver(currency) + if program == M.TOKEN_PROGRAM or program == M.TOKEN_2022_PROGRAM then + return program + end + return nil, string.format( + "could not resolve a supported token program for mint '%s'", currency) + end + return nil, string.format( + "token program for arbitrary mint '%s' is unknown; pass options.token_program " + .. 'or a token_program_resolver so the wrong (legacy) program is not assumed', + currency) + end + return nil, string.format("unknown currency '%s' (not a known symbol or mint address)", currency) end return M diff --git a/lua/tests/charge_handler_spec.lua b/lua/tests/charge_handler_spec.lua index e9ca4b631..59ac5daa5 100644 --- a/lua/tests/charge_handler_spec.lua +++ b/lua/tests/charge_handler_spec.lua @@ -487,7 +487,7 @@ end) -- 2. resubmission of the same signature is rejected as signature_consumed t.test('Kong-style shared replay_store does not double-consume on first payment', function() local mpp = require('tests._mpp') - local SECRET = 'kong-test-secret' + local SECRET = 'kong-test-secret-key-long-enough-32b' local RECIPIENT = '3yGpUKnU5HSVSMxye83YuseTeSQykiS5N4eh6iQn1d2h' local shared = store.memory() diff --git a/lua/tests/core_spec.lua b/lua/tests/core_spec.lua index db72d4433..77f0b3704 100644 --- a/lua/tests/core_spec.lua +++ b/lua/tests/core_spec.lua @@ -21,6 +21,28 @@ t.test('www-authenticate round trip', function() t.assert_equal(parsed.request:raw(), challenge.request:raw()) end) +-- Audit #9: the WWW-Authenticate `request` parameter must be length-capped +-- like the credential / receipt parsers (16 KiB) so an oversized value cannot +-- drive unbounded base64 decode + JSON parse work. +t.test('audit #9: parse_www_authenticate rejects an oversized request param', function() + local huge = string.rep('A', 16 * 1024 + 1) + local header = 'Payment id="x", realm="r", method="solana", intent="charge", request="' .. huge .. '"' + t.assert_error(function() + mpp.ParseWWWAuthenticate(header) + end, 'request field exceeds maximum length') +end) + +t.test('audit #9: parse_www_authenticate accepts a request param at the cap', function() + -- A valid base64url payload well under the cap still parses. + local request = mpp.NewBase64URLJSONValue({ amount = '1000', currency = 'sol' }) + local challenge = mpp.NewChallengeWithSecretFull( + 'secret', 'realm', mpp.NewMethodName('solana'), mpp.NewIntentName('charge'), + request, nil, nil, nil, nil) + local header = mpp.FormatWWWAuthenticate(challenge) + local parsed = mpp.ParseWWWAuthenticate(header) + t.assert_equal(parsed.request:raw(), challenge.request:raw()) +end) + t.test('authorization round trip', function() local request = mpp.NewBase64URLJSONValue({ amount = '1000' }) local challenge = mpp.NewChallengeWithSecret('secret', 'realm', mpp.NewMethodName('solana'), mpp.NewIntentName('charge'), request) diff --git a/lua/tests/error_codes_spec.lua b/lua/tests/error_codes_spec.lua index 1c0ce78db..4d2aa6187 100644 --- a/lua/tests/error_codes_spec.lua +++ b/lua/tests/error_codes_spec.lua @@ -9,7 +9,7 @@ local network_check = require('pay_kit.solana.network_check') -- from the others by a machine-readable code, not just a human string. local TEST_RECIPIENT = '3yGpUKnU5HSVSMxye83YuseTeSQykiS5N4eh6iQn1d2h' -local TEST_SECRET = 'mpp-test-secret-key' +local TEST_SECRET = 'mpp-test-secret-key-long-enough-32b' local function build_server(opts) opts = opts or {} @@ -17,7 +17,7 @@ local function build_server(opts) recipient = opts.recipient or TEST_RECIPIENT, currency = opts.currency or 'USDC', decimals = 6, - network = opts.network or 'mainnet-beta', + network = opts.network or 'mainnet', secret_key = opts.secret_key or TEST_SECRET, realm = opts.realm or 'MPP Test', store = mpp.store.memory(), @@ -144,7 +144,7 @@ helper.test('server emits charge_request_mismatch on recipient mismatch', functi end) helper.test('server emits challenge_verification_failed on HMAC mismatch', function() - local server = build_server({ secret_key = 'correct-secret' }) + local server = build_server({ secret_key = 'correct-secret-key-long-enough-32b' }) local challenge = server:charge('0.001') -- Re-issue the challenge under the wrong secret; the HMAC check rebuilds -- the id from echoed fields and rejects. diff --git a/lua/tests/pay_kit/apisix_plugin_runtime_spec.lua b/lua/tests/pay_kit/apisix_plugin_runtime_spec.lua index 0d3d250bb..83264049a 100644 --- a/lua/tests/pay_kit/apisix_plugin_runtime_spec.lua +++ b/lua/tests/pay_kit/apisix_plugin_runtime_spec.lua @@ -30,7 +30,7 @@ helper.test('APISIX access(conf,ctx) returns 402 on unpaid', function() rpc_url = 'https://api.devnet.solana.com', accept = {'x402', 'mpp'}, operator = {recipient = 'ApisixRecipient000000000000000000000000000'}, - mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret'}, + mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret-key-long-enough-32bytes'}, }) local plugin = require('plugins.apisix.plugins.pay-kit') @@ -48,7 +48,7 @@ helper.test('APISIX access returns 500 on invalid amount', function() rpc_url = 'https://api.devnet.solana.com', accept = {'x402', 'mpp'}, operator = {recipient = 'ApisixRecipient000000000000000000000000000'}, - mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret'}, + mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret-key-long-enough-32bytes'}, }) local plugin = require('plugins.apisix.plugins.pay-kit') local status = plugin.access({amount = 'not-decimal', stablecoins = {'USDC'}}, @@ -63,7 +63,7 @@ helper.test('APISIX access with named gate dispatches to dispatcher', function() rpc_url = 'https://api.devnet.solana.com', accept = {'x402', 'mpp'}, operator = {recipient = 'ApisixRecipient000000000000000000000000000'}, - mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret'}, + mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret-key-long-enough-32bytes'}, }) pay_kit.gate('report', {amount = pay_kit.usd('0.05', 'USDC')}) local plugin = require('plugins.apisix.plugins.pay-kit') diff --git a/lua/tests/pay_kit/config_spec.lua b/lua/tests/pay_kit/config_spec.lua index 9c09cb761..81981a201 100644 --- a/lua/tests/pay_kit/config_spec.lua +++ b/lua/tests/pay_kit/config_spec.lua @@ -139,8 +139,8 @@ end) helper.test('configure() mpp.challenge_binding_secret is stored', function() reset() - assert(pay_kit.configure({mpp = {challenge_binding_secret = 'rotate-me'}})) - helper.assert_equal(pay_kit.config().mpp.challenge_binding_secret, 'rotate-me') + assert(pay_kit.configure({mpp = {challenge_binding_secret = 'rotate-me-key-long-enough-32bytes!!'}})) + helper.assert_equal(pay_kit.config().mpp.challenge_binding_secret, 'rotate-me-key-long-enough-32bytes!!') end) helper.test('configure() mpp.expires_in default + override', function() @@ -164,13 +164,13 @@ helper.test('configure() preserves mpp.replay_store and other mpp fields', funct local sentinel_store = { put_if_absent = function() return true end } assert(pay_kit.configure({mpp = { replay_store = sentinel_store, - challenge_binding_secret = 'rotate-me', + challenge_binding_secret = 'rotate-me-key-long-enough-32bytes!!', expires_in = 90, }})) local cfg = pay_kit.config() helper.assert_equal(cfg.mpp.replay_store, sentinel_store) -- Normalized fields still applied alongside the preserved store. - helper.assert_equal(cfg.mpp.challenge_binding_secret, 'rotate-me') + helper.assert_equal(cfg.mpp.challenge_binding_secret, 'rotate-me-key-long-enough-32bytes!!') helper.assert_equal(cfg.mpp.expires_in, 90) helper.assert_equal(cfg.mpp.realm, 'App') end) diff --git a/lua/tests/pay_kit/dispatcher_spec.lua b/lua/tests/pay_kit/dispatcher_spec.lua index cc5bee224..a41e980a4 100644 --- a/lua/tests/pay_kit/dispatcher_spec.lua +++ b/lua/tests/pay_kit/dispatcher_spec.lua @@ -15,7 +15,7 @@ local function setup() assert(pay_kit.configure({ network = 'solana_devnet', operator = {recipient = SELLER}, - mpp = {challenge_binding_secret = 'test-secret'}, + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes'}, })) end @@ -77,7 +77,7 @@ local function reset_and_configure() rpc_url = 'https://api.devnet.solana.com', accept = {'x402', 'mpp'}, operator = {recipient = 'DispatcherRecipient000000000000000000000000'}, - mpp = {realm = 'TestRealm', challenge_binding_secret = 'disp-test-secret'}, + mpp = {realm = 'TestRealm', challenge_binding_secret = 'disp-test-secret-key-long-32bytes!'}, }) pay_kit.gate('paid', {amount = pay_kit.usd('0.001', 'USDC')}) end diff --git a/lua/tests/pay_kit/kong_plugin_runtime_spec.lua b/lua/tests/pay_kit/kong_plugin_runtime_spec.lua index bf1f2157d..26ef3efb7 100644 --- a/lua/tests/pay_kit/kong_plugin_runtime_spec.lua +++ b/lua/tests/pay_kit/kong_plugin_runtime_spec.lua @@ -111,7 +111,7 @@ helper.test('Kong handler access(conf) emits 402 on unpaid', function() patch_env({ PAY_KIT_NETWORK = 'solana_devnet', PAY_KIT_OPERATOR_RECIPIENT = 'KongAccessRecipient0000000000000000000000', - PAY_KIT_MPP_CHALLENGE_BINDING_SECRET = 'access-test', + PAY_KIT_MPP_CHALLENGE_BINDING_SECRET = 'access-test-key-long-enough-32bytes', }) require('plugins.kong.plugins.pay-kit.init').setup() restore_env() diff --git a/lua/tests/pay_kit/main_fixes_spec.lua b/lua/tests/pay_kit/main_fixes_spec.lua index 925b21b1b..aeda306af 100644 --- a/lua/tests/pay_kit/main_fixes_spec.lua +++ b/lua/tests/pay_kit/main_fixes_spec.lua @@ -38,7 +38,7 @@ helper.test('402 response carries Cache-Control: no-store', function() assert(pay_kit.configure({ network = 'solana_devnet', operator = {recipient = SELLER}, - mpp = {challenge_binding_secret = 'test-secret'}, + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes'}, })) assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) local _, _, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) @@ -53,7 +53,7 @@ helper.test('MPP 402 challenge carries an expiry from config.mpp.expires_in', fu network = 'solana_devnet', accept = {'mpp'}, operator = {recipient = SELLER}, - mpp = {challenge_binding_secret = 'test-secret', expires_in = 120}, + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', expires_in = 120}, })) assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) local _, _, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) @@ -72,7 +72,7 @@ helper.test('MPP 402 challenge omits expiry when expires_in = false (dev opt-out network = 'solana_devnet', accept = {'mpp'}, operator = {recipient = SELLER}, - mpp = {challenge_binding_secret = 'test-secret', expires_in = false}, + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', expires_in = false}, })) assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) local _, _, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) @@ -85,7 +85,7 @@ helper.test('configure rejects a non-positive expires_in', function() reset() local _, err = pay_kit.configure({ network = 'solana_devnet', - mpp = {challenge_binding_secret = 'test-secret', expires_in = 0}, + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', expires_in = 0}, }) helper.assert_true(err ~= nil and err:find('expires_in', 1, true) ~= nil, tostring(err)) end) @@ -113,7 +113,7 @@ helper.test('adapter expected methodDetails matches the issued challenge', funct network = 'solana_devnet', accept = {'mpp'}, operator = {recipient = SELLER}, - mpp = {challenge_binding_secret = 'test-secret', expires_in = 120}, + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', expires_in = 120}, })) assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) local _, _, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) @@ -149,7 +149,7 @@ end) helper.test('solana_localnet defaults to the hosted Surfpool RPC', function() reset() - assert(pay_kit.configure({mpp = {challenge_binding_secret = 'test-secret'}})) + assert(pay_kit.configure({mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes'}})) helper.assert_equal(pay_kit.config().rpc_url, 'https://402.surfnet.dev:8899') end) diff --git a/lua/tests/pay_kit/x402_broadcast_spec.lua b/lua/tests/pay_kit/x402_broadcast_spec.lua index 7165fbbc0..5fb11429d 100644 --- a/lua/tests/pay_kit/x402_broadcast_spec.lua +++ b/lua/tests/pay_kit/x402_broadcast_spec.lua @@ -138,7 +138,7 @@ helper.test('x402 verify_and_settle: cosigns + broadcasts + reserves signature', fee_payer = true, }, x402 = {scheme = 'exact'}, - mpp = {realm = 'unused', challenge_binding_secret = 'x'}, + mpp = {realm = 'unused', challenge_binding_secret = 'x-secret-key-long-enough-32bytes!!!'}, }) local mint = base58.encode(string.rep('\4', 32)) diff --git a/lua/tests/server_spec.lua b/lua/tests/server_spec.lua index 497b21ac7..74ce6ab3e 100644 --- a/lua/tests/server_spec.lua +++ b/lua/tests/server_spec.lua @@ -7,7 +7,7 @@ local function new_server() currency = 'USDC', decimals = 6, network = 'localnet', - secret_key = 'test-secret', + secret_key = 'test-secret-key-long-enough-for-hmac', store = mpp.store.memory(), verify_payment = function(context) if context.payload.type == 'signature' then @@ -95,8 +95,10 @@ t.test('verify credential rejects sponsored push mode', function() currency = 'USDC', decimals = 6, network = 'localnet', - secret_key = 'test-secret', + secret_key = 'test-secret-key-long-enough-for-hmac', fee_payer = true, + -- Audit #16: feePayer=true now requires a fee_payer_key at boot. + fee_payer_key = '9yGpUKnU5HSVSMxye83YuseTeSQykiS5N4eh6iQn1d2h', verify_payment = function(context) return { reference = context.payload.signature or context.payload.transaction } end, @@ -114,7 +116,7 @@ end) t.test('verify credential requires verification callback', function() local server = mpp.server.new({ recipient = '3yGpUKnU5HSVSMxye83YuseTeSQykiS5N4eh6iQn1d2h', - secret_key = 'test-secret', + secret_key = 'test-secret-key-long-enough-for-hmac', }) local challenge = server:charge('1') local credential = mpp.NewPaymentCredential(challenge:to_echo(), { @@ -131,7 +133,7 @@ t.test('verify credential accepts transaction payload when lua verifier hooks ar recipient = '3yGpUKnU5HSVSMxye83YuseTeSQykiS5N4eh6iQn1d2h', currency = 'sol', decimals = 9, - secret_key = 'test-secret', + secret_key = 'test-secret-key-long-enough-for-hmac', verifier_hooks = (function() local pull_tx = { meta = { err = nil }, @@ -250,3 +252,189 @@ t.test('charge_with_options threads explicit token_program override into methodD local request = challenge.request:decode() t.assert_equal(request.methodDetails.tokenProgram, 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb') end) + +local VALID_RECIPIENT = '3yGpUKnU5HSVSMxye83YuseTeSQykiS5N4eh6iQn1d2h' +local SPLIT_A = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU' +local SPLIT_B = '9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ' + +local function server_with(overrides) + local cfg = { + recipient = VALID_RECIPIENT, + currency = 'USDC', + decimals = 6, + network = 'localnet', + secret_key = 'test-secret-key-long-enough-for-hmac', + store = mpp.store.memory(), + verify_payment = function(context) + return { reference = context.payload.signature or context.payload.transaction } + end, + } + for k, v in pairs(overrides or {}) do cfg[k] = v end + return mpp.server.new(cfg) +end + +-- Audit #24: weak secret key. +t.test('audit #24: rejects secret key shorter than 32 bytes', function() + t.assert_error(function() + server_with({ secret_key = 'short-secret' }) + end, 'at least 32 bytes') +end) + +t.test('audit #24: accepts secret key at the 32-byte minimum', function() + local server = server_with({ secret_key = string.rep('a', 32) }) + t.assert_true(server ~= nil) +end) + +t.test('audit #24: MPP_SECRET_KEY env path is also length-gated', function() + local real = os.getenv + os.getenv = function(name) -- luacheck: ignore + if name == 'MPP_SECRET_KEY' then return 'tiny' end + return real(name) + end + local ok = pcall(function() + mpp.server.new({ recipient = VALID_RECIPIENT, network = 'localnet' }) + end) + os.getenv = real -- luacheck: ignore + t.assert_true(not ok, 'expected short env secret to be rejected') +end) + +-- Audit #15: per-recipient default realm. +t.test('audit #15: default realm is derived per-recipient (not the shared default)', function() + local a = server_with({ recipient = VALID_RECIPIENT }) + local b = server_with({ recipient = SPLIT_B }) + t.assert_true(a.realm ~= 'MPP Payment') + t.assert_true(a.realm ~= b.realm) + -- Deterministic for the same recipient. + local a2 = server_with({ recipient = VALID_RECIPIENT }) + t.assert_equal(a.realm, a2.realm) +end) + +t.test('audit #15: explicit empty realm is rejected', function() + t.assert_error(function() + server_with({ realm = '' }) + end, 'non%-empty') +end) + +-- Audit #37: network allowlist. +t.test('audit #37: rejects unknown network slug at boot', function() + t.assert_error(function() + server_with({ network = 'testnet' }) + end, 'unsupported network') +end) + +t.test('audit #37: rejects the mainnet-beta alias at boot', function() + t.assert_error(function() + server_with({ network = 'mainnet-beta' }) + end, 'unsupported network') +end) + +t.test('audit #37: accepts the three canonical networks', function() + for _, net in ipairs({ 'mainnet', 'devnet', 'localnet' }) do + local server = server_with({ network = net }) + t.assert_equal(server.network, net) + end +end) + +-- Audit #16: feePayer=true requires a key. +t.test('audit #16: rejects fee_payer=true without a fee_payer_key at boot', function() + t.assert_error(function() + server_with({ fee_payer = true }) + end, 'requires fee_payer_key') +end) + +t.test('audit #16: accepts fee_payer=true with a fee_payer_key and emits both fields', function() + local server = server_with({ fee_payer = true, fee_payer_key = SPLIT_A }) + local request = server:charge('0.001').request:decode() + t.assert_equal(request.methodDetails.feePayer, true) + t.assert_equal(request.methodDetails.feePayerKey, SPLIT_A) +end) + +t.test('audit #16: per-call fee_payer override without a key is rejected', function() + local server = server_with({}) + t.assert_error(function() + server:charge_with_options('0.001', { fee_payer = true }) + end, 'requires a fee_payer_key') +end) + +-- Audit #21: split validation at issuance. +t.test('audit #21: rejects a zero-amount split', function() + local server = server_with({}) + t.assert_error(function() + server:charge_with_options('0.001', { + splits = {{ recipient = SPLIT_A, amount = '0' }}, + }) + end, 'greater than zero') +end) + +t.test('audit #21: rejects an unparseable split recipient', function() + local server = server_with({}) + t.assert_error(function() + server:charge_with_options('0.001', { + splits = {{ recipient = 'not-a-pubkey', amount = '10' }}, + }) + end, 'valid Solana address') +end) + +t.test('audit #21: rejects duplicate split recipients', function() + local server = server_with({}) + t.assert_error(function() + server:charge_with_options('0.001', { + splits = { + { recipient = SPLIT_A, amount = '10' }, + { recipient = SPLIT_A, amount = '20' }, + }, + }) + end, 'duplicate split recipient') +end) + +-- Audit #38: primary recipient + ataCreationRequired. +t.test('audit #38: rejects primary recipient in splits with ataCreationRequired', function() + local server = server_with({}) + t.assert_error(function() + server:charge_with_options('0.010', { + splits = {{ recipient = VALID_RECIPIENT, amount = '10', ataCreationRequired = true }}, + }) + end, 'primary recipient cannot appear in splits') +end) + +t.test('audit #38: allows primary recipient in splits without ataCreationRequired', function() + local server = server_with({}) + local request = server:charge_with_options('0.010', { + splits = {{ recipient = VALID_RECIPIENT, amount = '10' }}, + }).request:decode() + t.assert_equal(request.methodDetails.splits[1].recipient, VALID_RECIPIENT) +end) + +-- Audit #28: arbitrary-mint token program resolution. +t.test('audit #28: arbitrary mint currency without token_program is rejected at boot', function() + -- A 32-byte base58 pubkey not in KNOWN_MINTS: cannot guess the program. + t.assert_error(function() + server_with({ currency = SPLIT_B }) + end, 'arbitrary mint') +end) + +t.test('audit #28: arbitrary mint resolves via an explicit token_program', function() + local server = server_with({ + currency = SPLIT_B, + token_program = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + }) + local request = server:charge('0.001').request:decode() + t.assert_equal(request.methodDetails.tokenProgram, 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb') +end) + +t.test('audit #28: arbitrary mint resolves via a token_program_resolver hook', function() + local server = server_with({ + currency = SPLIT_B, + token_program_resolver = function(_mint) + return 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb' + end, + }) + local request = server:charge('0.001').request:decode() + t.assert_equal(request.methodDetails.tokenProgram, 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb') +end) + +t.test('audit #28: known stablecoin still resolves from the static table', function() + local server = server_with({ currency = 'PYUSD' }) + local request = server:charge('0.001').request:decode() + t.assert_equal(request.methodDetails.tokenProgram, 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb') +end) diff --git a/lua/tests/solana_verify_spec.lua b/lua/tests/solana_verify_spec.lua index f561dfbec..49998e09f 100644 --- a/lua/tests/solana_verify_spec.lua +++ b/lua/tests/solana_verify_spec.lua @@ -722,7 +722,9 @@ t.test('server can wire verifier hooks automatically', function() currency = 'sol', decimals = 9, network = 'localnet', - secret_key = 'test-secret', + secret_key = 'test-secret-key-long-enough-for-hmac', + -- Audit #5: push mode is opt-in; this test submits a signature credential. + accept_push_mode = true, verifier_hooks = { fetch_transaction = function(signature) t.assert_equal(signature, 'sig-123') @@ -1099,6 +1101,99 @@ t.test('SECURITY: rejects compute-budget over cap pre-broadcast', function() t.assert_equal(send_calls, 0, 'send_transaction must not be called when pre-broadcast policy rejects') end) +-- Audit #25: in fee-sponsored pull mode the server co-signs (and pays the +-- priority fee) before broadcast, so the compute-unit-price cap tightens to +-- 10_000 µlamports. A client-paid charge keeps the 5_000_000 general cap. +local function compute_price_tx(price) + return { + meta = { err = nil }, + transaction = { + message = { + instructions = { + { + programId = 'ComputeBudget111111111111111111111111111111', + parsed = { type = 'setComputeUnitPrice', info = { microLamports = price } }, + }, + { + program = 'system', + parsed = { + type = 'transfer', + -- source is a regular sender, NOT the fee payer, so the + -- fee-payer drain guard does not fire and we isolate the cap. + info = { source = 'sender-1', destination = 'recipient-1', lamports = '1000' }, + }, + }, + }, + }, + }, + } +end + +t.test('audit #25: fee-sponsored compute price above tight cap is rejected pre-broadcast', function() + local context = signature_context({ + payload = { type = 'transaction', transaction = 'base64-tx' }, + request = { + amount = '1000', + currency = 'sol', + recipient = 'recipient-1', + methodDetails = { feePayer = true, feePayerKey = 'fee-payer-1' }, + }, + method_details = { feePayer = true, feePayerKey = 'fee-payer-1' }, + }) + local over_cap_tx = compute_price_tx(10001) + local send_calls = 0 + t.assert_error(function() + verify.verify_transaction(context, { + parse_transaction = function() return over_cap_tx.transaction end, + send_transaction = function() send_calls = send_calls + 1; return 'sig-evil' end, + await_transaction = function() return over_cap_tx end, + }) + end, 'compute unit price exceeds cap') + t.assert_equal(send_calls, 0, 'send_transaction must not be called when the tight cap rejects') +end) + +t.test('audit #25: fee-sponsored compute price at the tight cap is accepted', function() + local context = signature_context({ + payload = { type = 'transaction', transaction = 'base64-tx' }, + request = { + amount = '1000', + currency = 'sol', + recipient = 'recipient-1', + methodDetails = { feePayer = true, feePayerKey = 'fee-payer-1' }, + }, + method_details = { feePayer = true, feePayerKey = 'fee-payer-1' }, + }) + local at_cap_tx = compute_price_tx(10000) + local result = verify.verify_transaction(context, { + parse_transaction = function() return at_cap_tx.transaction end, + send_transaction = function() return 'sig-at-cap' end, + await_transaction = function() return at_cap_tx end, + }) + t.assert_equal(result.reference, 'sig-at-cap') +end) + +t.test('audit #25: client-paid compute price above the tight cap is still accepted', function() + -- Regression: the tight cap MUST NOT apply when the client pays its own gas + -- (no feePayer). The general 5_000_000 cap governs; 10_001 is well under it. + local context = signature_context({ + payload = { type = 'transaction', transaction = 'base64-tx' }, + request = { + amount = '1000', + currency = 'sol', + recipient = 'recipient-1', + methodDetails = {}, + }, + method_details = {}, + }) + local over_tight_tx = compute_price_tx(10001) + local result = verify.verify_transaction(context, { + parse_transaction = function() return over_tight_tx.transaction end, + send_transaction = function() return 'sig-client-paid' end, + await_transaction = function() return over_tight_tx end, + }) + t.assert_equal(result.reference, 'sig-client-paid') +end) + t.test('SECURITY: rejects unknown program instruction pre-broadcast', function() local context = signature_context({ payload = { type = 'transaction', transaction = 'base64-tx' }, @@ -1201,3 +1296,58 @@ t.test('SECURITY: result.consumed is not set when context.store is absent', func t.assert_equal(result.replay_key, nil) end) + +-- Audit #5: push mode (type=signature) is opt-in via the signature verifier +-- factory. Default-off reduces a server's attack surface (spec §13.5 +-- first-accepted-presentation trade-off). A transaction (pull) credential is +-- never gated. +t.test('audit #5: new_signature_verifier rejects a signature credential by default', function() + local verifier = verify.new_signature_verifier({ + fetch_transaction = function() error('must not fetch when push is disabled') end, + }) + t.assert_error(function() + verifier(signature_context({ payload = { type = 'signature', signature = 'sig-1' } })) + end, 'Push%-mode credentials are disabled') +end) + +t.test('audit #5: new_signature_verifier accepts a signature credential when opted in', function() + local fetched = false + local verifier = verify.new_signature_verifier({ + fetch_transaction = function() + fetched = true + return { + meta = { err = nil }, + transaction = { message = { instructions = { + { program = 'system', parsed = { type = 'transfer', + info = { destination = 'recipient-1', lamports = '1' } } }, + } } }, + } + end, + }, { accept_push_mode = true }) + local result = verifier(signature_context({ + payload = { type = 'signature', signature = 'sig-1' }, + request = { amount = '1', currency = 'sol', recipient = 'recipient-1', methodDetails = {} }, + })) + t.assert_true(fetched, 'opted-in push should reach fetch_transaction') + t.assert_equal(result.reference, 'sig-1') +end) + +t.test('audit #5: pull (transaction) credentials are never gated by the push opt-in', function() + local ok_tx = { + meta = { err = nil }, + transaction = { message = { instructions = { + { program = 'system', parsed = { type = 'transfer', + info = { destination = 'recipient-1', lamports = '1' } } }, + } } }, + } + local verifier = verify.new_signature_verifier({ + parse_transaction = function() return ok_tx end, + send_transaction = function() return 'sig-pull' end, + await_transaction = function() return ok_tx end, + }) + local result = verifier(signature_context({ + payload = { type = 'transaction', transaction = 'deadbeef' }, + request = { amount = '1', currency = 'sol', recipient = 'recipient-1', methodDetails = {} }, + })) + t.assert_equal(result.reference, 'sig-pull') +end) diff --git a/php/examples/simple-server/index.php b/php/examples/simple-server/index.php index 914b4549e..724617903 100644 --- a/php/examples/simple-server/index.php +++ b/php/examples/simple-server/index.php @@ -39,7 +39,9 @@ $client = new PayKit(new Config( network: Network::SolanaLocalnet, preflight: false, - mpp: new MppConfig(realm: 'PHP example', challengeBindingSecret: 'local-dev-secret'), + // The challenge-binding secret must be >= 32 bytes (audit #24). This is a + // throwaway dev value; generate yours with `openssl rand -base64 32`. + mpp: new MppConfig(realm: 'PHP example', challengeBindingSecret: 'local-dev-secret-0123456789abcdef-01'), )); // One inline-priced gate. Accepts both x402 and MPP per default diff --git a/php/src/Frameworks/Laravel/PayKitServiceProvider.php b/php/src/Frameworks/Laravel/PayKitServiceProvider.php index 3495c6e3b..5f11c5f33 100644 --- a/php/src/Frameworks/Laravel/PayKitServiceProvider.php +++ b/php/src/Frameworks/Laravel/PayKitServiceProvider.php @@ -72,8 +72,12 @@ public static function buildConfig(array $cfg): Config } $opFeePayer = (bool) ($operatorCfg['fee_payer'] ?? true); + // A null/empty realm derives a per-recipient default (audit #15); a + // shared literal like "Laravel" would put every Laravel app on one + // credential namespace when they also share a binding secret. + $rawRealm = $cfg['mpp']['realm'] ?? null; $mpp = new MppConfig( - realm: (string) ($cfg['mpp']['realm'] ?? 'Laravel'), + realm: ($rawRealm === null || (string) $rawRealm === '') ? null : (string) $rawRealm, challengeBindingSecret: isset($cfg['mpp_challenge_binding_secret']) && $cfg['mpp_challenge_binding_secret'] !== '' ? (string) $cfg['mpp_challenge_binding_secret'] diff --git a/php/src/Frameworks/Laravel/config/paykit.php b/php/src/Frameworks/Laravel/config/paykit.php index dd4d8c001..4abae2b47 100644 --- a/php/src/Frameworks/Laravel/config/paykit.php +++ b/php/src/Frameworks/Laravel/config/paykit.php @@ -15,7 +15,9 @@ 'x402_facilitator_url' => env('PAY_KIT_X402_FACILITATOR_URL'), 'mpp_challenge_binding_secret' => env('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET'), 'mpp' => [ - 'realm' => env('PAY_KIT_MPP_REALM', 'Laravel'), + // Leave unset to derive a per-recipient realm (audit #15). Set + // PAY_KIT_MPP_REALM only when you want an explicit, app-specific realm. + 'realm' => env('PAY_KIT_MPP_REALM'), 'expires_in' => 120, ], 'preflight' => env('PAY_KIT_PREFLIGHT', true), diff --git a/php/src/PayCore/Solana/Mints.php b/php/src/PayCore/Solana/Mints.php index b00abe5de..a5a88ddbd 100644 --- a/php/src/PayCore/Solana/Mints.php +++ b/php/src/PayCore/Solana/Mints.php @@ -113,6 +113,13 @@ private static function normalizeNetwork(string $network): string * `TokenProgram::TOKEN_2022_PROGRAM_ID` for stablecoins that live on * Token-2022 (PYUSD, USDG, CASH); `TokenProgram::PROGRAM_ID` for everything * else (including unknown / direct-mint inputs). + * + * WARNING (audit #28): for an *arbitrary, unknown* mint address this + * returns the legacy Token program, which is wrong for any Token-2022 mint + * not in the static table. Callers that may receive arbitrary mints (e.g. + * the MPP charge verifier) MUST NOT rely on this default — see + * {@see isKnownMint()} to detect the case and {@see resolveTokenProgramOnChain()} + * to resolve the owner on-chain. */ public static function tokenProgramFor(string $currency, string $network = 'mainnet'): string { @@ -122,6 +129,56 @@ public static function tokenProgramFor(string $currency, string $network = 'main : TokenProgram::PROGRAM_ID; } + /** + * True when `$currency` resolves to a mint we know from the static table + * (by symbol or by mint address). Unknown arbitrary mint addresses return + * false — for those the token program cannot be inferred without an + * on-chain owner lookup (audit #28). + */ + public static function isKnownMint(string $currency, string $network = 'mainnet'): bool + { + return self::symbolFor($currency, $network) !== null; + } + + /** + * True when `$currency` is a bare base58 mint address (32+ chars) rather + * than a short symbol like "USDC". Used to tell "unknown symbol" (safe to + * leave as legacy) from "unknown arbitrary mint" (must resolve on-chain). + */ + public static function looksLikeMintAddress(string $currency): bool + { + return strtoupper($currency) !== 'SOL' + && strlen($currency) >= 32 + && PublicKey::isBase58AlphabetString($currency); + } + + /** + * Resolve the owning token program for an arbitrary mint by fetching the + * mint account's owner on-chain (spec §7.2), instead of guessing legacy + * Token (audit #28). Mirrors Rust `resolve_server_token_program`. + * + * `$accountOwnerFetcher` is given the mint address and must return the + * account owner's base58 pubkey, or null if the account does not exist. + * Rejects any owner that is neither the Token Program nor the Token-2022 + * Program. + * + * @param callable(string):?string $accountOwnerFetcher + */ + public static function resolveTokenProgramOnChain(string $mint, callable $accountOwnerFetcher): string + { + $owner = $accountOwnerFetcher($mint); + if ($owner === null || $owner === '') { + throw new \InvalidArgumentException('mint account not found on-chain: ' . $mint); + } + if ($owner !== TokenProgram::PROGRAM_ID && $owner !== TokenProgram::TOKEN_2022_PROGRAM_ID) { + throw new \InvalidArgumentException( + 'mint ' . $mint . ' is not owned by the Token or Token-2022 program (owner: ' . $owner . ')', + ); + } + + return $owner; + } + /** * Derive the Associated Token Account address for (owner, mint, * tokenProgram). Used by the boot-time preflight to assert the diff --git a/php/src/Protocols/Mpp/Adapter.php b/php/src/Protocols/Mpp/Adapter.php index 2180de94a..44d6a5b94 100644 --- a/php/src/Protocols/Mpp/Adapter.php +++ b/php/src/Protocols/Mpp/Adapter.php @@ -73,7 +73,7 @@ public function acceptsEntry(Gate $gate, ServerRequestInterface $request): array 'amount' => (string) $this->totalUnits($gate), 'currency' => $coin, 'payTo' => $payTo, - 'realm' => $this->config->mpp->realm, + 'realm' => $this->config->mpp->resolveRealm($payTo), ]; if ($gate->hasFees()) { $splits = []; @@ -198,10 +198,25 @@ private function serverFor(Gate $gate): array if (isset($this->handlerCache[$key])) { return $this->handlerCache[$key]; } + // Pin the route's known currency/recipient/network/decimals so + // ChargeServer::validateChargeRequest enforces the field-match checks + // unconditionally at issuance on the real route (audit #19 parity with + // Rust `validate_charge_request`, which pins to its own + // currency/network/decimals). The in-SDK request is built from this + // same route config (chargeRequestFor), so these are correct by + // construction; pinning makes the enforcement explicit and rejects any + // off-route request that reaches this oracle. Decimals is the SDK's + // fixed 6-dp micro-unit convention (matches the X402 adapter and + // priceUnits()). $charges = new ChargeServer( secretKey: $this->config->mpp->challengeBindingSecret ?? '', - realm: $this->config->mpp->realm, + realm: $this->config->mpp->resolveRealm($payTo), method: 'solana', + blockhashProvider: null, + pinnedCurrency: $coin, + pinnedRecipient: $payTo, + pinnedNetwork: $this->config->network->mintsLabel(), + pinnedDecimals: 6, ); $rpc = new RpcClient($this->config->rpcUrl); $feePayer = null; @@ -215,6 +230,7 @@ private function serverFor(Gate $gate): array feePayer: $feePayer, network: $this->config->network->mintsLabel(), replayStore: $this->replayStore, + acceptPushMode: $this->config->mpp->acceptPushMode, ); $this->handlerCache[$key] = [$charges, $handler]; return $this->handlerCache[$key]; diff --git a/php/src/Protocols/Mpp/Core/Headers.php b/php/src/Protocols/Mpp/Core/Headers.php index 703ae5162..3af91a385 100644 --- a/php/src/Protocols/Mpp/Core/Headers.php +++ b/php/src/Protocols/Mpp/Core/Headers.php @@ -17,6 +17,14 @@ final class Headers public const AUTHORIZATION = 'authorization'; public const PAYMENT_RECEIPT = 'payment-receipt'; + /** + * Max length of a base64url field that is decoded + JSON-parsed. Matches + * the credential ({@see Credential}) and receipt ({@see parseReceipt}) + * parsers so the challenge `request` param can't drive disproportionately + * larger decode/parse work than the other two parsers allow (audit #9). + */ + private const MAX_TOKEN_LENGTH = 16 * 1024; + /** * Format a Payment challenge as a WWW-Authenticate header. */ @@ -213,6 +221,9 @@ public static function parseWwwAuthenticate(string $header): Challenge } } + if (strlen($params['request']) > self::MAX_TOKEN_LENGTH) { + throw new InvalidArgumentException('Challenge request parameter exceeds maximum length of 16384 bytes'); + } Base64Url::decodeJson($params['request']); // validate the encoded charge request return new Challenge( @@ -241,7 +252,7 @@ public static function formatReceipt(Receipt $receipt): string */ public static function parseReceipt(string $header): Receipt { - if (strlen($header) > 16 * 1024) { + if (strlen($header) > self::MAX_TOKEN_LENGTH) { throw new InvalidArgumentException('Receipt exceeds maximum length of 16384 bytes'); } diff --git a/php/src/Protocols/Mpp/MppConfig.php b/php/src/Protocols/Mpp/MppConfig.php index 20a8fd73c..7e3de997c 100644 --- a/php/src/Protocols/Mpp/MppConfig.php +++ b/php/src/Protocols/Mpp/MppConfig.php @@ -21,13 +21,22 @@ * 120s matches the Python/Rust reference TTLs. Setting `expiresIn = 0` is * an explicit, documented development opt-out: challenges are issued with * no `expires`, so they never expire. Do not ship `0` to production. + * + * `realm` is part of the HMAC id input, so two services sharing one + * `challengeBindingSecret` and keeping the same realm would share a single + * credential namespace — a credential paid against service A would pass HMAC + * verification on service B (audit #15). The default is therefore `null`, + * which {@see resolveRealm()} turns into a value derived from the server's + * recipient pubkey (unique per merchant). An explicit empty-string realm is + * rejected so an operator cannot re-introduce the shared namespace by typo. */ final readonly class MppConfig { public function __construct( - public string $realm = 'App', + public ?string $realm = null, public ?string $challengeBindingSecret = null, public int $expiresIn = 120, + public bool $acceptPushMode = false, ) { if ($expiresIn < 0) { throw new ConfigurationException( @@ -35,11 +44,50 @@ public function __construct( . '(0 is the explicit dev-only never-expires opt-out)', ); } + if ($realm !== null && trim($realm) === '') { + throw new ConfigurationException( + 'pay_kit: mpp.realm must be a non-empty string or null (null derives a ' + . 'per-recipient default; an empty realm would share a credential namespace ' + . 'across servers — audit #15)', + ); + } } public function withChallengeBindingSecret(string $secret): self { - return new self($this->realm, $secret, $this->expiresIn); + return new self($this->realm, $secret, $this->expiresIn, $this->acceptPushMode); + } + + /** + * Resolve the effective realm for a server serving `$recipient`. + * + * Returns the explicitly-configured realm when one is set, otherwise + * derives a deterministic per-recipient default of the shape + * `"App Id - #"` (mirrors Rust `derive_default_realm`, + * rust/crates/mpp/src/server/charge.rs). Deriving from the recipient + * pubkey — unique per merchant and already mandatory upstream — means two + * services sharing a secret but paying different recipients get distinct + * realms, distinct HMAC ids, and cannot replay credentials across each + * other (audit #15). + */ + public function resolveRealm(string $recipient): string + { + if ($this->realm !== null) { + return $this->realm; + } + if ($recipient === '') { + throw new ConfigurationException( + 'pay_kit: cannot derive a default mpp.realm without a recipient; ' + . 'set mpp.realm explicitly or configure a recipient', + ); + } + + $digest = hash('sha256', $recipient, true); + $first4 = substr($digest, 0, 4); + $unpacked = unpack('Nvalue', $first4); + $value = is_array($unpacked) && is_int($unpacked['value']) ? $unpacked['value'] : 0; + + return sprintf('App Id - #%d', $value % 100_000_000); } /** diff --git a/php/src/Protocols/Mpp/SecretResolver.php b/php/src/Protocols/Mpp/SecretResolver.php index dd7c5214f..a4c44950b 100644 --- a/php/src/Protocols/Mpp/SecretResolver.php +++ b/php/src/Protocols/Mpp/SecretResolver.php @@ -4,6 +4,9 @@ namespace PayKit\Protocols\Mpp; +use InvalidArgumentException; +use PayKit\Protocols\Mpp\Server\ChargeServer; + /** * Auto-resolves the MPP HMAC challenge-binding secret when the * application doesn't set one explicitly. Mirrors Ruby PR #142's @@ -46,11 +49,13 @@ public static function resolveMppSecret( $fromEnv = getenv($envVar); if (is_string($fromEnv) && $fromEnv !== '') { + self::assertStrong($fromEnv, $envVar, 'environment variable'); return ['secret' => $fromEnv, 'source' => 'env', 'persisted' => true]; } $fromDotenv = self::readDotenv($dotenvPath, $envVar); if ($fromDotenv !== null) { + self::assertStrong($fromDotenv, $envVar, $dotenvPath); return ['secret' => $fromDotenv, 'source' => 'dotenv', 'persisted' => true]; } @@ -63,6 +68,26 @@ public static function resolveMppSecret( ]; } + /** + * Reject an operator-supplied secret below the {@see ChargeServer} + * minimum-length floor with a source-specific message (audit #24). The + * auto-generated fallback is 32 random bytes (64 hex chars) and always + * clears the floor, so only the env/dotenv paths gate here. + */ + private static function assertStrong(string $secret, string $key, string $source): void + { + if (strlen($secret) < ChargeServer::MIN_SECRET_KEY_BYTES) { + throw new InvalidArgumentException(sprintf( + 'pay_kit: %s "%s" is only %d bytes; the mpp challenge-binding secret ' + . 'must be at least %d bytes (e.g. `openssl rand -base64 32`) — audit #24', + $source, + $key, + strlen($secret), + ChargeServer::MIN_SECRET_KEY_BYTES, + )); + } + } + private static function readDotenv(string $path, string $key): ?string { if (!is_readable($path)) { diff --git a/php/src/Protocols/Mpp/Server/ChargeServer.php b/php/src/Protocols/Mpp/Server/ChargeServer.php index 0b7805d1f..4372a01e9 100644 --- a/php/src/Protocols/Mpp/Server/ChargeServer.php +++ b/php/src/Protocols/Mpp/Server/ChargeServer.php @@ -8,6 +8,7 @@ use DateTimeImmutable; use InvalidArgumentException; use Throwable; +use PayKit\PayCore\Solana\Mints; use PayKit\PayCore\Wire\Base64Url; use PayKit\Protocols\Mpp\Core\Challenge; use PayKit\Protocols\Mpp\Core\Credential; @@ -15,12 +16,27 @@ use PayKit\PayCore\Wire\Json; use PayKit\Protocols\Mpp\Core\Receipt; use PayKit\Protocols\Mpp\Intent\ChargeRequest; +use SolanaPhpSdk\Keypair\PublicKey; /** * Issues charge challenges and verifies Payment credentials for a PHP server. */ final class ChargeServer { + /** + * Minimum HMAC challenge-binding secret length in bytes. 32 bytes matches + * the SHA-256 output length (NIST SP 800-107 guidance for HMAC-SHA256) and + * the Rust reference (`MIN_SECRET_KEY_BYTES`); a shorter secret weakens the + * binding that prevents challenge forgery (audit #24). + */ + public const MIN_SECRET_KEY_BYTES = 32; + + /** + * Maximum number of splits a challenge may carry. Mirrors Rust + * `MAX_SPLITS` and the verifier's pre-broadcast count cap. + */ + public const MAX_SPLITS = 8; + /** * Create a charge server for one realm and payment method. * @@ -30,6 +46,18 @@ final class ChargeServer * an extra RPC round-trip. Throwing or returning an empty string is * treated as best-effort failure — the challenge is still issued without * a pre-fetched blockhash, and the client falls back to fetching its own. + * + * `$secretKey` MUST be at least {@see MIN_SECRET_KEY_BYTES} bytes. This is + * the single chokepoint every secret flows through — env, dotenv, or an + * Adapter-supplied value — so the floor is enforced here once (audit #24). + * + * When `$pinnedCurrency` / `$pinnedRecipient` / `$pinnedNetwork` / + * `$pinnedDecimals` are set they are also used to validate issued requests + * against the server's configured route at issuance time (audit #19). The + * in-SDK {@see \PayKit\Protocols\Mpp\Adapter} always supplies these from + * route config so the match checks fire unconditionally on the real route, + * matching Rust `validate_charge_request`. Callers constructing this server + * directly may leave them null to keep the structural-only behavior. */ public function __construct( private readonly string $secretKey, @@ -38,14 +66,46 @@ public function __construct( private readonly ?Closure $blockhashProvider = null, private readonly ?string $pinnedCurrency = null, private readonly ?string $pinnedRecipient = null, + private readonly ?string $pinnedNetwork = null, + private readonly ?int $pinnedDecimals = null, ) { + self::assertStrongSecretKey($secretKey); + if ($realm === '') { + throw new InvalidArgumentException('realm is required'); + } + } + + /** + * Reject HMAC secrets below the {@see MIN_SECRET_KEY_BYTES} floor. + * + * Shared by every secret-entry path (env / dotenv / Adapter) so a weak + * value cannot slip in regardless of where it originated (audit #24). + */ + public static function assertStrongSecretKey(string $secretKey): void + { + if (strlen($secretKey) < self::MIN_SECRET_KEY_BYTES) { + throw new InvalidArgumentException(sprintf( + 'mpp challenge-binding secret must be at least %d bytes of ' + . 'cryptographically-random data (e.g. `openssl rand -base64 32`); ' + . 'got %d bytes (audit #24)', + self::MIN_SECRET_KEY_BYTES, + strlen($secretKey), + )); + } } /** * Create a signed MPP charge challenge. + * + * Validates the request before signing (audit #19/#21/#38): `createChallenge` + * is a public HMAC-signing oracle, so an un-vetted request would let a buggy + * or hostile caller mint a cryptographically-valid challenge with off-route + * or malformed contents. The validation mirrors Rust `validate_charge_request`. */ public function createChallenge(ChargeRequest $request, string $expires = '', string $digest = '', ?string $opaque = null): Challenge { + $this->validateChargeRequest($request); + return Challenge::withSecret( secretKey: $this->secretKey, realm: $this->realm, @@ -58,6 +118,141 @@ public function createChallenge(ChargeRequest $request, string $expires = '', st ); } + /** + * Validate a charge request before it is HMAC-signed at issuance. + * + * Mirrors Rust `validate_charge_request` (rust/crates/mpp/src/server/charge.rs): + * + * - recipient is present and parses as a Solana pubkey; + * - when the server is pinned to a currency/recipient, the request matches + * it (audit #19 — the issuance-side counterpart of the verify-time pin); + * - methodDetails.network matches the pinned network when one is configured; + * - methodDetails.decimals matches the pinned decimals when both are present + * (Rust pins decimals too — charge.rs `validate_charge_request`); + * - splits are well-formed (audit #21): count <= MAX_SPLITS, each recipient + * parses as a pubkey, each amount is a positive base-unit integer, no + * duplicate recipients, and the split sum does not exceed the total; + * - no split both equals the primary recipient AND requires fee-sponsored + * ATA creation (audit #38 — the ATA-recreate slow-drain shape). + * + * `ChargeRequest::__construct` already guarantees a positive base-unit + * `amount` and a non-empty `currency`, so those are not re-checked here. + */ + private function validateChargeRequest(ChargeRequest $request): void + { + if ($request->recipient === '') { + throw new InvalidArgumentException('recipient is required'); + } + self::assertPubkey($request->recipient, 'recipient'); + + if ($this->pinnedCurrency !== null && strcasecmp($request->currency, $this->pinnedCurrency) !== 0) { + throw new InvalidArgumentException('charge request currency does not match server configuration'); + } + if ($this->pinnedRecipient !== null && $request->recipient !== $this->pinnedRecipient) { + throw new InvalidArgumentException('charge request recipient does not match server configuration'); + } + + $methodDetails = is_array($request->methodDetails) ? $request->methodDetails : []; + if ($this->pinnedNetwork !== null) { + $network = $methodDetails['network'] ?? null; + if (is_string($network) && $network !== '' && $network !== $this->pinnedNetwork) { + throw new InvalidArgumentException('charge request network does not match server configuration'); + } + } + if ($this->pinnedDecimals !== null) { + $decimals = $methodDetails['decimals'] ?? null; + if (is_int($decimals) && $decimals !== $this->pinnedDecimals) { + throw new InvalidArgumentException('charge request decimals does not match server configuration'); + } + } + + $this->validateSplits($methodDetails, $request); + } + + /** + * Validate the `methodDetails.splits` list at issuance (audit #21/#38). + * + * @param array $methodDetails + */ + private function validateSplits(array $methodDetails, ChargeRequest $request): void + { + $splits = $methodDetails['splits'] ?? null; + if ($splits === null) { + return; + } + if (!is_array($splits) || !array_is_list($splits)) { + throw new InvalidArgumentException('splits must be an array'); + } + if (count($splits) > self::MAX_SPLITS) { + throw new InvalidArgumentException(sprintf('too many splits (max %d)', self::MAX_SPLITS)); + } + + $totalAmount = self::parseAmount($request->amount, 'amount'); + $splitTotal = 0; + $seenRecipients = []; + foreach ($splits as $split) { + if (!is_array($split) || !isset($split['recipient'], $split['amount'])) { + throw new InvalidArgumentException('split recipient and amount are required'); + } + $recipient = $split['recipient']; + $amount = $split['amount']; + if (!is_string($recipient) || !is_string($amount)) { + throw new InvalidArgumentException('split recipient and amount must be strings'); + } + self::assertPubkey($recipient, 'split recipient'); + + $value = self::parseAmount($amount, 'split amount'); + if ($value <= 0) { + throw new InvalidArgumentException('split amount must be a positive base-unit integer'); + } + if (isset($seenRecipients[$recipient])) { + throw new InvalidArgumentException('duplicate split recipient: ' . $recipient); + } + $seenRecipients[$recipient] = true; + $splitTotal += $value; + + // audit #38: a fee-sponsored ATA-create for the primary recipient + // is a slow-drain shape (close + recreate the merchant's own ATA on + // the server's dime). Reject the combination at issuance; a primary + // recipient appearing as a plain split (no ATA create) stays allowed. + if ($recipient === $request->recipient && ($split['ataCreationRequired'] ?? false) === true) { + throw new InvalidArgumentException( + 'primary recipient cannot appear in splits with ataCreationRequired=true (audit #38)', + ); + } + } + + if ($splitTotal > $totalAmount) { + throw new InvalidArgumentException('split amounts exceed total amount'); + } + } + + private static function assertPubkey(string $value, string $label): void + { + try { + new PublicKey($value); + } catch (Throwable) { + throw new InvalidArgumentException(sprintf('%s must be a valid Solana pubkey', $label)); + } + } + + private static function parseAmount(string $amount, string $field): int + { + if ($amount === '' || !ctype_digit($amount)) { + throw new InvalidArgumentException($field . ' must be a base-unit integer'); + } + // Compare digit strings to stay clear of PHP_INT_MAX before the cast. + $normalized = ltrim($amount, '0'); + $max = (string) PHP_INT_MAX; + $overflow = strlen($normalized) > strlen($max) + || (strlen($normalized) === strlen($max) && strcmp($normalized, $max) > 0); + if ($overflow) { + throw new InvalidArgumentException($field . ' exceeds PHP integer range'); + } + + return (int) $amount; + } + /** * Pre-fetch a recent blockhash and merge it into the request's method * details. Best-effort: a missing provider, a provider exception, or an diff --git a/php/src/Protocols/Mpp/Server/SolanaChargeHandler.php b/php/src/Protocols/Mpp/Server/SolanaChargeHandler.php index 6244cd21b..adf635632 100644 --- a/php/src/Protocols/Mpp/Server/SolanaChargeHandler.php +++ b/php/src/Protocols/Mpp/Server/SolanaChargeHandler.php @@ -84,11 +84,15 @@ public function __construct( private readonly int $confirmationAttempts = 40, private readonly int $confirmationDelayMicros = 250_000, ?Store $replayStore = null, + bool $acceptPushMode = false, ) { $this->rpc = $rpc instanceof RpcGateway ? $rpc : new SolanaRpcGateway($rpc); - $this->verifier = $verifier ?? new SolanaChargeTransactionVerifier(); + // Push mode (§13.5) is off by default; the default verifier is built + // with the route's opt-in so a non-opting route rejects push-mode + // credentials at verification (audit #5). + $this->verifier = $verifier ?? new SolanaChargeTransactionVerifier(acceptPushMode: $acceptPushMode); $this->transactionVerifier = $transactionVerifier - ?? ($this->verifier instanceof TransactionPayloadVerifier ? $this->verifier : new SolanaChargeTransactionVerifier()); + ?? ($this->verifier instanceof TransactionPayloadVerifier ? $this->verifier : new SolanaChargeTransactionVerifier(acceptPushMode: $acceptPushMode)); $this->replayStore = $replayStore ?? new MemoryStore(); } diff --git a/php/src/Protocols/Mpp/Server/SolanaChargeTransactionVerifier.php b/php/src/Protocols/Mpp/Server/SolanaChargeTransactionVerifier.php index 60b015181..672e197a2 100644 --- a/php/src/Protocols/Mpp/Server/SolanaChargeTransactionVerifier.php +++ b/php/src/Protocols/Mpp/Server/SolanaChargeTransactionVerifier.php @@ -41,6 +41,26 @@ final class SolanaChargeTransactionVerifier implements PaymentVerifier, Transact private const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111'; private const MAX_COMPUTE_UNIT_LIMIT = 200_000; private const MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 5_000_000; + // Tight cap for fee-sponsored pull mode, where the server signs before + // broadcast and pays the priority fee. Worst-case priority fee at this cap + // is ceil(10_000 * 200_000 / 1_000_000) = 2_000 lamports (~20% of the + // per-signature base fee) — enough headroom for honest clients to bump + // priority during congestion without exposing the merchant fee-payer to a + // drain. Client-paid mode keeps the general 5M ceiling (audit #25). + private const MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED = 10_000; + + /** + * @param bool $acceptPushMode Opt-in for push-mode (`type=signature`) + * credentials. Default `false` (off), matching the Rust reference. + * Push mode accepts the first presented on-chain signature (spec + * §13.5), a trade-off operators must consciously take on; routes + * that never opt in reject signature credentials outright instead of + * exposing the surface by default (audit #5). + */ + public function __construct( + private readonly bool $acceptPushMode = false, + ) { + } /** * Verify a pull- or push-mode credential against its challenge. @@ -73,6 +93,11 @@ public function verify(Credential $credential, Challenge $challenge): Verificati $signature = $credential->payload['signature'] ?? null; if (is_string($signature) && $signature !== '') { + if (!$this->acceptPushMode) { + // §13.5 push mode is off by default; reject before any shape + // check so a non-opting route never accepts a push credential. + return VerificationResult::failure('push-mode credentials are not accepted by this server'); + } try { $this->validateSignature($signature); } catch (Throwable $error) { @@ -187,6 +212,7 @@ private function verifyTransaction(string $transactionBase64, ChargeRequest $req requiredAtaOwners: [], createdAtaOwners: $createdAtaOwners, onChain: $onChain, + feeSponsored: $feePayer !== null, ); return; } @@ -194,8 +220,22 @@ private function verifyTransaction(string $transactionBase64, ChargeRequest $req $network = Json::optionalString($methodDetails['network'] ?? null, 'methodDetails.network', 'mainnet'); $resolvedMint = Mints::resolve($request->currency, $network) ?? $request->currency; $mint = new PublicKey($resolvedMint); - $defaultTokenProgram = Mints::tokenProgramFor($request->currency, $network); - $tokenProgram = new PublicKey(Json::optionalString($methodDetails['tokenProgram'] ?? null, 'methodDetails.tokenProgram', $defaultTokenProgram)); + // audit #28: for a known stablecoin the static table is authoritative; + // for an arbitrary, unknown mint address we cannot infer the owning + // token program (the legacy default is wrong for any unknown Token-2022 + // mint), so the embedded methodDetails.tokenProgram is REQUIRED. There + // is no RPC on this verifier path to resolve the owner on-chain. + $embeddedTokenProgram = Json::optionalString($methodDetails['tokenProgram'] ?? null, 'methodDetails.tokenProgram', ''); + if ($embeddedTokenProgram !== '') { + $tokenProgram = new PublicKey($embeddedTokenProgram); + } elseif (Mints::isKnownMint($request->currency, $network)) { + $tokenProgram = new PublicKey(Mints::tokenProgramFor($request->currency, $network)); + } else { + throw new InvalidArgumentException( + 'methodDetails.tokenProgram is required for an unknown mint address; ' + . 'the token program cannot be inferred for arbitrary mints (audit #28)', + ); + } $decimals = Json::optionalInt($methodDetails['decimals'] ?? null, 'methodDetails.decimals'); $allowedAtaOwners = $this->allowedAtaOwners($splits, $feePayer); if ($requiredAtaOwners !== [] && $resolvedMint !== $mint->toBase58()) { @@ -241,6 +281,7 @@ private function verifyTransaction(string $transactionBase64, ChargeRequest $req requiredAtaOwners: $requiredAtaOwners, createdAtaOwners: $createdAtaOwners, onChain: $onChain, + feeSponsored: $feePayer !== null, ); } @@ -480,6 +521,7 @@ private function validateInstructionAllowlist( array $requiredAtaOwners, array &$createdAtaOwners, bool $onChain = false, + bool $feeSponsored = false, ): void { $allowedPrograms = [ self::COMPUTE_BUDGET_PROGRAM, @@ -501,7 +543,7 @@ private function validateInstructionAllowlist( // `continue` here (charge.rs:1873-1876); only the pull-mode // pre-broadcast path enforces the caps. if (!$onChain) { - $this->validateComputeBudgetInstruction($instruction); + $this->validateComputeBudgetInstruction($instruction, $feeSponsored); } continue; } @@ -533,7 +575,7 @@ private function validateInstructionAllowlist( /** * @param array{programIdIndex: int, accounts: array, data: string} $instruction */ - private function validateComputeBudgetInstruction(array $instruction): void + private function validateComputeBudgetInstruction(array $instruction, bool $feeSponsored = false): void { if ($instruction['accounts'] !== []) { throw new InvalidArgumentException('compute budget instruction must not have accounts'); @@ -553,7 +595,14 @@ private function validateComputeBudgetInstruction(array $instruction): void } if ($kind === 3 && strlen($data) === 9) { $price = $this->readU64Le(substr($data, 1, 8)); - if ($price > self::MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS) { + // In fee-sponsored pull mode the server pays the priority fee, so + // an attacker can otherwise pick a price up to the general 5M cap + // and drain the merchant fee-payer (audit #25). Apply the tight cap + // when the server is the fee payer; keep the 5M ceiling otherwise. + $cap = $feeSponsored + ? self::MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED + : self::MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS; + if ($price > $cap) { throw new InvalidArgumentException('compute unit price exceeds maximum'); } return; diff --git a/php/tests/Middleware/RequirePaymentTest.php b/php/tests/Middleware/RequirePaymentTest.php index 911699bce..6ec9e82d4 100644 --- a/php/tests/Middleware/RequirePaymentTest.php +++ b/php/tests/Middleware/RequirePaymentTest.php @@ -34,7 +34,7 @@ protected function setUp(): void network: Network::SolanaDevnet, operator: new Operator(recipient: Signer::generate()->pubkey(), signer: Signer::generate(), feePayer: true), preflight: false, - mpp: new MppConfig(challengeBindingSecret: 'unit-test'), + mpp: new MppConfig(challengeBindingSecret: 'unit-test-secret-0123456789abcdef-01'), )); $this->factory = new Psr17Factory(); } diff --git a/php/tests/MppConfigTest.php b/php/tests/MppConfigTest.php index 96a76ea4c..9592d9bf1 100644 --- a/php/tests/MppConfigTest.php +++ b/php/tests/MppConfigTest.php @@ -13,9 +13,40 @@ final class MppConfigTest extends TestCase public function testDefaultsMatchCrossLanguageTarget(): void { $c = new MppConfig(); - $this->assertSame('App', $c->realm); + // audit #15: the default realm is now null (= derive per-recipient), + // not a shared literal that would put servers sharing a secret on one + // credential namespace. + $this->assertNull($c->realm); $this->assertSame(120, $c->expiresIn); $this->assertNull($c->challengeBindingSecret); + $this->assertFalse($c->acceptPushMode); + } + + public function testEmptyRealmRejected(): void + { + // An explicit empty realm would re-introduce the shared namespace. + $this->expectException(ConfigurationException::class); + new MppConfig(realm: ''); + } + + public function testResolveRealmDerivesDeterministicPerRecipientDefault(): void + { + $a = new MppConfig(); + $recipientA = 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY'; + $recipientB = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; + + $realmA = $a->resolveRealm($recipientA); + // Deterministic / restart-safe. + $this->assertSame($realmA, $a->resolveRealm($recipientA)); + $this->assertMatchesRegularExpression('/^App Id - #\d{1,8}$/', $realmA); + // Different recipients get different realms (closes the audit shape). + $this->assertNotSame($realmA, $a->resolveRealm($recipientB)); + } + + public function testResolveRealmUsesExplicitRealmWhenSet(): void + { + $c = new MppConfig(realm: 'Acme API'); + $this->assertSame('Acme API', $c->resolveRealm('CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY')); } public function testExpiresInZeroIsDevOnlyOptOut(): void diff --git a/php/tests/PayCore/MintsTest.php b/php/tests/PayCore/MintsTest.php index 50daa4dad..59033ae20 100644 --- a/php/tests/PayCore/MintsTest.php +++ b/php/tests/PayCore/MintsTest.php @@ -81,6 +81,49 @@ public function testSymbolForRoundTripsSymbolsAndMints(): void self::assertNull(Mints::symbolFor('FOOBAR')); } + // audit #28 — arbitrary mint resolution + public function testIsKnownMintDistinguishesTableMintsFromArbitrary(): void + { + self::assertTrue(Mints::isKnownMint('USDC')); + self::assertTrue(Mints::isKnownMint('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v')); + // An arbitrary base58 mint address not in the static table. + self::assertFalse(Mints::isKnownMint('So11111111111111111111111111111111111111112')); + } + + public function testLooksLikeMintAddress(): void + { + self::assertTrue(Mints::looksLikeMintAddress('So11111111111111111111111111111111111111112')); + self::assertFalse(Mints::looksLikeMintAddress('USDC')); + self::assertFalse(Mints::looksLikeMintAddress('SOL')); + } + + public function testResolveTokenProgramOnChainReturnsOwnerForToken2022(): void + { + $owner = Mints::resolveTokenProgramOnChain( + 'So11111111111111111111111111111111111111112', + fn (string $mint): string => TokenProgram::TOKEN_2022_PROGRAM_ID, + ); + self::assertSame(TokenProgram::TOKEN_2022_PROGRAM_ID, $owner); + } + + public function testResolveTokenProgramOnChainRejectsForeignOwner(): void + { + $this->expectException(\InvalidArgumentException::class); + Mints::resolveTokenProgramOnChain( + 'So11111111111111111111111111111111111111112', + fn (string $mint): string => '11111111111111111111111111111111', + ); + } + + public function testResolveTokenProgramOnChainRejectsMissingMint(): void + { + $this->expectException(\InvalidArgumentException::class); + Mints::resolveTokenProgramOnChain( + 'So11111111111111111111111111111111111111112', + fn (string $mint): ?string => null, + ); + } + /** * The canonical mainnet slug is `mainnet`. Legacy `mainnet-beta` input * is folded back to `mainnet` by normalizeNetwork() inside resolve(), diff --git a/php/tests/Protocols/Mpp/AdapterTest.php b/php/tests/Protocols/Mpp/AdapterTest.php index 105eb859f..fcf9bd1fe 100644 --- a/php/tests/Protocols/Mpp/AdapterTest.php +++ b/php/tests/Protocols/Mpp/AdapterTest.php @@ -13,6 +13,7 @@ use PayKit\Price; use PayKit\Protocol; use PayKit\Protocols\Mpp\Adapter; +use PayKit\Protocols\Mpp\Intent\ChargeRequest; use PayKit\Protocols\Mpp\MppConfig; use PayKit\Signer; use PayKit\PayCore\Stablecoin; @@ -30,7 +31,7 @@ private function makeConfig(): Config feePayer: true, ), preflight: false, - mpp: new MppConfig(challengeBindingSecret: 'unit-test'), + mpp: new MppConfig(challengeBindingSecret: 'unit-test-secret-0123456789abcdef-01'), ); } @@ -132,6 +133,90 @@ public function testVerifyAndSettleWithoutAuthorizationRaises(): void $adapter->verifyAndSettle($gate, $req); } + /** + * audit #19 (parity): the in-SDK Adapter path must pin the route's + * currency/recipient/network/decimals into the ChargeServer so the + * field-match checks in validateChargeRequest fire unconditionally at + * issuance — matching Rust `validate_charge_request`. Previously serverFor + * built the ChargeServer without pins, leaving those checks dormant on the + * real route. These tests reach the route's ChargeServer via serverFor and + * prove an off-route request is now rejected at issuance. + */ + private function serverFor(Adapter $adapter, Gate $gate): \PayKit\Protocols\Mpp\Server\ChargeServer + { + $method = new \ReflectionMethod($adapter, 'serverFor'); + [$charges, $_handler] = $method->invoke($adapter, $gate); + return $charges; + } + + public function testAdapterPathIssuesValidChallengeForOnRouteRequest(): void + { + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg); + $gate = new Gate(amount: Price::usd('0.10')); + + // The on-route request built by the adapter must pass the (now-active) + // pinned validation and produce a verifiable challenge. + $charges = $this->serverFor($adapter, $gate); + $chargeRequestMethod = new \ReflectionMethod($adapter, 'chargeRequestFor'); + $chargeRequest = $chargeRequestMethod->invoke($adapter, $gate); + + $challenge = $charges->createChallenge($chargeRequest); + $this->assertTrue($challenge->verify($cfg->mpp->challengeBindingSecret ?? '')); + } + + public function testAdapterPathRejectsMismatchedCurrencyAtIssuance(): void + { + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg); + $gate = new Gate(amount: Price::usd('0.10')); + $charges = $this->serverFor($adapter, $gate); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('currency does not match server configuration'); + $charges->createChallenge(new ChargeRequest( + amount: '100000', + currency: 'USDT', + recipient: $cfg->effectiveRecipient(), + methodDetails: ['network' => $cfg->network->mintsLabel()], + )); + } + + public function testAdapterPathRejectsMismatchedRecipientAtIssuance(): void + { + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg); + $gate = new Gate(amount: Price::usd('0.10')); + $charges = $this->serverFor($adapter, $gate); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('recipient does not match server configuration'); + $charges->createChallenge(new ChargeRequest( + amount: '100000', + currency: 'USDC', + recipient: Signer::generate()->pubkey(), + methodDetails: ['network' => $cfg->network->mintsLabel()], + )); + } + + public function testAdapterPathRejectsMismatchedNetworkAtIssuance(): void + { + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg); + $gate = new Gate(amount: Price::usd('0.10')); + $charges = $this->serverFor($adapter, $gate); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('network does not match server configuration'); + $charges->createChallenge(new ChargeRequest( + amount: '100000', + currency: 'USDC', + recipient: $cfg->effectiveRecipient(), + // config is SolanaDevnet -> "devnet"; advertise a different network. + methodDetails: ['network' => 'mainnet'], + )); + } + private function makeConfigWithExpiresIn(int $expiresIn): Config { return new Config( @@ -142,7 +227,7 @@ private function makeConfigWithExpiresIn(int $expiresIn): Config feePayer: true, ), preflight: false, - mpp: new MppConfig(challengeBindingSecret: 'unit-test', expiresIn: $expiresIn), + mpp: new MppConfig(challengeBindingSecret: 'unit-test-secret-0123456789abcdef-01', expiresIn: $expiresIn), ); } diff --git a/php/tests/Protocols/Mpp/Core/HeadersTest.php b/php/tests/Protocols/Mpp/Core/HeadersTest.php index a042994e0..fd232673c 100644 --- a/php/tests/Protocols/Mpp/Core/HeadersTest.php +++ b/php/tests/Protocols/Mpp/Core/HeadersTest.php @@ -263,4 +263,28 @@ public function testReceiptHeaderRoundTrip(): void self::assertSame('challenge-id', $parsed->challengeId); self::assertSame('order-001', $parsed->externalId); } + + public function testRejectsOversizedRequestParam(): void + { + // audit #9: the request param is base64url-decoded + JSON-parsed, so it + // must be capped like the credential/receipt parsers (16 KiB). + $oversized = str_repeat('A', 16 * 1024 + 1); + $header = sprintf( + 'Payment id=x, realm=api, method=solana, intent=charge, request="%s"', + $oversized, + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Challenge request parameter exceeds maximum length'); + Headers::parseWwwAuthenticate($header); + } + + public function testAcceptsRequestParamAtMaxSize(): void + { + // Regression: an at-cap (well-formed) request param must not trip the + // size gate. The encoded JSON of a real challenge is far below 16 KiB. + $challenge = Challenge::withSecret('secret', 'api', 'solana', 'charge', ['amount' => '1', 'currency' => 'USDC']); + $parsed = Headers::parseWwwAuthenticate(Headers::formatWwwAuthenticate($challenge)); + self::assertSame($challenge->id, $parsed->id); + } } diff --git a/php/tests/Protocols/Mpp/Server/ChargeServerTest.php b/php/tests/Protocols/Mpp/Server/ChargeServerTest.php index 56cf4b78b..14c1a05e9 100644 --- a/php/tests/Protocols/Mpp/Server/ChargeServerTest.php +++ b/php/tests/Protocols/Mpp/Server/ChargeServerTest.php @@ -15,13 +15,229 @@ use PayKit\Protocols\Mpp\Server\ChargeServer; use PayKit\Protocols\Mpp\Server\PaymentVerifier; use PayKit\Protocols\Mpp\Server\VerificationResult; +use SolanaPhpSdk\Keypair\PublicKey; final class ChargeServerTest extends TestCase { + private const SECRET = 'test-secret-0123456789abcdef-0123456789'; + private const RECIPIENT = 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY'; + private const SPLIT_RECIPIENT = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; + + // audit #24 — secret key strength + public function testConstructorRejectsShortSecretKey(): void + { + $this->expectException(\InvalidArgumentException::class); + new ChargeServer(secretKey: 'short', realm: 'api'); + } + + public function testConstructorAcceptsSecretKeyAtMinimumLength(): void + { + $secret = str_repeat('a', 32); + $server = new ChargeServer(secretKey: $secret, realm: 'api'); + $challenge = $server->createChallenge( + new ChargeRequest(amount: '1', currency: 'USDC', recipient: self::RECIPIENT), + ); + self::assertTrue($challenge->verify($secret)); + } + + // audit #19 — issuance request validation + public function testCreateChallengeRejectsMissingRecipient(): void + { + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api'); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('recipient is required'); + $server->createChallenge(new ChargeRequest(amount: '1000', currency: 'USDC')); + } + + public function testCreateChallengeRejectsNonPubkeyRecipient(): void + { + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api'); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('recipient must be a valid Solana pubkey'); + $server->createChallenge(new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'not-a-pubkey')); + } + + public function testCreateChallengeRejectsMismatchedPinnedCurrency(): void + { + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api', pinnedCurrency: 'USDC'); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('currency does not match server configuration'); + $server->createChallenge(new ChargeRequest(amount: '1000', currency: 'USDT', recipient: self::RECIPIENT)); + } + + public function testCreateChallengeRejectsMismatchedPinnedRecipient(): void + { + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api', pinnedRecipient: self::RECIPIENT); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('recipient does not match server configuration'); + $server->createChallenge(new ChargeRequest(amount: '1000', currency: 'USDC', recipient: self::SPLIT_RECIPIENT)); + } + + public function testCreateChallengeRejectsMismatchedPinnedNetwork(): void + { + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api', pinnedNetwork: 'localnet'); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('network does not match server configuration'); + $server->createChallenge(new ChargeRequest( + amount: '1000', + currency: 'USDC', + recipient: self::RECIPIENT, + methodDetails: ['network' => 'mainnet'], + )); + } + + public function testCreateChallengeRejectsMismatchedPinnedDecimals(): void + { + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api', pinnedDecimals: 6); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('decimals does not match server configuration'); + $server->createChallenge(new ChargeRequest( + amount: '1000', + currency: 'USDC', + recipient: self::RECIPIENT, + methodDetails: ['decimals' => 9], + )); + } + + public function testCreateChallengeAcceptsMatchingPinnedDecimals(): void + { + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api', pinnedDecimals: 6); + $challenge = $server->createChallenge(new ChargeRequest( + amount: '1000', + currency: 'USDC', + recipient: self::RECIPIENT, + methodDetails: ['decimals' => 6], + )); + self::assertTrue($challenge->verify(self::SECRET)); + } + + // audit #21 — split validation at issuance + public function testCreateChallengeRejectsTooManySplits(): void + { + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api'); + $splits = []; + for ($i = 0; $i < 9; $i++) { + // Distinct recipients so the count cap (not dedup) is what fires. + $splits[] = ['recipient' => self::distinctPubkey($i), 'amount' => '1']; + } + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('too many splits'); + $server->createChallenge(new ChargeRequest( + amount: '1000', + currency: 'USDC', + recipient: self::RECIPIENT, + methodDetails: ['splits' => $splits], + )); + } + + public function testCreateChallengeRejectsNonPubkeySplitRecipient(): void + { + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api'); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('split recipient must be a valid Solana pubkey'); + $server->createChallenge(new ChargeRequest( + amount: '1000', + currency: 'USDC', + recipient: self::RECIPIENT, + methodDetails: ['splits' => [['recipient' => 'bogus', 'amount' => '100']]], + )); + } + + public function testCreateChallengeRejectsZeroSplitAmount(): void + { + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api'); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('split amount must be a positive base-unit integer'); + $server->createChallenge(new ChargeRequest( + amount: '1000', + currency: 'USDC', + recipient: self::RECIPIENT, + methodDetails: ['splits' => [['recipient' => self::SPLIT_RECIPIENT, 'amount' => '0']]], + )); + } + + public function testCreateChallengeRejectsDuplicateSplitRecipient(): void + { + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api'); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('duplicate split recipient'); + $server->createChallenge(new ChargeRequest( + amount: '1000', + currency: 'USDC', + recipient: self::RECIPIENT, + methodDetails: ['splits' => [ + ['recipient' => self::SPLIT_RECIPIENT, 'amount' => '100'], + ['recipient' => self::SPLIT_RECIPIENT, 'amount' => '200'], + ]], + )); + } + + public function testCreateChallengeRejectsSplitSumExceedingAmount(): void + { + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api'); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('split amounts exceed total amount'); + $server->createChallenge(new ChargeRequest( + amount: '100', + currency: 'USDC', + recipient: self::RECIPIENT, + methodDetails: ['splits' => [['recipient' => self::SPLIT_RECIPIENT, 'amount' => '200']]], + )); + } + + public function testCreateChallengeAcceptsWellFormedSplits(): void + { + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api'); + $challenge = $server->createChallenge(new ChargeRequest( + amount: '1000', + currency: 'USDC', + recipient: self::RECIPIENT, + methodDetails: ['splits' => [['recipient' => self::SPLIT_RECIPIENT, 'amount' => '250']]], + )); + self::assertTrue($challenge->verify(self::SECRET)); + } + + // audit #38 — primary recipient in splits + ataCreationRequired + public function testCreateChallengeRejectsPrimaryRecipientSplitWithAtaCreation(): void + { + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api'); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('primary recipient cannot appear in splits with ataCreationRequired=true'); + $server->createChallenge(new ChargeRequest( + amount: '1000', + currency: 'USDC', + recipient: self::RECIPIENT, + methodDetails: ['splits' => [[ + 'recipient' => self::RECIPIENT, + 'amount' => '250', + 'ataCreationRequired' => true, + ]]], + )); + } + + public function testCreateChallengeAllowsPrimaryRecipientSplitWithoutAtaCreation(): void + { + // The legitimate use the strict ban would over-block: primary recipient + // taking a split, with no fee-sponsored ATA creation. + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api'); + $challenge = $server->createChallenge(new ChargeRequest( + amount: '1000', + currency: 'USDC', + recipient: self::RECIPIENT, + methodDetails: ['splits' => [['recipient' => self::RECIPIENT, 'amount' => '250']]], + )); + self::assertTrue($challenge->verify(self::SECRET)); + } + + private static function distinctPubkey(int $seed): string + { + return (new PublicKey(str_repeat(chr(($seed % 254) + 1), 32)))->toBase58(); + } + public function testCreatesChallengeHeaderAndVerifiesCredential(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); - $request = new ChargeRequest(amount: '1000', currency: 'USDC', externalId: 'order-001'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); + $request = new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', externalId: 'order-001'); $challenge = Headers::parseWwwAuthenticate($server->createChallengeHeader($request)); $credential = new Credential( challenge: $challenge->toEcho(), @@ -55,9 +271,9 @@ public function verify(Credential $credential, Challenge $challenge): Verificati public function testRejectsCredentialsForWrongSecret(): void { - $issuer = new ChargeServer(secretKey: 'issuer-secret', realm: 'api'); - $server = new ChargeServer(secretKey: 'server-secret', realm: 'api'); - $challenge = $issuer->createChallenge(new ChargeRequest(amount: '1', currency: 'USDC')); + $issuer = new ChargeServer(secretKey: 'issuer-secret-0123456789abcdef-012345', realm: 'api'); + $server = new ChargeServer(secretKey: 'server-secret-0123456789abcdef-012345', realm: 'api'); + $challenge = $issuer->createChallenge(new ChargeRequest(amount: '1', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY')); $credential = new Credential(challenge: $challenge->toEcho(), payload: ['type' => 'signature']); $result = $server->verifyAuthorizationHeader( @@ -71,9 +287,9 @@ public function testRejectsCredentialsForWrongSecret(): void public function testRejectsCredentialsForWrongRealm(): void { - $issuer = new ChargeServer(secretKey: 'secret', realm: 'issuer-api'); - $server = new ChargeServer(secretKey: 'secret', realm: 'server-api'); - $challenge = $issuer->createChallenge(new ChargeRequest(amount: '1', currency: 'USDC')); + $issuer = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'issuer-api'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'server-api'); + $challenge = $issuer->createChallenge(new ChargeRequest(amount: '1', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY')); $credential = new Credential(challenge: $challenge->toEcho(), payload: ['type' => 'signature']); $result = $server->verifyAuthorizationHeader( @@ -87,9 +303,9 @@ public function testRejectsCredentialsForWrongRealm(): void public function testRejectsExpiredChallenge(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $challenge = $server->createChallenge( - new ChargeRequest(amount: '1', currency: 'USDC'), + new ChargeRequest(amount: '1', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY'), expires: '2026-01-01T00:00:00+00:00', ); $credential = new Credential(challenge: $challenge->toEcho(), payload: ['type' => 'signature']); @@ -106,7 +322,7 @@ public function testRejectsExpiredChallenge(): void public function testRejectsMalformedAuthorizationHeader(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $result = $server->verifyAuthorizationHeader('Bearer invalid', $this->unusedVerifier()); @@ -116,9 +332,9 @@ public function testRejectsMalformedAuthorizationHeader(): void public function testRejectsChallengeMethodMismatch(): void { - $issuer = new ChargeServer(secretKey: 'secret', realm: 'api', method: 'card'); - $server = new ChargeServer(secretKey: 'secret', realm: 'api', method: 'solana'); - $challenge = $issuer->createChallenge(new ChargeRequest(amount: '1', currency: 'USD')); + $issuer = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api', method: 'card'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api', method: 'solana'); + $challenge = $issuer->createChallenge(new ChargeRequest(amount: '1', currency: 'USD', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY')); $credential = new Credential(challenge: $challenge->toEcho(), payload: ['type' => 'card']); $result = $server->verifyAuthorizationHeader($credential->toAuthorizationHeader(), $this->unusedVerifier()); @@ -131,7 +347,7 @@ public function testRejectsInvalidChargeRequestEcho(): void { $request = 'not-json'; $challenge = new Challenge( - id: Challenge::computeId('secret', 'api', 'solana', 'charge', $request), + id: Challenge::computeId('test-secret-0123456789abcdef-0123456789', 'api', 'solana', 'charge', $request), realm: 'api', method: 'solana', intent: 'charge', @@ -139,7 +355,7 @@ public function testRejectsInvalidChargeRequestEcho(): void ); $credential = new Credential(challenge: $challenge->toEcho(), payload: ['type' => 'signature']); - $result = (new ChargeServer(secretKey: 'secret', realm: 'api'))->verifyAuthorizationHeader( + $result = (new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'))->verifyAuthorizationHeader( $credential->toAuthorizationHeader(), $this->unusedVerifier(), ); @@ -150,9 +366,9 @@ public function testRejectsInvalidChargeRequestEcho(): void public function testRejectsCrossRouteChargeRequestReplay(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); - $cheapRequest = new ChargeRequest(amount: '500', currency: 'USDC', externalId: 'cheap'); - $expensiveRequest = new ChargeRequest(amount: '1000', currency: 'USDC', externalId: 'expensive'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); + $cheapRequest = new ChargeRequest(amount: '500', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', externalId: 'cheap'); + $expensiveRequest = new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', externalId: 'expensive'); $challenge = $server->createChallenge($cheapRequest); $credential = new Credential(challenge: $challenge->toEcho(), payload: ['type' => 'signature']); @@ -169,32 +385,32 @@ public function testRejectsCrossRouteChargeRequestReplay(): void public function testRejectsExpectedAmountMismatch(): void { $this->assertExpectedRequestMismatch( - challengeRequest: new ChargeRequest(amount: '500', currency: 'USDC', recipient: 'recipient'), - expectedRequest: new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'recipient'), + challengeRequest: new ChargeRequest(amount: '500', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY'), + expectedRequest: new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY'), ); } public function testRejectsExpectedCurrencyMismatch(): void { $this->assertExpectedRequestMismatch( - challengeRequest: new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'recipient'), - expectedRequest: new ChargeRequest(amount: '1000', currency: 'PYUSD', recipient: 'recipient'), + challengeRequest: new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY'), + expectedRequest: new ChargeRequest(amount: '1000', currency: 'PYUSD', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY'), ); } public function testRejectsExpectedRecipientMismatch(): void { $this->assertExpectedRequestMismatch( - challengeRequest: new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'recipient-a'), - expectedRequest: new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'recipient-b'), + challengeRequest: new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY'), + expectedRequest: new ChargeRequest(amount: '1000', currency: 'USDC', recipient: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'), ); } public function testRejectsExpectedExternalIdMismatch(): void { $this->assertExpectedRequestMismatch( - challengeRequest: new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'recipient', externalId: 'order-a'), - expectedRequest: new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'recipient', externalId: 'order-b'), + challengeRequest: new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', externalId: 'order-a'), + expectedRequest: new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', externalId: 'order-b'), ); } @@ -204,13 +420,13 @@ public function testRejectsExpectedMethodDetailsMismatchExceptRecentBlockhash(): challengeRequest: new ChargeRequest( amount: '1000', currency: 'USDC', - recipient: 'recipient', + recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', methodDetails: ['network' => 'localnet', 'decimals' => 6, 'recentBlockhash' => 'old'], ), expectedRequest: new ChargeRequest( amount: '1000', currency: 'USDC', - recipient: 'recipient', + recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', methodDetails: ['network' => 'devnet', 'decimals' => 6, 'recentBlockhash' => 'new'], ), ); @@ -218,8 +434,8 @@ public function testRejectsExpectedMethodDetailsMismatchExceptRecentBlockhash(): public function testAcceptsMatchingExpectedChargeRequest(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); - $request = new ChargeRequest(amount: '1000', currency: 'USDC', externalId: 'order-001'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); + $request = new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', externalId: 'order-001'); $challenge = $server->createChallenge($request); $credential = new Credential(challenge: $challenge->toEcho(), payload: ['type' => 'signature']); @@ -240,10 +456,11 @@ public function verify(Credential $credential, Challenge $challenge): Verificati public function testExpectedChargeRequestIgnoresVolatileRecentBlockhash(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $challengeRequest = new ChargeRequest( amount: '1000', currency: 'USDC', + recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', methodDetails: [ 'network' => 'localnet', 'recentBlockhash' => 'old-blockhash', @@ -252,6 +469,7 @@ public function testExpectedChargeRequestIgnoresVolatileRecentBlockhash(): void $expectedRequest = new ChargeRequest( amount: '1000', currency: 'USDC', + recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', methodDetails: [ 'network' => 'localnet', 'recentBlockhash' => 'new-blockhash', @@ -276,10 +494,11 @@ public function verify(Credential $credential, Challenge $challenge): Verificati public function testExpectedChargeRequestComparisonIsJsonOrderIndependent(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $challengeRequest = new ChargeRequest( amount: '1000', currency: 'USDC', + recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', methodDetails: [ 'network' => 'localnet', 'feePayer' => true, @@ -288,6 +507,7 @@ public function testExpectedChargeRequestComparisonIsJsonOrderIndependent(): voi $expectedRequest = new ChargeRequest( amount: '1000', currency: 'USDC', + recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', methodDetails: [ 'feePayer' => true, 'network' => 'localnet', @@ -312,8 +532,8 @@ public function verify(Credential $credential, Challenge $challenge): Verificati public function testPropagatesVerifierFailure(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); - $request = new ChargeRequest(amount: '1000', currency: 'USDC'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); + $request = new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY'); $challenge = $server->createChallenge($request); $credential = new Credential(challenge: $challenge->toEcho(), payload: ['type' => 'signature']); @@ -334,8 +554,8 @@ public function verify(Credential $credential, Challenge $challenge): Verificati public function testVerifierExceptionsFailClosed(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); - $request = new ChargeRequest(amount: '1000', currency: 'USDC'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); + $request = new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY'); $challenge = $server->createChallenge($request); $credential = new Credential(challenge: $challenge->toEcho(), payload: ['type' => 'signature']); @@ -356,7 +576,7 @@ public function verify(Credential $credential, Challenge $challenge): Verificati public function testRejectsReceiptForFailedVerification(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Cannot create a receipt for a failed verification'); @@ -366,7 +586,7 @@ public function testRejectsReceiptForFailedVerification(): void public function testRejectsReceiptForResultWithoutChallenge(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Verification result is missing a challenge'); @@ -376,8 +596,8 @@ public function testRejectsReceiptForResultWithoutChallenge(): void public function testCreatesReceiptHeaderForExternalSettlementReference(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); - $challenge = $server->createChallenge(new ChargeRequest(amount: '1000', currency: 'USDC')); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); + $challenge = $server->createChallenge(new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY')); $receipt = Headers::parseReceipt($server->createReceiptHeaderForReference( $challenge, @@ -392,8 +612,8 @@ public function testCreatesReceiptHeaderForExternalSettlementReference(): void public function testRejectsReceiptWithoutSettlementReference(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); - $challenge = $server->createChallenge(new ChargeRequest(amount: '1000', currency: 'USDC')); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); + $challenge = $server->createChallenge(new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY')); $credential = new Credential(challenge: $challenge->toEcho(), payload: []); $result = VerificationResult::success(reference: '')->withVerified($challenge, $credential); @@ -405,8 +625,8 @@ public function testRejectsReceiptWithoutSettlementReference(): void public function testPaymentRequiredResponseShape(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); - $request = new ChargeRequest(amount: '1000', currency: 'USDC'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); + $request = new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY'); $response = $server->paymentRequiredResponse($request); @@ -422,8 +642,8 @@ public function testPaymentRequiredResponseShape(): void public function testPaymentRequiredResponseUsesCustomReason(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); - $request = new ChargeRequest(amount: '1000', currency: 'USDC'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); + $request = new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY'); $response = $server->paymentRequiredResponse($request, 'charge request mismatch'); @@ -433,13 +653,14 @@ public function testPaymentRequiredResponseUsesCustomReason(): void public function testBlockhashProviderInjectsRecentBlockhashIntoChallenge(): void { $server = new ChargeServer( - secretKey: 'secret', + secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api', blockhashProvider: fn (): string => 'BlockhashFromRpc111111111111111111111111111', ); $request = new ChargeRequest( amount: '1000', currency: 'USDC', + recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', methodDetails: ['network' => 'localnet'], ); @@ -452,13 +673,14 @@ public function testBlockhashProviderInjectsRecentBlockhashIntoChallenge(): void public function testBlockhashProviderDoesNotOverrideExistingValue(): void { $server = new ChargeServer( - secretKey: 'secret', + secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api', blockhashProvider: fn (): string => 'FromRpc11111111111111111111111111111111111', ); $request = new ChargeRequest( amount: '1000', currency: 'USDC', + recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', methodDetails: ['network' => 'localnet', 'recentBlockhash' => 'CallerProvided111111111111111111111111111111'], ); @@ -470,7 +692,7 @@ public function testBlockhashProviderDoesNotOverrideExistingValue(): void public function testBlockhashProviderFailureIsBestEffort(): void { $server = new ChargeServer( - secretKey: 'secret', + secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api', blockhashProvider: function (): string { throw new RuntimeException('rpc unreachable'); @@ -479,6 +701,7 @@ public function testBlockhashProviderFailureIsBestEffort(): void $request = new ChargeRequest( amount: '1000', currency: 'USDC', + recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', methodDetails: ['network' => 'localnet'], ); @@ -503,8 +726,11 @@ public function testPinnedCurrencyRejectsCredentialWithDifferentCurrency(): void // not pass an expectedRequest, mirroring Rust verify_pinned_fields // (rust/crates/mpp/src/server/charge.rs:457-468), which runs // unconditionally on every credential. - $server = new ChargeServer(secretKey: 'secret', realm: 'api', pinnedCurrency: 'USDC'); - $challenge = $server->createChallenge(new ChargeRequest(amount: '1000', currency: 'USDT')); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api', pinnedCurrency: 'USDC'); + // Issue from a non-pinned server (same secret/realm) so the off-currency + // challenge can exist; the pinned server then rejects it at verify time. + $issuer = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); + $challenge = $issuer->createChallenge(new ChargeRequest(amount: '1000', currency: 'USDT', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY')); $credential = new Credential(challenge: $challenge->toEcho(), payload: ['type' => 'signature']); $result = $server->verifyAuthorizationHeader( @@ -518,9 +744,12 @@ public function testPinnedCurrencyRejectsCredentialWithDifferentCurrency(): void public function testPinnedRecipientRejectsCredentialWithDifferentRecipient(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api', pinnedRecipient: 'expected-recipient'); - $challenge = $server->createChallenge( - new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'attacker-recipient'), + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api', pinnedRecipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY'); + // Issue from a non-pinned server so the off-recipient challenge can + // exist; the pinned server rejects it at verify time. + $issuer = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); + $challenge = $issuer->createChallenge( + new ChargeRequest(amount: '1000', currency: 'USDC', recipient: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'), ); $credential = new Credential(challenge: $challenge->toEcho(), payload: ['type' => 'signature']); @@ -538,13 +767,13 @@ public function testPinnedFieldsAcceptMatchingCredential(): void // Happy-path guard: a credential whose currency and recipient match the // pinned configuration passes the backstop and reaches the verifier. $server = new ChargeServer( - secretKey: 'secret', + secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api', pinnedCurrency: 'USDC', - pinnedRecipient: 'pinned-recipient', + pinnedRecipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', ); $challenge = $server->createChallenge( - new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'pinned-recipient'), + new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY'), ); $credential = new Credential(challenge: $challenge->toEcho(), payload: ['type' => 'signature']); @@ -574,7 +803,7 @@ public function verify(Credential $credential, Challenge $challenge): Verificati private function assertExpectedRequestMismatch(ChargeRequest $challengeRequest, ChargeRequest $expectedRequest): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $challenge = $server->createChallenge($challengeRequest); $credential = new Credential(challenge: $challenge->toEcho(), payload: ['type' => 'signature']); diff --git a/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerInternalsTest.php b/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerInternalsTest.php index b1c3226da..902bbd285 100644 --- a/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerInternalsTest.php +++ b/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerInternalsTest.php @@ -24,7 +24,7 @@ private function handlerWith(FakeRpcGateway $rpc, int $confirmationAttempts = 3, { return new SolanaChargeHandler( challenges: new ChargeServer( - secretKey: 'test-secret', + secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'test', ), rpc: $rpc, diff --git a/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerTest.php b/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerTest.php index 64ad096e4..adbc73529 100644 --- a/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerTest.php +++ b/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerTest.php @@ -53,7 +53,7 @@ public function testReturns402WhenAuthorizationIsMalformed(): void public function testReturns402WhenCredentialIsMissingTransaction(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $request = $this->chargeRequest(); $challenge = $server->createChallenge($request); $credential = new Credential(challenge: $challenge->toEcho(), payload: []); @@ -67,7 +67,7 @@ public function testReturns402WhenCredentialIsMissingTransaction(): void public function testReturns402WhenChallengeMismatchesExpectedRequest(): void { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); + $server = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $issuedFor = $this->chargeRequest(amount: '500'); $challenge = $server->createChallenge($issuedFor); $credential = new Credential( @@ -84,7 +84,7 @@ public function testReturns402WhenChallengeMismatchesExpectedRequest(): void public function testReturnsChargeSettlementAfterSuccessfulBroadcastAndConfirmation(): void { - $challenges = new ChargeServer(secretKey: 'secret', realm: 'api'); + $challenges = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $request = $this->chargeRequest(); $challenge = $challenges->createChallenge($request); $credential = new Credential( @@ -122,7 +122,7 @@ public function testReturnsChargeSettlementAfterSuccessfulBroadcastAndConfirmati public function testRejectsSignatureReplayAfterSuccessfulSettlement(): void { - $challenges = new ChargeServer(secretKey: 'secret', realm: 'api'); + $challenges = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $request = $this->chargeRequest(); $challenge = $challenges->createChallenge($request); $credential = new Credential( @@ -163,7 +163,7 @@ public function testRejectsSignatureReplayAfterSuccessfulSettlement(): void public function testReturns402WhenSurfpoolBlockhashOnNonLocalnet(): void { - $challenges = new ChargeServer(secretKey: 'secret', realm: 'api'); + $challenges = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $request = $this->chargeRequest(); $challenge = $challenges->createChallenge($request); $credential = new Credential( @@ -186,7 +186,7 @@ public function testReturns402WhenSurfpoolBlockhashOnNonLocalnet(): void public function testReturns402WhenBroadcastReportsOnChainFailure(): void { - $challenges = new ChargeServer(secretKey: 'secret', realm: 'api'); + $challenges = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $request = $this->chargeRequest(); $challenge = $challenges->createChallenge($request); $credential = new Credential( @@ -233,7 +233,7 @@ public function testFeePayerPubkeyReflectsConfiguredKeypair(): void public function testReturnsChargeSettlementAfterSuccessfulPushSignatureVerification(): void { - $challenges = new ChargeServer(secretKey: 'secret', realm: 'api'); + $challenges = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $request = $this->pushChargeRequest(); $challenge = $challenges->createChallenge($request); $signature = $this->validSignature(); @@ -266,6 +266,26 @@ public function testReturnsChargeSettlementAfterSuccessfulPushSignatureVerificat self::assertNotEmpty($result->headers['payment-receipt']); } + public function testReturns402WhenPushModeNotOptedIn(): void + { + // audit #5: with push mode off (the default), a signature credential is + // rejected at verification and never reaches the settlement path. + $challenges = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); + $request = $this->pushChargeRequest(); + $challenge = $challenges->createChallenge($request); + $credential = new Credential( + challenge: $challenge->toEcho(), + payload: ['type' => 'signature', 'signature' => $this->validSignature()], + ); + + $handler = $this->handler(challenges: $challenges, acceptPushMode: false); + + $result = $handler->handle($credential->toAuthorizationHeader(), $request); + + self::assertInstanceOf(PaymentRequiredResponse::class, $result); + self::assertSame(402, $result->status); + } + public function testReturns402WhenPushCredentialUsedOnFeePayerRoute(): void { // B34: routes that advertise `methodDetails.feePayer = true` MUST @@ -273,7 +293,7 @@ public function testReturns402WhenPushCredentialUsedOnFeePayerRoute(): void // push credential references an already-landed transaction whose // fee the client paid, defeating the server-funded charge. The // canonical reject message is shared with Rust, Ruby, Lua, Python. - $challenges = new ChargeServer(secretKey: 'secret', realm: 'api'); + $challenges = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $request = new ChargeRequest( amount: '1000', currency: 'USDC', @@ -309,7 +329,7 @@ public function testReturns402WhenPushCredentialUsedOnFeePayerRoute(): void public function testReturns402WhenPushSignatureIsReplayed(): void { - $challenges = new ChargeServer(secretKey: 'secret', realm: 'api'); + $challenges = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $request = $this->pushChargeRequest(); $challenge = $challenges->createChallenge($request); $signature = $this->validSignature(); @@ -352,7 +372,7 @@ public function testReturns402WhenPushSignatureIsReplayed(): void public function testReturns402WhenPushTransactionFetchReportsOnChainFailure(): void { - $challenges = new ChargeServer(secretKey: 'secret', realm: 'api'); + $challenges = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $request = $this->pushChargeRequest(); $challenge = $challenges->createChallenge($request); $signature = $this->validSignature(); @@ -384,7 +404,7 @@ public function testReturns402WhenPushTransactionFetchReportsOnChainFailure(): v public function testReturns402WhenPushTransactionFetchOmitsMetadata(): void { - $challenges = new ChargeServer(secretKey: 'secret', realm: 'api'); + $challenges = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $request = $this->pushChargeRequest(); $challenge = $challenges->createChallenge($request); $credential = new Credential( @@ -414,7 +434,7 @@ public function testReturns402WhenPushTransactionFetchOmitsMetadata(): void public function testReturns402WhenPushTransactionIsNotFound(): void { - $challenges = new ChargeServer(secretKey: 'secret', realm: 'api'); + $challenges = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $request = $this->pushChargeRequest(); $challenge = $challenges->createChallenge($request); $signature = $this->validSignature(); @@ -441,7 +461,7 @@ public function testReturns402WhenPushTransactionIsNotFound(): void public function testReturns402WhenPushTransactionResponseIsMalformed(): void { - $challenges = new ChargeServer(secretKey: 'secret', realm: 'api'); + $challenges = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $request = $this->pushChargeRequest(); $challenge = $challenges->createChallenge($request); $credential = new Credential( @@ -466,7 +486,7 @@ public function testReturns402WhenPushTransactionResponseIsMalformed(): void public function testReturns402WhenPushTransactionResponseIsNotObject(): void { - $challenges = new ChargeServer(secretKey: 'secret', realm: 'api'); + $challenges = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $request = $this->pushChargeRequest(); $challenge = $challenges->createChallenge($request); $credential = new Credential( @@ -491,7 +511,7 @@ public function testReturns402WhenPushTransactionResponseIsNotObject(): void public function testReturns402WhenFetchedPushTransactionFailsStructuralVerification(): void { - $challenges = new ChargeServer(secretKey: 'secret', realm: 'api'); + $challenges = new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'); $request = $this->pushChargeRequest(); $challenge = $challenges->createChallenge($request); $credential = new Credential( @@ -561,9 +581,13 @@ private function handler( ?TransactionPayloadVerifier $transactionVerifier = null, ?Store $replayStore = null, int $confirmationAttempts = 40, + bool $acceptPushMode = true, ): SolanaChargeHandler { + // Default to acceptPushMode=true here so the push-mode tests below + // exercise the settlement path; audit #5's default-off posture is + // covered by testReturns402WhenPushModeNotOptedIn. return new SolanaChargeHandler( - challenges: $challenges ?? new ChargeServer(secretKey: 'secret', realm: 'api'), + challenges: $challenges ?? new ChargeServer(secretKey: 'test-secret-0123456789abcdef-0123456789', realm: 'api'), rpc: $rpc ?? new RpcClient('http://unused.invalid', new NullHttpClient()), feePayer: $feePayer, network: $network, @@ -572,6 +596,7 @@ private function handler( confirmationAttempts: $confirmationAttempts, confirmationDelayMicros: 0, replayStore: $replayStore, + acceptPushMode: $acceptPushMode, ); } diff --git a/php/tests/Protocols/Mpp/Server/SolanaChargeTransactionVerifierTest.php b/php/tests/Protocols/Mpp/Server/SolanaChargeTransactionVerifierTest.php index 18992c4c8..959f4ceaf 100644 --- a/php/tests/Protocols/Mpp/Server/SolanaChargeTransactionVerifierTest.php +++ b/php/tests/Protocols/Mpp/Server/SolanaChargeTransactionVerifierTest.php @@ -58,7 +58,7 @@ public function testRejectsMissingRequiredSplitAtaCreation(): void public function testRejectsMissingTransactionPayload(): void { $fixture = $this->fixture(); - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api'); $challenge = $server->createChallenge($this->request($fixture)); $credential = new Credential(challenge: $challenge->toEcho(), payload: []); @@ -84,7 +84,7 @@ public function testAcceptsValidPushSignaturePayload(): void // happens later in the handler via fetchSettledTransaction + // verifyTransactionPayload. $fixture = $this->fixture(); - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api'); $challenge = $server->createChallenge($this->request($fixture)); $signature = Base58::encode(str_repeat("\x01", 64)); $credential = new Credential( @@ -92,16 +92,61 @@ public function testAcceptsValidPushSignaturePayload(): void payload: ['type' => 'signature', 'signature' => $signature], ); - $result = (new SolanaChargeTransactionVerifier())->verify($credential, $challenge); + // Push mode is opt-in (audit #5); construct the verifier with it enabled. + $result = (new SolanaChargeTransactionVerifier(acceptPushMode: true))->verify($credential, $challenge); self::assertTrue($result->ok, $result->reason); self::assertSame($signature, $result->reference); } + public function testRejectsUnknownMintWithoutEmbeddedTokenProgram(): void + { + // audit #28: for an arbitrary, unknown mint the token program cannot be + // inferred (the legacy default is wrong for unknown Token-2022 mints), + // so an absent methodDetails.tokenProgram must be rejected rather than + // silently defaulting to legacy Token. + $fixture = $this->fixture(); + $unknownMint = 'So11111111111111111111111111111111111111112'; + $request = new ChargeRequest( + amount: '1000', + currency: $unknownMint, + recipient: $fixture['recipient']->toBase58(), + externalId: 'order-123', + methodDetails: [ + 'network' => 'localnet', + 'decimals' => 6, + // tokenProgram intentionally omitted. + ], + ); + $transaction = $this->transactionPayload($fixture, includeSplitAta: true); + $result = $this->verify($request, $transaction); + + self::assertFalse($result->ok); + self::assertStringContainsString('methodDetails.tokenProgram is required', $result->reason); + } + + public function testRejectsPushSignaturePayloadWhenPushModeNotOptedIn(): void + { + // audit #5: push mode is off by default. A signature credential must be + // rejected before any shape check unless the server opts in. + $fixture = $this->fixture(); + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api'); + $challenge = $server->createChallenge($this->request($fixture)); + $credential = new Credential( + challenge: $challenge->toEcho(), + payload: ['type' => 'signature', 'signature' => Base58::encode(str_repeat("\x01", 64))], + ); + + $result = (new SolanaChargeTransactionVerifier())->verify($credential, $challenge); + + self::assertFalse($result->ok); + self::assertSame('push-mode credentials are not accepted by this server', $result->reason); + } + public function testRejectsPushSignaturePayloadWithWrongLength(): void { $fixture = $this->fixture(); - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); + $server = new ChargeServer(secretKey: self::SECRET, realm: 'api'); $challenge = $server->createChallenge($this->request($fixture)); // 32-byte decoded value, not the required 64. $credential = new Credential( @@ -109,7 +154,7 @@ public function testRejectsPushSignaturePayloadWithWrongLength(): void payload: ['type' => 'signature', 'signature' => Base58::encode(str_repeat("\x01", 32))], ); - $result = (new SolanaChargeTransactionVerifier())->verify($credential, $challenge); + $result = (new SolanaChargeTransactionVerifier(acceptPushMode: true))->verify($credential, $challenge); self::assertFalse($result->ok); self::assertSame('invalid signature length', $result->reason); @@ -354,8 +399,10 @@ public function testRejectsComputeUnitPriceBeyondPhpIntegerRange(): void self::assertSame('u64 value exceeds PHP integer range', $result->reason); } - public function testAcceptsComputeBudgetWithinVerifierLimits(): void + public function testAcceptsComputeBudgetWithinFeeSponsoredTightCap(): void { + // audit #25: the default request() is fee-sponsored (feePayer=true), so + // the tight 10_000 µlamport cap applies. A price at the cap is accepted. $fixture = $this->fixture(); $request = $this->request($fixture); $transaction = $this->transactionPayload( @@ -363,6 +410,45 @@ public function testAcceptsComputeBudgetWithinVerifierLimits(): void includeSplitAta: true, extraInstructions: [ ComputeBudgetProgram::setComputeUnitLimit(200_000), + ComputeBudgetProgram::setComputeUnitPrice(10_000), + ], + ); + $result = $this->verify($request, $transaction); + + self::assertTrue($result->ok, $result->reason); + } + + public function testRejectsComputeBudgetPriceAboveFeeSponsoredTightCap(): void + { + // audit #25: in fee-sponsored mode the server pays the priority fee, so + // a price above the tight cap (but below the general 5M ceiling) is + // rejected to prevent draining the merchant fee-payer. + $fixture = $this->fixture(); + $request = $this->request($fixture); + $transaction = $this->transactionPayload( + $fixture, + includeSplitAta: true, + extraInstructions: [ + ComputeBudgetProgram::setComputeUnitPrice(10_001), + ], + ); + $result = $this->verify($request, $transaction); + + self::assertFalse($result->ok); + self::assertSame('compute unit price exceeds maximum', $result->reason); + } + + public function testAcceptsComputeBudgetPriceUpToGeneralCapWhenClientPaysFees(): void + { + // audit #25 regression: when the client pays its own fees (no server + // fee payer), the tight cap MUST NOT apply — the general 5M ceiling + // holds, since there is no merchant fee-payer at risk. + $fixture = $this->fixture(); + $request = $this->clientPaysRequest($fixture); + $transaction = $this->clientPaysTransactionPayload( + $fixture, + ataPayer: $fixture['payer'], + extraInstructions: [ ComputeBudgetProgram::setComputeUnitPrice(5_000_000), ], ); @@ -530,8 +616,9 @@ private function clientPaysRequest(array $fixture): ChargeRequest * is $fixture['payer'] but whose ATA-creation payer is configurable. * * @param array $fixture + * @param array $extraInstructions */ - private function clientPaysTransactionPayload(array $fixture, PublicKey $ataPayer): string + private function clientPaysTransactionPayload(array $fixture, PublicKey $ataPayer, array $extraInstructions = []): string { $tokenProgram = TokenProgram::programId(); $recipientAta = AssociatedTokenProgram::findAssociatedTokenAddress( @@ -574,6 +661,7 @@ private function clientPaysTransactionPayload(array $fixture, PublicKey $ataPaye MemoProgram::create('order-123'), MemoProgram::create('split memo'), ]; + array_push($instructions, ...$extraInstructions); $transaction = Transaction::new( $instructions, @@ -743,8 +831,17 @@ private function solTransactionPayload(array $fixture, ?PublicKey $primarySource private function verify(ChargeRequest $request, string $transaction): \PayKit\Protocols\Mpp\Server\VerificationResult { - $server = new ChargeServer(secretKey: 'secret', realm: 'api'); - $challenge = $server->createChallenge($request); + // Build the signed challenge directly (not via ChargeServer::createChallenge) + // so these tests exercise verify-time behavior in isolation. Issuance-time + // request validation (audit #19/#21/#38) is covered by ChargeServerTest; + // here we want to feed the verifier requests that issuance would reject. + $challenge = \PayKit\Protocols\Mpp\Core\Challenge::withSecret( + secretKey: self::SECRET, + realm: 'api', + method: 'solana', + intent: 'charge', + request: $request->toArray(), + ); $credential = new Credential( challenge: $challenge->toEcho(), payload: ['type' => 'transaction', 'transaction' => $transaction], @@ -753,6 +850,8 @@ private function verify(ChargeRequest $request, string $transaction): \PayKit\Pr return (new SolanaChargeTransactionVerifier())->verify($credential, $challenge); } + private const SECRET = 'test-secret-0123456789abcdef-0123456789'; + /** * @return array */ diff --git a/php/tests/SecretResolverTest.php b/php/tests/SecretResolverTest.php index e2a948ba5..7872ccd80 100644 --- a/php/tests/SecretResolverTest.php +++ b/php/tests/SecretResolverTest.php @@ -32,38 +32,60 @@ protected function tearDown(): void putenv('PAY_KIT_TEST_SECRET_AAA'); } + // 32+ byte secrets (audit #24): the resolver now rejects weak + // env/dotenv-supplied values, so test fixtures must clear the floor. + private const STRONG_ENV = 'from-env-0123456789abcdef-0123456789'; + private const STRONG_DOTENV = 'from-dotenv-0123456789abcdef-012345'; + public function testEnvVarWinsOverDotenvAndGenerator(): void { - putenv('PAY_KIT_TEST_SECRET_AAA=from-env'); - file_put_contents($this->tmpDotenv, "PAY_KIT_TEST_SECRET_AAA=from-dotenv\n"); + putenv('PAY_KIT_TEST_SECRET_AAA=' . self::STRONG_ENV); + file_put_contents($this->tmpDotenv, 'PAY_KIT_TEST_SECRET_AAA=' . self::STRONG_DOTENV . "\n"); $r = SecretResolver::resolveMppSecret('PAY_KIT_TEST_SECRET_AAA', $this->tmpDotenv); - $this->assertSame('from-env', $r['secret']); + $this->assertSame(self::STRONG_ENV, $r['secret']); $this->assertSame('env', $r['source']); } public function testDotenvWinsWhenEnvUnset(): void { putenv('PAY_KIT_TEST_SECRET_AAA'); - file_put_contents($this->tmpDotenv, "PAY_KIT_TEST_SECRET_AAA=from-dotenv\n"); + file_put_contents($this->tmpDotenv, 'PAY_KIT_TEST_SECRET_AAA=' . self::STRONG_DOTENV . "\n"); $r = SecretResolver::resolveMppSecret('PAY_KIT_TEST_SECRET_AAA', $this->tmpDotenv); - $this->assertSame('from-dotenv', $r['secret']); + $this->assertSame(self::STRONG_DOTENV, $r['secret']); $this->assertSame('dotenv', $r['source']); } public function testQuotedDotenvValueStripped(): void { putenv('PAY_KIT_TEST_SECRET_AAA'); - file_put_contents($this->tmpDotenv, "PAY_KIT_TEST_SECRET_AAA=\"quoted-secret\"\n"); + file_put_contents($this->tmpDotenv, 'PAY_KIT_TEST_SECRET_AAA="' . self::STRONG_DOTENV . "\"\n"); $r = SecretResolver::resolveMppSecret('PAY_KIT_TEST_SECRET_AAA', $this->tmpDotenv); - $this->assertSame('quoted-secret', $r['secret']); + $this->assertSame(self::STRONG_DOTENV, $r['secret']); } public function testCommentsAndBlankLinesIgnoredInDotenv(): void { putenv('PAY_KIT_TEST_SECRET_AAA'); - file_put_contents($this->tmpDotenv, "# comment\n\nUNRELATED=foo\nPAY_KIT_TEST_SECRET_AAA=value-here\n"); + file_put_contents($this->tmpDotenv, "# comment\n\nUNRELATED=foo\nPAY_KIT_TEST_SECRET_AAA=" . self::STRONG_DOTENV . "\n"); $r = SecretResolver::resolveMppSecret('PAY_KIT_TEST_SECRET_AAA', $this->tmpDotenv); - $this->assertSame('value-here', $r['secret']); + $this->assertSame(self::STRONG_DOTENV, $r['secret']); + } + + public function testRejectsWeakEnvSecret(): void + { + // audit #24: a short operator-supplied env secret is rejected at the + // resolution boundary rather than accepted verbatim. + putenv('PAY_KIT_TEST_SECRET_AAA=tooshort'); + $this->expectException(\InvalidArgumentException::class); + SecretResolver::resolveMppSecret('PAY_KIT_TEST_SECRET_AAA', $this->tmpDotenv); + } + + public function testRejectsWeakDotenvSecret(): void + { + putenv('PAY_KIT_TEST_SECRET_AAA'); + file_put_contents($this->tmpDotenv, "PAY_KIT_TEST_SECRET_AAA=tooshort\n"); + $this->expectException(\InvalidArgumentException::class); + SecretResolver::resolveMppSecret('PAY_KIT_TEST_SECRET_AAA', $this->tmpDotenv); } public function testGenerateAndPersistWhenBothMissing(): void diff --git a/python/src/pay_kit/_paycore/currency.py b/python/src/pay_kit/_paycore/currency.py index 4e5607bf7..60bf85e6d 100644 --- a/python/src/pay_kit/_paycore/currency.py +++ b/python/src/pay_kit/_paycore/currency.py @@ -39,8 +39,30 @@ def parse_units(amount: str, decimals: int) -> str: if len(parts) > 2: raise ValueError(f"invalid amount: {amount}") - whole = parts[0] or "0" - fractional = parts[1] if len(parts) == 2 else "" + has_fraction = len(parts) == 2 + whole = parts[0] + fractional = parts[1] if has_fraction else "" + + # Audit #44/#45: reject malformed shapes that ``int()`` would otherwise + # silently accept. Mirrors the Rust ``parse_units`` guards + # (rust/crates/mpp/src/protocol/intents/mod.rs): no empty integer/fraction + # halves (".5", "5.", "."), and every digit must be an ASCII 0-9 — Python's + # ``int()`` accepts a leading "+", underscore grouping ("1_000"), and + # non-ASCII Unicode digits ("١٢٣"), all of which would silently corrupt the + # base-unit amount. ``str.isdigit()`` is too loose (it accepts superscripts + # and other numeric Unicode), so screen each char with ``isascii``+``isdigit``. + if has_fraction and (not whole or not fractional): + raise ValueError(f"invalid amount: {amount}") + if not whole: + raise ValueError(f"invalid amount: {amount}") + + def _all_ascii_digits(s: str) -> bool: + return s != "" and all(c.isascii() and c.isdigit() for c in s) + + if not _all_ascii_digits(whole): + raise ValueError(f"invalid amount: {amount}") + if fractional and not _all_ascii_digits(fractional): + raise ValueError(f"invalid amount: {amount}") if len(fractional) > decimals: raise ValueError(f"amount {amount} has too many decimal places for {decimals} decimals") @@ -51,7 +73,7 @@ def parse_units(amount: str, decimals: int) -> str: # Strip leading zeros value_str = value_str.lstrip("0") or "0" - # Validate it's a valid integer + # Validate it's a valid integer (guards above already screened the digits) try: val = int(value_str) except ValueError as exc: diff --git a/python/src/pay_kit/_paycore/solana.py b/python/src/pay_kit/_paycore/solana.py index f3df8c6c0..ef529cb34 100644 --- a/python/src/pay_kit/_paycore/solana.py +++ b/python/src/pay_kit/_paycore/solana.py @@ -53,6 +53,165 @@ def _canonical_network(network: str) -> str: return "mainnet" if network == "mainnet-beta" else network +# Audit #37: canonical Solana network allowlist. The server rejects anything +# outside this set at boot rather than silently treating unknown slugs (typos, +# "testnet", or the RPC-hostname spelling "mainnet-beta") as mainnet. Mirrors +# Rust ``validate_network`` (rust/crates/mpp/src/protocol/solana.rs). +NETWORK_MAINNET = "mainnet" +NETWORK_DEVNET = "devnet" +NETWORK_LOCALNET = "localnet" +DEFAULT_NETWORK = NETWORK_MAINNET +_ALLOWED_NETWORKS = frozenset({NETWORK_MAINNET, NETWORK_DEVNET, NETWORK_LOCALNET}) + +# Audit #24: HMAC-SHA256 secret key minimum size. NIST SP 800-107 recommends a +# key at least as long as the hash output (32 bytes for SHA-256). Mirrors Rust +# ``MIN_SECRET_KEY_BYTES``. +MIN_SECRET_KEY_BYTES = 32 + + +def validate_network(network: str) -> None: + """Reject any network slug outside the canonical allowlist (Audit #37). + + ``mainnet-beta`` is canonicalized to ``mainnet`` first (backward-compat + alias). Empty input gets a distinct message for clearer boot diagnostics. + """ + if not network: + raise ValueError("network is required (one of: mainnet, devnet, localnet)") + canonical = _canonical_network(network) + if canonical not in _ALLOWED_NETWORKS: + raise ValueError( + f"unknown network '{network}'; must be one of: mainnet, devnet, localnet " + "('mainnet-beta' is accepted as an alias for 'mainnet')" + ) + + +def derive_default_realm(recipient: str) -> str: + """Derive a per-recipient default realm (Audit #15). + + A shared static default realm puts every server that reuses a secret key in + one HMAC credential namespace, enabling cross-service replay. The recipient + pubkey is unique per merchant and already mandatory, so deriving the default + realm from it gives two services with the same secret but different + recipients different realms (different HMAC ids). Mirrors Rust + ``derive_default_realm``: SHA-256 of the recipient, first 4 bytes mod 1e8. + """ + import hashlib + + digest = hashlib.sha256(recipient.encode("utf-8")).digest() + suffix = int.from_bytes(digest[:4], "big") % 100_000_000 + return f"App Id - #{suffix}" + + +def is_known_stablecoin_mint(currency: str) -> bool: + """Return True if ``currency`` is a known stablecoin symbol or mint address.""" + return stablecoin_symbol(currency) is not None + + +def _is_valid_pubkey(value: str) -> bool: + try: + from solders.pubkey import Pubkey + + Pubkey.from_string(value) + return True + except Exception: + return False + + +def resolve_server_token_program(currency: str, network: str, rpc_url: str | None) -> str | None: + """Resolve the token program a server should advertise for ``currency`` (Audit #28). + + Mirrors Rust ``resolve_server_token_program``: + + - native SOL → ``None`` (no token program). + - a known stablecoin symbol/mint → the program from the static table + (correctly Token-2022 for PYUSD/USDG/CASH, classic Token for USDC/USDT). + - an arbitrary mint address → fetch the mint account owner on-chain and + return it, rejecting any owner that is not the SPL Token or Token-2022 + program. The server fails fast at boot if the mint is unreachable. + - anything that is neither a known symbol nor a valid pubkey → reject. + + Unlike a silent fallback to the classic Token program, an arbitrary + Token-2022 mint is resolved to its real owner so the emitted + ``tokenProgram`` (and the derived ATAs) are correct. + """ + if is_native_sol(currency): + return None + if is_known_stablecoin_mint(currency): + return default_token_program_for_currency(currency, network) + # Arbitrary currency: must be a real mint pubkey. + if not _is_valid_pubkey(currency): + raise ValueError( + f"currency '{currency}' is neither a known stablecoin symbol nor a valid mint address" + ) + owner = _fetch_mint_owner_sync(currency, rpc_url) + if owner not in (TOKEN_PROGRAM, TOKEN_2022_PROGRAM): + raise ValueError( + f"mint '{currency}' is owned by an unexpected program '{owner}'; " + "only the SPL Token and Token-2022 programs are supported" + ) + return owner + + +def _fetch_mint_owner_sync(mint: str, rpc_url: str | None) -> str: + """Fetch the owner program of ``mint`` via a synchronous getAccountInfo call. + + Runs once at server boot (``Mpp.__init__`` is synchronous). Raises if the + RPC is unreachable or the mint account does not exist, so a misconfigured + arbitrary mint fails fast rather than shipping a wrong ``tokenProgram``. + """ + if not rpc_url: + raise ValueError( + f"cannot resolve token program for arbitrary mint '{mint}': no rpc_url configured" + ) + import httpx + + payload = { + "jsonrpc": "2.0", + "id": 1, + "method": "getAccountInfo", + "params": [mint, {"encoding": "base64"}], + } + try: + resp = httpx.post(rpc_url, json=payload, timeout=30.0) + resp.raise_for_status() + body = resp.json() + except Exception as exc: # noqa: BLE001 — surface any RPC failure at boot + raise ValueError(f"failed to fetch mint '{mint}' owner from {rpc_url}: {exc}") from exc + value = (body.get("result") or {}).get("value") + if not value: + raise ValueError(f"mint account '{mint}' not found on chain") + owner = value.get("owner") + if not owner: + raise ValueError(f"mint account '{mint}' has no owner field in RPC response") + return str(owner) + + +def validate_splits(splits: list[Split]) -> None: + """Validate a split set at challenge issuance (Audit #21). + + Enforces, before the splits are embedded into a signed challenge: + count <= ``MAX_SPLITS``; each recipient parses as a pubkey; each amount + parses as a non-negative integer and is strictly positive; the aggregate + does not overflow (Python ints are unbounded, but a parse failure is still + rejected); and no duplicate recipients. Mirrors Rust ``validate_splits``. + """ + if len(splits) > MAX_SPLITS: + raise ValueError(f"too many splits: maximum is {MAX_SPLITS}") + seen: set[str] = set() + for split in splits: + if not _is_valid_pubkey(split.recipient): + raise ValueError(f"split recipient '{split.recipient}' is not a valid pubkey") + try: + amount = int(split.amount) + except (ValueError, TypeError) as exc: + raise ValueError(f"split amount '{split.amount}' is not a valid integer") from exc + if amount <= 0: + raise ValueError("split amount must be positive") + if split.recipient in seen: + raise ValueError(f"duplicate split recipient '{split.recipient}'") + seen.add(split.recipient) + + STABLECOIN_TOKEN_PROGRAMS: dict[str, str] = { "USDC": TOKEN_PROGRAM, "USDT": TOKEN_PROGRAM, diff --git a/python/src/pay_kit/protocols/mpp/client/charge.py b/python/src/pay_kit/protocols/mpp/client/charge.py index d96bffc80..59db6654c 100644 --- a/python/src/pay_kit/protocols/mpp/client/charge.py +++ b/python/src/pay_kit/protocols/mpp/client/charge.py @@ -16,6 +16,7 @@ CredentialPayload, MethodDetails, default_token_program_for_currency, + is_known_stablecoin_mint, is_native_sol, resolve_mint, ) @@ -29,6 +30,10 @@ async def build_credential_header( signer: Any, rpc_client: Any, challenge: PaymentChallenge, + *, + max_amount_base_units: int | None = None, + expected_network: str | None = None, + allow_unknown_token_2022: bool = False, ) -> str: """Create an Authorization header value from a challenge. @@ -36,10 +41,30 @@ async def build_credential_header( signer: A Solana keypair (solders.Keypair) for signing transactions. rpc_client: A solana.rpc.async_api.AsyncClient for RPC calls. challenge: The payment challenge to satisfy. + max_amount_base_units: Audit #10 opt-in guard. When set, refuse to sign + a challenge whose amount (base units) exceeds this cap. Defaults to + no constraint so interactive callers are unaffected. + expected_network: Audit #10 opt-in guard. When set, refuse to sign a + challenge whose ``methodDetails.network`` does not match. + allow_unknown_token_2022: Audit #26 opt-in. When False (default), refuse + to sign an unknown (non-stablecoin) Token-2022 mint, which can carry + transfer hooks that execute arbitrary code on every transfer. Returns: The formatted Authorization header value. + + Raises: + ValueError: when an opt-in guard is violated, or — always, regardless of + opt-ins — when the challenge has already expired (Audit #10). An + expired challenge is never signed; challenges with no ``expires`` + field are still accepted (there is nothing to check against). """ + # Audit #10: ALWAYS refuse an expired challenge before signing. Auto-pay + # integrations otherwise sign whatever the server sends, including stale + # challenges. A challenge with no expiry is accepted (nothing to anchor on). + if challenge.expires and challenge.is_expired(): + raise ValueError(f"refusing to sign expired challenge (expired at {challenge.expires})") + request_data = decode_json(challenge.request) request = ChargeRequest.from_dict(request_data) @@ -47,6 +72,24 @@ async def build_credential_header( if request.method_details: details = MethodDetails.from_dict(request.method_details) + # Audit #10: opt-in max-amount guard. Amounts are base-unit integer strings. + if max_amount_base_units is not None: + try: + amount_int = int(request.amount) + except (ValueError, TypeError) as exc: + raise ValueError(f"challenge amount {request.amount!r} is not a valid integer") from exc + if amount_int > max_amount_base_units: + raise ValueError( + f"refusing to sign: challenge amount {amount_int} exceeds max {max_amount_base_units}" + ) + + # Audit #10: opt-in expected-network guard. + if expected_network is not None and details.network != expected_network: + raise ValueError( + f"refusing to sign: challenge network {details.network!r} does not match " + f"expected {expected_network!r}" + ) + payload = await build_charge_transaction( signer=signer, rpc_client=rpc_client, @@ -55,6 +98,7 @@ async def build_credential_header( recipient=request.recipient, external_id=request.external_id, method_details=details, + allow_unknown_token_2022=allow_unknown_token_2022, ) credential = PaymentCredential( @@ -75,6 +119,7 @@ async def build_charge_transaction( external_id: str = "", compute_unit_limit: int | None = None, compute_unit_price: int | None = None, + allow_unknown_token_2022: bool = False, ) -> CredentialPayload: """Build a Solana transaction for a charge intent. @@ -199,8 +244,16 @@ def append_memo(memo: str) -> None: from solders.instruction import AccountMeta mint = resolve_mint(currency, details.network) - token_program = await _resolve_token_program(rpc_client, mint, details) - decimals = details.decimals if details.decimals is not None else 6 + token_program = await _resolve_token_program( + rpc_client, mint, currency, details, allow_unknown_token_2022 + ) + # Audit #42: decimals are conditionally required by spec §7.2 — they + # MUST be present for an SPL charge. Silently defaulting to 6 produces a + # wrong divisor / wrong transferChecked decimals byte for non-6-decimal + # mints, so error out instead of guessing. + if details.decimals is None: + raise ValueError("methodDetails.decimals is required for SPL charges (spec §7.2)") + decimals = details.decimals token_program_key = Pubkey.from_string(token_program) mint_key = Pubkey.from_string(mint) system_program_key = Pubkey.from_string(SYSTEM_PROGRAM) @@ -259,7 +312,11 @@ def append_transfer_checked(owner: Any, transfer_amount: int, create_ata: bool, if details.recent_blockhash: blockhash = Hash.from_string(details.recent_blockhash) else: - resp = await rpc_client.get_latest_blockhash() + # Audit #36: fetch with `confirmed` commitment so the blockhash cannot + # come from a `processed` (unrooted) slot that vanishes under a reorg, + # which would make the signed transaction fail with BlockhashNotFound. + # Mirrors Rust ``get_latest_blockhash_with_commitment(confirmed)``. + resp = await _get_latest_blockhash_confirmed(rpc_client) blockhash = resp.value.blockhash # Build and sign transaction. The message fee payer (account[0]) is the @@ -281,7 +338,36 @@ def append_transfer_checked(owner: Any, transfer_amount: int, create_ata: bool, return CredentialPayload(type="transaction", transaction=tx_b64) -async def _resolve_token_program(rpc_client: Any, mint: str, details: MethodDetails) -> str: +async def _get_latest_blockhash_confirmed(rpc_client: Any) -> Any: + """Fetch the latest blockhash at ``confirmed`` commitment (Audit #36). + + Handles the two RPC client shapes this SDK is used with: solana-py's + ``AsyncClient.get_latest_blockhash`` expects a ``solders`` ``Commitment`` + object, while ``pay_kit._paycore.rpc.SolanaRpc`` accepts a string. Fall + back to a bare call if neither commitment form is accepted, so older / + stubbed clients keep working. + """ + # solana-py AsyncClient: commitment is a solders Commitment. + try: + from solana.rpc.commitment import Confirmed # type: ignore[import-untyped] + + return await rpc_client.get_latest_blockhash(commitment=Confirmed) + except (ImportError, TypeError): + pass + # pay_kit SolanaRpc and string-commitment clients. + try: + return await rpc_client.get_latest_blockhash(commitment="confirmed") + except TypeError: + return await rpc_client.get_latest_blockhash() + + +async def _resolve_token_program( + rpc_client: Any, + mint: str, + currency: str, + details: MethodDetails, + allow_unknown_token_2022: bool = False, +) -> str: """Resolve the SPL token program for ``mint``, matching rust resolve_token_program. Mirrors rust ``resolve_token_program`` (charge.rs:442-466): use @@ -300,6 +386,22 @@ async def _resolve_token_program(rpc_client: Any, mint: str, details: MethodDeta ) if token_program not in (TOKEN_PROGRAM, TOKEN_2022_PROGRAM): raise ValueError(f"Unsupported token program: {token_program}") + # Audit #26: refuse to sign an UNKNOWN Token-2022 mint unless explicitly + # opted in. Token-2022 mints can carry transfer hooks that execute + # arbitrary code on every transfer; the server's pre-broadcast checks do + # not simulate inner instructions in pull mode. The vanilla Token program + # has no hooks, so unknown classic-Token mints stay first-class. Known + # stablecoins (USDC/USDT/USDG/PYUSD/CASH) are always allowed. + if ( + token_program == TOKEN_2022_PROGRAM + and not allow_unknown_token_2022 + and not is_known_stablecoin_mint(currency) + and not is_known_stablecoin_mint(mint) + ): + raise ValueError( + f"refusing to sign unknown Token-2022 mint '{currency}' (transfer-hook risk); " + "pass allow_unknown_token_2022=True to opt in" + ) return token_program diff --git a/python/src/pay_kit/protocols/mpp/core/headers.py b/python/src/pay_kit/protocols/mpp/core/headers.py index 39545d7b3..8b542bacc 100644 --- a/python/src/pay_kit/protocols/mpp/core/headers.py +++ b/python/src/pay_kit/protocols/mpp/core/headers.py @@ -63,6 +63,13 @@ def parse_www_authenticate(header: str) -> PaymentChallenge: intent = _require_param(params, "intent") request_b64 = _require_param(params, "request") + # Audit #9: cap the ``request`` parameter before base64url-decode + JSON + # parse, matching ``parse_authorization`` / ``parse_receipt`` which cap the + # token they decode. ``request`` is the only challenge field that drives + # O(n) decode+parse cost; every other param is a short pass-through string. + if len(request_b64) > MAX_TOKEN_LEN: + raise ParseError(f"request parameter exceeds maximum length of {MAX_TOKEN_LEN} bytes") + # Validate that request is valid base64url JSON try: request_bytes = decode(request_b64) diff --git a/python/src/pay_kit/protocols/mpp/server/_tx_decode.py b/python/src/pay_kit/protocols/mpp/server/_tx_decode.py index 367043f6d..b895e49fb 100644 --- a/python/src/pay_kit/protocols/mpp/server/_tx_decode.py +++ b/python/src/pay_kit/protocols/mpp/server/_tx_decode.py @@ -45,6 +45,14 @@ _COMPUTE_BUDGET_SET_PRICE_DISCRIMINATOR = 3 MAX_COMPUTE_UNIT_LIMIT = 200_000 MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 5_000_000 +# Audit #25: in fee-sponsored pull mode the server co-signs (and pays the +# priority fee) before broadcast, so a client could set the price up to the +# general cap and drain the merchant. Apply a tight cap when the server is the +# fee payer. Worst-case priority fee = ceil(10_000 * 200_000 / 1_000_000) = +# 2_000 lamports (~20% of the per-signature base fee) — enough room for honest +# clients to bump priority during congestion. Mirrors Rust +# ``MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED``. +MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED = 10_000 # ``MAX_SPLITS`` (the split-recipient cap) is imported from # :mod:`pay_kit._paycore.solana` and re-exported here so the server verifier and @@ -333,7 +341,9 @@ def _extract_recent_blockhash(transaction_b64: str) -> str: return str(vtx.message.recent_blockhash) -def _validate_compute_budget_instruction(data: bytes, account_count: int) -> None: +def _validate_compute_budget_instruction( + data: bytes, account_count: int, fee_sponsored: bool = False +) -> None: """Validate a single ComputeBudget program instruction. Mirrors ``validate_compute_budget_instruction`` in @@ -343,6 +353,12 @@ def _validate_compute_budget_instruction(data: bytes, account_count: int) -> Non shapes, both must carry zero account references, and each value is capped at the per-instruction maximum. Anything else is rejected as an invalid payload to keep the on-wire allowlist tight. + + Audit #25: when ``fee_sponsored`` is True (the server is the fee payer and + co-signs before broadcast), the compute-unit price is held to the tight + ``MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED`` cap so a client + cannot inflate the priority fee the merchant pays. Client-paid mode keeps + the general cap. """ if account_count != 0: raise PaymentError( @@ -365,9 +381,14 @@ def _validate_compute_budget_instruction(data: bytes, account_count: int) -> Non return if discriminator == _COMPUTE_BUDGET_SET_PRICE_DISCRIMINATOR and len(data) == 9: price = int.from_bytes(data[1:9], "little") - if price > MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS: + price_cap = ( + MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED + if fee_sponsored + else MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS + ) + if price > price_cap: raise PaymentError( - f"compute unit price {price} exceeds cap {MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS}", + f"compute unit price {price} exceeds cap {price_cap}", code="compute-budget-cap-exceeded", ) return diff --git a/python/src/pay_kit/protocols/mpp/server/_verify.py b/python/src/pay_kit/protocols/mpp/server/_verify.py index 9bcd62126..55461dc06 100644 --- a/python/src/pay_kit/protocols/mpp/server/_verify.py +++ b/python/src/pay_kit/protocols/mpp/server/_verify.py @@ -431,7 +431,12 @@ def _validate_instruction_allowlist( accounts = list(instruction.accounts) if program_id == _COMPUTE_BUDGET_PROGRAM: - _validate_compute_budget_instruction(data, len(accounts)) + # Audit #25: apply the tight fee-sponsored price cap when the server + # is the fee payer (it co-signs and pays the priority fee before + # broadcast). Client-paid charges keep the general cap. + _validate_compute_budget_instruction( + data, len(accounts), fee_sponsored=details.fee_payer + ) continue if program_id == MEMO_PROGRAM: diff --git a/python/src/pay_kit/protocols/mpp/server/charge.py b/python/src/pay_kit/protocols/mpp/server/charge.py index 6521e15e2..af25c973e 100644 --- a/python/src/pay_kit/protocols/mpp/server/charge.py +++ b/python/src/pay_kit/protocols/mpp/server/charge.py @@ -26,12 +26,16 @@ ) from pay_kit._paycore.network_check import check_network_blockhash from pay_kit._paycore.solana import ( + MIN_SECRET_KEY_BYTES, CredentialPayload, MethodDetails, + Split, default_rpc_url, - default_token_program_for_currency, + derive_default_realm, is_native_sol, - stablecoin_symbol, + resolve_server_token_program, + validate_network, + validate_splits, ) from pay_kit._paycore.store import Store from pay_kit.protocols.mpp.core.base64url import encode_json @@ -41,6 +45,7 @@ _SYSTEM_PROGRAM, MAX_COMPUTE_UNIT_LIMIT, MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS, + MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED, MAX_SPLITS, _build_expected_transfers, _decode_legacy_payment_instructions, @@ -65,7 +70,6 @@ logger = logging.getLogger(__name__) -_DEFAULT_REALM = "MPP Payment" _SECRET_KEY_ENV_VAR = "MPP_SECRET_KEY" _CONSUMED_PREFIX = "solana-charge:consumed:" @@ -77,6 +81,7 @@ "Mpp", "MAX_COMPUTE_UNIT_LIMIT", "MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS", + "MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED", "MAX_SPLITS", "_assert_signature_slot", "_build_expected_transfers", @@ -120,8 +125,16 @@ class Config: network: str = "mainnet" rpc_url: str = "" secret_key: str = "" - realm: str = "" + # ``None`` (the default) means "derive a per-recipient default realm" + # (Audit #15). An explicit empty string is a misconfiguration and is + # rejected at construction; a non-empty string is used verbatim. + realm: str | None = None html: bool = False + # Audit #5: push-mode (``type="signature"``) credentials are accepted only + # when this is opted in. Default OFF reduces attack surface; the pull-mode + # (``type="transaction"``) path is unaffected. Spec §13.5 permits push as a + # shape-matching mode, but it is off by default to match the Rust posture. + accept_push_mode: bool = False fee_payer_signer: Any = None store: Store | None = None # The RPC client MUST expose at least the methods on @@ -152,17 +165,56 @@ def __init__(self, config: Config) -> None: secret_key = config.secret_key or os.environ.get(_SECRET_KEY_ENV_VAR, "") if not secret_key: raise PaymentError("missing secret key", code="invalid-config") + # Audit #24: enforce a >=32-byte HMAC-SHA256 secret on BOTH the config + # and env-var paths (the value is already resolved from either above). + # The key length is measured in bytes (UTF-8) so multi-byte secrets are + # not over-counted by character length. + if len(secret_key.encode("utf-8")) < MIN_SECRET_KEY_BYTES: + raise PaymentError( + f"secret key must be at least {MIN_SECRET_KEY_BYTES} bytes; " + "use cryptographically-random key material (e.g. `openssl rand -base64 32`)", + code="invalid-config", + ) self._secret_key = secret_key - self._realm = config.realm or _DEFAULT_REALM self._recipient = config.recipient + # Audit #15: derive a per-recipient default realm instead of sharing the + # static "MPP Payment" namespace across every server on the same secret. + # An explicitly supplied empty realm is a misconfig and is rejected so an + # operator cannot accidentally re-introduce the shared namespace. + if config.realm == "": + raise PaymentError( + "realm must not be empty; omit it to derive a per-recipient default", + code="invalid-config", + ) + self._realm = config.realm if config.realm else derive_default_realm(self._recipient) self._currency = config.currency or "USDC" self._decimals = config.decimals or 6 from pay_kit._paycore.solana import _canonical_network as _canonical_net + # Audit #37: reject any network outside {mainnet, devnet, localnet} at + # boot, before any RPC client is built, instead of silently resolving + # unknown slugs to the mainnet RPC host. + try: + validate_network(config.network or "mainnet") + except ValueError as exc: + raise PaymentError(str(exc), code="invalid-config") from exc self._network = _canonical_net(config.network or "mainnet") self._rpc_url = config.rpc_url or default_rpc_url(self._network) + # Audit #28: resolve the token program ONCE at boot. Known stablecoins + # resolve from the static table (Token vs Token-2022 correctly); an + # arbitrary mint address has its on-chain owner fetched and validated; + # a currency that is neither a known symbol nor a valid pubkey is + # rejected. The result is emitted on every SPL challenge instead of a + # silent legacy-Token fallback for arbitrary mints. + try: + self._token_program: str | None = resolve_server_token_program( + self._currency, self._network, self._rpc_url + ) + except ValueError as exc: + raise PaymentError(str(exc), code="invalid-config") from exc self._html = config.html + self._accept_push_mode = config.accept_push_mode self._fee_payer_signer = config.fee_payer_signer if config.store is None: # L4 lock: a missing replay store is a server misconfiguration. @@ -241,12 +293,36 @@ def charge_with_options(self, amount: str, options: ChargeOptions) -> PaymentCha """Create a charge challenge with optional fields.""" base_units = parse_units(amount, self._decimals) + # Audit #21 / #38: validate splits at ISSUANCE (count/parse/positive/ + # dedup) and reject the fee-sponsored drain shape — a split paying the + # primary recipient with ataCreationRequired=true — before the splits + # are embedded into a signed challenge. Previously invalid splits were + # only caught at verify / on-chain time. + split_objs = [Split.from_dict(s) for s in options.splits] + try: + validate_splits(split_objs) + except ValueError as exc: + raise PaymentError(str(exc), code="invalid-config") from exc + is_fee_sponsored = options.fee_payer or self._fee_payer_signer is not None + if is_fee_sponsored: + for split in split_objs: + if split.recipient == self._recipient and split.ata_creation_required: + raise PaymentError( + "fee-sponsored challenge must not create the primary recipient's ATA " + "via a split (drain risk); remove ataCreationRequired on the primary " + "recipient's split", + code="invalid-config", + ) + details: dict[str, Any] = {"network": self._network} if not is_native_sol(self._currency): details["decimals"] = self._decimals - if stablecoin_symbol(self._currency): - details["tokenProgram"] = default_token_program_for_currency(self._currency, self._network) - if options.fee_payer or self._fee_payer_signer is not None: + # Audit #28: emit the boot-resolved token program for every SPL + # challenge (including arbitrary mints resolved on-chain), not just + # known stablecoin symbols. + if self._token_program is not None: + details["tokenProgram"] = self._token_program + if is_fee_sponsored: details["feePayer"] = True if self._fee_payer_signer is not None: details["feePayerKey"] = str(self._fee_payer_signer.pubkey()) @@ -280,24 +356,6 @@ def charge_with_options(self, amount: str, options: ChargeOptions) -> PaymentCha description=options.description, ) - async def verify_credential(self, credential: PaymentCredential) -> Receipt: - """Verify either a transaction or signature credential payload. - - This is the simple API and is appropriate for servers that only gate a - single route. Servers that gate multiple routes at different prices on - the same secret key MUST use ``verify_credential_with_expected`` so the - route's expected amount is compared to the credential's claimed amount; - otherwise a credential issued for a cheaper route can be replayed at - an expensive one. - - Even on the simple API, a Tier-2 pinned-field check enforces that the - credential's method/intent/realm/currency/recipient match this Mpp's - configuration, so cross-route replay across instances with different - recipients/currencies is blocked. - """ - request, details, payload = self._verify_challenge_and_decode(credential) - return await self._verify_payload(credential, request, details, payload) - async def verify_credential_with_expected( self, credential: PaymentCredential, @@ -423,6 +481,14 @@ async def _verify_payload( if payload.type == "transaction": return await self._verify_transaction(credential, request, details, payload) elif payload.type == "signature": + # Audit #5: push mode is opt-in (spec §13.5). Reject unless the + # server explicitly enabled it via Config.accept_push_mode. + if not self._accept_push_mode: + raise PaymentError( + 'type="signature" (push mode) credentials are not accepted; ' + "set Config.accept_push_mode=True to opt in (spec §13.5)", + code="invalid-payload-type", + ) if details.fee_payer: raise PaymentError( 'type="signature" credentials cannot be used with fee sponsorship', diff --git a/python/tests/test_client_charge.py b/python/tests/test_client_charge.py index ed06b1fb6..ce50d33f5 100644 --- a/python/tests/test_client_charge.py +++ b/python/tests/test_client_charge.py @@ -312,6 +312,8 @@ async def get_account(self, _pubkey): currency=unknown_mint, recipient=recipient, method_details=MethodDetails(network="mainnet", decimals=6, recent_blockhash=BLOCKHASH), + # Audit #26: an unknown Token-2022 mint requires explicit opt-in. + allow_unknown_token_2022=True, ) tx, ixs = _instructions(payload.transaction) keys = tx.message.account_keys @@ -455,3 +457,140 @@ async def test_build_credential_header_without_method_details(): ) assert "Payment " in header assert rpc.calls == 1 + + +# ── Audit fixes: client-side guards ────────────────────────────────────────── + + +def _sol_challenge(amount="100", expires="", network="mainnet"): + recipient = str(Keypair().pubkey()) + request = encode_json( + { + "amount": amount, + "currency": "sol", + "recipient": recipient, + "methodDetails": {"network": network, "recentBlockhash": BLOCKHASH}, + } + ) + return PaymentChallenge( + id="c1", realm="api", method="solana", intent="charge", request=request, expires=expires + ) + + +async def test_client_refuses_expired_challenge(): + # Audit #10: an expired challenge is NEVER signed, regardless of opt-ins. + challenge = _sol_challenge(expires="2020-01-01T00:00:00Z") + with pytest.raises(ValueError, match="expired"): + await build_credential_header(signer=Keypair(), rpc_client=None, challenge=challenge) + + +async def test_client_max_amount_guard(): + # Audit #10: opt-in max-amount cap. + challenge = _sol_challenge(amount="1000") + with pytest.raises(ValueError, match="exceeds max"): + await build_credential_header( + signer=Keypair(), rpc_client=None, challenge=challenge, max_amount_base_units=999 + ) + + +async def test_client_max_amount_at_cap_allowed(): + challenge = _sol_challenge(amount="1000") + header = await build_credential_header( + signer=Keypair(), rpc_client=None, challenge=challenge, max_amount_base_units=1000 + ) + assert header.startswith("Payment ") + + +async def test_client_expected_network_guard(): + # Audit #10: opt-in network pin. + challenge = _sol_challenge(network="mainnet") + with pytest.raises(ValueError, match="does not match"): + await build_credential_header( + signer=Keypair(), rpc_client=None, challenge=challenge, expected_network="devnet" + ) + + +async def test_client_refuses_unknown_token_2022_without_opt_in(): + # Audit #26: unknown Token-2022 mint requires opt-in. + from pay_kit._paycore.solana import TOKEN_2022_PROGRAM + + signer = Keypair() + unknown_mint = str(Keypair().pubkey()) + with pytest.raises(ValueError, match="unknown Token-2022 mint"): + await build_charge_transaction( + signer=signer, + rpc_client=None, + amount="1000", + currency=unknown_mint, + recipient=str(Keypair().pubkey()), + method_details=MethodDetails( + network="mainnet", + decimals=6, + token_program=TOKEN_2022_PROGRAM, + recent_blockhash=BLOCKHASH, + ), + ) + + +async def test_client_allows_unknown_vanilla_token_mint(): + # Audit #26: vanilla Token program has no hooks -> first-class, no opt-in. + from pay_kit._paycore.solana import TOKEN_PROGRAM + + signer = Keypair() + unknown_mint = str(Keypair().pubkey()) + payload = await build_charge_transaction( + signer=signer, + rpc_client=None, + amount="1000", + currency=unknown_mint, + recipient=str(Keypair().pubkey()), + method_details=MethodDetails( + network="mainnet", + decimals=6, + token_program=TOKEN_PROGRAM, + recent_blockhash=BLOCKHASH, + ), + ) + assert payload.type == "transaction" + + +async def test_client_requires_decimals_for_spl(): + # Audit #42: SPL charge must carry decimals (no silent default to 6). + from pay_kit._paycore.solana import TOKEN_PROGRAM + + signer = Keypair() + with pytest.raises(ValueError, match="decimals is required"): + await build_charge_transaction( + signer=signer, + rpc_client=None, + amount="1000", + currency="USDC", + recipient=str(Keypair().pubkey()), + method_details=MethodDetails( + network="mainnet", token_program=TOKEN_PROGRAM, recent_blockhash=BLOCKHASH + ), + ) + + +async def test_client_blockhash_fetched_with_confirmed_commitment(): + # Audit #36: blockhash fetched at confirmed commitment. + captured = {} + + class _CommitmentRpc: + async def get_latest_blockhash(self, commitment=None): + captured["commitment"] = commitment + return _Resp(_BlockhashValue(Hash.default())) + + rpc = _CommitmentRpc() + await build_charge_transaction( + signer=Keypair(), + rpc_client=rpc, + amount="100", + currency="sol", + recipient=str(Keypair().pubkey()), + method_details=MethodDetails(), + ) + # Either a solders Confirmed object or the "confirmed" string is accepted. + assert str(captured["commitment"]).lower().find("confirmed") != -1 or captured[ + "commitment" + ] == "confirmed" diff --git a/python/tests/test_cross_route_replay.py b/python/tests/test_cross_route_replay.py index 55bc66eba..4766003cd 100644 --- a/python/tests/test_cross_route_replay.py +++ b/python/tests/test_cross_route_replay.py @@ -86,7 +86,10 @@ async def test_tier2_rejects_tampered_realm(): _resign_echo(echo) with pytest.raises(PaymentError) as exc: - await mpp.verify_credential(_bogus_signature_credential(echo)) + await mpp.verify_credential_with_expected( + _bogus_signature_credential(echo), + ChargeRequest.from_dict(challenge.decode_request()), + ) assert "realm" in str(exc.value).lower() @@ -99,7 +102,10 @@ async def test_tier2_rejects_tampered_method(): _resign_echo(echo) with pytest.raises(PaymentError) as exc: - await mpp.verify_credential(_bogus_signature_credential(echo)) + await mpp.verify_credential_with_expected( + _bogus_signature_credential(echo), + ChargeRequest.from_dict(challenge.decode_request()), + ) assert "method" in str(exc.value).lower() @@ -112,7 +118,10 @@ async def test_tier2_rejects_non_charge_intent(): _resign_echo(echo) with pytest.raises(PaymentError) as exc: - await mpp.verify_credential(_bogus_signature_credential(echo)) + await mpp.verify_credential_with_expected( + _bogus_signature_credential(echo), + ChargeRequest.from_dict(challenge.decode_request()), + ) assert "intent" in str(exc.value).lower() @@ -129,7 +138,10 @@ async def test_tier2_rejects_tampered_currency(): _resign_echo(echo) with pytest.raises(PaymentError) as exc: - await mpp.verify_credential(_bogus_signature_credential(echo)) + await mpp.verify_credential_with_expected( + _bogus_signature_credential(echo), + ChargeRequest.from_dict(challenge.decode_request()), + ) assert "currency" in str(exc.value).lower() @@ -146,7 +158,10 @@ async def test_tier2_rejects_tampered_recipient(): _resign_echo(echo) with pytest.raises(PaymentError) as exc: - await mpp.verify_credential(_bogus_signature_credential(echo)) + await mpp.verify_credential_with_expected( + _bogus_signature_credential(echo), + ChargeRequest.from_dict(challenge.decode_request()), + ) assert "recipient" in str(exc.value).lower() diff --git a/python/tests/test_headers.py b/python/tests/test_headers.py index c5d8d7d10..de963792f 100644 --- a/python/tests/test_headers.py +++ b/python/tests/test_headers.py @@ -70,6 +70,28 @@ def test_rejects_invalid_json_in_request(self): with pytest.raises(ParseError, match="Invalid JSON"): parse_www_authenticate(header) + def test_rejects_oversized_request_param(self): + # Audit #9: request param must be capped before decode/JSON-parse. + from pay_kit.protocols.mpp.core.headers import MAX_TOKEN_LEN + + big = "A" * (MAX_TOKEN_LEN + 1) + header = f'Payment id="x", realm="api", method="solana", intent="charge", request="{big}"' + with pytest.raises(ParseError, match="request parameter exceeds maximum"): + parse_www_authenticate(header) + + def test_accepts_request_param_at_max_size(self): + from pay_kit.protocols.mpp.core.headers import MAX_TOKEN_LEN + + body = encode_json({"amount": "1", "currency": "USDC"}) + # Pad to exactly MAX_TOKEN_LEN with base64url-safe chars; still valid b64 + # for the prefix is not required since we only assert the size gate does + # not fire — but the body must remain decodable JSON. Use a body short + # enough that the gate is not triggered. + assert len(body) <= MAX_TOKEN_LEN + header = f'Payment id="x", realm="api", method="solana", intent="charge", request="{body}"' + parsed = parse_www_authenticate(header) + assert parsed.id == "x" + def test_rejects_duplicate_params(self): header = 'Payment id="a", realm="api", method="solana", intent="charge", request="e30", id="b"' with pytest.raises(ParseError, match="Duplicate"): diff --git a/python/tests/test_intents.py b/python/tests/test_intents.py index 32eb1cbdd..d4266a966 100644 --- a/python/tests/test_intents.py +++ b/python/tests/test_intents.py @@ -26,12 +26,38 @@ def test_zero(self): def test_zero_point_zero(self): assert parse_units("0.0", 6) == "0" - def test_leading_decimal(self): - assert parse_units(".5", 6) == "500000" - def test_exact_decimals(self): assert parse_units("1.000001", 6) == "1000001" + # Audit #44/#45: malformed shapes must be rejected, not silently parsed. + def test_leading_decimal_rejected(self): + with pytest.raises(ValueError, match="invalid amount"): + parse_units(".5", 6) + + def test_trailing_decimal_rejected(self): + with pytest.raises(ValueError, match="invalid amount"): + parse_units("5.", 6) + + def test_bare_dot_rejected(self): + with pytest.raises(ValueError, match="invalid amount"): + parse_units(".", 6) + + def test_leading_plus_rejected(self): + with pytest.raises(ValueError, match="invalid amount"): + parse_units("+5", 6) + + def test_underscore_grouping_rejected(self): + with pytest.raises(ValueError, match="invalid amount"): + parse_units("1_000", 6) + + def test_unicode_digits_rejected(self): + with pytest.raises(ValueError, match="invalid amount"): + parse_units("١٢٣", 6) # noqa: RUF001 — Arabic-Indic digits + + def test_unicode_digits_in_fraction_rejected(self): + with pytest.raises(ValueError, match="invalid amount"): + parse_units("1.٥", 6) # noqa: RUF001 + def test_empty_raises(self): with pytest.raises(ValueError, match="amount is required"): parse_units("", 6) diff --git a/python/tests/test_middleware.py b/python/tests/test_middleware.py index 3c0ee1770..42508762c 100644 --- a/python/tests/test_middleware.py +++ b/python/tests/test_middleware.py @@ -67,9 +67,12 @@ class FakeRequest: @pytest.mark.asyncio async def test_splits_option_is_included_in_challenge(self, mpp_handler): + # Audit #21: split recipients are validated as real pubkeys at issuance. + from solders.pubkey import Pubkey + splits = [ { - "recipient": "VendorPayoutsWaLLetxxxxxxxxxxxxxxxxxxxxxx1111", + "recipient": str(Pubkey.new_unique()), "amount": "1000", "memo": "vendor payout", } diff --git a/python/tests/test_rpc_contract.py b/python/tests/test_rpc_contract.py index d6158a889..a74021eb6 100644 --- a/python/tests/test_rpc_contract.py +++ b/python/tests/test_rpc_contract.py @@ -16,7 +16,9 @@ async def get_transaction(self, sig, **kw): def test_config_rpc_missing_method_rejected_at_init(): cfg = Config( recipient="11111111111111111111111111111112", - secret_key="s", + # Audit #24: secret must be >=32 bytes; use a valid one so the RPC + # contract check is what fires, not the secret-length gate. + secret_key="test-secret-key-that-is-long-enough-for-hmac-sha256", rpc=_LegacyClientLackingAwaitConfirmation(), store=MemoryStore(), ) diff --git a/python/tests/test_server.py b/python/tests/test_server.py index 88e8ea03e..e8f86464e 100644 --- a/python/tests/test_server.py +++ b/python/tests/test_server.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + import pytest from solders.hash import Hash from solders.instruction import AccountMeta, Instruction @@ -34,6 +36,24 @@ TEST_BLOCKHASH = "4vJ9JU1bJJQpUgJ8V6hYz7xXKz4F2tN6aBrZEcD3xKhs" +def _expected_from_challenge(challenge) -> ChargeRequest: + """Build the route's expected ChargeRequest from a challenge this test issued. + + Audit #2: ``verify_credential`` (which trusted the echoed amount) was + removed; callers must verify against an explicit expected request. These + tests issue the challenge themselves via ``mpp.charge*`` so the expected is + just the decoded issued request — the amount-pinning security property is + exercised by the dedicated ``verify_credential_with_expected`` mismatch + tests, not these path tests. + """ + return ChargeRequest.from_dict(challenge.decode_request()) + + +def _verify(mpp: Mpp, credential: PaymentCredential, challenge) -> Any: + """Verify a credential against the expected request decoded from ``challenge``.""" + return mpp.verify_credential_with_expected(credential, _expected_from_challenge(challenge)) + + def _derive_ata(owner: str, mint: str, token_program: str = TOKEN_PROGRAM) -> str: """Derive ATA address for test helpers.""" owner_pk = Pubkey.from_string(owner) @@ -176,6 +196,10 @@ def mpp() -> Mpp: secret_key=TEST_SECRET, rpc=rpc, store=MemoryStore(), + # Audit #5: this shared fixture exercises push-mode (signature) paths, + # which are opt-in. Enable it here so those tests reach the settlement + # logic; the default-off behavior is covered by dedicated tests. + accept_push_mode=True, ) return Mpp(config) @@ -191,7 +215,11 @@ def test_missing_secret_key_raises(self, monkeypatch: pytest.MonkeyPatch): Mpp(Config(recipient=TEST_RECIPIENT, secret_key="", store=MemoryStore())) def test_defaults(self, mpp: Mpp): - assert mpp.realm == "MPP Payment" + # Audit #15: default realm is derived per-recipient, not the shared + # "MPP Payment" namespace. + from pay_kit._paycore.solana import derive_default_realm + + assert mpp.realm == derive_default_realm(TEST_RECIPIENT) assert "devnet" in mpp.rpc_url @@ -225,14 +253,16 @@ def test_charge_includes_recipient(self, mpp: Mpp): assert request["currency"] == "USDC" def test_charge_with_splits(self, mpp: Mpp): + vendor = str(Pubkey.new_unique()) + processor = str(Pubkey.new_unique()) options = ChargeOptions( splits=[ { - "recipient": "VendorPayoutsWaLLetxxxxxxxxxxxxxxxxxxxxxx1111", + "recipient": vendor, "amount": "500000", "memo": "Vendor payout", }, - {"recipient": "ProcessorFeeWaLLetxxxxxxxxxxxxxxxxxxxxxxx1111", "amount": "29000"}, + {"recipient": processor, "amount": "29000"}, ], ) challenge = mpp.charge_with_options("1.00", options) @@ -284,7 +314,9 @@ async def test_challenge_mismatch(self, mpp: Mpp): payload={"type": "transaction", "transaction": "abc"}, ) with pytest.raises(ChallengeMismatchError): - await mpp.verify_credential(credential) + await mpp.verify_credential_with_expected( + credential, ChargeRequest(amount="0", currency="USDC", recipient=TEST_RECIPIENT) + ) async def test_challenge_expired(self, mpp: Mpp): challenge = mpp.charge_with_options("1.00", ChargeOptions(expires="2020-01-01T00:00:00Z")) @@ -294,7 +326,7 @@ async def test_challenge_expired(self, mpp: Mpp): payload={"type": "transaction", "transaction": "abc"}, ) with pytest.raises(ChallengeExpiredError): - await mpp.verify_credential(credential) + await _verify(mpp, credential, challenge) async def test_invalid_payload_type(self, mpp: Mpp): challenge = mpp.charge("1.00") @@ -304,7 +336,7 @@ async def test_invalid_payload_type(self, mpp: Mpp): payload={"type": "unknown"}, ) with pytest.raises(PaymentError, match="invalid payload type"): - await mpp.verify_credential(credential) + await _verify(mpp, credential, challenge) async def test_replay_protection(self, mpp: Mpp): challenge = mpp.charge("1.00") @@ -314,12 +346,12 @@ async def test_replay_protection(self, mpp: Mpp): payload={"type": "signature", "signature": VALID_SIGNATURE}, ) # First call succeeds - receipt = await mpp.verify_credential(credential) + receipt = await _verify(mpp, credential, challenge) assert receipt.is_success() # Second call with same signature fails with pytest.raises(ReplayError): - await mpp.verify_credential(credential) + await _verify(mpp, credential, challenge) async def test_missing_transaction(self, mpp: Mpp): challenge = mpp.charge("1.00") @@ -329,7 +361,7 @@ async def test_missing_transaction(self, mpp: Mpp): payload={"type": "transaction", "transaction": ""}, ) with pytest.raises(PaymentError, match="missing transaction"): - await mpp.verify_credential(credential) + await _verify(mpp, credential, challenge) async def test_missing_signature(self, mpp: Mpp): challenge = mpp.charge("1.00") @@ -339,7 +371,7 @@ async def test_missing_signature(self, mpp: Mpp): payload={"type": "signature", "signature": ""}, ) with pytest.raises(PaymentError, match="missing signature"): - await mpp.verify_credential(credential) + await _verify(mpp, credential, challenge) async def test_signature_fee_payer_rejected(self, mpp: Mpp): options = ChargeOptions(fee_payer=True) @@ -350,7 +382,7 @@ async def test_signature_fee_payer_rejected(self, mpp: Mpp): payload={"type": "signature", "signature": "sig456"}, ) with pytest.raises(PaymentError, match="fee sponsorship"): - await mpp.verify_credential(credential) + await _verify(mpp, credential, challenge) async def test_signature_verification_fetches_and_checks_transaction(self): recipient_ata = _derive_ata(TEST_RECIPIENT, USDC_DEVNET) @@ -384,6 +416,7 @@ async def test_signature_verification_fetches_and_checks_transaction(self): secret_key=TEST_SECRET, rpc=rpc, store=MemoryStore(), + accept_push_mode=True, # Audit #5: push-mode is opt-in ) ) challenge = mpp.charge("1.00") @@ -392,7 +425,7 @@ async def test_signature_verification_fetches_and_checks_transaction(self): payload={"type": "signature", "signature": VALID_SIGNATURE}, ) - receipt = await mpp.verify_credential(credential) + receipt = await _verify(mpp, credential, challenge) assert receipt.is_success() assert receipt.reference == credential.payload["signature"] @@ -421,6 +454,7 @@ async def test_signature_verification_checks_external_id_memo(self): secret_key=TEST_SECRET, rpc=rpc, store=MemoryStore(), + accept_push_mode=True, # Audit #5: push-mode is opt-in ) ) challenge = mpp.charge_with_options("0.000001", ChargeOptions(external_id="order-123")) @@ -429,7 +463,7 @@ async def test_signature_verification_checks_external_id_memo(self): payload={"type": "signature", "signature": VALID_SIGNATURE}, ) - receipt = await mpp.verify_credential(credential) + receipt = await _verify(mpp, credential, challenge) assert receipt.is_success() assert receipt.external_id == "order-123" @@ -468,7 +502,7 @@ async def test_transaction_verification_broadcasts_and_checks_transaction(self): }, ) - receipt = await mpp.verify_credential(credential) + receipt = await _verify(mpp, credential, challenge) assert receipt.is_success() assert receipt.reference == "1111111111111111111111111111111111111111111111111111111111111111" assert rpc.sent @@ -497,7 +531,7 @@ async def test_transaction_verification_rejects_wrong_recipient_before_broadcast ) with pytest.raises(PaymentError, match="no matching SOL transfer"): - await mpp.verify_credential(credential) + await _verify(mpp, credential, challenge) assert rpc.sent == [] async def test_transaction_verification_rejects_wrong_amount_before_broadcast(self): @@ -524,7 +558,7 @@ async def test_transaction_verification_rejects_wrong_amount_before_broadcast(se ) with pytest.raises(PaymentError, match="no matching SOL transfer"): - await mpp.verify_credential(credential) + await _verify(mpp, credential, challenge) assert rpc.sent == [] async def test_transaction_verification_rejects_missing_memo_before_broadcast(self): @@ -551,7 +585,7 @@ async def test_transaction_verification_rejects_missing_memo_before_broadcast(se ) with pytest.raises(PaymentError, match="No memo instruction found"): - await mpp.verify_credential(credential) + await _verify(mpp, credential, challenge) assert rpc.sent == [] async def test_token_transaction_verification_broadcasts_matching_transaction(self): @@ -597,7 +631,7 @@ async def test_token_transaction_verification_broadcasts_matching_transaction(se }, ) - receipt = await mpp.verify_credential(credential) + receipt = await _verify(mpp, credential, challenge) assert receipt.is_success() assert rpc.sent @@ -625,7 +659,7 @@ async def test_token_transaction_verification_rejects_wrong_recipient_before_bro ) with pytest.raises(PaymentError, match="no matching token transfer"): - await mpp.verify_credential(credential) + await _verify(mpp, credential, challenge) assert rpc.sent == [] async def test_token_transaction_verification_rejects_wrong_amount_before_broadcast(self): @@ -652,7 +686,7 @@ async def test_token_transaction_verification_rejects_wrong_amount_before_broadc ) with pytest.raises(PaymentError, match="no matching token transfer"): - await mpp.verify_credential(credential) + await _verify(mpp, credential, challenge) assert rpc.sent == [] async def test_token_transaction_verification_rejects_missing_memo_before_broadcast(self): @@ -679,7 +713,7 @@ async def test_token_transaction_verification_rejects_missing_memo_before_broadc ) with pytest.raises(PaymentError, match="No memo instruction found"): - await mpp.verify_credential(credential) + await _verify(mpp, credential, challenge) assert rpc.sent == [] @@ -970,7 +1004,7 @@ async def put_if_absent(self, key, value): self._data[key] = value return True - def _build_credential(self, mpp_handler: Mpp) -> tuple[PaymentCredential, str]: + def _build_credential(self, mpp_handler: Mpp): transaction = _build_spl_transfer_checked_transaction(TEST_RECIPIENT, USDC_DEVNET, 1_000_000) challenge = mpp_handler.charge("1.00") echo = challenge.to_echo() @@ -978,7 +1012,7 @@ def _build_credential(self, mpp_handler: Mpp) -> tuple[PaymentCredential, str]: challenge=echo, payload={"type": "transaction", "transaction": transaction}, ) - return credential, transaction + return credential, transaction, challenge async def test_broadcast_before_consume(self): ordering: list[str] = [] @@ -997,9 +1031,9 @@ async def test_broadcast_before_consume(self): store=store, ) ) - credential, _tx = self._build_credential(handler) + credential, _tx, challenge = self._build_credential(handler) - receipt = await handler.verify_credential(credential) + receipt = await _verify(handler, credential, challenge) assert receipt.is_success() # The canonical L8 order is broadcast → consume → await. assertions @@ -1036,10 +1070,10 @@ async def test_confirm_timeout_after_broadcast_does_not_rollback_consume(self): store=store, ) ) - credential, _tx = self._build_credential(handler) + credential, _tx, challenge = self._build_credential(handler) with pytest.raises(PaymentError): - await handler.verify_credential(credential) + await _verify(handler, credential, challenge) # The consume marker must still be present after the timeout: the # signature is on the wire and may finalize asynchronously. @@ -1088,6 +1122,7 @@ async def await_confirmation(self, *_a, **_kw): secret_key=TEST_SECRET, rpc=rpc, store=MemoryStore(), + accept_push_mode=True, # Audit #5: reach the fee-sponsorship check ) ) # Build a challenge with feePayer=true via ChargeOptions. @@ -1099,7 +1134,7 @@ async def await_confirmation(self, *_a, **_kw): ) with pytest.raises(PaymentError, match="fee sponsorship"): - await handler.verify_credential(credential) + await _verify(handler, credential, challenge) # Critical: the rejection happened BEFORE any RPC call. A signature # credential under feePayer is a structural error; we never look up @@ -1126,8 +1161,8 @@ async def test_signature_keyed_consume_not_credential_keyed(self): store=store, ) ) - credential, _tx = self._build_credential(handler) - await handler.verify_credential(credential) + credential, _tx, challenge = self._build_credential(handler) + await _verify(handler, credential, challenge) # Inspect the store: the consume key must include the on-chain # signature returned by send_raw_transaction, not the credential @@ -2239,3 +2274,176 @@ def test_legitimate_payment_with_matching_echoed_and_server_keys_is_accepted(sel details, expected_fee_payer_pubkey=str(server_fee_payer.pubkey()), ) + + +class TestAuditServerConfigGuards: + """Boot-time config guards: #24 secret length, #15 realm, #37 network.""" + + def _base(self, **overrides: Any) -> Config: + # Annotated so pyright doesn't widen the heterogeneous literals to a + # union and then reject every Config(**kwargs) parameter. + kwargs: dict[str, Any] = dict( + recipient=TEST_RECIPIENT, + currency="USDC", + decimals=6, + network="devnet", + secret_key=TEST_SECRET, + store=MemoryStore(), + ) + kwargs.update(overrides) + return Config(**kwargs) + + def test_rejects_short_secret_key(self): + with pytest.raises(PaymentError, match="at least 32 bytes"): + Mpp(self._base(secret_key="too-short")) + + def test_rejects_short_env_secret_key(self, monkeypatch: pytest.MonkeyPatch): + # Audit #24: the env-var path must apply the same floor. + monkeypatch.setenv("MPP_SECRET_KEY", "x") + with pytest.raises(PaymentError, match="at least 32 bytes"): + Mpp(self._base(secret_key="")) + + def test_accepts_secret_key_at_minimum(self): + Mpp(self._base(secret_key="a" * 32)) # exactly 32 bytes + + def test_rejects_explicit_empty_realm(self): + with pytest.raises(PaymentError, match="realm must not be empty"): + Mpp(self._base(realm="")) + + def test_default_realm_is_derived(self): + from pay_kit._paycore.solana import derive_default_realm + + mpp = Mpp(self._base()) + assert mpp.realm == derive_default_realm(TEST_RECIPIENT) + + def test_explicit_realm_used_verbatim(self): + mpp = Mpp(self._base(realm="Acme API")) + assert mpp.realm == "Acme API" + + def test_rejects_unknown_network(self): + with pytest.raises(PaymentError, match="unknown network"): + Mpp(self._base(network="testnet")) + + def test_rejects_mainnet_beta_is_canonicalized(self): + # mainnet-beta is accepted (alias) and canonicalized. + mpp = Mpp(self._base(network="mainnet-beta", currency="USDC")) + assert mpp._network == "mainnet" + + +class TestAuditPushModeOptIn: + """Audit #5: push (type=signature) is rejected unless opted in.""" + + def _mpp(self, accept_push: bool): + return Mpp( + Config( + recipient=TEST_RECIPIENT, + currency="SOL", + decimals=9, + network="devnet", + secret_key=TEST_SECRET, + rpc=FakeRPC(tx={"meta": {"err": None}, "transaction": {"message": {"instructions": []}}}), + store=MemoryStore(), + accept_push_mode=accept_push, + ) + ) + + async def test_push_rejected_by_default(self): + mpp = self._mpp(accept_push=False) + challenge = mpp.charge("0.000001") + credential = PaymentCredential( + challenge=challenge.to_echo(), + payload={"type": "signature", "signature": VALID_SIGNATURE}, + ) + with pytest.raises(PaymentError, match="push mode"): + await _verify(mpp, credential, challenge) + + +class TestAuditSplitIssuanceGuards: + """Audit #21 / #38: split validation and primary-in-splits at issuance.""" + + def _mpp(self, fee_payer_signer=None): + return Mpp( + Config( + recipient=TEST_RECIPIENT, + currency="USDC", + decimals=6, + network="devnet", + secret_key=TEST_SECRET, + rpc=FakeRPC(), + store=MemoryStore(), + fee_payer_signer=fee_payer_signer, + ) + ) + + def test_rejects_invalid_split_recipient_at_issuance(self): + mpp = self._mpp() + with pytest.raises(PaymentError, match="not a valid pubkey"): + mpp.charge_with_options( + "1.00", ChargeOptions(splits=[{"recipient": "bogus", "amount": "1000"}]) + ) + + def test_rejects_duplicate_split_recipient_at_issuance(self): + mpp = self._mpp() + r = str(Pubkey.new_unique()) + with pytest.raises(PaymentError, match="duplicate"): + mpp.charge_with_options( + "1.00", + ChargeOptions(splits=[{"recipient": r, "amount": "1"}, {"recipient": r, "amount": "2"}]), + ) + + def test_rejects_primary_recipient_split_with_ata_creation_when_fee_sponsored(self): + signer = Keypair() + mpp = self._mpp(fee_payer_signer=signer) + with pytest.raises(PaymentError, match="primary recipient"): + mpp.charge_with_options( + "1.00", + ChargeOptions( + splits=[{"recipient": TEST_RECIPIENT, "amount": "1000", "ataCreationRequired": True}] + ), + ) + + def test_allows_primary_recipient_split_without_ata_creation(self): + signer = Keypair() + mpp = self._mpp(fee_payer_signer=signer) + # Same recipient as primary, but no ataCreationRequired -> legitimate. + challenge = mpp.charge_with_options( + "1.00", + ChargeOptions(splits=[{"recipient": TEST_RECIPIENT, "amount": "1000"}]), + ) + assert challenge.id + + +class TestAuditFeeSponsoredComputeCap: + """Audit #25: tight compute-unit-price cap when fee-sponsored.""" + + def test_fee_sponsored_under_tight_cap_passes(self): + from pay_kit.protocols.mpp.server.charge import ( + MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED, + _validate_compute_budget_instruction, + ) + + data = bytes([3]) + MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED.to_bytes(8, "little") + _validate_compute_budget_instruction(data, 0, fee_sponsored=True) # at cap, ok + + def test_fee_sponsored_above_tight_cap_rejected(self): + from pay_kit.protocols.mpp.server.charge import ( + MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED, + _validate_compute_budget_instruction, + ) + + over = MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED + 1 + data = bytes([3]) + over.to_bytes(8, "little") + with pytest.raises(PaymentError) as exc: + _validate_compute_budget_instruction(data, 0, fee_sponsored=True) + assert exc.value.code == "compute-budget-cap-exceeded" + + def test_client_paid_above_tight_cap_passes(self): + # Regression: the tight cap MUST NOT apply when the client pays. + from pay_kit.protocols.mpp.server.charge import ( + MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED, + _validate_compute_budget_instruction, + ) + + price = MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED + 1 + data = bytes([3]) + price.to_bytes(8, "little") + _validate_compute_budget_instruction(data, 0, fee_sponsored=False) # general cap applies diff --git a/python/tests/test_solana_protocol.py b/python/tests/test_solana_protocol.py index 01165bc9d..d18bc4c32 100644 --- a/python/tests/test_solana_protocol.py +++ b/python/tests/test_solana_protocol.py @@ -218,3 +218,159 @@ def test_associated_token_program(self): def test_memo_program(self): assert MEMO_PROGRAM.startswith("Memo") + + +class TestValidateNetwork: + """Audit #37: boot-time network allowlist.""" + + def test_accepts_canonical_networks(self): + from pay_kit._paycore.solana import validate_network + + for net in ("mainnet", "devnet", "localnet"): + validate_network(net) # must not raise + + def test_accepts_mainnet_beta_alias(self): + from pay_kit._paycore.solana import validate_network + + validate_network("mainnet-beta") + + def test_rejects_unknown_network(self): + import pytest + + from pay_kit._paycore.solana import validate_network + + with pytest.raises(ValueError, match="unknown network"): + validate_network("testnet") + + def test_rejects_empty_network(self): + import pytest + + from pay_kit._paycore.solana import validate_network + + with pytest.raises(ValueError, match="network is required"): + validate_network("") + + +class TestDeriveDefaultRealm: + """Audit #15: per-recipient derived default realm.""" + + def test_shape(self): + from pay_kit._paycore.solana import derive_default_realm + + realm = derive_default_realm("11111111111111111111111111111112") + assert realm.startswith("App Id - #") + + def test_deterministic(self): + from pay_kit._paycore.solana import derive_default_realm + + r = "11111111111111111111111111111112" + assert derive_default_realm(r) == derive_default_realm(r) + + def test_differs_across_recipients(self): + from pay_kit._paycore.solana import derive_default_realm + + a = derive_default_realm("11111111111111111111111111111112") + b = derive_default_realm("9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ") + assert a != b + + +class TestValidateSplits: + """Audit #21: issuance-time split validation.""" + + def _split(self, recipient=None, amount="1000", ata=False): + from solders.pubkey import Pubkey + + return Split( + recipient=recipient or str(Pubkey.new_unique()), + amount=amount, + ata_creation_required=ata, + ) + + def test_accepts_valid_set(self): + from pay_kit._paycore.solana import validate_splits + + validate_splits([self._split(), self._split()]) + + def test_accepts_empty(self): + from pay_kit._paycore.solana import validate_splits + + validate_splits([]) + + def test_rejects_count_above_max(self): + import pytest + + from pay_kit._paycore.solana import MAX_SPLITS, validate_splits + + with pytest.raises(ValueError, match="too many splits"): + validate_splits([self._split() for _ in range(MAX_SPLITS + 1)]) + + def test_rejects_invalid_recipient(self): + import pytest + + from pay_kit._paycore.solana import validate_splits + + with pytest.raises(ValueError, match="not a valid pubkey"): + validate_splits([self._split(recipient="not-a-pubkey-xxx")]) + + def test_rejects_unparseable_amount(self): + import pytest + + from pay_kit._paycore.solana import validate_splits + + with pytest.raises(ValueError, match="not a valid integer"): + validate_splits([self._split(amount="abc")]) + + def test_rejects_zero_amount(self): + import pytest + + from pay_kit._paycore.solana import validate_splits + + with pytest.raises(ValueError, match="must be positive"): + validate_splits([self._split(amount="0")]) + + def test_rejects_duplicate_recipient(self): + import pytest + from solders.pubkey import Pubkey + + from pay_kit._paycore.solana import validate_splits + + r = str(Pubkey.new_unique()) + with pytest.raises(ValueError, match="duplicate split recipient"): + validate_splits([self._split(recipient=r), self._split(recipient=r)]) + + +class TestResolveServerTokenProgram: + """Audit #28: boot-time token-program resolution.""" + + def test_sol_returns_none(self): + from pay_kit._paycore.solana import resolve_server_token_program + + assert resolve_server_token_program("SOL", "mainnet", None) is None + + def test_known_stablecoin_classic_token(self): + from pay_kit._paycore.solana import resolve_server_token_program + + assert resolve_server_token_program("USDC", "mainnet", None) == TOKEN_PROGRAM + + def test_known_stablecoin_token_2022(self): + from pay_kit._paycore.solana import resolve_server_token_program + + assert resolve_server_token_program("PYUSD", "mainnet", None) == TOKEN_2022_PROGRAM + + def test_rejects_unparseable_currency(self): + import pytest + + from pay_kit._paycore.solana import resolve_server_token_program + + with pytest.raises(ValueError, match="neither a known stablecoin"): + resolve_server_token_program("not-a-symbol-or-mint", "mainnet", None) + + def test_arbitrary_mint_without_rpc_fails_fast(self): + import pytest + from solders.pubkey import Pubkey + + from pay_kit._paycore.solana import resolve_server_token_program + + mint = str(Pubkey.new_unique()) + with pytest.raises(ValueError, match="no rpc_url configured"): + resolve_server_token_program(mint, "mainnet", None) diff --git a/ruby/examples/simple-server/app.rb b/ruby/examples/simple-server/app.rb index 73350fd73..c4740f360 100644 --- a/ruby/examples/simple-server/app.rb +++ b/ruby/examples/simple-server/app.rb @@ -22,7 +22,8 @@ # MPP signs its 402 challenges with this HMAC secret. A real # deployment loads it from a secret store; the demo uses a fixed # value so the server boots with no setup. - c.mpp.challenge_binding_secret = "simple-server-demo-secret" + # Must be >= 32 bytes; the MPP server enforces this at boot (audit #24). + c.mpp.challenge_binding_secret = "simple-server-demo-secret-0000000000" end # One gate: $0.10, settled in any configured stablecoin. diff --git a/ruby/lib/pay_core/solana/mints.rb b/ruby/lib/pay_core/solana/mints.rb index 3daa238cd..d62d7aecf 100644 --- a/ruby/lib/pay_core/solana/mints.rb +++ b/ruby/lib/pay_core/solana/mints.rb @@ -81,6 +81,15 @@ def resolve(currency, network) end # Return the default SPL token program for a currency. + # + # SAFETY (audit #28): this resolves the token program from the static + # stablecoin table ONLY. Known stablecoins (including Token-2022 ones: + # PYUSD/USDG/CASH) resolve correctly. An ARBITRARY mint address that is + # not in the table is NOT recognised here — callers that accept arbitrary + # mints MUST NOT rely on this method's legacy-Token default. Use + # `known_currency?` to gate, and have the caller require an explicit + # on-chain-resolved `tokenProgram` (or reject) for unknown mints. See + # `PayKit::Protocols::Mpp::Protocol::Solana::ChargeMethod`. def token_program_for(currency, network) symbol = symbol_for(currency, network) TOKEN_2022_SYMBOLS.include?(symbol) ? TOKEN_2022_PROGRAM : TOKEN_PROGRAM @@ -97,6 +106,15 @@ def symbol_for(currency, network) nil end + # True when `currency` is a known symbol (or SOL), or a known stablecoin + # mint address. False for an arbitrary, unrecognised mint address — for + # which the static `token_program_for` legacy-Token default is NOT safe + # to trust (audit #28). Used to decide whether the token program can be + # resolved from the table or must be supplied/resolved on-chain. + def known_currency?(currency, network) + !symbol_for(currency, network).nil? + end + # Look up the decimals for a known mint symbol or address. Falls back # to 6 (the common SPL stablecoin precision) for unknown tokens. def decimals_for(currency, network) diff --git a/ruby/lib/pay_core/solana/rpc.rb b/ruby/lib/pay_core/solana/rpc.rb index 0b5b698c8..f0efd1144 100644 --- a/ruby/lib/pay_core/solana/rpc.rb +++ b/ruby/lib/pay_core/solana/rpc.rb @@ -92,6 +92,20 @@ def signature_statuses(signatures) call("getSignatureStatuses", [signatures]).fetch("value") end + # Fetch the owning program of an account (the `owner` field of + # getAccountInfo). Returns the owner program ID string, or nil when the + # account does not exist. Used to resolve the token program of an + # arbitrary SPL mint at boot (audit #28). + def account_owner(pubkey) + value = call("getAccountInfo", [ + pubkey, + {"encoding" => "base64", "commitment" => "confirmed"} + ]).fetch("value") + return nil if value.nil? + + value["owner"] + end + # Fetch a confirmed transaction by signature using base64 encoding. def transaction_base64(signature) call("getTransaction", [ diff --git a/ruby/lib/pay_kit/protocols/mpp/protocol/core/challenge_store.rb b/ruby/lib/pay_kit/protocols/mpp/protocol/core/challenge_store.rb index 96868e971..b1d19c065 100644 --- a/ruby/lib/pay_kit/protocols/mpp/protocol/core/challenge_store.rb +++ b/ruby/lib/pay_kit/protocols/mpp/protocol/core/challenge_store.rb @@ -12,8 +12,15 @@ class ChallengeStore attr_reader :secret_key, :realm, :blockhash_provider, :default_expires_seconds - def initialize(secret_key:, realm: "MPP Payment", blockhash_provider: nil, + # `realm:` is required (audit #15): there is intentionally no shared + # default. The previous `"MPP Payment"` default put two servers that + # shared an HMAC secret into one credential namespace. The user-facing + # `Server::Charge` derives a per-recipient realm before constructing + # this store; low-level callers must pass their own. + def initialize(secret_key:, realm:, blockhash_provider: nil, default_expires_seconds: DEFAULT_EXPIRES_SECONDS) + raise ArgumentError, "realm must not be empty" if realm.to_s.empty? + @secret_key = secret_key @realm = realm @blockhash_provider = blockhash_provider diff --git a/ruby/lib/pay_kit/protocols/mpp/protocol/solana.rb b/ruby/lib/pay_kit/protocols/mpp/protocol/solana.rb index d65cac1b7..efadc91e3 100644 --- a/ruby/lib/pay_kit/protocols/mpp/protocol/solana.rb +++ b/ruby/lib/pay_kit/protocols/mpp/protocol/solana.rb @@ -1,11 +1,37 @@ # frozen_string_literal: true +require "pay_core/error_codes" require "pay_core/solana/rpc" require "pay_core/solana/mints" module PayKit::Protocols::Mpp module Protocol module Solana + # Network slug allowlist (audit #37). The spec requires `network` to be + # one of these canonical slugs. `"mainnet-beta"` is a Solana RPC hostname + # convention only and is rejected here in favour of `"mainnet"`. + NETWORK_MAINNET = "mainnet" + NETWORK_DEVNET = "devnet" + NETWORK_LOCALNET = "localnet" + NETWORKS = [NETWORK_MAINNET, NETWORK_DEVNET, NETWORK_LOCALNET].freeze + DEFAULT_NETWORK = NETWORK_MAINNET + + # Validate a network slug against the allowlist. Raises on anything + # outside {mainnet, devnet, localnet} — in particular "mainnet-beta" and + # "testnet" — so a misconfigured server fails fast at boot rather than + # silently resolving the mainnet mint (audit #37). + def self.validate_network!(network) + slug = network.to_s + raise Error.new("network is required", code: ::PayCore::ErrorCodes::CODE_PAYMENT_INVALID) if slug.empty? + return if NETWORKS.include?(slug) + + raise Error.new( + "Unsupported network #{slug.inspect}: must be one of #{NETWORKS.join(", ")} " \ + "(use \"mainnet\", not \"mainnet-beta\")", + code: ::PayCore::ErrorCodes::CODE_PAYMENT_INVALID + ) + end + # Build a Solana charge method bundling all static config (recipient, # currency, network, RPC, optional fee payer, decimals). Pass the result # to PayKit::Protocols::Mpp.create(method:, ...). @@ -19,7 +45,8 @@ module Solana # network: "mainnet", # rpc: "https://api.mainnet-beta.solana.com", # ) - def self.charge(recipient:, currency:, rpc:, network: "mainnet", fee_payer: nil, decimals: nil) + def self.charge(recipient:, currency:, rpc:, network: DEFAULT_NETWORK, fee_payer: nil, decimals: nil) + validate_network!(network) ChargeMethod.new( recipient: recipient, currency: currency, @@ -55,8 +82,18 @@ def fee_payer_pubkey end # Default SPL token program for this method's currency+network pair. + # + # SOL has no token program. Known stablecoins resolve from the static + # table. For an ARBITRARY mint address (not in the table), the static + # table's legacy-Token default is unsafe (audit #28): the mint may be + # owned by the Token-2022 program. We fetch the mint's on-chain owner + # once, lazily, and cache it; we reject any owner that is neither the + # Token nor Token-2022 program. The result is `nil` for SOL. def token_program - ::PayCore::Solana::Mints.token_program_for(currency, network) + return nil if currency.to_s.casecmp("SOL").zero? + return ::PayCore::Solana::Mints.token_program_for(currency, network) if ::PayCore::Solana::Mints.known_currency?(currency, network) + + resolve_arbitrary_mint_token_program end # Short-window blockhash cache: every protected request would otherwise @@ -80,7 +117,7 @@ def method_details(currency: self.currency) details = { "network" => network, "decimals" => (currency == self.currency) ? decimals : ::PayCore::Solana::Mints.decimals_for(currency, network), - "tokenProgram" => ::PayCore::Solana::Mints.token_program_for(currency, network), + "tokenProgram" => token_program_for(currency), "recentBlockhash" => latest_blockhash } if fee_payer @@ -89,6 +126,46 @@ def method_details(currency: self.currency) end details end + + private + + # Resolve the token program for a currency on THIS method, routing + # arbitrary mints through the on-chain owner lookup (audit #28). + def token_program_for(currency) + return token_program if currency == self.currency + return ::PayCore::Solana::Mints.token_program_for(currency, network) if ::PayCore::Solana::Mints.known_currency?(currency, network) + + resolve_arbitrary_mint_token_program(currency) + end + + # Fetch the on-chain owner of an arbitrary mint and validate it is one + # of the two SPL token programs. Cached per resolved mint so we issue + # at most one RPC round-trip per mint for the life of the method. + # Rejects (rather than silently defaulting to legacy Token) when the + # owner is unexpected or the mint cannot be fetched (audit #28). + def resolve_arbitrary_mint_token_program(mint = currency) + @token_program_cache ||= {} + @token_program_cache.fetch(mint) do + owner = + begin + rpc.account_owner(mint) + rescue => error + raise Error.new( + "Could not resolve the token program for mint #{mint}: #{error.message}. " \ + "Pass the mint's token program explicitly or use a known stablecoin symbol.", + code: ::PayCore::ErrorCodes::CODE_PAYMENT_INVALID + ) + end + valid = [::PayCore::Solana::Mints::TOKEN_PROGRAM, ::PayCore::Solana::Mints::TOKEN_2022_PROGRAM] + unless valid.include?(owner) + raise Error.new( + "Mint #{mint} is owned by #{owner.inspect}, not the SPL Token or Token-2022 program", + code: ::PayCore::ErrorCodes::CODE_PAYMENT_INVALID + ) + end + @token_program_cache[mint] = owner + end + end end end end diff --git a/ruby/lib/pay_kit/protocols/mpp/protocol/solana/verifier.rb b/ruby/lib/pay_kit/protocols/mpp/protocol/solana/verifier.rb index 1fe3e8f94..01d9b5b7a 100644 --- a/ruby/lib/pay_kit/protocols/mpp/protocol/solana/verifier.rb +++ b/ruby/lib/pay_kit/protocols/mpp/protocol/solana/verifier.rb @@ -13,6 +13,13 @@ module Solana class Verifier MAX_COMPUTE_UNIT_LIMIT = 200_000 MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 5_000_000 + # Fee-sponsored pull mode (audit #25): when the server is the fee + # payer it signs the client-supplied transaction before broadcast, so + # an inflated compute-unit price is paid by the merchant. Cap it tight. + # Worst case ceil(10_000 * 200_000 / 1_000_000) = 2_000 lamports, ~20% + # of the per-signature base fee — room for honest priority bumps. + # Mirrors the Rust spine MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED. + MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED = 10_000 # Verify a credential payload against a charge challenge. def verify(credential, challenge, expected_request: nil) @@ -88,9 +95,10 @@ def verify_transaction(transaction, request) verify_memos(transaction, request, splits, matched) validate_allowlist(transaction, matched, expected_mint: nil, expected_token_program: nil, fee_payer: fee_payer, splits: splits) else - network = details["network"] || "mainnet" + network = details["network"] || ::PayKit::Protocols::Mpp::Protocol::Solana::DEFAULT_NETWORK + ::PayKit::Protocols::Mpp::Protocol::Solana.validate_network!(network) mint = ::PayCore::Solana::Mints.resolve(request.currency, network) - token_program = details["tokenProgram"] || ::PayCore::Solana::Mints.token_program_for(request.currency, network) + token_program = resolve_token_program(details, request.currency, network) if splits.any? { |split| split["ataCreationRequired"] == true } && mint != request.currency raise VerificationError, "ataCreationRequired requires currency to be an SPL token mint address" end @@ -210,7 +218,7 @@ def validate_allowlist(transaction, matched, expected_mint:, expected_token_prog transaction.message.instructions.each_with_index do |ix, index| program = program_id(transaction, ix) if program == ::PayCore::Solana::Mints::COMPUTE_BUDGET_PROGRAM - validate_compute_budget(ix) + validate_compute_budget(ix, fee_payer: fee_payer) elsif [::PayCore::Solana::Mints::MEMO_PROGRAM, ::PayCore::Solana::Mints::SYSTEM_PROGRAM, ::PayCore::Solana::Mints::TOKEN_PROGRAM, ::PayCore::Solana::Mints::TOKEN_2022_PROGRAM].include?(program) raise VerificationError, "Unexpected program instruction in payment transaction: #{program}" unless matched[index] elsif program == ::PayCore::Solana::Mints::ASSOCIATED_TOKEN_PROGRAM @@ -249,7 +257,7 @@ def validate_ata_create(transaction, ix, expected_mint, allowed_owners, expected owner end - def validate_compute_budget(ix) + def validate_compute_budget(ix, fee_payer: nil) raise VerificationError, "Compute budget instruction must not have accounts" unless ix.accounts.empty? case ix.data.getbyte(0) @@ -260,12 +268,34 @@ def validate_compute_budget(ix) when 3 raise VerificationError, "Unsupported compute budget instruction" unless ix.data.bytesize == 9 price = u64_le(ix.data.byteslice(1, 8)) - raise VerificationError, "Compute unit price #{price} exceeds maximum #{MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS}" if price > MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS + # Tight cap when the server is the fee payer (audit #25); the + # general 5M ceiling stays for client-paid charges where the + # client funds its own priority fee. + cap = fee_payer ? MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED : MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS + raise VerificationError, "Compute unit price #{price} exceeds maximum #{cap}" if price > cap else raise VerificationError, "Unsupported compute budget instruction" end end + # Resolve the SPL token program for verification. Prefer the embedded + # methodDetails.tokenProgram (pinned against the server route via + # verify_expected). Fall back to the static table only for known + # currencies; for an arbitrary, unrecognised mint with no embedded + # tokenProgram we REJECT rather than silently defaulting to legacy + # Token, which would derive the wrong ATA for a Token-2022 mint + # (audit #28). + def resolve_token_program(details, currency, network) + embedded = details["tokenProgram"] + return embedded if embedded && !embedded.to_s.empty? + return ::PayCore::Solana::Mints.token_program_for(currency, network) if ::PayCore::Solana::Mints.known_currency?(currency, network) + + raise VerificationError.new( + "methodDetails.tokenProgram is required for an arbitrary mint address (cannot default to legacy Token)", + code: ::PayCore::ErrorCodes::CODE_PAYMENT_INVALID + ) + end + def program_id(transaction, ix) account_key(transaction, ix.program_id_index, "program_id") end diff --git a/ruby/lib/pay_kit/protocols/mpp/runtime.rb b/ruby/lib/pay_kit/protocols/mpp/runtime.rb index f2ee6ce0d..edb91abef 100644 --- a/ruby/lib/pay_kit/protocols/mpp/runtime.rb +++ b/ruby/lib/pay_kit/protocols/mpp/runtime.rb @@ -24,7 +24,11 @@ require_relative "server/middleware" module PayKit::Protocols::Mpp - DEFAULT_REALM = "MPP" + # Sentinel meaning "caller did not pass an explicit realm" so we can derive + # a per-recipient default (audit #15) instead of sharing one hardcoded + # namespace across every server. An explicit realm (including an explicit + # empty string, which is rejected) is honoured as-is. + DEFAULT_REALM = :__mpp_default_realm__ # Sentinel used to detect when the caller did not pass an explicit # replay store. The sentinel allows us to distinguish "caller passed diff --git a/ruby/lib/pay_kit/protocols/mpp/server/charge.rb b/ruby/lib/pay_kit/protocols/mpp/server/charge.rb index 6e13b0019..7b6182084 100644 --- a/ruby/lib/pay_kit/protocols/mpp/server/charge.rb +++ b/ruby/lib/pay_kit/protocols/mpp/server/charge.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true require "base64" +require "digest" require "pay_core/error_codes" +require "pay_core/solana/base58" require "pay_core/solana/transaction" require "pay_core/solana/rpc" @@ -21,16 +23,23 @@ module Server # orchestrator (verify, settle, consume, receipt) lives in the nested # `Handler` class. class Charge + # NIST SP 800-107 guidance for HMAC-SHA256: the key should be at least + # the hash output length (32 bytes). The challenge HMAC secret binds + # challenge IDs, so a weak key lets an attacker forge challenges + # (audit #24). Mirrors the Rust spine MIN_SECRET_KEY_BYTES. + MIN_SECRET_KEY_BYTES = 32 + attr_reader :method, :realm def initialize(method:, secret_key:, realm:, replay_store:, settlement_header: Handler::DEFAULT_SETTLEMENT_HEADER, expires_in: ::PayKit::Protocols::Mpp::Protocol::Core::ChallengeStore::DEFAULT_EXPIRES_SECONDS) @method = method - @realm = realm + validate_secret_key!(secret_key) + @realm = resolve_realm(realm, method.recipient) @challenge_store = ::PayKit::Protocols::Mpp::Protocol::Core::ChallengeStore.new( secret_key: secret_key, - realm: realm, + realm: @realm, default_expires_seconds: expires_in ) @handler = Handler.new( @@ -54,7 +63,10 @@ def initialize(method:, secret_key:, realm:, replay_store:, def charge(authorization, amount:, description: nil, external_id: nil, splits: nil, currency: nil) currency ||= method.currency details = method.method_details(currency: currency) - details = details.merge("splits" => splits) if splits && !splits.empty? + if splits && !splits.empty? + validate_splits!(splits, recipient: method.recipient) + details = details.merge("splits" => splits) + end request = ::PayKit::Protocols::Mpp::Protocol::Intents::ChargeRequest.new( amount: amount.to_s, @@ -67,6 +79,102 @@ def charge(authorization, amount:, description: nil, external_id: nil, splits: n @handler.handle(authorization, request) end + private + + MAX_SPLITS = 8 + + # Validate splits at challenge issuance (audit #21 + #38) rather than + # deferring every check to on-chain settlement. Enforces: + # - count <= MAX_SPLITS + # - each recipient parses as a 32-byte base58 pubkey + # - each amount parses as an integer, is > 0, and fits in u64 + # - the aggregate does not overflow u64 + # - no duplicate split recipients + # - (audit #38) no split whose recipient == the top-level recipient + # while ataCreationRequired is true — the fee-sponsored ATA + # recreate/drain shape. + def validate_splits!(splits, recipient:) + raise split_error("splits has more than #{MAX_SPLITS} entries") if splits.length > MAX_SPLITS + + seen = {} + total = 0 + splits.each do |split| + split_recipient = split["recipient"] + raise split_error("split recipient is required") if split_recipient.to_s.empty? + raise split_error("split recipient #{split_recipient.inspect} is not a valid base58 pubkey") unless valid_pubkey?(split_recipient) + raise split_error("duplicate split recipient #{split_recipient}") if seen[split_recipient] + seen[split_recipient] = true + + amount = split_amount(split) + total += amount + raise split_error("split amounts overflow u64") if total > ::PayKit::Protocols::Mpp::Protocol::Intents::ChargeRequest::U64_MAX + + if split_recipient == recipient && split["ataCreationRequired"] == true + raise split_error("primary recipient must not appear in splits with ataCreationRequired: true") + end + end + end + + def split_amount(split) + value = + begin + Integer(split.fetch("amount"), 10) + rescue KeyError, TypeError, ArgumentError + raise split_error("split amount must be an integer string") + end + raise split_error("split amount must be greater than zero") unless value.positive? + raise split_error("split amount exceeds the maximum u64 amount") if value > ::PayKit::Protocols::Mpp::Protocol::Intents::ChargeRequest::U64_MAX + + value + end + + def valid_pubkey?(value) + ::PayCore::Solana::Base58.decode(value.to_s).bytesize == 32 + rescue ArgumentError + false + end + + def split_error(message) + ::PayKit::Protocols::Mpp::VerificationError.new(message, code: ::PayCore::ErrorCodes::CODE_PAYMENT_INVALID) + end + + # Reject empty/short HMAC secrets at boot (audit #24). Covers both the + # explicit `secret_key:` argument and any value resolved from the + # environment upstream (e.g. preflight's MPP_SECRET env path), since + # both funnel through here. Counts bytes, not characters. + def validate_secret_key!(secret_key) + bytes = secret_key.to_s.bytesize + return if bytes >= MIN_SECRET_KEY_BYTES + + raise ::PayKit::Protocols::Mpp::Error.new( + "secret_key must be at least #{MIN_SECRET_KEY_BYTES} bytes of cryptographically-random data " \ + "(got #{bytes}); e.g. `openssl rand -base64 32`", + code: ::PayCore::ErrorCodes::CODE_PAYMENT_INVALID + ) + end + + # Resolve the realm (audit #15). An explicit non-empty realm is honoured; + # an explicit empty string is rejected (it would re-open the shared + # namespace); when the caller passes the default sentinel we derive a + # per-recipient realm so two servers sharing one secret but serving + # different recipients land in different HMAC namespaces. + def resolve_realm(realm, recipient) + return derive_default_realm(recipient) if realm == ::PayKit::Protocols::Mpp::DEFAULT_REALM + raise ::PayKit::Protocols::Mpp::Error.new("realm must not be empty", code: ::PayCore::ErrorCodes::CODE_PAYMENT_INVALID) if realm.to_s.empty? + + realm + end + + # Deterministically derive a human-friendly default realm from the + # recipient pubkey: SHA-256 the recipient, take the first 4 bytes as a + # big-endian u32 mod 10^8. Same recipient -> same realm (restart-safe); + # different recipients -> different realms (closes cross-server replay). + def derive_default_realm(recipient) + digest = ::Digest::SHA256.digest(recipient.to_s) + suffix = digest.byteslice(0, 4).unpack1("N") % 100_000_000 + "App Id - ##{suffix}" + end + # High-level Solana charge orchestrator: verify, settle, consume, receipt. # Not part of the public API. Drive this through `PayKit::Protocols::Mpp.create` + `Charge#charge`. class Handler diff --git a/ruby/test/example_test.rb b/ruby/test/example_test.rb index 29cd86d69..4ce4a9be4 100644 --- a/ruby/test/example_test.rb +++ b/ruby/test/example_test.rb @@ -9,7 +9,7 @@ class ExampleTest < Minitest::Test def test_sinatra_example_loads_and_exposes_health_route with_env( "PAY_KIT_PAY_TO" => pubkey(2), - "PAY_KIT_MPP_SECRET" => "test-secret" + "PAY_KIT_MPP_SECRET" => "test-secret-" + ("0" * 32) ) do require_relative "../examples/sinatra/app" diff --git a/ruby/test/pay_kit/middleware_test.rb b/ruby/test/pay_kit/middleware_test.rb index d890ec131..cf29967af 100644 --- a/ruby/test/pay_kit/middleware_test.rb +++ b/ruby/test/pay_kit/middleware_test.rb @@ -78,7 +78,7 @@ def setup c.stablecoins = %i[USDC] c.rpc_url = "https://example.test" c.mpp.realm = "Test" - c.mpp.challenge_binding_secret = "test" + c.mpp.challenge_binding_secret = "test-secret-" + ("0" * 32) end PayKit.pricing = TestPricing.new end diff --git a/ruby/test/pay_kit/protocols/mpp/api_test.rb b/ruby/test/pay_kit/protocols/mpp/api_test.rb index de46fdefd..b1994dc96 100644 --- a/ruby/test/pay_kit/protocols/mpp/api_test.rb +++ b/ruby/test/pay_kit/protocols/mpp/api_test.rb @@ -6,6 +6,10 @@ require "rack/mock" require "pay_kit/protocols/mpp/sinatra" +# HMAC secret >= 32 bytes (audit #24); the secret-key gate rejects +# anything shorter, so every server-building test uses this. +TEST_SECRET = "test-secret-" + ("0" * 32) + # Stub RPC that never hits the network. class StubRpc def initialize(blockhash: "TestBlockhashAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") @@ -86,12 +90,13 @@ class MppCreateTest < Minitest::Test def test_create_returns_a_server_instance server = PayKit::Protocols::Mpp.create( method: PayKit::Protocols::Mpp::Protocol::Solana.charge(recipient: "x", currency: "USDC", rpc: StubRpc.new), - secret_key: "secret", + secret_key: TEST_SECRET, replay_store: PayKit::Protocols::Mpp::MemoryStore.new ) assert_instance_of PayKit::Protocols::Mpp::Server::Charge, server - assert_equal PayKit::Protocols::Mpp::DEFAULT_REALM, server.realm + # No explicit realm -> derived per-recipient (audit #15). + assert_match(/\AApp Id - #\d+\z/, server.realm) end def test_charge_with_missing_auth_returns_a_challenge @@ -133,7 +138,7 @@ def test_charge_accepts_a_different_currency_per_call currency: "USDC", rpc: StubRpc.new ), - secret_key: "secret", + secret_key: TEST_SECRET, realm: "Test", replay_store: PayKit::Protocols::Mpp::MemoryStore.new ) @@ -147,7 +152,9 @@ def test_charge_accepts_a_different_currency_per_call def test_charge_threads_splits_through_method_details server = build_server - splits = [{"recipient" => "x", "amount" => "100"}] + # A valid 32-byte base58 recipient: split validation at issuance now + # rejects unparseable recipients (audit #21), so the fixture must be real. + splits = [{"recipient" => "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", "amount" => "100"}] # We can't fully verify on-chain settlement here without a real RPC; instead # assert that the challenge body echoes the splits we passed through. @@ -164,7 +171,7 @@ def test_charge_threads_splits_through_method_details def build_server PayKit::Protocols::Mpp.create( method: PayKit::Protocols::Mpp::Protocol::Solana.charge(recipient: "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", currency: "USDC", rpc: StubRpc.new), - secret_key: "secret", + secret_key: TEST_SECRET, realm: "Test", replay_store: PayKit::Protocols::Mpp::MemoryStore.new ) @@ -257,7 +264,7 @@ def test_unexpected_handler_result_raises def build_server PayKit::Protocols::Mpp.create( method: PayKit::Protocols::Mpp::Protocol::Solana.charge(recipient: "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", currency: "USDC", rpc: StubRpc.new), - secret_key: "secret", + secret_key: TEST_SECRET, replay_store: PayKit::Protocols::Mpp::MemoryStore.new ) end @@ -282,7 +289,7 @@ def paid_app class SinatraHelperTest < Minitest::Test def test_mpp_charge_halts_with_402_when_auth_missing - server = PayKit::Protocols::Mpp.create(method: PayKit::Protocols::Mpp::Protocol::Solana.charge(recipient: "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", currency: "USDC", rpc: StubRpc.new), secret_key: "secret", realm: "T", replay_store: PayKit::Protocols::Mpp::MemoryStore.new) + server = PayKit::Protocols::Mpp.create(method: PayKit::Protocols::Mpp::Protocol::Solana.charge(recipient: "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", currency: "USDC", rpc: StubRpc.new), secret_key: TEST_SECRET, realm: "T", replay_store: PayKit::Protocols::Mpp::MemoryStore.new) app = Class.new(Sinatra::Base) do helpers PayKit::Protocols::Mpp::Sinatra::Helpers set :mpp_server, server diff --git a/ruby/test/pay_kit/protocols/mpp/audit_hardening_test.rb b/ruby/test/pay_kit/protocols/mpp/audit_hardening_test.rb new file mode 100644 index 000000000..e88e8c4d0 --- /dev/null +++ b/ruby/test/pay_kit/protocols/mpp/audit_hardening_test.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require_relative "../../../test_helper" + +# Coverage for the server-side audit hardening: +# #24 weak/short HMAC secret rejected at boot +# #15 default realm derived per-recipient; empty realm rejected +# #37 network allowlist at boot ({mainnet, devnet, localnet}) +# #21 split validation at issuance (count/parse/positive/overflow/dups) +# #38 primary recipient in splits + ataCreationRequired rejected at issuance +class AuditHardeningTest < Minitest::Test + include RubyMppTestHelpers + + STRONG_SECRET = "a-32-byte-or-longer-hmac-secret-000000" + RECIPIENT = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY" + + # Stub RPC so the SPL/arbitrary-mint resolution path never hits the network. + class StubRpc + def initialize(owner: nil, raise_on_owner: false) + @owner = owner + @raise_on_owner = raise_on_owner + end + + def latest_blockhash = "TestBlockhashAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + def account_owner(_pubkey) + raise "rpc unreachable" if @raise_on_owner + + @owner + end + end + + def method_fixture(network: "localnet", currency: "USDC") + PayKit::Protocols::Mpp::Protocol::Solana.charge( + recipient: RECIPIENT, currency: currency, network: network, rpc: StubRpc.new + ) + end + + def server(secret: STRONG_SECRET, realm: PayKit::Protocols::Mpp::DEFAULT_REALM, **method_opts) + PayKit::Protocols::Mpp.create( + method: method_fixture(**method_opts), + secret_key: secret, + realm: realm, + replay_store: PayKit::Protocols::Mpp::MemoryStore.new + ) + end + + # --- #24 secret key gate --------------------------------------------- + + def test_rejects_empty_secret_key + error = assert_raises(PayKit::Protocols::Mpp::Error) { server(secret: "") } + assert_match(/at least 32 bytes/, error.message) + end + + def test_rejects_short_secret_key + error = assert_raises(PayKit::Protocols::Mpp::Error) { server(secret: "too-short") } + assert_match(/at least 32 bytes/, error.message) + end + + def test_accepts_secret_key_at_minimum_length + assert_instance_of PayKit::Protocols::Mpp::Server::Charge, server(secret: "x" * 32) + end + + # --- #15 realm derivation -------------------------------------------- + + def test_default_realm_is_derived_per_recipient + assert_match(/\AApp Id - #\d+\z/, server.realm) + end + + def test_default_realm_is_deterministic_for_same_recipient + assert_equal server.realm, server.realm + end + + def test_default_realm_differs_across_recipients + other = PayKit::Protocols::Mpp.create( + method: PayKit::Protocols::Mpp::Protocol::Solana.charge( + recipient: pubkey(9), currency: "USDC", network: "localnet", rpc: StubRpc.new + ), + secret_key: STRONG_SECRET, + replay_store: PayKit::Protocols::Mpp::MemoryStore.new + ) + refute_equal server.realm, other.realm + end + + def test_rejects_empty_explicit_realm + error = assert_raises(PayKit::Protocols::Mpp::Error) { server(realm: "") } + assert_match(/realm must not be empty/, error.message) + end + + def test_honours_explicit_non_empty_realm + assert_equal "Acme API", server(realm: "Acme API").realm + end + + # --- #37 network allowlist ------------------------------------------- + + def test_accepts_canonical_networks + %w[mainnet devnet localnet].each do |net| + assert_instance_of( + PayKit::Protocols::Mpp::Protocol::Solana::ChargeMethod, + PayKit::Protocols::Mpp::Protocol::Solana.charge(recipient: RECIPIENT, currency: "USDC", network: net, rpc: StubRpc.new) + ) + end + end + + def test_rejects_mainnet_beta_slug + error = assert_raises(PayKit::Protocols::Mpp::Error) do + PayKit::Protocols::Mpp::Protocol::Solana.charge(recipient: RECIPIENT, currency: "USDC", network: "mainnet-beta", rpc: StubRpc.new) + end + assert_match(/Unsupported network/, error.message) + end + + def test_rejects_unknown_network + assert_raises(PayKit::Protocols::Mpp::Error) do + PayKit::Protocols::Mpp::Protocol::Solana.charge(recipient: RECIPIENT, currency: "USDC", network: "testnet", rpc: StubRpc.new) + end + end + + # --- #28 arbitrary-mint token program resolution --------------------- + + def arbitrary_mint_method(rpc) + PayKit::Protocols::Mpp::Protocol::Solana.charge( + recipient: RECIPIENT, currency: pubkey(7), network: "localnet", rpc: rpc + ) + end + + def test_arbitrary_mint_resolves_token_2022_owner_on_chain + method = arbitrary_mint_method(StubRpc.new(owner: PayCore::Solana::Mints::TOKEN_2022_PROGRAM)) + assert_equal PayCore::Solana::Mints::TOKEN_2022_PROGRAM, method.token_program + assert_equal PayCore::Solana::Mints::TOKEN_2022_PROGRAM, method.method_details["tokenProgram"] + end + + def test_arbitrary_mint_resolves_legacy_token_owner_on_chain + method = arbitrary_mint_method(StubRpc.new(owner: PayCore::Solana::Mints::TOKEN_PROGRAM)) + assert_equal PayCore::Solana::Mints::TOKEN_PROGRAM, method.token_program + end + + def test_arbitrary_mint_rejects_non_token_owner + method = arbitrary_mint_method(StubRpc.new(owner: PayCore::Solana::Mints::SYSTEM_PROGRAM)) + error = assert_raises(PayKit::Protocols::Mpp::Error) { method.token_program } + assert_match(/not the SPL Token or Token-2022 program/, error.message) + end + + def test_arbitrary_mint_rejects_when_rpc_unreachable + method = arbitrary_mint_method(StubRpc.new(raise_on_owner: true)) + error = assert_raises(PayKit::Protocols::Mpp::Error) { method.token_program } + assert_match(/Could not resolve the token program/, error.message) + end + + def test_known_stablecoin_does_not_fetch_owner_on_chain + # PYUSD is a known Token-2022 stablecoin; resolution comes from the static + # table with no RPC round-trip (raise_on_owner would fire otherwise). + method = PayKit::Protocols::Mpp::Protocol::Solana.charge( + recipient: RECIPIENT, currency: "PYUSD", network: "mainnet", rpc: StubRpc.new(raise_on_owner: true) + ) + assert_equal PayCore::Solana::Mints::TOKEN_2022_PROGRAM, method.token_program + end + + # --- #21 split validation at issuance -------------------------------- + + def charge_splits(splits) + server.charge(nil, amount: "1000", splits: splits) + end + + def test_accepts_valid_splits + result = charge_splits([{"recipient" => pubkey(3), "amount" => "100"}]) + assert_instance_of PayKit::Protocols::Mpp::Challenge, result + end + + def test_rejects_more_than_max_splits + splits = 9.times.map { |i| {"recipient" => pubkey(i + 10), "amount" => "1"} } + error = assert_raises(PayKit::Protocols::Mpp::VerificationError) { charge_splits(splits) } + assert_match(/more than 8/, error.message) + end + + def test_rejects_unparseable_split_recipient + error = assert_raises(PayKit::Protocols::Mpp::VerificationError) { charge_splits([{"recipient" => "not-a-key!", "amount" => "1"}]) } + assert_match(/not a valid base58 pubkey/, error.message) + end + + def test_rejects_non_integer_split_amount + error = assert_raises(PayKit::Protocols::Mpp::VerificationError) { charge_splits([{"recipient" => pubkey(3), "amount" => "1.5"}]) } + assert_match(/integer string/, error.message) + end + + def test_rejects_zero_split_amount + error = assert_raises(PayKit::Protocols::Mpp::VerificationError) { charge_splits([{"recipient" => pubkey(3), "amount" => "0"}]) } + assert_match(/greater than zero/, error.message) + end + + def test_rejects_overflowing_split_amount + error = assert_raises(PayKit::Protocols::Mpp::VerificationError) { charge_splits([{"recipient" => pubkey(3), "amount" => (2**64).to_s}]) } + assert_match(/exceeds the maximum u64/, error.message) + end + + def test_rejects_duplicate_split_recipients + splits = [{"recipient" => pubkey(3), "amount" => "1"}, {"recipient" => pubkey(3), "amount" => "2"}] + error = assert_raises(PayKit::Protocols::Mpp::VerificationError) { charge_splits(splits) } + assert_match(/duplicate split recipient/, error.message) + end + + # --- #38 primary-in-splits + ataCreationRequired --------------------- + + def test_rejects_primary_recipient_in_splits_with_ata_creation_required + splits = [{"recipient" => RECIPIENT, "amount" => "100", "ataCreationRequired" => true}] + error = assert_raises(PayKit::Protocols::Mpp::VerificationError) { charge_splits(splits) } + assert_match(/primary recipient must not appear in splits with ataCreationRequired/, error.message) + end + + def test_allows_primary_recipient_in_splits_without_ata_creation + result = charge_splits([{"recipient" => RECIPIENT, "amount" => "100"}]) + assert_instance_of PayKit::Protocols::Mpp::Challenge, result + end +end diff --git a/ruby/test/pay_kit/protocols/mpp/dev_store_warning_test.rb b/ruby/test/pay_kit/protocols/mpp/dev_store_warning_test.rb index c34bb81a6..a5ac4736f 100644 --- a/ruby/test/pay_kit/protocols/mpp/dev_store_warning_test.rb +++ b/ruby/test/pay_kit/protocols/mpp/dev_store_warning_test.rb @@ -25,7 +25,7 @@ def test_no_store_argument_emits_dev_warning # Capture the Kernel.warn output without actually printing it. PayKit::Protocols::Mpp.stub(:warn, ->(msg) { warned = msg }) do - server = PayKit::Protocols::Mpp.create(method: method_fixture, secret_key: "test-secret") + server = PayKit::Protocols::Mpp.create(method: method_fixture, secret_key: ("test-secret-" + ("0" * 32))) assert_kind_of PayKit::Protocols::Mpp::Server::Charge, server end @@ -46,7 +46,7 @@ def test_explicit_store_argument_suppresses_warning PayKit::Protocols::Mpp.stub(:warn, ->(msg) { warned << msg }) do server = PayKit::Protocols::Mpp.create( method: method_fixture, - secret_key: "test-secret", + secret_key: ("test-secret-" + ("0" * 32)), replay_store: explicit_store ) assert_kind_of PayKit::Protocols::Mpp::Server::Charge, server @@ -64,7 +64,7 @@ def test_explicit_file_store_suppresses_warning PayKit::Protocols::Mpp.stub(:warn, ->(msg) { warned << msg }) do server = PayKit::Protocols::Mpp.create( method: method_fixture, - secret_key: "test-secret", + secret_key: ("test-secret-" + ("0" * 32)), replay_store: file_store ) assert_kind_of PayKit::Protocols::Mpp::Server::Charge, server diff --git a/ruby/test/pay_kit/protocols/mpp/expires_in_test.rb b/ruby/test/pay_kit/protocols/mpp/expires_in_test.rb index e51f55094..b056c1f90 100644 --- a/ruby/test/pay_kit/protocols/mpp/expires_in_test.rb +++ b/ruby/test/pay_kit/protocols/mpp/expires_in_test.rb @@ -48,7 +48,7 @@ def test_mpp_create_threads_expires_in network: "devnet", rpc: "https://api.devnet.solana.com" ) - server = ::PayKit::Protocols::Mpp.create(method: method, secret_key: "secret", realm: "Test", expires_in: 42, replay_store: ::PayKit::Protocols::Mpp::MemoryStore.new) + server = ::PayKit::Protocols::Mpp.create(method: method, secret_key: ("secret" + ("0" * 32)), realm: "Test", expires_in: 42, replay_store: ::PayKit::Protocols::Mpp::MemoryStore.new) store = server.instance_variable_get(:@challenge_store) assert_equal 42, store.default_expires_seconds end diff --git a/ruby/test/pay_kit/protocols/mpp/server_test.rb b/ruby/test/pay_kit/protocols/mpp/server_test.rb index 24f9760de..6f673d64e 100644 --- a/ruby/test/pay_kit/protocols/mpp/server_test.rb +++ b/ruby/test/pay_kit/protocols/mpp/server_test.rb @@ -602,6 +602,97 @@ def test_rejects_spl_wrong_destination_and_fee_payer_authority assert_match(/fee payer cannot authorize|No matching SPL/, result.reason) end + # Audit #25: when the server is the fee payer (fee-sponsored pull mode) a + # tight compute-unit-price cap (10_000) applies, since the merchant pays the + # priority fee. A client-paid charge keeps the general 5M ceiling. + def test_fee_sponsored_compute_unit_price_cap + fee_payer = pubkey(1) + payer = pubkey(2) + recipient = pubkey(3) + build = lambda do |price| + # account_keys[0] == fee_payer (matches feePayerKey); the SOL transfer + # source is `payer` (index 1), not the fee payer, so the value-transfer + # passes and the compute-budget cap is the deciding gate. + tx_base64( + account_keys: [fee_payer, payer, recipient, PROGRAMS::SYSTEM_PROGRAM, PROGRAMS::COMPUTE_BUDGET_PROGRAM], + instructions: [ + compiled_instruction(4, [], [3].pack("C") + u64(price)), + compiled_instruction(3, [1, 2], u32(2) + u64(1000)) + ] + ) + end + fee_sponsored = charge_request(recipient: recipient, method_details: {"feePayer" => true, "feePayerKey" => fee_payer}) + + # Just over the tight fee-sponsored cap -> rejected. + result = @verifier.verify_transaction_payload(build.call(10_001), fee_sponsored) + refute result.ok? + assert_match(/Compute unit price.*exceeds maximum 10000/, result.reason) + + # At the tight cap -> passes verification entirely. + result = @verifier.verify_transaction_payload(build.call(10_000), fee_sponsored) + assert result.ok?, result.reason + end + + # Audit #25 regression: the tight cap MUST NOT apply when the client pays + # its own gas (no server fee payer). A price between the two caps passes. + def test_client_paid_compute_unit_price_keeps_general_cap + payer = pubkey(1) + recipient = pubkey(2) + tx = tx_base64( + account_keys: [payer, recipient, PROGRAMS::SYSTEM_PROGRAM, PROGRAMS::COMPUTE_BUDGET_PROGRAM], + instructions: [ + compiled_instruction(3, [], [3].pack("C") + u64(1_000_000)), + compiled_instruction(2, [0, 1], u32(2) + u64(1000)) + ] + ) + + result = @verifier.verify_transaction_payload(tx, charge_request) + + assert result.ok?, result.reason + end + + # Audit #28: an arbitrary mint address (not a known stablecoin) with no + # embedded methodDetails.tokenProgram must be rejected rather than silently + # defaulting to the legacy Token program (which would derive the wrong ATA + # for a Token-2022 mint). + def test_rejects_arbitrary_mint_without_token_program + arbitrary_mint = pubkey(7) + request = charge_request( + currency: arbitrary_mint, + recipient: pubkey(2), + method_details: {"network" => "localnet", "decimals" => 6} + ) + tx = tx_base64( + account_keys: [pubkey(1), pubkey(3), arbitrary_mint, pubkey(4), PROGRAMS::TOKEN_PROGRAM], + instructions: [compiled_instruction(4, [1, 2, 3, 0], [12].pack("C") + u64(1000) + [6].pack("C"))] + ) + + result = @verifier.verify_transaction_payload(tx, request) + + refute result.ok? + assert_match(/tokenProgram is required for an arbitrary mint/, result.reason) + end + + # Audit #37: the verifier rejects a non-allowlisted network slug embedded in + # the SPL branch (e.g. "mainnet-beta"). + def test_rejects_unsupported_network_in_method_details + mint = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" + request = charge_request( + currency: mint, + recipient: pubkey(2), + method_details: {"network" => "mainnet-beta", "decimals" => 6, "tokenProgram" => PROGRAMS::TOKEN_PROGRAM} + ) + tx = tx_base64( + account_keys: [pubkey(1), pubkey(3), mint, pubkey(4), PROGRAMS::TOKEN_PROGRAM], + instructions: [compiled_instruction(4, [1, 2, 3, 0], [12].pack("C") + u64(1000) + [6].pack("C"))] + ) + + result = @verifier.verify_transaction_payload(tx, request) + + refute result.ok? + assert_match(/Unsupported network/, result.reason) + end + private def tx_base64(account_keys:, instructions:) diff --git a/ruby/test/pay_kit/test_helper.rb b/ruby/test/pay_kit/test_helper.rb index 88e464839..52c8a58cf 100644 --- a/ruby/test/pay_kit/test_helper.rb +++ b/ruby/test/pay_kit/test_helper.rb @@ -39,7 +39,7 @@ def self.with_config(overrides = {}) c.x402.facilitator_url = overrides[:x402_facilitator_url] c.x402.signer = overrides[:x402_signer] c.mpp.realm = overrides[:realm] || "Test" - c.mpp.challenge_binding_secret = overrides[:mpp_secret] || "test-secret" + c.mpp.challenge_binding_secret = overrides[:mpp_secret] || ("test-secret-" + ("0" * 32)) c.mpp.expires_in = overrides[:mpp_expires_in] if overrides.key?(:mpp_expires_in) end diff --git a/swift/Package.resolved b/swift/Package.resolved deleted file mode 100644 index 26e148b00..000000000 --- a/swift/Package.resolved +++ /dev/null @@ -1,24 +0,0 @@ -{ - "originHash" : "f66157b3d80f8f4d4bc4ea23d9cc6bbabbc3757ccc56bbe77ba891b1292b22d6", - "pins" : [ - { - "identity" : "swift-docc-plugin", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-docc-plugin", - "state" : { - "revision" : "647c708be89f834fa6a6d4945442793a77ddf5b6", - "version" : "1.5.0" - } - }, - { - "identity" : "swift-docc-symbolkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-docc-symbolkit", - "state" : { - "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", - "version" : "1.0.0" - } - } - ], - "version" : 3 -} diff --git a/swift/Sources/SolanaPayKit/PayCore/Mints.swift b/swift/Sources/SolanaPayKit/PayCore/Mints.swift index 07e2f472e..18b039a8a 100644 --- a/swift/Sources/SolanaPayKit/PayCore/Mints.swift +++ b/swift/Sources/SolanaPayKit/PayCore/Mints.swift @@ -123,6 +123,28 @@ public enum Mints { } } + /// Whether `mint` is one of the well-known stablecoin mint addresses + /// whose token program is hardcoded. Returning `false` for an arbitrary + /// mint means callers must do an on-chain mint-owner lookup to find the + /// program — and, for the charge client, that an unknown Token-2022 mint + /// (which can carry transfer hooks) is gated behind an explicit opt-in. + /// Mirrors rust `protocol::solana::is_known_stablecoin_mint`. + /// + /// Matches on the raw mint address only (the audit #26 gate reasons about + /// the resolved mint, not the symbol form). + public static func isKnownStablecoinMint(_ mint: String) -> Bool { + switch mint { + case usdcMainnet, usdcDevnet, + usdtMainnet, + usdgMainnet, usdgDevnet, + pyusdMainnet, pyusdDevnet, + cashMainnet: + return true + default: + return false + } + } + /// True if a stablecoin (by symbol or mint) uses SPL Token-2022. /// Mirrors rust `stablecoin_uses_token_2022`. public static func usesToken2022(_ currencyOrMint: String) -> Bool { diff --git a/swift/Sources/SolanaPayKit/PayCore/RpcClient.swift b/swift/Sources/SolanaPayKit/PayCore/RpcClient.swift index 89b8d4859..7b6cbeea4 100644 --- a/swift/Sources/SolanaPayKit/PayCore/RpcClient.swift +++ b/swift/Sources/SolanaPayKit/PayCore/RpcClient.swift @@ -17,12 +17,17 @@ public struct RpcClient: Sendable { self.urlSession = urlSession } - /// Returns the most recent blockhash from the RPC's processed + /// Returns the most recent blockhash from the RPC's `confirmed` /// commitment level as a 32-byte value plus its base58 form. + /// + /// `confirmed` (not `processed`) is passed explicitly per audit #36: + /// a `processed` blockhash can disappear under a reorg, leaving the + /// client holding a signed transaction that fails with + /// `BlockhashNotFound` after broadcast. public func getLatestBlockhash() async throws -> (bytes: Data, base58: String) { let response = try await rpcCall( method: "getLatestBlockhash", - params: [["commitment": "processed"]] + params: [["commitment": "confirmed"]] ) guard let outer = response as? [String: Any], diff --git a/swift/Sources/SolanaPayKit/Protocols/Mpp/Client/Charge.swift b/swift/Sources/SolanaPayKit/Protocols/Mpp/Client/Charge.swift index 43db856dd..00b3ecd85 100644 --- a/swift/Sources/SolanaPayKit/Protocols/Mpp/Client/Charge.swift +++ b/swift/Sources/SolanaPayKit/Protocols/Mpp/Client/Charge.swift @@ -30,6 +30,13 @@ public struct ChargeCredentialBuilder: Sendable { public func authorizationHeader(for challenge: PaymentChallenge) async throws -> String { try challenge.requireSolanaCharge() + // Audit #10: refuse expired challenges before invoking the + // transaction provider (which may sign). Always-on, fail-closed. + guard !challenge.isExpired() else { + throw MppError.invalidTransaction( + "refusing to sign expired charge challenge (expires=\(challenge.expires ?? "(nil)"))" + ) + } let transaction = try await transactionProvider.buildTransaction(for: challenge.chargeRequest) let credential = PaymentCredential( @@ -53,9 +60,43 @@ public enum Charge { public var computeUnitLimit: UInt32 public var computeUnitPrice: UInt64 - public init(computeUnitLimit: UInt32 = 200_000, computeUnitPrice: UInt64 = 1) { + /// Opt-in auto-pay policy gates (audit #10). All default to "no + /// constraint" so interactive UI callers, where a human reviews the + /// challenge before signing, plumb nothing. Auto-pay integrations — + /// where the server effectively controls what gets signed against + /// the user's wallet — should set these to bind the build path. + + /// Maximum charge amount, in base units, the client will sign. + /// When set, `buildChargeTransaction` refuses any request whose + /// `amount` exceeds the cap (equal-to-cap is allowed). Mirrors rust + /// `BuildChargeTransactionOptions::max_amount_base_units`. + public var maxAmountBaseUnits: UInt64? + + /// Expected `methodDetails.network`. When set, the client refuses to + /// sign a challenge whose network does not match. Mirrors rust + /// `BuildChargeTransactionOptions::expected_network`. + public var expectedNetwork: String? + + /// Opt-in to sign charges for unknown Token-2022 mints (audit #26). + /// Unknown Token-2022 mints can carry transfer hooks that execute + /// arbitrary code on every transfer; by default the client refuses + /// to sign them. Vanilla Token mints and known stablecoins are never + /// gated. Mirrors rust + /// `BuildChargeTransactionOptions::allow_unknown_token_2022`. + public var allowUnknownToken2022: Bool + + public init( + computeUnitLimit: UInt32 = 200_000, + computeUnitPrice: UInt64 = 1, + maxAmountBaseUnits: UInt64? = nil, + expectedNetwork: String? = nil, + allowUnknownToken2022: Bool = false + ) { self.computeUnitLimit = computeUnitLimit self.computeUnitPrice = computeUnitPrice + self.maxAmountBaseUnits = maxAmountBaseUnits + self.expectedNetwork = expectedNetwork + self.allowUnknownToken2022 = allowUnknownToken2022 } } @@ -111,6 +152,21 @@ public enum Charge { let recipientPubkey = try Pubkey(base58: request.recipient) let amount = try parseU64(request.amount, field: "amount") + + // Audit #10 auto-pay policy gates. Run before any signing or + // instruction building so a hostile/untrusted challenge is rejected + // up front. Both default to "no constraint" (see `Options`). + if let cap = options.maxAmountBaseUnits, amount > cap { + throw MppError.invalidTransaction( + "charge amount \(amount) exceeds max_amount_base_units cap \(cap)" + ) + } + if let expectedNetwork = options.expectedNetwork, + methodDetails.network != expectedNetwork { + throw MppError.invalidTransaction( + "charge network \"\(methodDetails.network ?? "(nil)")\" does not match expected network \"\(expectedNetwork)\"" + ) + } let splits = methodDetails.splits ?? [] // Spine cap: Rust (`rust/src/client/charge.rs`) and TypeScript // (`typescript/packages/mpp/src/server/Charge.ts`) both reject @@ -178,7 +234,30 @@ public enum Charge { mintBase58: mintStr, rpc: rpc ) - let rawDecimals = methodDetails.decimals ?? 6 + // Audit #26: an unknown Token-2022 mint can carry transfer hooks + // that execute arbitrary code on every transfer, and the server's + // pre-broadcast checks do not simulate inner instructions in pull + // mode. Refuse to sign unless the caller explicitly opts in. + // Vanilla Token mints (no hooks) and known stablecoins stay + // first-class. Mirrors rust `build_spl_instructions`. + if tokenProgram == .token2022Program, + !Mints.isKnownStablecoinMint(mintStr), + !options.allowUnknownToken2022 { + throw MppError.invalidTransaction( + "refusing to sign unknown Token-2022 mint \(mintStr) (transfer-hook risk); " + + "set Charge.Options.allowUnknownToken2022 = true to override" + ) + } + // Audit #42: SPL transferChecked needs the correct decimals to + // form the right divisor. The spec (§7.2) requires the server to + // supply `methodDetails.decimals` for SPL charges; silently + // defaulting to 6 signs a wrong amount for non-6-decimal mints. + // Require it instead of defaulting. + guard let rawDecimals = methodDetails.decimals else { + throw MppError.invalidTransaction( + "methodDetails.decimals is required for SPL charges (spec §7.2)" + ) + } guard rawDecimals >= 0, rawDecimals <= 255 else { // SPL TokenChecked encodes decimals as u8; an out-of-range // server value must not crash the client (would `UInt8(_:)` @@ -214,10 +293,14 @@ public enum Charge { for split in splits { let destinationOwner = try Pubkey(base58: split.recipient) let splitAmount = try parseU64(split.amount, field: "split amount") - // Spine semantics: when no server fee payer, every split - // owner gets an idempotent ATA-create; when server pays - // fees, only splits with ataCreationRequired == true do. - let createAta = !serverPaysFees || split.ataCreationRequired == true + // Audit #20: create a split's ATA only when the challenge + // explicitly requests it (`ataCreationRequired == true`), in + // BOTH fee modes. The previous `!serverPaysFees || ...` form + // auto-created an idempotent ATA for every split in + // client-paid mode, letting a hostile server attach N dust + // splits and drain ~0.002 SOL of client-funded rent each. + // Mirrors rust's narrowed `create_ata = ata_creation_required`. + let createAta = split.ataCreationRequired == true try appendSplTransfer( into: &instructions, payer: actualFeePayer, @@ -312,6 +395,16 @@ public enum Charge { options: Options = Options() ) async throws -> String { try challenge.requireSolanaCharge() + // Audit #10: always-on expiry refusal. A challenge that carries an + // `expires` in the past (or an unparseable one — fail-closed) must + // never be signed, even when the caller sets no other policy. The + // check lives here, not in `buildChargeTransaction`, because + // `expires` is a challenge field, not part of the decoded request. + guard !challenge.isExpired() else { + throw MppError.invalidTransaction( + "refusing to sign expired charge challenge (expires=\(challenge.expires ?? "(nil)"))" + ) + } let request = try challenge.chargeRequest let encodedTx = try await buildChargeTransaction( request: request, diff --git a/swift/Sources/SolanaPayKit/Protocols/Mpp/Core/Headers.swift b/swift/Sources/SolanaPayKit/Protocols/Mpp/Core/Headers.swift index 5d7a8236d..2902c2503 100644 --- a/swift/Sources/SolanaPayKit/Protocols/Mpp/Core/Headers.swift +++ b/swift/Sources/SolanaPayKit/Protocols/Mpp/Core/Headers.swift @@ -3,6 +3,13 @@ import Foundation public enum MppHeaders { public static let paymentScheme = "Payment" + /// Upper bound on the base64url-encoded `request` parameter before it is + /// decoded and JSON-parsed. Mirrors the rust `MAX_TOKEN_LEN = 16 * 1024` + /// cap that the credential/receipt parsers already enforce (audit #9): + /// an oversized `WWW-Authenticate` value must not drive unbounded + /// base64url-decode + JSON-parse work. + public static let maxTokenLength = 16 * 1024 + public static func parseWWWAuthenticate(_ header: String) throws -> PaymentChallenge { let rest = try paymentSchemePayload(header) let params = try parseAuthParams(rest) @@ -10,6 +17,10 @@ public enum MppHeaders { guard let request = params["request"], !request.isEmpty else { throw MppError.missingField("request") } + // Cap the encoded `request` before any decode/JSON-parse work runs. + guard request.utf8.count <= maxTokenLength else { + throw MppError.invalidHeader + } guard let id = params["id"], !id.isEmpty else { throw MppError.missingField("id") } diff --git a/swift/Sources/SolanaPayKit/Protocols/Mpp/Core/Models.swift b/swift/Sources/SolanaPayKit/Protocols/Mpp/Core/Models.swift index 5e90bd9cb..50ecca4a6 100644 --- a/swift/Sources/SolanaPayKit/Protocols/Mpp/Core/Models.swift +++ b/swift/Sources/SolanaPayKit/Protocols/Mpp/Core/Models.swift @@ -15,6 +15,12 @@ public struct PaymentChallenge: Codable, Equatable, Sendable { public var chargeRequest: ChargeRequest { get throws { + // Cap before decode/JSON-parse — mirrors the WWW-Authenticate parser + // (audit #9). Closes the direct-construction bypass: a challenge built + // without going through `parseWWWAuthenticate` must still be bounded. + guard request.utf8.count <= MppHeaders.maxTokenLength else { + throw MppError.invalidHeader + } let data = try Base64URL.decode(request) do { return try JSONDecoder().decode(ChargeRequest.self, from: data) @@ -34,6 +40,9 @@ public struct PaymentChallenge: Codable, Equatable, Sendable { digest: String? = nil, opaque: String? = nil ) throws { + guard request.utf8.count <= MppHeaders.maxTokenLength else { + throw MppError.invalidHeader + } _ = try Base64URL.decode(request) self.id = id self.realm = realm @@ -51,6 +60,28 @@ public struct PaymentChallenge: Codable, Equatable, Sendable { } } + /// Returns `true` if the challenge carries an `expires` timestamp that + /// is in the past (or is unparseable). Challenges with no `expires` + /// are never considered expired — the protocol allows omitting it and + /// the client has no anchor to check against. Mirrors the fail-closed + /// RFC3339 parser in rust `protocol::core::challenge::is_expired`: an + /// `expires` we cannot parse is treated as expired so a hostile server + /// cannot bypass the gate with a malformed timestamp. + public func isExpired(now: Date = Date()) -> Bool { + guard let expires = expires else { return false } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let parsed = formatter.date(from: expires) { + return parsed <= now + } + // Retry without fractional seconds (RFC3339 allows either form). + formatter.formatOptions = [.withInternetDateTime] + if let parsed = formatter.date(from: expires) { + return parsed <= now + } + return true // fail-closed: unparseable expiry refuses to sign + } + public func echo() -> ChallengeEcho { ChallengeEcho( id: id, diff --git a/swift/Tests/SolanaPayKitTests/ChargeCredentialTests.swift b/swift/Tests/SolanaPayKitTests/ChargeCredentialTests.swift index 60806dd5f..706dce569 100644 --- a/swift/Tests/SolanaPayKitTests/ChargeCredentialTests.swift +++ b/swift/Tests/SolanaPayKitTests/ChargeCredentialTests.swift @@ -151,10 +151,36 @@ struct ChargeCredentialTests { } } + @Test + func rejectsOversizedRequestParam() throws { + // Audit #9: the `request` parameter must be capped at 16 KiB before + // any base64url-decode + JSON-parse work runs. A value past the cap + // is rejected as an invalid header. + let oversized = String(repeating: "A", count: MppHeaders.maxTokenLength + 1) + let header = """ + Payment id="c", realm="r", method="solana", intent="charge", request="\(oversized)" + """ + #expect(throws: MppError.invalidHeader) { + _ = try MppHeaders.parseWWWAuthenticate(header) + } + } + + @Test + func acceptsRequestParamAtCap() throws { + // A valid challenge whose encoded request is within the cap parses. + let request = try Self.encodedRequest() + #expect(request.utf8.count <= MppHeaders.maxTokenLength) + let header = """ + Payment id="c", realm="r", method="solana", intent="charge", request="\(request)" + """ + let challenge = try MppHeaders.parseWWWAuthenticate(header) + #expect(challenge.method == "solana") + } + private static func challengeHeader() throws -> String { let request = try encodedRequest() return """ - Payment id="challenge-1", realm="MPP Payment", method="solana", intent="charge", request="\(request)", expires="2026-05-20T00:00:00Z" + Payment id="challenge-1", realm="MPP Payment", method="solana", intent="charge", request="\(request)", expires="2099-05-20T00:00:00Z" """ } diff --git a/swift/Tests/SolanaPayKitTests/ChargeWireTests.swift b/swift/Tests/SolanaPayKitTests/ChargeWireTests.swift index 852f11e52..f9afb5591 100644 --- a/swift/Tests/SolanaPayKitTests/ChargeWireTests.swift +++ b/swift/Tests/SolanaPayKitTests/ChargeWireTests.swift @@ -295,7 +295,14 @@ struct ChargeWireTests { intent: "charge", request: requestB64 ) - let header = try await Charge.buildPullCredential(challenge: challenge, signer: signer, rpc: rpc) + // An unknown Token-2022 mint is gated by audit #26 unless the caller + // opts in; opt in here so this test exercises the resolution path. + let header = try await Charge.buildPullCredential( + challenge: challenge, + signer: signer, + rpc: rpc, + options: Charge.Options(allowUnknownToken2022: true) + ) #expect(header.hasPrefix("Payment ")) } @@ -441,6 +448,283 @@ struct ChargeWireTests { _ = try await Charge.buildPullCredential(challenge: challenge, signer: signer) } } + + // MARK: - Audit #26: unknown Token-2022 mint gate + + /// Helper: a challenge for an unknown mint whose owner the RPC stub + /// reports as the given program id. + private func unknownMintChallenge( + ownerProgram: String, + decimals: String = "\"decimals\": 6," + ) throws -> (PaymentChallenge, RpcClient, any SolanaSigner) { + RpcStubURLProtocol.reset() + RpcStubURLProtocol.responder = { _ in + let body = #"{"jsonrpc":"2.0","id":1,"result":{"context":{"slot":1},"value":{"owner":""# + ownerProgram + #"","lamports":0,"data":["",""],"executable":false,"rentEpoch":0}}}"# + return StubResponse(statusCode: 200, headers: ["Content-Type": "application/json"], body: Data(body.utf8)) + } + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [RpcStubURLProtocol.self] + let session = URLSession(configuration: config) + let rpc = RpcClient(endpoint: URL(string: "https://stub.test/rpc")!, urlSession: session) + + let seed = Data(repeating: 31, count: 32) + let signer = try MemorySigner(secretKey: seed) + let blockhash = Base58.encode(Data(repeating: 0x55, count: 32)) + let mint = "9zoqdwEBKWEi9G5Ze8BSkdmppbGSebokm5o8HWXdZMVw" + let requestJson = """ + { + "amount": "1000", + "currency": "\(mint)", + "recipient": "5wEwLBR3aTGdz8wWUFKafdGiLcQNqotQK1ndJxXLfHir", + "methodDetails": { + "network": "localnet", + \(decimals) + "feePayer": false, + "recentBlockhash": "\(blockhash)" + } + } + """ + let requestB64 = Base64URL.encode(Data(requestJson.utf8)) + let challenge = try PaymentChallenge( + id: "ch-26", + realm: "MPP Payment", + method: "solana", + intent: "charge", + request: requestB64 + ) + return (challenge, rpc, signer) + } + + @Test + func refusesUnknownToken2022MintWithoutOptIn() async throws { + let (challenge, rpc, signer) = try unknownMintChallenge( + ownerProgram: "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + ) + await #expect(throws: MppError.self) { + _ = try await Charge.buildPullCredential(challenge: challenge, signer: signer, rpc: rpc) + } + } + + @Test + func signsUnknownToken2022MintWithOptIn() async throws { + let (challenge, rpc, signer) = try unknownMintChallenge( + ownerProgram: "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + ) + let header = try await Charge.buildPullCredential( + challenge: challenge, + signer: signer, + rpc: rpc, + options: Charge.Options(allowUnknownToken2022: true) + ) + #expect(header.hasPrefix("Payment ")) + } + + @Test + func signsUnknownVanillaTokenMintWithoutOptIn() async throws { + // Vanilla Token Program has no transfer hooks, so unknown mints + // there are never gated. + let (challenge, rpc, signer) = try unknownMintChallenge( + ownerProgram: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + ) + let header = try await Charge.buildPullCredential(challenge: challenge, signer: signer, rpc: rpc) + #expect(header.hasPrefix("Payment ")) + } + + // MARK: - Audit #42: SPL decimals required + + @Test + func refusesSplChargeWithMissingDecimals() async throws { + // Known Token-2022 mint owner so the #26 gate does not fire first; + // decimals omitted entirely. + let (challenge, rpc, signer) = try unknownMintChallenge( + ownerProgram: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + decimals: "" + ) + await #expect(throws: MppError.self) { + _ = try await Charge.buildPullCredential(challenge: challenge, signer: signer, rpc: rpc) + } + } + + // MARK: - Audit #10: auto-pay policy gates + + /// Helper: a minimal SOL charge challenge (no RPC needed) with the + /// given amount and an optional `expires` attribute on the header. + private func solChallenge(amount: String, network: String = "localnet") throws -> PaymentChallenge { + let blockhash = Base58.encode(Data(repeating: 0x66, count: 32)) + let requestJson = """ + { + "amount": "\(amount)", + "currency": "SOL", + "recipient": "5wEwLBR3aTGdz8wWUFKafdGiLcQNqotQK1ndJxXLfHir", + "methodDetails": { + "network": "\(network)", + "feePayer": false, + "recentBlockhash": "\(blockhash)" + } + } + """ + return try PaymentChallenge( + id: "ch-10", + realm: "MPP Payment", + method: "solana", + intent: "charge", + request: Base64URL.encode(Data(requestJson.utf8)) + ) + } + + @Test + func refusesAmountAboveMaxCap() async throws { + let challenge = try solChallenge(amount: "1000") + let signer = try MemorySigner(secretKey: Data(repeating: 41, count: 32)) + await #expect(throws: MppError.self) { + _ = try await Charge.buildPullCredential( + challenge: challenge, + signer: signer, + options: Charge.Options(maxAmountBaseUnits: 999) + ) + } + } + + @Test + func acceptsAmountAtMaxCap() async throws { + let challenge = try solChallenge(amount: "1000") + let signer = try MemorySigner(secretKey: Data(repeating: 42, count: 32)) + let header = try await Charge.buildPullCredential( + challenge: challenge, + signer: signer, + options: Charge.Options(maxAmountBaseUnits: 1000) + ) + #expect(header.hasPrefix("Payment ")) + } + + @Test + func refusesUnexpectedNetwork() async throws { + let challenge = try solChallenge(amount: "10", network: "mainnet") + let signer = try MemorySigner(secretKey: Data(repeating: 43, count: 32)) + await #expect(throws: MppError.self) { + _ = try await Charge.buildPullCredential( + challenge: challenge, + signer: signer, + options: Charge.Options(expectedNetwork: "devnet") + ) + } + } + + @Test + func acceptsMatchingNetwork() async throws { + let challenge = try solChallenge(amount: "10", network: "devnet") + let signer = try MemorySigner(secretKey: Data(repeating: 44, count: 32)) + let header = try await Charge.buildPullCredential( + challenge: challenge, + signer: signer, + options: Charge.Options(expectedNetwork: "devnet") + ) + #expect(header.hasPrefix("Payment ")) + } + + @Test + func refusesExpiredChallengeAlwaysOn() async throws { + let blockhash = Base58.encode(Data(repeating: 0x66, count: 32)) + let requestJson = """ + {"amount":"10","currency":"SOL","recipient":"5wEwLBR3aTGdz8wWUFKafdGiLcQNqotQK1ndJxXLfHir","methodDetails":{"network":"localnet","feePayer":false,"recentBlockhash":"\(blockhash)"}} + """ + // Past expiry; refusal is always-on (no Options needed). + let challenge = try PaymentChallenge( + id: "ch-expired", + realm: "MPP Payment", + method: "solana", + intent: "charge", + request: Base64URL.encode(Data(requestJson.utf8)), + expires: "2000-01-01T00:00:00Z" + ) + let signer = try MemorySigner(secretKey: Data(repeating: 45, count: 32)) + await #expect(throws: MppError.self) { + _ = try await Charge.buildPullCredential(challenge: challenge, signer: signer) + } + } + + @Test + func acceptsFutureExpiry() async throws { + let blockhash = Base58.encode(Data(repeating: 0x66, count: 32)) + let requestJson = """ + {"amount":"10","currency":"SOL","recipient":"5wEwLBR3aTGdz8wWUFKafdGiLcQNqotQK1ndJxXLfHir","methodDetails":{"network":"localnet","feePayer":false,"recentBlockhash":"\(blockhash)"}} + """ + let challenge = try PaymentChallenge( + id: "ch-future", + realm: "MPP Payment", + method: "solana", + intent: "charge", + request: Base64URL.encode(Data(requestJson.utf8)), + expires: "2099-01-01T00:00:00Z" + ) + let signer = try MemorySigner(secretKey: Data(repeating: 46, count: 32)) + let header = try await Charge.buildPullCredential(challenge: challenge, signer: signer) + #expect(header.hasPrefix("Payment ")) + } + + @Test + func refusesUnparseableExpiryFailClosed() async throws { + let blockhash = Base58.encode(Data(repeating: 0x66, count: 32)) + let requestJson = """ + {"amount":"10","currency":"SOL","recipient":"5wEwLBR3aTGdz8wWUFKafdGiLcQNqotQK1ndJxXLfHir","methodDetails":{"network":"localnet","feePayer":false,"recentBlockhash":"\(blockhash)"}} + """ + let challenge = try PaymentChallenge( + id: "ch-bad-expiry", + realm: "MPP Payment", + method: "solana", + intent: "charge", + request: Base64URL.encode(Data(requestJson.utf8)), + expires: "not-a-timestamp" + ) + let signer = try MemorySigner(secretKey: Data(repeating: 47, count: 32)) + await #expect(throws: MppError.self) { + _ = try await Charge.buildPullCredential(challenge: challenge, signer: signer) + } + } + + // MARK: - Audit #20: split ATA creation gated on the flag only + + @Test + func splitWithoutAtaFlagDoesNotCreateAtaInClientPaidMode() async throws { + // Client-paid mode (feePayer:false) with a split that does NOT set + // ataCreationRequired must NOT emit a create-ATA instruction. We + // assert by counting instructions against a baseline that does. + let mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + let recipient = "5wEwLBR3aTGdz8wWUFKafdGiLcQNqotQK1ndJxXLfHir" + let splitRecipient = "11111111111111111111111111111112" + let blockhash = Base58.encode(Data(repeating: 0x11, count: 32)) + let signer = try MemorySigner(secretKey: Data(repeating: 51, count: 32)) + + func serializedLength(ataFlag: String) async throws -> Int { + let requestJson = """ + { + "amount": "1000", + "currency": "\(mint)", + "recipient": "\(recipient)", + "methodDetails": { + "network": "localnet", + "decimals": 6, + "feePayer": false, + "recentBlockhash": "\(blockhash)", + "splits": [ + {"recipient": "\(splitRecipient)", "amount": "100"\(ataFlag)} + ], + "tokenProgram": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + } + } + """ + let request = try JSONDecoder().decode(ChargeRequest.self, from: Data(requestJson.utf8)) + let tx = try await Charge.buildChargeTransaction(request: request, signer: signer) + return Data(base64Encoded: tx)!.count + } + + // Audit #20: in client-paid mode the split without the flag must not + // gain a create-ATA instruction; the flagged variant is strictly + // larger because it carries that extra instruction. + let withoutFlag = try await serializedLength(ataFlag: "") + let withFlag = try await serializedLength(ataFlag: #", "ataCreationRequired": true"#) + #expect(withFlag > withoutFlag) + } } // MARK: - Dedicated URLProtocol stub for RPC tests diff --git a/typescript/packages/mpp/src/Methods.ts b/typescript/packages/mpp/src/Methods.ts index 9550318c6..21d5dde63 100644 --- a/typescript/packages/mpp/src/Methods.ts +++ b/typescript/packages/mpp/src/Methods.ts @@ -83,7 +83,7 @@ export const charge = Method.from({ feePayer: z.optional(z.boolean()), /** Server's base58-encoded public key for fee payment. Present when feePayer is true. */ feePayerKey: z.optional(z.string()), - /** Solana network: mainnet-beta, devnet, or localnet. */ + /** Solana network: mainnet, devnet, or localnet. */ network: z.optional(z.string()), /** Server-provided base58-encoded recent blockhash. Saves the client an RPC round-trip. */ recentBlockhash: z.optional(z.string()), @@ -157,7 +157,7 @@ export const subscription = Method.from({ feePayerKey: z.optional(z.string()), /** Base58 of the SPL token mint. Must equal the on-chain plan.mint. */ mint: z.string(), - /** Solana network: mainnet-beta, devnet, testnet, or localnet. */ + /** Solana network: mainnet, devnet, or localnet. */ network: z.optional(z.string()), /** Base58 of the on-chain Plan PDA. */ planId: z.string(), @@ -282,7 +282,7 @@ export const session = Method.from({ minVoucherDelta: z.optional(z.string()), /** Supported funding modes. Omitted means push mode only. */ modes: z.optional(z.array(sessionMode)), - /** Solana network: mainnet-beta, devnet, or localnet. */ + /** Solana network: mainnet, devnet, or localnet. */ network: z.optional(z.string()), /** Operator/server public key. */ operator: z.string(), diff --git a/typescript/packages/mpp/src/__tests__/challenge-guard.test.ts b/typescript/packages/mpp/src/__tests__/challenge-guard.test.ts new file mode 100644 index 000000000..a86b2f6af --- /dev/null +++ b/typescript/packages/mpp/src/__tests__/challenge-guard.test.ts @@ -0,0 +1,26 @@ +/** + * Coverage for the in-repo challenge-parse guard (shared/challenge-guard.ts). + * + * - empty-id rejection (pre-existing canonical-conformance guard) + * - oversized-header size cap (audit #9): mppx's WWW-Authenticate parser + * base64-decodes + JSON-parses the embedded `request` param with no cap, a + * client-side DoS surface. We cap the full header at our boundary. + */ +import { test, expect } from 'vitest'; + +import { deserialize, deserializeList, MAX_CHALLENGE_HEADER_LEN } from '../shared/challenge-guard.js'; + +test('#9 deserialize rejects an oversized challenge header before parsing', () => { + const oversized = 'Payment ' + 'A'.repeat(MAX_CHALLENGE_HEADER_LEN + 1); + expect(() => deserialize(oversized)).toThrow(/exceeds maximum size/); +}); + +test('#9 deserializeList rejects an oversized challenge header before parsing', () => { + const oversized = 'Payment ' + 'A'.repeat(MAX_CHALLENGE_HEADER_LEN + 1); + expect(() => deserializeList(oversized)).toThrow(/exceeds maximum size/); +}); + +test('#9 deserialize does not fire the size cap for a normal-length (malformed) header', () => { + // A short, malformed header should fail somewhere other than the size cap. + expect(() => deserialize('Payment id=""')).not.toThrow(/exceeds maximum size/); +}); diff --git a/typescript/packages/mpp/src/__tests__/charge.test.ts b/typescript/packages/mpp/src/__tests__/charge.test.ts index 5bc6ea698..739320d70 100644 --- a/typescript/packages/mpp/src/__tests__/charge.test.ts +++ b/typescript/packages/mpp/src/__tests__/charge.test.ts @@ -7,6 +7,7 @@ */ import { test, expect, beforeEach, afterEach } from 'vitest'; import { Store } from 'mppx/server'; +import { getSetComputeUnitPriceInstruction } from '@solana-program/compute-budget'; import { getTransferSolInstruction } from '@solana-program/system'; import { findAssociatedTokenPda, getTransferCheckedInstruction } from '@solana-program/token'; import { @@ -29,7 +30,7 @@ import { type Blockhash, } from '@solana/kit'; import { buildChargeTransaction } from '../client/Charge.js'; -import { charge } from '../server/Charge.js'; +import { charge, interpretPostTimeoutStatus, verifyChargeTransaction } from '../server/Charge.js'; import { ASSOCIATED_TOKEN_PROGRAM, CASH, @@ -3089,3 +3090,151 @@ test('splits: multiple splits with SOL', async () => { expect(receipt.status).toBe('success'); }); + +// ── Audit fix coverage ────────────────────────────────────────────────────── + +// #37 — network allowlist at boot +test('#37 charge() rejects an unknown network slug at construction', () => { + expect(() => charge({ recipient: RECIPIENT, network: 'testnet' })).toThrow(/Unsupported network/); +}); + +test('#37 charge() rejects an empty network slug', () => { + expect(() => charge({ recipient: RECIPIENT, network: '' })).toThrow(/must not be empty/); +}); + +test('#37 charge() accepts the canonical networks and the mainnet-beta alias', () => { + for (const network of ['mainnet', 'devnet', 'localnet', 'mainnet-beta']) { + expect(() => charge({ recipient: RECIPIENT, network })).not.toThrow(); + } +}); + +// #21 — split validation at issuance +test('#21 charge() rejects more than 8 splits at issuance', () => { + const splits = Array.from({ length: 9 }, () => ({ recipient: PLATFORM, amount: '1' })); + expect(() => charge({ recipient: RECIPIENT, network: 'devnet', splits })).toThrow(/cannot exceed 8/); +}); + +test('#21 charge() rejects an unparseable split recipient at issuance', () => { + const splits = [{ recipient: 'not-a-pubkey!!!', amount: '100' }]; + expect(() => charge({ recipient: RECIPIENT, network: 'devnet', splits })).toThrow(/Invalid split recipient/); +}); + +test('#21 charge() rejects a zero/negative split amount at issuance', () => { + expect(() => + charge({ recipient: RECIPIENT, network: 'devnet', splits: [{ recipient: PLATFORM, amount: '0' }] }), + ).toThrow(/must be positive/); +}); + +test('#21 charge() accepts a valid split set at issuance', () => { + expect(() => + charge({ recipient: RECIPIENT, network: 'devnet', splits: [{ recipient: PLATFORM, amount: '100' }] }), + ).not.toThrow(); +}); + +// #38 — primary recipient in splits + ataCreationRequired in fee-sponsored mode +test('#38 charge() rejects a primary-recipient split with ataCreationRequired in fee-sponsored mode', async () => { + const signer = await generateKeyPairSigner(); + const splits = [{ recipient: RECIPIENT, amount: '50000', ataCreationRequired: true }]; + expect(() => + charge({ recipient: RECIPIENT, currency: USDC_MINT, decimals: 6, network: 'devnet', signer, splits }), + ).toThrow(/primary recipient must not set ataCreationRequired/); +}); + +test('#38 charge() allows a primary-recipient split WITHOUT ataCreationRequired in fee-sponsored mode', async () => { + const signer = await generateKeyPairSigner(); + const splits = [{ recipient: RECIPIENT, amount: '50000' }]; + expect(() => + charge({ recipient: RECIPIENT, currency: USDC_MINT, decimals: 6, network: 'devnet', signer, splits }), + ).not.toThrow(); +}); + +test('#38 charge() allows a primary-recipient split with ataCreationRequired in client-paid mode (no signer)', () => { + const splits = [{ recipient: RECIPIENT, amount: '50000', ataCreationRequired: true }]; + expect(() => + charge({ recipient: RECIPIENT, currency: USDC_MINT, decimals: 6, network: 'devnet', splits }), + ).not.toThrow(); +}); + +// #25 — fee-sponsored compute-unit price cap +async function buildSolTxWithComputePrice(feePayerKey: string, microLamports: bigint) { + const authority = await generateKeyPairSigner(); + const instructions: Instruction[] = [ + getSetComputeUnitPriceInstruction({ microLamports }), + getTransferSolInstruction({ source: authority, destination: address(RECIPIENT), amount: 1_000_000n }), + ]; + const txMessage = pipe( + createTransactionMessage({ version: 0 }), + msg => setTransactionMessageFeePayer(address(feePayerKey), msg), + msg => setTransactionMessageLifetimeUsingBlockhash({ blockhash: BLOCKHASH, lastValidBlockHeight: 1n }, msg), + msg => appendTransactionMessageInstructions(instructions, msg), + ); + return getBase64EncodedWireTransaction(await partiallySignTransactionMessageWithSigners(txMessage)); +} + +test('#25 fee-sponsored compute-unit price above the tight cap is rejected', async () => { + const signer = await generateKeyPairSigner(); + const tx = await buildSolTxWithComputePrice(signer.address, 20_000n); + await expect( + verifyChargeTransaction(tx, { + amount: '1000000', + currency: 'sol', + methodDetails: { network: 'devnet', feePayer: true, feePayerKey: signer.address }, + recipient: RECIPIENT, + }), + ).rejects.toThrow(/Compute unit price.*exceeds maximum 10000/); +}); + +test('#25 fee-sponsored compute-unit price under the tight cap passes', async () => { + const signer = await generateKeyPairSigner(); + const tx = await buildSolTxWithComputePrice(signer.address, 5_000n); + await expect( + verifyChargeTransaction(tx, { + amount: '1000000', + currency: 'sol', + methodDetails: { network: 'devnet', feePayer: true, feePayerKey: signer.address }, + recipient: RECIPIENT, + }), + ).resolves.toBeUndefined(); +}); + +test('#25 client-paid compute-unit price above the tight cap still passes (general cap applies)', async () => { + const authority = await generateKeyPairSigner(); + const instructions: Instruction[] = [ + getSetComputeUnitPriceInstruction({ microLamports: 20_000n }), + getTransferSolInstruction({ source: authority, destination: address(RECIPIENT), amount: 1_000_000n }), + ]; + const txMessage = pipe( + createTransactionMessage({ version: 0 }), + msg => setTransactionMessageFeePayerSigner(authority, msg), + msg => setTransactionMessageLifetimeUsingBlockhash({ blockhash: BLOCKHASH, lastValidBlockHeight: 1n }, msg), + msg => appendTransactionMessageInstructions(instructions, msg), + ); + const tx = getBase64EncodedWireTransaction(await partiallySignTransactionMessageWithSigners(txMessage)); + await expect( + verifyChargeTransaction(tx, { + amount: '1000000', + currency: 'sol', + methodDetails: { network: 'devnet' }, + recipient: RECIPIENT, + }), + ).resolves.toBeUndefined(); +}); + +// #3 — post-timeout definitive status interpretation +test('#3 interpretPostTimeoutStatus: landed cleanly returns confirmed', () => { + expect(interpretPostTimeoutStatus({ err: null })).toEqual({ kind: 'confirmed' }); +}); + +test('#3 interpretPostTimeoutStatus: landed but failed on-chain returns failed', () => { + const outcome = interpretPostTimeoutStatus({ err: { InstructionError: [0, 'Custom'] } }); + expect(outcome.kind).toBe('failed'); +}); + +test('#3 interpretPostTimeoutStatus: not found returns timeout', () => { + expect(interpretPostTimeoutStatus(null)).toEqual({ kind: 'timeout' }); +}); + +test('#3 interpretPostTimeoutStatus: rpc error returns timeout with detail', () => { + const outcome = interpretPostTimeoutStatus(null, 'connection refused'); + expect(outcome).toEqual({ detail: 'connection refused', kind: 'timeout' }); +}); diff --git a/typescript/packages/mpp/src/__tests__/client-charge-validation.test.ts b/typescript/packages/mpp/src/__tests__/client-charge-validation.test.ts index 101a0fbdc..a5b2962ce 100644 --- a/typescript/packages/mpp/src/__tests__/client-charge-validation.test.ts +++ b/typescript/packages/mpp/src/__tests__/client-charge-validation.test.ts @@ -15,9 +15,16 @@ * - client native SOL transaction building path (with provided blockhash) */ import { test, expect } from 'vitest'; -import { generateKeyPairSigner, type Blockhash } from '@solana/kit'; +import { + generateKeyPairSigner, + getBase64Codec, + getCompiledTransactionMessageDecoder, + getTransactionDecoder, + type Blockhash, +} from '@solana/kit'; import { buildChargeTransaction, charge } from '../client/Charge.js'; +import { ASSOCIATED_TOKEN_PROGRAM, TOKEN_2022_PROGRAM, TOKEN_PROGRAM } from '../constants.js'; const RECIPIENT = '9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ'; const USDC_MINT = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; @@ -257,3 +264,179 @@ test('charge() accepts custom rpcUrl, computeUnitLimit, computeUnitPrice, onProg }); expect(method).toBeDefined(); }); + +// ── Audit fix coverage ────────────────────────────────────────────────────── + +const PLATFORM = '3pF8Kg2aHbNvJkLMwEqR7YtDxZ5sGhJn4UV6mWcXrT9A'; +// A real Token-2022 mint that is NOT a known stablecoin (random base58 pubkey). +const UNKNOWN_T22_MINT = 'BPFLoaderUpgradeab1e11111111111111111111111'; + +function programAddressesOf(base64Tx: string): string[] { + const decoded = getTransactionDecoder().decode(getBase64Codec().encode(base64Tx)); + const message = getCompiledTransactionMessageDecoder().decode(decoded.messageBytes) as unknown as { + instructions: readonly { programAddressIndex: number }[]; + staticAccounts: readonly string[]; + }; + return message.instructions.map(ix => String(message.staticAccounts[ix.programAddressIndex])); +} + +// #42 — decimals required for SPL +test('#42 buildChargeTransaction errors when decimals is missing for an SPL charge', async () => { + const signer = await newSigner(); + await expect( + buildChargeTransaction({ + signer, + request: { + amount: '1000000', + currency: USDC_MINT, + recipient: RECIPIENT, + methodDetails: { network: 'devnet', tokenProgram: TOKEN_PROGRAM, recentBlockhash: BLOCKHASH }, + }, + }), + ).rejects.toThrow(/decimals is required for SPL/); +}); + +// #26 — refuse unknown Token-2022 mints unless opted in +test('#26 buildChargeTransaction refuses an unknown Token-2022 mint without opt-in', async () => { + const signer = await newSigner(); + await expect( + buildChargeTransaction({ + signer, + request: { + amount: '1000000', + currency: UNKNOWN_T22_MINT, + recipient: RECIPIENT, + methodDetails: { + network: 'devnet', + decimals: 6, + tokenProgram: TOKEN_2022_PROGRAM, + recentBlockhash: BLOCKHASH, + }, + }, + }), + ).rejects.toThrow(/unknown Token-2022 mint/); +}); + +test('#26 buildChargeTransaction allows an unknown Token-2022 mint with allowUnknownToken2022', async () => { + const signer = await newSigner(); + const tx = await buildChargeTransaction({ + allowUnknownToken2022: true, + signer, + request: { + amount: '1000000', + currency: UNKNOWN_T22_MINT, + recipient: RECIPIENT, + methodDetails: { + network: 'devnet', + decimals: 6, + tokenProgram: TOKEN_2022_PROGRAM, + recentBlockhash: BLOCKHASH, + }, + }, + }); + expect(tx.length).toBeGreaterThan(0); +}); + +test('#26 buildChargeTransaction allows an unknown vanilla Token mint without opt-in', async () => { + const signer = await newSigner(); + const tx = await buildChargeTransaction({ + signer, + request: { + amount: '1000000', + currency: UNKNOWN_T22_MINT, + recipient: RECIPIENT, + methodDetails: { + network: 'devnet', + decimals: 6, + tokenProgram: TOKEN_PROGRAM, + recentBlockhash: BLOCKHASH, + }, + }, + }); + expect(tx.length).toBeGreaterThan(0); +}); + +// #20 — split ATA created only when flagged (client-paid mode) +test('#20 client-paid split WITHOUT ataCreationRequired emits no ATA-create instruction', async () => { + const signer = await newSigner(); + const tx = await buildChargeTransaction({ + signer, + request: { + amount: '1000000', + currency: USDC_MINT, + recipient: RECIPIENT, + methodDetails: { + network: 'devnet', + decimals: 6, + tokenProgram: TOKEN_PROGRAM, + recentBlockhash: BLOCKHASH, + splits: [{ amount: '50000', recipient: PLATFORM }], + }, + }, + }); + expect(programAddressesOf(tx)).not.toContain(ASSOCIATED_TOKEN_PROGRAM); +}); + +test('#20 client-paid split WITH ataCreationRequired emits an ATA-create instruction', async () => { + const signer = await newSigner(); + const tx = await buildChargeTransaction({ + signer, + request: { + amount: '1000000', + currency: USDC_MINT, + recipient: RECIPIENT, + methodDetails: { + network: 'devnet', + decimals: 6, + tokenProgram: TOKEN_PROGRAM, + recentBlockhash: BLOCKHASH, + splits: [{ amount: '50000', recipient: PLATFORM, ataCreationRequired: true }], + }, + }, + }); + expect(programAddressesOf(tx)).toContain(ASSOCIATED_TOKEN_PROGRAM); +}); + +// #10 — client guards (always-on expiry; opt-in max amount + expected network) +function challengeFor(request: Record, expires?: string) { + return { expires, id: 'test-id', method: 'solana', request } as never; +} + +test('#10 createCredential refuses an expired challenge before signing', async () => { + const signer = await newSigner(); + const method = charge({ signer }); + const challenge = challengeFor( + { + amount: '1000', + currency: 'sol', + recipient: RECIPIENT, + methodDetails: { network: 'devnet', recentBlockhash: BLOCKHASH }, + }, + new Date(Date.now() - 60_000).toISOString(), + ); + await expect(method.createCredential({ challenge })).rejects.toThrow(/expired challenge/); +}); + +test('#10 createCredential rejects an amount above maxAmount', async () => { + const signer = await newSigner(); + const method = charge({ signer, maxAmount: 500n }); + const challenge = challengeFor({ + amount: '1000', + currency: 'sol', + recipient: RECIPIENT, + methodDetails: { network: 'devnet', recentBlockhash: BLOCKHASH }, + }); + await expect(method.createCredential({ challenge })).rejects.toThrow(/exceeds the configured maxAmount/); +}); + +test('#10 createCredential rejects a network that does not match expectedNetwork', async () => { + const signer = await newSigner(); + const method = charge({ signer, expectedNetwork: 'mainnet' }); + const challenge = challengeFor({ + amount: '1000', + currency: 'sol', + recipient: RECIPIENT, + methodDetails: { network: 'devnet', recentBlockhash: BLOCKHASH }, + }); + await expect(method.createCredential({ challenge })).rejects.toThrow(/does not match the expected network/); +}); diff --git a/typescript/packages/mpp/src/client/Charge.ts b/typescript/packages/mpp/src/client/Charge.ts index 655546d7d..042745d1d 100644 --- a/typescript/packages/mpp/src/client/Charge.ts +++ b/typescript/packages/mpp/src/client/Charge.ts @@ -34,6 +34,7 @@ import { MEMO_PROGRAM, normalizeNetwork, resolveStablecoinMint, + stablecoinSymbolForCurrency, SYSTEM_PROGRAM, TOKEN_2022_PROGRAM, TOKEN_PROGRAM, @@ -70,7 +71,7 @@ import * as Methods from '../Methods.js'; * ``` */ export function charge(parameters: charge.Parameters) { - const { signer, broadcast = false, onProgress } = parameters; + const { signer, broadcast = false, onProgress, maxAmount, expectedNetwork, allowUnknownToken2022 } = parameters; const method = Method.toClient(Methods.charge, { async createCredential({ challenge }) { @@ -80,7 +81,29 @@ export function charge(parameters: charge.Parameters) { if (serverPaysFees && broadcast) { throw new Error('broadcast=true cannot be used with fee sponsorship (feePayer: true)'); } + + // Client-side policy gates (audit #10). The protocol's trust model + // assumes a human reviews the challenge before signing; auto-pay + // agents break that, so an auto-pay caller can bind what we'll sign. + // All gates default to "no constraint" except expiry, which is + // always-on (fail-closed). + assertChallengeNotExpired(challenge.expires); + if (maxAmount !== undefined && BigInt(challenge.request.amount) > maxAmount) { + throw new Error( + `Challenge amount ${challenge.request.amount} exceeds the configured maxAmount ${maxAmount}`, + ); + } + if ( + expectedNetwork !== undefined && + normalizeNetwork(network ?? 'mainnet') !== normalizeNetwork(expectedNetwork) + ) { + throw new Error( + `Challenge network "${network ?? 'mainnet'}" does not match the expected network "${expectedNetwork}"`, + ); + } + const encodedTx = await buildChargeTransaction({ + allowUnknownToken2022, computeUnitLimit: parameters.computeUnitLimit, computeUnitPrice: parameters.computeUnitPrice, onProgress, @@ -145,6 +168,7 @@ export async function buildChargeTransaction( signer, request: { amount, currency, externalId, recipient, methodDetails }, onProgress, + allowUnknownToken2022 = false, } = parameters; const { network, @@ -209,7 +233,30 @@ export async function buildChargeTransaction( // ── SPL token transfers ── const mintAddress = address(mint); const tokenProg = tokenProgramAddr ? address(tokenProgramAddr) : await resolveTokenProgram(rpc, mintAddress); - const tokenDecimals = decimals ?? 6; + + // Audit #26: refuse to sign unknown Token-2022 mints unless explicitly + // allowed. Token-2022 mints can carry transfer hooks that execute + // arbitrary code on every transfer; the server's pre-broadcast checks do + // not simulate inner instructions in pull mode. Vanilla Token mints have + // no hooks, so arbitrary mints there stay allowed. + if ( + String(tokenProg) === TOKEN_2022_PROGRAM && + stablecoinSymbolForCurrency(mint) === undefined && + !allowUnknownToken2022 + ) { + throw new Error( + 'Refusing to sign an unknown Token-2022 mint (transfer-hook risk). ' + + 'Set allowUnknownToken2022: true to override.', + ); + } + + // Audit #42: decimals MUST be present for SPL (spec §7.2). Silently + // defaulting to 6 produces a wrong transferChecked divisor for + // non-6-decimal mints — the worst possible failure for a signer. + if (decimals === undefined) { + throw new Error('methodDetails.decimals is required for SPL charges (spec §7.2)'); + } + const tokenDecimals = decimals; const [sourceAta] = await findAssociatedTokenPda({ mint: mintAddress, @@ -273,13 +320,12 @@ export async function buildChargeTransaction( await addSplTransfer(recipient, primaryAmount, false); addMemoInstruction(externalId); - // Split transfers. + // Split transfers. Audit #20: create the split ATA only when the server + // flags it via ataCreationRequired, in BOTH modes. Previously + // client-paid mode auto-funded every split ATA, letting a hostile server + // drain the client with N dust splits (N × ~0.002 SOL rent). for (const split of splits ?? []) { - await addSplTransfer( - split.recipient, - BigInt(split.amount), - !useServerFeePayer || split.ataCreationRequired === true, - ); + await addSplTransfer(split.recipient, BigInt(split.amount), split.ataCreationRequired === true); addMemoInstruction(split.memo); } } else { @@ -347,6 +393,23 @@ export async function buildChargeTransaction( // ── Helpers ── +/** + * Always-on expiry refusal for the client (audit #10): refuse to sign a + * challenge whose `expires` timestamp is in the past or malformed. A challenge + * with no `expires` is accepted — the protocol allows omitting it and the client + * has no anchor to check against. + */ +function assertChallengeNotExpired(expires: string | undefined): void { + if (expires === undefined) return; + const expiresAt = new Date(expires).getTime(); + if (Number.isNaN(expiresAt)) { + throw new Error(`Refusing to sign: malformed challenge expires timestamp "${expires}"`); + } + if (expiresAt < Date.now()) { + throw new Error('Refusing to sign an expired challenge'); + } +} + /** * Creates an Associated Token Account using the idempotent instruction * (CreateIdempotent = discriminator 1). This is a no-op if the ATA exists. @@ -413,6 +476,13 @@ async function confirmTransaction(rpc: ReturnType, signa export declare namespace charge { type Parameters = { + /** + * Opt-in (audit #26): allow signing unknown Token-2022 mints. Token-2022 + * mints can carry transfer hooks that execute arbitrary code on transfer, + * so by default the client refuses unknown (non-stablecoin) Token-2022 + * mints. Vanilla Token mints are always allowed regardless of this flag. + */ + allowUnknownToken2022?: boolean; /** * If true, the client broadcasts the transaction and sends the signature * as a `type="signature"` credential. If false (default), the client sends @@ -426,6 +496,17 @@ export declare namespace charge { computeUnitLimit?: number; /** Compute unit price in micro-lamports for priority fees. Defaults to 1. */ computeUnitPrice?: bigint; + /** + * Opt-in guard (audit #10): refuse to sign a challenge whose network does + * not match this value. Use for auto-pay flows. Defaults to no constraint. + */ + expectedNetwork?: string; + /** + * Opt-in guard (audit #10): refuse to sign a challenge whose amount (in + * base units) exceeds this cap. Use for auto-pay flows where the server + * controls what gets signed. Defaults to no constraint. + */ + maxAmount?: bigint; /** Called at each step of the payment process. */ onProgress?: (event: ProgressEvent) => void; /** Custom RPC URL. If not set, inferred from the challenge's network field. */ @@ -457,6 +538,12 @@ export declare namespace charge { export declare namespace buildChargeTransaction { type Parameters = { + /** + * Allow signing unknown Token-2022 mints (audit #26). Defaults to false; + * unknown (non-stablecoin) Token-2022 mints are refused because they may + * carry transfer hooks. + */ + allowUnknownToken2022?: boolean; /** Compute unit limit. Defaults to 200,000. */ computeUnitLimit?: number; /** Compute unit price in micro-lamports for priority fees. Defaults to 1. */ diff --git a/typescript/packages/mpp/src/constants.ts b/typescript/packages/mpp/src/constants.ts index fcb47b90a..00ee41f60 100644 --- a/typescript/packages/mpp/src/constants.ts +++ b/typescript/packages/mpp/src/constants.ts @@ -85,6 +85,31 @@ export function normalizeNetwork(network: string): string { return network; } +/** + * Canonical network slug is `mainnet`. The legacy `mainnet-beta` spelling is + * accepted as a backward-compatible alias (normalized to `mainnet`). + * + * Per the spec, the network MUST be one of mainnet / devnet / localnet. + * Anything else (`testnet`, typos, empty string) is rejected at boot rather + * than silently treated as mainnet. + */ +export const CANONICAL_NETWORKS = ['mainnet', 'devnet', 'localnet'] as const; + +/** + * Validates a network slug against the allowlist, throwing on anything outside + * {mainnet, devnet, localnet} (or the `mainnet-beta` alias). Use at server boot + * so misconfigured networks fail fast instead of silently defaulting to mainnet. + */ +export function validateNetwork(network: string): void { + if (network.length === 0) { + throw new Error('network must not be empty (expected one of: mainnet, devnet, localnet)'); + } + const normalized = normalizeNetwork(network); + if (!(CANONICAL_NETWORKS as readonly string[]).includes(normalized)) { + throw new Error(`Unsupported network "${network}" (expected one of: mainnet, devnet, localnet)`); + } +} + export function resolveStablecoinMint(currency: string, network = 'mainnet'): string | undefined { const key = normalizeNetwork(network); switch (currency.toUpperCase()) { diff --git a/typescript/packages/mpp/src/server/Charge.ts b/typescript/packages/mpp/src/server/Charge.ts index 10b129f3d..62c06221d 100644 --- a/typescript/packages/mpp/src/server/Charge.ts +++ b/typescript/packages/mpp/src/server/Charge.ts @@ -20,6 +20,7 @@ import { SYSTEM_PROGRAM, TOKEN_2022_PROGRAM, TOKEN_PROGRAM, + validateNetwork, } from '../constants.js'; import * as Methods from '../Methods.js'; import { coSignBase64Transaction } from '../utils/transactions.js'; @@ -67,31 +68,37 @@ export function charge(parameters: charge.Parameters) { decimals, html: htmlEnabled = false, tokenProgram: configuredTokenProgram, - network = 'mainnet-beta', + network = 'mainnet', store = Store.memory(), splits, signer, } = parameters; + // Reject unknown network slugs at boot (spec: mainnet | devnet | localnet), + // rather than silently falling back to mainnet for a typo. The legacy + // `mainnet-beta` spelling is accepted as an alias and normalized below. + validateNetwork(network); + const isSplToken = currency !== undefined && currency !== 'sol'; const tokenProgram = configuredTokenProgram ?? defaultTokenProgramForCurrency(currency, network); - const rpcUrl = parameters.rpcUrl ?? DEFAULT_RPC_URLS[network] ?? DEFAULT_RPC_URLS['mainnet-beta']; + const rpcUrl = parameters.rpcUrl ?? DEFAULT_RPC_URLS[network] ?? DEFAULT_RPC_URLS['mainnet']; if (isSplToken && decimals === undefined) { throw new Error('decimals is required when currency is a token mint address'); } - if (splits && splits.length > 8) { - throw new Error('splits cannot exceed 8 entries'); - } - if (signer && !isTransactionPartialSigner(signer)) { throw new Error( 'signer must implement signTransactions() for fee payer mode (e.g. KeyPairSigner, SolanaSigner)', ); } + // Validate splits at issuance (audit #21): count cap, parseable recipient, + // positive parseable amount, no duplicate recipients, no aggregate overflow. + // Invalid splits would otherwise only surface at on-chain settlement. + validateChargeSplits(splits); + const hasAtaCreationSplits = splits?.some(split => split.ataCreationRequired === true) === true; if (!isSplToken && hasAtaCreationSplits) { throw new Error('ataCreationRequired requires an SPL token currency'); @@ -100,6 +107,19 @@ export function charge(parameters: charge.Parameters) { throw new Error('ataCreationRequired requires currency to be an SPL token mint address'); } + // Audit #38: in fee-sponsored mode the server funds idempotent ATA-create + // for split recipients. If a split targets the PRIMARY recipient AND sets + // ataCreationRequired, a malicious recipient can close+recreate that ATA in + // a loop to drain the server's fee-payer wallet. Having the primary + // recipient appear in splits is otherwise legitimate, so we narrow the gate + // to exactly the drain shape (primary-in-splits + ataCreationRequired) and + // only when the server is acting as fee payer. + if (signer && splits?.some(split => split.recipient === recipient && split.ataCreationRequired === true)) { + throw new Error( + 'A split that targets the primary recipient must not set ataCreationRequired in fee-sponsored mode', + ); + } + const method = Method.toServer(Methods.charge, { defaults: { currency: currency ?? 'sol', @@ -196,6 +216,58 @@ export function charge(parameters: charge.Parameters) { return method; } +// ── Split validation (issuance, audit #21) ── + +const MAX_SPLITS = 8; + +/** + * Validates the split set supplied at challenge issuance. Enforces: + * - count ≤ {@link MAX_SPLITS} + * - each recipient parses as a base58 Solana pubkey + * - each amount parses as a non-negative u64 BigInt and is > 0 + * - the aggregate sum does not overflow u64 + * + * Without this, malformed splits would only surface much later (a `BigInt` + * throw while building the transaction, or an on-chain failure). + * + * Duplicate recipients are intentionally NOT rejected: two splits to the same + * recipient (with distinct amounts/memos) is a supported shape, verified + * element-wise against distinct transfer instructions on-chain. + */ +function validateChargeSplits(splits: charge.Parameters['splits'] | undefined): void { + if (!splits || splits.length === 0) return; + + if (splits.length > MAX_SPLITS) { + throw new Error(`splits cannot exceed ${MAX_SPLITS} entries`); + } + + const U64_MAX = (1n << 64n) - 1n; + let total = 0n; + + for (const split of splits) { + try { + address(split.recipient); + } catch { + throw new Error(`Invalid split recipient: ${split.recipient}`); + } + + let amount: bigint; + try { + amount = BigInt(split.amount); + } catch { + throw new Error(`Invalid split amount: ${split.amount}`); + } + if (amount <= 0n) { + throw new Error(`Split amount must be positive: ${split.amount}`); + } + + total += amount; + if (total > U64_MAX) { + throw new Error('Split amounts overflow u64'); + } + } +} + // ── Payload type resolution ── function resolvePayloadType(payload: { @@ -232,6 +304,13 @@ function extractRecentBlockhash(clientTxBase64: string): string | null { const MAX_COMPUTE_UNIT_LIMIT = 200_000; const MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 5_000_000n; +// Audit #25: in fee-sponsored pull mode the server co-signs as fee payer and +// pays the priority fee, so a client picking a price near the general cap could +// drain the merchant (~0.001 SOL per charge at the general cap × 200k CU). Apply +// a tight cap when the server is the fee payer. Worst-case priority fee at this +// cap: ceil(10_000 × 200_000 / 1_000_000) = 2_000 lamports (~20% of base fee) — +// enough headroom for honest clients to bump priority during congestion. +const MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED = 10_000n; type CompiledMessage = { addressTableLookups?: readonly unknown[]; @@ -498,7 +577,7 @@ async function validateInstructionAllowlist( const program = programAddress(message, ix); if (program === COMPUTE_BUDGET_PROGRAM) { - validateComputeBudgetInstruction(ix); + validateComputeBudgetInstruction(ix, options.feePayer !== undefined); continue; } @@ -540,7 +619,7 @@ async function validateInstructionAllowlist( } } -function validateComputeBudgetInstruction(ix: CompiledInstruction) { +function validateComputeBudgetInstruction(ix: CompiledInstruction, feeSponsored: boolean) { if ((ix.accountIndices ?? []).length !== 0) { throw new Error('Compute budget instruction must not have accounts'); } @@ -555,8 +634,13 @@ function validateComputeBudgetInstruction(ix: CompiledInstruction) { if (ix.data[0] === 3 && ix.data.length === 9) { const price = readU64Le(ix.data, 1); - if (price > MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS) { - throw new Error(`Compute unit price ${price} exceeds maximum ${MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS}`); + // Tight cap when the server is the fee payer (audit #25): the merchant, + // not the client, pays the priority fee in fee-sponsored mode. + const cap = feeSponsored + ? MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED + : MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS; + if (price > cap) { + throw new Error(`Compute unit price ${price} exceeds maximum ${cap}`); } return; } @@ -690,15 +774,20 @@ async function verifyTransaction( // Broadcast the (now fully-signed) transaction. const signature = await broadcastTransaction(rpcUrl, txToSend); - // Wait for on-chain confirmation. + // Audit #3: reserve the signature BETWEEN broadcast and confirmation polling. + // If we only marked it consumed after confirmation+verify (as before), a tx + // that landed during a confirmation-poll timeout could be lost — the user + // pays but the signature is never recorded, so a retry re-broadcasts (double + // charge) or replays. Reserving here closes the replay window; the + // post-timeout status recovery below rescues the false-negative case. + await store.put(`solana-charge:consumed:${signature}`, true); + + // Wait for on-chain confirmation (with a definitive post-timeout status check). await waitForConfirmation(rpcUrl, signature); // Verify the confirmed transaction matches the challenge. await verifyOnChain(rpcUrl, signature, challenge, recipient); - // Mark consumed to prevent replay. - await store.put(`solana-charge:consumed:${signature}`, true); - return Receipt.from({ method: 'solana', ...(credential.challenge.id ? { challengeId: credential.challenge.id } : {}), @@ -1222,6 +1311,35 @@ async function broadcastTransaction(rpcUrl: string, base64Tx: string): Promise setTimeout(r, 2_000)); } - throw new Error('Transaction confirmation timeout'); + + // Audit #3: the poll loop timed out, but the tx may have landed during the + // window while the RPC lagged. Do ONE definitive status check (with + // `searchTransactionHistory`) to distinguish "landed" from "never landed" + // before reporting a false-negative timeout. + const outcome = await fetchPostTimeoutStatus(rpcUrl, signature); + switch (outcome.kind) { + case 'confirmed': + // Landed cleanly during the timeout window — recover. + console.warn(`[solana-mpp] confirmed_via_status_recovery: ${signature}`); + return; + case 'failed': + throw new Error(`Transaction landed on-chain but failed: ${outcome.detail}`); + case 'timeout': + throw new Error( + outcome.detail + ? `Transaction confirmation timeout (status recovery failed: ${outcome.detail})` + : 'Transaction confirmation timeout', + ); + } +} + +async function fetchPostTimeoutStatus(rpcUrl: string, signature: string): Promise { + try { + const response = await fetch(rpcUrl, { + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method: 'getSignatureStatuses', + params: [[signature], { searchTransactionHistory: true }], + }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + const data = (await response.json()) as { + error?: { message: string }; + result?: { value: ({ err: unknown } | null)[] }; + }; + if (data.error) { + return interpretPostTimeoutStatus(null, data.error.message); + } + return interpretPostTimeoutStatus(data.result?.value?.[0] ?? null); + } catch (e) { + return interpretPostTimeoutStatus(null, e instanceof Error ? e.message : String(e)); + } } export declare namespace charge { @@ -1282,8 +1444,12 @@ export declare namespace charge { * ``` */ html?: boolean; - /** Solana network. Defaults to 'mainnet-beta'. */ - network?: 'devnet' | 'localnet' | 'mainnet-beta' | (string & {}); + /** + * Solana network. One of 'mainnet' | 'devnet' | 'localnet'. Defaults to + * 'mainnet'. The legacy 'mainnet-beta' spelling is accepted as an alias. + * Any other value is rejected at construction. + */ + network?: 'devnet' | 'localnet' | 'mainnet-beta' | 'mainnet' | (string & {}); /** Base58-encoded recipient public key that receives payments. */ recipient: string; /** Custom RPC URL. Defaults to public RPC for the selected network. */ diff --git a/typescript/packages/mpp/src/shared/challenge-guard.ts b/typescript/packages/mpp/src/shared/challenge-guard.ts index b21d6876e..8c0a89593 100644 --- a/typescript/packages/mpp/src/shared/challenge-guard.ts +++ b/typescript/packages/mpp/src/shared/challenge-guard.ts @@ -29,12 +29,31 @@ function assertNonEmptyId(challenge: { id?: unknown }): void { } } +/** + * Maximum accepted `WWW-Authenticate` challenge header length (audit #9). + * + * mppx@0.5.x base64-decodes + JSON-parses the embedded `request` parameter with + * no size cap, so an oversized header drives proportionally larger decode/parse + * work — a client-side DoS surface. Until a cap lands upstream, we guard the + * full header at OUR `@solana/mpp` boundary, mirroring the existing empty-id + * guard. 16 KiB matches the `MAX_TOKEN_LEN` the canonical credential/receipt + * parsers already enforce. + */ +export const MAX_CHALLENGE_HEADER_LEN = 16 * 1024; + +function assertWithinSizeCap(value: string): void { + if (value.length > MAX_CHALLENGE_HEADER_LEN) { + throw new Error(`challenge header exceeds maximum size of ${MAX_CHALLENGE_HEADER_LEN} bytes`); + } +} + /** * Deserializes a `WWW-Authenticate` header value to a challenge, rejecting a * challenge whose `id` is empty (canonical mpp-tools requires a non-empty, * HMAC-bound id). Otherwise identical to `mppx`'s `Challenge.deserialize`. */ export const deserialize: typeof MppxChallenge.deserialize = ((value, options) => { + if (typeof value === 'string') assertWithinSizeCap(value); const challenge = MppxChallenge.deserialize(value, options); assertNonEmptyId(challenge as { id?: unknown }); return challenge; @@ -45,6 +64,7 @@ export const deserialize: typeof MppxChallenge.deserialize = ((value, options) = * challenges, rejecting any challenge whose `id` is empty. */ export const deserializeList: typeof MppxChallenge.deserializeList = ((value, options) => { + if (typeof value === 'string') assertWithinSizeCap(value); const challenges = MppxChallenge.deserializeList(value, options); for (const challenge of challenges) assertNonEmptyId(challenge as { id?: unknown }); return challenges;