diff --git a/.changeset/thirty-aliens-talk.md b/.changeset/thirty-aliens-talk.md new file mode 100644 index 0000000..8366e67 --- /dev/null +++ b/.changeset/thirty-aliens-talk.md @@ -0,0 +1,5 @@ +--- +"@agentcommercekit/vc": minor +--- + +add verifiable presentation creation and signing diff --git a/packages/vc/src/create-presentation.test.ts b/packages/vc/src/create-presentation.test.ts new file mode 100644 index 0000000..3bc52c9 --- /dev/null +++ b/packages/vc/src/create-presentation.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest" +import { createPresentation } from "./create-presentation" +import type { Verifiable, W3CCredential } from "./types" + +describe("createPresentation", () => { + const mockHolder = "did:example:holder" + + const mockCredential: Verifiable = { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential"], + issuer: { id: "did:example:issuer" }, + credentialSubject: { id: "did:example:subject" }, + issuanceDate: new Date().toISOString(), + proof: { + type: "Ed25519Signature2018" + } + } + + it("should create a basic presentation with required fields", () => { + const presentation = createPresentation({ + credentials: [mockCredential], + holder: mockHolder + }) + + expect(presentation).toEqual({ + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiablePresentation"], + holder: mockHolder, + verifiableCredential: [mockCredential] + }) + }) + + it("should handle multiple credentials", () => { + const secondCredential = { + ...mockCredential, + credentialSubject: { id: "did:example:subject2" } + } + const presentation = createPresentation({ + credentials: [mockCredential, secondCredential], + holder: mockHolder + }) + + expect(presentation.verifiableCredential).toEqual([ + mockCredential, + secondCredential + ]) + }) + + it("should handle custom presentation types", () => { + const customType = "CustomPresentation" + const presentation = createPresentation({ + credentials: [mockCredential], + holder: mockHolder, + type: customType + }) + + expect(presentation.type).toEqual(["VerifiablePresentation", customType]) + }) + + it("should handle multiple presentation types", () => { + const types = ["CustomPresentation1", "CustomPresentation2"] + const presentation = createPresentation({ + credentials: [mockCredential], + holder: mockHolder, + type: types + }) + + expect(presentation.type).toEqual(["VerifiablePresentation", ...types]) + }) + + it("should use provided issuance date", () => { + const issuanceDate = new Date("2024-01-01") + const presentation = createPresentation({ + credentials: [mockCredential], + holder: mockHolder, + issuanceDate + }) + + expect(presentation.issuanceDate).toBe(issuanceDate.toISOString()) + }) + + it("should use provided expiration date", () => { + const expirationDate = new Date("2024-12-31") + const presentation = createPresentation({ + credentials: [mockCredential], + holder: mockHolder, + expirationDate + }) + + expect(presentation.expirationDate).toBe(expirationDate.toISOString()) + }) + + it("should include custom ID when provided", () => { + const customId = "urn:uuid:12345678-1234-5678-1234-567812345678" + const presentation = createPresentation({ + id: customId, + credentials: [mockCredential], + holder: mockHolder + }) + + expect(presentation.id).toBe(customId) + }) +}) diff --git a/packages/vc/src/create-presentation.ts b/packages/vc/src/create-presentation.ts new file mode 100644 index 0000000..b19720e --- /dev/null +++ b/packages/vc/src/create-presentation.ts @@ -0,0 +1,52 @@ +import type { Verifiable, W3CCredential, W3CPresentation } from "./types" +import type { DidUri } from "@agentcommercekit/did" + +export type CreatePresentationParams = { + /** + * The ID of the presentation. + */ + id?: string + /** + * The type of the presentation. + */ + type?: string | string[] + /** + * The holder of the presentation. + */ + holder: DidUri + /** + * The credentials to include in the presentation. + */ + credentials: Verifiable[] + /** + * The issuance date of the presentation. + */ + issuanceDate?: Date + /** + * The expiration date of the presentation. + */ + expirationDate?: Date +} + +export function createPresentation({ + credentials, + holder, + id, + type, + issuanceDate, + expirationDate +}: CreatePresentationParams): W3CPresentation { + const credentialTypes = [type] + .flat() + .filter((t): t is string => !!t && t !== "VerifiablePresentation") + + return { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiablePresentation", ...credentialTypes], + id, + holder, + verifiableCredential: credentials, + issuanceDate: issuanceDate?.toISOString(), + expirationDate: expirationDate?.toISOString() + } +} diff --git a/packages/vc/src/index.ts b/packages/vc/src/index.ts index 80fe497..63314fc 100644 --- a/packages/vc/src/index.ts +++ b/packages/vc/src/index.ts @@ -1,6 +1,7 @@ export * from "./create-credential" export * from "./is-credential" -export * from "./sign-credential" +export * from "./signing/sign-credential" +export * from "./signing/sign-presentation" export * from "./types" export * from "./revocation/make-revocable" export * from "./revocation/status-list-credential" diff --git a/packages/vc/src/sign-credential.test.ts b/packages/vc/src/signing/sign-credential.test.ts similarity index 94% rename from packages/vc/src/sign-credential.test.ts rename to packages/vc/src/signing/sign-credential.test.ts index 4dbd75a..b1b62a5 100644 --- a/packages/vc/src/sign-credential.test.ts +++ b/packages/vc/src/signing/sign-credential.test.ts @@ -6,9 +6,9 @@ import { import { createJwtSigner, verifyJwt } from "@agentcommercekit/jwt" import { generateKeypair } from "@agentcommercekit/keys" import { expect, test } from "vitest" -import { createCredential } from "./create-credential" +import { createCredential } from "../create-credential" import { signCredential } from "./sign-credential" -import type { JwtCredentialPayload } from "./types" +import type { JwtCredentialPayload } from "../types" test("signCredential creates a valid JWT and verifiable credential", async () => { const resolver = getDidResolver() diff --git a/packages/vc/src/sign-credential.ts b/packages/vc/src/signing/sign-credential.ts similarity index 66% rename from packages/vc/src/sign-credential.ts rename to packages/vc/src/signing/sign-credential.ts index a45c495..7d5d0e1 100644 --- a/packages/vc/src/sign-credential.ts +++ b/packages/vc/src/signing/sign-credential.ts @@ -1,27 +1,8 @@ import { isJwtString, resolveJwtAlgorithm } from "@agentcommercekit/jwt" import { createVerifiableCredentialJwt, verifyCredential } from "did-jwt-vc" -import type { Verifiable, W3CCredential } from "./types" -import type { Resolvable } from "@agentcommercekit/did" -import type { JwtAlgorithm, JwtSigner, JwtString } from "@agentcommercekit/jwt" - -interface SignCredentialOptions { - /** - * The algorithm to use for the JWT - */ - alg?: JwtAlgorithm - /** - * The DID of the credential issuer - */ - did: string - /** - * The signer to use for the JWT - */ - signer: JwtSigner - /** - * A resolver to use for parsing the signed credential - */ - resolver: Resolvable -} +import type { SignOptions } from "./types" +import type { Verifiable, W3CCredential } from "../types" +import type { JwtString } from "@agentcommercekit/jwt" type SignedCredential = { /** @@ -43,7 +24,7 @@ type SignedCredential = { */ export async function signCredential( credential: T, - options: SignCredentialOptions + options: SignOptions ): Promise> { options.alg = options.alg ? resolveJwtAlgorithm(options.alg) : options.alg const jwt = await createVerifiableCredentialJwt(credential, options) diff --git a/packages/vc/src/signing/sign-presentation.test.ts b/packages/vc/src/signing/sign-presentation.test.ts new file mode 100644 index 0000000..91215b2 --- /dev/null +++ b/packages/vc/src/signing/sign-presentation.test.ts @@ -0,0 +1,61 @@ +import { + createDidDocumentFromKeypair, + createDidWebUri, + getDidResolver +} from "@agentcommercekit/did" +import { createJwtSigner, verifyJwt } from "@agentcommercekit/jwt" +import { generateKeypair } from "@agentcommercekit/keys" +import { expect, test } from "vitest" +import { createPresentation } from "../create-presentation" +import { signPresentation } from "./sign-presentation" +import type { Verifiable, W3CCredential } from "../types" + +test("signPresentation creates a valid JWT and verifiable presentation", async () => { + const resolver = getDidResolver() + const holderKeypair = await generateKeypair("secp256k1") + const holderDid = createDidWebUri("https://holder.example.com") + + resolver.addToCache( + holderDid, + createDidDocumentFromKeypair({ + did: holderDid, + keypair: holderKeypair + }) + ) + + // Create a mock credential for the presentation + const mockCredential: Verifiable = { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential"], + issuer: { id: "did:example:issuer" }, + credentialSubject: { id: "did:example:subject" }, + issuanceDate: new Date().toISOString(), + proof: { + type: "Ed25519Signature2018" + } + } + + // Generate an unsigned presentation + const presentation = createPresentation({ + credentials: [mockCredential], + holder: holderDid, + id: "test-presentation", + type: "TestPresentation" + }) + + // Sign the presentation + const { jwt, verifiablePresentation } = await signPresentation(presentation, { + did: holderDid, + signer: createJwtSigner(holderKeypair), + alg: "ES256K", + resolver + }) + + // Verify the JWT using did-jwt verifier + const result = await verifyJwt(jwt, { + resolver + }) + + expect(result.payload.iss).toBe(holderDid) + expect(verifiablePresentation).toMatchObject(result.payload.vp) +}) diff --git a/packages/vc/src/signing/sign-presentation.ts b/packages/vc/src/signing/sign-presentation.ts new file mode 100644 index 0000000..9d0001d --- /dev/null +++ b/packages/vc/src/signing/sign-presentation.ts @@ -0,0 +1,42 @@ +import { isJwtString, resolveJwtAlgorithm } from "@agentcommercekit/jwt" +import { createVerifiablePresentationJwt, verifyPresentation } from "did-jwt-vc" +import type { SignOptions } from "./types" +import type { Verifiable, W3CPresentation } from "../types" +import type { JwtString } from "@agentcommercekit/jwt" + +type SignedPresentation = { + /** + * The signed {@link Verifiable} presentation + */ + verifiablePresentation: Verifiable + /** + * The JWT string representation of the signed presentation + */ + jwt: JwtString +} + +/** + * Signs a presentation with a given holder. + * + * @param presentation - The {@link W3CPresentation} to sign + * @param options - The {@link SignCredentialOptions} to use + * @returns A {@link SignedPresentation} + */ +export async function signPresentation( + presentation: W3CPresentation, + options: SignOptions +): Promise { + options.alg = options.alg ? resolveJwtAlgorithm(options.alg) : options.alg + const jwt = await createVerifiablePresentationJwt(presentation, options) + + if (!isJwtString(jwt)) { + throw new Error("Failed to sign presentation") + } + + const { verifiablePresentation } = await verifyPresentation( + jwt, + options.resolver + ) + + return { jwt, verifiablePresentation } +} diff --git a/packages/vc/src/signing/types.ts b/packages/vc/src/signing/types.ts new file mode 100644 index 0000000..944e5c4 --- /dev/null +++ b/packages/vc/src/signing/types.ts @@ -0,0 +1,21 @@ +import type { Resolvable } from "@agentcommercekit/did" +import type { JwtAlgorithm, JwtSigner } from "@agentcommercekit/jwt" + +export interface SignOptions { + /** + * The algorithm to use for the JWT + */ + alg?: JwtAlgorithm + /** + * The DID of the credential issuer + */ + did: string + /** + * The signer to use for the JWT + */ + signer: JwtSigner + /** + * A resolver to use for parsing the signed credential + */ + resolver: Resolvable +} diff --git a/packages/vc/src/types.ts b/packages/vc/src/types.ts index 5f8d857..b4ed6bf 100644 --- a/packages/vc/src/types.ts +++ b/packages/vc/src/types.ts @@ -24,5 +24,15 @@ type W3CCredential = { termsOfUse?: any } +type W3CPresentation = { + "@context": string[] + type: string[] + id?: string + verifiableCredential?: Verifiable[] + holder: string + issuanceDate?: string + expirationDate?: string +} + export type CredentialSubject = W3CCredential["credentialSubject"] -export type { JwtCredentialPayload, Verifiable, W3CCredential } +export type { JwtCredentialPayload, Verifiable, W3CCredential, W3CPresentation } diff --git a/packages/vc/src/verification/parse-jwt-credential.test.ts b/packages/vc/src/verification/parse-jwt-credential.test.ts index d4cd093..d3b8c34 100644 --- a/packages/vc/src/verification/parse-jwt-credential.test.ts +++ b/packages/vc/src/verification/parse-jwt-credential.test.ts @@ -6,9 +6,9 @@ import { import { createJwtSigner } from "@agentcommercekit/jwt" import { generateKeypair } from "@agentcommercekit/keys" import { expect, test } from "vitest" -import { signCredential } from "../sign-credential" import { parseJwtCredential } from "./parse-jwt-credential" import { createCredential } from "../create-credential" +import { signCredential } from "../signing/sign-credential" test("parseJwtCredential should parse a valid credential", async () => { const resolver = getDidResolver()