Skip to content
Open
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
17 changes: 17 additions & 0 deletions .changeset/p256-jwk-curve-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@agentcommercekit/keys": minor
---

secp256r1 JWKs now use the curve name `"P-256"`, the name JOSE registers for
this curve (RFC 7518, Section 6.2.1.1), instead of `"secp256r1"`.
`publicKeyBytesToJwk` and `keypairToJwk` emit `crv: "P-256"`, the JWK guards
accept it, and `jwkToKeypair` maps it back to the `secp256r1` curve.

DID documents built from a secp256r1 keypair publish their key as a JWK by
default, and did-jwt only matches an EC JWK whose `crv` is `"secp256k1"` or
`"P-256"`. An ES256 JWT or credential signed by such an identity therefore
failed verification with "no matching public key found", and a standard P-256
JWK from any other JOSE library was rejected by `isJwk`.

JWKs stored with the old `crv: "secp256r1"` no longer pass the guards; set
`crv` to `"P-256"` to use them.
30 changes: 30 additions & 0 deletions packages/keys/src/encoding/jwk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isPublicKeyJwk,
isPublicKeyJwkEd25519,
isPublicKeyJwkSecp256k1,
isPublicKeyJwkSecp256r1,
publicKeyBytesToJwk,
publicKeyJwkToBytes,
type PublicKeyJwkEd25519,
Expand Down Expand Up @@ -212,4 +213,33 @@ describe("JWK encoding", () => {
expect(bytes).toEqual(secp256k1Bytes)
})
})

describe("secp256r1", () => {
// The P-256 public key from RFC 7515, Appendix A.3.1
const rfc7515PublicKeyJwk = {
kty: "EC",
crv: "P-256",
x: "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
y: "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
} as const

// RFC 7518, Section 6.2.1.1 registers the curve as "P-256"
test("names the curve P-256", () => {
const bytes = publicKeyJwkToBytes(rfc7515PublicKeyJwk)
expect(publicKeyBytesToJwk(bytes, "secp256r1")).toEqual(
rfc7515PublicKeyJwk,
)
})

test("accepts a P-256 public key JWK", () => {
expect(isPublicKeyJwk(rfc7515PublicKeyJwk)).toBe(true)
expect(isPublicKeyJwkSecp256r1(rfc7515PublicKeyJwk)).toBe(true)
})

test("rejects the curve name secp256r1, which JOSE does not register", () => {
expect(isPublicKeyJwk({ ...rfc7515PublicKeyJwk, crv: "secp256r1" })).toBe(
false,
)
})
})
})
14 changes: 9 additions & 5 deletions packages/keys/src/encoding/jwk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@ export type PrivateKeyJwkSecp256k1 = JwkSecp256k1 & {
d: string // base64url encoded private key
}

