Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
8 changes: 4 additions & 4 deletions typescript/packages/mpp/src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions typescript/packages/mpp/src/__tests__/secret-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Comment on lines +17 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing tests for "no secret" and conflicting-signal cases

Two behaviours documented in the guard but not exercised by the suite: (1) when neither secretKey nor MPP_SECRET_KEY is set the guard must stay silent (currently only an inline comment guarantees this); (2) when both are set and they differ in strength, only secretKey is evaluated due to ?? short-circuiting — a short env var with a strong explicit key should be accepted, and a strong env var with a short explicit key should be rejected. Without these cases the guard's priority semantics are untested and a silent regression would be easy to miss.

7 changes: 5 additions & 2 deletions typescript/packages/mpp/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
62 changes: 62 additions & 0 deletions typescript/packages/mpp/src/server/secret-guard.ts
Original file line number Diff line number Diff line change
@@ -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<typeof MppxBase.create>[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,
};
Comment on lines +59 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Spread may silently drop non-enumerable methods from mppx

{ ...MppxBase, create } only copies own enumerable properties from MppxBase. If mppx exposes Mppx as a class (where static methods have enumerable: false by spec), every method except the explicitly overridden create will be silently absent from the guarded object at runtime even though TypeScript's typeof MppxBase type says they exist. Current tests may all exercise create-only paths, masking this. A safer alternative is to use Object.create or an explicit Proxy so future mppx additions are automatically delegated.

Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
Loading