diff --git a/.changeset/parse-jwt-credential-shape-guard.md b/.changeset/parse-jwt-credential-shape-guard.md new file mode 100644 index 0000000..3d0c5cf --- /dev/null +++ b/.changeset/parse-jwt-credential-shape-guard.md @@ -0,0 +1,9 @@ +--- +"@agentcommercekit/vc": patch +--- + +Validate the decoded credential shape in `parseJwtCredential` instead of relying +on an unchecked cast. `verifyCredential` (did-jwt-vc) returns its own credential +shape; `parseJwtCredential` now checks it conforms to `W3CCredential` via +`isCredential` and throws `InvalidCredentialError` on a divergent shape, rather +than silently casting it. diff --git a/packages/vc/src/verification/parse-jwt-credential.test.ts b/packages/vc/src/verification/parse-jwt-credential.test.ts index 6912a82..280852c 100644 --- a/packages/vc/src/verification/parse-jwt-credential.test.ts +++ b/packages/vc/src/verification/parse-jwt-credential.test.ts @@ -5,13 +5,22 @@ import { } from "@agentcommercekit/did" import { createJwtSigner } from "@agentcommercekit/jwt" import { generateKeypair } from "@agentcommercekit/keys" -import { expect, test } from "vitest" +import { verifyCredential } from "did-jwt-vc" +import { expect, it, vi } from "vitest" import { createCredential } from "../create-credential" import { signCredential } from "../signing/sign-credential" +import { InvalidCredentialError } from "./errors" import { parseJwtCredential } from "./parse-jwt-credential" -test("parseJwtCredential should parse a valid credential", async () => { +vi.mock("did-jwt-vc", async (importOriginal) => { + const actual = await importOriginal() + // Delegate to the real implementation by default; individual tests can + // override `verifyCredential` to exercise malformed decoder output. + return { ...actual, verifyCredential: vi.fn(actual.verifyCredential) } +}) + +it("parseJwtCredential should parse a valid credential", async () => { const resolver = getDidResolver() // Generate keypair for the issuer @@ -51,7 +60,7 @@ test("parseJwtCredential should parse a valid credential", async () => { expect(vc.type).toContain("TestCredential") }) -test("verifyCredentialJwt should throw for invalid credential", async () => { +it("verifyCredentialJwt should throw for invalid credential", async () => { const resolver = getDidResolver() const invalidCredential = "invalid.jwt.token" @@ -59,3 +68,60 @@ test("verifyCredentialJwt should throw for invalid credential", async () => { parseJwtCredential(invalidCredential, resolver), ).rejects.toThrow() }) + +it("throws when the verified JWT does not decode to a valid credential", async () => { + const resolver = getDidResolver() + + // Simulate did-jwt-vc returning a shape that diverges from W3CCredential + vi.mocked(verifyCredential).mockResolvedValueOnce({ + verifiableCredential: { not: "a credential" }, + } as unknown as Awaited>) + + await expect(parseJwtCredential("a.b.c", resolver)).rejects.toThrow( + InvalidCredentialError, + ) +}) + +it("throws when the decoded credential has a non-normalized string issuer", async () => { + const resolver = getDidResolver() + + // Downstream reads `issuer.id`, so a top-level string issuer must be rejected + vi.mocked(verifyCredential).mockResolvedValueOnce({ + verifiableCredential: { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential"], + issuer: "did:example:issuer", + issuanceDate: "2024-01-01T00:00:00.000Z", + credentialSubject: { id: "did:example:subject" }, + proof: { type: "JwtProof2020", jwt: "a.b.c" }, + }, + } as unknown as Awaited>) + + await expect(parseJwtCredential("a.b.c", resolver)).rejects.toThrow( + InvalidCredentialError, + ) +}) + +it("returns the decoded credential for a JSON-LD object context entry", async () => { + const resolver = getDidResolver() + + // A valid VC with an object `@context` entry must NOT be false-rejected + const verifiableCredential = { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + { ex: "https://example.com/vocab#" }, + ], + type: ["VerifiableCredential"], + issuer: { id: "did:example:issuer" }, + issuanceDate: "2024-01-01T00:00:00.000Z", + credentialSubject: { id: "did:example:subject" }, + proof: { type: "JwtProof2020", jwt: "a.b.c" }, + } + vi.mocked(verifyCredential).mockResolvedValueOnce({ + verifiableCredential, + } as unknown as Awaited>) + + await expect(parseJwtCredential("a.b.c", resolver)).resolves.toBe( + verifiableCredential, + ) +}) diff --git a/packages/vc/src/verification/parse-jwt-credential.ts b/packages/vc/src/verification/parse-jwt-credential.ts index 5b75d67..d53fc46 100644 --- a/packages/vc/src/verification/parse-jwt-credential.ts +++ b/packages/vc/src/verification/parse-jwt-credential.ts @@ -2,6 +2,40 @@ import type { Resolvable } from "@agentcommercekit/did" import { verifyCredential } from "did-jwt-vc" import type { Verifiable, W3CCredential } from "../types" +import { InvalidCredentialError } from "./errors" + +/** + * Validate that a decoded credential has the shape the verification chain relies + * on. did-jwt-vc returns its own credential type; this guards exactly the fields + * downstream code reads — `issuer.id`, `type`, `credentialSubject` — plus the + * `proof` that makes it a {@link Verifiable}, rather than trusting an unchecked + * cast that would silently mask a divergent shape. + * + * It intentionally does NOT enforce ACK's authoring schema, so valid third-party + * VCs (e.g. an object `@context` entry, or a VC 2.0 `validFrom`) are not rejected + * here; conversely a top-level string `issuer` is rejected because downstream + * reads `issuer.id`. + */ +function isDecodedCredential( + value: unknown, +): value is Verifiable { + if (typeof value !== "object" || value === null) { + return false + } + + const credential = value as Record + const issuer = credential.issuer + + return ( + typeof credential.credentialSubject === "object" && + credential.credentialSubject !== null && + typeof issuer === "object" && + issuer !== null && + typeof (issuer as Record).id === "string" && + Array.isArray(credential.type) && + credential.proof != null + ) +} /** * Parse a JWT credential @@ -16,5 +50,11 @@ export async function parseJwtCredential( ): Promise> { const result = await verifyCredential(jwt, resolver) + if (!isDecodedCredential(result.verifiableCredential)) { + throw new InvalidCredentialError( + "Verified JWT did not decode to a valid W3C credential", + ) + } + return result.verifiableCredential as Verifiable } diff --git a/packages/vc/src/verification/verify-proof.test.ts b/packages/vc/src/verification/verify-proof.test.ts index 50d87e6..d8bac7b 100644 --- a/packages/vc/src/verification/verify-proof.test.ts +++ b/packages/vc/src/verification/verify-proof.test.ts @@ -63,8 +63,12 @@ describe("verifyProof", () => { } const verifiableCredential = { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential"], issuer: { id: "did:example:issuer" }, + issuanceDate: "2024-01-01T00:00:00.000Z", credentialSubject: { id: "did:example:subject" }, + proof: { type: "JwtProof2020", jwt: "valid.jwt.token" }, } vi.mocked(verifyCredential).mockResolvedValueOnce({ diff --git a/packages/vc/src/verification/verify-proof.ts b/packages/vc/src/verification/verify-proof.ts index 1221ada..72939de 100644 --- a/packages/vc/src/verification/verify-proof.ts +++ b/packages/vc/src/verification/verify-proof.ts @@ -1,7 +1,11 @@ import type { Resolvable } from "@agentcommercekit/did" import type { Verifiable, W3CCredential } from "../types" -import { InvalidProofError, UnsupportedProofTypeError } from "./errors" +import { + InvalidCredentialError, + InvalidProofError, + UnsupportedProofTypeError, +} from "./errors" import { parseJwtCredential } from "./parse-jwt-credential" interface JwtProof { @@ -36,7 +40,12 @@ async function verifyJwtProof( try { return await parseJwtCredential(proof.jwt, resolver) - } catch (_error) { + } catch (error) { + // Preserve a malformed-credential error (the decoded credential is the + // problem, not the signature); wrap anything else as an invalid proof. + if (error instanceof InvalidCredentialError) { + throw error + } throw new InvalidProofError() } }