---
title: did:key Identifiers
description: Encode a public key as a self-describing did:key string, parse it back, and derive verification material — plus a frank assessment of the keys/parsers package.
sidebar:
  order: 2
  icon: id-card
---

`github.com/sonr-io/crypto/keys` turns a public key into a stable, self-describing string and back
again. A `did:key` identifier needs no registry and no network lookup: the key material *is* the
identifier, so resolving one is a pure decode. The package wraps libp2p's
`github.com/libp2p/go-libp2p/core/crypto.PubKey` interface, which gives it RSA, Ed25519, and
secp256k1 support for free, and adds a secp256k1-specific path for public keys that arrive as raw
bytes from an [MPC enclave](/identity/mpc-enclave).

**Reach for this when** you need a canonical identifier for a key you already hold — a UCAN issuer,
a log line, a database column, a delegation audience.

**Do not reach for this when** you need a DID with mutable state (rotation, service endpoints,
multiple verification methods). `did:key` is immutable by construction: change the key, change the
identifier. The `DIDMethod` enum in this package names other methods, but only `did:key` is
implemented here.

## Encoding

`DID.String()` builds the identifier in three steps:

1. `id.Raw()` — the raw public key bytes from libp2p (33 or 65 bytes for secp256k1, 32 for Ed25519,
   DER PKIX for RSA).
2. An unsigned-varint multicodec prefix identifying the key type is prepended.
3. The whole buffer is multibase-encoded with base58btc, which yields the leading `z`.

So every identifier this package produces looks like `did:key:z…`. `Parse` reverses exactly those
steps and rejects any multibase encoding other than base58btc.

| Key type | Constant | Multicodec | Accepted raw lengths |
| --- | --- | --- | --- |
| RSA (`rsa-x509-pub`) | `MulticodecKindRSAPubKey` | `0x1205` | DER, parsed via `x509.ParsePKIXPublicKey` |
| Ed25519 (`ed25519-pub`) | `MulticodecKindEd25519PubKey` | `0xed` | 32 |
| secp256k1 (`secp256k1-pub`) | `MulticodecKindSecp256k1PubKey` | `0xe7` | 33 (compressed) or 65 (uncompressed) |

`KeyPrefix` is the string constant `"did:key"`. `GetMulticodecType(keyType int)` maps an
`int(crypto.RSA)` / `int(crypto.Ed25519)` / `int(crypto.Secp256k1)` to the values above and errors on
anything else.

:::note
Canonical `did:key` for secp256k1 uses the **compressed** 33-byte point. `Parse` and `NewFromMPCPubKey`
also accept the 65-byte uncompressed form, which means two distinct `did:key` strings can name the
same key. If you compare identifiers as strings, normalise through `CompressedPubKey()` first.
:::

## Constructors

| Prop | Type | Default | Description |
| - | - | - | - |
| `NewDID?` | `func(pub crypto.PubKey) (DID, error)` | - | Wraps a libp2p public key. Accepts Ed25519, RSA, Secp256k1; errors on any other key type. |
| `NewFromPubKey?` | `func(pub PubKey) DID` | - | Wraps this package's own PubKey (a curves.Point-backed secp256k1 key). Infallible. |
| `NewFromMPCPubKey?` | `func(pubKeyBytes []byte) (DID, error)` | - | Unmarshals 33- or 65-byte secp256k1 public key bytes straight from an MPC enclave. Errors on any other length. |
| `Parse?` | `func(keystr string) (DID, error)` | - | Decodes a did:key string. Requires the did:key prefix, base58btc multibase, and a recognised multicodec. |
| `ValidateFormat?` | `func(didString string) error` | - | Prefix check followed by a full Parse. Use when you only need a yes/no on a string. |

## The `DID` type

`DID` embeds `crypto.PubKey`, so every libp2p method (`Raw`, `Type`, `Equals`, `Verify`, `Bytes`) is
promoted onto it. On top of that:

| Prop | Type | Default | Description |
| - | - | - | - |
| `String?` | `func() string` | - | The did:key identifier. Returns "" — not an error — if Raw() or multibase encoding fails. |
| `PublicKey?` | `func() crypto.PubKey` | - | The embedded libp2p public key. |
| `MulticodecType?` | `func() uint64` | - | The multicodec for this key type. PANICS on an unrecognised key type rather than returning an error. |
| `CompressedPubKey?` | `func() ([]byte, error)` | - | 33-byte compressed point for secp256k1 (converting from 65 bytes if needed); raw bytes for every other key type. |
| `VerifyKey?` | `func() (any, error)` | - | *rsa.PublicKey for RSA, ed25519.PublicKey for Ed25519, and the raw []byte for secp256k1. |
| `Address?` | `func() (string, error)` | - | A "sonr1"-prefixed string. See the caveat below — it is not a hash and not bech32. |

