Skip to content

Commit 422ce4e

Browse files
naoryNAOR YUVAL
andauthored
feat(secop): Tier 1 — SECOP-aligned verifier, schemas, and crypto (#51)
* feat(secop): Tier 1 — SECOP-aligned verifier, schemas, and crypto Schemas / types: - PolicyGrantLike + verification schema: add velocityLimit, maxSpend, offlineMaxCumulativePayment, destinationAllowlist, merchantCredentialIssuer, activeGrantCredentialIssuer, gatewayCredentialIssuer, subjectCredentialIssuer, operatorId - CreatePolicyGrantInput: all new fields wired through - PaymentPolicyDecision: add purpose field (SECOP 1a) - FPA schema: allowedAssets accepts structured Asset objects - SettlementVerificationContext: gatewayAddress, purpose, expectedActorId, budgetIdStore, clockDriftToleranceMs, grantCumulativeSpentMinor, fleetPolicyAuthorization Verifier pipeline (SECOP checks): - Step 0a: optional FleetPolicyAuthorization verification (10c) - authorizedGateway check (6b) - allowedPurposes enforcement (1a) - budgetId replay prevention via BudgetIdStore interface (4a / 3b) - actorId binding validation (5a-c) - grant-level budgetMinor ceiling check (10a) - grant-level destinationAllowlist check (1b) - Clock drift tolerance (default 5 min) on all expiry checks (3d) New modules: - src/verifier/budgetIdStore.ts — BudgetIdStore + InMemoryBudgetIdStore - src/verifier/verifyFpa.ts — FPA signature, expiry, intersection - src/hash/lowS.ts — secp256k1 low-S normalization (8b) - src/hash/policyHash.ts — domain-separated policy document hash (10a) - src/protocol/jwksResolver.ts — HTTPS JWKS fetch with active/alg (2a, 8a) Tests updated for clock drift tolerance (clockDriftToleranceMs: 0 for strict expiry tests). All 191 tests pass. Made-with: Cursor * fix: review findings — forward clockDriftToleranceMs to SBA, sync-only BudgetIdStore, cleanup cast Bug 1: clockDriftToleranceMs was not forwarded from verifyBudgetAuthorization to verifySignedSessionBudgetAuthorizationForDecision, causing the SBA expiry check to silently use the 5-minute default regardless of pipeline config. Bug 2: BudgetIdStore.markSeen returned boolean | Promise<boolean> but the pipeline never awaited, so async stores always failed as replays. The interface is now sync-only with guidance for async backing stores. Cleanup: removed unnecessary type assertion in verifyFpa.ts — Zod already provides the correct type after safeParse. Adds 62 tests covering all new SECOP checks: authorizedGateway, allowedPurposes, budgetId replay, actorId binding, budgetMinor ceiling, destinationAllowlist, FPA verification, clock drift propagation, lowS normalization, and policyHash. Made-with: Cursor --------- Co-authored-by: NAOR YUVAL <naoryuval@NAORs-MacBook-Air.local>
1 parent ce463d9 commit 422ce4e

25 files changed

Lines changed: 1474 additions & 75 deletions

src/hash/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export { canonicalJson } from "./canonicalJson.js";
22
export { sha256Hex } from "./sha256.js";
3+
export { hashPolicyDocument } from "./policyHash.js";
4+
export { isLowS, ensureLowS } from "./lowS.js";

src/hash/lowS.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* secp256k1 low-S normalization (SECOP 8b).
3+
*
4+
* ECDSA signatures over secp256k1 are malleable: given (r, s) a valid signature,
5+
* (r, n − s) is also valid. MPCP requires the canonical "low-S" form where
6+
* s ≤ n/2. This matches Bitcoin's standardness rule (BIP 62) and prevents
7+
* third-party malleability.
8+
*/
9+
10+
const SECP256K1_ORDER = BigInt(
11+
"0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141",
12+
);
13+
const SECP256K1_HALF_ORDER = SECP256K1_ORDER >> 1n;
14+
15+
/**
16+
* Return true if the S value in a DER-encoded secp256k1 ECDSA signature
17+
* is in the low half (s <= n/2).
18+
*/
19+
export function isLowS(derSignature: Buffer): boolean {
20+
const s = extractSFromDer(derSignature);
21+
if (s === null) return false;
22+
return s <= SECP256K1_HALF_ORDER;
23+
}
24+
25+
/**
26+
* If S > n/2, replace it with n − S (canonical low-S form).
27+
* Returns a new DER buffer or the original if already canonical.
28+
*/
29+
export function ensureLowS(derSignature: Buffer): Buffer {
30+
const s = extractSFromDer(derSignature);
31+
if (s === null) return derSignature;
32+
if (s <= SECP256K1_HALF_ORDER) return derSignature;
33+
34+
const newS = SECP256K1_ORDER - s;
35+
return replaceSInDer(derSignature, newS);
36+
}
37+
38+
function extractSFromDer(der: Buffer): bigint | null {
39+
if (der.length < 8 || der[0] !== 0x30) return null;
40+
let offset = 2;
41+
if (der[1] & 0x80) offset += (der[1] & 0x7f);
42+
43+
// R
44+
if (der[offset] !== 0x02) return null;
45+
const rLen = der[offset + 1]!;
46+
offset += 2 + rLen;
47+
48+
// S
49+
if (offset >= der.length || der[offset] !== 0x02) return null;
50+
const sLen = der[offset + 1]!;
51+
const sBytes = der.subarray(offset + 2, offset + 2 + sLen);
52+
return bufToBigInt(sBytes);
53+
}
54+
55+
function bufToBigInt(buf: Uint8Array): bigint {
56+
let result = 0n;
57+
for (const byte of buf) result = (result << 8n) | BigInt(byte);
58+
return result;
59+
}
60+
61+
function bigIntToBuf(n: bigint): Buffer {
62+
let hex = n.toString(16);
63+
if (hex.length % 2) hex = "0" + hex;
64+
const buf = Buffer.from(hex, "hex");
65+
if (buf[0]! >= 0x80) return Buffer.concat([Buffer.from([0x00]), buf]);
66+
return buf;
67+
}
68+
69+
function replaceSInDer(der: Buffer, newS: bigint): Buffer {
70+
let offset = 2;
71+
if (der[1]! & 0x80) offset += (der[1]! & 0x7f);
72+
73+
// R header + body
74+
const rLen = der[offset + 1]!;
75+
const rPart = der.subarray(offset, offset + 2 + rLen);
76+
77+
// Build new S TLV
78+
const sBuf = bigIntToBuf(newS);
79+
const sTlv = Buffer.concat([Buffer.from([0x02, sBuf.length]), sBuf]);
80+
81+
// Reassemble
82+
const inner = Buffer.concat([rPart, sTlv]);
83+
const outerLen = inner.length;
84+
if (outerLen <= 127) {
85+
return Buffer.concat([Buffer.from([0x30, outerLen]), inner]);
86+
}
87+
const lenBytes = outerLen <= 0xff ? Buffer.from([0x81, outerLen])
88+
: Buffer.from([0x82, (outerLen >> 8) & 0xff, outerLen & 0xff]);
89+
return Buffer.concat([Buffer.from([0x30]), lenBytes, inner]);
90+
}

src/hash/policyHash.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Domain-separated policy document hashing (SECOP 10a / spec alignment).
3+
*
4+
* Per PolicyGrant.md § Policy Hashing:
5+
* policyHash = SHA256("MPCP:Policy:<version>:" || canonicalJson(policyDocument))
6+
*/
7+
8+
import { createHash } from "node:crypto";
9+
import { canonicalJson } from "./canonicalJson.js";
10+
11+
/**
12+
* Compute the spec-compliant policy hash for a policy document.
13+
*
14+
* @param policyDocument - The structured policy document object
15+
* @param version - Protocol version (default "1.0")
16+
* @returns Lowercase hex SHA-256 digest
17+
*/
18+
export function hashPolicyDocument(policyDocument: unknown, version = "1.0"): string {
19+
const prefix = `MPCP:Policy:${version}:`;
20+
return createHash("sha256")
21+
.update(prefix + canonicalJson(policyDocument))
22+
.digest("hex");
23+
}

src/policy-core/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ export interface PaymentPolicyDecision {
177177
chosen?: { rail: Rail; quoteId: string };
178178
createdAt?: string;
179179
maxSpend?: { perTxMinor?: string; perSessionMinor?: string; perDayMinor?: string };
180+
/** Payment purpose / merchant category for allowedPurposes enforcement. */
181+
purpose?: string;
180182
}
181183

182184
export interface SettlementResult {

src/protocol/jwksResolver.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* HTTPS JWKS key resolution (SECOP 2a / 8a).
3+
*
4+
* Step 3 of the MPCP 3-step key resolution algorithm:
5+
* 1. Trust Bundle (offline)
6+
* 2. Pre-configured key (env var)
7+
* 3. HTTPS well-known endpoint ← this module
8+
*
9+
* Fetches /.well-known/mpcp-keys.json from the issuer's domain, filters by
10+
* `active` field (SECOP 2a), and optionally validates the `alg` field (SECOP 8a).
11+
*/
12+
13+
import type { KeyWithKid } from "./trustBundle.js";
14+
15+
export interface JwksResolverOptions {
16+
/** Timeout in ms for the HTTPS fetch. Default 5000. */
17+
timeoutMs?: number;
18+
/** Required algorithm — reject keys whose `alg` does not match. */
19+
requiredAlg?: string;
20+
}
21+
22+
export interface JwksDocument {
23+
keys: (KeyWithKid & { active?: boolean; alg?: string })[];
24+
}
25+
26+
/**
27+
* Fetch the JWKS document from an issuer's well-known endpoint.
28+
*
29+
* @param issuer - Domain or did:web (only the domain part is used)
30+
* @returns Parsed JWKS document, or null on failure
31+
*/
32+
export async function fetchJwks(
33+
issuer: string,
34+
options?: JwksResolverOptions,
35+
): Promise<JwksDocument | null> {
36+
const domain = issuerToDomain(issuer);
37+
if (!domain) return null;
38+
39+
const url = `https://${domain}/.well-known/mpcp-keys.json`;
40+
const timeoutMs = options?.timeoutMs ?? 5000;
41+
42+
try {
43+
const controller = new AbortController();
44+
const timer = setTimeout(() => controller.abort(), timeoutMs);
45+
const res = await fetch(url, { signal: controller.signal });
46+
clearTimeout(timer);
47+
if (!res.ok) return null;
48+
return (await res.json()) as JwksDocument;
49+
} catch {
50+
return null;
51+
}
52+
}
53+
54+
/**
55+
* Resolve a specific key from a remote JWKS endpoint.
56+
*
57+
* Filters by:
58+
* - `kid` match
59+
* - `active !== false` (SECOP 2a: inactive keys are excluded)
60+
* - `alg` match when `options.requiredAlg` is set (SECOP 8a)
61+
*/
62+
export async function resolveFromJwks(
63+
issuer: string,
64+
issuerKeyId: string,
65+
options?: JwksResolverOptions,
66+
): Promise<KeyWithKid | null> {
67+
const doc = await fetchJwks(issuer, options);
68+
if (!doc?.keys) return null;
69+
70+
for (const key of doc.keys) {
71+
if (key.kid !== issuerKeyId) continue;
72+
if (key.active === false) continue;
73+
if (options?.requiredAlg && key.alg && key.alg !== options.requiredAlg) continue;
74+
return key;
75+
}
76+
return null;
77+
}
78+
79+
function issuerToDomain(issuer: string): string | null {
80+
if (issuer.startsWith("did:web:")) {
81+
return issuer.slice("did:web:".length).replace(/%3A/gi, ":");
82+
}
83+
if (issuer.startsWith("https://")) {
84+
try {
85+
return new URL(issuer).hostname;
86+
} catch {
87+
return null;
88+
}
89+
}
90+
if (issuer.includes(".") && !issuer.includes(" ")) {
91+
return issuer;
92+
}
93+
return null;
94+
}

src/protocol/sba.ts

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

113113
export function verifySignedSessionBudgetAuthorizationForDecision(
114114
envelope: SignedSessionBudgetAuthorization,
115-
input: { sessionId: string; decision: PaymentPolicyDecision; nowMs?: number; cumulativeSpentMinor?: string; trustBundles?: TrustBundle[] },
115+
input: { sessionId: string; decision: PaymentPolicyDecision; nowMs?: number; cumulativeSpentMinor?: string; trustBundles?: TrustBundle[]; clockDriftToleranceMs?: number },
116116
): { ok: true } | { ok: false; reason: "invalid_signature" | "expired" | "budget_exceeded" | "mismatch" } {
117117
// Key resolution per spec (3-step algorithm):
118118
// 1. Trust Bundle — offline JWK lookup by issuer + issuerKeyId
@@ -145,7 +145,8 @@ export function verifySignedSessionBudgetAuthorizationForDecision(
145145
if (!isValid) return { ok: false, reason: "invalid_signature" };
146146

147147
const nowMs = typeof input.nowMs === "number" ? input.nowMs : Date.now();
148-
if (Date.parse(envelope.authorization.expiresAt) <= nowMs) return { ok: false, reason: "expired" };
148+
const driftMs = input.clockDriftToleranceMs ?? 300_000;
149+
if (Date.parse(envelope.authorization.expiresAt) <= nowMs - driftMs) return { ok: false, reason: "expired" };
149150

150151
const { authorization } = envelope;
151152
const { decision } = input;

src/protocol/schema/fleetPolicyAuthorization.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from "zod";
22
import {
33
railSchema,
4+
assetSchema,
45
mpcpVersionSchema,
56
currencySchema,
67
minorUnitSchema,
@@ -19,7 +20,7 @@ export const fleetPolicyAuthorizationPayloadSchema = z.strictObject({
1920
minorUnit: minorUnitSchema,
2021
maxAmountMinor: z.string(),
2122
allowedRails: z.array(railSchema),
22-
allowedAssets: z.array(z.string()),
23+
allowedAssets: z.array(z.union([assetSchema, z.string()])),
2324
allowedOperators: z.array(z.string()),
2425
geoFence: z.array(z.string()).optional(),
2526
expiresAt: iso8601DatetimeSchema,

src/protocol/schema/verifySchemas.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,19 @@ import {
66
iso8601DatetimeSchema,
77
} from "./shared.js";
88

9+
const velocityLimitSchema = z.strictObject({
10+
maxPayments: z.number().int().min(1),
11+
windowSeconds: z.number().int().min(1),
12+
});
13+
14+
const maxSpendSchema = z.object({
15+
perTxMinor: z.string().optional(),
16+
perSessionMinor: z.string().optional(),
17+
perDayMinor: z.string().optional(),
18+
});
19+
920
/**
10-
* Minimal policy grant shape for verification.
21+
* Policy grant shape for verification (SECOP-aligned).
1122
* Accepts expiresAt or expiresAtISO (at least one required).
1223
*/
1324
export const policyGrantForVerificationSchema = z
@@ -30,6 +41,19 @@ export const policyGrantForVerificationSchema = z
3041
authorizedGateway: z.string().optional(),
3142
offlineMaxSinglePayment: z.string().regex(/^\d+$/).optional(),
3243
offlineMaxSinglePaymentCurrency: z.string().optional(),
44+
offlineMaxCumulativePayment: z.string().regex(/^\d+$/).optional(),
45+
offlineMaxCumulativePaymentCurrency: z.string().optional(),
46+
velocityLimit: velocityLimitSchema.optional(),
47+
maxSpend: maxSpendSchema.optional(),
48+
destinationAllowlist: z.array(z.string()).optional(),
49+
merchantCredentialIssuer: z.string().optional(),
50+
merchantCredentialType: z.string().optional(),
51+
activeGrantCredentialIssuer: z.string().optional(),
52+
gatewayCredentialIssuer: z.string().optional(),
53+
gatewayCredentialType: z.string().optional(),
54+
subjectCredentialIssuer: z.string().optional(),
55+
subjectCredentialType: z.string().optional(),
56+
operatorId: z.string().optional(),
3357
})
3458
.refine((g) => g.expiresAt != null || g.expiresAtISO != null, {
3559
message: "policy_grant_missing_expiry",

src/sdk/createPolicyGrant.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,25 @@ export interface CreatePolicyGrantInput {
1212
revocationEndpoint?: string;
1313
allowedPurposes?: string[];
1414
anchorRef?: string;
15-
/** Total authorized spend in minor units (e.g. drops for XRP). Signed by the PA. */
1615
budgetMinor?: string;
17-
/** Currency code for budgetMinor (e.g. "XRP"). Required when budgetMinor is set. */
1816
budgetCurrency?: string;
19-
/** On-chain escrow locking budgetMinor. Format: "xrpl:escrow:{account}:{sequence}". Signed by the PA. */
2017
budgetEscrowRef?: string;
21-
/** Address of the only gateway authorized to spend against this grant's escrow. Rail-specific format. PA-signed. */
2218
authorizedGateway?: string;
23-
/** PA-signed per-transaction cap for offline merchant acceptance, in minor units (see offlineMaxSinglePaymentCurrency). */
2419
offlineMaxSinglePayment?: string;
25-
/** Currency code for offlineMaxSinglePayment (e.g. "XRP"). */
2620
offlineMaxSinglePaymentCurrency?: string;
21+
offlineMaxCumulativePayment?: string;
22+
offlineMaxCumulativePaymentCurrency?: string;
23+
velocityLimit?: { maxPayments: number; windowSeconds: number };
24+
maxSpend?: { perTxMinor?: string; perSessionMinor?: string; perDayMinor?: string };
25+
destinationAllowlist?: string[];
26+
merchantCredentialIssuer?: string;
27+
merchantCredentialType?: string;
28+
activeGrantCredentialIssuer?: string;
29+
gatewayCredentialIssuer?: string;
30+
gatewayCredentialType?: string;
31+
subjectCredentialIssuer?: string;
32+
subjectCredentialType?: string;
33+
operatorId?: string;
2734
}
2835

2936
/**
@@ -33,20 +40,30 @@ export interface CreatePolicyGrantInput {
3340
* @returns Policy grant compatible with verifyPolicyGrant / verifySettlement
3441
*/
3542
export function createPolicyGrant(input: CreatePolicyGrantInput): PolicyGrantLike {
36-
return {
43+
const grant: PolicyGrantLike = {
3744
grantId: input.grantId ?? randomUUID(),
3845
policyHash: input.policyHash,
3946
expiresAt: input.expiresAt,
4047
allowedRails: input.allowedRails,
4148
allowedAssets: input.allowedAssets ?? [],
42-
...(input.revocationEndpoint ? { revocationEndpoint: input.revocationEndpoint } : {}),
43-
...(input.allowedPurposes ? { allowedPurposes: input.allowedPurposes } : {}),
44-
...(input.anchorRef ? { anchorRef: input.anchorRef } : {}),
45-
...(input.budgetMinor ? { budgetMinor: input.budgetMinor } : {}),
46-
...(input.budgetCurrency ? { budgetCurrency: input.budgetCurrency } : {}),
47-
...(input.budgetEscrowRef ? { budgetEscrowRef: input.budgetEscrowRef } : {}),
48-
...(input.authorizedGateway ? { authorizedGateway: input.authorizedGateway } : {}),
49-
...(input.offlineMaxSinglePayment ? { offlineMaxSinglePayment: input.offlineMaxSinglePayment } : {}),
50-
...(input.offlineMaxSinglePaymentCurrency ? { offlineMaxSinglePaymentCurrency: input.offlineMaxSinglePaymentCurrency } : {}),
5149
};
50+
const optionalFields: Array<keyof CreatePolicyGrantInput> = [
51+
"revocationEndpoint", "allowedPurposes", "anchorRef",
52+
"budgetMinor", "budgetCurrency", "budgetEscrowRef", "authorizedGateway",
53+
"offlineMaxSinglePayment", "offlineMaxSinglePaymentCurrency",
54+
"offlineMaxCumulativePayment", "offlineMaxCumulativePaymentCurrency",
55+
"velocityLimit", "maxSpend", "destinationAllowlist",
56+
"merchantCredentialIssuer", "merchantCredentialType",
57+
"activeGrantCredentialIssuer",
58+
"gatewayCredentialIssuer", "gatewayCredentialType",
59+
"subjectCredentialIssuer", "subjectCredentialType",
60+
"operatorId",
61+
];
62+
for (const key of optionalFields) {
63+
const val = input[key];
64+
if (val !== undefined && val !== null) {
65+
(grant as Record<string, unknown>)[key] = val;
66+
}
67+
}
68+
return grant;
5269
}

src/sdk/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export type {
4040
PolicyDocumentCustody,
4141
XrplPolicyAnchorPreparation,
4242
} from "../anchor/index.js";
43-
export { canonicalJson } from "../hash/index.js";
43+
export { canonicalJson, hashPolicyDocument, isLowS, ensureLowS } from "../hash/index.js";
4444

4545
export { signTrustBundle, verifyTrustBundle, resolveFromTrustBundle } from "../protocol/trustBundle.js";
4646
export type { TrustBundle, TrustBundleIssuerEntry, UnsignedTrustBundle, KeyWithKid } from "../protocol/trustBundle.js";
@@ -54,3 +54,7 @@ export {
5454
verifySettlementWithReportSafe,
5555
verifySettlementDetailedSafe,
5656
} from "../verifier/verifySettlement.js";
57+
export { verifyFleetPolicyAuthorization } from "../verifier/verifyFpa.js";
58+
export { InMemoryBudgetIdStore } from "../verifier/budgetIdStore.js";
59+
export type { BudgetIdStore } from "../verifier/budgetIdStore.js";
60+
export { fetchJwks, resolveFromJwks } from "../protocol/jwksResolver.js";

0 commit comments

Comments
 (0)