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
32 changes: 32 additions & 0 deletions .changeset/multikey-public-key-multibase.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 29 additions & 5 deletions packages/did/src/create-did-document.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 = {
Expand All @@ -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"

Expand Down
22 changes: 11 additions & 11 deletions packages/did/src/create-did-document.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
encodePublicKey,
encodePublicKeyFromKeypair,
type KeyCurve,
type Keypair,
Expand All @@ -8,7 +9,6 @@ import {
} from "@agentcommercekit/keys"
import {
base58ToBytes,
bytesToMultibase,
hexStringToBytes,
} from "@agentcommercekit/keys/encoding"
import type { VerificationMethod } from "did-resolver"
Expand Down Expand Up @@ -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
}
Expand Down
5 changes: 4 additions & 1 deletion packages/did/src/did-resolvers/did-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
})

Expand Down
8 changes: 5 additions & 3 deletions packages/did/src/methods/did-web.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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: {
Expand Down
11 changes: 11 additions & 0 deletions packages/keys/src/curves/secp256k1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions packages/keys/src/curves/secp256r1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions packages/keys/src/key-curves.ts
Original file line number Diff line number Diff line change
@@ -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<KeyCurve, number>

export function isKeyCurve(curve: unknown): curve is KeyCurve {
if (typeof curve !== "string") {
return false
Expand Down
29 changes: 26 additions & 3 deletions packages/keys/src/public-key.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { varint } from "multiformats"
import { describe, expect, test } from "vitest"

import { isBase58 } from "./encoding/base58"
Expand All @@ -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[]

Expand Down Expand Up @@ -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)
Expand Down
50 changes: 48 additions & 2 deletions packages/keys/src/public-key.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { varint } from "multiformats"

import * as ed25519 from "./curves/ed25519"
import * as secp256k1 from "./curves/secp256k1"
import * as secp256r1 from "./curves/secp256r1"
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"

/**
Expand Down Expand Up @@ -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)
*/
Expand All @@ -83,7 +129,7 @@ function encodePublicKeyMultibase(
return {
encoding: "multibase",
curve,
value: bytesToMultibase(publicKey),
value: publicKeyToMultikey(publicKey, curve),
}
}

Expand Down