:::warning[`MulticodecType` panics]
`String()` calls `MulticodecType()` unconditionally. A `DID` holding a key type outside
`{RSA, Ed25519, secp256k1}` will panic with `"unexpected crypto type"` when stringified. `NewDID`
guards against this, but a `DID` constructed as a struct literal (`keys.DID{PubKey: k}`) does not.
:::

## Round trip

Grounded in `TestDIDStringFormat` and `TestMPCIntegration` in `keys/didkey_test.go`:

```go didkey_roundtrip.go
package main

import (
	"crypto/rand"
	"fmt"

	p2pcrypto "github.com/libp2p/go-libp2p/core/crypto"
	"github.com/sonr-io/crypto/keys"
)

func main() {
	priv, _, err := p2pcrypto.GenerateSecp256k1Key(rand.Reader)
	if err != nil {
		panic(err)
	}

	did, err := keys.NewDID(priv.GetPublic())
	if err != nil {
		panic(err)
	}

	s := did.String() // "did:key:z..."
	fmt.Println(s)

	parsed, err := keys.Parse(s)
	if err != nil {
		panic(err)
	}

	// The encoding is canonical for a given input: re-stringifying is identical.
	fmt.Println("stable:", parsed.String() == s)
	fmt.Println("same type:", parsed.Type() == did.Type())

	// Cheap validity check on an untrusted string.
	fmt.Println("valid:", keys.ValidateFormat(s) == nil)

	compressed, err := parsed.CompressedPubKey()
	fmt.Println("compressed len:", len(compressed), err) // 33
}
```

For a key that arrives from an enclave rather than a libp2p keypair, swap the constructor:

```go
did, err := keys.NewFromMPCPubKey(enclave.PubKeyBytes())
```

## `DIDMethod`

A plain string enum, verbatim from `keys/methods.go`. It carries no behaviour beyond `String()`, and
nothing else in the package consumes it — it exists for callers that need to tag which method a DID
string belongs to.

```go
const (
	DIDMethodKey      DIDMethod = "key"
	DIDMethodSonr     DIDMethod = "sonr"
	DIDMehthodBitcoin DIDMethod = "btcr"
	DIDMethodEthereum DIDMethod = "ethr"
	DIDMethodCbor     DIDMethod = "cbor"
	DIDMethodCID      DIDMethod = "cid"
	DIDMethodIPFS     DIDMethod = "ipfs"
)
```

:::note
`DIDMehthodBitcoin` is misspelled in the source. It is exported, so fixing it would be a breaking
change; use it as written.
:::

## The `PubKey` interface

Separate from libp2p's type, `keys.PubKey` adapts a [`curves.Point`](/foundations/curves) into
something `DID` can embed. `NewPubKey(pk curves.Point) PubKey` is the only constructor.

| Prop | Type | Default | Description |
| - | - | - | - |
| `Bytes?` | `func() []byte` | - | point.ToAffineCompressed() — 33 bytes on secp256k1. |
| `Raw?` | `func() ([]byte, error)` | - | Identical to Bytes; the error is always nil. |
| `Hex?` | `func() string` | - | Hex of the compressed point. |
| `Type?` | `func() p2ppb.KeyType` | - | Hardcoded to KeyType_Secp256k1 regardless of the point's actual curve. |
| `Equals?` | `func(b p2pcrypto.Key) bool` | - | Compares Raw() bytes. |
| `Verify?` | `func(msg, sig []byte) (bool, error)` | - | ECDSA verify over a SHA3-256 digest. Signature layout below. |

### The 66-byte signature layout

`PubKey.Verify` does **not** accept a standard 64-byte `r || s` signature. Reading
`keys/pubkey.go` and `keys/utils.go`, it:

1. Requires the signature to be **exactly 66 bytes**, rejecting anything else with
   `"malformed signature: not the correct size"`.
2. Parses it as `V || R || S`, where `V` is a single recovery-id byte at offset 0, `R` is
   `sig[1:33]`, and `S` is `sig[33:66]`.
3. Hashes the message with **SHA3-256** (not SHA-256) and calls `ecdsa.Verify` on that digest,
   ignoring `V` entirely.
4. Reconstructs the ECDSA public key by slicing the compressed point as `x = bytes[1:33]`,
   `y = bytes[33:]` on `curves.K256()`.

:::danger[`keys.PubKey.Verify` cannot verify `mpc.Enclave.Sign` output]
`mpc.SerializeSignature` produces a fixed **64-byte** `r || s` buffer, and `keys.deserializeSignature`
rejects anything that is not 66 bytes. So `keys.NewPubKey(point).Verify(msg, enclaveSig)` always
returns `("malformed signature: not the correct size")`. Verify enclave signatures with
`enclave.Verify(data, sig)` or `mpc.VerifyWithPubKey(enclave.PubKeyBytes(), data, sig)` instead — both
use the 64-byte layout. See [MPC Enclave](/identity/mpc-enclave).

