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
9 changes: 9 additions & 0 deletions .changeset/parse-jwt-credential-shape-guard.md
Original file line number Diff line number Diff line change
@@ -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.
72 changes: 69 additions & 3 deletions packages/vc/src/verification/parse-jwt-credential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("did-jwt-vc")>()
// 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
Expand Down Expand Up @@ -51,11 +60,68 @@ 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"

await expect(
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<ReturnType<typeof verifyCredential>>)

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<ReturnType<typeof verifyCredential>>)

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<ReturnType<typeof verifyCredential>>)

await expect(parseJwtCredential("a.b.c", resolver)).resolves.toBe(
verifiableCredential,
)
})
40 changes: 40 additions & 0 deletions packages/vc/src/verification/parse-jwt-credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<W3CCredential> {
if (typeof value !== "object" || value === null) {
return false
}

const credential = value as Record<string, unknown>
const issuer = credential.issuer

return (
typeof credential.credentialSubject === "object" &&
credential.credentialSubject !== null &&
typeof issuer === "object" &&
issuer !== null &&
typeof (issuer as Record<string, unknown>).id === "string" &&
Array.isArray(credential.type) &&
credential.proof != null
)
}

/**
* Parse a JWT credential
Expand All @@ -16,5 +50,11 @@ export async function parseJwtCredential<T extends W3CCredential>(
): Promise<Verifiable<T>> {
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<T>
}
4 changes: 4 additions & 0 deletions packages/vc/src/verification/verify-proof.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
13 changes: 11 additions & 2 deletions packages/vc/src/verification/verify-proof.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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()
}
}
Expand Down
Loading