-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add X25519 multikey key-agreement keypairs #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| // 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. | ||
| func (k *KeyPair) ECDH(peer *PublicKey) ([]byte, error) { | ||
| 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()) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| 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) | ||
| }) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.