Skip to content
Open
53 changes: 53 additions & 0 deletions clientcore/identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package clientcore

import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"fmt"
)

// PeerIdentity wraps an Ed25519 keypair that serves as a peer's persistent
// cryptographic identity. The public key is used as the PeerID for bandwidth
// attribution, and the keypair doubles as a Solana wallet (Ed25519 is Solana's
// native curve).
type PeerIdentity struct {
privateKey ed25519.PrivateKey
publicKey ed25519.PublicKey
}

// NewPeerIdentity generates a new random Ed25519 keypair.
func NewPeerIdentity() (*PeerIdentity, error) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("generating ed25519 keypair: %w", err)
}
return &PeerIdentity{privateKey: priv, publicKey: pub}, nil
}

// PeerIdentityFromPrivateKeyHex reconstructs a PeerIdentity from a hex-encoded
// 64-byte Ed25519 private key (128 hex characters).
func PeerIdentityFromPrivateKeyHex(hexKey string) (*PeerIdentity, error) {
keyBytes, err := hex.DecodeString(hexKey)
if err != nil {
return nil, fmt.Errorf("decoding hex private key: %w", err)
}
if len(keyBytes) != ed25519.PrivateKeySize {
return nil, fmt.Errorf("invalid private key length: got %d bytes, want %d", len(keyBytes), ed25519.PrivateKeySize)
}
priv := ed25519.PrivateKey(keyBytes)
pub := priv.Public().(ed25519.PublicKey)
return &PeerIdentity{privateKey: priv, publicKey: pub}, nil
}

// PeerID returns the hex-encoded 32-byte public key (64 hex characters),
// suitable for use as EgressOptions.PeerID.
func (id *PeerIdentity) PeerID() string {
return hex.EncodeToString(id.publicKey)
}

// PrivateKeyHex returns the hex-encoded 64-byte private key (128 hex characters),
// the value to persist to disk or localStorage.
func (id *PeerIdentity) PrivateKeyHex() string {
return hex.EncodeToString(id.privateKey)
}
110 changes: 110 additions & 0 deletions clientcore/identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package clientcore

import (
"crypto/ed25519"
"encoding/hex"
"strings"
"testing"
)

func TestNewPeerIdentity(t *testing.T) {
id, err := NewPeerIdentity()
if err != nil {
t.Fatalf("NewPeerIdentity() error: %v", err)
}

// PeerID should be 64 hex chars (32 bytes)
peerID := id.PeerID()
if len(peerID) != 64 {
t.Errorf("PeerID length = %d, want 64", len(peerID))
}
if _, err := hex.DecodeString(peerID); err != nil {
t.Errorf("PeerID is not valid hex: %v", err)
}

// PrivateKeyHex should be 128 hex chars (64 bytes)
privHex := id.PrivateKeyHex()
if len(privHex) != 128 {
t.Errorf("PrivateKeyHex length = %d, want 128", len(privHex))
}
if _, err := hex.DecodeString(privHex); err != nil {
t.Errorf("PrivateKeyHex is not valid hex: %v", err)
}
}

func TestNewPeerIdentityUniqueness(t *testing.T) {
id1, err := NewPeerIdentity()
if err != nil {
t.Fatalf("NewPeerIdentity() error: %v", err)
}
id2, err := NewPeerIdentity()
if err != nil {
t.Fatalf("NewPeerIdentity() error: %v", err)
}
if id1.PeerID() == id2.PeerID() {
t.Error("two generated identities have the same PeerID")
}
}

func TestPeerIdentityFromPrivateKeyHex_RoundTrip(t *testing.T) {
original, err := NewPeerIdentity()
if err != nil {
t.Fatalf("NewPeerIdentity() error: %v", err)
}

restored, err := PeerIdentityFromPrivateKeyHex(original.PrivateKeyHex())
if err != nil {
t.Fatalf("PeerIdentityFromPrivateKeyHex() error: %v", err)
}

if original.PeerID() != restored.PeerID() {
t.Errorf("PeerID mismatch: original=%s, restored=%s", original.PeerID(), restored.PeerID())
}
if original.PrivateKeyHex() != restored.PrivateKeyHex() {
t.Errorf("PrivateKeyHex mismatch after round-trip")
}
}

