diff --git a/typescript/packages/mpp/src/__tests__/cross-route-replay.test.ts b/typescript/packages/mpp/src/__tests__/cross-route-replay.test.ts index 2fd63636..2c184e3f 100644 --- a/typescript/packages/mpp/src/__tests__/cross-route-replay.test.ts +++ b/typescript/packages/mpp/src/__tests__/cross-route-replay.test.ts @@ -19,7 +19,8 @@ import { Mppx } from 'mppx/server'; import { charge } from '../server/Charge.js'; const RECIPIENT = '9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ'; -const SECRET_KEY = 'cross-route-replay-test-secret'; +// >= 32 bytes (audit #24 secret-key floor enforced by the secret guard). +const SECRET_KEY = 'cross-route-replay-test-secret-32-bytes'; const REALM = 'api.example.com'; function makeHandler() { diff --git a/typescript/packages/mpp/src/__tests__/integration.test.ts b/typescript/packages/mpp/src/__tests__/integration.test.ts index 87199665..12050539 100644 --- a/typescript/packages/mpp/src/__tests__/integration.test.ts +++ b/typescript/packages/mpp/src/__tests__/integration.test.ts @@ -261,7 +261,7 @@ test('e2e: fee payer mode — server co-signs and pays fees', async () => { const feePayerSigner = await generateKeyPairSigner(); await client.airdrop(feePayerSigner.address, lamports(10_000_000_000n)); - const secretKey = 'test-secret-key-feepayer'; + const secretKey = 'test-secret-key-feepayer-padded-32b'; const feePayerMppx = ServerMppx.create({ secretKey, @@ -358,7 +358,7 @@ test('e2e: USDC charge via pull mode with fee payer', async () => { await fundUsdc(clientSigner.address, 100_000_000); // 100 USDC await fundUsdc(recipientSigner.address, 0); - const secretKey = 'test-secret-key-usdc'; + const secretKey = 'test-secret-key-usdc-padded-to-32bytes'; const usdcMppx = ServerMppx.create({ secretKey, @@ -428,7 +428,7 @@ test('e2e: USDC charge with splits (platform fee)', async () => { await fundUsdc(recipientSigner.address, 0); await fundUsdc(platformSigner.address, 0); - const secretKey = 'test-secret-key-splits'; + const secretKey = 'test-secret-key-splits-padded-32bytes'; const splits = [{ recipient: platformSigner.address, amount: '5000', memo: 'platform fee' }]; const splitsMppx = ServerMppx.create({ @@ -493,7 +493,7 @@ test('e2e: native SOL charge with splits', async () => { const platformSigner = await generateKeyPairSigner(); const referrerSigner = await generateKeyPairSigner(); - const secretKey = 'test-secret-key-sol-splits'; + const secretKey = 'test-secret-key-sol-splits-padded-32b'; // Split amounts must be >= rent-exempt minimum (~890_880 lamports) // because the recipient accounts are freshly generated keypairs that // don't yet exist on-chain, and Solana refuses to create system diff --git a/typescript/packages/mpp/src/__tests__/secret-guard.test.ts b/typescript/packages/mpp/src/__tests__/secret-guard.test.ts new file mode 100644 index 00000000..919dadcb --- /dev/null +++ b/typescript/packages/mpp/src/__tests__/secret-guard.test.ts @@ -0,0 +1,38 @@ +/** + * Audit #24: the @solana/mpp boundary rejects a weak HMAC secret key before any + * challenge is signed. mppx@0.5.x accepts any non-empty secret, so the floor is + * enforced here in the `Mppx.create` wrapper (see server/secret-guard.ts). + */ +import { afterEach, expect, test } from 'vitest'; +import { charge } from '../server/Charge.js'; +import { MIN_SECRET_KEY_BYTES, Mppx } from '../server/secret-guard.js'; + +const RECIPIENT = '9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ'; +const methods = () => [charge({ recipient: RECIPIENT, network: 'devnet', rpcUrl: 'https://mock-rpc' })]; + +// A 'a'.repeat(N) string is N UTF-8 bytes, so length maps directly to byte count. +const strong = 'a'.repeat(MIN_SECRET_KEY_BYTES); +const tooShort = 'a'.repeat(MIN_SECRET_KEY_BYTES - 1); + +afterEach(() => { + delete process.env.MPP_SECRET_KEY; +}); + +test('rejects an explicit secret key shorter than the floor', () => { + expect(() => Mppx.create({ methods: methods(), secretKey: tooShort })).toThrow(/at least 32 bytes/); +}); + +test('accepts an explicit secret key at the floor', () => { + delete process.env.MPP_SECRET_KEY; + expect(() => Mppx.create({ methods: methods(), secretKey: strong })).not.toThrow(); +}); + +test('rejects a short MPP_SECRET_KEY from the environment', () => { + process.env.MPP_SECRET_KEY = tooShort; + expect(() => Mppx.create({ methods: methods() })).toThrow(/at least 32 bytes/); +}); + +test('accepts a strong MPP_SECRET_KEY from the environment', () => { + process.env.MPP_SECRET_KEY = strong; + expect(() => Mppx.create({ methods: methods() })).not.toThrow(); +}); diff --git a/typescript/packages/mpp/src/server/index.ts b/typescript/packages/mpp/src/server/index.ts index 76ec8a49..178fb706 100644 --- a/typescript/packages/mpp/src/server/index.ts +++ b/typescript/packages/mpp/src/server/index.ts @@ -12,5 +12,8 @@ export { type SessionStore, } from './session/store.js'; export { subscription } from './Subscription.js'; -// Re-export Mppx so consumers can do: import { Mppx, solana } from '@solana/mpp/server' -export { Mppx, Expires, Store } from 'mppx/server'; +// Re-export Mppx so consumers can do: import { Mppx, solana } from '@solana/mpp/server'. +// Mppx comes from the secret-strength guard (audit #24): a weak HMAC secret is +// rejected at `Mppx.create` before any challenge is signed. +export { Expires, Store } from 'mppx/server'; +export { MIN_SECRET_KEY_BYTES, Mppx } from './secret-guard.js'; diff --git a/typescript/packages/mpp/src/server/secret-guard.ts b/typescript/packages/mpp/src/server/secret-guard.ts new file mode 100644 index 00000000..83d84484 --- /dev/null +++ b/typescript/packages/mpp/src/server/secret-guard.ts @@ -0,0 +1,62 @@ +// Defensive secret-key strength guard for the MPP server (audit #24). +// +// pay-kit's TypeScript SDK delegates HMAC-bound challenge issuance/verification +// to `mppx` via `Mppx.create({ secretKey })`. mppx@0.5.x only rejects an empty +// secret (`if (!secretKey) throw`), so a weak key such as `"key"` is accepted as +// the HMAC-SHA256 key that binds challenge IDs — an attacker who guesses or +// brute-forces a low-entropy key can forge challenges. Per NIST SP 800-107 the +// HMAC-SHA256 key should be at least the hash output size (32 bytes). +// +// Until a length floor lands upstream in mppx, we gate at OUR `@solana/mpp` +// boundary, mirroring `challenge-guard.ts`: this module re-exports the `Mppx` +// namespace with `create` wrapped so a short secret is rejected before any +// challenge is signed. We do NOT fork or vendor mppx, and we touch only the +// secret-strength gate, never the signing or settlement path. + +import { Mppx as MppxBase } from 'mppx/server'; + +/** + * Minimum accepted MPP HMAC secret-key length in bytes (audit #24). + * + * 32 bytes matches the HMAC-SHA256 output size (NIST SP 800-107). Generate a + * conforming key with `openssl rand -base64 32`. + */ +export const MIN_SECRET_KEY_BYTES = 32; + +function assertStrongSecret(config: Parameters[0]): void { + // Mirror mppx's own resolution order: explicit `secretKey`, else the + // `MPP_SECRET_KEY` environment variable. When neither is set we stay silent + // and let mppx raise its own "secret key required" error, so we own only the + // strength check and never change the missing-secret behavior. + const secret = config.secretKey ?? process.env.MPP_SECRET_KEY; + if (secret === undefined) return; + + const byteLength = new TextEncoder().encode(secret).length; + if (byteLength < MIN_SECRET_KEY_BYTES) { + throw new Error( + `MPP secret key must be at least ${MIN_SECRET_KEY_BYTES} bytes (got ${byteLength}); ` + + 'generate one with `openssl rand -base64 32`', + ); + } +} + +/** + * `Mppx.create` with the audit #24 secret-strength gate applied. Identical to + * `mppx`'s `Mppx.create` except a secret shorter than {@link MIN_SECRET_KEY_BYTES} + * bytes (whether passed explicitly or via `MPP_SECRET_KEY`) is rejected. + */ +export const create: typeof MppxBase.create = (config => { + assertStrongSecret(config); + return MppxBase.create(config); +}) as typeof MppxBase.create; + +/** + * The `Mppx` namespace as exposed by `@solana/mpp/server`: the upstream `mppx` + * surface with the secret-strength gate applied to `create`. Use this instead + * of importing `Mppx` directly from `mppx` so a weak HMAC secret is rejected at + * server construction. + */ +export const Mppx: typeof MppxBase = { + ...MppxBase, + create, +}; diff --git a/typescript/packages/pay-kit/src/__tests__/mpp-adapter.test.ts b/typescript/packages/pay-kit/src/__tests__/mpp-adapter.test.ts index 64b3f502..b4a3303e 100644 --- a/typescript/packages/pay-kit/src/__tests__/mpp-adapter.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/mpp-adapter.test.ts @@ -11,7 +11,7 @@ const PLATFORM = 'CXG3Pq3DwZb1HVckhPQbVxiwoNGM3jNGYvC2BSdkj1pK'; async function setup() { const config = await configure({ - mpp: { challengeBindingSecret: 'adapter-test-secret', realm: 'Adapter test' }, + mpp: { challengeBindingSecret: 'adapter-test-secret-padded-to-32b', realm: 'Adapter test' }, operator: { recipient: SELLER, signer: await Signer.generate() }, }); return { adapter: createMppAdapter(config), config };