This commit is contained in:
Ilia Denisov
2026-05-07 09:40:37 +02:00
parent 63cccdc958
commit dc1c9b109c
29 changed files with 1991 additions and 20 deletions
+108
View File
@@ -0,0 +1,108 @@
// Package keypair provides Ed25519 keypair generation and signing helpers
// over opaque []byte blobs. The package is network-free, storage-free,
// and TinyGo-friendly: it does not import `crypto/x509`, `encoding/pem`,
// or `os`. Random bytes are not read internally; callers pass an io.Reader
// (typically `crypto/rand.Reader` on host builds, or a `crypto.getRandomValues`
// adapter on WASM).
//
// Public APIs return raw byte blobs (32-byte public keys, 64-byte private
// keys, 64-byte signatures) so the WASM bridge in later phases can hand
// them back and forth across the JS boundary as Uint8Array. The package
// never re-exports `crypto/ed25519` types in its surface.
package keypair
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"errors"
"fmt"
"io"
)
var (
// ErrInvalidPrivateKey reports that a private key blob does not have the
// required Ed25519 private-key length.
ErrInvalidPrivateKey = errors.New("private_key must be a 64-byte Ed25519 private key")
// ErrInvalidPublicKey reports that a public key blob does not have the
// required Ed25519 public-key length.
ErrInvalidPublicKey = errors.New("public_key must be a 32-byte Ed25519 public key")
// ErrInvalidPublicKeyEncoding reports that a marshaled public key is not a
// strict base64 encoding of a 32-byte Ed25519 public key.
ErrInvalidPublicKeyEncoding = errors.New("public_key is not a valid base64-encoded Ed25519 public key")
)
// Generate reads 32 seed bytes from reader and derives an Ed25519 keypair.
// The returned slices are independent copies; callers may retain or zero
// them without affecting subsequent calls.
func Generate(reader io.Reader) (privateKey, publicKey []byte, err error) {
if reader == nil {
return nil, nil, errors.New("keypair.Generate: reader must not be nil")
}
pub, priv, err := ed25519.GenerateKey(reader)
if err != nil {
return nil, nil, fmt.Errorf("keypair.Generate: %w", err)
}
return bytes.Clone(priv), bytes.Clone(pub), nil
}
// Sign returns the raw 64-byte Ed25519 signature of message under privateKey.
func Sign(privateKey, message []byte) ([]byte, error) {
if len(privateKey) != ed25519.PrivateKeySize {
return nil, ErrInvalidPrivateKey
}
signature := ed25519.Sign(ed25519.PrivateKey(privateKey), message)
return bytes.Clone(signature), nil
}
// Verify reports whether signature authenticates message under publicKey.
// It returns false if any input has the wrong length.
func Verify(publicKey, message, signature []byte) bool {
if len(publicKey) != ed25519.PublicKeySize || len(signature) != ed25519.SignatureSize {
return false
}
return ed25519.Verify(ed25519.PublicKey(publicKey), message, signature)
}
// MarshalPublicKey returns the base64 (StdEncoding) representation of the raw
// 32-byte Ed25519 public key. The encoding matches docs/ARCHITECTURE.md §15:
// the backend stores client public keys in this exact form and the gateway
// reads them out of session cache as base64 strings.
func MarshalPublicKey(publicKey []byte) (string, error) {
if len(publicKey) != ed25519.PublicKeySize {
return "", ErrInvalidPublicKey
}
return base64.StdEncoding.EncodeToString(publicKey), nil
}
// UnmarshalPublicKey decodes a strict base64 (StdEncoding) representation of
// a raw 32-byte Ed25519 public key.
func UnmarshalPublicKey(value string) ([]byte, error) {
decoded, err := base64.StdEncoding.Strict().DecodeString(value)
if err != nil {
return nil, ErrInvalidPublicKeyEncoding
}
if len(decoded) != ed25519.PublicKeySize {
return nil, ErrInvalidPublicKey
}
return decoded, nil
}
// PublicKeyFromPrivate extracts the Ed25519 public key embedded in privateKey.
// The returned slice is an independent copy.
func PublicKeyFromPrivate(privateKey []byte) ([]byte, error) {
if len(privateKey) != ed25519.PrivateKeySize {
return nil, ErrInvalidPrivateKey
}
pub, _ := ed25519.PrivateKey(privateKey).Public().(ed25519.PublicKey)
return bytes.Clone(pub), nil
}
+143
View File
@@ -0,0 +1,143 @@
package keypair_test
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"testing"
"galaxy/core/keypair"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateProducesIndependentCopies(t *testing.T) {
t.Parallel()
priv, pub, err := keypair.Generate(rand.Reader)
require.NoError(t, err)
require.Len(t, priv, ed25519.PrivateKeySize)
require.Len(t, pub, ed25519.PublicKeySize)
// Mutating the returned slices must not affect a fresh call.
priv[0] ^= 0xFF
pub[0] ^= 0xFF
priv2, pub2, err := keypair.Generate(rand.Reader)
require.NoError(t, err)
assert.NotEqual(t, priv[:8], priv2[:8])
assert.NotEqual(t, pub[:8], pub2[:8])
}
func TestGenerateIsDeterministicForFixedSeed(t *testing.T) {
t.Parallel()
seed := bytes.Repeat([]byte{0x42}, ed25519.SeedSize)
priv1, pub1, err := keypair.Generate(bytes.NewReader(seed))
require.NoError(t, err)
priv2, pub2, err := keypair.Generate(bytes.NewReader(seed))
require.NoError(t, err)
assert.Equal(t, priv1, priv2)
assert.Equal(t, pub1, pub2)
}
func TestGenerateRejectsNilReader(t *testing.T) {
t.Parallel()
_, _, err := keypair.Generate(nil)
require.Error(t, err)
}
func TestSignRoundTrip(t *testing.T) {
t.Parallel()
priv, pub, err := keypair.Generate(rand.Reader)
require.NoError(t, err)
message := []byte("ui-core-roundtrip")
signature, err := keypair.Sign(priv, message)
require.NoError(t, err)
assert.Len(t, signature, ed25519.SignatureSize)
assert.True(t, keypair.Verify(pub, message, signature))
assert.False(t, keypair.Verify(pub, []byte("tampered"), signature))
tampered := append([]byte(nil), signature...)
tampered[0] ^= 0xFF
assert.False(t, keypair.Verify(pub, message, tampered))
}
func TestSignRejectsInvalidPrivateKey(t *testing.T) {
t.Parallel()
_, err := keypair.Sign([]byte("short"), []byte("message"))
require.ErrorIs(t, err, keypair.ErrInvalidPrivateKey)
}
func TestVerifyRejectsInvalidLengths(t *testing.T) {
t.Parallel()
priv, pub, err := keypair.Generate(rand.Reader)
require.NoError(t, err)
signature, err := keypair.Sign(priv, []byte("message"))
require.NoError(t, err)
assert.False(t, keypair.Verify(pub[:8], []byte("message"), signature))
assert.False(t, keypair.Verify(pub, []byte("message"), signature[:8]))
}
func TestMarshalUnmarshalPublicKeyRoundTrip(t *testing.T) {
t.Parallel()
_, pub, err := keypair.Generate(rand.Reader)
require.NoError(t, err)
encoded, err := keypair.MarshalPublicKey(pub)
require.NoError(t, err)
require.NotEmpty(t, encoded)
// Encoding must be base64 StdEncoding to match docs/ARCHITECTURE.md §15.
expected := base64.StdEncoding.EncodeToString(pub)
assert.Equal(t, expected, encoded)
decoded, err := keypair.UnmarshalPublicKey(encoded)
require.NoError(t, err)
assert.Equal(t, pub, decoded)
}
func TestMarshalPublicKeyRejectsInvalidLength(t *testing.T) {
t.Parallel()
_, err := keypair.MarshalPublicKey([]byte("short"))
require.ErrorIs(t, err, keypair.ErrInvalidPublicKey)
}
func TestUnmarshalPublicKeyRejectsBadEncoding(t *testing.T) {
t.Parallel()
_, err := keypair.UnmarshalPublicKey("%%%not-base64%%%")
require.ErrorIs(t, err, keypair.ErrInvalidPublicKeyEncoding)
}
func TestUnmarshalPublicKeyRejectsWrongLength(t *testing.T) {
t.Parallel()
_, err := keypair.UnmarshalPublicKey(base64.StdEncoding.EncodeToString([]byte("short")))
require.ErrorIs(t, err, keypair.ErrInvalidPublicKey)
}
func TestPublicKeyFromPrivate(t *testing.T) {
t.Parallel()
priv, pub, err := keypair.Generate(rand.Reader)
require.NoError(t, err)
derived, err := keypair.PublicKeyFromPrivate(priv)
require.NoError(t, err)
assert.Equal(t, pub, derived)
_, err = keypair.PublicKeyFromPrivate([]byte("short"))
require.ErrorIs(t, err, keypair.ErrInvalidPrivateKey)
}