diff --git a/.changeset/p256-jwk-curve-name.md b/.changeset/p256-jwk-curve-name.md new file mode 100644 index 00000000..d9001cc4 --- /dev/null +++ b/.changeset/p256-jwk-curve-name.md @@ -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. diff --git a/packages/keys/src/encoding/jwk.test.ts b/packages/keys/src/encoding/jwk.test.ts index c0979c72..a60b73ad 100644 --- a/packages/keys/src/encoding/jwk.test.ts +++ b/packages/keys/src/encoding/jwk.test.ts @@ -7,6 +7,7 @@ import { isPublicKeyJwk, isPublicKeyJwkEd25519, isPublicKeyJwkSecp256k1, + isPublicKeyJwkSecp256r1, publicKeyBytesToJwk, publicKeyJwkToBytes, type PublicKeyJwkEd25519, @@ -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, + ) + }) + }) }) diff --git a/packages/keys/src/encoding/jwk.ts b/packages/keys/src/encoding/jwk.ts index fdae2e99..3f0f6bdc 100644 --- a/packages/keys/src/encoding/jwk.ts +++ b/packages/keys/src/encoding/jwk.ts @@ -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 @@ -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 @@ -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") } /** @@ -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 @@ -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 diff --git a/packages/keys/src/keypair.test.ts b/packages/keys/src/keypair.test.ts index b279ebf4..076e42bc 100644 --- a/packages/keys/src/keypair.test.ts +++ b/packages/keys/src/keypair.test.ts @@ -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) diff --git a/packages/keys/src/keypair.ts b/packages/keys/src/keypair.ts index 652f368e..3db85b37 100644 --- a/packages/keys/src/keypair.ts +++ b/packages/keys/src/keypair.ts @@ -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, } } diff --git a/packages/keys/src/public-key.test.ts b/packages/keys/src/public-key.test.ts index 0c1c5cd2..bb35e9f0 100644 --- a/packages/keys/src/public-key.test.ts +++ b/packages/keys/src/public-key.test.ts @@ -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), }) diff --git a/packages/vc/src/verification/parse-jwt-credential.test.ts b/packages/vc/src/verification/parse-jwt-credential.test.ts index 85b53082..17cdc966 100644 --- a/packages/vc/src/verification/parse-jwt-credential.test.ts +++ b/packages/vc/src/verification/parse-jwt-credential.test.ts @@ -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"