From c298fb230f5ec13ddcb9d4ee287e97d85ae3489b Mon Sep 17 00:00:00 2001 From: NAOR YUVAL Date: Sat, 14 Mar 2026 11:25:54 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20security=20hardening=20=E2=80=94=20polic?= =?UTF-8?q?yHash=20min,=20SPA=20nonce,=20PolicyGrant=20signing,=20cumulati?= =?UTF-8?q?ve=20budget,=20hash=20binding=20indicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Raise policyHashSchema minimum from 6 to 12 hex chars (Full Profile SHOULD use SHA-256 / 64 chars) - Add SPA nonce field (auto-generated UUID on createSignedPaymentAuthorization) - Add PolicyGrant signing: createSignedPolicyGrant / verifyPolicyGrantSignature; verifier enforces signature when MPCP_POLICY_GRANT_SIGNING_PUBLIC_KEY_PEM is set (opt-in, backward compatible) - Expose cumulativeSpentMinor in SettlementVerificationContext; thread through pipeline so verifier can enforce alreadySpent + currentPayment <= maxAmountMinor - Add hashBindingChecked to VerificationReport and pipeline output; CLI shows "Hash binding: CHECKED" / "NOT CHECKED (Lite Profile)" accordingly - Flag synthesized PaymentPolicyDecision (_synthesized: true) with console.warn + CLI warning - Update all test policyHash fixtures to 12-char minimum; add new tests for all new features - Update docs/reference/sdk.md with createSignedPolicyGrant, cumulative budget note, new env vars Co-Authored-By: Claude Sonnet 4.6 --- docs/reference/sdk.md | 23 +++- src/cli/bundle.ts | 6 +- src/cli/formatReport.ts | 14 ++- src/protocol/policyGrant.ts | 70 +++++++++++ src/protocol/sba.ts | 5 +- src/protocol/schema/paymentAuthorization.ts | 1 + src/protocol/schema/shared.ts | 4 +- src/protocol/schema/verifySchemas.ts | 3 + src/protocol/spa.ts | 6 +- src/sdk/createPolicyGrant.ts | 2 + src/sdk/index.ts | 4 +- src/verifier/types.ts | 6 + src/verifier/verifyBudgetAuthorization.ts | 3 +- src/verifier/verifyPipeline.ts | 7 +- src/verifier/verifyPolicyGrant.ts | 21 ++++ src/verifier/verifySettlement.ts | 5 +- test/cli/formatReport.test.ts | 26 ++++ test/cli/verify.test.ts | 18 +-- test/protocol/conformance.test.ts | 8 +- test/protocol/policyGrant.test.ts | 118 +++++++++++++++++++ test/protocol/sba.test.ts | 45 +++++++ test/schema/artifact-schemas.test.ts | 8 +- test/sdk/sdk.test.ts | 18 +-- test/service/serviceApi.test.ts | 4 +- test/verify/verify.test.ts | 99 +++++++++++++--- test/verify/verifySettlementDetailed.test.ts | 10 +- 26 files changed, 470 insertions(+), 64 deletions(-) create mode 100644 src/protocol/policyGrant.ts create mode 100644 test/protocol/policyGrant.test.ts diff --git a/docs/reference/sdk.md b/docs/reference/sdk.md index 7c17ccf0..c08f2c7a 100644 --- a/docs/reference/sdk.md +++ b/docs/reference/sdk.md @@ -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 @@ -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 | @@ -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 diff --git a/src/cli/bundle.ts b/src/cli/bundle.ts index 35c77f46..c09f6d65 100644 --- a/src/cli/bundle.ts +++ b/src/cli/bundle.ts @@ -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, @@ -70,6 +70,7 @@ function decisionFromSpa(spa: SignedPaymentAuthorization): PaymentPolicyDecision asset: a.asset, chosen: { rail: a.rail, quoteId: a.quoteId }, settlementQuotes: [quote], + _synthesized: true, }; } @@ -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, diff --git a/src/cli/formatReport.ts b/src/cli/formatReport.ts index 353cdf2c..29f4725d 100644 --- a/src/cli/formatReport.ts +++ b/src/cli/formatReport.ts @@ -4,6 +4,7 @@ */ import type { VerificationReport, VerificationStep } from "../verifier/types.js"; +import type { PaymentPolicyDecision } from "../policy-core/types.js"; const CHECK = "✔"; const CROSS = "✗"; @@ -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"}`; @@ -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 { diff --git a/src/protocol/policyGrant.ts b/src/protocol/policyGrant.ts new file mode 100644 index 00000000..937029b6 --- /dev/null +++ b/src/protocol/policyGrant.ts @@ -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 }; +} diff --git a/src/protocol/sba.ts b/src/protocol/sba.ts index 44ad8cf5..2871d95d 100644 --- a/src/protocol/sba.ts +++ b/src/protocol/sba.ts @@ -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(); @@ -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; diff --git a/src/protocol/schema/paymentAuthorization.ts b/src/protocol/schema/paymentAuthorization.ts index cbbae999..d5b7fc0c 100644 --- a/src/protocol/schema/paymentAuthorization.ts +++ b/src/protocol/schema/paymentAuthorization.ts @@ -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, }); diff --git a/src/protocol/schema/shared.ts b/src/protocol/schema/shared.ts index 318e508e..a5c4d861 100644 --- a/src/protocol/schema/shared.ts +++ b/src/protocol/schema/shared.ts @@ -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}$/); diff --git a/src/protocol/schema/verifySchemas.ts b/src/protocol/schema/verifySchemas.ts index 3b1245be..5b736a0d 100644 --- a/src/protocol/schema/verifySchemas.ts +++ b/src/protocol/schema/verifySchemas.ts @@ -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", diff --git a/src/protocol/spa.ts b/src/protocol/spa.ts index a0ac155c..83b8182f 100644 --- a/src/protocol/spa.ts +++ b/src/protocol/spa.ts @@ -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"; @@ -14,6 +14,7 @@ export interface PaymentAuthorization { amount: string; destination: string; intentHash?: string; + nonce?: string; expiresAt: string; } @@ -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 }; } diff --git a/src/sdk/createPolicyGrant.ts b/src/sdk/createPolicyGrant.ts index 75e28e20..bad85d68 100644 --- a/src/sdk/createPolicyGrant.ts +++ b/src/sdk/createPolicyGrant.ts @@ -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; diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 9486a928..89c9e400 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -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 { diff --git a/src/verifier/types.ts b/src/verifier/types.ts index a57313ae..14085e96 100644 --- a/src/verifier/types.ts +++ b/src/verifier/types.ts @@ -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 */ @@ -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; } diff --git a/src/verifier/verifyBudgetAuthorization.ts b/src/verifier/verifyBudgetAuthorization.ts index 7dadc56b..bc29fd3f 100644 --- a/src/verifier/verifyBudgetAuthorization.ts +++ b/src/verifier/verifyBudgetAuthorization.ts @@ -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); @@ -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 }; diff --git a/src/verifier/verifyPipeline.ts b/src/verifier/verifyPipeline.ts index 43935694..75061f4c 100644 --- a/src/verifier/verifyPipeline.ts +++ b/src/verifier/verifyPipeline.ts @@ -36,6 +36,7 @@ export interface VerificationPipelineOutput { result: VerificationResult; steps: VerificationStep[]; checks: VerificationCheck[]; + hashBindingChecked?: boolean; // true if intentHash was present and verified } function parseArtifact( @@ -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 --- @@ -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, { @@ -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; diff --git a/src/verifier/verifyPolicyGrant.ts b/src/verifier/verifyPolicyGrant.ts index 6c23b24f..55c25f6e 100644 --- a/src/verifier/verifyPolicyGrant.ts +++ b/src/verifier/verifyPolicyGrant.ts @@ -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). @@ -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; + 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 }; } diff --git a/src/verifier/verifySettlement.ts b/src/verifier/verifySettlement.ts index 67d9412f..3cffe3a4 100644 --- a/src/verifier/verifySettlement.ts +++ b/src/verifier/verifySettlement.ts @@ -28,8 +28,8 @@ export function verifySettlement( export function verifySettlementWithReport( ctx: SettlementVerificationContext, ): VerificationReport { - const { result, steps } = runVerificationPipeline(ctx); - return { result, steps }; + const { result, steps, hashBindingChecked } = runVerificationPipeline(ctx); + return { result, steps, hashBindingChecked }; } /** @@ -72,6 +72,7 @@ export function verifySettlementWithReportSafe( return { result: { valid: false, reason: `verification_error: ${message}` }, steps: [{ name: "Verification.error", ok: false, reason: message }], + hashBindingChecked: false, }; } } diff --git a/test/cli/formatReport.test.ts b/test/cli/formatReport.test.ts index 5e289aee..c5e07751 100644 --- a/test/cli/formatReport.test.ts +++ b/test/cli/formatReport.test.ts @@ -12,12 +12,14 @@ describe("formatVerificationReport", () => { { name: "SignedPaymentAuthorization.valid", ok: true }, { name: "SettlementIntent.intentHash", ok: true }, ], + hashBindingChecked: true, }; const out = formatVerificationReport(report); expect(out).toContain("✔ SettlementIntent.intentHash"); expect(out).toContain("✔ SignedPaymentAuthorization.valid"); expect(out).toContain("✔ SignedBudgetAuthorization.valid"); expect(out).toContain("✔ PolicyGrant.valid"); + expect(out).toContain("Hash binding: CHECKED"); expect(out).toContain("MPCP verification PASSED"); }); @@ -29,12 +31,14 @@ describe("formatVerificationReport", () => { { name: "SignedBudgetAuthorization.valid", ok: true }, { name: "SignedPaymentAuthorization.valid", ok: true }, ], + hashBindingChecked: false, }; const out = formatVerificationReport(report); expect(out).toContain("✔ PolicyGrant.valid"); expect(out).toContain("✔ SignedBudgetAuthorization.valid"); expect(out).toContain("✔ SignedPaymentAuthorization.valid"); expect(out).not.toContain("SettlementIntent"); + expect(out).toContain("Hash binding: NOT CHECKED (Lite Profile — intentHash absent)"); expect(out).toContain("MPCP verification PASSED"); }); @@ -42,10 +46,32 @@ describe("formatVerificationReport", () => { const report: VerificationReport = { result: { valid: false, reason: "policy_grant_expired", artifact: "policyGrant" }, steps: [{ name: "PolicyGrant.valid", ok: false, reason: "policy_grant_expired" }], + hashBindingChecked: false, }; const out = formatVerificationReport(report); expect(out).toContain("✗ PolicyGrant.valid"); expect(out).toContain("policy_grant_expired"); expect(out).toContain("MPCP verification FAILED"); }); + + it("does not show hash binding line when hashBindingChecked is undefined", () => { + const report: VerificationReport = { + result: { valid: true }, + steps: [{ name: "PolicyGrant.valid", ok: true }], + }; + const out = formatVerificationReport(report); + expect(out).not.toContain("Hash binding"); + }); + + it("shows synthesized decision warning when decision._synthesized is true", () => { + const report: VerificationReport & { decision?: { _synthesized?: boolean } } = { + result: { valid: true }, + steps: [{ name: "PolicyGrant.valid", ok: true }], + hashBindingChecked: false, + decision: { _synthesized: true }, + }; + const out = formatVerificationReport(report); + expect(out).toContain("⚠ Policy decision: SYNTHESIZED FROM SPA"); + expect(out).toContain("policy evaluation not verified"); + }); }); diff --git a/test/cli/verify.test.ts b/test/cli/verify.test.ts index c616e102..a538ca84 100644 --- a/test/cli/verify.test.ts +++ b/test/cli/verify.test.ts @@ -38,7 +38,7 @@ const verificationNowIso = new Date(Date.now() - 1000).toISOString(); const baseGrant: PolicyGrantLike = { grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", expiresAt: futureExpiry, allowedRails: ["xrpl"], allowedAssets: [{ kind: "IOU", currency: "RLUSD", issuer: "rIssuer" }], @@ -46,7 +46,7 @@ const baseGrant: PolicyGrantLike = { const baseDecision: PaymentPolicyDecision = { decisionId: "dec-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", action: "ALLOW", reasons: ["OK"], expiresAtISO: futureExpiry, @@ -90,7 +90,7 @@ describe("runVerify", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -137,7 +137,7 @@ describe("runVerify", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -206,7 +206,7 @@ describe("runVerify", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -247,7 +247,7 @@ describe("runVerify", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -287,7 +287,7 @@ describe("runVerify", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -328,7 +328,7 @@ describe("runVerify", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -371,7 +371,7 @@ describe("runVerify", () => { const sba = createSignedSessionBudgetAuthorization({ sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], diff --git a/test/protocol/conformance.test.ts b/test/protocol/conformance.test.ts index 6af99df3..5266b57e 100644 --- a/test/protocol/conformance.test.ts +++ b/test/protocol/conformance.test.ts @@ -72,7 +72,7 @@ const verificationNowIso = new Date(Date.now() - 1000).toISOString(); const baseGrant: PolicyGrantLike = { grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", expiresAt: futureExpiry, allowedRails: ["xrpl"], allowedAssets: [{ kind: "IOU", currency: "RLUSD", issuer: "rIssuer" }], @@ -80,7 +80,7 @@ const baseGrant: PolicyGrantLike = { const baseDecision: PaymentPolicyDecision = { decisionId: "dec-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", action: "ALLOW", reasons: ["OK"], expiresAtISO: futureExpiry, @@ -112,7 +112,7 @@ const defaultSbaConfig = { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"] as const, @@ -204,7 +204,7 @@ describe("Reference Implementation Conformance", () => { it("fails when policy hash mismatch", () => { setupBothKeys(); - const sba = makeSba({ policyHash: "deadbeef", allowedAssets: [], destinationAllowlist: [] }); + const sba = makeSba({ policyHash: "deadbeefcafe", allowedAssets: [], destinationAllowlist: [] }); expect(verifyBudgetAuthorization(sba, baseGrant, baseDecision)).toMatchObject({ valid: false, reason: "budget_policy_hash_mismatch", diff --git a/test/protocol/policyGrant.test.ts b/test/protocol/policyGrant.test.ts new file mode 100644 index 00000000..6ed45124 --- /dev/null +++ b/test/protocol/policyGrant.test.ts @@ -0,0 +1,118 @@ +import crypto from "node:crypto"; +import { afterEach, describe, expect, it } from "vitest"; +import { createSignedPolicyGrant, verifyPolicyGrantSignature } from "../../src/protocol/policyGrant.js"; +import type { PolicyGrantLike } from "../../src/verifier/types.js"; +import { verifyPolicyGrant } from "../../src/verifier/verifyPolicyGrant.js"; + +const ORIGINAL_ENV = { + privateKey: process.env.MPCP_POLICY_GRANT_SIGNING_PRIVATE_KEY_PEM, + publicKey: process.env.MPCP_POLICY_GRANT_SIGNING_PUBLIC_KEY_PEM, + keyId: process.env.MPCP_POLICY_GRANT_SIGNING_KEY_ID, +}; + +afterEach(() => { + process.env.MPCP_POLICY_GRANT_SIGNING_PRIVATE_KEY_PEM = ORIGINAL_ENV.privateKey; + process.env.MPCP_POLICY_GRANT_SIGNING_PUBLIC_KEY_PEM = ORIGINAL_ENV.publicKey; + process.env.MPCP_POLICY_GRANT_SIGNING_KEY_ID = ORIGINAL_ENV.keyId; + delete process.env.MPCP_POLICY_GRANT_SIGNING_PRIVATE_KEY_PEM; + delete process.env.MPCP_POLICY_GRANT_SIGNING_PUBLIC_KEY_PEM; + delete process.env.MPCP_POLICY_GRANT_SIGNING_KEY_ID; +}); + +function setupKeys() { + const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519"); + process.env.MPCP_POLICY_GRANT_SIGNING_PRIVATE_KEY_PEM = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + process.env.MPCP_POLICY_GRANT_SIGNING_PUBLIC_KEY_PEM = publicKey.export({ type: "spki", format: "pem" }).toString(); + process.env.MPCP_POLICY_GRANT_SIGNING_KEY_ID = "mpcp-policy-grant-signing-key-1"; +} + +const baseGrant: PolicyGrantLike = { + grantId: "grant-pg-1", + policyHash: "a1b2c3d4e5f6", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + allowedRails: ["xrpl"], + allowedAssets: [{ kind: "IOU", currency: "RLUSD", issuer: "rIssuer" }], +}; + +describe("createSignedPolicyGrant + verifyPolicyGrantSignature", () => { + it("returns null when private key env var not set", () => { + const result = createSignedPolicyGrant(baseGrant); + expect(result).toBeNull(); + }); + + it("creates a signed policy grant and verifies it", () => { + setupKeys(); + const signed = createSignedPolicyGrant(baseGrant); + expect(signed).not.toBeNull(); + expect(signed!.signature).toBeDefined(); + expect(signed!.issuerKeyId).toBe("mpcp-policy-grant-signing-key-1"); + expect(signed!.grant).toEqual(baseGrant); + + const result = verifyPolicyGrantSignature(signed!); + expect(result).toEqual({ ok: true }); + }); + + it("fails verification with tampered grant payload", () => { + setupKeys(); + const signed = createSignedPolicyGrant(baseGrant); + expect(signed).not.toBeNull(); + + const tampered = { ...signed!, grant: { ...signed!.grant, policyHash: "deadbeefcafe" } }; + const result = verifyPolicyGrantSignature(tampered); + expect(result).toEqual({ ok: false, reason: "invalid_signature" }); + }); + + it("fails verification with wrong key ID", () => { + setupKeys(); + const signed = createSignedPolicyGrant(baseGrant); + expect(signed).not.toBeNull(); + + const wrongKeyId = { ...signed!, issuerKeyId: "wrong-key-id" }; + const result = verifyPolicyGrantSignature(wrongKeyId); + expect(result).toEqual({ ok: false, reason: "invalid_signature" }); + }); + + it("fails verification when public key env var not set", () => { + setupKeys(); + const signed = createSignedPolicyGrant(baseGrant); + expect(signed).not.toBeNull(); + delete process.env.MPCP_POLICY_GRANT_SIGNING_PUBLIC_KEY_PEM; + + const result = verifyPolicyGrantSignature(signed!); + expect(result).toEqual({ ok: false, reason: "invalid_signature" }); + }); +}); + +describe("verifyPolicyGrant with signature enforcement", () => { + it("passes without signature when public key env var not set", () => { + const result = verifyPolicyGrant(baseGrant); + expect(result).toEqual({ valid: true }); + }); + + it("fails when public key env var set but grant has no signature", () => { + setupKeys(); + const result = verifyPolicyGrant(baseGrant); + expect(result).toMatchObject({ valid: false, reason: "invalid_policy_grant_signature", artifact: "policyGrant" }); + }); + + it("passes when public key env var set and grant is properly signed", () => { + setupKeys(); + const signed = createSignedPolicyGrant(baseGrant); + expect(signed).not.toBeNull(); + + const grantWithSig = { ...baseGrant, issuerKeyId: signed!.issuerKeyId, signature: signed!.signature }; + const result = verifyPolicyGrant(grantWithSig); + expect(result).toEqual({ valid: true }); + }); + + it("fails when public key env var set and grant has invalid signature", () => { + setupKeys(); + const grantWithBadSig = { + ...baseGrant, + issuerKeyId: "mpcp-policy-grant-signing-key-1", + signature: "aW52YWxpZHNpZ25hdHVyZQ==", // invalid base64 signature + }; + const result = verifyPolicyGrant(grantWithBadSig); + expect(result).toMatchObject({ valid: false, reason: "invalid_policy_grant_signature", artifact: "policyGrant" }); + }); +}); diff --git a/test/protocol/sba.test.ts b/test/protocol/sba.test.ts index 55aae506..0f534544 100644 --- a/test/protocol/sba.test.ts +++ b/test/protocol/sba.test.ts @@ -147,6 +147,51 @@ describe("SBA createSignedSessionBudgetAuthorization + verifySignedSessionBudget expect(verification).toEqual({ ok: false, reason: "budget_exceeded" }); }); + it("fails on cumulative budget overflow even when single payment fits", () => { + setupKeys(); + const envelope = createSignedSessionBudgetAuthorization({ + sessionId: "11111111-1111-4111-8111-111111111111", + vehicleId: "1234567", + grantId: "grant-ph-1", + policyHash: "a1b2c3d4e5f6", + currency: "USD", + maxAmountMinor: "3000", + allowedRails: ["stripe"], + allowedAssets: [], + destinationAllowlist: [], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }); + expect(envelope).not.toBeNull(); + + const decision: PaymentPolicyDecision = { + decisionId: "dec-1", + policyHash: "a1b2c3d4e5f6", + action: "ALLOW", + reasons: ["OK"], + expiresAtISO: new Date(Date.now() + 60_000).toISOString(), + rail: "stripe", + priceFiat: { amountMinor: "1500", currency: "USD" }, // fits alone but not with prior spending + chosen: { rail: "stripe", quoteId: "q1" }, + settlementQuotes: [ + { + quoteId: "q1", + rail: "stripe", + amount: { amount: "1500", decimals: 2 }, + destination: "", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + ], + }; + + // Without cumulative context — passes (1500 <= 3000) + const noCtx = verifySignedSessionBudgetAuthorizationForDecision(envelope!, { sessionId: envelope!.authorization.sessionId, decision }); + expect(noCtx).toEqual({ ok: true }); + + // With prior spending that pushes total over budget — fails + const withCtx = verifySignedSessionBudgetAuthorizationForDecision(envelope!, { sessionId: envelope!.authorization.sessionId, decision, cumulativeSpentMinor: "2000" }); + expect(withCtx).toEqual({ ok: false, reason: "budget_exceeded" }); + }); + it("fails on unsupported scope (DAY not SESSION)", () => { setupKeys(); const envelope = createSignedSessionBudgetAuthorization({ diff --git a/test/schema/artifact-schemas.test.ts b/test/schema/artifact-schemas.test.ts index 6d7d4581..a373b1e6 100644 --- a/test/schema/artifact-schemas.test.ts +++ b/test/schema/artifact-schemas.test.ts @@ -14,7 +14,7 @@ import { const validPolicyGrant = { version: "1.0", grantId: "grant_7ab3", - policyHash: "9f3a0d", + policyHash: "9f3a0d1e2b4c", subjectId: "vehicle_1284", scope: "SESSION", allowedRails: ["xrpl", "stripe"], @@ -28,7 +28,7 @@ const validBudgetAuthorization = { grantId: "grant-1", sessionId: "sess_456", vehicleId: "veh_001", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", minorUnit: 2, budgetScope: "SESSION", @@ -43,7 +43,7 @@ const validPaymentAuthorization = { version: "1.0", decisionId: "dec_123", sessionId: "sess_456", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", budgetId: "550e8400-e29b-41d4-a716-446655440000", quoteId: "quote_789", rail: "xrpl", @@ -320,7 +320,7 @@ describe("FleetPolicyAuthorization schema", () => { const validArtifactBundle = { policyGrant: { grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", expiresAt: "2030-12-31T23:59:59Z", allowedRails: ["xrpl"], allowedAssets: [{ kind: "IOU", currency: "RLUSD", issuer: "rIssuer" }], diff --git a/test/sdk/sdk.test.ts b/test/sdk/sdk.test.ts index 35e5ddb2..a55123af 100644 --- a/test/sdk/sdk.test.ts +++ b/test/sdk/sdk.test.ts @@ -16,11 +16,11 @@ describe("createPolicyGrant", () => { it("creates valid grant with required fields", () => { const grant = createPolicyGrant({ - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", allowedRails: ["xrpl"], expiresAt, }); - expect(grant.policyHash).toBe("a1b2c3"); + expect(grant.policyHash).toBe("a1b2c3d4e5f6"); expect(grant.allowedRails).toEqual(["xrpl"]); expect(grant.expiresAt).toBe(expiresAt); expect(grant.grantId).toBeDefined(); @@ -30,7 +30,7 @@ describe("createPolicyGrant", () => { it("uses provided grantId when given", () => { const grant = createPolicyGrant({ - policyHash: "deadbeef", + policyHash: "deadbeefcafe", allowedRails: ["evm"], expiresAt, grantId: "my-grant-123", @@ -40,7 +40,7 @@ describe("createPolicyGrant", () => { it("includes allowedAssets when provided", () => { const grant = createPolicyGrant({ - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", allowedRails: ["xrpl"], expiresAt, allowedAssets: [{ kind: "IOU", currency: "RLUSD", issuer: "rIssuer" }], @@ -57,7 +57,7 @@ describe("createBudgetAuthorization", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "v1", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -68,7 +68,7 @@ describe("createBudgetAuthorization", () => { expect(auth.version).toBe("1.0"); expect(auth.sessionId).toBe("11111111-1111-4111-8111-111111111111"); expect(auth.vehicleId).toBe("v1"); - expect(auth.policyHash).toBe("a1b2c3"); + expect(auth.policyHash).toBe("a1b2c3d4e5f6"); expect(auth.maxAmountMinor).toBe("3000"); expect(auth.budgetScope).toBe("SESSION"); expect(auth.minorUnit).toBe(2); @@ -162,7 +162,7 @@ describe("SDK artifact integration", () => { const nowISO = new Date(Date.now() - 1000).toISOString(); const policyGrant = createPolicyGrant({ - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", allowedRails: ["xrpl"], allowedAssets: [{ kind: "IOU", currency: "RLUSD", issuer: "rIssuer" }], expiresAt, @@ -172,7 +172,7 @@ describe("SDK artifact integration", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: policyGrant.grantId, - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -184,7 +184,7 @@ describe("SDK artifact integration", () => { const paymentPolicyDecision = { decisionId: "dec-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", action: "ALLOW" as const, reasons: ["OK"], expiresAtISO: expiresAt, diff --git a/test/service/serviceApi.test.ts b/test/service/serviceApi.test.ts index 363b9c9f..fed6684c 100644 --- a/test/service/serviceApi.test.ts +++ b/test/service/serviceApi.test.ts @@ -13,7 +13,7 @@ describe("service API", () => { describe("issueBudget", () => { it("returns null when signing key not configured", () => { const policyGrant = createPolicyGrant({ - policyHash: "abc", + policyHash: "abcabcabcabc", allowedRails: ["xrpl"], expiresAt: "2030-12-31T23:59:59Z", }); @@ -44,7 +44,7 @@ describe("service API", () => { try { const policyGrant = createPolicyGrant({ - policyHash: "abc", + policyHash: "abcabcabcabc", allowedRails: ["xrpl"], expiresAt: "2030-12-31T23:59:59Z", }); diff --git a/test/verify/verify.test.ts b/test/verify/verify.test.ts index dbe81618..e204c5fc 100644 --- a/test/verify/verify.test.ts +++ b/test/verify/verify.test.ts @@ -60,7 +60,7 @@ const verificationNowIso = new Date(Date.now() - 1000).toISOString(); const baseGrant: PolicyGrantLike = { grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", expiresAt: futureExpiry, allowedRails: ["xrpl"], allowedAssets: [{ kind: "IOU", currency: "RLUSD", issuer: "rIssuer" }], @@ -68,7 +68,7 @@ const baseGrant: PolicyGrantLike = { const baseDecision: PaymentPolicyDecision = { decisionId: "dec-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", action: "ALLOW", reasons: ["OK"], expiresAtISO: futureExpiry, @@ -134,7 +134,7 @@ describe("verifyBudgetAuthorization", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -160,7 +160,7 @@ describe("verifyBudgetAuthorization", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "deadbeef", + policyHash: "deadbeefcafe", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -181,7 +181,7 @@ describe("verifyPaymentAuthorization", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -212,7 +212,7 @@ describe("verifyPaymentAuthorization", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -321,7 +321,7 @@ describe("verifySettlement", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -359,7 +359,7 @@ describe("verifySettlement", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -393,7 +393,7 @@ describe("verifySettlement", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -426,7 +426,7 @@ describe("verifySettlement", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -459,7 +459,7 @@ describe("verifySettlement", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -497,7 +497,7 @@ describe("verifySettlement", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -528,7 +528,7 @@ describe("verifySettlement", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -571,7 +571,7 @@ describe("verifySettlement", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -606,7 +606,7 @@ describe("verifySettlement", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -632,4 +632,73 @@ describe("verifySettlement", () => { expect(report.steps).toHaveLength(1); expect(report.steps[0]).toMatchObject({ name: "PolicyGrant.valid", ok: false }); }); + + it("hashBindingChecked is false when SPA has no intentHash (Lite Profile)", () => { + setupBothKeys(); + const sba = createSignedSessionBudgetAuthorization({ + sessionId: "11111111-1111-4111-8111-111111111111", + vehicleId: "1234567", + grantId: "grant-1", + policyHash: "a1b2c3d4e5f6", + currency: "USD", + maxAmountMinor: "3000", + allowedRails: ["xrpl"], + allowedAssets: [{ kind: "IOU", currency: "RLUSD", issuer: "rIssuer" }], + destinationAllowlist: ["rDestination"], + expiresAt: futureExpiry, + }); + const spa = createSignedPaymentAuthorization( + "11111111-1111-4111-8111-111111111111", + baseDecision, + { budgetId: sba!.authorization.budgetId }, + ); + const report = verifySettlementWithReport({ + policyGrant: baseGrant, + signedBudgetAuthorization: sba!, + signedPaymentAuthorization: spa!, + settlement: baseSettlement, + paymentPolicyDecision: baseDecision, + decisionId: "dec-1", + }); + expect(report.result).toEqual({ valid: true }); + expect(report.hashBindingChecked).toBe(false); + }); + + it("hashBindingChecked is true when SPA has intentHash (Full Profile)", () => { + setupBothKeys(); + const intent = { + rail: "xrpl", + amount: "19440000", + destination: "rDestination", + asset: { kind: "IOU", currency: "RLUSD", issuer: "rIssuer" }, + }; + const sba = createSignedSessionBudgetAuthorization({ + sessionId: "11111111-1111-4111-8111-111111111111", + vehicleId: "1234567", + grantId: "grant-1", + policyHash: "a1b2c3d4e5f6", + currency: "USD", + maxAmountMinor: "3000", + allowedRails: ["xrpl"], + allowedAssets: [{ kind: "IOU", currency: "RLUSD", issuer: "rIssuer" }], + destinationAllowlist: ["rDestination"], + expiresAt: futureExpiry, + }); + const spa = createSignedPaymentAuthorization( + "11111111-1111-4111-8111-111111111111", + baseDecision, + { settlementIntent: intent, budgetId: sba!.authorization.budgetId }, + ); + const report = verifySettlementWithReport({ + policyGrant: baseGrant, + signedBudgetAuthorization: sba!, + signedPaymentAuthorization: spa!, + settlement: baseSettlement, + paymentPolicyDecision: baseDecision, + decisionId: "dec-1", + settlementIntent: intent, + }); + expect(report.result).toEqual({ valid: true }); + expect(report.hashBindingChecked).toBe(true); + }); }); diff --git a/test/verify/verifySettlementDetailed.test.ts b/test/verify/verifySettlementDetailed.test.ts index fd94f91b..e4766047 100644 --- a/test/verify/verifySettlementDetailed.test.ts +++ b/test/verify/verifySettlementDetailed.test.ts @@ -33,7 +33,7 @@ const verificationNowIso = new Date(Date.now() - 1000).toISOString(); const baseGrant: PolicyGrantLike = { grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", expiresAt: futureExpiry, allowedRails: ["xrpl"], allowedAssets: [{ kind: "IOU", currency: "RLUSD", issuer: "rIssuer" }], @@ -41,7 +41,7 @@ const baseGrant: PolicyGrantLike = { const baseDecision: PaymentPolicyDecision = { decisionId: "dec-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", action: "ALLOW", reasons: ["OK"], expiresAtISO: futureExpiry, @@ -91,7 +91,7 @@ describe("verifySettlementDetailed", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -131,7 +131,7 @@ describe("verifySettlementDetailed", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"], @@ -175,7 +175,7 @@ describe("verifySettlementDetailed", () => { sessionId: "11111111-1111-4111-8111-111111111111", vehicleId: "1234567", grantId: "grant-1", - policyHash: "a1b2c3", + policyHash: "a1b2c3d4e5f6", currency: "USD", maxAmountMinor: "3000", allowedRails: ["xrpl"],