func TestPeerIdentityFromPrivateKeyHex_SignVerify(t *testing.T) {
id, err := NewPeerIdentity()
if err != nil {
t.Fatalf("NewPeerIdentity() error: %v", err)
}

restored, err := PeerIdentityFromPrivateKeyHex(id.PrivateKeyHex())
if err != nil {
t.Fatalf("PeerIdentityFromPrivateKeyHex() error: %v", err)
}

msg := []byte("test message for signing")
sig := ed25519.Sign(restored.privateKey, msg)

pubBytes, _ := hex.DecodeString(id.PeerID())
pub := ed25519.PublicKey(pubBytes)
if !ed25519.Verify(pub, msg, sig) {
t.Error("signature verification failed after round-trip")
}
}

func TestPeerIdentityFromPrivateKeyHex_InvalidHex(t *testing.T) {
_, err := PeerIdentityFromPrivateKeyHex("not-valid-hex!")
if err == nil {
t.Error("expected error for invalid hex, got nil")
}
}

func TestPeerIdentityFromPrivateKeyHex_WrongLength(t *testing.T) {
// 32 bytes (64 hex chars) instead of 64 bytes
shortKey := strings.Repeat("ab", 32)
_, err := PeerIdentityFromPrivateKeyHex(shortKey)
if err == nil {
t.Error("expected error for wrong key length, got nil")
}
}

func TestPeerIdentityFromPrivateKeyHex_Empty(t *testing.T) {
_, err := PeerIdentityFromPrivateKeyHex("")
if err == nil {
t.Error("expected error for empty string, got nil")
}
}
10 changes: 10 additions & 0 deletions clientcore/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ type EgressOptions struct {
ConnectTimeout time.Duration
ErrorBackoff time.Duration
PeerID string
Identity *PeerIdentity
}

// SetIdentity sets the peer identity and updates PeerID to the identity's
// hex-encoded public key.
func (o *EgressOptions) SetIdentity(id *PeerIdentity) {
o.Identity = id
o.PeerID = id.PeerID()
}

