Migrate to YAW/2 protocol: WebRTC transport, anchor signaling, nacl/box crypto
Replace the original raw-TCP peer connections and Blowfish/RSA-era design with the YAW/2 protocol stack: Transport - internal/mesh/peer.go: raw TCP + manual ECDH replaced with pion/webrtc DataChannels (ICE, DTLS, SCTP). Peers negotiate via sealed offer/answer/ candidate exchange rather than direct dialling. - internal/nat: deleted — ICE subsumes everything this package was going to do. Signaling - cmd/anchor: new WebSocket signaling server (replaces cmd/relay). Verifies Ed25519 challenge/join signatures, routes opaque nacl/box blobs, broadcasts peer-join/peer-leave events. Never sees plaintext signaling content. - internal/anchor: new anchor client. Manages PeerConnection lifecycle, decides offerer by peer-id comparison, seals/opens signaling payloads with nacl/box, implements mesh.Anchor. Crypto (internal/crypto) - PeerID encoding: base64url → lowercase hex (64 chars, YAW/2 §2). - Sign/Verify: hex signatures throughout. - Added CurvePublicKey/CurvePrivateKey: X25519 keys derived from Ed25519 identity via Montgomery conversion (filippo.io/edwards25519), matching libsodium's crypto_sign_ed25519_*_to_curve25519. - Added SignalingBox/SignalingOpen: nacl/box (XSalsa20-Poly1305) seal/open for signaling payloads. Wire types (internal/proto) - ChatMessage gains Mid (random 16-byte hex) for mesh deduplication (§8). - FileChunk removed from PeerMessage — chunks go on a separate binary DataChannel labelled "f:<xid>"; FileDone added. - New types: SignalingPayload, AnchorMessage, HelloMessage, HelloBindString. - PeerID.Short() now returns first 16 hex chars grouped in 4s. - IPC: CmdConnect removed; CmdJoinNetwork/CmdLeaveNetwork added; EvtSessionReady added (fires when DataChannel opens and hello is received). IPC (internal/ipc) - join_network/leave_network replace connect (direct TCP dial). - Network joins are context-scoped: leave_network or client disconnect cancels the anchor connection cleanly. README updated to reflect new project layout, getting-started commands, IPC protocol, and roadmap state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,9 @@ import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -17,13 +19,15 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"filippo.io/edwards25519"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
// b64 is the base64url encoder we use everywhere (no padding, URL-safe).
|
||||
// b64 is used only for storing the private key on disk and for symmetric nonces/ciphertexts.
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
// ── Identity ──────────────────────────────────────────────────────────────────
|
||||
@@ -93,10 +97,9 @@ func LoadOrCreate(dataDir, alias string) (*Identity, error) {
|
||||
return &Identity{privateKey: priv, PublicKey: pub, Alias: alias}, nil
|
||||
}
|
||||
|
||||
// PeerID returns the base64url encoding of the public key.
|
||||
// This is the peer's stable identifier.
|
||||
// PeerID returns the lowercase hex encoding of the 32-byte Ed25519 public key (YAW/2 §2).
|
||||
func (id *Identity) PeerID() proto.PeerID {
|
||||
return proto.PeerID(b64.EncodeToString(id.PublicKey))
|
||||
return proto.PeerID(hex.EncodeToString(id.PublicKey))
|
||||
}
|
||||
|
||||
// PeerInfo builds the PeerInfo struct for the handshake.
|
||||
@@ -104,24 +107,24 @@ func (id *Identity) PeerInfo() proto.PeerInfo {
|
||||
return proto.PeerInfo{
|
||||
ID: id.PeerID(),
|
||||
Alias: id.Alias,
|
||||
PublicKey: b64.EncodeToString(id.PublicKey),
|
||||
PublicKey: hex.EncodeToString(id.PublicKey),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Sign signs data with our Ed25519 private key. Returns base64url signature.
|
||||
// Sign signs data with our Ed25519 private key. Returns hex-encoded signature.
|
||||
func (id *Identity) Sign(data []byte) string {
|
||||
sig := ed25519.Sign(id.privateKey, data)
|
||||
return b64.EncodeToString(sig)
|
||||
return hex.EncodeToString(sig)
|
||||
}
|
||||
|
||||
// Verify checks an Ed25519 signature against a base64url public key.
|
||||
func Verify(publicKeyB64 string, data []byte, sigB64 string) error {
|
||||
pubBytes, err := b64.DecodeString(publicKeyB64)
|
||||
// Verify checks an Ed25519 signature against a hex-encoded public key.
|
||||
func Verify(publicKeyHex string, data []byte, sigHex string) error {
|
||||
pubBytes, err := hex.DecodeString(publicKeyHex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding public key: %w", err)
|
||||
}
|
||||
sigBytes, err := b64.DecodeString(sigB64)
|
||||
sigBytes, err := hex.DecodeString(sigHex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding signature: %w", err)
|
||||
}
|
||||
@@ -131,6 +134,51 @@ func Verify(publicKeyB64 string, data []byte, sigB64 string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CurvePublicKey returns the X25519 public key derived from this Ed25519 identity (YAW/2 §3).
|
||||
func (id *Identity) CurvePublicKey() *[32]byte {
|
||||
edPoint, _ := new(edwards25519.Point).SetBytes(id.PublicKey)
|
||||
mont := edPoint.BytesMontgomery()
|
||||
var out [32]byte
|
||||
copy(out[:], mont)
|
||||
return &out
|
||||
}
|
||||
|
||||
// CurvePrivateKey returns the X25519 private key derived from this Ed25519 identity (YAW/2 §3).
|
||||
// Uses the clamped SHA-512 first half of the Ed25519 seed, matching libsodium's conversion.
|
||||
func (id *Identity) CurvePrivateKey() *[32]byte {
|
||||
h := sha512.Sum512(id.privateKey[:32])
|
||||
h[0] &= 248
|
||||
h[31] &= 127
|
||||
h[31] |= 64
|
||||
var out [32]byte
|
||||
copy(out[:], h[:32])
|
||||
return &out
|
||||
}
|
||||
|
||||
// SignalingBox seals a plaintext signaling payload for a recipient using nacl/box (YAW/2 §5).
|
||||
// Returns base64(nonce(24) || ciphertext).
|
||||
func SignalingBox(plaintext []byte, recipientPub, senderPriv *[32]byte) string {
|
||||
var nonce [24]byte
|
||||
rand.Read(nonce[:]) //nolint:errcheck
|
||||
ct := box.Seal(nonce[:], plaintext, &nonce, recipientPub, senderPriv)
|
||||
return base64.StdEncoding.EncodeToString(ct)
|
||||
}
|
||||
|
||||
// SignalingOpen opens a nacl/box sealed by SignalingBox.
|
||||
func SignalingOpen(b64box string, senderPub, recipientPriv *[32]byte) ([]byte, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(b64box)
|
||||
if err != nil || len(raw) < 24 {
|
||||
return nil, errors.New("invalid box")
|
||||
}
|
||||
var nonce [24]byte
|
||||
copy(nonce[:], raw[:24])
|
||||
out, ok := box.Open(nil, raw[24:], &nonce, senderPub, recipientPriv)
|
||||
if !ok {
|
||||
return nil, errors.New("box open failed")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── X25519 ECDH ───────────────────────────────────────────────────────────────
|
||||
|
||||
// EphemeralKey is an X25519 keypair used for a single session.
|
||||
|
||||
71
internal/crypto/crypto_test.go
Normal file
71
internal/crypto/crypto_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPeerIDIsHex(t *testing.T) {
|
||||
id := newTestIdentity(t)
|
||||
peerID := string(id.PeerID())
|
||||
|
||||
if len(peerID) != 64 {
|
||||
t.Fatalf("PeerID length = %d, want 64", len(peerID))
|
||||
}
|
||||
if strings.ToLower(peerID) != peerID {
|
||||
t.Fatal("PeerID is not lowercase")
|
||||
}
|
||||
if _, err := hex.DecodeString(peerID); err != nil {
|
||||
t.Fatalf("PeerID is not valid hex: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignVerifyHex(t *testing.T) {
|
||||
id := newTestIdentity(t)
|
||||
msg := []byte("test message")
|
||||
|
||||
sig := id.Sign(msg)
|
||||
if err := Verify(string(id.PeerID()), msg, sig); err != nil {
|
||||
t.Fatalf("Verify failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalingBoxRoundTrip(t *testing.T) {
|
||||
alice := newTestIdentity(t)
|
||||
bob := newTestIdentity(t)
|
||||
|
||||
plaintext := []byte(`{"kind":"offer","sdp":"v=0..."}`)
|
||||
|
||||
sealed := SignalingBox(plaintext, bob.CurvePublicKey(), alice.CurvePrivateKey())
|
||||
got, err := SignalingOpen(sealed, alice.CurvePublicKey(), bob.CurvePrivateKey())
|
||||
if err != nil {
|
||||
t.Fatalf("SignalingOpen failed: %v", err)
|
||||
}
|
||||
if string(got) != string(plaintext) {
|
||||
t.Fatalf("got %q, want %q", got, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalingBoxTamperedFails(t *testing.T) {
|
||||
alice := newTestIdentity(t)
|
||||
bob := newTestIdentity(t)
|
||||
|
||||
sealed := SignalingBox([]byte("hello"), bob.CurvePublicKey(), alice.CurvePrivateKey())
|
||||
|
||||
// wrong sender public key
|
||||
carol := newTestIdentity(t)
|
||||
if _, err := SignalingOpen(sealed, carol.CurvePublicKey(), bob.CurvePrivateKey()); err == nil {
|
||||
t.Fatal("expected error with wrong sender key, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// newTestIdentity creates a fresh in-memory identity for testing.
|
||||
func newTestIdentity(t *testing.T) *Identity {
|
||||
t.Helper()
|
||||
id, err := LoadOrCreate(t.TempDir(), "test")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
Reference in New Issue
Block a user