/**
* JWK for a secp256r1 key. JOSE registers this curve as "P-256" (RFC 7518,
* Section 6.2.1.1), and that is the name other JOSE implementations look for.
*/
export type JwkSecp256r1 = {
kty: "EC"
crv: "secp256r1"
crv: "P-256"
x: string // base64url encoded x-coordinate
y: string // base64url encoded y-coordinate
d?: string // base64url encoded private key
Expand Down Expand Up @@ -82,7 +86,7 @@ export type PrivateKeyJwk =
*/
function isJwkSecp256(
jwk: unknown,
crv: "secp256k1" | "secp256r1",
crv: "secp256k1" | "P-256",
): jwk is JwkSecp256k1 | JwkSecp256r1 {
if (!isRecord(jwk)) {
return false
Expand Down Expand Up @@ -120,7 +124,7 @@ export function isJwkSecp256k1(jwk: unknown): jwk is JwkSecp256k1 {
* @returns True if the JWK is a valid secp256r1 public key JWK
*/
export function isJwkSecp256r1(jwk: unknown): jwk is JwkSecp256r1 {
return isJwkSecp256(jwk, "secp256r1")
return isJwkSecp256(jwk, "P-256")
}

/**
Expand Down Expand Up @@ -246,7 +250,7 @@ export function publicKeyBytesToJwk(
const yBytes = bytes.slice(33)
return {
kty: "EC",
crv: curve,
crv: curve === "secp256r1" ? "P-256" : curve,
x: bytesToBase64url(xBytes),
y: bytesToBase64url(yBytes),
} as const
Expand Down Expand Up @@ -278,7 +282,7 @@ export function publicKeyJwkToBytes(jwk: PublicKeyJwk): Uint8Array {
const xBytes = base64urlToBytes(jwk.x)

// For secp256k1 and secp256r1, we need to reconstruct the full public key
if (jwk.crv === "secp256k1" || jwk.crv === "secp256r1") {
if (jwk.crv === "secp256k1" || jwk.crv === "P-256") {
if ("y" in jwk && jwk.y) {
const fullKey = new Uint8Array(65)
fullKey[0] = 0x04 // Add the prefix byte
Expand Down
13 changes: 13 additions & 0 deletions packages/keys/src/keypair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,19 @@ describe("keypairToJwk and jwkToKeypair", () => {
expect(reconstructedKeypair.privateKey).toEqual(keypair.privateKey)
})

test("converts secp256r1 keypair to JWK and back", async () => {
const keypair = await generateKeypair("secp256r1")
const jwk = keypairToJwk(keypair)

expect(jwk.kty).toBe("EC")
expect(jwk.crv).toBe("P-256")

const reconstructedKeypair = jwkToKeypair(jwk)
expect(reconstructedKeypair.curve).toBe("secp256r1")
expect(reconstructedKeypair.publicKey).toEqual(keypair.publicKey)
expect(reconstructedKeypair.privateKey).toEqual(keypair.privateKey)
})

test("converts Ed25519 keypair to JWK and back", async () => {
const keypair = await generateKeypair("Ed25519")
const jwk = keypairToJwk(keypair)
Expand Down
2 changes: 1 addition & 1 deletion packages/keys/src/keypair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,6 @@ export function jwkToKeypair(jwk: PrivateKeyJwk): Keypair {
return {
publicKey: publicKeyJwkToBytes(publicKeyJwk),
privateKey: base64urlToBytes(jwk.d),
curve: jwk.crv,
curve: jwk.crv === "P-256" ? "secp256r1" : jwk.crv,
}
}
2 changes: 1 addition & 1 deletion packages/keys/src/public-key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe("public-key methods", () => {
expect(isValidEcJwk).toBe(true)
expect(jwk).toEqual({
kty: "EC",
crv: curve,
crv: curve === "secp256r1" ? "P-256" : curve,
x: expect.any(String),
y: expect.any(String),
})
Expand Down
36 changes: 36 additions & 0 deletions packages/vc/src/verification/parse-jwt-credential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,42 @@ it("parseJwtCredential should parse a valid credential", async () => {
expect(vc.type).toContain("TestCredential")
})

it("parses a credential signed with a secp256r1 key published as a JWK", async () => {
const resolver = getDidResolver()

// A secp256r1 issuer whose DID document carries its key as a JWK, which is
// the default encoding for createDidDocumentFromKeypair
const issuerKeypair = await generateKeypair("secp256r1")
const issuerDid = createDidWebUri("https://issuer.example.com")
resolver.addToCache(
issuerDid,
createDidDocumentFromKeypair({
did: issuerDid,
keypair: issuerKeypair,
}),
)

const credential = createCredential({
id: "test-credential",
type: "TestCredential",
issuer: issuerDid,
subject: createDidWebUri("https://subject.example.com"),
attestation: {
test: "test",
},
})

const jwt = await signCredential(credential, {
did: issuerDid,
signer: createJwtSigner(issuerKeypair),
alg: "ES256",
})

const vc = await parseJwtCredential(jwt, resolver)

expect(vc.issuer.id).toBe(issuerDid)
})

it("verifyCredentialJwt should throw for invalid credential", async () => {
const resolver = getDidResolver()
const invalidCredential = "invalid.jwt.token"
Expand Down