From 5ab3c68bcf1636816aec1474900a375fb2ff03f4 Mon Sep 17 00:00:00 2001 From: Dusk1e Date: Sun, 16 Aug 2026 19:14:45 +0300 Subject: [PATCH] fix(did,keys): emit a real Multikey for `publicKeyMultibase` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createDidDocument` builds verification methods with `type: "Multikey"`, but `publicKeyMultibase` held a multibase encoding of the raw public key bytes. A Multikey is `multibase(base58-btc, varint(multicodec) ‖ key)`, 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. Encode the multicodec code for the curve and, for secp256k1 and secp256r1, the 33-byte compressed point that code identifies. The `hex` and `base58` inputs route through the same encoder, since they are converted to a Multikey verification method too. `publicKeyMultibase` now equals the `did:key` method-specific identifier for the same key, which is asserted directly against `createDidKeyUri`. --- .changeset/multikey-public-key-multibase.md | 32 ++++++++++++ packages/did/src/create-did-document.test.ts | 34 +++++++++++-- packages/did/src/create-did-document.ts | 22 ++++---- .../src/did-resolvers/did-resolver.test.ts | 5 +- packages/did/src/methods/did-web.test.ts | 8 +-- packages/keys/src/curves/secp256k1.ts | 11 ++++ packages/keys/src/curves/secp256r1.ts | 11 ++++ packages/keys/src/key-curves.ts | 15 ++++++ packages/keys/src/public-key.test.ts | 29 +++++++++-- packages/keys/src/public-key.ts | 50 ++++++++++++++++++- 10 files changed, 192 insertions(+), 25 deletions(-) create mode 100644 .changeset/multikey-public-key-multibase.md 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), } }