Skip to content
Merged
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
23 changes: 21 additions & 2 deletions docs/reference/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,17 @@ import {
## Policy Grant

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

const grant = createPolicyGrant({
policyHash: "a1b2c3",
policyHash: "a1b2c3d4e5f6",
allowedRails: ["xrpl", "evm"],
allowedAssets: [{ kind: "IOU", currency: "RLUSD", issuer: "rIssuer" }],
expiresAt: "2030-12-31T23:59:59Z",
});

// Signed (requires MPCP_POLICY_GRANT_SIGNING_PRIVATE_KEY_PEM — returns null if not set)
const signedGrant = createSignedPolicyGrant(grant);
```

## Budget Authorization
Expand Down Expand Up @@ -129,6 +132,19 @@ const { result, steps } = verifySettlementWithReport(context);
const { valid, checks } = verifySettlementDetailed(context);
```

## Cumulative Budget Enforcement

When performing multiple payments in a session, pass `cumulativeSpentMinor` to the verification context so the budget check accounts for all prior spending:

```typescript
const result = verifySettlement({
...context,
cumulativeSpentMinor: "5000", // total minor-unit amount spent before this payment
});
```

The session authority MUST maintain this counter. The verifier is stateless and will not track prior payments on its own.

## Environment Variables

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

## See Also

Expand Down
6 changes: 5 additions & 1 deletion src/cli/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function isSettlementBundle(obj: unknown): obj is SettlementBundle {
* Build a minimal PaymentPolicyDecision from SPA authorization.
* Used when bundle omits paymentPolicyDecision.
*/
function decisionFromSpa(spa: SignedPaymentAuthorization): PaymentPolicyDecision {
function decisionFromSpa(spa: SignedPaymentAuthorization): PaymentPolicyDecision & { _synthesized: true } {
const a = spa.authorization;
const quote = {
quoteId: a.quoteId,
Expand All @@ -70,6 +70,7 @@ function decisionFromSpa(spa: SignedPaymentAuthorization): PaymentPolicyDecision
asset: a.asset,
chosen: { rail: a.rail, quoteId: a.quoteId },
settlementQuotes: [quote],
_synthesized: true,
};
}

Expand All @@ -79,6 +80,9 @@ function decisionFromSpa(spa: SignedPaymentAuthorization): PaymentPolicyDecision
export function bundleToContext(bundle: SettlementBundle): SettlementVerificationContext {
const decision =
bundle.paymentPolicyDecision ?? decisionFromSpa(bundle.spa);
if (!bundle.paymentPolicyDecision) {
console.warn("[mpcp] Warning: paymentPolicyDecision absent — synthesized from SPA. Policy evaluation not verified.");
}
return {
policyGrant: bundle.policyGrant,
signedBudgetAuthorization: bundle.sba,
Expand Down
14 changes: 13 additions & 1 deletion src/cli/formatReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

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

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

export function formatVerificationReport(report: VerificationReport): string {
export function formatVerificationReport(report: VerificationReport & { decision?: PaymentPolicyDecision & { _synthesized?: boolean } }): string {
const lines: string[] = [];
const ordered = orderSteps(report.steps);

if (report.decision?._synthesized) {
lines.push("⚠ Policy decision: SYNTHESIZED FROM SPA (policy evaluation not verified)");
lines.push("");
}

for (const step of ordered) {
const icon = step.ok ? CHECK : CROSS;
const msg = step.ok ? step.name : `${step.name}: ${step.reason ?? "failed"}`;
Expand All @@ -37,6 +43,12 @@ export function formatVerificationReport(report: VerificationReport): string {

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

if (report.hashBindingChecked === true) {
lines.push("Hash binding: CHECKED");
} else if (report.hashBindingChecked === false) {
lines.push("Hash binding: NOT CHECKED (Lite Profile — intentHash absent)");
}

if (report.result.valid) {
lines.push("MPCP verification PASSED");
} else {
Expand Down
70 changes: 70 additions & 0 deletions src/protocol/policyGrant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import crypto, { createHash } from "node:crypto";
import { canonicalJson } from "../hash/canonicalJson.js";
import type { PolicyGrantLike } from "../verifier/types.js";

export interface SignedPolicyGrant {
grant: PolicyGrantLike;
issuer?: string;
issuerKeyId: string;
signature: string;
}

function getExpectedKeyId(): string {
return process.env.MPCP_POLICY_GRANT_SIGNING_KEY_ID || "mpcp-policy-grant-signing-key-1";
}

function hashGrant(grant: PolicyGrantLike): Buffer {
return createHash("sha256").update("MPCP:PolicyGrant:1.0:" + canonicalJson(grant)).digest();
}

function parseSigningPrivateKey(): crypto.KeyObject | null {
const pem = process.env.MPCP_POLICY_GRANT_SIGNING_PRIVATE_KEY_PEM;
if (!pem) return null;
try {
return crypto.createPrivateKey(pem);
} catch {
return null;
}
}

function parseVerificationPublicKey(): crypto.KeyObject | null {
const pem = process.env.MPCP_POLICY_GRANT_SIGNING_PUBLIC_KEY_PEM;
if (!pem) return null;
try {
return crypto.createPublicKey(pem);
} catch {
return null;
}
}

export function createSignedPolicyGrant(
grant: PolicyGrantLike,
options?: { issuer?: string; keyId?: string },
): SignedPolicyGrant | null {
const privateKey = parseSigningPrivateKey();
if (!privateKey) return null;

const issuerKeyId = options?.keyId ?? getExpectedKeyId();
const signature = crypto.sign(null, hashGrant(grant), privateKey).toString("base64");
const result: SignedPolicyGrant = { grant, issuerKeyId, signature };
if (options?.issuer) result.issuer = options.issuer;
return result;
}

export function verifyPolicyGrantSignature(
envelope: SignedPolicyGrant,
): { ok: true } | { ok: false; reason: "invalid_signature" } {
if (envelope.issuerKeyId !== getExpectedKeyId()) return { ok: false, reason: "invalid_signature" };

const publicKey = parseVerificationPublicKey();
if (!publicKey) return { ok: false, reason: "invalid_signature" };

const isValid = crypto.verify(
null,
hashGrant(envelope.grant),
publicKey,
Buffer.from(envelope.signature, "base64"),
);
if (!isValid) return { ok: false, reason: "invalid_signature" };
return { ok: true };
}
5 changes: 3 additions & 2 deletions src/protocol/sba.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export function createSignedSessionBudgetAuthorization(input: {

export function verifySignedSessionBudgetAuthorizationForDecision(
envelope: SignedSessionBudgetAuthorization,
input: { sessionId: string; decision: PaymentPolicyDecision; nowMs?: number },
input: { sessionId: string; decision: PaymentPolicyDecision; nowMs?: number; cumulativeSpentMinor?: string },
): { ok: true } | { ok: false; reason: "invalid_signature" | "expired" | "budget_exceeded" | "mismatch" } {
if (envelope.issuerKeyId !== getExpectedKeyId()) return { ok: false, reason: "invalid_signature" };
const publicKey = parseVerificationPublicKey();
Expand Down Expand Up @@ -143,7 +143,8 @@ export function verifySignedSessionBudgetAuthorizationForDecision(
if (decision.priceFiat?.amountMinor) {
const budgetMinor = BigInt(authorization.maxAmountMinor);
const decisionMinor = BigInt(decision.priceFiat.amountMinor);
if (decisionMinor > budgetMinor) return { ok: false, reason: "budget_exceeded" };
const alreadySpent = BigInt(input.cumulativeSpentMinor ?? "0");
if (alreadySpent + decisionMinor > budgetMinor) return { ok: false, reason: "budget_exceeded" };
}

const quoteId = decision.chosen?.quoteId;
Expand Down
1 change: 1 addition & 0 deletions src/protocol/schema/paymentAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const paymentAuthorizationSchema = z.strictObject({
amount: z.string(),
destination: z.string().optional(),
intentHash: intentHashSchema.optional(),
nonce: z.string().optional(),
expiresAt: iso8601DatetimeSchema,
});

Expand Down
4 changes: 2 additions & 2 deletions src/protocol/schema/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export const minorUnitSchema = z.number().int().min(0);
/** ISO 4217 currency code (3 uppercase letters) */
export const currencySchema = z.string().length(3).regex(/^[A-Z]{3}$/);

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

/** SHA256 hex hash (64 chars) */
export const intentHashSchema = z.string().length(64).regex(/^[a-f0-9]{64}$/);
Expand Down
3 changes: 3 additions & 0 deletions src/protocol/schema/verifySchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export const policyGrantForVerificationSchema = z
expiresAtISO: iso8601DatetimeSchema.optional(),
allowedRails: z.array(railSchema),
allowedAssets: z.array(assetSchema).optional(),
issuer: z.string().optional(),
issuerKeyId: z.string().optional(),
signature: z.string().optional(),
})
.refine((g) => g.expiresAt != null || g.expiresAtISO != null, {
message: "policy_grant_missing_expiry",
Expand Down
6 changes: 4 additions & 2 deletions src/protocol/spa.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import crypto, { createHash } from "node:crypto";
import crypto, { createHash, randomUUID } from "node:crypto";
import type { Asset, PaymentPolicyDecision, Rail, SettlementResult } from "../policy-core/types.js";
import { canonicalJson, computeIntentHash } from "../hash/index.js";

Expand All @@ -14,6 +14,7 @@ export interface PaymentAuthorization {
amount: string;
destination: string;
intentHash?: string;
nonce?: string;
expiresAt: string;
}

Expand Down Expand Up @@ -107,12 +108,13 @@ function assetMatches(a: Asset, b: Asset): boolean {
export function createSignedPaymentAuthorization(
sessionId: string,
decision: PaymentPolicyDecision,
options?: { settlementIntent?: unknown; budgetId?: string },
options?: { settlementIntent?: unknown; budgetId?: string; nonce?: string },
): SignedPaymentAuthorization | null {
const authorization = buildAuthorization(sessionId, decision, options);
const privateKey = parseSigningPrivateKey();
if (!authorization || !privateKey) return null;

authorization.nonce = options?.nonce ?? randomUUID();
const signature = crypto.sign(null, hashAuthorization(authorization), privateKey).toString("base64");
return { authorization, issuerKeyId: getExpectedSigningKeyId(), signature };
}
Expand Down
2 changes: 2 additions & 0 deletions src/sdk/createPolicyGrant.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { randomUUID } from "node:crypto";
import type { PolicyGrantLike } from "../verifier/types.js";
export { createSignedPolicyGrant } from "../protocol/policyGrant.js";
export type { SignedPolicyGrant } from "../protocol/policyGrant.js";

export interface CreatePolicyGrantInput {
policyHash: string;
Expand Down
4 changes: 2 additions & 2 deletions src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export {
enforcePayment,
} from "../policy-core/evaluate.js";

export { createPolicyGrant } from "./createPolicyGrant.js";
export type { CreatePolicyGrantInput } from "./createPolicyGrant.js";
export { createPolicyGrant, createSignedPolicyGrant } from "./createPolicyGrant.js";
export type { CreatePolicyGrantInput, SignedPolicyGrant } from "./createPolicyGrant.js";

export { createBudgetAuthorization } from "./createBudgetAuthorization.js";
export type {
Expand Down
6 changes: 6 additions & 0 deletions src/verifier/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export interface VerificationStep {
export interface VerificationReport {
result: VerificationResult;
steps: VerificationStep[];
/** Whether intentHash binding was verified. False when SPA has no intentHash (Lite Profile). */
hashBindingChecked?: boolean;
}

/** Check phase for ordering: schema → linkage → hash → policy */
Expand Down Expand Up @@ -73,4 +75,8 @@ export interface SettlementVerificationContext {
settlementIntent?: unknown;
decisionId: string;
nowMs?: number;
/** Running total of minor-unit amounts spent in this session before this payment.
* When provided, budget check becomes: cumulativeSpentMinor + currentAmount <= maxAmountMinor.
* Session authority MUST maintain this counter for correct cumulative enforcement. */
cumulativeSpentMinor?: string;
}
3 changes: 2 additions & 1 deletion src/verifier/verifyBudgetAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function verifyBudgetAuthorization(
envelope: unknown,
grant: unknown,
decision: PaymentPolicyDecision,
options?: { nowMs?: number },
options?: { nowMs?: number; cumulativeSpentMinor?: string },
): VerificationResult {
// 1. Schema validation
const sbaResult = signedBudgetAuthorizationSchema.safeParse(envelope);
Expand Down Expand Up @@ -63,6 +63,7 @@ export function verifyBudgetAuthorization(
sessionId: envelopeParsed.authorization.sessionId,
decision,
nowMs: options?.nowMs,
cumulativeSpentMinor: options?.cumulativeSpentMinor,
});
if (!result.ok) return { valid: false, reason: result.reason, artifact: "signedBudgetAuthorization" };
return { valid: true };
Expand Down
7 changes: 6 additions & 1 deletion src/verifier/verifyPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface VerificationPipelineOutput {
result: VerificationResult;
steps: VerificationStep[];
checks: VerificationCheck[];
hashBindingChecked?: boolean; // true if intentHash was present and verified
}

function parseArtifact(
Expand Down Expand Up @@ -95,16 +96,19 @@ export function runVerificationPipeline(
): VerificationPipelineOutput {
const steps: VerificationStep[] = [];
const checks: VerificationCheck[] = [];
let hashBindingChecked = false;

const out = (): VerificationPipelineOutput => ({
result: { valid: true },
steps,
checks: sortChecksByPhase(checks),
hashBindingChecked,
});
const fail = (result: VerificationResult): VerificationPipelineOutput => ({
result,
steps,
checks: sortChecksByPhase(checks),
hashBindingChecked,
});

// --- Schema validation ---
Expand Down Expand Up @@ -162,7 +166,7 @@ export function runVerificationPipeline(
ctx.signedBudgetAuthorization,
ctx.policyGrant,
ctx.paymentPolicyDecision,
{ nowMs: ctx.nowMs },
{ nowMs: ctx.nowMs, cumulativeSpentMinor: ctx.cumulativeSpentMinor },
);
if (!pushStep(steps, "SignedBudgetAuthorization.valid", budgetResult)) {
pushCheck(checks, "SignedBudgetAuthorization", "valid", "linkage", false, {
Expand All @@ -174,6 +178,7 @@ export function runVerificationPipeline(

// --- Intent hash ---
if (ctx.settlementIntent && ctx.signedPaymentAuthorization.authorization.intentHash) {
hashBindingChecked = true;
const intentParsed = settlementIntentForVerificationSchema.safeParse(ctx.settlementIntent);
if (intentParsed.success) {
const auth = ctx.signedPaymentAuthorization.authorization;
Expand Down
21 changes: 21 additions & 0 deletions src/verifier/verifyPolicyGrant.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { PolicyGrantLike, VerificationResult } from "./types.js";
import { policyGrantForVerificationSchema } from "../protocol/schema/verifySchemas.js";
import { verifyPolicyGrantSignature } from "../protocol/policyGrant.js";
import type { SignedPolicyGrant } from "../protocol/policyGrant.js";

/**
* Verify a policy grant is valid (not expired).
Expand Down Expand Up @@ -35,5 +37,24 @@ export function verifyPolicyGrant(
if (expiryMs <= nowMs) {
return { valid: false, reason: "policy_grant_expired", artifact: "policyGrant" };
}

// If signing public key is configured, require and verify signature
if (process.env.MPCP_POLICY_GRANT_SIGNING_PUBLIC_KEY_PEM) {
if (!g.issuerKeyId || !g.signature) {
return { valid: false, reason: "invalid_policy_grant_signature", artifact: "policyGrant" };
}
// Strip signing envelope fields so hash matches what was originally signed
const { issuerKeyId: _kid, signature: _sig, issuer: _iss, ...coreGrant } = g as Record<string, unknown>;
const sigResult = verifyPolicyGrantSignature({
grant: coreGrant as unknown as PolicyGrantLike,
issuerKeyId: g.issuerKeyId,
signature: g.signature,
...(g.issuer ? { issuer: g.issuer } : {}),
} as SignedPolicyGrant);
if (!sigResult.ok) {
return { valid: false, reason: "invalid_policy_grant_signature", artifact: "policyGrant" };
}
}

return { valid: true };
}
Loading