From a6de11f37ef26c415384a7884e2edb7da1cc3a64 Mon Sep 17 00:00:00 2001 From: Fabiano Cruz Date: Sun, 5 Jul 2026 09:44:40 -0300 Subject: [PATCH 1/4] feat(cli): offline V3 mandate codec + did:web resolver Port the V3 canonical signing-string format from the private mandate package into the public CLI, dependency-free (node:crypto + stdlib only): - decodeToken: split the base64url JSON presentation envelope (signature / agent_sig / issuer_sig / kid) from the signed fields. - reconstructSigningString: the 14-field V3 canonical string (V2's 12 + principal_kyc_ref + agent_kid), byte-locked against the copied canonical.v3.fixture.json freeze. - verifyEd25519: SPKI-wrap a raw 32-byte key (RFC 8410) and verify with node:crypto; returns false, never throws, on malformed input. - did.ts: did:web resolution (standard URL first, api.codespar.dev encoded fallback) extracting Ed25519 keys from JsonWebKey2020 docs. Tests (offline only): byte-lock, agent+issuer verify pass, one-byte tamper fails, wrong key fails, did:web URL mapping. Co-Authored-By: Claude Fable 5 --- packages/cli/src/__tests__/did.test.ts | 39 +++ .../fixtures/canonical.v3.fixture.json | 28 +++ .../cli/src/__tests__/mandate-codec.test.ts | 174 ++++++++++++++ packages/cli/src/did.ts | 132 +++++++++++ packages/cli/src/mandate-codec.ts | 224 ++++++++++++++++++ 5 files changed, 597 insertions(+) create mode 100644 packages/cli/src/__tests__/did.test.ts create mode 100644 packages/cli/src/__tests__/fixtures/canonical.v3.fixture.json create mode 100644 packages/cli/src/__tests__/mandate-codec.test.ts create mode 100644 packages/cli/src/did.ts create mode 100644 packages/cli/src/mandate-codec.ts diff --git a/packages/cli/src/__tests__/did.test.ts b/packages/cli/src/__tests__/did.test.ts new file mode 100644 index 0000000..386316a --- /dev/null +++ b/packages/cli/src/__tests__/did.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { apiFallbackUrl, didWebToUrl } from "../did.js"; + +describe("did:web URL mapping", () => { + it("maps a bare domain DID to /.well-known/did.json", () => { + expect(didWebToUrl("did:web:id.codespar.dev")).toBe( + "https://id.codespar.dev/.well-known/did.json", + ); + }); + + it("maps a path DID to //did.json", () => { + expect(didWebToUrl("did:web:id.codespar.dev:org_demo:a1")).toBe( + "https://id.codespar.dev/org_demo/a1/did.json", + ); + }); + + it("decodes a %3A port in the domain segment", () => { + expect(didWebToUrl("did:web:localhost%3A8080:org:a1")).toBe( + "https://localhost:8080/org/a1/did.json", + ); + }); + + it("returns null for a non-did:web input", () => { + expect(didWebToUrl("did:key:z6Mk...")).toBeNull(); + expect(didWebToUrl("not-a-did")).toBeNull(); + }); + + it("builds the api.codespar.dev fallback with the full DID url-encoded", () => { + expect(apiFallbackUrl("did:web:id.codespar.dev:org_demo:a1", "https://api.codespar.dev")).toBe( + "https://api.codespar.dev/v1/agents/did%3Aweb%3Aid.codespar.dev%3Aorg_demo%3Aa1/did.json", + ); + }); + + it("trims a trailing slash on the base URL for the fallback", () => { + expect(apiFallbackUrl("did:web:id.codespar.dev", "https://api.codespar.dev/")).toBe( + "https://api.codespar.dev/v1/agents/did%3Aweb%3Aid.codespar.dev/did.json", + ); + }); +}); diff --git a/packages/cli/src/__tests__/fixtures/canonical.v3.fixture.json b/packages/cli/src/__tests__/fixtures/canonical.v3.fixture.json new file mode 100644 index 0000000..fcae33f --- /dev/null +++ b/packages/cli/src/__tests__/fixtures/canonical.v3.fixture.json @@ -0,0 +1,28 @@ +{ + "_comment": "Build 2 V3 byte-frozen fixture. DO NOT CHANGE. Mirrors canonical.fixture.json for V3 (14 fields, 13 separators = V2's 12 + principal_kyc_ref + agent_kid). HMAC key is the same test key; Ed25519 seeds are sha256('codespar-build2-agent-key-fixture') and sha256('codespar-build2-issuer-key-fixture'). Ed25519 is deterministic (RFC 8032) so agent_sig/issuer_sig are frozen. agent_kid intentionally contains colons (did:web) to pin the colon-in-final-field behaviour.", + "key": "test-fixture-key-do-not-use-in-prod-0123456789abcdef", + "input": { + "format_version": 3, + "id": "mnd_v3_abc...", + "agent_id": "a1", + "type": "delegation", + "amount": "5000", + "currency": "BRL", + "purposes": ["refund", "utility"], + "expires_at": 1735689600, + "max_amount": null, + "parent_id": null, + "denomination": null, + "secret_version": 1, + "principal_kyc_ref": "kyc_celcoin_11144477735", + "agent_kid": "did:web:id.codespar.dev:org_demo:a1#1" + }, + "canonical_string": "3:mnd_v3_abc...:a1:delegation:5000:BRL:refund,utility:1735689600::::1:kyc_celcoin_11144477735:did:web:id.codespar.dev:org_demo:a1#1", + "hmac_sha256_hex": "4e627ac1074f1c15684cb10d63b1d4481fbeda8f82450f26c04b7860210fc3e4", + "agent_seed_hex": "1050a34bae067327e59c1ac43f3a25190a582448219521d35d000650d714ec9a", + "agent_pubkey_hex": "70940e2c00698a520339c07332ff35c05cad7b39d024a4f4737310e740b2ecc3", + "issuer_seed_hex": "23dddf5e3a0038f1a81cda29dbbd5d1c97d434a6610a814d3d628f7b269d031f", + "issuer_pubkey_hex": "41826edefd6d40fd7c32dd63ebcc0476e4324cd6a424881c53f170fee9865b14", + "agent_sig_b64url": "F9V0rSsfT1IjeF_4_9pciIV7w2ERdLN5r-UMkCdQWjce9ouYYY2awrrjFw5cENKbTEw0l61HitYPO5ZocfQ7Bg", + "issuer_sig_b64url": "ClsA7hWq1fBAG-qrhYAahDPm0xMdPl5lOJ6X0KxSzruJ2kD6NCzH-baSbxcwTxTCkWd1eBpJVke3onUR6mH1Ag" +} diff --git a/packages/cli/src/__tests__/mandate-codec.test.ts b/packages/cli/src/__tests__/mandate-codec.test.ts new file mode 100644 index 0000000..9091d78 --- /dev/null +++ b/packages/cli/src/__tests__/mandate-codec.test.ts @@ -0,0 +1,174 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it, expect } from "vitest"; +import { + agentDidFromKid, + decodeToken, + parsePubkeyHex, + reconstructSigningString, + verifyEd25519, + type MandateFields, +} from "../mandate-codec.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_PATH = join(__dirname, "fixtures/canonical.v3.fixture.json"); + +interface V3Fixture { + key: string; + input: MandateFields & { agent_kid: string; principal_kyc_ref: string }; + canonical_string: string; + hmac_sha256_hex: string; + agent_pubkey_hex: string; + issuer_pubkey_hex: string; + agent_sig_b64url: string; + issuer_sig_b64url: string; +} + +const fx = JSON.parse(readFileSync(FIXTURE_PATH, "utf8")) as V3Fixture; + +/** + * Reproduce the enterprise `computeToken` envelope so the CLI decoder has a real + * presentation token to chew on: base64url JSON of the signed fields + the + * org-HMAC `signature` + the V3 `agent_sig` / `issuer_sig` / `kid` members. + */ +function makeToken( + fields: Record, + envelope: { signature: string; agent_sig?: string; issuer_sig?: string; kid?: string }, +): string { + return Buffer.from(JSON.stringify({ ...fields, ...envelope }), "utf8").toString("base64url"); +} + +const TOKEN = makeToken(fx.input as unknown as Record, { + signature: fx.hmac_sha256_hex, + agent_sig: fx.agent_sig_b64url, + issuer_sig: fx.issuer_sig_b64url, + kid: fx.input.agent_kid, +}); + +describe("mandate-codec — decode", () => { + it("round-trips the V3 envelope and separates it from the signed fields", () => { + const res = decodeToken(TOKEN); + expect(res.ok).toBe(true); + if (!res.ok) return; + const { token } = res; + expect(token.signature).toBe(fx.hmac_sha256_hex); + expect(token.agent_sig).toBe(fx.agent_sig_b64url); + expect(token.issuer_sig).toBe(fx.issuer_sig_b64url); + expect(token.kid).toBe(fx.input.agent_kid); + // Envelope members must NOT leak into the signed field set. + const m = token.mandate as unknown as Record; + expect(m.agent_sig).toBeUndefined(); + expect(m.issuer_sig).toBeUndefined(); + expect(m.kid).toBeUndefined(); + expect(m.signature).toBeUndefined(); + expect(token.mandate.principal_kyc_ref).toBe(fx.input.principal_kyc_ref); + expect(token.mandate.agent_kid).toBe(fx.input.agent_kid); + }); + + it("rejects a non-base64url / non-JSON blob", () => { + const res = decodeToken("!!!not-a-token!!!"); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("invalid_payload"); + }); + + it("rejects an unsupported (v1 / reserved) format_version", () => { + const bad = makeToken({ ...fx.input, format_version: 1 }, { signature: "deadbeef" }); + const res = decodeToken(bad); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("mandate_format_unsupported"); + }); + + it("rejects a V3 payload missing principal_kyc_ref / agent_kid", () => { + const { principal_kyc_ref, agent_kid, ...rest } = fx.input; + void principal_kyc_ref; + void agent_kid; + const bad = makeToken(rest as unknown as Record, { signature: "deadbeef" }); + const res = decodeToken(bad); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("invalid_payload"); + }); +}); + +describe("mandate-codec — reconstructSigningString (byte-lock)", () => { + it("reproduces the frozen 14-field canonical string byte-for-byte", () => { + const s = reconstructSigningString(fx.input as unknown as Record); + expect(s).toBe(fx.canonical_string); + }); + + it("reconstructs the same string from the decoded token's mandate fields", () => { + const res = decodeToken(TOKEN); + expect(res.ok).toBe(true); + if (!res.ok) return; + const s = reconstructSigningString(res.token.mandate as unknown as Record); + expect(s).toBe(fx.canonical_string); + }); + + it("sorts + escapes purposes independent of input order", () => { + const s = reconstructSigningString({ + ...fx.input, + purposes: ["utility", "refund"], + } as unknown as Record); + expect(s).toBe(fx.canonical_string); + }); +}); + +describe("mandate-codec — Ed25519 verification", () => { + const signingString = fx.canonical_string; + const agentPub = () => parsePubkeyHex(fx.agent_pubkey_hex)!; + const issuerPub = () => parsePubkeyHex(fx.issuer_pubkey_hex)!; + + it("verifies the agent signature with only the DID-document public key", () => { + expect(verifyEd25519(signingString, fx.agent_sig_b64url, agentPub())).toBe(true); + }); + + it("verifies the issuer signature against the platform public key", () => { + expect(verifyEd25519(signingString, fx.issuer_sig_b64url, issuerPub())).toBe(true); + }); + + it("fails a one-byte tamper of any signed field", () => { + const forged = reconstructSigningString({ + ...fx.input, + amount: "9999", + } as unknown as Record); + expect(forged).not.toBe(signingString); + expect(verifyEd25519(forged, fx.agent_sig_b64url, agentPub())).toBe(false); + }); + + it("fails when the signing string is truncated by one char", () => { + expect(verifyEd25519(signingString + "x", fx.agent_sig_b64url, agentPub())).toBe(false); + }); + + it("fails against the wrong public key (issuer key vs agent signature)", () => { + expect(verifyEd25519(signingString, fx.agent_sig_b64url, issuerPub())).toBe(false); + }); + + it("returns false — never throws — on a malformed key", () => { + expect(verifyEd25519(signingString, fx.agent_sig_b64url, Buffer.alloc(5))).toBe(false); + }); + + it("swapping agent/issuer signatures fails verification", () => { + // The issuer signature does not verify under the agent key and vice versa. + expect(verifyEd25519(signingString, fx.issuer_sig_b64url, agentPub())).toBe(false); + expect(verifyEd25519(signingString, fx.agent_sig_b64url, issuerPub())).toBe(false); + }); +}); + +describe("mandate-codec — helpers", () => { + it("parsePubkeyHex accepts 64 hex chars (with or without 0x) and rejects the rest", () => { + expect(parsePubkeyHex(fx.agent_pubkey_hex)?.length).toBe(32); + expect(parsePubkeyHex("0x" + fx.agent_pubkey_hex)?.length).toBe(32); + expect(parsePubkeyHex("abc")).toBeNull(); + expect(parsePubkeyHex(fx.agent_pubkey_hex + "ff")).toBeNull(); + expect(parsePubkeyHex("zz".repeat(32))).toBeNull(); + }); + + it("agentDidFromKid strips the #fragment to recover the bare agent DID", () => { + expect(agentDidFromKid("did:web:id.codespar.dev:org_demo:a1#1")).toBe( + "did:web:id.codespar.dev:org_demo:a1", + ); + expect(agentDidFromKid("did:web:id.codespar.dev:org_demo:a1")).toBe( + "did:web:id.codespar.dev:org_demo:a1", + ); + }); +}); diff --git a/packages/cli/src/did.ts b/packages/cli/src/did.ts new file mode 100644 index 0000000..50d6775 --- /dev/null +++ b/packages/cli/src/did.ts @@ -0,0 +1,132 @@ +/** + * did:web resolution for the network verify path. Fetches an agent / issuer DID + * document and extracts its raw Ed25519 public keys so the codec can verify a + * presentation token's signatures. + * + * Resolution order (per the rollout in progress): try the standard did:web URL + * mapping first, then fall back to the api.codespar.dev encoded route that + * serves the same document today. + * + * Zero runtime deps — global `fetch` and the standard library only. Kept out of + * `mandate-codec.ts` so the offline verifier path stays pure and network-free. + */ + +/** A raw Ed25519 key pulled from a DID document's verificationMethod. */ +export interface DidKey { + /** The verificationMethod id (`#`). */ + kid: string; + /** Raw 32-byte Ed25519 public key. */ + pubkey: Buffer; +} + +interface JsonWebKey2020 { + id?: unknown; + type?: unknown; + publicKeyJwk?: { kty?: unknown; crv?: unknown; x?: unknown }; +} + +interface DidDocument { + verificationMethod?: JsonWebKey2020[]; +} + +/** + * Map a `did:web` identifier to its standard document URL. + * did:web:id.codespar.dev → https://id.codespar.dev/.well-known/did.json + * did:web:id.codespar.dev:org:agent → https://id.codespar.dev/org/agent/did.json + * Colon-separated path segments become URL path segments; a `%3A` in the domain + * segment decodes to a port. Returns null for a non-did:web input. + */ +export function didWebToUrl(did: string): string | null { + if (!did.startsWith("did:web:")) return null; + const rest = did.slice("did:web:".length); + if (rest.length === 0) return null; + const segments = rest.split(":"); + const domain = decodeURIComponent(segments[0]!); + const path = segments.slice(1).map((s) => decodeURIComponent(s)); + if (path.length === 0) { + return `https://${domain}/.well-known/did.json`; + } + return `https://${domain}/${path.join("/")}/did.json`; +} + +/** The api.codespar.dev fallback route that serves the DID doc today. */ +export function apiFallbackUrl(did: string, baseUrl: string): string { + const base = baseUrl.replace(/\/+$/, ""); + return `${base}/v1/agents/${encodeURIComponent(did)}/did.json`; +} + +async function fetchJson(url: string, timeoutMs: number): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + headers: { Accept: "application/did+json, application/json" }, + signal: controller.signal, + }); + if (!res.ok) return null; + return (await res.json()) as unknown; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +/** Extract every Ed25519 (OKP) key from a DID document's verificationMethod. */ +function keysFromDocument(doc: unknown): DidKey[] { + if (!doc || typeof doc !== "object") return []; + const methods = (doc as DidDocument).verificationMethod; + if (!Array.isArray(methods)) return []; + const keys: DidKey[] = []; + for (const m of methods) { + const jwk = m?.publicKeyJwk; + if (!jwk || jwk.kty !== "OKP" || jwk.crv !== "Ed25519") continue; + if (typeof jwk.x !== "string" || typeof m.id !== "string") continue; + const pubkey = Buffer.from(jwk.x, "base64url"); + if (pubkey.length !== 32) continue; + keys.push({ kid: m.id, pubkey }); + } + return keys; +} + +export interface ResolveOptions { + /** Base URL for the api.codespar.dev fallback route (default the prod API). */ + baseUrl?: string; + /** Preferred verificationMethod id — sorted to the front of the result. */ + preferredKid?: string; + timeoutMs?: number; +} + +/** + * Resolve a `did:web` identifier to its Ed25519 public keys. Tries the standard + * did:web URL, then the api.codespar.dev encoded fallback. Keys matching + * `preferredKid` are ordered first so the caller can verify against the exact + * signing key when the token names one. Returns [] if the document can't be + * fetched or carries no Ed25519 keys. + */ +export async function resolveDidKeys( + did: string, + opts: ResolveOptions = {}, +): Promise { + const baseUrl = opts.baseUrl ?? "https://api.codespar.dev"; + const timeoutMs = opts.timeoutMs ?? 15_000; + + const candidates: string[] = []; + const standard = didWebToUrl(did); + if (standard) candidates.push(standard); + candidates.push(apiFallbackUrl(did, baseUrl)); + + for (const url of candidates) { + const doc = await fetchJson(url, timeoutMs); + const keys = keysFromDocument(doc); + if (keys.length > 0) { + if (opts.preferredKid) { + keys.sort((a, b) => + a.kid === opts.preferredKid ? -1 : b.kid === opts.preferredKid ? 1 : 0, + ); + } + return keys; + } + } + return []; +} diff --git a/packages/cli/src/mandate-codec.ts b/packages/cli/src/mandate-codec.ts new file mode 100644 index 0000000..4b0e427 --- /dev/null +++ b/packages/cli/src/mandate-codec.ts @@ -0,0 +1,224 @@ +/** + * Offline V3 mandate verification — a dependency-free port of the canonical + * signing-string format from `codespar-enterprise/packages/mandate/canonical.ts`. + * + * This is the third-party verifier path: given only a presentation token and a + * raw 32-byte Ed25519 public key (from a did:web document), a counterparty can + * reconstruct the exact 14-field signing string and verify the agent + issuer + * signatures with `node:crypto` alone — no CodeSpar API call, no CodeSpar code. + * + * PORT, not import: the codec lives in the private enterprise repo. The byte + * format is frozen by `tests/fixtures/canonical.v3.fixture.json` (copied into + * this package's tests), so any drift here fails the fixture byte-lock. + * + * Zero runtime deps — `node:crypto` and the standard library only, matching the + * SDK's zero-dependency constraint. + */ +import { createPublicKey, verify as nodeVerify, type KeyObject } from "node:crypto"; + +/** + * The signed mandate fields. Mirrors the enterprise `MandateFields`. The two + * V3-only fields (`principal_kyc_ref`, `agent_kid`) are absent on V2 mandates. + */ +export interface MandateFields { + format_version: number; + id: string; + agent_id: string; + type: "payment" | "subscription" | "delegation"; + /** Decimal string without trailing zeros (e.g. "5000", "99.5"). */ + amount: string; + currency: string; + /** ASCII-only, sorted lexicographically before encoding. */ + purposes: string[]; + /** UNIX seconds. */ + expires_at: number; + max_amount?: string | null; + parent_id?: string | null; + denomination?: string | null; + secret_version: number; + /** V3-only. Reference to the proofed CPF/CNPJ (Celcoin KYC) the agent acts for. */ + principal_kyc_ref?: string | null; + /** V3-only. The agent key id (`#`) that signed this mandate. */ + agent_kid?: string | null; +} + +/** A decoded presentation token: the signed fields plus the signature envelope. */ +export interface DecodedToken { + mandate: MandateFields; + /** The org-HMAC hex digest — present on every version. Not verifiable offline + * (it needs the org secret); carried through for completeness. */ + signature: string; + /** V3 envelope: Ed25519 signature by the agent key (base64url). */ + agent_sig?: string; + /** V3 envelope: Ed25519 signature by the platform issuer key (base64url). */ + issuer_sig?: string; + /** V3 envelope: the agent key id (`#`) that produced agent_sig. */ + kid?: string; +} + +export type DecodeResult = + | { ok: true; token: DecodedToken } + | { ok: false; error: "invalid_payload" | "mandate_format_unsupported" }; + +/** + * Decode a signed token produced by the enterprise `computeToken`: a base64url + * UTF-8 JSON object carrying all MandateFields plus `signature` (org-HMAC hex) + * and — for V3 — the `agent_sig` / `issuer_sig` / `kid` envelope members. + * + * DOES NOT verify anything; it only splits the envelope from the signed fields + * so `mandate` is exactly the field set the signatures cover. + */ +export function decodeToken(token: string): DecodeResult { + let raw: unknown; + try { + raw = JSON.parse(Buffer.from(token, "base64url").toString("utf8")); + } catch { + return { ok: false, error: "invalid_payload" }; + } + if (!raw || typeof raw !== "object") { + return { ok: false, error: "invalid_payload" }; + } + + const r = raw as Record; + const version = r["format_version"]; + if (typeof version !== "number" || !Number.isInteger(version) || version < 2) { + return { ok: false, error: "mandate_format_unsupported" }; + } + if (!isValidMandateFields(r)) { + return { ok: false, error: "invalid_payload" }; + } + + // Pull the envelope members out so `mandate` is exactly the signed fields. + const { signature, agent_sig, issuer_sig, kid, ...fields } = r as unknown as MandateFields & { + signature: unknown; + agent_sig?: unknown; + issuer_sig?: unknown; + kid?: unknown; + }; + if (typeof signature !== "string") { + return { ok: false, error: "invalid_payload" }; + } + + const decoded: DecodedToken = { mandate: fields as MandateFields, signature }; + if (typeof agent_sig === "string") decoded.agent_sig = agent_sig; + if (typeof issuer_sig === "string") decoded.issuer_sig = issuer_sig; + if (typeof kid === "string") decoded.kid = kid; + return { ok: true, token: decoded }; +} + +function isValidMandateFields(r: Record): boolean { + if (typeof r["format_version"] !== "number") return false; + if (typeof r["id"] !== "string") return false; + if (typeof r["agent_id"] !== "string") return false; + if (!["payment", "subscription", "delegation"].includes(r["type"] as string)) return false; + if (typeof r["amount"] !== "string") return false; + if (typeof r["currency"] !== "string") return false; + if (!Array.isArray(r["purposes"])) return false; + if (typeof r["expires_at"] !== "number") return false; + if (typeof r["secret_version"] !== "number") return false; + // V3 binds the KYC'd principal and the signing key into the signed string, + // so both are mandatory for a well-formed V3 payload. + if (r["format_version"] === 3) { + if (typeof r["principal_kyc_ref"] !== "string") return false; + if (typeof r["agent_kid"] !== "string") return false; + } + return true; +} + +/** + * Reconstruct the canonical signing string the Ed25519 signatures cover. + * + * Field order (V3 = 14 fields, 13 `:` separators): V2's 12 fields, then the two + * V3-only fields appended (principal_kyc_ref, agent_kid). Absent optionals + * render as the empty string so the separator count is invariant. `purposes` is + * comma-joined after a lexicographic sort with escaping (`\` → `\\` first, then + * `,` → `\,`). Colons inside `agent_kid` (from the `did:web` prefix) are emitted + * verbatim — the signing string is a one-way serialization, never re-split. + * + * A V2 mandate reconstructs to 12 fields because its two V3 fields are absent + * (rendered empty would change the byte count), so this reads the fields it + * needs and appends the V3 tail only when `format_version >= 3`. + */ +export function reconstructSigningString(f: Record): string { + const esc = (s: string) => s.replace(/\\/g, "\\\\").replace(/,/g, "\\,"); + const purposes = ((f["purposes"] as string[]) ?? []) + .slice() + .sort() + .map(esc) + .join(","); + const version = Number(f["format_version"]); + const parts: unknown[] = [ + String(f["format_version"]), + f["id"], + f["agent_id"], + f["type"], + f["amount"], + f["currency"], + purposes, + String(f["expires_at"]), + f["max_amount"] ?? "", + f["parent_id"] ?? "", + f["denomination"] ?? "", + String(f["secret_version"]), + ]; + if (version >= 3) { + parts.push(f["principal_kyc_ref"] ?? "", f["agent_kid"] ?? ""); + } + return parts.join(":"); +} + +// ── Ed25519 verify with only a raw 32-byte public key ───────────────── +// +// A raw Ed25519 public key becomes an SPKI KeyObject by prefixing the fixed +// RFC 8410 header (302a300506032b6570032100). This is the exact wrapper a bare +// third-party verifier uses; no key resolution happens here. +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +function publicKeyFromRaw(pub: Buffer): KeyObject { + if (pub.length !== 32) { + throw new Error(`Ed25519 public key must be 32 bytes, got ${pub.length}`); + } + return createPublicKey({ + key: Buffer.concat([ED25519_SPKI_PREFIX, pub]), + format: "der", + type: "spki", + }); +} + +/** + * Verify an Ed25519 signature (base64url) over a signing string with only the + * raw 32-byte public key. Returns false — never throws — on a malformed key or + * signature, so callers treat it as a single boolean gate. + */ +export function verifyEd25519( + signingString: string, + signatureB64url: string, + pub: Buffer, +): boolean { + try { + return nodeVerify( + null, + Buffer.from(signingString, "utf8"), + publicKeyFromRaw(pub), + Buffer.from(signatureB64url, "base64url"), + ); + } catch { + return false; + } +} + +/** + * Parse a hex-encoded raw Ed25519 public key into a 32-byte Buffer. + * Returns null on any malformed input (odd length, non-hex, wrong byte count). + */ +export function parsePubkeyHex(hex: string): Buffer | null { + const clean = hex.trim().toLowerCase().replace(/^0x/, ""); + if (clean.length !== 64 || !/^[0-9a-f]+$/.test(clean)) return null; + return Buffer.from(clean, "hex"); +} + +/** Strip the `#` from an agent key id to recover the bare agent DID. */ +export function agentDidFromKid(kid: string): string { + const hash = kid.indexOf("#"); + return hash === -1 ? kid : kid.slice(0, hash); +} From ed9ece6fd64b2f27ec2bc6659f54b147c2dec5e7 Mon Sep 17 00:00:00 2001 From: Fabiano Cruz Date: Sun, 5 Jul 2026 09:44:40 -0300 Subject: [PATCH 2/4] feat(cli): codespar mandate verify command Add the offline/network V3 verifier command under the mandate group: - Offline mode (--agent-pubkey / --issuer-pubkey hex): verify the named signatures against supplied raw Ed25519 keys, zero network calls. - Network mode (default): resolve agent + issuer public keys via did:web (issuer DID derived from the agent DID host) and verify. - Prints per-signature status (verified / failed / skipped), the decoded governed fields (purposes, caps, agent_did, kid, expiry), and redacts principal_kyc_ref to a boolean. --json for machine output. - Non-zero exit code on any verification failure. Requires no API key (offline verification is fully local). Co-Authored-By: Claude Fable 5 --- packages/cli/src/commands/mandate-verify.ts | 268 ++++++++++++++++++++ packages/cli/src/index.ts | 30 +++ 2 files changed, 298 insertions(+) create mode 100644 packages/cli/src/commands/mandate-verify.ts diff --git a/packages/cli/src/commands/mandate-verify.ts b/packages/cli/src/commands/mandate-verify.ts new file mode 100644 index 0000000..d083bae --- /dev/null +++ b/packages/cli/src/commands/mandate-verify.ts @@ -0,0 +1,268 @@ +import { CliError } from "../config.js"; +import { c, info, json, kv, success, warn } from "../output.js"; +import { + agentDidFromKid, + decodeToken, + parsePubkeyHex, + reconstructSigningString, + verifyEd25519, + type DecodedToken, +} from "../mandate-codec.js"; +import { resolveDidKeys, type DidKey } from "../did.js"; + +export interface MandateVerifyOptions { + /** Raw 32-byte Ed25519 agent public key (hex). Presence forces offline mode. */ + agentPubkey?: string; + /** Raw 32-byte Ed25519 issuer public key (hex). Presence forces offline mode. */ + issuerPubkey?: string; + /** Override the issuer DID (default: did:web derived from the agent DID host). */ + issuerDid?: string; + baseUrl: string; + json?: boolean; +} + +type SigStatus = "verified" | "failed" | "skipped"; + +interface SigResult { + present: boolean; + status: SigStatus; + /** The verificationMethod / key id that verified the signature (or was tried). */ + kid?: string; + /** Where the public key came from: "flag" | "did:web" | "-". */ + source: string; + detail?: string; +} + +/** did:web platform issuer DID derived from an agent DID's host segment. */ +function platformIssuerDid(agentDid: string): string | null { + if (!agentDid.startsWith("did:web:")) return null; + const host = agentDid.slice("did:web:".length).split(":")[0]; + return host ? `did:web:${host}` : null; +} + +/** Try a signature against a set of candidate keys; first hit wins. */ +function verifyAgainst( + signingString: string, + sig: string, + keys: DidKey[], +): DidKey | null { + for (const k of keys) { + if (verifyEd25519(signingString, sig, k.pubkey)) return k; + } + return null; +} + +/** + * Verify a V3 mandate presentation token. + * + * codespar mandate verify + * + * Offline mode (any --agent-pubkey / --issuer-pubkey given): verify the named + * signatures against the supplied raw Ed25519 keys with zero network calls. + * Network mode (no pubkey flags): resolve the agent + issuer public keys from + * their did:web documents (standard route, api.codespar.dev fallback) and verify. + * + * A signature the token carries must verify for an overall pass. In offline mode + * a signature with no supplied key is "skipped" (not proven, not failed); at + * least one signature must verify and none may fail. In network mode an + * unresolvable key is a failure. Exit code is non-zero on any failure. + */ +export async function mandateVerifyCommand( + token: string, + opts: MandateVerifyOptions, +): Promise { + const decoded = decodeToken(token); + if (!decoded.ok) { + if (opts.json) { + json({ verified: false, error: decoded.error }); + process.exitCode = 1; + return; + } + throw new CliError( + decoded.error === "mandate_format_unsupported" + ? "unsupported mandate format (need format_version >= 2)." + : "cannot decode token — not a valid base64url mandate presentation token.", + ); + } + + const t: DecodedToken = decoded.token; + const m = t.mandate; + const signingString = reconstructSigningString(m as unknown as Record); + const offline = Boolean(opts.agentPubkey || opts.issuerPubkey); + + const agentKid = t.kid ?? m.agent_kid ?? undefined; + const agentDid = agentKid ? agentDidFromKid(agentKid) : undefined; + + // ── Agent signature ────────────────────────────────────────────── + const agent: SigResult = { present: Boolean(t.agent_sig), status: "skipped", source: "-" }; + if (t.agent_sig) { + if (offline) { + if (opts.agentPubkey) { + const pub = parsePubkeyHex(opts.agentPubkey); + if (!pub) throw new CliError("--agent-pubkey must be 64 hex chars (a raw 32-byte Ed25519 key)."); + agent.source = "flag"; + const hit = verifyAgainst(signingString, t.agent_sig, [{ kid: agentKid ?? "(flag)", pubkey: pub }]); + agent.status = hit ? "verified" : "failed"; + agent.kid = agentKid; + } else { + agent.status = "skipped"; + agent.detail = "no --agent-pubkey supplied"; + } + } else { + agent.source = "did:web"; + if (!agentDid) { + agent.status = "failed"; + agent.detail = "token carries no agent_kid to resolve"; + } else { + const keys = await resolveDidKeys(agentDid, { + baseUrl: opts.baseUrl, + preferredKid: agentKid, + }); + if (keys.length === 0) { + agent.status = "failed"; + agent.detail = `could not resolve ${agentDid}`; + } else { + const hit = verifyAgainst(signingString, t.agent_sig, keys); + agent.status = hit ? "verified" : "failed"; + agent.kid = hit?.kid ?? agentKid; + } + } + } + } + + // ── Issuer signature ───────────────────────────────────────────── + const issuer: SigResult = { present: Boolean(t.issuer_sig), status: "skipped", source: "-" }; + if (t.issuer_sig) { + if (offline) { + if (opts.issuerPubkey) { + const pub = parsePubkeyHex(opts.issuerPubkey); + if (!pub) throw new CliError("--issuer-pubkey must be 64 hex chars (a raw 32-byte Ed25519 key)."); + issuer.source = "flag"; + const hit = verifyAgainst(signingString, t.issuer_sig, [{ kid: "(flag)", pubkey: pub }]); + issuer.status = hit ? "verified" : "failed"; + } else { + issuer.status = "skipped"; + issuer.detail = "no --issuer-pubkey supplied"; + } + } else { + issuer.source = "did:web"; + const issuerDid = opts.issuerDid ?? (agentDid ? platformIssuerDid(agentDid) : null); + if (!issuerDid) { + issuer.status = "failed"; + issuer.detail = "no issuer DID (pass --issuer-did)"; + } else { + // The envelope names the agent kid, not the issuer's, so try every + // Ed25519 key the issuer document publishes. + const keys = await resolveDidKeys(issuerDid, { baseUrl: opts.baseUrl }); + if (keys.length === 0) { + issuer.status = "failed"; + issuer.detail = `could not resolve ${issuerDid}`; + } else { + const hit = verifyAgainst(signingString, t.issuer_sig, keys); + issuer.status = hit ? "verified" : "failed"; + issuer.kid = hit?.kid; + } + } + } + } + + const anyVerified = agent.status === "verified" || issuer.status === "verified"; + const anyFailed = agent.status === "failed" || issuer.status === "failed"; + const verified = anyVerified && !anyFailed; + + const expiresIso = Number.isFinite(m.expires_at) + ? new Date(m.expires_at * 1000).toISOString() + : null; + const expired = Number.isFinite(m.expires_at) ? m.expires_at * 1000 < Date.now() : false; + + if (opts.json) { + json({ + verified, + mode: offline ? "offline" : "network", + format_version: m.format_version, + signatures: { + agent_sig: sigJson(agent), + issuer_sig: sigJson(issuer), + }, + mandate: { + id: m.id, + agent_id: m.agent_id, + agent_did: agentDid ?? null, + kid: agentKid ?? null, + type: m.type, + amount: m.amount, + currency: m.currency, + max_amount: m.max_amount ?? null, + parent_id: m.parent_id ?? null, + denomination: m.denomination ?? null, + purposes: m.purposes, + // Redacted: never emit the CPF/CNPJ reference itself, only its presence. + principal_kyc_ref_present: Boolean(m.principal_kyc_ref), + expires_at: m.expires_at, + expires_at_iso: expiresIso, + expired, + format_version: m.format_version, + }, + }); + if (!verified) process.exitCode = 1; + return; + } + + // ── Human output ───────────────────────────────────────────────── + if (verified) success(`mandate token verified (${offline ? "offline" : "network"} mode)`); + else warn(`mandate token NOT verified (${offline ? "offline" : "network"} mode)`); + + process.stdout.write(c.bold("\nsignatures\n")); + kv([ + ["agent_sig", sigLine(agent)], + ["issuer_sig", sigLine(issuer)], + ]); + + process.stdout.write(c.bold("\nmandate\n")); + kv([ + ["id", m.id], + ["agent_id", m.agent_id], + ["agent_did", agentDid ?? "(none)"], + ["kid", agentKid ?? "(none)"], + ["type", m.type], + ["amount", `${m.amount} ${m.currency}`], + ["max_amount", m.max_amount ? `${m.max_amount} ${m.currency}` : "(none)"], + ["purposes", m.purposes.join(", ")], + ["principal_kyc", m.principal_kyc_ref ? "present" : "absent"], + [ + "expires_at", + expiresIso ? `${m.expires_at} (${expiresIso})${expired ? c.yellow(" [expired]") : ""}` : String(m.expires_at), + ], + ["format", `v${m.format_version}`], + ]); + + if (!verified) { + info("A NOT-verified result means a carried signature failed or could not be checked. See the per-signature status above."); + process.exitCode = 1; + } +} + +function statusMark(status: SigStatus): string { + if (status === "verified") return c.green("✓ verified"); + if (status === "failed") return c.red("✗ failed"); + return c.gray("– skipped"); +} + +function sigLine(r: SigResult): string { + if (!r.present) return c.gray("– absent (not in token)"); + const bits = [statusMark(r.status)]; + if (r.kid) bits.push(c.gray(`kid ${r.kid}`)); + if (r.source && r.source !== "-") bits.push(c.gray(`via ${r.source}`)); + if (r.detail) bits.push(c.gray(`(${r.detail})`)); + return bits.join(" "); +} + +function sigJson(r: SigResult): Record { + return { + present: r.present, + status: r.present ? r.status : "absent", + kid: r.kid ?? null, + source: r.source === "-" ? null : r.source, + detail: r.detail ?? null, + }; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 90a5bfc..54720b6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -22,6 +22,7 @@ import { discoverCommand } from "./commands/discover.js"; import { chargeCommand } from "./commands/charge.js"; import { spendCommand } from "./commands/spend.js"; import { mandateCreateCommand } from "./commands/mandate.js"; +import { mandateVerifyCommand } from "./commands/mandate-verify.js"; import { walletCommand } from "./commands/wallet.js"; import { transferCommand } from "./commands/transfer.js"; import { shipCommand } from "./commands/ship.js"; @@ -315,6 +316,35 @@ mandate }, ); +mandate + .command("verify ") + .description("Verify a V3 mandate presentation token offline (agent + issuer Ed25519 signatures)") + .option( + "--agent-pubkey ", + "Raw 32-byte Ed25519 agent public key (hex). Forces pure-offline verification (no network).", + ) + .option( + "--issuer-pubkey ", + "Raw 32-byte Ed25519 issuer public key (hex). Forces pure-offline verification (no network).", + ) + .option( + "--issuer-did ", + "Issuer DID for network mode (default: did:web derived from the agent DID host)", + ) + .action( + async ( + token: string, + opts: { agentPubkey?: string; issuerPubkey?: string; issuerDid?: string }, + ) => { + // Offline verification needs no API key — resolve the base URL (for the + // network fallback) from flags/env/config without requiring auth. + const config = await loadConfig(); + const root = program.opts<{ baseUrl?: string }>(); + const baseUrl = root.baseUrl ?? config.baseUrl ?? "https://api.codespar.dev"; + await mandateVerifyCommand(token, { ...opts, baseUrl, json: rootJsonFlag() }); + }, + ); + program .command("spend") .description("Execute an agentic spend against a consumer mandate (Pix / USDC / x402 by payee)") From 96f268d0880d288ecf17ad362dfd78110484c495 Mon Sep 17 00:00:00 2001 From: Fabiano Cruz Date: Sun, 5 Jul 2026 09:52:48 -0300 Subject: [PATCH 3/4] =?UTF-8?q?feat(sdk):=20@codespar/sdk/mandate=20?= =?UTF-8?q?=E2=80=94=20offline=20V3=20verify=20subpath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the V3 mandate codec to the @codespar/sdk public surface as a new ./mandate subpath export, keeping node:crypto out of the main (edge-safe) graph: - decodeMandateToken: split the base64url presentation envelope from the signed fields. - reconstructSigningString: the 14-field V3 canonical string (byte-locked against the shared canonical.v3.fixture.json freeze). - verifyEd25519: SPKI-wrap a raw 32-byte key (RFC 8410), verify via node:crypto; false-never-throws on malformed input. - verifyMandateToken: high-level offline verify from hex/byte pubkeys → { verified, mandate, agent/issuer SignatureCheck, agentDid, kid }. node:crypto is a Node builtin, not an npm dependency — the zero-runtime-dep guarantee holds. Fixture is byte-identical to the enterprise freeze and the CLI copy (cross-impl parity anchor). 16 tests; subpath import driven E2E. Co-Authored-By: Claude Fable 5 --- packages/core/package.json | 4 + .../fixtures/canonical.v3.fixture.json | 28 ++ packages/core/src/__tests__/mandate.test.ts | 188 +++++++++++ packages/core/src/mandate/index.ts | 306 ++++++++++++++++++ 4 files changed, 526 insertions(+) create mode 100644 packages/core/src/__tests__/fixtures/canonical.v3.fixture.json create mode 100644 packages/core/src/__tests__/mandate.test.ts create mode 100644 packages/core/src/mandate/index.ts diff --git a/packages/core/package.json b/packages/core/package.json index 11aae9e..cd13438 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -13,6 +13,10 @@ "./testing": { "import": "./dist/testing/index.js", "types": "./dist/testing/index.d.ts" + }, + "./mandate": { + "import": "./dist/mandate/index.js", + "types": "./dist/mandate/index.d.ts" } }, "files": [ diff --git a/packages/core/src/__tests__/fixtures/canonical.v3.fixture.json b/packages/core/src/__tests__/fixtures/canonical.v3.fixture.json new file mode 100644 index 0000000..fcae33f --- /dev/null +++ b/packages/core/src/__tests__/fixtures/canonical.v3.fixture.json @@ -0,0 +1,28 @@ +{ + "_comment": "Build 2 V3 byte-frozen fixture. DO NOT CHANGE. Mirrors canonical.fixture.json for V3 (14 fields, 13 separators = V2's 12 + principal_kyc_ref + agent_kid). HMAC key is the same test key; Ed25519 seeds are sha256('codespar-build2-agent-key-fixture') and sha256('codespar-build2-issuer-key-fixture'). Ed25519 is deterministic (RFC 8032) so agent_sig/issuer_sig are frozen. agent_kid intentionally contains colons (did:web) to pin the colon-in-final-field behaviour.", + "key": "test-fixture-key-do-not-use-in-prod-0123456789abcdef", + "input": { + "format_version": 3, + "id": "mnd_v3_abc...", + "agent_id": "a1", + "type": "delegation", + "amount": "5000", + "currency": "BRL", + "purposes": ["refund", "utility"], + "expires_at": 1735689600, + "max_amount": null, + "parent_id": null, + "denomination": null, + "secret_version": 1, + "principal_kyc_ref": "kyc_celcoin_11144477735", + "agent_kid": "did:web:id.codespar.dev:org_demo:a1#1" + }, + "canonical_string": "3:mnd_v3_abc...:a1:delegation:5000:BRL:refund,utility:1735689600::::1:kyc_celcoin_11144477735:did:web:id.codespar.dev:org_demo:a1#1", + "hmac_sha256_hex": "4e627ac1074f1c15684cb10d63b1d4481fbeda8f82450f26c04b7860210fc3e4", + "agent_seed_hex": "1050a34bae067327e59c1ac43f3a25190a582448219521d35d000650d714ec9a", + "agent_pubkey_hex": "70940e2c00698a520339c07332ff35c05cad7b39d024a4f4737310e740b2ecc3", + "issuer_seed_hex": "23dddf5e3a0038f1a81cda29dbbd5d1c97d434a6610a814d3d628f7b269d031f", + "issuer_pubkey_hex": "41826edefd6d40fd7c32dd63ebcc0476e4324cd6a424881c53f170fee9865b14", + "agent_sig_b64url": "F9V0rSsfT1IjeF_4_9pciIV7w2ERdLN5r-UMkCdQWjce9ouYYY2awrrjFw5cENKbTEw0l61HitYPO5ZocfQ7Bg", + "issuer_sig_b64url": "ClsA7hWq1fBAG-qrhYAahDPm0xMdPl5lOJ6X0KxSzruJ2kD6NCzH-baSbxcwTxTCkWd1eBpJVke3onUR6mH1Ag" +} diff --git a/packages/core/src/__tests__/mandate.test.ts b/packages/core/src/__tests__/mandate.test.ts new file mode 100644 index 0000000..ffe87eb --- /dev/null +++ b/packages/core/src/__tests__/mandate.test.ts @@ -0,0 +1,188 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it, expect } from "vitest"; +import { + agentDidFromKid, + decodeMandateToken, + reconstructSigningString, + verifyEd25519, + verifyMandateToken, + type MandateFields, +} from "../mandate/index.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_PATH = join(__dirname, "fixtures/canonical.v3.fixture.json"); + +interface V3Fixture { + key: string; + input: MandateFields & { agent_kid: string; principal_kyc_ref: string }; + canonical_string: string; + hmac_sha256_hex: string; + agent_pubkey_hex: string; + issuer_pubkey_hex: string; + agent_sig_b64url: string; + issuer_sig_b64url: string; +} + +const fx = JSON.parse(readFileSync(FIXTURE_PATH, "utf8")) as V3Fixture; + +/** Reproduce the enterprise `computeToken` envelope so we have a real token. */ +function makeToken( + fields: Record, + envelope: { signature: string; agent_sig?: string; issuer_sig?: string; kid?: string }, +): string { + return Buffer.from(JSON.stringify({ ...fields, ...envelope }), "utf8").toString("base64url"); +} + +const TOKEN = makeToken(fx.input as unknown as Record, { + signature: fx.hmac_sha256_hex, + agent_sig: fx.agent_sig_b64url, + issuer_sig: fx.issuer_sig_b64url, + kid: fx.input.agent_kid, +}); + +describe("@codespar/sdk/mandate — decode", () => { + it("round-trips the V3 envelope and separates it from the signed fields", () => { + const res = decodeMandateToken(TOKEN); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.token.signature).toBe(fx.hmac_sha256_hex); + expect(res.token.agent_sig).toBe(fx.agent_sig_b64url); + expect(res.token.issuer_sig).toBe(fx.issuer_sig_b64url); + expect(res.token.kid).toBe(fx.input.agent_kid); + const m = res.token.mandate as unknown as Record; + expect(m.signature).toBeUndefined(); + expect(m.agent_sig).toBeUndefined(); + expect(m.issuer_sig).toBeUndefined(); + expect(m.kid).toBeUndefined(); + }); + + it("rejects a non-base64url / non-JSON blob", () => { + const res = decodeMandateToken("!!!nope!!!"); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("invalid_payload"); + }); + + it("rejects an unsupported (v1) format_version", () => { + const res = decodeMandateToken(makeToken({ ...fx.input, format_version: 1 }, { signature: "x" })); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("mandate_format_unsupported"); + }); +}); + +describe("@codespar/sdk/mandate — reconstructSigningString (byte-lock)", () => { + it("reproduces the frozen 14-field canonical string byte-for-byte", () => { + expect(reconstructSigningString(fx.input as unknown as Record)).toBe( + fx.canonical_string, + ); + }); + + it("sorts + escapes purposes independent of input order", () => { + expect( + reconstructSigningString({ + ...fx.input, + purposes: ["utility", "refund"], + } as unknown as Record), + ).toBe(fx.canonical_string); + }); +}); + +describe("@codespar/sdk/mandate — verifyEd25519", () => { + const agentPub = () => Buffer.from(fx.agent_pubkey_hex, "hex"); + const issuerPub = () => Buffer.from(fx.issuer_pubkey_hex, "hex"); + + it("verifies agent + issuer signatures with only the DID public keys", () => { + expect(verifyEd25519(fx.canonical_string, fx.agent_sig_b64url, agentPub())).toBe(true); + expect(verifyEd25519(fx.canonical_string, fx.issuer_sig_b64url, issuerPub())).toBe(true); + }); + + it("fails a one-byte tamper of a signed field", () => { + const forged = reconstructSigningString({ + ...fx.input, + amount: "9999", + } as unknown as Record); + expect(verifyEd25519(forged, fx.agent_sig_b64url, agentPub())).toBe(false); + }); + + it("fails against the wrong key and never throws on a malformed key", () => { + expect(verifyEd25519(fx.canonical_string, fx.agent_sig_b64url, issuerPub())).toBe(false); + expect(verifyEd25519(fx.canonical_string, fx.agent_sig_b64url, Buffer.alloc(5))).toBe(false); + }); +}); + +describe("@codespar/sdk/mandate — verifyMandateToken (high-level)", () => { + it("verifies both signatures from hex keys and exposes governed fields", () => { + const res = verifyMandateToken(TOKEN, { + agentPublicKey: fx.agent_pubkey_hex, + issuerPublicKey: fx.issuer_pubkey_hex, + }); + expect(res.verified).toBe(true); + expect(res.agent.status).toBe("verified"); + expect(res.issuer.status).toBe("verified"); + expect(res.agent.kid).toBe(fx.input.agent_kid); + expect(res.agentDid).toBe("did:web:id.codespar.dev:org_demo:a1"); + expect(res.kid).toBe(fx.input.agent_kid); + expect(res.mandate.purposes).toContain("refund"); + expect(res.mandate.amount).toBe("5000"); + }); + + it("accepts raw byte keys as well as hex", () => { + const res = verifyMandateToken(TOKEN, { + agentPublicKey: Buffer.from(fx.agent_pubkey_hex, "hex"), + issuerPublicKey: Buffer.from(fx.issuer_pubkey_hex, "hex"), + }); + expect(res.verified).toBe(true); + }); + + it("skips a signature when its key is not supplied (still verified overall)", () => { + const res = verifyMandateToken(TOKEN, { agentPublicKey: fx.agent_pubkey_hex }); + expect(res.agent.status).toBe("verified"); + expect(res.issuer.status).toBe("skipped"); + expect(res.verified).toBe(true); + }); + + it("reports NOT verified when a supplied key fails", () => { + // Issuer key given for the agent slot → agent signature fails. + const res = verifyMandateToken(TOKEN, { agentPublicKey: fx.issuer_pubkey_hex }); + expect(res.agent.status).toBe("failed"); + expect(res.verified).toBe(false); + }); + + it("reports NOT verified for a tampered token", () => { + const tampered = makeToken( + { ...fx.input, amount: "9999" }, + { + signature: fx.hmac_sha256_hex, + agent_sig: fx.agent_sig_b64url, + issuer_sig: fx.issuer_sig_b64url, + kid: fx.input.agent_kid, + }, + ); + const res = verifyMandateToken(tampered, { + agentPublicKey: fx.agent_pubkey_hex, + issuerPublicKey: fx.issuer_pubkey_hex, + }); + expect(res.verified).toBe(false); + expect(res.agent.status).toBe("failed"); + expect(res.issuer.status).toBe("failed"); + }); + + it("throws on a token that cannot be decoded", () => { + expect(() => verifyMandateToken("not-a-token")).toThrow(/cannot decode/); + }); + + it("rejects a malformed hex key by treating it as no key (skipped)", () => { + const res = verifyMandateToken(TOKEN, { agentPublicKey: "xyz" }); + expect(res.agent.status).toBe("skipped"); + expect(res.verified).toBe(false); + }); +}); + +describe("@codespar/sdk/mandate — helpers", () => { + it("agentDidFromKid strips the #fragment", () => { + expect(agentDidFromKid("did:web:id.codespar.dev:org_demo:a1#1")).toBe( + "did:web:id.codespar.dev:org_demo:a1", + ); + }); +}); diff --git a/packages/core/src/mandate/index.ts b/packages/core/src/mandate/index.ts new file mode 100644 index 0000000..0ff0ee3 --- /dev/null +++ b/packages/core/src/mandate/index.ts @@ -0,0 +1,306 @@ +/** + * `@codespar/sdk/mandate` — offline V3 mandate verification. + * + * A third party holding only a presentation token and a raw Ed25519 public key + * (from the agent's did:web document) can reconstruct the exact signing string + * and verify the agent + issuer signatures with `node:crypto` alone — no + * CodeSpar API call. This is the programmatic form of `codespar mandate verify`. + * + * Isolation: this lives on its own subpath export so the main SDK client stays + * free of `node:crypto` (edge/bundler safe). Import it explicitly: + * + * ```ts + * import { verifyMandateToken } from "@codespar/sdk/mandate"; + * const res = verifyMandateToken(token, { agentPublicKey, issuerPublicKey }); + * if (!res.verified) throw new Error("mandate signature invalid"); + * ``` + * + * The byte format is frozen by the shared `canonical.v3.fixture.json` (the same + * freeze the enterprise codec and the CLI pin), so all three impls stay in lock + * step. `node:crypto` is a Node builtin, not an npm dependency — the SDK's + * zero-runtime-dependency guarantee is intact. + */ +import { createPublicKey, verify as nodeVerify, type KeyObject } from "node:crypto"; + +/** + * The signed mandate fields. The two V3-only fields (`principal_kyc_ref`, + * `agent_kid`) are absent on V2 mandates and required on V3. + */ +export interface MandateFields { + format_version: number; + id: string; + agent_id: string; + type: "payment" | "subscription" | "delegation"; + /** Decimal string without trailing zeros (e.g. "5000", "99.5"). */ + amount: string; + currency: string; + /** ASCII-only, sorted lexicographically before encoding. */ + purposes: string[]; + /** UNIX seconds. */ + expires_at: number; + max_amount?: string | null; + parent_id?: string | null; + denomination?: string | null; + secret_version: number; + /** V3-only. Reference to the proofed CPF/CNPJ (Celcoin KYC) the agent acts for. */ + principal_kyc_ref?: string | null; + /** V3-only. The agent key id (`#`) that signed this mandate. */ + agent_kid?: string | null; +} + +/** A decoded presentation token: the signed fields plus the signature envelope. */ +export interface DecodedMandateToken { + mandate: MandateFields; + /** The org-HMAC hex digest. Present on every version; NOT offline-verifiable + * (it needs the org secret) — carried through for completeness. */ + signature: string; + /** V3 envelope: Ed25519 signature by the agent key (base64url). */ + agent_sig?: string; + /** V3 envelope: Ed25519 signature by the platform issuer key (base64url). */ + issuer_sig?: string; + /** V3 envelope: the agent key id (`#`) that produced agent_sig. */ + kid?: string; +} + +export type MandateDecodeResult = + | { ok: true; token: DecodedMandateToken } + | { ok: false; error: "invalid_payload" | "mandate_format_unsupported" }; + +/** + * Decode a signed presentation token: base64url UTF-8 JSON of the mandate fields + * plus `signature` and — for V3 — the `agent_sig` / `issuer_sig` / `kid` + * envelope. Splits the envelope from the signed fields so `mandate` is exactly + * the field set the signatures cover. Does not verify anything. + */ +export function decodeMandateToken(token: string): MandateDecodeResult { + let raw: unknown; + try { + raw = JSON.parse(Buffer.from(token, "base64url").toString("utf8")); + } catch { + return { ok: false, error: "invalid_payload" }; + } + if (!raw || typeof raw !== "object") { + return { ok: false, error: "invalid_payload" }; + } + + const r = raw as Record; + const version = r["format_version"]; + if (typeof version !== "number" || !Number.isInteger(version) || version < 2) { + return { ok: false, error: "mandate_format_unsupported" }; + } + if (!isValidMandateFields(r)) { + return { ok: false, error: "invalid_payload" }; + } + + const { signature, agent_sig, issuer_sig, kid, ...fields } = r as unknown as MandateFields & { + signature: unknown; + agent_sig?: unknown; + issuer_sig?: unknown; + kid?: unknown; + }; + if (typeof signature !== "string") { + return { ok: false, error: "invalid_payload" }; + } + + const decoded: DecodedMandateToken = { mandate: fields as MandateFields, signature }; + if (typeof agent_sig === "string") decoded.agent_sig = agent_sig; + if (typeof issuer_sig === "string") decoded.issuer_sig = issuer_sig; + if (typeof kid === "string") decoded.kid = kid; + return { ok: true, token: decoded }; +} + +function isValidMandateFields(r: Record): boolean { + if (typeof r["format_version"] !== "number") return false; + if (typeof r["id"] !== "string") return false; + if (typeof r["agent_id"] !== "string") return false; + if (!["payment", "subscription", "delegation"].includes(r["type"] as string)) return false; + if (typeof r["amount"] !== "string") return false; + if (typeof r["currency"] !== "string") return false; + if (!Array.isArray(r["purposes"])) return false; + if (typeof r["expires_at"] !== "number") return false; + if (typeof r["secret_version"] !== "number") return false; + if (r["format_version"] === 3) { + if (typeof r["principal_kyc_ref"] !== "string") return false; + if (typeof r["agent_kid"] !== "string") return false; + } + return true; +} + +/** + * Reconstruct the canonical signing string the Ed25519 signatures cover. + * + * Field order (V3 = 14 fields, 13 `:` separators): V2's 12 fields then the two + * V3-only fields (principal_kyc_ref, agent_kid). Absent optionals render empty + * so the separator count is invariant. `purposes` is comma-joined after a + * lexicographic sort with escaping (`\` → `\\` first, then `,` → `\,`). Colons + * inside `agent_kid` (from `did:web`) are emitted verbatim — the string is a + * one-way serialization, never re-split. The V3 tail is appended only for + * `format_version >= 3`, so a V2 mandate reconstructs to its 12-field form. + */ +export function reconstructSigningString(f: Record): string { + const esc = (s: string) => s.replace(/\\/g, "\\\\").replace(/,/g, "\\,"); + const purposes = ((f["purposes"] as string[]) ?? []) + .slice() + .sort() + .map(esc) + .join(","); + const version = Number(f["format_version"]); + const parts: unknown[] = [ + String(f["format_version"]), + f["id"], + f["agent_id"], + f["type"], + f["amount"], + f["currency"], + purposes, + String(f["expires_at"]), + f["max_amount"] ?? "", + f["parent_id"] ?? "", + f["denomination"] ?? "", + String(f["secret_version"]), + ]; + if (version >= 3) { + parts.push(f["principal_kyc_ref"] ?? "", f["agent_kid"] ?? ""); + } + return parts.join(":"); +} + +// A raw Ed25519 public key becomes an SPKI KeyObject by prefixing the fixed +// RFC 8410 header. This is the exact wrapper a bare third-party verifier uses. +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +function publicKeyFromRaw(pub: Buffer): KeyObject { + if (pub.length !== 32) { + throw new Error(`Ed25519 public key must be 32 bytes, got ${pub.length}`); + } + return createPublicKey({ + key: Buffer.concat([ED25519_SPKI_PREFIX, pub]), + format: "der", + type: "spki", + }); +} + +/** + * Verify an Ed25519 signature (base64url) over a signing string with only the + * raw 32-byte public key. Returns false — never throws — on malformed input. + */ +export function verifyEd25519( + signingString: string, + signatureB64url: string, + pub: Buffer, +): boolean { + try { + return nodeVerify( + null, + Buffer.from(signingString, "utf8"), + publicKeyFromRaw(pub), + Buffer.from(signatureB64url, "base64url"), + ); + } catch { + return false; + } +} + +/** Coerce a hex string (with/without 0x) or raw bytes into a 32-byte key, or null. */ +function toPubkey(key: string | Uint8Array | undefined): Buffer | null { + if (key === undefined) return null; + if (typeof key === "string") { + const clean = key.trim().toLowerCase().replace(/^0x/, ""); + if (clean.length !== 64 || !/^[0-9a-f]+$/.test(clean)) return null; + return Buffer.from(clean, "hex"); + } + const buf = Buffer.from(key); + return buf.length === 32 ? buf : null; +} + +/** Strip the `#` from an agent key id to recover the bare agent DID. */ +export function agentDidFromKid(kid: string): string { + const hash = kid.indexOf("#"); + return hash === -1 ? kid : kid.slice(0, hash); +} + +/** Per-signature outcome. `skipped` = present but no key supplied to check it. */ +export type SignatureStatus = "verified" | "failed" | "skipped" | "absent"; + +export interface SignatureCheck { + present: boolean; + status: SignatureStatus; + /** The key id associated with the signature (agent kid; issuer has none). */ + kid?: string; +} + +export interface VerifyMandateOptions { + /** Raw 32-byte Ed25519 agent public key — hex string or bytes. */ + agentPublicKey?: string | Uint8Array; + /** Raw 32-byte Ed25519 issuer (platform) public key — hex string or bytes. */ + issuerPublicKey?: string | Uint8Array; +} + +export interface MandateVerification { + /** True iff at least one carried signature verified and none failed. */ + verified: boolean; + mandate: MandateFields; + /** The bare agent DID (kid without its `#fragment`), when present. */ + agentDid?: string; + /** The agent key id from the envelope, when present. */ + kid?: string; + agent: SignatureCheck; + issuer: SignatureCheck; +} + +/** + * Offline-verify a V3 mandate presentation token against supplied public keys. + * + * Pure and network-free: you pass the agent and/or issuer public keys (from + * their did:web documents) and it checks the signatures the token carries. A + * signature with a supplied key that validates is `verified`; a supplied key + * that fails is `failed`; a carried signature with no supplied key is `skipped`; + * a signature the token doesn't carry is `absent`. `verified` is true iff at + * least one signature verified and none failed. + * + * Throws only on a token that cannot be decoded (so a caller can distinguish a + * malformed token from a well-formed but unverified one). + */ +export function verifyMandateToken( + token: string, + opts: VerifyMandateOptions = {}, +): MandateVerification { + const decoded = decodeMandateToken(token); + if (!decoded.ok) { + throw new Error(`cannot decode mandate token: ${decoded.error}`); + } + const t = decoded.token; + const m = t.mandate; + const signingString = reconstructSigningString(m as unknown as Record); + + const kid = t.kid ?? m.agent_kid ?? undefined; + const agentDid = kid ? agentDidFromKid(kid) : undefined; + + const agent = checkSignature(signingString, t.agent_sig, toPubkey(opts.agentPublicKey), kid); + const issuer = checkSignature(signingString, t.issuer_sig, toPubkey(opts.issuerPublicKey), undefined); + + const anyVerified = agent.status === "verified" || issuer.status === "verified"; + const anyFailed = agent.status === "failed" || issuer.status === "failed"; + + const result: MandateVerification = { + verified: anyVerified && !anyFailed, + mandate: m, + agent, + issuer, + }; + if (agentDid) result.agentDid = agentDid; + if (kid) result.kid = kid; + return result; +} + +function checkSignature( + signingString: string, + sig: string | undefined, + pub: Buffer | null, + kid: string | undefined, +): SignatureCheck { + if (!sig) return { present: false, status: "absent", ...(kid ? { kid } : {}) }; + if (!pub) return { present: true, status: "skipped", ...(kid ? { kid } : {}) }; + const ok = verifyEd25519(signingString, sig, pub); + return { present: true, status: ok ? "verified" : "failed", ...(kid ? { kid } : {}) }; +} From 20da5668517bf5acc152f159828ab46ca44c4d28 Mon Sep 17 00:00:00 2001 From: Fabiano Cruz Date: Sun, 5 Jul 2026 10:03:03 -0300 Subject: [PATCH 4/4] =?UTF-8?q?feat(python):=20codespar.mandate=20?= =?UTF-8?q?=E2=80=94=20offline=20V3=20verify=20(parity=20+=20optional=20cr?= =?UTF-8?q?ypto)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the V3 mandate codec to the Python SDK as a submodule (mirrors the TS ./mandate subpath — imported explicitly, kept out of the base import path): - decode_mandate_token / reconstruct_signing_string: pure stdlib, zero new deps. The 14-field canonical string is byte-locked against the same shared canonical.v3.fixture.json the enterprise codec, TS SDK, and CLI pin. - verify_ed25519 / verify_mandate_token: Ed25519 offline verify (hex or bytes keys) → { verified, mandate, agent/issuer SignatureCheck, agent_did, kid }. Ed25519 isn't in the stdlib, so it lazy-imports 'cryptography' and raises a clear install hint if absent — the base package keeps its single runtime dependency (httpx). Opt in via the new [verify] extra. Tests byte-lock decode + reconstruction (always run); the verify cases are guarded on the optional extra (installed in the dev extra, so they run in CI). Verified locally against real cryptography 49.0.0 (agent+issuer verify, tamper fails, wrong/short key fails, partial-key skip). Note: this env is Python 3.9 and the package requires 3.10+ (match statements), so the pytest suite itself runs in CI; behavior here was exercised via standalone module load. Co-Authored-By: Claude Fable 5 --- packages/python/pyproject.toml | 7 + packages/python/src/codespar/mandate.py | 320 ++++++++++++++++++ .../tests/_fixtures/canonical.v3.fixture.json | 28 ++ packages/python/tests/test_mandate.py | 217 ++++++++++++ 4 files changed, 572 insertions(+) create mode 100644 packages/python/src/codespar/mandate.py create mode 100644 packages/python/tests/_fixtures/canonical.v3.fixture.json create mode 100644 packages/python/tests/test_mandate.py diff --git a/packages/python/pyproject.toml b/packages/python/pyproject.toml index 5c5e412..b33f64e 100644 --- a/packages/python/pyproject.toml +++ b/packages/python/pyproject.toml @@ -30,10 +30,17 @@ dependencies = [ ] [project.optional-dependencies] +# Offline V3 mandate verification (codespar.mandate) needs Ed25519, which is +# not in the stdlib. Opt in with `pip install codespar[verify]`; the base +# package keeps its single runtime dependency (httpx). +verify = [ + "cryptography>=42", +] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", "pytest-httpx>=0.30", + "cryptography>=42", "ruff>=0.6", "mypy>=1.11", ] diff --git a/packages/python/src/codespar/mandate.py b/packages/python/src/codespar/mandate.py new file mode 100644 index 0000000..9ad735d --- /dev/null +++ b/packages/python/src/codespar/mandate.py @@ -0,0 +1,320 @@ +""" +Offline V3 mandate verification for the CodeSpar Python SDK. + +A third party holding only a presentation token and a raw Ed25519 public key +(from the agent's ``did:web`` document) can reconstruct the exact signing string +and verify the agent + issuer signatures — no CodeSpar API call. This mirrors +``@codespar/sdk/mandate`` (TS) and ``codespar mandate verify`` (CLI); all three +byte-lock the canonical string against the same fixture, so they cannot drift. + +Import it explicitly (``from codespar.mandate import verify_mandate_token``) — it +is not re-exported from the top-level ``codespar`` namespace, keeping the crypto +dependency out of the base import path. Decoding and signing-string +reconstruction are pure stdlib (zero dependencies). Ed25519 *verification* needs +the optional ``cryptography`` extra:: + + pip install 'codespar[verify]' + +``verify_ed25519`` / ``verify_mandate_token`` (when given a key) raise a clear +``RuntimeError`` if that extra is not installed. +""" + +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass +from typing import Any + +__all__ = [ + "MandateDecodeError", + "DecodedMandateToken", + "SignatureCheck", + "MandateVerification", + "decode_mandate_token", + "reconstruct_signing_string", + "verify_ed25519", + "verify_mandate_token", + "agent_did_from_kid", +] + + +class MandateDecodeError(ValueError): + """Raised when a token is not a well-formed mandate presentation token. + + The message is the stable machine code: ``invalid_payload`` or + ``mandate_format_unsupported``. + """ + + +@dataclass +class DecodedMandateToken: + """A decoded token: the signed fields plus the signature envelope.""" + + mandate: dict[str, Any] + # Org-HMAC hex digest. Present on every version; NOT offline-verifiable + # (needs the org secret) — carried through for completeness. + signature: str + agent_sig: str | None = None + issuer_sig: str | None = None + kid: str | None = None + + +@dataclass +class SignatureCheck: + """Per-signature outcome. ``skipped`` = present but no key supplied.""" + + present: bool + # "verified" | "failed" | "skipped" | "absent" + status: str + kid: str | None = None + + +@dataclass +class MandateVerification: + """Result of :func:`verify_mandate_token`.""" + + verified: bool + mandate: dict[str, Any] + agent: SignatureCheck + issuer: SignatureCheck + agent_did: str | None = None + kid: str | None = None + + +def _b64url_decode(s: str) -> bytes: + # Node emits base64url without padding; restore it before decoding. + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) + + +def _s(value: Any) -> str: + # Absent optional fields render as the empty string so the separator count + # stays invariant — never the literal "None". + return "" if value is None else str(value) + + +def _is_valid_fields(r: dict[str, Any]) -> bool: + version = r.get("format_version") + if not isinstance(version, int) or isinstance(version, bool): + return False + if not isinstance(r.get("id"), str): + return False + if not isinstance(r.get("agent_id"), str): + return False + if r.get("type") not in ("payment", "subscription", "delegation"): + return False + if not isinstance(r.get("amount"), str): + return False + if not isinstance(r.get("currency"), str): + return False + if not isinstance(r.get("purposes"), list): + return False + expires_at = r.get("expires_at") + if not isinstance(expires_at, int) or isinstance(expires_at, bool): + return False + secret_version = r.get("secret_version") + if not isinstance(secret_version, int) or isinstance(secret_version, bool): + return False + # V3 binds the KYC'd principal and the signing key into the signed string. + if version == 3: + if not isinstance(r.get("principal_kyc_ref"), str): + return False + if not isinstance(r.get("agent_kid"), str): + return False + return True + + +def decode_mandate_token(token: str) -> DecodedMandateToken: + """Decode a base64url JSON presentation token. + + Splits the envelope (``signature`` / ``agent_sig`` / ``issuer_sig`` / ``kid``) + from the signed fields so ``mandate`` is exactly the field set the signatures + cover. Does not verify anything. Raises :class:`MandateDecodeError` on a + malformed or unsupported token. + """ + try: + raw = json.loads(_b64url_decode(token).decode("utf-8")) + except Exception as exc: + raise MandateDecodeError("invalid_payload") from exc + + if not isinstance(raw, dict): + raise MandateDecodeError("invalid_payload") + + version = raw.get("format_version") + if not isinstance(version, int) or isinstance(version, bool) or version < 2: + raise MandateDecodeError("mandate_format_unsupported") + + if not _is_valid_fields(raw): + raise MandateDecodeError("invalid_payload") + + signature = raw.get("signature") + if not isinstance(signature, str): + raise MandateDecodeError("invalid_payload") + + envelope = {"signature", "agent_sig", "issuer_sig", "kid"} + mandate: dict[str, Any] = {k: v for k, v in raw.items() if k not in envelope} + + def _str_or_none(key: str) -> str | None: + value = raw.get(key) + return value if isinstance(value, str) else None + + return DecodedMandateToken( + mandate=mandate, + signature=signature, + agent_sig=_str_or_none("agent_sig"), + issuer_sig=_str_or_none("issuer_sig"), + kid=_str_or_none("kid"), + ) + + +def reconstruct_signing_string(fields: dict[str, Any]) -> str: + r"""Reconstruct the canonical signing string the Ed25519 signatures cover. + + Field order (V3 = 14 fields, 13 ``:`` separators): V2's 12 fields then the + two V3-only fields (``principal_kyc_ref``, ``agent_kid``). Absent optionals + render empty; ``purposes`` is comma-joined after a lexicographic sort with + escaping (``\`` -> ``\\`` first, then ``,`` -> ``\,``). Colons inside + ``agent_kid`` (from ``did:web``) are emitted verbatim. The V3 tail is + appended only for ``format_version >= 3``. + """ + + def esc(member: str) -> str: + return member.replace("\\", "\\\\").replace(",", "\\,") + + purposes = ",".join(esc(p) for p in sorted(fields.get("purposes") or [])) + version = int(fields.get("format_version")) + parts = [ + str(fields.get("format_version")), + _s(fields.get("id")), + _s(fields.get("agent_id")), + _s(fields.get("type")), + _s(fields.get("amount")), + _s(fields.get("currency")), + purposes, + str(fields.get("expires_at")), + _s(fields.get("max_amount")), + _s(fields.get("parent_id")), + _s(fields.get("denomination")), + str(fields.get("secret_version")), + ] + if version >= 3: + parts.append(_s(fields.get("principal_kyc_ref"))) + parts.append(_s(fields.get("agent_kid"))) + return ":".join(parts) + + +def verify_ed25519(signing_string: str, signature_b64url: str, pubkey: bytes) -> bool: + """Verify an Ed25519 signature (base64url) over a signing string. + + Uses only the raw 32-byte public key. Returns ``False`` — never raises — on a + bad signature or malformed key. Requires the optional ``cryptography`` + dependency; raises ``RuntimeError`` with install guidance if it is absent. + """ + try: + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PublicKey, + ) + except ImportError as exc: + raise RuntimeError( + "Ed25519 verification requires the optional 'cryptography' dependency. " + "Install it with: pip install 'codespar[verify]'" + ) from exc + + try: + key = Ed25519PublicKey.from_public_bytes(pubkey) + except Exception: + # A malformed key is a verification failure, not an error. + return False + try: + key.verify(_b64url_decode(signature_b64url), signing_string.encode("utf-8")) + return True + except InvalidSignature: + return False + + +def _to_pubkey(key: str | bytes | bytearray | None) -> bytes | None: + if key is None: + return None + if isinstance(key, str): + clean = key.strip().lower() + if clean.startswith("0x"): + clean = clean[2:] + if len(clean) != 64 or any(c not in "0123456789abcdef" for c in clean): + return None + return bytes.fromhex(clean) + raw = bytes(key) + return raw if len(raw) == 32 else None + + +def agent_did_from_kid(kid: str) -> str: + """Strip the ``#`` from an agent key id to recover the bare DID.""" + idx = kid.find("#") + return kid if idx == -1 else kid[:idx] + + +def _check_signature( + signing_string: str, + sig: str | None, + pubkey: bytes | None, + kid: str | None, +) -> SignatureCheck: + if sig is None: + return SignatureCheck(present=False, status="absent", kid=kid) + if pubkey is None: + return SignatureCheck(present=True, status="skipped", kid=kid) + ok = verify_ed25519(signing_string, sig, pubkey) + return SignatureCheck(present=True, status="verified" if ok else "failed", kid=kid) + + +def verify_mandate_token( + token: str, + *, + agent_public_key: str | bytes | bytearray | None = None, + issuer_public_key: str | bytes | bytearray | None = None, +) -> MandateVerification: + """Offline-verify a V3 mandate presentation token against supplied keys. + + Pure and network-free: pass the agent and/or issuer public keys (hex string + or raw bytes, from their ``did:web`` documents). A signature with a supplied + key that validates is ``verified``; a supplied key that fails is ``failed``; + a carried signature with no supplied key is ``skipped``; a signature the + token does not carry is ``absent``. ``verified`` is ``True`` iff at least one + signature verified and none failed. + + Raises :class:`MandateDecodeError` on an undecodable token, and + ``RuntimeError`` if a key is supplied but the ``cryptography`` extra is not + installed. + """ + decoded = decode_mandate_token(token) + mandate = decoded.mandate + signing_string = reconstruct_signing_string(mandate) + + kid_value = decoded.kid or mandate.get("agent_kid") + kid = kid_value if isinstance(kid_value, str) else None + agent_did = agent_did_from_kid(kid) if kid is not None else None + + agent = _check_signature( + signing_string, + decoded.agent_sig, + _to_pubkey(agent_public_key), + kid, + ) + issuer = _check_signature( + signing_string, + decoded.issuer_sig, + _to_pubkey(issuer_public_key), + None, + ) + + any_verified = agent.status == "verified" or issuer.status == "verified" + any_failed = agent.status == "failed" or issuer.status == "failed" + + return MandateVerification( + verified=any_verified and not any_failed, + mandate=mandate, + agent=agent, + issuer=issuer, + agent_did=agent_did, + kid=kid, + ) diff --git a/packages/python/tests/_fixtures/canonical.v3.fixture.json b/packages/python/tests/_fixtures/canonical.v3.fixture.json new file mode 100644 index 0000000..fcae33f --- /dev/null +++ b/packages/python/tests/_fixtures/canonical.v3.fixture.json @@ -0,0 +1,28 @@ +{ + "_comment": "Build 2 V3 byte-frozen fixture. DO NOT CHANGE. Mirrors canonical.fixture.json for V3 (14 fields, 13 separators = V2's 12 + principal_kyc_ref + agent_kid). HMAC key is the same test key; Ed25519 seeds are sha256('codespar-build2-agent-key-fixture') and sha256('codespar-build2-issuer-key-fixture'). Ed25519 is deterministic (RFC 8032) so agent_sig/issuer_sig are frozen. agent_kid intentionally contains colons (did:web) to pin the colon-in-final-field behaviour.", + "key": "test-fixture-key-do-not-use-in-prod-0123456789abcdef", + "input": { + "format_version": 3, + "id": "mnd_v3_abc...", + "agent_id": "a1", + "type": "delegation", + "amount": "5000", + "currency": "BRL", + "purposes": ["refund", "utility"], + "expires_at": 1735689600, + "max_amount": null, + "parent_id": null, + "denomination": null, + "secret_version": 1, + "principal_kyc_ref": "kyc_celcoin_11144477735", + "agent_kid": "did:web:id.codespar.dev:org_demo:a1#1" + }, + "canonical_string": "3:mnd_v3_abc...:a1:delegation:5000:BRL:refund,utility:1735689600::::1:kyc_celcoin_11144477735:did:web:id.codespar.dev:org_demo:a1#1", + "hmac_sha256_hex": "4e627ac1074f1c15684cb10d63b1d4481fbeda8f82450f26c04b7860210fc3e4", + "agent_seed_hex": "1050a34bae067327e59c1ac43f3a25190a582448219521d35d000650d714ec9a", + "agent_pubkey_hex": "70940e2c00698a520339c07332ff35c05cad7b39d024a4f4737310e740b2ecc3", + "issuer_seed_hex": "23dddf5e3a0038f1a81cda29dbbd5d1c97d434a6610a814d3d628f7b269d031f", + "issuer_pubkey_hex": "41826edefd6d40fd7c32dd63ebcc0476e4324cd6a424881c53f170fee9865b14", + "agent_sig_b64url": "F9V0rSsfT1IjeF_4_9pciIV7w2ERdLN5r-UMkCdQWjce9ouYYY2awrrjFw5cENKbTEw0l61HitYPO5ZocfQ7Bg", + "issuer_sig_b64url": "ClsA7hWq1fBAG-qrhYAahDPm0xMdPl5lOJ6X0KxSzruJ2kD6NCzH-baSbxcwTxTCkWd1eBpJVke3onUR6mH1Ag" +} diff --git a/packages/python/tests/test_mandate.py b/packages/python/tests/test_mandate.py new file mode 100644 index 0000000..aa21e2f --- /dev/null +++ b/packages/python/tests/test_mandate.py @@ -0,0 +1,217 @@ +""" +Offline V3 mandate verification (``codespar.mandate``). + +Byte-locks the canonical signing string against the shared freeze at +``tests/_fixtures/canonical.v3.fixture.json`` — the same JSON the enterprise +codec, the TS SDK, and the CLI pin — so the four implementations cannot drift. + +Decode + reconstruction are pure stdlib and always run. Ed25519 verification +needs the optional ``cryptography`` extra; those tests skip when it is absent +(``pip install codespar[verify]`` / the ``dev`` extra installs it in CI). +""" + +from __future__ import annotations + +import base64 +import json +from pathlib import Path +from typing import Any + +import pytest + +from codespar.mandate import ( + MandateDecodeError, + agent_did_from_kid, + decode_mandate_token, + reconstruct_signing_string, + verify_ed25519, + verify_mandate_token, +) + +FIXTURE_PATH = Path(__file__).parent / "_fixtures" / "canonical.v3.fixture.json" +FX: dict[str, Any] = json.loads(FIXTURE_PATH.read_text()) + +try: + import cryptography # noqa: F401 + + HAS_CRYPTO = True +except ImportError: + HAS_CRYPTO = False + +requires_crypto = pytest.mark.skipif( + not HAS_CRYPTO, + reason="offline verify needs the optional 'cryptography' extra (pip install codespar[verify])", +) + + +def _make_token(fields: dict[str, Any], **envelope: Any) -> str: + raw = json.dumps({**fields, **envelope}).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +TOKEN = _make_token( + FX["input"], + signature=FX["hmac_sha256_hex"], + agent_sig=FX["agent_sig_b64url"], + issuer_sig=FX["issuer_sig_b64url"], + kid=FX["input"]["agent_kid"], +) + + +# ── decode ──────────────────────────────────────────────────────────── + + +def test_decode_round_trips_envelope_and_splits_signed_fields() -> None: + dec = decode_mandate_token(TOKEN) + assert dec.signature == FX["hmac_sha256_hex"] + assert dec.agent_sig == FX["agent_sig_b64url"] + assert dec.issuer_sig == FX["issuer_sig_b64url"] + assert dec.kid == FX["input"]["agent_kid"] + for leaked in ("signature", "agent_sig", "issuer_sig", "kid"): + assert leaked not in dec.mandate + assert dec.mandate["principal_kyc_ref"] == FX["input"]["principal_kyc_ref"] + + +def test_decode_rejects_garbage() -> None: + with pytest.raises(MandateDecodeError, match="invalid_payload"): + decode_mandate_token("!!!nope!!!") + + +def test_decode_rejects_unsupported_version() -> None: + bad = _make_token({**FX["input"], "format_version": 1}, signature="x") + with pytest.raises(MandateDecodeError, match="mandate_format_unsupported"): + decode_mandate_token(bad) + + +def test_decode_rejects_v3_missing_required_fields() -> None: + fields = {k: v for k, v in FX["input"].items() if k not in ("principal_kyc_ref", "agent_kid")} + with pytest.raises(MandateDecodeError, match="invalid_payload"): + decode_mandate_token(_make_token(fields, signature="x")) + + +# ── reconstruct (byte-lock) ─────────────────────────────────────────── + + +def test_reconstruct_is_byte_identical_to_frozen_canonical_string() -> None: + assert reconstruct_signing_string(FX["input"]) == FX["canonical_string"] + + +def test_reconstruct_sorts_and_escapes_purposes() -> None: + fields = {**FX["input"], "purposes": ["utility", "refund"]} + assert reconstruct_signing_string(fields) == FX["canonical_string"] + + +def test_reconstruct_from_decoded_mandate_matches() -> None: + dec = decode_mandate_token(TOKEN) + assert reconstruct_signing_string(dec.mandate) == FX["canonical_string"] + + +# ── helpers ─────────────────────────────────────────────────────────── + + +def test_agent_did_from_kid_strips_fragment() -> None: + assert agent_did_from_kid("did:web:id.codespar.dev:org_demo:a1#1") == ( + "did:web:id.codespar.dev:org_demo:a1" + ) + assert agent_did_from_kid("did:web:x") == "did:web:x" + + +def test_verify_with_no_keys_skips_and_is_not_verified() -> None: + res = verify_mandate_token(TOKEN) + assert res.agent.status == "skipped" + assert res.issuer.status == "skipped" + assert res.verified is False + assert res.agent_did == "did:web:id.codespar.dev:org_demo:a1" + assert res.kid == FX["input"]["agent_kid"] + + +@pytest.mark.skipif(HAS_CRYPTO, reason="only meaningful when cryptography is absent") +def test_verify_without_cryptography_raises_helpful_error() -> None: + with pytest.raises(RuntimeError, match="cryptography"): + verify_mandate_token(TOKEN, agent_public_key=FX["agent_pubkey_hex"]) + + +# ── Ed25519 verification (optional extra) ───────────────────────────── + + +@requires_crypto +def test_verify_ed25519_agent_and_issuer() -> None: + agent = bytes.fromhex(FX["agent_pubkey_hex"]) + issuer = bytes.fromhex(FX["issuer_pubkey_hex"]) + assert verify_ed25519(FX["canonical_string"], FX["agent_sig_b64url"], agent) is True + assert verify_ed25519(FX["canonical_string"], FX["issuer_sig_b64url"], issuer) is True + + +@requires_crypto +def test_verify_ed25519_tamper_and_wrong_key_fail() -> None: + agent = bytes.fromhex(FX["agent_pubkey_hex"]) + issuer = bytes.fromhex(FX["issuer_pubkey_hex"]) + forged = reconstruct_signing_string({**FX["input"], "amount": "9999"}) + assert verify_ed25519(forged, FX["agent_sig_b64url"], agent) is False + assert verify_ed25519(FX["canonical_string"], FX["agent_sig_b64url"], issuer) is False + assert verify_ed25519(FX["canonical_string"], FX["agent_sig_b64url"], b"\x00" * 5) is False + + +@requires_crypto +def test_verify_mandate_token_both_signatures() -> None: + res = verify_mandate_token( + TOKEN, + agent_public_key=FX["agent_pubkey_hex"], + issuer_public_key=FX["issuer_pubkey_hex"], + ) + assert res.verified is True + assert res.agent.status == "verified" + assert res.issuer.status == "verified" + assert res.agent.kid == FX["input"]["agent_kid"] + assert res.mandate["amount"] == "5000" + + +@requires_crypto +def test_verify_mandate_token_accepts_raw_bytes_keys() -> None: + res = verify_mandate_token( + TOKEN, + agent_public_key=bytes.fromhex(FX["agent_pubkey_hex"]), + issuer_public_key=bytes.fromhex(FX["issuer_pubkey_hex"]), + ) + assert res.verified is True + + +@requires_crypto +def test_verify_mandate_token_agent_only_skips_issuer() -> None: + res = verify_mandate_token(TOKEN, agent_public_key=FX["agent_pubkey_hex"]) + assert res.agent.status == "verified" + assert res.issuer.status == "skipped" + assert res.verified is True + + +@requires_crypto +def test_verify_mandate_token_wrong_key_fails() -> None: + res = verify_mandate_token(TOKEN, agent_public_key=FX["issuer_pubkey_hex"]) + assert res.agent.status == "failed" + assert res.verified is False + + +@requires_crypto +def test_verify_mandate_token_tampered_fails() -> None: + tampered = _make_token( + {**FX["input"], "amount": "9999"}, + signature=FX["hmac_sha256_hex"], + agent_sig=FX["agent_sig_b64url"], + issuer_sig=FX["issuer_sig_b64url"], + kid=FX["input"]["agent_kid"], + ) + res = verify_mandate_token( + tampered, + agent_public_key=FX["agent_pubkey_hex"], + issuer_public_key=FX["issuer_pubkey_hex"], + ) + assert res.verified is False + assert res.agent.status == "failed" + assert res.issuer.status == "failed" + + +@requires_crypto +def test_verify_mandate_token_malformed_hex_key_is_skipped() -> None: + res = verify_mandate_token(TOKEN, agent_public_key="xyz") + assert res.agent.status == "skipped" + assert res.verified is False