func NewDefaultEgressOptions() *EgressOptions {
Expand All @@ -64,6 +72,8 @@ func NewDefaultEgressOptions() *EgressOptions {

// ConnectionChangeFunc is a callback for consumer connection state changes.
// state: 1 = connected, -1 = disconnected.
// When state == 1 (connected), addr is the IPv4 or IPv6 address of the new consumer.
// When state == -1 (disconnected), addr may be nil and should not be assumed to be non-nil.
type ConnectionChangeFunc func(state int, workerIdx int, addr net.IP)

type BroflakeOptions struct {
Expand Down
9 changes: 7 additions & 2 deletions clientcore/ui_wasm_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import (

type UIImpl struct {
UI
BroflakeEngine *BroflakeEngine
ID string
BroflakeEngine *BroflakeEngine
ID string
OnConnectionChangeFunc ConnectionChangeFunc
}

func (ui *UIImpl) Init(bf *BroflakeEngine) {
Expand Down Expand Up @@ -93,6 +94,10 @@ func (ui UIImpl) OnDownstreamThroughput(bytesPerSec int) {
// consumer (or a 0-length string indicating that address extraction failed); when state == -1,
// addr == "<nil>"
func (ui UIImpl) OnConsumerConnectionChange(state int, workerIdx int, addr net.IP) {
if ui.OnConnectionChangeFunc != nil {
ui.OnConnectionChangeFunc(state, workerIdx, addr)
}

addrString := ""
if addr != nil {
addrString = addr.String()
Expand Down
50 changes: 50 additions & 0 deletions cmd/client_default_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"net/http"
_ "net/http/pprof"
"os"
"path/filepath"
"strings"

"github.com/getlantern/broflake/clientcore"
"github.com/getlantern/broflake/common"
Expand Down Expand Up @@ -61,6 +63,14 @@ func main() {
egOpt.Addr = egress
}

// Load or generate persistent peer identity
if id, err := loadOrGenerateIdentity(); err != nil {
common.Debugf("Warning: failed to load/generate peer identity, using UUID: %v", err)
} else {
egOpt.SetIdentity(id)
common.Debugf("PeerID (ed25519 public key): %v", egOpt.PeerID)
}

bfconn, _, err := clientcore.NewBroflake(bfOpt, rtcOpt, egOpt)
if err != nil {
log.Fatal(err)
Expand All @@ -78,3 +88,43 @@ func main() {

select {}
}

func identityFilePath() string {
if p := os.Getenv("IDENTITY_FILE"); p != "" {
return p
}
home, err := os.UserHomeDir()
if err != nil {
return filepath.Join(".", ".unbounded", "identity.key")
}
return filepath.Join(home, ".unbounded", "identity.key")
}

func loadOrGenerateIdentity() (*clientcore.PeerIdentity, error) {
path := identityFilePath()

data, err := os.ReadFile(path)
if err == nil {
hexKey := strings.TrimSpace(string(data))
return clientcore.PeerIdentityFromPrivateKeyHex(hexKey)
}

if !os.IsNotExist(err) {
return nil, err
}

id, err := clientcore.NewPeerIdentity()
if err != nil {
return nil, err
}

if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return nil, err
}
if err := os.WriteFile(path, []byte(id.PrivateKeyHex()+"\n"), 0600); err != nil {
return nil, err
}

common.Debugf("Generated new peer identity, saved to %v", path)
return id, nil
}
32 changes: 32 additions & 0 deletions cmd/client_wasm_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ import (
func main() {
common.Debugf("wasm client started...")

// generateIdentity generates a new Ed25519 keypair and returns it as a JS
// object with publicKeyHex and privateKeyHex fields. JS should call this on
// first run and persist privateKeyHex in localStorage.
js.Global().Set(
"generateIdentity",
js.FuncOf(func(this js.Value, args []js.Value) interface{} {
id, err := clientcore.NewPeerIdentity()
if err != nil {
common.Debugf("generateIdentity error: %v", err)
return nil
}
result := js.Global().Get("Object").New()
result.Set("publicKeyHex", id.PeerID())
result.Set("privateKeyHex", id.PrivateKeyHex())
return result
}),
)

// A constructor is exposed to JS. Some (but not all) defaults are forcibly overridden by passing
// args. You *must* pass valid values for all of these args:
//
Expand All @@ -28,6 +46,7 @@ func main() {
// WebRTCOptions.Tag
// EgressOptions.Addr
// EgressOptions.Endpoint
// (optional) privateKeyHex — hex-encoded Ed25519 private key for persistent identity
// )
//
// Returns a reference to a Broflake JS API impl (defined in ui_wasm_impl.go)
Expand All @@ -52,6 +71,19 @@ func main() {
egOpt.Addr = args[9].String()
egOpt.Endpoint = args[10].String()

// Optional 12th arg: hex-encoded Ed25519 private key for persistent identity
if len(args) > 11 && args[11].Type() == js.TypeString {
privKeyHex := args[11].String()
if privKeyHex != "" {
if id, err := clientcore.PeerIdentityFromPrivateKeyHex(privKeyHex); err != nil {
common.Debugf("Invalid identity key from JS, using UUID: %v", err)
} else {
egOpt.SetIdentity(id)
common.Debugf("PeerID (ed25519 public key): %v", egOpt.PeerID)
}
}
}

_, ui, err := clientcore.NewBroflake(&bfOpt, rtcOpt, egOpt)
if err != nil {
common.Debugf("newBroflake error: %v", err)
Expand Down
2 changes: 1 addition & 1 deletion egress/cmd/http/egress_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func main() {
// And here's why it doesn't use secure TLS at the QUIC layer
tlsConfig := egcmdcommon.GenerateSelfSignedTLSConfig(true)

ll, err := egress.NewListener(ctx, l, tlsConfig)
ll, err := egress.NewListener(ctx, l, tlsConfig, nil)
if err != nil {
panic(err)
}
Expand Down
2 changes: 1 addition & 1 deletion egress/cmd/socks5/egress_socks5.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func main() {
// And here's why it doesn't use secure TLS at the QUIC layer
tlsConfig := egcmdcommon.GenerateSelfSignedTLSConfig(true)

ll, err := egress.NewListener(ctx, l, tlsConfig)
ll, err := egress.NewListener(ctx, l, tlsConfig, nil)
if err != nil {
panic(err)
}
Expand Down
Loading