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:
Fredrik Johansson
2026-06-21 16:14:07 +02:00
commit b3a4af15ca
16 changed files with 1566 additions and 0 deletions

232
internal/crypto/crypto.go Normal file
View 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
}