Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/cli/src/__tests__/did.test.ts
Original file line number Diff line number Diff line change
@@ -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 /<segments>/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",
);
});
});
28 changes: 28 additions & 0 deletions packages/cli/src/__tests__/fixtures/canonical.v3.fixture.json
Original file line number Diff line number Diff line change
@@ -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"
}
174 changes: 174 additions & 0 deletions packages/cli/src/__tests__/mandate-codec.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
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<string, unknown>, {
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<string, unknown>;
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<string, unknown>, { 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<string, unknown>);
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<string, unknown>);
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<string, unknown>);
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<string, unknown>);
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",
);
});
});
Loading
Loading