diff --git a/multikey/x25519/x25519.go b/multikey/x25519/x25519.go new file mode 100644 index 0000000..80ffc99 --- /dev/null +++ b/multikey/x25519/x25519.go @@ -0,0 +1,206 @@ +// Package x25519 implements X25519 key-agreement keypairs for the multikey +// family. +// +// Unlike the signing keys in the sibling packages (ed25519, secp256k1), X25519 +// keys cannot sign or verify: they exist purely for Elliptic-Curve +// Diffie-Hellman (ECDH) key agreement — for example the ECDH-ES+A256KW key +// wrapping used to seal a content-encryption key to a recipient. They therefore +// do not implement [github.com/fil-forge/ucantone/multikey.Signer] or +// [github.com/fil-forge/ucantone/multikey.Verifier] (which extend the signing +// ucan.Signer/ucan.Verifier interfaces); they are an independent keypair type +// that nonetheless follows the same multiformats and did:key conventions, so a +// public key round-trips through a did:key DID. +package x25519 + +import ( + "crypto/ecdh" + "crypto/rand" + "fmt" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/did/key" + "github.com/fil-forge/ucantone/multikey/internal/multiformat" + "github.com/multiformats/go-multibase" + "github.com/multiformats/go-multicodec" +) + +const ( + // PublicKeyCode is the multicodec code for an X25519 public key + // (x25519-pub, 0xec). + PublicKeyCode = multicodec.X25519Pub + // PrivateKeyCode is the multicodec code for an X25519 private key + // (x25519-priv, 0x1302). + PrivateKeyCode = multicodec.X25519Priv +) + +// KeySize is the length in bytes of a raw X25519 public or private key. +const KeySize = 32 + +var curve = ecdh.X25519() + +// KeyPair is an X25519 key-agreement keypair. It holds the private key and can +// perform ECDH against a peer's [PublicKey]. It cannot sign or verify. +type KeyPair struct { + priv *ecdh.PrivateKey +} + +// Generate creates a new random X25519 keypair. +func Generate() (*KeyPair, error) { + priv, err := curve.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating X25519 key: %w", err) + } + return &KeyPair{priv: priv}, nil +} + +// FromRaw builds a keypair from raw (untagged) 32-byte X25519 private key bytes. +func FromRaw(b []byte) (*KeyPair, error) { + priv, err := curve.NewPrivateKey(b) + if err != nil { + return nil, fmt.Errorf("invalid X25519 private key: %w", err) + } + return &KeyPair{priv: priv}, nil +} + +// Decode decodes a keypair from multiformat-tagged private key bytes: the +// x25519-priv varint (0x1302) followed by the 32-byte raw private key. +func Decode(b []byte) (*KeyPair, error) { + raw, err := multiformat.UntagWith(PrivateKeyCode, b, 0) + if err != nil { + return nil, err + } + return FromRaw(raw) +} + +// Parse decodes a keypair from a multibase-encoded tagged private key string, +// the inverse of [KeyPair.String]. +func Parse(s string) (*KeyPair, error) { + _, b, err := multibase.Decode(s) + if err != nil { + return nil, fmt.Errorf("decoding multibase string: %w", err) + } + return Decode(b) +} + +// Code returns the multicodec code for the private key (x25519-priv). +func (k *KeyPair) Code() multicodec.Code { return PrivateKeyCode } + +// Raw returns the 32-byte raw private key, without multiformats tags. +func (k *KeyPair) Raw() []byte { return k.priv.Bytes() } + +// Bytes returns the private key bytes with the multiformats prefix varint +// (x25519-priv). This is the form to seal in custody (e.g. a vault) so the key +// type is recoverable on decode rather than assumed. +func (k *KeyPair) Bytes() []byte { + return multiformat.TagWith(PrivateKeyCode, k.priv.Bytes()) +} + +// String returns the multibase (Base64pad) encoding of the tagged private key, +// matching the convention used for multikey signers. +func (k *KeyPair) String() string { + s, _ := multibase.Encode(multibase.Base64pad, k.Bytes()) + return s +} + +// PrivateKey returns the underlying [ecdh.PrivateKey]. +func (k *KeyPair) PrivateKey() *ecdh.PrivateKey { return k.priv } + +// Public returns the public half of the keypair. +func (k *KeyPair) Public() *PublicKey { return &PublicKey{pub: k.priv.PublicKey()} } + +// KeyDID returns the did:key DID for the public half of the keypair. +func (k *KeyPair) KeyDID() did.DID { return k.Public().KeyDID() } + +// ECDH performs X25519 Diffie-Hellman between this private key and the peer's +// public key, returning the raw shared secret. It returns an error rather than +// panicking when either the keypair or the peer is nil or carries no key, since +// peer keys may come from external input. +func (k *KeyPair) ECDH(peer *PublicKey) ([]byte, error) { + if k == nil || k.priv == nil { + return nil, fmt.Errorf("nil private key") + } + if peer == nil || peer.pub == nil { + return nil, fmt.Errorf("nil peer public key") + } + return k.priv.ECDH(peer.pub) +} + +// PublicKey is an X25519 public key. +type PublicKey struct { + pub *ecdh.PublicKey +} + +// PublicFromRaw builds a public key from raw (untagged) 32-byte X25519 public +// key bytes. +func PublicFromRaw(b []byte) (*PublicKey, error) { + pub, err := curve.NewPublicKey(b) + if err != nil { + return nil, fmt.Errorf("invalid X25519 public key: %w", err) + } + return &PublicKey{pub: pub}, nil +} + +// DecodePublic decodes a public key from multiformat-tagged bytes: the +// x25519-pub varint (0xec) followed by the 32-byte raw public key. +func DecodePublic(b []byte) (*PublicKey, error) { + raw, err := multiformat.UntagWith(PublicKeyCode, b, 0) + if err != nil { + return nil, err + } + return PublicFromRaw(raw) +} + +// ParsePublic decodes a public key from a multibase-encoded tagged string, the +// inverse of [PublicKey.String]. +func ParsePublic(s string) (*PublicKey, error) { + _, b, err := multibase.Decode(s) + if err != nil { + return nil, fmt.Errorf("decoding multibase string: %w", err) + } + return DecodePublic(b) +} + +// ParsePublicKeyDID decodes the X25519 public key from a did:key DID (e.g. +// did:key:z6LS...). It errors if the DID is not a did:key or does not encode an +// X25519 public key. +func ParsePublicKeyDID(d did.DID) (*PublicKey, error) { + if d.Method() != key.Method { + return nil, fmt.Errorf("invalid DID method: %s, expected: %s", d.Method(), key.Method) + } + enc, b, err := multibase.Decode(d.Identifier()) + if err != nil { + return nil, err + } + if enc != multibase.Base58BTC { + return nil, fmt.Errorf("not Base58BTC encoded") + } + return DecodePublic(b) +} + +// Code returns the multicodec code for the public key (x25519-pub). +func (p *PublicKey) Code() multicodec.Code { return PublicKeyCode } + +// Raw returns the 32-byte raw public key, without multiformats tags. +func (p *PublicKey) Raw() []byte { return p.pub.Bytes() } + +// Bytes returns the public key bytes with the multiformats prefix varint +// (x25519-pub). +func (p *PublicKey) Bytes() []byte { + return multiformat.TagWith(PublicKeyCode, p.pub.Bytes()) +} + +// PublicKey returns the underlying [ecdh.PublicKey]. +func (p *PublicKey) PublicKey() *ecdh.PublicKey { return p.pub } + +// String returns the multibase (Base58BTC) encoding of the tagged public key. +// This is the same encoding used as the identifier of the key's did:key DID. +func (p *PublicKey) String() string { + s, _ := multibase.Encode(multibase.Base58BTC, p.Bytes()) + return s +} + +// KeyDID returns the did:key DID corresponding to this public key, e.g. +// did:key:z6LS.... +func (p *PublicKey) KeyDID() did.DID { + return did.New(key.Method, p.String()) +} diff --git a/multikey/x25519/x25519_test.go b/multikey/x25519/x25519_test.go new file mode 100644 index 0000000..0afb903 --- /dev/null +++ b/multikey/x25519/x25519_test.go @@ -0,0 +1,168 @@ +package x25519_test + +import ( + "strings" + "testing" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/multikey/ed25519" + "github.com/fil-forge/ucantone/multikey/x25519" + "github.com/stretchr/testify/require" +) + +func TestGenerate(t *testing.T) { + k, err := x25519.Generate() + require.NoError(t, err) + require.Len(t, k.Raw(), x25519.KeySize) + require.Len(t, k.Public().Raw(), x25519.KeySize) + + // Two independent keypairs differ. + k2, err := x25519.Generate() + require.NoError(t, err) + require.NotEqual(t, k.Raw(), k2.Raw()) +} + +func TestPrivateKeyRoundTrip(t *testing.T) { + k, err := x25519.Generate() + require.NoError(t, err) + + t.Run("tagged bytes carry the x25519-priv code", func(t *testing.T) { + b := k.Bytes() + // 2-byte varint for 0x1302 + 32 raw bytes. + require.Len(t, b, 34) + + decoded, err := x25519.Decode(b) + require.NoError(t, err) + require.Equal(t, k.Raw(), decoded.Raw()) + }) + + t.Run("FromRaw", func(t *testing.T) { + decoded, err := x25519.FromRaw(k.Raw()) + require.NoError(t, err) + require.Equal(t, k.Bytes(), decoded.Bytes()) + }) + + t.Run("Parse(String())", func(t *testing.T) { + decoded, err := x25519.Parse(k.String()) + require.NoError(t, err) + require.Equal(t, k.Raw(), decoded.Raw()) + }) + + t.Run("Decode rejects the wrong key type", func(t *testing.T) { + // An ed25519 private key is multiformat-tagged with a different code. + signer, err := ed25519.Generate() + require.NoError(t, err) + _, err = x25519.Decode(signer.Bytes()) + require.Error(t, err) + }) + + t.Run("FromRaw rejects the wrong length", func(t *testing.T) { + _, err := x25519.FromRaw([]byte{1, 2, 3}) + require.Error(t, err) + }) +} + +func TestPublicKeyRoundTrip(t *testing.T) { + k, err := x25519.Generate() + require.NoError(t, err) + pub := k.Public() + + t.Run("tagged bytes carry the x25519-pub code", func(t *testing.T) { + b := pub.Bytes() + require.Len(t, b, 34) + + decoded, err := x25519.DecodePublic(b) + require.NoError(t, err) + require.Equal(t, pub.Raw(), decoded.Raw()) + }) + + t.Run("PublicFromRaw", func(t *testing.T) { + decoded, err := x25519.PublicFromRaw(pub.Raw()) + require.NoError(t, err) + require.Equal(t, pub.Bytes(), decoded.Bytes()) + }) + + t.Run("ParsePublic(String())", func(t *testing.T) { + decoded, err := x25519.ParsePublic(pub.String()) + require.NoError(t, err) + require.Equal(t, pub.Raw(), decoded.Raw()) + }) + + t.Run("DecodePublic rejects the wrong key type", func(t *testing.T) { + // Tagged private bytes have the x25519-priv code, not x25519-pub. + _, err := x25519.DecodePublic(k.Bytes()) + require.Error(t, err) + }) +} + +func TestKeyDID(t *testing.T) { + k, err := x25519.Generate() + require.NoError(t, err) + + d := k.KeyDID() + require.Equal(t, "key", d.Method()) + // X25519 did:key identifiers (x25519-pub, base58btc) begin "z6LS". + require.True(t, strings.HasPrefix(d.String(), "did:key:z6LS"), + "unexpected did:key prefix: %s", d.String()) + + t.Run("ParsePublicKeyDID round trips", func(t *testing.T) { + pub, err := x25519.ParsePublicKeyDID(d) + require.NoError(t, err) + require.Equal(t, k.Public().Raw(), pub.Raw()) + }) + + t.Run("ParsePublicKeyDID rejects a non-key DID", func(t *testing.T) { + _, err := x25519.ParsePublicKeyDID(did.New("plc", "abc123")) + require.Error(t, err) + }) + + t.Run("ParsePublicKeyDID rejects an ed25519 did:key", func(t *testing.T) { + signer, err := ed25519.Generate() + require.NoError(t, err) + _, err = x25519.ParsePublicKeyDID(signer.KeyDID()) + require.Error(t, err) + }) +} + +func TestECDH(t *testing.T) { + alice, err := x25519.Generate() + require.NoError(t, err) + bob, err := x25519.Generate() + require.NoError(t, err) + + t.Run("both parties derive the same shared secret", func(t *testing.T) { + ab, err := alice.ECDH(bob.Public()) + require.NoError(t, err) + ba, err := bob.ECDH(alice.Public()) + require.NoError(t, err) + require.NotEmpty(t, ab) + require.Equal(t, ab, ba) + }) + + t.Run("a public key recovered from its DID agrees too", func(t *testing.T) { + bobPub, err := x25519.ParsePublicKeyDID(bob.KeyDID()) + require.NoError(t, err) + viaDID, err := alice.ECDH(bobPub) + require.NoError(t, err) + direct, err := alice.ECDH(bob.Public()) + require.NoError(t, err) + require.Equal(t, direct, viaDID) + }) + + t.Run("returns an error for a nil peer instead of panicking", func(t *testing.T) { + _, err := alice.ECDH(nil) + require.Error(t, err) + // A zero-value public key (nil underlying key) is also rejected. + _, err = alice.ECDH(&x25519.PublicKey{}) + require.Error(t, err) + }) + + t.Run("returns an error for a nil or empty keypair instead of panicking", func(t *testing.T) { + var nilKeyPair *x25519.KeyPair + _, err := nilKeyPair.ECDH(alice.Public()) + require.Error(t, err) + // A zero-value keypair (nil underlying key) is also rejected. + _, err = (&x25519.KeyPair{}).ECDH(alice.Public()) + require.Error(t, err) + }) +}