Skip to content

Commit 6dec6d2

Browse files
authored
Merge pull request #39 from mpcp-protocol/fix/security-hardening
fix: security hardening — policyHash min, SPA nonce, PolicyGrant signing, cumulative budget, hash binding
2 parents 5fa621e + c298fb2 commit 6dec6d2

26 files changed

Lines changed: 470 additions & 64 deletions

docs/reference/sdk.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,17 @@ import {
3232
## Policy Grant
3333

3434
```typescript
35-
import { createPolicyGrant } from "mpcp-service/sdk";
35+
import { createPolicyGrant, createSignedPolicyGrant } from "mpcp-service/sdk";
3636

3737
const grant = createPolicyGrant({
38-
policyHash: "a1b2c3",
38+
policyHash: "a1b2c3d4e5f6",
3939
allowedRails: ["xrpl", "evm"],
4040
allowedAssets: [{ kind: "IOU", currency: "RLUSD", issuer: "rIssuer" }],
4141
expiresAt: "2030-12-31T23:59:59Z",
4242
});
43+
44+
// Signed (requires MPCP_POLICY_GRANT_SIGNING_PRIVATE_KEY_PEM — returns null if not set)
45+
const signedGrant = createSignedPolicyGrant(grant);
4346
```
4447

4548
## Budget Authorization
@@ -129,6 +132,19 @@ const { result, steps } = verifySettlementWithReport(context);
129132
const { valid, checks } = verifySettlementDetailed(context);
130133
```
131134

135+
## Cumulative Budget Enforcement
136+
137+
When performing multiple payments in a session, pass `cumulativeSpentMinor` to the verification context so the budget check accounts for all prior spending:
138+
139+
```typescript
140+
const result = verifySettlement({
141+
...context,
142+
cumulativeSpentMinor: "5000", // total minor-unit amount spent before this payment
143+
});
144+
```
145+
146+
The session authority MUST maintain this counter. The verifier is stateless and will not track prior payments on its own.
147+
132148
## Environment Variables
133149

134150
| Variable | Purpose |
@@ -139,6 +155,9 @@ const { valid, checks } = verifySettlementDetailed(context);
139155
| MPCP_SPA_SIGNING_PRIVATE_KEY_PEM | Private key for signing SPAs |
140156
| MPCP_SPA_SIGNING_PUBLIC_KEY_PEM | Public key for verifying SPAs |
141157
| MPCP_SPA_SIGNING_KEY_ID | Key identifier (default: mpcp-spa-signing-key-1) |
158+
| MPCP_POLICY_GRANT_SIGNING_PRIVATE_KEY_PEM | Private key for signing PolicyGrants |
159+
| MPCP_POLICY_GRANT_SIGNING_PUBLIC_KEY_PEM | Public key for verifying PolicyGrant signatures (when set, unsigned grants are rejected) |
160+
| MPCP_POLICY_GRANT_SIGNING_KEY_ID | Key identifier (default: mpcp-policy-grant-signing-key-1) |
142161

143162
## See Also
144163

src/cli/bundle.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export function isSettlementBundle(obj: unknown): obj is SettlementBundle {
5050
* Build a minimal PaymentPolicyDecision from SPA authorization.
5151
* Used when bundle omits paymentPolicyDecision.
5252
*/
53-
function decisionFromSpa(spa: SignedPaymentAuthorization): PaymentPolicyDecision {
53+
function decisionFromSpa(spa: SignedPaymentAuthorization): PaymentPolicyDecision & { _synthesized: true } {
5454
const a = spa.authorization;
5555
const quote = {
5656
quoteId: a.quoteId,
@@ -70,6 +70,7 @@ function decisionFromSpa(spa: SignedPaymentAuthorization): PaymentPolicyDecision
7070
asset: a.asset,
7171
chosen: { rail: a.rail, quoteId: a.quoteId },
7272
settlementQuotes: [quote],
73+
_synthesized: true,
7374
};
7475
}
7576

@@ -79,6 +80,9 @@ function decisionFromSpa(spa: SignedPaymentAuthorization): PaymentPolicyDecision
7980
export function bundleToContext(bundle: SettlementBundle): SettlementVerificationContext {
8081
const decision =
8182
bundle.paymentPolicyDecision ?? decisionFromSpa(bundle.spa);
83+
if (!bundle.paymentPolicyDecision) {
84+
console.warn("[mpcp] Warning: paymentPolicyDecision absent — synthesized from SPA. Policy evaluation not verified.");
85+
}
8286
return {
8387
policyGrant: bundle.policyGrant,
8488
signedBudgetAuthorization: bundle.sba,

src/cli/formatReport.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
import type { VerificationReport, VerificationStep } from "../verifier/types.js";
7+
import type { PaymentPolicyDecision } from "../policy-core/types.js";
78

89
const CHECK = "✔";
910
const CROSS = "✗";
@@ -25,10 +26,15 @@ function orderSteps(steps: VerificationStep[]): VerificationStep[] {
2526
return ordered;
2627
}
2728

28-
export function formatVerificationReport(report: VerificationReport): string {
29+
export function formatVerificationReport(report: VerificationReport & { decision?: PaymentPolicyDecision & { _synthesized?: boolean } }): string {
2930
const lines: string[] = [];
3031
const ordered = orderSteps(report.steps);
3132

33+
if (report.decision?._synthesized) {
34+
lines.push("⚠ Policy decision: SYNTHESIZED FROM SPA (policy evaluation not verified)");
35+
lines.push("");
36+
}
37+
3238
for (const step of ordered) {
3339
const icon = step.ok ? CHECK : CROSS;
3440
const msg = step.ok ? step.name : `${step.name}: ${step.reason ?? "failed"}`;
@@ -37,6 +43,12 @@ export function formatVerificationReport(report: VerificationReport): string {
3743

3844
if (ordered.length > 0) lines.push("");
3945

46+
if (report.hashBindingChecked === true) {
47+
lines.push("Hash binding: CHECKED");
48+
} else if (report.hashBindingChecked === false) {
49+
lines.push("Hash binding: NOT CHECKED (Lite Profile — intentHash absent)");
50+
}
51+
4052
if (report.result.valid) {
4153
lines.push("MPCP verification PASSED");
4254
} else {

src/protocol/policyGrant.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import crypto, { createHash } from "node:crypto";
2+
import { canonicalJson } from "../hash/canonicalJson.js";
3+
import type { PolicyGrantLike } from "../verifier/types.js";
4+
5+
export interface SignedPolicyGrant {
6+
grant: PolicyGrantLike;
7+
issuer?: string;
8+
issuerKeyId: string;
9+
signature: string;
10+
}
11+
12+
function getExpectedKeyId(): string {
13+
return process.env.MPCP_POLICY_GRANT_SIGNING_KEY_ID || "mpcp-policy-grant-signing-key-1";
14+
}
15+
16+
function hashGrant(grant: PolicyGrantLike): Buffer {
17+
return createHash("sha256").update("MPCP:PolicyGrant:1.0:" + canonicalJson(grant)).digest();
18+
}
19+
20+
function parseSigningPrivateKey(): crypto.KeyObject | null {
21+
const pem = process.env.MPCP_POLICY_GRANT_SIGNING_PRIVATE_KEY_PEM;
22+
if (!pem) return null;
23+
try {
24+
return crypto.createPrivateKey(pem);
25+
} catch {
26+
return null;
27+
}
28+
}
29+
30+
function parseVerificationPublicKey(): crypto.KeyObject | null {
31+
const pem = process.env.MPCP_POLICY_GRANT_SIGNING_PUBLIC_KEY_PEM;
32+
if (!pem) return null;
33+
try {
34+
return crypto.createPublicKey(pem);
35+
} catch {
36+
return null;
37+
}
38+
}
39+
40+
export function createSignedPolicyGrant(
41+
grant: PolicyGrantLike,
42+
options?: { issuer?: string; keyId?: string },
43+
): SignedPolicyGrant | null {
44+
const privateKey = parseSigningPrivateKey();
45+
if (!privateKey) return null;
46+
47+
const issuerKeyId = options?.keyId ?? getExpectedKeyId();
48+
const signature = crypto.sign(null, hashGrant(grant), privateKey).toString("base64");
49+
const result: SignedPolicyGrant = { grant, issuerKeyId, signature };
50+
if (options?.issuer) result.issuer = options.issuer;
51+
return result;
52+
}
53+
54+
export function verifyPolicyGrantSignature(
55+
envelope: SignedPolicyGrant,
56+
): { ok: true } | { ok: false; reason: "invalid_signature" } {
57+
if (envelope.issuerKeyId !== getExpectedKeyId()) return { ok: false, reason: "invalid_signature" };
58+
59+
const publicKey = parseVerificationPublicKey();
60+
if (!publicKey) return { ok: false, reason: "invalid_signature" };
61+
62+
const isValid = crypto.verify(
63+
null,
64+
hashGrant(envelope.grant),
65+
publicKey,
66+
Buffer.from(envelope.signature, "base64"),
67+
);
68+
if (!isValid) return { ok: false, reason: "invalid_signature" };
69+
return { ok: true };
70+
}

src/protocol/sba.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export function createSignedSessionBudgetAuthorization(input: {
107107

108108
export function verifySignedSessionBudgetAuthorizationForDecision(
109109
envelope: SignedSessionBudgetAuthorization,
110-
input: { sessionId: string; decision: PaymentPolicyDecision; nowMs?: number },
110+
input: { sessionId: string; decision: PaymentPolicyDecision; nowMs?: number; cumulativeSpentMinor?: string },
111111
): { ok: true } | { ok: false; reason: "invalid_signature" | "expired" | "budget_exceeded" | "mismatch" } {
112112
if (envelope.issuerKeyId !== getExpectedKeyId()) return { ok: false, reason: "invalid_signature" };
113113
const publicKey = parseVerificationPublicKey();
@@ -143,7 +143,8 @@ export function verifySignedSessionBudgetAuthorizationForDecision(
143143
if (decision.priceFiat?.amountMinor) {
144144
const budgetMinor = BigInt(authorization.maxAmountMinor);
145145
const decisionMinor = BigInt(decision.priceFiat.amountMinor);
146-
if (decisionMinor > budgetMinor) return { ok: false, reason: "budget_exceeded" };
146+
const alreadySpent = BigInt(input.cumulativeSpentMinor ?? "0");
147+
if (alreadySpent + decisionMinor > budgetMinor) return { ok: false, reason: "budget_exceeded" };
147148
}
148149

149150
const quoteId = decision.chosen?.quoteId;

src/protocol/schema/paymentAuthorization.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const paymentAuthorizationSchema = z.strictObject({
2020
amount: z.string(),
2121
destination: z.string().optional(),
2222
intentHash: intentHashSchema.optional(),
23+
nonce: z.string().optional(),
2324
expiresAt: iso8601DatetimeSchema,
2425
});
2526

src/protocol/schema/shared.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ export const minorUnitSchema = z.number().int().min(0);
1414
/** ISO 4217 currency code (3 uppercase letters) */
1515
export const currencySchema = z.string().length(3).regex(/^[A-Z]{3}$/);
1616

17-
/** Policy hash (hex, 6–64 chars; allows truncated hashes) */
18-
export const policyHashSchema = z.string().regex(/^[a-f0-9]{6,64}$/);
17+
/** Policy hash (hex, 12–64 chars; Full Profile SHOULD use SHA-256 (64 chars)) */
18+
export const policyHashSchema = z.string().regex(/^[a-f0-9]{12,64}$/);
1919

2020
/** SHA256 hex hash (64 chars) */
2121
export const intentHashSchema = z.string().length(64).regex(/^[a-f0-9]{64}$/);

src/protocol/schema/verifySchemas.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export const policyGrantForVerificationSchema = z
1919
expiresAtISO: iso8601DatetimeSchema.optional(),
2020
allowedRails: z.array(railSchema),
2121
allowedAssets: z.array(assetSchema).optional(),
22+
issuer: z.string().optional(),
23+
issuerKeyId: z.string().optional(),
24+
signature: z.string().optional(),
2225
})
2326
.refine((g) => g.expiresAt != null || g.expiresAtISO != null, {
2427
message: "policy_grant_missing_expiry",

src/protocol/spa.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import crypto, { createHash } from "node:crypto";
1+
import crypto, { createHash, randomUUID } from "node:crypto";
22
import type { Asset, PaymentPolicyDecision, Rail, SettlementResult } from "../policy-core/types.js";
33
import { canonicalJson, computeIntentHash } from "../hash/index.js";
44

@@ -14,6 +14,7 @@ export interface PaymentAuthorization {
1414
amount: string;
1515
destination: string;
1616
intentHash?: string;
17+
nonce?: string;
1718
expiresAt: string;
1819
}
1920

@@ -107,12 +108,13 @@ function assetMatches(a: Asset, b: Asset): boolean {
107108
export function createSignedPaymentAuthorization(
108109
sessionId: string,
109110
decision: PaymentPolicyDecision,
110-
options?: { settlementIntent?: unknown; budgetId?: string },
111+
options?: { settlementIntent?: unknown; budgetId?: string; nonce?: string },
111112
): SignedPaymentAuthorization | null {
112113
const authorization = buildAuthorization(sessionId, decision, options);
113114
const privateKey = parseSigningPrivateKey();
114115
if (!authorization || !privateKey) return null;
115116

117+
authorization.nonce = options?.nonce ?? randomUUID();
116118
const signature = crypto.sign(null, hashAuthorization(authorization), privateKey).toString("base64");
117119
return { authorization, issuerKeyId: getExpectedSigningKeyId(), signature };
118120
}

src/sdk/createPolicyGrant.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { randomUUID } from "node:crypto";
22
import type { PolicyGrantLike } from "../verifier/types.js";
3+
export { createSignedPolicyGrant } from "../protocol/policyGrant.js";
4+
export type { SignedPolicyGrant } from "../protocol/policyGrant.js";
35

46
export interface CreatePolicyGrantInput {
57
policyHash: string;

0 commit comments

Comments
 (0)