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
5 changes: 5 additions & 0 deletions .changeset/thirty-aliens-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agentcommercekit/vc": minor
---

add verifiable presentation creation and signing
103 changes: 103 additions & 0 deletions packages/vc/src/create-presentation.test.ts
Original file line number Diff line number Diff line change
@@ -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<W3CCredential> = {
"@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)
})
})
52 changes: 52 additions & 0 deletions packages/vc/src/create-presentation.ts
Original file line number Diff line number Diff line change
@@ -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<W3CCredential>[]
/**
* 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()
}
}
3 changes: 2 additions & 1 deletion packages/vc/src/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T extends W3CCredential> = {
/**
Expand All @@ -43,7 +24,7 @@ type SignedCredential<T extends W3CCredential> = {
*/
export async function signCredential<T extends W3CCredential>(
credential: T,
options: SignCredentialOptions
options: SignOptions
): Promise<SignedCredential<T>> {
options.alg = options.alg ? resolveJwtAlgorithm(options.alg) : options.alg
const jwt = await createVerifiableCredentialJwt(credential, options)
Expand Down
61 changes: 61 additions & 0 deletions packages/vc/src/signing/sign-presentation.test.ts
Original file line number Diff line number Diff line change
@@ -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<W3CCredential> = {
"@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)
})
42 changes: 42 additions & 0 deletions packages/vc/src/signing/sign-presentation.ts
Original file line number Diff line number Diff line change
@@ -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<W3CPresentation>} presentation
*/
verifiablePresentation: Verifiable<W3CPresentation>
/**
* 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<SignedPresentation> {
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 }
}
21 changes: 21 additions & 0 deletions packages/vc/src/signing/types.ts
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 11 additions & 1 deletion packages/vc/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,15 @@ type W3CCredential = {
termsOfUse?: any
}

type W3CPresentation = {
"@context": string[]
type: string[]
id?: string
verifiableCredential?: Verifiable<W3CCredential>[]
holder: string
issuanceDate?: string
expirationDate?: string
}

export type CredentialSubject = W3CCredential["credentialSubject"]
export type { JwtCredentialPayload, Verifiable, W3CCredential }
export type { JwtCredentialPayload, Verifiable, W3CCredential, W3CPresentation }
2 changes: 1 addition & 1 deletion packages/vc/src/verification/parse-jwt-credential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down