Initial commit: waste-go skeleton
Ed25519/X25519/ChaCha20-Poly1305 crypto, peer handshake, mesh state, IPC server, relay server, and NAT stub. Builds clean on Go 1.22+. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/bin/
|
||||
*.exe
|
||||
*.identity.json
|
||||
/tmp/
|
||||
.env
|
||||
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"golang.go",
|
||||
"usernamehw.errorlens",
|
||||
"streetsidesoftware.code-spell-checker"
|
||||
]
|
||||
}
|
||||
55
.vscode/launch.json
vendored
Normal file
55
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "daemon (peer A)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}/cmd/daemon",
|
||||
"args": [
|
||||
"-alias", "peer-a",
|
||||
"-data-dir", "/tmp/waste-a",
|
||||
"-peer-port", "17338",
|
||||
"-ipc-port", "17337"
|
||||
],
|
||||
"env": { "WASTE_LOG": "debug" }
|
||||
},
|
||||
{
|
||||
"name": "daemon (peer B)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}/cmd/daemon",
|
||||
"args": [
|
||||
"-alias", "peer-b",
|
||||
"-data-dir", "/tmp/waste-b",
|
||||
"-peer-port", "17340",
|
||||
"-ipc-port", "17341"
|
||||
],
|
||||
"env": { "WASTE_LOG": "debug" }
|
||||
},
|
||||
{
|
||||
"name": "relay (local)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}/cmd/relay",
|
||||
"args": ["-bind", "127.0.0.1:17339"]
|
||||
},
|
||||
{
|
||||
"name": "daemon (with relay)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}/cmd/daemon",
|
||||
"args": [
|
||||
"-alias", "peer-a",
|
||||
"-data-dir", "/tmp/waste-a",
|
||||
"-peer-port", "17338",
|
||||
"-ipc-port", "17337",
|
||||
"-relay", "127.0.0.1:17339"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
21
.vscode/settings.json
vendored
Normal file
21
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"go.useLanguageServer": true,
|
||||
"go.lintTool": "golangci-lint",
|
||||
"go.lintOnSave": "package",
|
||||
"go.formatTool": "goimports",
|
||||
"go.formatOnSave": true,
|
||||
"editor.formatOnSave": true,
|
||||
"[go]": {
|
||||
"editor.defaultFormatter": "golang.go",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
},
|
||||
"go.testFlags": ["-v", "-race"],
|
||||
"files.watcherExclude": {
|
||||
"**/bin/**": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/bin": true
|
||||
}
|
||||
}
|
||||
46
.vscode/tasks.json
vendored
Normal file
46
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "build all",
|
||||
"type": "shell",
|
||||
"command": "go build ./...",
|
||||
"group": { "kind": "build", "isDefault": true },
|
||||
"problemMatcher": ["$go"]
|
||||
},
|
||||
{
|
||||
"label": "go mod tidy",
|
||||
"type": "shell",
|
||||
"command": "go mod tidy",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "test all",
|
||||
"type": "shell",
|
||||
"command": "go test -race ./...",
|
||||
"group": "test",
|
||||
"problemMatcher": ["$go"]
|
||||
},
|
||||
{
|
||||
"label": "run relay (local)",
|
||||
"type": "shell",
|
||||
"command": "go run ./cmd/relay -bind 127.0.0.1:17339",
|
||||
"presentation": { "reveal": "always", "panel": "dedicated", "group": "runtime" },
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "run daemon (peer A)",
|
||||
"type": "shell",
|
||||
"command": "go run ./cmd/daemon -alias peer-a -data-dir /tmp/waste-a -peer-port 17338 -ipc-port 17337",
|
||||
"presentation": { "reveal": "always", "panel": "dedicated", "group": "runtime" },
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "run daemon (peer B)",
|
||||
"type": "shell",
|
||||
"command": "go run ./cmd/daemon -alias peer-b -data-dir /tmp/waste-b -peer-port 17340 -ipc-port 17341",
|
||||
"presentation": { "reveal": "always", "panel": "dedicated", "group": "runtime" },
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
129
README.md
Normal file
129
README.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# waste-go
|
||||
|
||||
A modern reimagining of [WASTE](https://en.wikipedia.org/wiki/WASTE) — decentralized,
|
||||
friend-to-friend encrypted mesh networking with chat and file sharing. Written in Go.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
waste-go/
|
||||
├── cmd/
|
||||
│ ├── daemon/ The peer process — run one on each friend's machine
|
||||
│ └── relay/ Bootstrap/relay server — run this on your Hetzner VPS
|
||||
└── internal/
|
||||
├── proto/ All wire types (shared by daemon and relay)
|
||||
├── crypto/ Ed25519 identity, X25519 ECDH, ChaCha20-Poly1305
|
||||
├── mesh/ Connected peer state + per-connection handler
|
||||
├── ipc/ Local JSON API (UI talks to daemon here, port 17337)
|
||||
└── nat/ Relay client (hole-punching lives here later)
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.22+ → https://go.dev/dl/
|
||||
- VS Code with the Go extension (`golang.go`)
|
||||
|
||||
On first open VS Code will prompt you to install `gopls`, `dlv`, and `goimports` — accept all of them.
|
||||
|
||||
## Getting started
|
||||
|
||||
```bash
|
||||
# Fetch dependencies
|
||||
go mod tidy
|
||||
|
||||
# Build everything (confirms it compiles)
|
||||
go build ./...
|
||||
|
||||
# Terminal 1 — relay (optional for LAN testing, required across internet)
|
||||
go run ./cmd/relay -bind 127.0.0.1:17339
|
||||
|
||||
# Terminal 2 — peer A
|
||||
go run ./cmd/daemon -alias alice -data-dir /tmp/waste-alice -peer-port 17338 -ipc-port 17337
|
||||
|
||||
# Terminal 3 — peer B
|
||||
go run ./cmd/daemon -alias bob -data-dir /tmp/waste-bob -peer-port 17340 -ipc-port 17341
|
||||
```
|
||||
|
||||
Then connect B → A and send a message (netcat works fine as a quick test):
|
||||
|
||||
```bash
|
||||
# Tell peer B to connect to peer A
|
||||
echo '{"type":"connect","addr":"127.0.0.1:17338"}' | nc 127.0.0.1 17341
|
||||
|
||||
# In another terminal — subscribe to peer A's events, then send a message from B
|
||||
nc 127.0.0.1 17337 &
|
||||
echo '{"type":"send_message","room":"general","body":"hello from bob"}' | nc 127.0.0.1 17341
|
||||
```
|
||||
|
||||
**On Windows** — use PowerShell's built-in TCP client instead of `nc`:
|
||||
|
||||
```powershell
|
||||
# Open a connection to peer B's IPC port and keep it open
|
||||
$c = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 17341)
|
||||
$w = [System.IO.StreamWriter]::new($c.GetStream()); $w.AutoFlush = $true
|
||||
|
||||
# Tell peer B to connect to peer A
|
||||
$w.WriteLine('{"type":"connect","addr":"127.0.0.1:17338"}')
|
||||
|
||||
# Send a chat message from B
|
||||
$w.WriteLine('{"type":"send_message","room":"general","body":"hello from bob"}')
|
||||
|
||||
# In a separate terminal — subscribe to peer A's events
|
||||
$r = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 17337)
|
||||
$reader = [System.IO.StreamReader]::new($r.GetStream())
|
||||
while ($true) { $reader.ReadLine() }
|
||||
```
|
||||
|
||||
Keep `$c` / `$w` in scope for the session; closing them disconnects the peer.
|
||||
|
||||
## Deploying the relay on your Hetzner VPS
|
||||
|
||||
```bash
|
||||
GOOS=linux GOARCH=amd64 go build -o bin/waste-relay ./cmd/relay
|
||||
scp bin/waste-relay user@your-vps:~/
|
||||
|
||||
# On the VPS
|
||||
./waste-relay -bind 0.0.0.0:17339
|
||||
```
|
||||
|
||||
Then start daemons with `-relay your-vps-ip:17339` and they'll register and
|
||||
be able to find each other across NAT.
|
||||
|
||||
## IPC protocol (plain JSON over TCP)
|
||||
|
||||
Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`.
|
||||
|
||||
**Commands you send:**
|
||||
```jsonc
|
||||
{"type":"connect","addr":"1.2.3.4:17338"}
|
||||
{"type":"send_message","room":"general","body":"hi"}
|
||||
{"type":"get_state"}
|
||||
```
|
||||
|
||||
**Events the daemon pushes:**
|
||||
```jsonc
|
||||
{"type":"state_snapshot","local_peer":{...},"connected_peers":[...]}
|
||||
{"type":"peer_connected","peer":{...}}
|
||||
{"type":"message_received","message":{"from":"...","body":"hi","room":"general"}}
|
||||
{"type":"peer_disconnected","peer_id":"..."}
|
||||
```
|
||||
|
||||
## Crypto choices
|
||||
|
||||
| Purpose | Algorithm | Notes |
|
||||
|---|---|---|
|
||||
| Identity | Ed25519 | Fast, small keys, standard |
|
||||
| Key exchange | X25519 ECDH | Per-session ephemeral keys |
|
||||
| Symmetric | ChaCha20-Poly1305 | No AES-NI needed, authenticated |
|
||||
| Hashing | SHA-256 | File integrity |
|
||||
|
||||
Replaces WASTE's original Blowfish/PCBC (broken cipher mode) + RSA.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] UDP hole-punching (STUN) in `internal/nat`
|
||||
- [ ] Invite file format (`.waste-invite`) — share a keypair + address hint
|
||||
- [ ] Peer gossip → auto-connect to friends-of-friends
|
||||
- [ ] File transfer
|
||||
- [ ] Message persistence (SQLite via `modernc.org/sqlite`)
|
||||
- [ ] Tauri or simple web UI consuming the IPC port
|
||||
74
cmd/daemon/main.go
Normal file
74
cmd/daemon/main.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// waste-daemon: the local peer process.
|
||||
// Run one of these on each friend's machine.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/ipc"
|
||||
"github.com/waste-go/internal/mesh"
|
||||
"github.com/waste-go/internal/nat"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dataDir := flag.String("data-dir", "~/.waste", "path to identity/config directory")
|
||||
alias := flag.String("alias", "anon", "display name shown to peers (advisory only)")
|
||||
peerPort := flag.Int("peer-port", 17338, "port to accept incoming peer connections")
|
||||
ipcPort := flag.Int("ipc-port", 17337, "port for local IPC (UI connects here)")
|
||||
relay := flag.String("relay", "", "optional relay server address (host:port)")
|
||||
flag.Parse()
|
||||
|
||||
// Load or generate identity keypair
|
||||
id, err := crypto.LoadOrCreate(expandHome(*dataDir), *alias)
|
||||
if err != nil {
|
||||
log.Fatalf("identity: %v", err)
|
||||
}
|
||||
log.Printf("daemon: local peer id: %s alias: %s", id.PeerID().Short(), id.Alias)
|
||||
|
||||
// Shared mesh state
|
||||
m := mesh.New(id)
|
||||
|
||||
// Peer listener (inbound connections from other nodes)
|
||||
go func() {
|
||||
addr := fmt.Sprintf("0.0.0.0:%d", *peerPort)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Fatalf("peer listener: %v", err)
|
||||
}
|
||||
log.Printf("daemon: listening for peers on %s", addr)
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Printf("peer accept error: %v", err)
|
||||
continue
|
||||
}
|
||||
go mesh.HandleConn(conn, m, false)
|
||||
}
|
||||
}()
|
||||
|
||||
// NAT traversal / relay client
|
||||
go func() {
|
||||
if err := nat.Run(m, *relay); err != nil {
|
||||
log.Printf("nat: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// IPC server — blocks until error
|
||||
if err := ipc.Run(m, *ipcPort); err != nil {
|
||||
log.Fatalf("ipc: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func expandHome(path string) string {
|
||||
if len(path) >= 2 && path[:2] == "~/" {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return home + path[1:]
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
177
cmd/relay/main.go
Normal file
177
cmd/relay/main.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// waste-relay: bootstrap and relay server.
|
||||
// Deploy this on your Hetzner VPS. Peers register here and can ask it
|
||||
// to forward encrypted envelopes to other peers.
|
||||
// The relay never sees plaintext — it only shuffles opaque blobs.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
func main() {
|
||||
bind := flag.String("bind", "0.0.0.0:17339", "address to listen on")
|
||||
flag.Parse()
|
||||
|
||||
ln, err := net.Listen("tcp", *bind)
|
||||
if err != nil {
|
||||
log.Fatalf("relay: listen on %s: %v", *bind, err)
|
||||
}
|
||||
log.Printf("relay: listening on %s", *bind)
|
||||
|
||||
r := newRegistry()
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Printf("relay: accept error: %v", err)
|
||||
continue
|
||||
}
|
||||
go r.handleClient(conn)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type registeredPeer struct {
|
||||
gossip proto.GossipEntry
|
||||
send chan proto.RelayMessage // write to this to forward a message
|
||||
}
|
||||
|
||||
type registry struct {
|
||||
mu sync.RWMutex
|
||||
peers map[proto.PeerID]*registeredPeer
|
||||
}
|
||||
|
||||
func newRegistry() *registry {
|
||||
return ®istry{peers: make(map[proto.PeerID]*registeredPeer)}
|
||||
}
|
||||
|
||||
func (r *registry) register(id proto.PeerID, entry proto.GossipEntry, send chan proto.RelayMessage) {
|
||||
r.mu.Lock()
|
||||
r.peers[id] = ®isteredPeer{gossip: entry, send: send}
|
||||
r.mu.Unlock()
|
||||
log.Printf("relay: registered %s (%s)", id.Short(), entry.Peer.Alias)
|
||||
}
|
||||
|
||||
func (r *registry) unregister(id proto.PeerID) {
|
||||
r.mu.Lock()
|
||||
delete(r.peers, id)
|
||||
r.mu.Unlock()
|
||||
log.Printf("relay: unregistered %s", id.Short())
|
||||
}
|
||||
|
||||
func (r *registry) listPeers() []proto.GossipEntry {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]proto.GossipEntry, 0, len(r.peers))
|
||||
for _, p := range r.peers {
|
||||
out = append(out, p.gossip)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *registry) forward(to proto.PeerID, msg proto.RelayMessage) bool {
|
||||
r.mu.RLock()
|
||||
p, ok := r.peers[to]
|
||||
r.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case p.send <- msg:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Client handler ────────────────────────────────────────────────────────────
|
||||
|
||||
func (r *registry) handleClient(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
addr := conn.RemoteAddr().String()
|
||||
|
||||
sendCh := make(chan proto.RelayMessage, 64)
|
||||
var myID proto.PeerID
|
||||
|
||||
// Writer goroutine
|
||||
go func() {
|
||||
enc := json.NewEncoder(conn)
|
||||
for msg := range sendCh {
|
||||
if err := enc.Encode(msg); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
send := func(msg proto.RelayMessage) {
|
||||
select {
|
||||
case sendCh <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
var msg proto.RelayMessage
|
||||
if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil {
|
||||
log.Printf("relay: bad message from %s: %v", addr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
|
||||
case proto.RelayRegister:
|
||||
if msg.Peer == nil {
|
||||
send(proto.RelayMessage{Type: proto.RelayError, Message: "register: peer required"})
|
||||
continue
|
||||
}
|
||||
// TODO: verify msg.Signature against msg.Peer.PublicKey
|
||||
myID = msg.Peer.ID
|
||||
entry := proto.GossipEntry{
|
||||
Peer: *msg.Peer,
|
||||
AddrHint: addr,
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
r.register(myID, entry, sendCh)
|
||||
|
||||
case proto.RelayListPeers:
|
||||
send(proto.RelayMessage{
|
||||
Type: proto.RelayPeerList,
|
||||
Peers: r.listPeers(),
|
||||
})
|
||||
|
||||
case proto.RelayForward:
|
||||
if msg.To == nil || msg.Envelope == nil {
|
||||
send(proto.RelayMessage{Type: proto.RelayError, Message: "forward: to and envelope required"})
|
||||
continue
|
||||
}
|
||||
fwd := proto.RelayMessage{
|
||||
Type: proto.RelayForwarded,
|
||||
From: &myID,
|
||||
Envelope: msg.Envelope,
|
||||
}
|
||||
if !r.forward(*msg.To, fwd) {
|
||||
send(proto.RelayMessage{
|
||||
Type: proto.RelayError,
|
||||
Message: "peer not connected to relay",
|
||||
})
|
||||
}
|
||||
|
||||
default:
|
||||
log.Printf("relay: unknown message type %q from %s", msg.Type, addr)
|
||||
}
|
||||
}
|
||||
|
||||
close(sendCh)
|
||||
if myID != "" {
|
||||
r.unregister(myID)
|
||||
}
|
||||
}
|
||||
10
go.mod
Normal file
10
go.mod
Normal file
@@ -0,0 +1,10 @@
|
||||
module github.com/waste-go
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
golang.org/x/crypto v0.24.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.21.0 // indirect
|
||||
6
go.sum
Normal file
6
go.sum
Normal file
@@ -0,0 +1,6 @@
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
232
internal/crypto/crypto.go
Normal file
232
internal/crypto/crypto.go
Normal file
@@ -0,0 +1,232 @@
|
||||
// Package crypto handles all cryptographic operations for waste-go.
|
||||
//
|
||||
// Identity: Ed25519 keypair — who you are, persistent across restarts
|
||||
// Session: X25519 ECDH — per-connection shared secret
|
||||
// Symmetric: ChaCha20-Poly1305 — encrypts every message after the handshake
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
// b64 is the base64url encoder we use everywhere (no padding, URL-safe).
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
// ── Identity ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Identity holds our Ed25519 keypair and alias.
|
||||
// Load it once at startup with LoadOrCreate; then pass it around.
|
||||
type Identity struct {
|
||||
privateKey ed25519.PrivateKey
|
||||
PublicKey ed25519.PublicKey
|
||||
Alias string
|
||||
}
|
||||
|
||||
// identityFile is what we store on disk.
|
||||
type identityFile struct {
|
||||
PrivateKeyB64 string `json:"private_key"`
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
|
||||
// LoadOrCreate loads the identity from dataDir/identity.json,
|
||||
// or generates a fresh keypair if none exists yet.
|
||||
func LoadOrCreate(dataDir, alias string) (*Identity, error) {
|
||||
if err := os.MkdirAll(dataDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("creating data dir: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dataDir, "identity.json")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
// File exists — load it
|
||||
var f identityFile
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
return nil, fmt.Errorf("parsing identity file: %w", err)
|
||||
}
|
||||
privBytes, err := b64.DecodeString(f.PrivateKeyB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding private key: %w", err)
|
||||
}
|
||||
priv := ed25519.PrivateKey(privBytes)
|
||||
return &Identity{
|
||||
privateKey: priv,
|
||||
PublicKey: priv.Public().(ed25519.PublicKey),
|
||||
Alias: f.Alias,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("reading identity file: %w", err)
|
||||
}
|
||||
|
||||
// Generate new keypair
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating keypair: %w", err)
|
||||
}
|
||||
|
||||
f := identityFile{
|
||||
PrivateKeyB64: b64.EncodeToString(priv),
|
||||
Alias: alias,
|
||||
}
|
||||
raw, _ := json.MarshalIndent(f, "", " ")
|
||||
if err := os.WriteFile(path, raw, 0600); err != nil {
|
||||
return nil, fmt.Errorf("saving identity: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Generated new identity, saved to %s\n", path)
|
||||
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.
|
||||
func (id *Identity) PeerID() proto.PeerID {
|
||||
return proto.PeerID(b64.EncodeToString(id.PublicKey))
|
||||
}
|
||||
|
||||
// PeerInfo builds the PeerInfo struct for the handshake.
|
||||
func (id *Identity) PeerInfo() proto.PeerInfo {
|
||||
return proto.PeerInfo{
|
||||
ID: id.PeerID(),
|
||||
Alias: id.Alias,
|
||||
PublicKey: b64.EncodeToString(id.PublicKey),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Sign signs data with our Ed25519 private key. Returns base64url signature.
|
||||
func (id *Identity) Sign(data []byte) string {
|
||||
sig := ed25519.Sign(id.privateKey, data)
|
||||
return b64.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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding public key: %w", err)
|
||||
}
|
||||
sigBytes, err := b64.DecodeString(sigB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding signature: %w", err)
|
||||
}
|
||||
if !ed25519.Verify(ed25519.PublicKey(pubBytes), data, sigBytes) {
|
||||
return errors.New("signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── X25519 ECDH ───────────────────────────────────────────────────────────────
|
||||
|
||||
// EphemeralKey is an X25519 keypair used for a single session.
|
||||
type EphemeralKey struct {
|
||||
private [32]byte
|
||||
public [32]byte
|
||||
}
|
||||
|
||||
// GenerateEphemeral creates a fresh X25519 keypair.
|
||||
func GenerateEphemeral() (*EphemeralKey, error) {
|
||||
ek := &EphemeralKey{}
|
||||
if _, err := rand.Read(ek.private[:]); err != nil {
|
||||
return nil, fmt.Errorf("generating ephemeral key: %w", err)
|
||||
}
|
||||
// Clamp per RFC 7748
|
||||
ek.private[0] &= 248
|
||||
ek.private[31] &= 127
|
||||
ek.private[31] |= 64
|
||||
|
||||
pub, err := curve25519.X25519(ek.private[:], curve25519.Basepoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computing public key: %w", err)
|
||||
}
|
||||
copy(ek.public[:], pub)
|
||||
return ek, nil
|
||||
}
|
||||
|
||||
// PublicKeyB64 returns the base64url public key to send in the handshake.
|
||||
func (ek *EphemeralKey) PublicKeyB64() string {
|
||||
return b64.EncodeToString(ek.public[:])
|
||||
}
|
||||
|
||||
// SharedSecret performs ECDH with the other party's public key.
|
||||
// Returns a 32-byte shared secret suitable for use as an AEAD key.
|
||||
func (ek *EphemeralKey) SharedSecret(theirPublicB64 string) ([32]byte, error) {
|
||||
var result [32]byte
|
||||
theirBytes, err := b64.DecodeString(theirPublicB64)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("decoding their public key: %w", err)
|
||||
}
|
||||
shared, err := curve25519.X25519(ek.private[:], theirBytes)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("ECDH: %w", err)
|
||||
}
|
||||
// Hash the raw shared secret (simple KDF — use HKDF in production)
|
||||
result = sha256.Sum256(shared)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ── ChaCha20-Poly1305 AEAD ────────────────────────────────────────────────────
|
||||
|
||||
// Session holds the symmetric key for an established peer session.
|
||||
type Session struct {
|
||||
key [32]byte
|
||||
}
|
||||
|
||||
// NewSession wraps a 32-byte shared secret as a usable session.
|
||||
func NewSession(key [32]byte) *Session {
|
||||
return &Session{key: key}
|
||||
}
|
||||
|
||||
// Encrypt encrypts plaintext. Returns (nonce_b64, ciphertext_b64).
|
||||
func (s *Session) Encrypt(plaintext []byte) (string, string, error) {
|
||||
aead, err := chacha20poly1305.New(s.key[:])
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("creating AEAD: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", "", fmt.Errorf("generating nonce: %w", err)
|
||||
}
|
||||
|
||||
ciphertext := aead.Seal(nil, nonce, plaintext, nil)
|
||||
return b64.EncodeToString(nonce), b64.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts a nonce+ciphertext pair. Returns plaintext.
|
||||
func (s *Session) Decrypt(nonceB64, ciphertextB64 string) ([]byte, error) {
|
||||
aead, err := chacha20poly1305.New(s.key[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating AEAD: %w", err)
|
||||
}
|
||||
|
||||
nonce, err := b64.DecodeString(nonceB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding nonce: %w", err)
|
||||
}
|
||||
ciphertext, err := b64.DecodeString(ciphertextB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding ciphertext: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := aead.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("decryption failed — bad key or tampered message")
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
167
internal/ipc/ipc.go
Normal file
167
internal/ipc/ipc.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Package ipc implements the local IPC server.
|
||||
// The UI (or any local tool) connects to 127.0.0.1:17337 and speaks
|
||||
// newline-delimited JSON: send IpcMessage commands, receive IpcMessage events.
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/waste-go/internal/mesh"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
// Run starts the IPC listener. Blocks until the listener fails.
|
||||
func Run(m *mesh.Mesh, port int) error {
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ipc listen on %s: %w", addr, err)
|
||||
}
|
||||
log.Printf("ipc: listening on %s", addr)
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ipc accept: %w", err)
|
||||
}
|
||||
log.Printf("ipc: UI client connected from %s", conn.RemoteAddr())
|
||||
go handleClient(conn, m)
|
||||
}
|
||||
}
|
||||
|
||||
func handleClient(conn net.Conn, m *mesh.Mesh) {
|
||||
defer conn.Close()
|
||||
|
||||
// Subscribe to mesh events before anything else so we miss nothing.
|
||||
events := m.Subscribe()
|
||||
defer m.Unsubscribe(events)
|
||||
|
||||
// Channel to serialize writes from two goroutines (event pusher + reply sender).
|
||||
writeCh := make(chan []byte, 128)
|
||||
|
||||
// Writer goroutine — single goroutine owns the connection write side.
|
||||
go func() {
|
||||
w := bufio.NewWriter(conn)
|
||||
for line := range writeCh {
|
||||
line = append(line, '\n')
|
||||
if _, err := w.Write(line); err != nil {
|
||||
return
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
}()
|
||||
|
||||
// Event pusher goroutine — forwards mesh events to the UI.
|
||||
go func() {
|
||||
for evt := range events {
|
||||
line, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case writeCh <- line:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Send an initial state snapshot so the UI has something to render.
|
||||
send(writeCh, proto.IpcMessage{
|
||||
Type: proto.EvtStateSnapshot,
|
||||
LocalPeer: ptr(m.Identity.PeerInfo()),
|
||||
ConnectedPeers: m.ConnectedPeers(),
|
||||
Rooms: []string{"general"},
|
||||
})
|
||||
|
||||
// Command reader loop.
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
var cmd proto.IpcMessage
|
||||
if err := json.Unmarshal(scanner.Bytes(), &cmd); err != nil {
|
||||
log.Printf("ipc: bad command: %v", err)
|
||||
continue
|
||||
}
|
||||
handleCommand(cmd, m, writeCh)
|
||||
}
|
||||
|
||||
close(writeCh)
|
||||
log.Printf("ipc: UI client disconnected")
|
||||
}
|
||||
|
||||
func handleCommand(cmd proto.IpcMessage, m *mesh.Mesh, writeCh chan<- []byte) {
|
||||
switch cmd.Type {
|
||||
|
||||
case proto.CmdSendMessage:
|
||||
msg := &proto.ChatMessage{
|
||||
ID: uuid.NewString(),
|
||||
From: m.Identity.PeerID(),
|
||||
To: cmd.To,
|
||||
Room: cmd.Room,
|
||||
Body: cmd.Body,
|
||||
SentAt: time.Now(),
|
||||
}
|
||||
payload, err := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgChat,
|
||||
Chat: msg,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.Broadcast(payload)
|
||||
// Echo locally so the sender sees their own message.
|
||||
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: msg})
|
||||
|
||||
case proto.CmdConnect:
|
||||
if cmd.Addr == "" {
|
||||
send(writeCh, errMsg("connect: addr is required"))
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
log.Printf("ipc: connecting to peer at %s", cmd.Addr)
|
||||
conn, err := net.DialTimeout("tcp", cmd.Addr, 10*time.Second)
|
||||
if err != nil {
|
||||
log.Printf("ipc: dial %s failed: %v", cmd.Addr, err)
|
||||
m.Emit(errMsg(fmt.Sprintf("connect to %s failed: %v", cmd.Addr, err)))
|
||||
return
|
||||
}
|
||||
mesh.HandleConn(conn, m, true)
|
||||
}()
|
||||
|
||||
case proto.CmdGetState:
|
||||
send(writeCh, proto.IpcMessage{
|
||||
Type: proto.EvtStateSnapshot,
|
||||
LocalPeer: ptr(m.Identity.PeerInfo()),
|
||||
ConnectedPeers: m.ConnectedPeers(),
|
||||
Rooms: []string{"general"},
|
||||
})
|
||||
|
||||
case proto.CmdSendFile:
|
||||
send(writeCh, errMsg("file transfer not yet implemented"))
|
||||
|
||||
default:
|
||||
send(writeCh, errMsg(fmt.Sprintf("unknown command: %s", cmd.Type)))
|
||||
}
|
||||
}
|
||||
|
||||
func send(ch chan<- []byte, msg proto.IpcMessage) {
|
||||
line, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case ch <- line:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func errMsg(s string) proto.IpcMessage {
|
||||
return proto.IpcMessage{Type: proto.EvtError, ErrorMessage: s}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
144
internal/mesh/mesh.go
Normal file
144
internal/mesh/mesh.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// Package mesh manages the set of live peer connections and broadcasts events.
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
// PeerConn is a live connection to one peer.
|
||||
type PeerConn struct {
|
||||
Info proto.PeerInfo
|
||||
// Send a line of JSON to this peer (pre-encrypted by the sender goroutine).
|
||||
Send chan<- []byte
|
||||
}
|
||||
|
||||
// Mesh is the shared state of the local node.
|
||||
// All methods are safe to call from multiple goroutines.
|
||||
type Mesh struct {
|
||||
Identity *crypto.Identity
|
||||
|
||||
mu sync.RWMutex
|
||||
peers map[proto.PeerID]*PeerConn
|
||||
|
||||
// subscribers receive a copy of every event (fan-out to IPC clients)
|
||||
subMu sync.Mutex
|
||||
subs []chan proto.IpcMessage
|
||||
}
|
||||
|
||||
// New creates an empty mesh with the given identity.
|
||||
func New(id *crypto.Identity) *Mesh {
|
||||
return &Mesh{
|
||||
Identity: id,
|
||||
peers: make(map[proto.PeerID]*PeerConn),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Peer management ───────────────────────────────────────────────────────────
|
||||
|
||||
// AddPeer registers a connected peer and notifies subscribers.
|
||||
func (m *Mesh) AddPeer(conn *PeerConn) {
|
||||
m.mu.Lock()
|
||||
m.peers[conn.Info.ID] = conn
|
||||
m.mu.Unlock()
|
||||
|
||||
m.emit(proto.IpcMessage{
|
||||
Type: proto.EvtPeerConnected,
|
||||
Peer: &conn.Info,
|
||||
})
|
||||
}
|
||||
|
||||
// RemovePeer unregisters a peer and notifies subscribers.
|
||||
func (m *Mesh) RemovePeer(id proto.PeerID) {
|
||||
m.mu.Lock()
|
||||
delete(m.peers, id)
|
||||
m.mu.Unlock()
|
||||
|
||||
m.emit(proto.IpcMessage{
|
||||
Type: proto.EvtPeerDisconnected,
|
||||
PeerID: &id,
|
||||
})
|
||||
}
|
||||
|
||||
// ConnectedPeers returns a snapshot of current peer infos.
|
||||
func (m *Mesh) ConnectedPeers() []proto.PeerInfo {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]proto.PeerInfo, 0, len(m.peers))
|
||||
for _, c := range m.peers {
|
||||
out = append(out, c.Info)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SendTo delivers a raw JSON payload to a specific peer.
|
||||
// Returns false if the peer isn't connected.
|
||||
func (m *Mesh) SendTo(id proto.PeerID, payload []byte) bool {
|
||||
m.mu.RLock()
|
||||
conn, ok := m.peers[id]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case conn.Send <- payload:
|
||||
return true
|
||||
default:
|
||||
return false // channel full — peer is slow
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast delivers a raw JSON payload to every connected peer.
|
||||
func (m *Mesh) Broadcast(payload []byte) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, conn := range m.peers {
|
||||
select {
|
||||
case conn.Send <- payload:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event fan-out ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Subscribe returns a channel that receives every IPC event.
|
||||
// The caller must drain it; a full channel is silently dropped.
|
||||
func (m *Mesh) Subscribe() <-chan proto.IpcMessage {
|
||||
ch := make(chan proto.IpcMessage, 64)
|
||||
m.subMu.Lock()
|
||||
m.subs = append(m.subs, ch)
|
||||
m.subMu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
// Unsubscribe removes and closes a subscription channel.
|
||||
func (m *Mesh) Unsubscribe(ch <-chan proto.IpcMessage) {
|
||||
m.subMu.Lock()
|
||||
defer m.subMu.Unlock()
|
||||
for i, s := range m.subs {
|
||||
if s == ch {
|
||||
m.subs = append(m.subs[:i], m.subs[i+1:]...)
|
||||
close(s)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit sends an event to all IPC subscribers (exported for ipc/nat packages).
|
||||
func (m *Mesh) Emit(msg proto.IpcMessage) {
|
||||
m.emit(msg)
|
||||
}
|
||||
|
||||
func (m *Mesh) emit(msg proto.IpcMessage) {
|
||||
m.subMu.Lock()
|
||||
defer m.subMu.Unlock()
|
||||
for _, ch := range m.subs {
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
194
internal/mesh/peer.go
Normal file
194
internal/mesh/peer.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Package mesh/peer handles individual peer TCP connections.
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
// HandleConn runs the full lifecycle of one peer connection:
|
||||
//
|
||||
// 1. Handshake (Hello / HelloAck)
|
||||
// 2. ECDH → session key
|
||||
// 3. Register in mesh
|
||||
// 4. Concurrent read + write loops
|
||||
// 5. Unregister on disconnect
|
||||
//
|
||||
// Call this in a goroutine for both inbound and outbound connections.
|
||||
func HandleConn(conn net.Conn, m *Mesh, weInitiated bool) {
|
||||
defer conn.Close()
|
||||
addr := conn.RemoteAddr().String()
|
||||
log.Printf("peer: connected to %s (we initiated: %v)", addr, weInitiated)
|
||||
|
||||
session, peerInfo, err := handshake(conn, m.Identity, weInitiated)
|
||||
if err != nil {
|
||||
log.Printf("peer: handshake with %s failed: %v", addr, err)
|
||||
return
|
||||
}
|
||||
log.Printf("peer: handshake complete with %s (%s)", peerInfo.Alias, peerInfo.ID.Short())
|
||||
|
||||
// Channel for outbound messages (IPC handler writes here)
|
||||
sendCh := make(chan []byte, 64)
|
||||
peerConn := &PeerConn{Info: *peerInfo, Send: sendCh}
|
||||
m.AddPeer(peerConn)
|
||||
defer m.RemovePeer(peerInfo.ID)
|
||||
|
||||
// Outbound writer goroutine
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
w := bufio.NewWriter(conn)
|
||||
for payload := range sendCh {
|
||||
nonce, ct, err := session.Encrypt(payload)
|
||||
if err != nil {
|
||||
log.Printf("peer: encrypt error: %v", err)
|
||||
continue
|
||||
}
|
||||
env := proto.Envelope{Nonce: nonce, Payload: ct}
|
||||
line, _ := json.Marshal(env)
|
||||
line = append(line, '\n')
|
||||
if _, err := w.Write(line); err != nil {
|
||||
return
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
}()
|
||||
|
||||
// Inbound reader loop (this goroutine)
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
var env proto.Envelope
|
||||
if err := json.Unmarshal(scanner.Bytes(), &env); err != nil {
|
||||
log.Printf("peer: bad envelope from %s: %v", addr, err)
|
||||
continue
|
||||
}
|
||||
plaintext, err := session.Decrypt(env.Nonce, env.Payload)
|
||||
if err != nil {
|
||||
log.Printf("peer: decrypt error from %s: %v", addr, err)
|
||||
continue
|
||||
}
|
||||
var msg proto.PeerMessage
|
||||
if err := json.Unmarshal(plaintext, &msg); err != nil {
|
||||
log.Printf("peer: bad peer message from %s: %v", addr, err)
|
||||
continue
|
||||
}
|
||||
handleMessage(msg, peerInfo, m)
|
||||
}
|
||||
|
||||
close(sendCh)
|
||||
<-done
|
||||
log.Printf("peer: disconnected from %s", addr)
|
||||
}
|
||||
|
||||
// handshake performs the Ed25519-authenticated X25519 key exchange.
|
||||
// Returns the symmetric session and the remote peer's info.
|
||||
func handshake(conn net.Conn, id *crypto.Identity, weInitiated bool) (*crypto.Session, *proto.PeerInfo, error) {
|
||||
conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
defer conn.SetDeadline(time.Time{})
|
||||
|
||||
ek, err := crypto.GenerateEphemeral()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
ourHello := proto.Hello{
|
||||
Version: 1,
|
||||
Peer: id.PeerInfo(),
|
||||
EphemeralKey: ek.PublicKeyB64(),
|
||||
Signature: id.Sign([]byte(ek.PublicKeyB64())),
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(conn)
|
||||
dec := json.NewDecoder(conn)
|
||||
|
||||
if weInitiated {
|
||||
// We go first
|
||||
if err := enc.Encode(ourHello); err != nil {
|
||||
return nil, nil, fmt.Errorf("sending hello: %w", err)
|
||||
}
|
||||
var ack proto.HelloAck
|
||||
if err := dec.Decode(&ack); err != nil {
|
||||
return nil, nil, fmt.Errorf("reading hello ack: %w", err)
|
||||
}
|
||||
if err := crypto.Verify(ack.Peer.PublicKey, []byte(ack.EphemeralKey), ack.Signature); err != nil {
|
||||
return nil, nil, fmt.Errorf("bad ack signature: %w", err)
|
||||
}
|
||||
secret, err := ek.SharedSecret(ack.EphemeralKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return crypto.NewSession(secret), &ack.Peer, nil
|
||||
}
|
||||
|
||||
// They go first — read their Hello, then send our Ack
|
||||
var theirHello proto.Hello
|
||||
if err := dec.Decode(&theirHello); err != nil {
|
||||
return nil, nil, fmt.Errorf("reading hello: %w", err)
|
||||
}
|
||||
if err := crypto.Verify(theirHello.Peer.PublicKey, []byte(theirHello.EphemeralKey), theirHello.Signature); err != nil {
|
||||
return nil, nil, fmt.Errorf("bad hello signature: %w", err)
|
||||
}
|
||||
|
||||
ack := proto.HelloAck{
|
||||
Peer: id.PeerInfo(),
|
||||
EphemeralKey: ek.PublicKeyB64(),
|
||||
Signature: id.Sign([]byte(ek.PublicKeyB64())),
|
||||
}
|
||||
if err := enc.Encode(ack); err != nil {
|
||||
return nil, nil, fmt.Errorf("sending ack: %w", err)
|
||||
}
|
||||
|
||||
secret, err := ek.SharedSecret(theirHello.EphemeralKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return crypto.NewSession(secret), &theirHello.Peer, nil
|
||||
}
|
||||
|
||||
// handleMessage dispatches an incoming decrypted peer message.
|
||||
func handleMessage(msg proto.PeerMessage, from *proto.PeerInfo, m *Mesh) {
|
||||
switch msg.Type {
|
||||
case proto.MsgChat:
|
||||
if msg.Chat == nil {
|
||||
return
|
||||
}
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtMessageReceived,
|
||||
Message: msg.Chat,
|
||||
})
|
||||
|
||||
case proto.MsgPeerGossip:
|
||||
if msg.Gossip == nil {
|
||||
return
|
||||
}
|
||||
log.Printf("mesh: gossip from %s: %d peer hints", from.Alias, len(msg.Gossip.Peers))
|
||||
// TODO: attempt connections to new peers
|
||||
|
||||
case proto.MsgPing:
|
||||
log.Printf("mesh: ping from %s", from.Alias)
|
||||
// TODO: send pong back through the send channel
|
||||
|
||||
case proto.MsgPong:
|
||||
log.Printf("mesh: pong from %s", from.Alias)
|
||||
|
||||
case proto.MsgFileOffer:
|
||||
if msg.FileOffer == nil {
|
||||
return
|
||||
}
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtIncomingFile,
|
||||
PeerID: &from.ID,
|
||||
Offer: msg.FileOffer,
|
||||
})
|
||||
|
||||
default:
|
||||
log.Printf("mesh: unknown message type %q from %s", msg.Type, from.Alias)
|
||||
}
|
||||
}
|
||||
87
internal/nat/nat.go
Normal file
87
internal/nat/nat.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Package nat handles relay registration and future hole-punching.
|
||||
package nat
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/waste-go/internal/mesh"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
// Run connects to the relay server (if configured) and keeps the connection alive.
|
||||
// Pass an empty relayAddr to skip relay entirely (LAN-only mode).
|
||||
func Run(m *mesh.Mesh, relayAddr string) error {
|
||||
if relayAddr == "" {
|
||||
log.Println("nat: no relay configured, running in LAN-only mode")
|
||||
select {} // block forever — task stays alive
|
||||
}
|
||||
|
||||
for {
|
||||
log.Printf("nat: connecting to relay at %s", relayAddr)
|
||||
if err := connectRelay(relayAddr, m); err != nil {
|
||||
log.Printf("nat: relay error: %v", err)
|
||||
}
|
||||
log.Printf("nat: reconnecting to relay in 10s...")
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func connectRelay(addr string, m *mesh.Mesh) error {
|
||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial relay: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
id := m.Identity
|
||||
enc := json.NewEncoder(conn)
|
||||
dec := json.NewDecoder(bufio.NewReader(conn))
|
||||
|
||||
// Register with the relay
|
||||
reg := proto.RelayMessage{
|
||||
Type: proto.RelayRegister,
|
||||
Peer: ptr(id.PeerInfo()),
|
||||
Signature: id.Sign([]byte(id.PeerID())),
|
||||
}
|
||||
if err := enc.Encode(reg); err != nil {
|
||||
return fmt.Errorf("sending register: %w", err)
|
||||
}
|
||||
log.Printf("nat: registered with relay as %s", id.PeerID().Short())
|
||||
|
||||
// Ask for the current peer list (bootstrap)
|
||||
if err := enc.Encode(proto.RelayMessage{Type: proto.RelayListPeers}); err != nil {
|
||||
return fmt.Errorf("sending list_peers: %w", err)
|
||||
}
|
||||
|
||||
// Read relay messages
|
||||
for {
|
||||
var msg proto.RelayMessage
|
||||
if err := dec.Decode(&msg); err != nil {
|
||||
return fmt.Errorf("reading relay message: %w", err)
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case proto.RelayPeerList:
|
||||
log.Printf("nat: relay gave us %d peer hints", len(msg.Peers))
|
||||
// TODO: attempt direct connections to each peer hint
|
||||
|
||||
case proto.RelayForwarded:
|
||||
from := "unknown"
|
||||
if msg.From != nil {
|
||||
from = msg.From.Short()
|
||||
}
|
||||
log.Printf("nat: relayed envelope from %s — direct connection not yet implemented", from)
|
||||
// TODO: decrypt and process as PeerMessage
|
||||
|
||||
case proto.RelayError:
|
||||
log.Printf("nat: relay error: %s", msg.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
212
internal/proto/proto.go
Normal file
212
internal/proto/proto.go
Normal file
@@ -0,0 +1,212 @@
|
||||
// Package proto defines all wire types shared between the daemon and relay.
|
||||
// Everything on the wire is newline-delimited JSON.
|
||||
// Binary data (keys, signatures, ciphertext) is base64url encoded.
|
||||
package proto
|
||||
|
||||
import "time"
|
||||
|
||||
// ── Identity ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// PeerID is a peer's stable identity: their Ed25519 public key, base64url encoded.
|
||||
// This IS the peer — display names are advisory only and unauthenticated.
|
||||
type PeerID string
|
||||
|
||||
// Short returns the first 8 characters, useful for display.
|
||||
func (p PeerID) Short() string {
|
||||
if len(p) < 8 {
|
||||
return string(p)
|
||||
}
|
||||
return string(p)[:8]
|
||||
}
|
||||
|
||||
// PeerInfo is a peer's self-description. Included in the Hello handshake.
|
||||
type PeerInfo struct {
|
||||
ID PeerID `json:"id"`
|
||||
Alias string `json:"alias"` // advisory, not authenticated
|
||||
PublicKey string `json:"public_key"` // Ed25519 pubkey, base64url
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ── Handshake ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Hello is the first message sent on a new TCP connection.
|
||||
type Hello struct {
|
||||
Version int `json:"version"`
|
||||
Peer PeerInfo `json:"peer"`
|
||||
EphemeralKey string `json:"ephemeral_key"` // X25519 pubkey, base64url
|
||||
Signature string `json:"signature"` // Ed25519 sig over ephemeral_key
|
||||
}
|
||||
|
||||
// HelloAck is the response to Hello, completing the handshake.
|
||||
type HelloAck struct {
|
||||
Peer PeerInfo `json:"peer"`
|
||||
EphemeralKey string `json:"ephemeral_key"`
|
||||
Signature string `json:"signature"`
|
||||
RelayCapable bool `json:"relay_capable"`
|
||||
}
|
||||
|
||||
// ── Encrypted envelope ────────────────────────────────────────────────────────
|
||||
|
||||
// Envelope wraps all post-handshake messages.
|
||||
// The payload is ChaCha20-Poly1305 encrypted.
|
||||
type Envelope struct {
|
||||
Nonce string `json:"nonce"` // 12 bytes, base64url
|
||||
Payload string `json:"payload"` // ciphertext, base64url
|
||||
}
|
||||
|
||||
// ── Peer-to-peer message types ────────────────────────────────────────────────
|
||||
|
||||
// MsgType identifies the kind of peer message inside an Envelope.
|
||||
type MsgType string
|
||||
|
||||
const (
|
||||
MsgChat MsgType = "chat"
|
||||
MsgPeerGossip MsgType = "peer_gossip"
|
||||
MsgFileOffer MsgType = "file_offer"
|
||||
MsgFileResp MsgType = "file_response"
|
||||
MsgFileChunk MsgType = "file_chunk"
|
||||
MsgPing MsgType = "ping"
|
||||
MsgPong MsgType = "pong"
|
||||
)
|
||||
|
||||
// PeerMessage is the top-level container decoded from inside an Envelope.
|
||||
type PeerMessage struct {
|
||||
Type MsgType `json:"type"`
|
||||
|
||||
// Only one of these will be set, depending on Type.
|
||||
Chat *ChatMessage `json:"chat,omitempty"`
|
||||
Gossip *PeerGossip `json:"gossip,omitempty"`
|
||||
FileOffer *FileOffer `json:"file_offer,omitempty"`
|
||||
FileResp *FileResponse `json:"file_response,omitempty"`
|
||||
FileChunk *FileChunk `json:"file_chunk,omitempty"`
|
||||
Seq *uint64 `json:"seq,omitempty"` // for ping/pong
|
||||
}
|
||||
|
||||
// ChatMessage is a message to a room or a DM.
|
||||
type ChatMessage struct {
|
||||
ID string `json:"id"`
|
||||
From PeerID `json:"from"`
|
||||
To *PeerID `json:"to,omitempty"` // nil = broadcast to room
|
||||
Room string `json:"room"`
|
||||
Body string `json:"body"`
|
||||
SentAt time.Time `json:"sent_at"`
|
||||
}
|
||||
|
||||
// PeerGossip shares known peer addresses.
|
||||
type PeerGossip struct {
|
||||
Peers []GossipEntry `json:"peers"`
|
||||
}
|
||||
|
||||
// GossipEntry is one peer hint shared via gossip.
|
||||
type GossipEntry struct {
|
||||
Peer PeerInfo `json:"peer"`
|
||||
AddrHint string `json:"addr_hint"` // IP:port, may be behind NAT
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
}
|
||||
|
||||
// FileOffer initiates a file transfer.
|
||||
type FileOffer struct {
|
||||
TransferID string `json:"transfer_id"`
|
||||
Filename string `json:"filename"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// FileResponse accepts or declines a FileOffer.
|
||||
type FileResponse struct {
|
||||
TransferID string `json:"transfer_id"`
|
||||
Accepted bool `json:"accepted"`
|
||||
}
|
||||
|
||||
// FileChunk is one piece of a file transfer.
|
||||
type FileChunk struct {
|
||||
TransferID string `json:"transfer_id"`
|
||||
Seq uint32 `json:"seq"`
|
||||
Data string `json:"data"` // base64url
|
||||
IsLast bool `json:"is_last"`
|
||||
}
|
||||
|
||||
// ── Relay protocol ────────────────────────────────────────────────────────────
|
||||
|
||||
// RelayMsgType identifies relay wire messages.
|
||||
type RelayMsgType string
|
||||
|
||||
const (
|
||||
RelayRegister RelayMsgType = "register"
|
||||
RelayForward RelayMsgType = "forward"
|
||||
RelayListPeers RelayMsgType = "list_peers"
|
||||
RelayForwarded RelayMsgType = "forwarded"
|
||||
RelayPeerList RelayMsgType = "peer_list"
|
||||
RelayError RelayMsgType = "error"
|
||||
)
|
||||
|
||||
// RelayMessage is used for both client→relay and relay→client.
|
||||
type RelayMessage struct {
|
||||
Type RelayMsgType `json:"type"`
|
||||
|
||||
// register
|
||||
Peer *PeerInfo `json:"peer,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
|
||||
// forward / forwarded
|
||||
To *PeerID `json:"to,omitempty"`
|
||||
From *PeerID `json:"from,omitempty"`
|
||||
Envelope *Envelope `json:"envelope,omitempty"`
|
||||
|
||||
// peer_list
|
||||
Peers []GossipEntry `json:"peers,omitempty"`
|
||||
|
||||
// error
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// ── IPC protocol (daemon ↔ local UI) ─────────────────────────────────────────
|
||||
|
||||
// IpcMsgType identifies IPC messages.
|
||||
type IpcMsgType string
|
||||
|
||||
const (
|
||||
// Commands (UI → daemon)
|
||||
CmdSendMessage IpcMsgType = "send_message"
|
||||
CmdConnect IpcMsgType = "connect"
|
||||
CmdGetState IpcMsgType = "get_state"
|
||||
CmdSendFile IpcMsgType = "send_file"
|
||||
|
||||
// Events (daemon → UI)
|
||||
EvtMessageReceived IpcMsgType = "message_received"
|
||||
EvtPeerConnected IpcMsgType = "peer_connected"
|
||||
EvtPeerDisconnected IpcMsgType = "peer_disconnected"
|
||||
EvtIncomingFile IpcMsgType = "incoming_file"
|
||||
EvtFileProgress IpcMsgType = "file_progress"
|
||||
EvtStateSnapshot IpcMsgType = "state_snapshot"
|
||||
EvtError IpcMsgType = "error"
|
||||
)
|
||||
|
||||
// IpcMessage covers both commands and events.
|
||||
type IpcMessage struct {
|
||||
Type IpcMsgType `json:"type"`
|
||||
|
||||
// send_message
|
||||
Room string `json:"room,omitempty"`
|
||||
To *PeerID `json:"to,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
|
||||
// connect
|
||||
Addr string `json:"addr,omitempty"`
|
||||
|
||||
// send_file
|
||||
Path string `json:"path,omitempty"`
|
||||
|
||||
// events
|
||||
Peer *PeerInfo `json:"peer,omitempty"`
|
||||
PeerID *PeerID `json:"peer_id,omitempty"`
|
||||
Message *ChatMessage `json:"message,omitempty"`
|
||||
Offer *FileOffer `json:"offer,omitempty"`
|
||||
TransferID string `json:"transfer_id,omitempty"`
|
||||
BytesReceived int64 `json:"bytes_received,omitempty"`
|
||||
TotalBytes int64 `json:"total_bytes,omitempty"`
|
||||
LocalPeer *PeerInfo `json:"local_peer,omitempty"`
|
||||
ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"`
|
||||
Rooms []string `json:"rooms,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user