Step 4 above is also wrong for a genuinely compressed point: on a 33-byte compressed encoding,
`bytes[33:]` is empty, so `y` decodes as zero. `Verify` therefore only works if `Bytes()` happens to
return 65 bytes — which it never does, since `ToAffineCompressed()` returns 33. Treat
`keys.PubKey.Verify` as non-functional.
:::

## `Address()` does not do what its comment says

The doc comment promises "a blockchain-compatible address" and an inline comment claims
"first 20 bytes of Keccak-256 hash (Ethereum-style)". The code does neither:

```go
// keys/didkey.go, secp256k1 branch, verbatim:
return fmt.Sprintf("sonr1%x", rawPubBytes[:8]), nil
```

:::danger[`Address()` is a truncated hex prefix, not an address]
For all three key types the function returns `"sonr1"` followed by the hex of the **first 8 bytes of
the raw public key**. There is no hash, no Keccak, and no bech32 encoding despite the bech32-looking
`sonr1` prefix. Consequences:

- It is **not one-way**: the output leaks 8 bytes of the public key verbatim.
- It has **no checksum**, so a typo is undetectable.
- 64 bits of collision space, birthday-bounded at roughly 2<sup>32</sup> keys.
- For secp256k1 it compresses a 65-byte key first, so the compressed and uncompressed forms of the
  same key produce the same address — but an Ed25519 and a secp256k1 key sharing a first-8-byte
  prefix also collide.

`ucan` uses this value as the address in `MPCTokenBuilder.GetAddress()` and `KeyshareSource.Address()`.
Do not treat it as a chain address on any real network.
:::

## Avoid `keys/parsers`

`keys/parsers` looks like a set of per-chain address parsers. It is not. Verified by reading every
file in the directory:

| File | Lines | Contents |
| --- | --- | --- |
| `btc_parser.go` | 1 | `package parsers` |
| `eth_parser.go` | 1 | `package parsers` |
| `fil_parser.go` | 1 | `package parsers` |
| `sol_parser.go` | 1 | `package parsers` |
| `ton_parser.go` | 1 | `package parsers` |
| `cosmos_parser.go` | 12 | A `CosmosPrefix` string type and six bech32 HRP constants. No functions. |
| `key_parser.go` | 157 | A near-verbatim copy of `keys/didkey.go`, exporting `DIDKey` instead of `DID`. |

:::danger[`keys/parsers` duplicates `keys` with an incompatible multicodec]
`keys/parsers` redeclares the multicodec constants, and one of them disagrees:

```go
// keys/didkey.go
MulticodecKindSecp256k1PubKey = 0xe7   // secp256k1-pub, the registered value

// keys/parsers/key_parser.go
MulticodecKindSecp256k1PubKey = 0x1206 // not secp256k1-pub
```

A secp256k1 `did:key` produced by `parsers.DIDKey.String()` carries a different varint prefix, so
`keys.Parse` rejects it with `"unrecognized key type multicodec prefix"`, and vice versa. The two
packages produce **mutually unparseable identifiers for the same key**. `keys` uses the registered
multicodec table value; `parsers` does not.

The five empty files mean the package name promises chain address parsing that does not exist:
`parsers` exports only `KeyPrefix`, the multicodec constants, `CosmosPrefix` and its six constants,
`DIDKey` with `NewKeyDID`/`MulticodecType`/`String`/`VerifyKey`, and `Parse`.

**Use `github.com/sonr-io/crypto/keys`. Do not import `keys/parsers`.**
:::

## Caveats

:::warning[Silent failure in `String()`]
`String()` returns the empty string on any internal error rather than reporting it. An empty
identifier where you expected `did:key:z…` means `Raw()` or multibase encoding failed; check the key
with `NewDID` first, or call `ValidateFormat` on the result.
:::

:::warning[`Parse` error message reads the wrong byte]
The fallthrough error is `fmt.Errorf("unrecognized key type multicodec prefix: %x", data[0])`, but
the multicodec was decoded as a multi-byte varint into `keyType`. For prefixes above `0x7f` — RSA's
`0x1205`, for example — the reported byte is the first varint byte, not the codec. The error is
cosmetic; the rejection itself is correct.
:::

:::info[What is actually covered by tests]
`keys/didkey_test.go` exercises `NewFromMPCPubKey` length validation, the `0xe7` constant,
`Address`, `CompressedPubKey`, `ValidateFormat`, `GetMulticodecType`, and the string/parse round trip.
There is **no** test for `NewFromPubKey`, `NewPubKey`, or `PubKey.Verify` — which is consistent with
the signature-layout defect above going unnoticed.
:::

## Next

<CardGroup cols={2}>
  <Card title="MPC Enclave" href="/identity/mpc-enclave" icon="shield">
    Where `NewFromMPCPubKey`'s input comes from, and how to sign with the key behind the identifier.
  </Card>
  <Card title="UCAN Tokens" href="/identity/ucan" icon="ticket">
    Using a `did:key` as a token issuer and delegation audience.
  </Card>
</CardGroup>
