diff --git a/.changeset/multikey-public-key-multibase.md b/.changeset/multikey-public-key-multibase.md new file mode 100644 index 00000000..8fdb2521 --- /dev/null +++ b/.changeset/multikey-public-key-multibase.md @@ -0,0 +1,32 @@ +--- +"@agentcommercekit/keys": minor +"@agentcommercekit/did": minor +--- + +`publicKeyMultibase` on a `Multikey` verification method is now an actual +Multikey: `multibase(base58-btc, varint(multicodec code) ‖ key-bytes)`, with +secp256k1 and secp256r1 keys in their 33-byte compressed form. It previously +multibase-encoded the raw public key bytes, so the value carried no algorithm +identifier and a relying party could not tell one curve's key from another's. +`createDidKeyUri` already built the prefixed form, so the same key came out two +different ways depending on which function produced it; the two now agree, and +`publicKeyMultibase` equals the `did:key` method-specific identifier for the +same key. + +This changes the value emitted by `createDidDocument`, +`createDidDocumentFromKeypair`, `createDidWebDocument` and +`encodePublicKey("multibase", ...)`, for the `multibase`, `hex` and `base58` +encodings — `hex` and `base58` are converted to a `Multikey` verification +method, so they carry the same value. Documents built with the default `jwk` +encoding are unaffected. A document published with the old value keeps whatever +it was published with; re-generating it produces the corrected value. + +`encodePublicKey("multibase", bytes, curve)` now throws for `secp256k1` and +`secp256r1` when `bytes` is not a point on that curve, since compressing it +requires decoding it. It previously accepted any bytes and encoded them, which +is how a value that was not a key could end up in a `Multikey`. Ed25519 is +unaffected: it has a single 32-byte encoding and no compression step. + +Adds `publicKeyToMultikey(publicKey, curve)` and `keyCurveMulticodecs` to +`@agentcommercekit/keys`, plus `compressPublicKey` on the `secp256k1` and +`secp256r1` curve modules. diff --git a/packages/did/src/create-did-document.test.ts b/packages/did/src/create-did-document.test.ts index be6ce785..68cbcdb9 100644 --- a/packages/did/src/create-did-document.test.ts +++ b/packages/did/src/create-did-document.test.ts @@ -3,19 +3,18 @@ import { generateKeypair, keyCurves, publicKeyEncodings, + publicKeyToMultikey, type Keypair, type PublicKeyEncoding, } from "@agentcommercekit/keys" -import { - bytesToMultibase, - publicKeyBytesToJwk, -} from "@agentcommercekit/keys/encoding" +import { publicKeyBytesToJwk } from "@agentcommercekit/keys/encoding" import { beforeEach, describe, expect, test } from "vitest" import { createDidDocument, createDidDocumentFromKeypair, } from "./create-did-document" +import { createDidKeyUri } from "./methods/did-key" const keyTypeMap = { jwk: "JsonWebKey2020", @@ -87,7 +86,10 @@ describe("createDidDocument() and createDidDocumentFromKeypair()", () => { id: keyId, type: keyTypeMap[encoding], controller: did, - publicKeyMultibase: bytesToMultibase(keypair.publicKey), + publicKeyMultibase: publicKeyToMultikey( + keypair.publicKey, + curve, + ), } const expectedDocument = { @@ -107,6 +109,28 @@ describe("createDidDocument() and createDidDocumentFromKeypair()", () => { ) }) + describe.each(keyCurves)("Multikey encoding: %s", (curve) => { + test("publicKeyMultibase matches the did:key identifier for the key", () => { + const keypair = keypairMap[curve]() + const expected = createDidKeyUri(keypair).slice("did:key:".length) + + // A Multikey and a did:key identifier are the same construction, so + // every encoding that resolves to a Multikey must produce this value + for (const encoding of ["multibase", "hex", "base58"] as const) { + const document = createDidDocumentFromKeypair({ + did, + keypair, + encoding, + }) + + expect(document.verificationMethod?.[0]?.type).toBe("Multikey") + expect(document.verificationMethod?.[0]?.publicKeyMultibase).toBe( + expected, + ) + } + }) + }) + test("includes controller when provided", () => { const controller = "did:web:controller.com" diff --git a/packages/did/src/create-did-document.ts b/packages/did/src/create-did-document.ts index ed3452c4..a2d744ca 100644 --- a/packages/did/src/create-did-document.ts +++ b/packages/did/src/create-did-document.ts @@ -1,4 +1,5 @@ import { + encodePublicKey, encodePublicKeyFromKeypair, type KeyCurve, type Keypair, @@ -8,7 +9,6 @@ import { } from "@agentcommercekit/keys" import { base58ToBytes, - bytesToMultibase, hexStringToBytes, } from "@agentcommercekit/keys/encoding" import type { VerificationMethod } from "did-resolver" @@ -68,17 +68,17 @@ function convertLegacyPublicKeyToMultibase( ): DidDocumentPublicKey { switch (publicKey.encoding) { case "hex": - return { - encoding: "multibase", - curve: publicKey.curve, - value: bytesToMultibase(hexStringToBytes(publicKey.value)), - } + return encodePublicKey( + "multibase", + hexStringToBytes(publicKey.value), + publicKey.curve, + ) case "base58": - return { - encoding: "multibase", - curve: publicKey.curve, - value: bytesToMultibase(base58ToBytes(publicKey.value)), - } + return encodePublicKey( + "multibase", + base58ToBytes(publicKey.value), + publicKey.curve, + ) default: return publicKey } diff --git a/packages/did/src/did-resolvers/did-resolver.test.ts b/packages/did/src/did-resolvers/did-resolver.test.ts index 8fd16c28..bf720c0b 100644 --- a/packages/did/src/did-resolvers/did-resolver.test.ts +++ b/packages/did/src/did-resolvers/did-resolver.test.ts @@ -11,7 +11,10 @@ describe("DidResolver", () => { publicKey: { encoding: "hex", curve: "secp256k1", - value: "0xc0ffee254729296a45a3885639AC7E10F9d54979", + // Uncompressed secp256k1 public key, matching the fixture in + // `methods/did-key.test.ts` + value: + "0x040bbd0a3fd05709d2814df8ed91003dc47eeecdded1f21602e9c4a913a094a6bada499b1fb61333e449d86e7de9489dc640774a21baea08bd86db2b8a8a7beba8", }, }) diff --git a/packages/did/src/methods/did-web.test.ts b/packages/did/src/methods/did-web.test.ts index bf119534..f2673eed 100644 --- a/packages/did/src/methods/did-web.test.ts +++ b/packages/did/src/methods/did-web.test.ts @@ -1,7 +1,6 @@ -import { generateKeypair } from "@agentcommercekit/keys" +import { generateKeypair, publicKeyToMultikey } from "@agentcommercekit/keys" import { bytesToHexString, - bytesToMultibase, publicKeyBytesToJwk, } from "@agentcommercekit/keys/encoding" import { describe, expect, it } from "vitest" @@ -66,7 +65,10 @@ describe("createDidWebDocument", () => { it("generates a valid DidUri and DidDocument, upgrading legacy hex to multibase", async () => { const keypair = await generateKeypair("secp256k1") const publicKeyHex = bytesToHexString(keypair.publicKey) - const publicKeyMultibase = bytesToMultibase(keypair.publicKey) + const publicKeyMultibase = publicKeyToMultikey( + keypair.publicKey, + keypair.curve, + ) const { did, didDocument } = createDidWebDocument({ publicKey: { diff --git a/packages/keys/src/curves/secp256k1.ts b/packages/keys/src/curves/secp256k1.ts index 2d6b3c4d..ed29c1cb 100644 --- a/packages/keys/src/curves/secp256k1.ts +++ b/packages/keys/src/curves/secp256k1.ts @@ -34,6 +34,17 @@ export async function generateKeypair( }) } +/** + * Compress a public key to its 33-byte form. Already-compressed keys are + * returned unchanged. + * + * @param pubkey - The public key bytes to compress + * @returns The compressed public key bytes + */ +export function compressPublicKey(pubkey: Uint8Array): Uint8Array { + return secp256k1.Point.fromBytes(pubkey).toBytes(true) +} + /** * Check if a public key is a valid secp256k1 public key (either compressed or * uncompressed) diff --git a/packages/keys/src/curves/secp256r1.ts b/packages/keys/src/curves/secp256r1.ts index f16e6780..b8c8c2c4 100644 --- a/packages/keys/src/curves/secp256r1.ts +++ b/packages/keys/src/curves/secp256r1.ts @@ -34,6 +34,17 @@ export async function generateKeypair( }) } +/** + * Compress a public key to its 33-byte form. Already-compressed keys are + * returned unchanged. + * + * @param pubkey - The public key bytes to compress + * @returns The compressed public key bytes + */ +export function compressPublicKey(pubkey: Uint8Array): Uint8Array { + return secp256r1.Point.fromBytes(pubkey).toBytes(true) +} + /** * Check if a public key is a valid secp256r1 public key (either compressed or * uncompressed) diff --git a/packages/keys/src/key-curves.ts b/packages/keys/src/key-curves.ts index 178eb6aa..991effdf 100644 --- a/packages/keys/src/key-curves.ts +++ b/packages/keys/src/key-curves.ts @@ -1,6 +1,21 @@ export const keyCurves = ["secp256k1", "secp256r1", "Ed25519"] as const export type KeyCurve = (typeof keyCurves)[number] +/** + * The multicodec code identifying each curve's public key. + * + * A Multikey value is `multibase(base58-btc, varint(code) ‖ key-bytes)`, so + * these are what let a reader tell one curve's key from another's. The codes + * for the two elliptic curves identify the compressed point encoding. + * + * @see {@link https://github.com/multiformats/multicodec/blob/master/table.csv} + */ +export const keyCurveMulticodecs = { + secp256k1: 0xe7, // secp256k1-pub + secp256r1: 0x1200, // p256-pub + Ed25519: 0xed, // ed25519-pub +} as const satisfies Record + export function isKeyCurve(curve: unknown): curve is KeyCurve { if (typeof curve !== "string") { return false diff --git a/packages/keys/src/public-key.test.ts b/packages/keys/src/public-key.test.ts index 0c1c5cd2..7632a57e 100644 --- a/packages/keys/src/public-key.test.ts +++ b/packages/keys/src/public-key.test.ts @@ -1,3 +1,4 @@ +import { varint } from "multiformats" import { describe, expect, test } from "vitest" import { isBase58 } from "./encoding/base58" @@ -8,10 +9,14 @@ import { isPublicKeyJwkSecp256k1, isPublicKeyJwkSecp256r1, } from "./encoding/jwk" -import { isMultibase } from "./encoding/multibase" -import { keyCurves, type KeyCurve } from "./key-curves" +import { isMultibase, multibaseToBytes } from "./encoding/multibase" +import { keyCurveMulticodecs, keyCurves, type KeyCurve } from "./key-curves" import { generateKeypair } from "./keypair" -import { encodePublicKeyFromKeypair, isValidPublicKey } from "./public-key" +import { + encodePublicKeyFromKeypair, + isValidPublicKey, + publicKeyToMultikey, +} from "./public-key" const ecCurves = ["secp256k1", "secp256r1"] as const satisfies KeyCurve[] @@ -42,6 +47,24 @@ describe("public-key methods", () => { expect(isMultibase(publicKey.value)).toBe(true) }) + test("encodes public key as a Multikey", async () => { + const keypair = await generateKeypair(curve) + const value = publicKeyToMultikey(keypair.publicKey, curve) + + expect(encodePublicKeyFromKeypair("multibase", keypair).value).toBe(value) + + const bytes = multibaseToBytes(value) + const [code, prefixLength] = varint.decode(bytes) + + // The multicodec code identifies the curve, so a reader can tell one + // curve's key from another's + expect(code).toBe(keyCurveMulticodecs[curve]) + + // Ed25519 has a single 32-byte encoding; the EC curves use their + // 33-byte compressed form, which is what their codes identify + expect(bytes.length - prefixLength).toBe(curve === "Ed25519" ? 32 : 33) + }) + test("encodes public key to base58", async () => { const keypair = await generateKeypair(curve) const publicKey = encodePublicKeyFromKeypair("base58", keypair) diff --git a/packages/keys/src/public-key.ts b/packages/keys/src/public-key.ts index 1ffbe53d..2d88b9a8 100644 --- a/packages/keys/src/public-key.ts +++ b/packages/keys/src/public-key.ts @@ -1,3 +1,5 @@ +import { varint } from "multiformats" + import * as ed25519 from "./curves/ed25519" import * as secp256k1 from "./curves/secp256k1" import * as secp256r1 from "./curves/secp256r1" @@ -5,7 +7,7 @@ import { bytesToBase58 } from "./encoding/base58" import { bytesToHexString } from "./encoding/hex" import { publicKeyBytesToJwk, type PublicKeyJwk } from "./encoding/jwk" import { bytesToMultibase } from "./encoding/multibase" -import type { KeyCurve } from "./key-curves" +import { keyCurveMulticodecs, type KeyCurve } from "./key-curves" import type { Keypair } from "./keypair" /** @@ -73,6 +75,50 @@ export function isValidPublicKey( return ed25519.isValidPublicKey(publicKey) } +/** + * Compress a public key, for curves that have a compressed point encoding. + * Ed25519 keys have a single 32-byte encoding and are returned unchanged. + */ +function compressPublicKey(publicKey: Uint8Array, curve: KeyCurve): Uint8Array { + if (curve === "secp256k1") { + return secp256k1.compressPublicKey(publicKey) + } + + if (curve === "secp256r1") { + return secp256r1.compressPublicKey(publicKey) + } + + return publicKey +} + +/** + * Encode a public key as a Multikey: the curve's multicodec code as a varint, + * followed by the key bytes, multibase-encoded with base58-btc. + * + * This is the value a `Multikey` verification method's `publicKeyMultibase` + * holds, and it is the same value that follows `did:key:` in a `did:key` URI + * for the same key. + * + * @param publicKey - The raw public key bytes + * @param curve - The curve the key belongs to + * @returns The Multikey string + */ +export function publicKeyToMultikey( + publicKey: Uint8Array, + curve: KeyCurve, +): string { + const compressed = compressPublicKey(publicKey, curve) + const code = keyCurveMulticodecs[curve] + const prefix = new Uint8Array(varint.encodingLength(code)) + varint.encodeTo(code, prefix, 0) + + const prefixed = new Uint8Array(prefix.length + compressed.length) + prefixed.set(prefix) + prefixed.set(compressed, prefix.length) + + return bytesToMultibase(prefixed) +} + /** * Convert a public key to a multibase string (used for DID:key) */ @@ -83,7 +129,7 @@ function encodePublicKeyMultibase( return { encoding: "multibase", curve, - value: bytesToMultibase(publicKey), + value: publicKeyToMultikey(publicKey, curve), } }