diff options
| -rw-r--r-- | Makefile | 183 | ||||
| -rw-r--r-- | README.md | 405 | ||||
| -rw-r--r-- | noshitalk-client.go | 947 | ||||
| -rw-r--r-- | noshitalk-server.go | 1005 | ||||
| -rw-r--r-- | noshitalk-web-client.go | 851 | ||||
| -rw-r--r-- | noshitalk_cli-client.go | 869 | ||||
| -rw-r--r-- | pkg/crypto/crypto_test.go | 440 | ||||
| -rw-r--r-- | pkg/identity/identity_test.go | 426 | ||||
| -rw-r--r-- | pkg/protocol/messages_test.go | 428 | ||||
| -rw-r--r-- | pkg/tor/tor_test.go | 282 |
10 files changed, 1969 insertions, 3867 deletions
diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..720e2c0 --- /dev/null +++ b/Makefile @@ -0,0 +1,183 @@ +# NoshiTalk Makefile +# Build, test, and manage the NoshiTalk encrypted chat system + +.PHONY: all build build-all test test-verbose clean install lint fmt vet help +.PHONY: server cli-client gui-client web-client + +# Build settings +GO := go +BINARY_DIR := bin +LDFLAGS := -ldflags="-s -w" +BUILD_FLAGS := -trimpath + +# Version info +VERSION := 2.0.0 +GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S') + +# Default target +all: build-all + +# Build all components +build-all: server cli-client gui-client web-client + @echo "All components built successfully" + @ls -la $(BINARY_DIR)/ + +# Individual component builds +server: + @echo "Building server..." + @mkdir -p $(BINARY_DIR) + $(GO) build $(BUILD_FLAGS) $(LDFLAGS) -o $(BINARY_DIR)/noshitalk-server ./cmd/server + +cli-client: + @echo "Building CLI client..." + @mkdir -p $(BINARY_DIR) + $(GO) build $(BUILD_FLAGS) $(LDFLAGS) -o $(BINARY_DIR)/noshitalk-cli ./cmd/cli-client + +gui-client: + @echo "Building GUI client..." + @mkdir -p $(BINARY_DIR) + $(GO) build $(BUILD_FLAGS) $(LDFLAGS) -o $(BINARY_DIR)/noshitalk-gui ./cmd/gui-client + +web-client: + @echo "Building web client..." + @mkdir -p $(BINARY_DIR) + $(GO) build $(BUILD_FLAGS) $(LDFLAGS) -o $(BINARY_DIR)/noshitalk-web ./cmd/web-client + +# Run tests +test: + @echo "Running tests..." + $(GO) test ./pkg/... -cover + +test-verbose: + @echo "Running tests (verbose)..." + $(GO) test ./pkg/... -v -cover + +test-race: + @echo "Running tests with race detector..." + $(GO) test ./pkg/... -race -cover + +# Coverage report +coverage: + @echo "Generating coverage report..." + @mkdir -p coverage + $(GO) test ./pkg/... -coverprofile=coverage/coverage.out + $(GO) tool cover -html=coverage/coverage.out -o coverage/coverage.html + @echo "Coverage report: coverage/coverage.html" + +# Benchmarks +bench: + @echo "Running benchmarks..." + $(GO) test ./pkg/... -bench=. -benchmem + +# Code quality +lint: + @echo "Running linter..." + @which golangci-lint > /dev/null || echo "golangci-lint not installed" + golangci-lint run ./... + +fmt: + @echo "Formatting code..." + $(GO) fmt ./... + +vet: + @echo "Running go vet..." + $(GO) vet ./... + +# Dependencies +deps: + @echo "Downloading dependencies..." + $(GO) mod download + +tidy: + @echo "Tidying modules..." + $(GO) mod tidy + +# Clean build artifacts +clean: + @echo "Cleaning..." + rm -rf $(BINARY_DIR) + rm -rf coverage + $(GO) clean -cache -testcache + +# Install binaries to GOPATH/bin +install: build-all + @echo "Installing binaries..." + cp $(BINARY_DIR)/noshitalk-server $(GOPATH)/bin/ 2>/dev/null || true + cp $(BINARY_DIR)/noshitalk-cli $(GOPATH)/bin/ 2>/dev/null || true + cp $(BINARY_DIR)/noshitalk-gui $(GOPATH)/bin/ 2>/dev/null || true + cp $(BINARY_DIR)/noshitalk-web $(GOPATH)/bin/ 2>/dev/null || true + @echo "Installed to GOPATH/bin" + +# Development helpers +dev-server: server + @echo "Starting server in development mode..." + ./$(BINARY_DIR)/noshitalk-server + +dev-web: web-client + @echo "Starting web client in development mode..." + ./$(BINARY_DIR)/noshitalk-web + +# Build for release (all platforms) +release: clean + @echo "Building release binaries..." + @mkdir -p $(BINARY_DIR)/release + # Linux AMD64 + GOOS=linux GOARCH=amd64 $(GO) build $(BUILD_FLAGS) $(LDFLAGS) -o $(BINARY_DIR)/release/noshitalk-server-linux-amd64 ./cmd/server + GOOS=linux GOARCH=amd64 $(GO) build $(BUILD_FLAGS) $(LDFLAGS) -o $(BINARY_DIR)/release/noshitalk-cli-linux-amd64 ./cmd/cli-client + GOOS=linux GOARCH=amd64 $(GO) build $(BUILD_FLAGS) $(LDFLAGS) -o $(BINARY_DIR)/release/noshitalk-web-linux-amd64 ./cmd/web-client + # Linux ARM64 + GOOS=linux GOARCH=arm64 $(GO) build $(BUILD_FLAGS) $(LDFLAGS) -o $(BINARY_DIR)/release/noshitalk-server-linux-arm64 ./cmd/server + GOOS=linux GOARCH=arm64 $(GO) build $(BUILD_FLAGS) $(LDFLAGS) -o $(BINARY_DIR)/release/noshitalk-cli-linux-arm64 ./cmd/cli-client + # macOS AMD64 + GOOS=darwin GOARCH=amd64 $(GO) build $(BUILD_FLAGS) $(LDFLAGS) -o $(BINARY_DIR)/release/noshitalk-server-darwin-amd64 ./cmd/server + GOOS=darwin GOARCH=amd64 $(GO) build $(BUILD_FLAGS) $(LDFLAGS) -o $(BINARY_DIR)/release/noshitalk-cli-darwin-amd64 ./cmd/cli-client + # macOS ARM64 (M1/M2) + GOOS=darwin GOARCH=arm64 $(GO) build $(BUILD_FLAGS) $(LDFLAGS) -o $(BINARY_DIR)/release/noshitalk-server-darwin-arm64 ./cmd/server + GOOS=darwin GOARCH=arm64 $(GO) build $(BUILD_FLAGS) $(LDFLAGS) -o $(BINARY_DIR)/release/noshitalk-cli-darwin-arm64 ./cmd/cli-client + # Windows AMD64 + GOOS=windows GOARCH=amd64 $(GO) build $(BUILD_FLAGS) $(LDFLAGS) -o $(BINARY_DIR)/release/noshitalk-server-windows-amd64.exe ./cmd/server + GOOS=windows GOARCH=amd64 $(GO) build $(BUILD_FLAGS) $(LDFLAGS) -o $(BINARY_DIR)/release/noshitalk-cli-windows-amd64.exe ./cmd/cli-client + @echo "Release binaries built in $(BINARY_DIR)/release/" + @ls -la $(BINARY_DIR)/release/ + +# Security check +security: + @echo "Running security checks..." + @which gosec > /dev/null || echo "gosec not installed (go install github.com/securego/gosec/v2/cmd/gosec@latest)" + gosec ./... || true + +# Help +help: + @echo "NoshiTalk Build System" + @echo "" + @echo "Usage: make [target]" + @echo "" + @echo "Build targets:" + @echo " all - Build all components (default)" + @echo " build-all - Build all components" + @echo " server - Build server only" + @echo " cli-client - Build CLI client only" + @echo " gui-client - Build GUI client only" + @echo " web-client - Build web client only" + @echo " release - Build release binaries for all platforms" + @echo "" + @echo "Test targets:" + @echo " test - Run tests with coverage" + @echo " test-verbose - Run tests with verbose output" + @echo " test-race - Run tests with race detector" + @echo " coverage - Generate HTML coverage report" + @echo " bench - Run benchmarks" + @echo "" + @echo "Quality targets:" + @echo " fmt - Format code" + @echo " vet - Run go vet" + @echo " lint - Run golangci-lint" + @echo " security - Run security checks" + @echo "" + @echo "Other targets:" + @echo " deps - Download dependencies" + @echo " tidy - Tidy go.mod" + @echo " clean - Remove build artifacts" + @echo " install - Install binaries to GOPATH/bin" + @echo " help - Show this help" @@ -18,6 +18,141 @@ NoshiTalk is a self-hosted instant messaging platform designed for decentralized --- +## Project Structure + +``` +noshitalk/ +├── cmd/ # Application entry points +│ ├── server/ # TCP server for Tor hidden service +│ ├── cli-client/ # Command-line interface client +│ ├── gui-client/ # Desktop GUI client (Fyne) +│ └── web-client/ # Web interface with WebSocket +├── pkg/ # Shared libraries +│ ├── crypto/ # Encryption, padding, ECDH, HMAC auth +│ ├── protocol/ # Message types and JSON serialization +│ ├── identity/ # Persistent identity management +│ └── tor/ # Tor SOCKS5 proxy utilities +├── Makefile # Build automation +├── go.mod # Go module definition +└── README.md # This file +``` + +--- + +## Quick Start + +### Prerequisites + +- Go 1.19+ +- Tor daemon (for client connections) + +### Build + +```bash +# Clone repository +git clone https://github.com/gabrix73/Noshitalk.git +cd Noshitalk + +# Build all components +make build-all + +# Or build individual components +make server # bin/noshitalk-server +make cli-client # bin/noshitalk-cli +make gui-client # bin/noshitalk-gui +make web-client # bin/noshitalk-web +``` + +### Run Tests + +```bash +make test # Run all tests +make test-verbose # Verbose output +make coverage # Generate HTML coverage report +``` + +### Available Make Targets + +```bash +make help # Show all available targets +``` + +| Target | Description | +|--------|-------------| +| `build-all` | Build all components | +| `test` | Run tests with coverage | +| `test-race` | Run tests with race detector | +| `coverage` | Generate HTML coverage report | +| `bench` | Run benchmarks | +| `fmt` | Format code | +| `vet` | Run go vet | +| `clean` | Remove build artifacts | +| `release` | Build for all platforms | + +--- + +## Components + +### Server (`cmd/server`) + +TCP server designed for Tor hidden service deployment. Handles: +- Client connections via encrypted channel +- Message routing (public and private) +- File transfers +- Ghost mode (invisible users) +- Key rotation + +```bash +./bin/noshitalk-server +# Listens on 127.0.0.1:8083 +``` + +### CLI Client (`cmd/cli-client`) + +Lightweight terminal-based client: + +```bash +./bin/noshitalk-cli +# Enter .onion address when prompted +``` + +Commands: +- `/help` - Show commands +- `/users` - List online users +- `/pm user message` - Private message +- `/ghost` / `/reveal` - Toggle visibility +- `/quit` - Exit + +### GUI Client (`cmd/gui-client`) + +Desktop application using Fyne framework: + +```bash +./bin/noshitalk-gui +``` + +Features: +- Visual user list +- Click-to-PM +- Auto-reconnect +- PANIC button (wipe keys) + +### Web Client (`cmd/web-client`) + +Browser-based interface: + +```bash +./bin/noshitalk-web +# Access at http://localhost:8080 +``` + +Features: +- WebSocket communication +- Browser-based key generation +- Export/import .noshikey files + +--- + ## VPS Installation (Debian/Ubuntu) ### Requirements @@ -36,44 +171,18 @@ sudo apt update && sudo apt upgrade -y sudo apt install -y tor golang-go git build-essential ``` -Verify: -```bash -tor --version # Expected: 0.4.x+ -go version # Expected: 1.19+ -``` - -#### 2. Clone repository +#### 2. Clone and build ```bash cd ~ git clone https://github.com/gabrix73/Noshitalk.git cd Noshitalk +make server web-client ``` -#### 3. Build with hardened flags - -```bash -go build \ - -ldflags="-s -w" \ - -trimpath \ - -buildmode=pie \ - -o noshitalk-server \ - noshitalk-web-client.go -``` - -**Build flags:** -- `-ldflags="-s -w"`: Strip symbols and DWARF tables -- `-trimpath`: Remove filesystem paths -- `-buildmode=pie`: Position-independent executable (ASLR) - -#### 4. Configure Tor - -Edit configuration: -```bash -sudo nano /etc/tor/torrc -``` +#### 3. Configure Tor -Add: +Edit `/etc/tor/torrc`: ``` HiddenServiceDir /var/lib/tor/noshitalk/ HiddenServicePort 8080 127.0.0.1:8080 @@ -82,21 +191,15 @@ HiddenServicePort 8080 127.0.0.1:8080 Restart Tor: ```bash sudo systemctl restart tor -sudo systemctl enable tor +sudo cat /var/lib/tor/noshitalk/hostname # Get .onion address ``` -Retrieve .onion address: -```bash -sudo cat /var/lib/tor/noshitalk/hostname -``` - -#### 5. Create systemd service +#### 4. Create systemd service ```bash sudo nano /etc/systemd/system/noshitalk.service ``` -Configuration: ```ini [Unit] Description=NoshiTalk Server @@ -107,22 +210,18 @@ Requires=tor.service Type=simple User=YOUR_USERNAME WorkingDirectory=/home/YOUR_USERNAME/Noshitalk -ExecStart=/home/YOUR_USERNAME/Noshitalk/noshitalk-server +ExecStart=/home/YOUR_USERNAME/Noshitalk/bin/noshitalk-web Restart=always RestartSec=10 NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict -ProtectHome=true -ReadWritePaths=/home/YOUR_USERNAME/Noshitalk [Install] WantedBy=multi-user.target ``` -Replace `YOUR_USERNAME` with actual username. - Enable and start: ```bash sudo systemctl daemon-reload @@ -130,53 +229,51 @@ sudo systemctl enable noshitalk sudo systemctl start noshitalk ``` -Check status: -```bash -sudo systemctl status noshitalk -``` - -#### 6. Configure firewall - -```bash -sudo ufw default deny incoming -sudo ufw default allow outgoing -sudo ufw allow ssh -sudo ufw enable -``` - -Note: No incoming ports required (Tor handles routing). - -#### 7. Verify - -Access via Tor Browser: -``` -http://YOUR_ONION_ADDRESS.onion -``` - --- ## Architecture -### Decentralization - -Each server operates independently. No federation protocol implemented (servers do not communicate). Users connect directly to specific .onion addresses. +### Shared Packages -**Network characteristics:** -- No single point of failure -- Geographic/jurisdictional distribution -- Each instance sets own policies +#### `pkg/crypto` +- `PadMessage()` / `UnpadMessage()` - Traffic analysis resistance +- `EncryptMessage()` / `DecryptMessage()` - AES-256-GCM +- `PerformClientAuth()` / `PerformServerAuth()` - HMAC mutual auth +- `GenerateX25519KeyPair()` / `PerformECDH()` - Key exchange +- `RandomDelay()` - Timing attack mitigation -### Cryptographic Implementation +#### `pkg/protocol` +- `Message` - Chat message structure +- `UserListMessage` - Online users +- `FileOfferMessage` / `FileChunkMessage` - File transfers +- `KeyRotationMessage` - Forward secrecy -**Identity:** Ed25519 public key hash serves as user identifier +#### `pkg/identity` +- `New()` - Generate new identity +- `Load()` / `Save()` - Persistent storage +- `LoadOrCreate()` - Auto-initialize -**Key Exchange:** ECDH X25519 generates shared secret +#### `pkg/tor` +- `ValidateOnionAddress()` - v3 .onion validation +- `Connect()` - SOCKS5 proxy connection +- `Dialer` - Reusable connection helper -**Encryption:** AES-256-GCM with derived keys +### Cryptographic Flow -**Authentication:** Challenge-response using Ed25519 signatures - -**Transport:** Tor v3 hidden services +``` +Client Server + | | + |------ Connect via Tor:9050 ----->| + |------ Public Key (32B) --------->| + |<----- Public Key (32B) ----------| + | [ECDH Shared Secret] | + |<----- Challenge (32B) -----------| + |------ HMAC Response (32B) ------>| + |------ Challenge (32B) ---------->| + |<----- HMAC Response (32B) -------| + | [AES-256-GCM Session] | + |<===== Encrypted Messages =======>| +``` ### Zero-Knowledge Design @@ -184,95 +281,7 @@ Server cannot: - Decrypt message content (E2E encrypted) - Log IP addresses (Tor anonymization) - Access private keys (client-side generation) -- Store messages (RAM only, no database) - -### Client Implementation - -**Web:** JavaScript WebCrypto API, keys in browser memory - -**Desktop:** Go native with memguard (encrypted RAM) - -**CLI:** Go terminal interface - -All clients generate keys locally and support .noshikey file export. - ---- - -## Configuration - -### Performance Tuning - -For high-traffic servers, increase file descriptor limits: - -```bash -sudo nano /etc/security/limits.conf -``` - -Add: -``` -YOUR_USERNAME soft nofile 65535 -YOUR_USERNAME hard nofile 65535 -``` - -### Monitoring - -View logs: -```bash -sudo journalctl -u noshitalk -f -``` - -Check Tor: -```bash -sudo systemctl status tor -``` - -### Updates - -```bash -cd ~/Noshitalk -git pull -go build -ldflags="-s -w" -trimpath -buildmode=pie -o noshitalk-server noshitalk-web-client.go -sudo systemctl restart noshitalk -``` - ---- - -## Troubleshooting - -### Server fails to start - -Check logs: -```bash -sudo journalctl -u noshitalk -n 50 -``` - -Common causes: -- Tor not running: `sudo systemctl start tor` -- Port conflict: Change port or kill conflicting process -- Permission errors: Verify systemd service user - -### Tor connection issues - -Verify Tor status: -```bash -sudo systemctl status tor -``` - -Check Tor logs: -```bash -sudo journalctl -u tor -f -``` - -Verify hidden service: -```bash -sudo ls -la /var/lib/tor/noshitalk/ -``` - -### Client connection failures - -1. Verify .onion address: `sudo cat /var/lib/tor/noshitalk/hostname` -2. Check server listening: `sudo netstat -tlnp | grep 8080` -3. Test locally: `curl -x socks5h://localhost:9050 http://YOUR_ONION.onion` +- Store messages (RAM only) --- @@ -285,10 +294,7 @@ sudo ls -la /var/lib/tor/noshitalk/ | Encryption | AES-GCM | 256-bit | | Authentication | HMAC-SHA256 | 256-bit | | Transport | Tor v3 | - | - -**Language:** Go - -**License:** MIT +| Padding | 256-byte blocks | - | --- @@ -299,31 +305,48 @@ sudo ls -la /var/lib/tor/noshitalk/ **Protected against:** - Network surveillance (Tor) - Server compromise (E2E encryption) -- Metadata harvesting (zero-knowledge design) +- Metadata harvesting (zero-knowledge) +- Traffic analysis (message padding) +- Timing attacks (random delays) **Not protected against:** - Client device compromise - Physical device seizure - Social engineering -- Global passive adversary ### Memory Protection -**Desktop client:** memguard encrypts key material in RAM +- **Desktop/CLI:** memguard encrypts keys in RAM +- **Web:** Keys cleared on disconnect/panic +- **All:** Random padding prevents size analysis + +--- -**Web client:** Keys cleared on disconnect/panic +## Development + +### Running Tests + +```bash +make test # Quick test +make test-verbose # Detailed output +make test-race # Race condition detection +make coverage # HTML report in coverage/ +make bench # Performance benchmarks +``` -### Build Security +### Code Quality -Recommended compilation: ```bash -go build -ldflags="-s -w" -trimpath -buildmode=pie +make fmt # Format code +make vet # Static analysis +make lint # golangci-lint (if installed) ``` -This enables: -- ASLR (Address Space Layout Randomization) -- Symbol stripping (reduced attack surface) -- Path obfuscation (information disclosure prevention) +### Building Releases + +```bash +make release # Builds for Linux, macOS, Windows (amd64/arm64) +``` --- @@ -331,17 +354,9 @@ This enables: 1. Fork repository 2. Create feature branch -3. Commit changes -4. Open pull request - -### Development - -```bash -git clone https://github.com/gabrix73/Noshitalk.git -cd Noshitalk -go mod download -go test ./... -``` +3. Run `make test` and `make fmt` +4. Commit changes +5. Open pull request ### Security Issues diff --git a/noshitalk-client.go b/noshitalk-client.go deleted file mode 100644 index bed425c..0000000 --- a/noshitalk-client.go +++ /dev/null @@ -1,947 +0,0 @@ -package main - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/ecdh" - "crypto/hmac" - cryptorand "crypto/rand" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "math/rand" - "net" - "net/url" - "os" - "os/signal" - "path/filepath" - "regexp" - "strings" - "syscall" - "time" - - "github.com/awnumar/memguard" - "golang.org/x/net/proxy" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/app" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/widget" - "fyne.io/fyne/v2/theme" -) - -type ChatClient struct { - conn net.Conn - gcm cipher.AEAD - sharedSecret *memguard.Enclave - app fyne.App - window fyne.Window - messages *widget.List - messageList []string - input *widget.Entry - status *widget.Label - connectBtn *widget.Button - serverEntry *widget.Entry - debugLog *widget.Entry - connected bool - autoReconnect bool - lastServer string - userList *widget.List - onlineUsers []string - username string - fingerprint string - signKey *ecdh.PrivateKey - encryptKey *ecdh.PrivateKey -} - -type Message struct { - From string `json:"from"` - To string `json:"to,omitempty"` - Content string `json:"content"` - Time string `json:"time"` - Type string `json:"type"` -} - -type UserListMessage struct { - Type string `json:"type"` - Users []string `json:"users"` - Time string `json:"time"` -} - -type IdentityFile struct { - Version string `json:"version"` - Username string `json:"username"` - Fingerprint string `json:"fingerprint"` - SignKeyPair KeyPair `json:"signKeyPair"` - EncryptKeyPair KeyPair `json:"encryptKeyPair"` -} - -type KeyPair struct { - PrivateKey string `json:"privateKey"` - PublicKey string `json:"publicKey"` -} - -var ( - version = "1.0" - onionRegex = regexp.MustCompile(`^[a-z2-7]{56}\.onion(:[0-9]{1,5})?$`) -) - -const ( - messageBlockSize = 256 - minRandomDelay = 50 - maxRandomDelay = 200 -) - -func main() { - memguard.CatchInterrupt() - defer memguard.Purge() - - chatApp := app.NewWithID("noshitalk-gui") - client := &ChatClient{ - app: chatApp, - messageList: []string{}, - autoReconnect: true, - onlineUsers: []string{}, - } - - if err := client.initializeIdentity(); err != nil { - fmt.Printf("Identity initialization failed: %v\n", err) - fmt.Printf("Continuing with temporary identity\n\n") - } - - client.createMainWindow() - client.enableAutoReconnect() - client.window.ShowAndRun() -} - -func (c *ChatClient) initializeIdentity() error { - homeDir, err := os.UserHomeDir() - if err != nil { - return err - } - - keyPath := filepath.Join(homeDir, ".noshitalk", "gui-identity.noshikey") - - if _, err := os.Stat(keyPath); err == nil { - return c.loadIdentity(keyPath) - } - - return c.generateNewIdentity(keyPath) -} - -func (c *ChatClient) generateNewIdentity(keyPath string) error { - fmt.Printf("Generating new identity\n") - - curve := ecdh.X25519() - - signKey, err := curve.GenerateKey(cryptorand.Reader) - if err != nil { - return err - } - - encryptKey, err := curve.GenerateKey(cryptorand.Reader) - if err != nil { - return err - } - - signPubKey := signKey.PublicKey().Bytes() - hash := sha256.Sum256(signPubKey) - fingerprint := hex.EncodeToString(hash[:16]) - - username := fmt.Sprintf("gui_%s", fingerprint[:8]) - - c.signKey = signKey - c.encryptKey = encryptKey - c.fingerprint = fingerprint - c.username = username - - fmt.Printf("Identity: %s\n", fingerprint) - fmt.Printf("Username: %s\n", username) - - return c.saveIdentity(keyPath) -} - -func (c *ChatClient) loadIdentity(keyPath string) error { - fmt.Printf("Loading identity from %s\n", keyPath) - - data, err := os.ReadFile(keyPath) - if err != nil { - return err - } - - var identity IdentityFile - if err := json.Unmarshal(data, &identity); err != nil { - return err - } - - curve := ecdh.X25519() - - signPrivBytes, err := hex.DecodeString(identity.SignKeyPair.PrivateKey) - if err != nil { - return err - } - - encryptPrivBytes, err := hex.DecodeString(identity.EncryptKeyPair.PrivateKey) - if err != nil { - return err - } - - signKey, err := curve.NewPrivateKey(signPrivBytes) - if err != nil { - return err - } - - encryptKey, err := curve.NewPrivateKey(encryptPrivBytes) - if err != nil { - return err - } - - c.signKey = signKey - c.encryptKey = encryptKey - c.fingerprint = identity.Fingerprint - c.username = identity.Username - - fmt.Printf("Identity loaded: %s\n", identity.Fingerprint) - fmt.Printf("Username: %s\n\n", identity.Username) - - return nil -} - -func (c *ChatClient) saveIdentity(keyPath string) error { - dir := filepath.Dir(keyPath) - if err := os.MkdirAll(dir, 0700); err != nil { - return err - } - - identity := IdentityFile{ - Version: "1.0", - Username: c.username, - Fingerprint: c.fingerprint, - SignKeyPair: KeyPair{ - PrivateKey: hex.EncodeToString(c.signKey.Bytes()), - PublicKey: hex.EncodeToString(c.signKey.PublicKey().Bytes()), - }, - EncryptKeyPair: KeyPair{ - PrivateKey: hex.EncodeToString(c.encryptKey.Bytes()), - PublicKey: hex.EncodeToString(c.encryptKey.PublicKey().Bytes()), - }, - } - - data, err := json.MarshalIndent(identity, "", " ") - if err != nil { - return err - } - - if err := os.WriteFile(keyPath, data, 0600); err != nil { - return err - } - - fmt.Printf("Identity saved to %s\n\n", keyPath) - return nil -} - -func (c *ChatClient) createMainWindow() { - c.window = c.app.NewWindow(fmt.Sprintf("NoshiTalk GUI v%s", version)) - c.window.SetIcon(theme.ComputerIcon()) - c.window.Resize(fyne.NewSize(1200, 800)) - - c.status = widget.NewLabel("Disconnected") - c.status.TextStyle.Bold = true - - c.serverEntry = widget.NewEntry() - c.serverEntry.SetText("") - c.serverEntry.SetPlaceHolder("abc...xyz.onion:8080") - - c.connectBtn = widget.NewButton("Connect", c.connectToServer) - c.connectBtn.Importance = widget.HighImportance - - c.messages = widget.NewList( - func() int { - return len(c.messageList) - }, - func() fyne.CanvasObject { - return widget.NewLabel("Template") - }, - func(i widget.ListItemID, o fyne.CanvasObject) { - o.(*widget.Label).SetText(c.messageList[i]) - }, - ) - - c.userList = widget.NewList( - func() int { - return len(c.onlineUsers) - }, - func() fyne.CanvasObject { - return widget.NewButton("", nil) - }, - func(i widget.ListItemID, o fyne.CanvasObject) { - if i < len(c.onlineUsers) { - btn := o.(*widget.Button) - username := c.onlineUsers[i] - btn.SetText(username) - btn.OnTapped = func() { - c.onUserClick(username) - } - } - }, - ) - - c.input = widget.NewEntry() - c.input.SetPlaceHolder("Message...") - c.input.Disable() - c.input.OnSubmitted = c.sendMessage - - autoReconnectCheck := widget.NewCheck("Auto-reconnect", func(checked bool) { - c.autoReconnect = checked - }) - - panicBtn := widget.NewButton("PANIC", c.panic) - panicBtn.Importance = widget.DangerImportance - - identityInfo := widget.NewLabel("Identity: Loading...") - if c.fingerprint != "" { - identityInfo.SetText(fmt.Sprintf("ID: %s", c.fingerprint[:16])) - } - - securityPanel := widget.NewCard("Security", "", - container.NewVBox( - widget.NewLabel("Tor only\nE2E encrypted\nEphemeral messages\nmemguard protected"), - autoReconnectCheck, - identityInfo, - panicBtn, - )) - - c.debugLog = widget.NewEntry() - c.debugLog.MultiLine = true - c.debugLog.Wrapping = fyne.TextWrapWord - c.debugLog.SetText("Ready\n") - - autoReconnectCheck.SetChecked(true) - - debugScroll := container.NewScroll(c.debugLog) - debugScroll.SetMinSize(fyne.NewSize(350, 300)) - - debugPanel := widget.NewCard("Debug", "", debugScroll) - - clearLogBtn := widget.NewButton("Clear", func() { - c.debugLog.SetText("Log cleared\n") - }) - - rightPanel := container.NewVBox( - securityPanel, - debugPanel, - clearLogBtn, - ) - - connectionPanel := container.NewVBox( - widget.NewLabel("Server:"), - c.serverEntry, - c.connectBtn, - c.status, - ) - - userListScroll := container.NewScroll(c.userList) - userListScroll.SetMinSize(fyne.NewSize(200, 0)) - - userListPanel := container.NewBorder( - widget.NewCard("Online", "", widget.NewLabel("")), - nil, nil, nil, - userListScroll, - ) - - leftSplit := container.NewHSplit(userListPanel, c.messages) - leftSplit.SetOffset(0.20) - - chatArea := container.NewHSplit(leftSplit, rightPanel) - chatArea.SetOffset(0.70) - - bottomBar := container.NewBorder( - nil, nil, - widget.NewLabel("Input: "), - widget.NewButton("Send", func() { c.sendMessage(c.input.Text) }), - c.input, - ) - - content := container.NewBorder( - connectionPanel, - bottomBar, - nil, nil, - chatArea, - ) - - c.window.SetContent(content) -} - -func (c *ChatClient) enableAutoReconnect() { - go func() { - lastAttempt := time.Now() - for { - time.Sleep(5 * time.Second) - if !c.connected && c.autoReconnect && c.lastServer != "" { - if time.Since(lastAttempt) < 10*time.Second { - continue - } - lastAttempt = time.Now() - - c.addDebugLog("Auto-reconnecting to " + c.lastServer) - fyne.Do(func() { - c.serverEntry.SetText(c.lastServer) - c.connectToServer() - }) - } - } - }() -} - -func validateOnionAddress(addr string) error { - if addr == "" { - return fmt.Errorf("empty address") - } - - if !strings.Contains(addr, ".onion") { - return fmt.Errorf("only .onion addresses allowed") - } - - if !onionRegex.MatchString(addr) { - return fmt.Errorf("invalid .onion format") - } - - return nil -} - -func (c *ChatClient) connectToServer() { - if c.connected { - c.disconnect() - return - } - - serverAddr := c.serverEntry.Text - if serverAddr == "" { - c.showError("Error", "Enter .onion address") - return - } - - if err := validateOnionAddress(serverAddr); err != nil { - c.showError("Invalid Address", err.Error()) - return - } - - c.lastServer = serverAddr - fyne.Do(func() { - c.debugLog.SetText("Connecting\n") - }) - c.addDebugLog("Target: " + serverAddr) - - fyne.Do(func() { - c.status.SetText("Connecting...") - c.connectBtn.Disable() - }) - - progress := dialog.NewCustom("Connecting", "Cancel", - widget.NewProgressBarInfinite(), c.window) - progress.Show() - - go func() { - defer fyne.Do(func() { progress.Hide() }) - - conn, err := c.connectThroughTor(serverAddr) - if err != nil { - c.showError("Connection Error", err.Error()) - c.addDebugLog(fmt.Sprintf("Connection failed: %v", err)) - fyne.Do(func() { - c.connectBtn.Enable() - c.status.SetText("Connection failed") - }) - return - } - - c.conn = conn - fyne.Do(func() { - c.status.SetText("Encrypting...") - }) - c.addDebugLog("Starting ECDH") - - curve := ecdh.X25519() - - var encryptKey *ecdh.PrivateKey - if c.encryptKey != nil { - encryptKey = c.encryptKey - c.addDebugLog("Using persistent identity: " + c.fingerprint) - } else { - var err error - encryptKey, err = curve.GenerateKey(cryptorand.Reader) - if err != nil { - c.showError("Crypto Error", err.Error()) - c.disconnect() - return - } - c.addDebugLog("Using temporary identity") - } - - encryptKeyBuffer := memguard.NewBufferFromBytes(encryptKey.Bytes()) - defer encryptKeyBuffer.Destroy() - - publicKey := encryptKey.PublicKey() - publicKeyBytes := publicKey.Bytes() - c.addDebugLog("Sending public key") - - c.conn.SetWriteDeadline(time.Now().Add(30 * time.Second)) - if _, err := c.conn.Write(publicKeyBytes); err != nil { - c.showError("Connection Error", err.Error()) - c.disconnect() - return - } - c.conn.SetWriteDeadline(time.Time{}) - - c.addDebugLog("Receiving server key") - serverPublicKeyBytes := make([]byte, 32) - if _, err := io.ReadFull(conn, serverPublicKeyBytes); err != nil { - c.showError("Connection Error", err.Error()) - c.disconnect() - return - } - - serverPublicKey, err := curve.NewPublicKey(serverPublicKeyBytes) - if err != nil { - c.showError("Crypto Error", err.Error()) - c.disconnect() - return - } - - c.addDebugLog("Calculating shared secret") - - sharedSecret, err := encryptKey.ECDH(serverPublicKey) - if err != nil { - c.showError("Crypto Error", err.Error()) - c.disconnect() - return - } - - c.addDebugLog("Starting HMAC auth") - if err := c.performMutualAuth(sharedSecret); err != nil { - c.showError("Auth Error", err.Error()) - c.disconnect() - return - } - c.addDebugLog("Auth successful") - - c.addDebugLog("Setting up AES-GCM") - block, err := aes.NewCipher(sharedSecret[:32]) - if err != nil { - c.showError("Crypto Error", err.Error()) - c.disconnect() - return - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - c.showError("Crypto Error", err.Error()) - c.disconnect() - return - } - - sharedSecretBuffer := memguard.NewBufferFromBytes(sharedSecret) - c.sharedSecret = sharedSecretBuffer.Seal() - sharedSecretBuffer.Destroy() - - c.gcm = gcm - c.connected = true - - fyne.Do(func() { - c.input.Enable() - c.connectBtn.SetText("Disconnect") - c.connectBtn.OnTapped = c.disconnect - c.connectBtn.Enable() - c.status.SetText("Connected (E2E)") - }) - - c.addMessage("System", "Connected via Tor") - c.addMessage("System", "E2E encryption active") - c.addDebugLog("Session established") - - go c.receiveMessages() - }() -} - -func (c *ChatClient) connectThroughTor(serverAddr string) (net.Conn, error) { - c.addDebugLog("Starting Tor connection") - - var conn net.Conn - proxyURLs := []string{ - "socks5://127.0.0.1:9050", - "socks5://localhost:9050", - } - - for _, proxyURL := range proxyURLs { - torProxyUrl, _ := url.Parse(proxyURL) - if torProxyUrl == nil { - continue - } - - baseDialer := &net.Dialer{ - Timeout: 90 * time.Second, - KeepAlive: 30 * time.Second, - } - - dialer, dialErr := proxy.FromURL(torProxyUrl, baseDialer) - if dialErr != nil { - continue - } - - c.addDebugLog("Connecting via " + proxyURL) - - var connErr error - conn, connErr = dialer.Dial("tcp", serverAddr) - if connErr != nil { - c.addDebugLog("Failed: " + connErr.Error()) - continue - } else { - c.addDebugLog("Connected via " + proxyURL) - break - } - } - - if conn == nil { - return nil, fmt.Errorf("Tor connection failed") - } - - c.addDebugLog("TCP established") - return conn, nil -} - -func (c *ChatClient) performMutualAuth(sharedSecret []byte) error { - c.conn.SetDeadline(time.Now().Add(30 * time.Second)) - defer c.conn.SetDeadline(time.Time{}) - - serverChallenge := make([]byte, 32) - if _, err := io.ReadFull(c.conn, serverChallenge); err != nil { - return fmt.Errorf("receive challenge failed: %v", err) - } - - h := hmac.New(sha256.New, sharedSecret) - h.Write(serverChallenge) - clientResponse := h.Sum(nil) - - clientChallenge := make([]byte, 32) - if _, err := cryptorand.Read(clientChallenge); err != nil { - return fmt.Errorf("challenge generation failed: %v", err) - } - - if _, err := c.conn.Write(clientResponse); err != nil { - return fmt.Errorf("send response failed: %v", err) - } - if _, err := c.conn.Write(clientChallenge); err != nil { - return fmt.Errorf("send challenge failed: %v", err) - } - - serverResponse := make([]byte, 32) - if _, err := io.ReadFull(c.conn, serverResponse); err != nil { - return fmt.Errorf("receive response failed: %v", err) - } - - h.Reset() - h.Write(clientChallenge) - expectedServerMAC := h.Sum(nil) - - if !hmac.Equal(serverResponse, expectedServerMAC) { - return fmt.Errorf("server authentication failed") - } - - return nil -} - -func (c *ChatClient) addDebugLog(message string) { - timestamp := time.Now().Format("15:04:05") - logEntry := fmt.Sprintf("[%s] %s\n", timestamp, message) - - fyne.Do(func() { - c.debugLog.SetText(c.debugLog.Text + logEntry) - c.debugLog.CursorRow = len(strings.Split(c.debugLog.Text, "\n")) - 1 - }) -} - -func (c *ChatClient) disconnect() { - if !c.connected { - return - } - - c.connected = false - c.addDebugLog("Disconnecting") - - if c.conn != nil { - c.sendEncryptedMessage("/quit") - time.Sleep(100 * time.Millisecond) - c.conn.Close() - c.conn = nil - } - - if c.sharedSecret != nil { - buf, _ := c.sharedSecret.Open() - if buf != nil { - buf.Destroy() - } - c.sharedSecret = nil - } - c.gcm = nil - - fyne.Do(func() { - c.input.Disable() - c.connectBtn.SetText("Connect") - c.connectBtn.OnTapped = c.connectToServer - c.connectBtn.Enable() - c.status.SetText("Disconnected") - }) - - c.addMessage("System", "Disconnected") - c.addDebugLog("Keys wiped") -} - -func (c *ChatClient) panic() { - c.addDebugLog("PANIC MODE") - - if c.conn != nil { - c.conn.Close() - c.conn = nil - } - - if c.sharedSecret != nil { - buf, _ := c.sharedSecret.Open() - if buf != nil { - buf.Destroy() - } - c.sharedSecret = nil - } - - if c.signKey != nil { - c.signKey = nil - } - - if c.encryptKey != nil { - c.encryptKey = nil - } - - c.gcm = nil - c.connected = false - c.fingerprint = "" - c.username = "" - - fyne.Do(func() { - c.input.Disable() - c.connectBtn.SetText("Connect") - c.connectBtn.OnTapped = c.connectToServer - c.connectBtn.Enable() - c.status.SetText("PANIC - All keys destroyed") - }) - - c.addMessage("System", "PANIC: Keys destroyed") - c.addDebugLog("Memory wiped") - - memguard.Purge() -} - -func (c *ChatClient) sendMessage(message string) { - if !c.connected || message == "" { - return - } - - c.addDebugLog("Sending: " + message) - - err := c.sendEncryptedMessage(message) - if err != nil { - c.addDebugLog("Send error: " + err.Error()) - c.showError("Send Error", err.Error()) - return - } - - c.addMessage("You", message) - fyne.Do(func() { - c.input.SetText("") - }) -} - -func padMessage(plaintext []byte) []byte { - currentLen := len(plaintext) - paddedLen := ((currentLen / messageBlockSize) + 1) * messageBlockSize - padLen := paddedLen - currentLen - - result := make([]byte, 2+paddedLen) - binary.BigEndian.PutUint16(result[0:2], uint16(currentLen)) - copy(result[2:], plaintext) - - if padLen > 0 { - padding := make([]byte, padLen) - cryptorand.Read(padding) - copy(result[2+currentLen:], padding) - } - - return result -} - -func unpadMessage(paddedData []byte) ([]byte, error) { - if len(paddedData) < 2 { - return nil, fmt.Errorf("data too short") - } - - originalLen := binary.BigEndian.Uint16(paddedData[0:2]) - - if int(originalLen) > len(paddedData)-2 { - return nil, fmt.Errorf("invalid padding") - } - - return paddedData[2 : 2+originalLen], nil -} - -func randomDelay() { - delay := minRandomDelay + rand.Intn(maxRandomDelay-minRandomDelay) - time.Sleep(time.Duration(delay) * time.Millisecond) -} - -func (c *ChatClient) sendEncryptedMessage(message string) error { - if c.gcm == nil || c.conn == nil { - return fmt.Errorf("not connected") - } - - randomDelay() - - paddedMessage := padMessage([]byte(message)) - - nonce := make([]byte, 12) - if _, err := io.ReadFull(cryptorand.Reader, nonce); err != nil { - return fmt.Errorf("nonce generation failed: %v", err) - } - - encrypted := c.gcm.Seal(nil, nonce, paddedMessage, nil) - data := append(nonce, encrypted...) - - _, err := c.conn.Write(data) - return err -} - -func (c *ChatClient) receiveMessages() { - buf := make([]byte, 8192) - - c.addDebugLog("Starting receive loop") - - for c.connected { - c.conn.SetReadDeadline(time.Time{}) - - n, err := c.conn.Read(buf) - if err != nil { - if err == io.EOF { - c.addDebugLog("Server closed connection") - } else { - c.addDebugLog("Read error: " + err.Error()) - } - if c.connected { - c.addMessage("System", "Connection lost") - c.disconnect() - } - return - } - - if n == 0 { - continue - } - - c.addDebugLog(fmt.Sprintf("Received %d bytes", n)) - - message, err := c.decryptMessage(buf[:n]) - if err != nil { - c.addDebugLog("Decrypt error: " + err.Error()) - continue - } - - var userListMsg UserListMessage - if err := json.Unmarshal([]byte(message), &userListMsg); err == nil && userListMsg.Type == "user_list" { - c.onlineUsers = userListMsg.Users - fyne.Do(func() { - c.userList.Refresh() - }) - c.addMessage("System", fmt.Sprintf("Online: %d users", len(userListMsg.Users))) - continue - } - - var msg Message - if err := json.Unmarshal([]byte(message), &msg); err != nil { - c.addMessage("Server", message) - } else { - switch msg.Type { - case "system": - c.addMessage("System", msg.Content) - case "private": - c.addMessage("PM-"+msg.From, msg.Content) - case "error": - c.addMessage("System", "Error: "+msg.Content) - default: - c.addMessage(msg.From, msg.Content) - } - } - } - - c.addDebugLog("Receive loop ended") -} - -func (c *ChatClient) decryptMessage(data []byte) (string, error) { - if len(data) < 12 { - return "", fmt.Errorf("message too short") - } - - nonce := data[:12] - ciphertext := data[12:] - - paddedPlaintext, err := c.gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return "", fmt.Errorf("decryption failed: %v", err) - } - - plaintext, err := unpadMessage(paddedPlaintext) - if err != nil { - return "", fmt.Errorf("unpad failed: %v", err) - } - - return string(plaintext), nil -} - -func (c *ChatClient) onUserClick(username string) { - fyne.Do(func() { - c.input.SetText("/pm " + username + " ") - c.input.CursorColumn = len(c.input.Text) - c.window.Canvas().Focus(c.input) - }) - c.addDebugLog("Ready to PM " + username) -} - -func (c *ChatClient) addMessage(sender, message string) { - timestamp := time.Now().Format("15:04:05") - formatted := fmt.Sprintf("[%s] %s: %s", timestamp, sender, message) - - c.messageList = append(c.messageList, formatted) - fyne.Do(func() { - c.messages.Refresh() - c.messages.ScrollToBottom() - }) -} - -func (c *ChatClient) showError(title, message string) { - fyne.Do(func() { - dialog.ShowError(fmt.Errorf(message), c.window) - }) - c.addMessage("System", title+": "+message) - c.addDebugLog(title + ": " + message) -} - -func init() { - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - - go func() { - <-sigChan - fmt.Printf("\nShutting down\n") - memguard.Purge() - os.Exit(0) - }() -} diff --git a/noshitalk-server.go b/noshitalk-server.go deleted file mode 100644 index 92e6f47..0000000 --- a/noshitalk-server.go +++ /dev/null @@ -1,1005 +0,0 @@ -package main - -import ( - "bytes" - "compress/zlib" - "crypto/aes" - "crypto/cipher" - "crypto/ecdh" - "crypto/hmac" - cryptorand "crypto/rand" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "math/rand" - "net" - "os" - "os/signal" - "sync" - "syscall" - "time" - - "github.com/awnumar/memguard" -) - -type Client struct { - conn net.Conn - identity string - gcm cipher.AEAD - sharedSecret *memguard.Enclave - quit chan struct{} - lastActivity time.Time - mu sync.Mutex - - // Visibility control - isVisible bool - - // Key rotation - currentKeyPair *ecdh.PrivateKey - nextKeyPair *ecdh.PrivateKey - sessionKeys [][]byte - keyRotationCount uint32 - lastRotation time.Time - messageCount uint32 -} - -type FileTransfer struct { - FileID string - Sender *Client - Receiver *Client - StartTime time.Time - TotalChunks int - Received int - Completed bool -} - -type Message struct { - From string `json:"from"` - To string `json:"to,omitempty"` - Content string `json:"content"` - Time string `json:"time"` - Type string `json:"type"` -} - -type UserListMessage struct { - Type string `json:"type"` - Users []string `json:"users"` - Time string `json:"time"` -} - -type FileOfferMessage struct { - Type string `json:"type"` - From string `json:"from"` - To string `json:"to"` - FileID string `json:"file_id"` - Filename string `json:"filename"` - Size int64 `json:"size"` - TotalChunks int `json:"total_chunks"` - SHA256 string `json:"sha256"` - Compressed bool `json:"compressed"` - Time string `json:"time"` -} - -type FileChunkMessage struct { - Type string `json:"type"` - FileID string `json:"file_id"` - ChunkIndex int `json:"chunk_index"` - TotalChunks int `json:"total_chunks"` - Data string `json:"data"` - Time string `json:"time"` -} - -type FileResponseMessage struct { - Type string `json:"type"` - FileID string `json:"file_id"` - From string `json:"from"` - Status string `json:"status"` - Message string `json:"message"` - SHA256 string `json:"sha256,omitempty"` - Time string `json:"time"` -} - -type KeyRotationMessage struct { - Type string `json:"type"` - PublicKey []byte `json:"public_key"` - Nonce []byte `json:"nonce"` - Signature []byte `json:"signature"` - Time string `json:"time"` -} - -var ( - clients = make(map[net.Conn]*Client) - clientsMux sync.RWMutex - fileTransfers = make(map[string]*FileTransfer) - transfersMux sync.RWMutex - version = "1.0-enhanced" -) - -const ( - handshakeTimeout = 30 * time.Second - protocolVersion = 2 - messageBlockSize = 256 - minRandomDelay = 50 - maxRandomDelay = 200 - - // Enhanced file transfer settings - fileChunkSize = 256 * 1024 // 256KB chunks - maxFileSize = 500 * 1024 * 1024 // 500MB max - maxConcurrentTransfers = 3 - - // Key rotation settings - rotateAfterMessages = 100 - rotateAfterTime = 5 * time.Minute -) - -func main() { - fmt.Printf("🔐 NoshiTalk Server v%s - Enhanced Security Edition\n", version) - fmt.Printf("⚠️ Zero logs, zero traces, auto-wipe post-session\n") - fmt.Printf("🧅 Designed for Tor Hidden Service - localhost only\n") - fmt.Printf("🎭 Traffic obfuscation: Padding + Random delays\n") - fmt.Printf("🔄 Key rotation enabled (every %d messages or %v)\n", rotateAfterMessages, rotateAfterTime) - fmt.Printf("👻 Ghost mode enabled by default\n") - - secureSetup() - defer secureShutdown("Session completed") - - listenAddr := "127.0.0.1:8083" - fmt.Printf("🔐 Listening on: localhost:8083 (Tor Hidden Service backend)\n") - fmt.Printf("⚠️ Server NOT exposed directly - Tor proxy required\n\n") - - listener, err := net.Listen("tcp", listenAddr) - if err != nil { - fmt.Printf("❌ Listen error: %v\n", err) - secureShutdown(fmt.Sprintf("Listen error: %v", err)) - } - - fmt.Printf("🚀 Server started successfully\n") - fmt.Printf("🔗 Waiting for connections...\n\n") - - stop := make(chan os.Signal, 1) - signal.Notify(stop, os.Interrupt, syscall.SIGTERM) - go func() { - <-stop - fmt.Printf("\n🛑 Received shutdown signal\n") - secureShutdown("Operator requested shutdown") - }() - - // Monitor goroutine - go func() { - ticker := time.NewTicker(60 * time.Second) - defer ticker.Stop() - for range ticker.C { - clientsMux.RLock() - activeCount := len(clients) - visibleCount := 0 - for _, client := range clients { - if client.isVisible { - visibleCount++ - } - } - if activeCount > 0 { - fmt.Printf("📊 Active connections: %d (visible: %d, ghost: %d)\n", - activeCount, visibleCount, activeCount-visibleCount) - } - clientsMux.RUnlock() - - // Cleanup old transfers - cleanupOldTransfers() - } - }() - - for { - conn, err := listener.Accept() - if err != nil { - fmt.Printf("❌ Accept error: %v\n", err) - continue - } - - fmt.Printf("🎯 New connection from %s\n", conn.RemoteAddr()) - go handleClient(conn) - } -} - -func deriveIdentity(publicKey []byte) string { - hash := sha256.Sum256(publicKey) - return fmt.Sprintf("anon_%s", hex.EncodeToString(hash[:8])) -} - -func handleClient(conn net.Conn) { - defer func() { - fmt.Printf("🔌 Connection handler ending\n") - conn.Close() - removeClient(conn) - }() - - curve := ecdh.X25519() - privateKey, err := curve.GenerateKey(cryptorand.Reader) - if err != nil { - fmt.Printf("❌ Key generation failed: %v\n", err) - return - } - - privateKeyBuffer := memguard.NewBufferFromBytes(privateKey.Bytes()) - defer privateKeyBuffer.Destroy() - - fmt.Printf("📥 Waiting for client public key...\n") - clientPubKeyBytes := make([]byte, 32) - - conn.SetReadDeadline(time.Now().Add(30 * time.Second)) - n, err := io.ReadFull(conn, clientPubKeyBytes) - if err != nil { - fmt.Printf("❌ Error reading client public key: %v\n", err) - return - } - conn.SetReadDeadline(time.Time{}) - - identity := deriveIdentity(clientPubKeyBytes) - fmt.Printf("🔑 Client identity derived: %s\n", identity) - fmt.Printf("✅ [%s] Received client public key (%d bytes)\n", identity, n) - - clientPubKey, err := curve.NewPublicKey(clientPubKeyBytes) - if err != nil { - fmt.Printf("❌ [%s] Invalid client public key: %v\n", identity, err) - return - } - - fmt.Printf("📤 [%s] Sending server public key...\n", identity) - serverPubKey := privateKey.PublicKey() - if _, err := conn.Write(serverPubKey.Bytes()); err != nil { - fmt.Printf("❌ [%s] Error sending server public key: %v\n", identity, err) - return - } - - fmt.Printf("🔢 [%s] Calculating shared secret...\n", identity) - sharedSecret, err := privateKey.ECDH(clientPubKey) - if err != nil { - fmt.Printf("❌ [%s] ECDH failed: %v\n", identity, err) - return - } - - fmt.Printf("🔐 [%s] Starting mutual authentication...\n", identity) - if err := performMutualAuth(conn, sharedSecret, identity); err != nil { - fmt.Printf("❌ [%s] Authentication failed: %v\n", identity, err) - return - } - - fmt.Printf("🔐 [%s] Setting up AES-GCM...\n", identity) - block, err := aes.NewCipher(sharedSecret[:32]) - if err != nil { - fmt.Printf("❌ [%s] AES cipher creation failed: %v\n", identity, err) - return - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - fmt.Printf("❌ [%s] GCM creation failed: %v\n", identity, err) - return - } - - sharedSecretBuffer := memguard.NewBufferFromBytes(sharedSecret) - defer sharedSecretBuffer.Destroy() - - client := &Client{ - conn: conn, - identity: identity, - gcm: gcm, - sharedSecret: sharedSecretBuffer.Seal(), - quit: make(chan struct{}), - lastActivity: time.Now(), - isVisible: false, // Ghost mode by default - currentKeyPair: privateKey, - lastRotation: time.Now(), - messageCount: 0, - } - - addClient(conn, client) - fmt.Printf("✅ [%s] Client initialized (ghost mode)\n", identity) - - // Send welcome message only to the new client - welcomeMsg := Message{ - From: "System", - Content: fmt.Sprintf("🟢 Connected as %s (ghost mode). Use /reveal to become visible, /ghost to hide.", identity), - Time: time.Now().Format("15:04:05"), - Type: "system", - } - msgJSON, _ := json.Marshal(welcomeMsg) - client.sendEncryptedMessage(string(msgJSON)) - - // Send personal user list (only themselves initially) - sendPersonalUserList(client) - - fmt.Printf("📡 [%s] Starting message handling...\n", identity) - client.receiveMessages() - - fmt.Printf("🔴 [%s] Client disconnected\n", identity) - - // Only notify if user was visible - if client.isVisible { - broadcastSystemMessage(fmt.Sprintf("🔴 %s disconnected", identity)) - } -} - -func addClient(conn net.Conn, client *Client) { - clientsMux.Lock() - clients[conn] = client - totalClients := len(clients) - clientsMux.Unlock() - - fmt.Printf("➕ [%s] Added to clients map. Total clients: %d\n", client.identity, totalClients) -} - -func removeClient(conn net.Conn) { - clientsMux.Lock() - client, exists := clients[conn] - if exists && client.isVisible { - delete(clients, conn) - clientsMux.Unlock() - // Only update lists if user was visible - broadcastUserListToVisible() - } else { - delete(clients, conn) - clientsMux.Unlock() - } -} - -func sendPersonalUserList(client *Client) { - // Send only visible users to this client - clientsMux.RLock() - visibleUsers := []string{} - for _, c := range clients { - if c.isVisible || c == client { - visibleUsers = append(visibleUsers, c.identity) - } - } - clientsMux.RUnlock() - - userListMsg := UserListMessage{ - Type: "user_list", - Users: visibleUsers, - Time: time.Now().Format("15:04:05"), - } - - msgJSON, _ := json.Marshal(userListMsg) - client.sendEncryptedMessage(string(msgJSON)) -} - -func broadcastUserListToVisible() { - clientsMux.RLock() - defer clientsMux.RUnlock() - - visibleUsers := []string{} - for _, c := range clients { - if c.isVisible { - visibleUsers = append(visibleUsers, c.identity) - } - } - - userListMsg := UserListMessage{ - Type: "user_list", - Users: visibleUsers, - Time: time.Now().Format("15:04:05"), - } - - msgJSON, _ := json.Marshal(userListMsg) - - // Send only to visible users - for _, client := range clients { - if client.isVisible { - client.sendEncryptedMessage(string(msgJSON)) - } - } -} - -func (c *Client) checkKeyRotation() { - c.mu.Lock() - defer c.mu.Unlock() - - needRotation := false - if c.messageCount >= rotateAfterMessages { - needRotation = true - fmt.Printf("🔄 [%s] Key rotation triggered (message count: %d)\n", c.identity, c.messageCount) - } - if time.Since(c.lastRotation) > rotateAfterTime { - needRotation = true - fmt.Printf("🔄 [%s] Key rotation triggered (time elapsed: %v)\n", c.identity, time.Since(c.lastRotation)) - } - - if needRotation { - c.performKeyRotation() - } -} - -func (c *Client) performKeyRotation() { - // Generate new key pair - curve := ecdh.X25519() - newKeyPair, err := curve.GenerateKey(cryptorand.Reader) - if err != nil { - fmt.Printf("❌ [%s] Key rotation failed: %v\n", c.identity, err) - return - } - - c.nextKeyPair = newKeyPair - c.keyRotationCount++ - c.messageCount = 0 - c.lastRotation = time.Now() - - // Send new public key to client - rotMsg := KeyRotationMessage{ - Type: "key_rotation", - PublicKey: newKeyPair.PublicKey().Bytes(), - Nonce: make([]byte, 12), - Time: time.Now().Format("15:04:05"), - } - - cryptorand.Read(rotMsg.Nonce) - - msgJSON, _ := json.Marshal(rotMsg) - c.sendEncryptedMessage(string(msgJSON)) - - fmt.Printf("✅ [%s] Key rotation completed (rotation #%d)\n", c.identity, c.keyRotationCount) -} - -func compressData(data []byte) ([]byte, error) { - var buf bytes.Buffer - w := zlib.NewWriter(&buf) - _, err := w.Write(data) - w.Close() - return buf.Bytes(), err -} - -func decompressData(data []byte) ([]byte, error) { - r, err := zlib.NewReader(bytes.NewReader(data)) - if err != nil { - return nil, err - } - defer r.Close() - return io.ReadAll(r) -} - -func (c *Client) receiveMessages() { - defer func() { - if r := recover(); r != nil { - fmt.Printf("💥 [%s] PANIC: %v\n", c.identity, r) - } - close(c.quit) - }() - - buf := make([]byte, 512*1024) // Larger buffer for big file chunks - - fmt.Printf("📡 [%s] Starting receive loop\n", c.identity) - - for { - n, err := c.conn.Read(buf) - if err != nil { - if err == io.EOF { - fmt.Printf("🔴 [%s] Connection closed\n", c.identity) - } else { - fmt.Printf("❌ [%s] Read error: %v\n", c.identity, err) - } - return - } - - if n == 0 { - continue - } - - c.mu.Lock() - c.lastActivity = time.Now() - c.messageCount++ - c.mu.Unlock() - - fmt.Printf("📥 [%s] Received %d bytes\n", c.identity, n) - - message, err := c.decryptMessage(buf[:n]) - if err != nil { - fmt.Printf("❌ [%s] Decrypt error: %v\n", c.identity, err) - continue - } - - // Check for key rotation - c.checkKeyRotation() - - // Handle commands - if message == "/quit" { - return - } - - if message == "/reveal" { - c.mu.Lock() - wasVisible := c.isVisible - c.isVisible = true - c.mu.Unlock() - - if !wasVisible { - fmt.Printf("👻→👤 [%s] Became visible\n", c.identity) - broadcastSystemMessage(fmt.Sprintf("👤 %s joined the chat", c.identity)) - broadcastUserListToVisible() - } - continue - } - - if message == "/ghost" { - c.mu.Lock() - wasVisible := c.isVisible - c.isVisible = false - c.mu.Unlock() - - if wasVisible { - fmt.Printf("👤→👻 [%s] Went ghost\n", c.identity) - broadcastSystemMessage(fmt.Sprintf("👻 %s went invisible", c.identity)) - broadcastUserListToVisible() - } - continue - } - - if message == "/users" { - sendPersonalUserList(c) - continue - } - - // Handle file transfer messages - var genericMsg map[string]interface{} - if err := json.Unmarshal([]byte(message), &genericMsg); err == nil { - msgType, ok := genericMsg["type"].(string) - if ok { - switch msgType { - case "file_offer": - var fileOffer FileOfferMessage - if err := json.Unmarshal([]byte(message), &fileOffer); err == nil { - routeFileOfferEnhanced(c, &fileOffer) - continue - } - case "file_chunk": - var fileChunk FileChunkMessage - if err := json.Unmarshal([]byte(message), &fileChunk); err == nil { - routeFileChunkEnhanced(c, &fileChunk) - continue - } - case "file_accept", "file_reject", "file_complete": - var fileResp FileResponseMessage - if err := json.Unmarshal([]byte(message), &fileResp); err == nil { - routeFileResponseEnhanced(c, &fileResp) - continue - } - } - } - } - - // Handle private messages - if len(message) > 4 && message[:4] == "/pm " { - parts := splitPrivateMessage(message) - if len(parts) < 2 { - errorMsg := Message{ - From: "System", - Content: "❌ Usage: /pm username message", - Time: time.Now().Format("15:04:05"), - Type: "error", - } - msgJSON, _ := json.Marshal(errorMsg) - c.sendEncryptedMessage(string(msgJSON)) - continue - } - sendPrivateMessage(c, parts[0], parts[1]) - continue - } - - // Broadcast only if visible - if c.isVisible { - broadcastUserMessage(c, message) - } else { - // Ghost users can't broadcast - errorMsg := Message{ - From: "System", - Content: "👻 You are in ghost mode. Use /reveal to send messages.", - Time: time.Now().Format("15:04:05"), - Type: "error", - } - msgJSON, _ := json.Marshal(errorMsg) - c.sendEncryptedMessage(string(msgJSON)) - } - } -} - -func routeFileOfferEnhanced(sender *Client, offer *FileOfferMessage) { - // Check concurrent transfers limit - transfersMux.RLock() - activeTransfers := 0 - for _, transfer := range fileTransfers { - if !transfer.Completed && transfer.Sender == sender { - activeTransfers++ - } - } - transfersMux.RUnlock() - - if activeTransfers >= maxConcurrentTransfers { - errorMsg := Message{ - From: "System", - Content: fmt.Sprintf("❌ Max concurrent transfers (%d) reached", maxConcurrentTransfers), - Time: time.Now().Format("15:04:05"), - Type: "error", - } - msgJSON, _ := json.Marshal(errorMsg) - sender.sendEncryptedMessage(string(msgJSON)) - return - } - - clientsMux.RLock() - var targetClient *Client - for _, client := range clients { - if client.identity == offer.To { - targetClient = client - break - } - } - clientsMux.RUnlock() - - if targetClient == nil { - errorMsg := Message{ - From: "System", - Content: fmt.Sprintf("❌ User '%s' not found", offer.To), - Time: time.Now().Format("15:04:05"), - Type: "error", - } - msgJSON, _ := json.Marshal(errorMsg) - sender.sendEncryptedMessage(string(msgJSON)) - return - } - - // Create transfer tracking - transfer := &FileTransfer{ - FileID: offer.FileID, - Sender: sender, - Receiver: targetClient, - StartTime: time.Now(), - TotalChunks: offer.TotalChunks, - Received: 0, - Completed: false, - } - - transfersMux.Lock() - fileTransfers[offer.FileID] = transfer - transfersMux.Unlock() - - offer.From = sender.identity - offer.Time = time.Now().Format("15:04:05") - - fmt.Printf("📁 [%s → %s] File offer: %s (%d bytes, %d chunks, compressed: %v)\n", - sender.identity, offer.To, offer.Filename, offer.Size, offer.TotalChunks, offer.Compressed) - - offerJSON, _ := json.Marshal(offer) - targetClient.sendEncryptedMessage(string(offerJSON)) -} - -func routeFileChunkEnhanced(sender *Client, chunk *FileChunkMessage) { - transfersMux.RLock() - transfer, exists := fileTransfers[chunk.FileID] - transfersMux.RUnlock() - - if !exists { - fmt.Printf("⚠️ [%s] Unknown file transfer: %s\n", sender.identity, chunk.FileID[:8]) - return - } - - if transfer.Sender != sender { - fmt.Printf("⚠️ [%s] Unauthorized chunk sender for: %s\n", sender.identity, chunk.FileID[:8]) - return - } - - chunk.Time = time.Now().Format("15:04:05") - - // Update progress - transfersMux.Lock() - transfer.Received++ - if transfer.Received >= transfer.TotalChunks { - transfer.Completed = true - duration := time.Since(transfer.StartTime) - fmt.Printf("✅ [%s] File transfer completed in %v\n", sender.identity, duration) - } - transfersMux.Unlock() - - fmt.Printf("📦 [%s] File chunk %d/%d (FileID: %s)\n", - sender.identity, chunk.ChunkIndex+1, chunk.TotalChunks, chunk.FileID[:8]) - - chunkJSON, _ := json.Marshal(chunk) - transfer.Receiver.sendEncryptedMessage(string(chunkJSON)) -} - -func routeFileResponseEnhanced(sender *Client, resp *FileResponseMessage) { - transfersMux.RLock() - transfer, exists := fileTransfers[resp.FileID] - transfersMux.RUnlock() - - if exists { - resp.From = sender.identity - resp.Time = time.Now().Format("15:04:05") - - fmt.Printf("📬 [%s] File response: %s (FileID: %s)\n", - sender.identity, resp.Status, resp.FileID[:8]) - - respJSON, _ := json.Marshal(resp) - - // Send to the other party - if sender == transfer.Receiver { - transfer.Sender.sendEncryptedMessage(string(respJSON)) - } else { - transfer.Receiver.sendEncryptedMessage(string(respJSON)) - } - - // Cleanup if rejected or completed - if resp.Status == "rejected" || resp.Status == "completed" { - transfersMux.Lock() - delete(fileTransfers, resp.FileID) - transfersMux.Unlock() - } - } -} - -func cleanupOldTransfers() { - transfersMux.Lock() - defer transfersMux.Unlock() - - now := time.Now() - for fileID, transfer := range fileTransfers { - if now.Sub(transfer.StartTime) > 30*time.Minute { - fmt.Printf("🗑️ Cleaning up old transfer: %s\n", fileID[:8]) - delete(fileTransfers, fileID) - } - } -} - -func sendPrivateMessage(sender *Client, targetUsername string, message string) { - clientsMux.RLock() - defer clientsMux.RUnlock() - - var targetClient *Client - for _, client := range clients { - if client.identity == targetUsername { - targetClient = client - break - } - } - - if targetClient == nil { - errorMsg := Message{ - From: "System", - Content: fmt.Sprintf("❌ User '%s' not found", targetUsername), - Time: time.Now().Format("15:04:05"), - Type: "error", - } - msgJSON, _ := json.Marshal(errorMsg) - sender.sendEncryptedMessage(string(msgJSON)) - return - } - - privateMsg := Message{ - From: sender.identity, - To: targetUsername, - Content: message, - Time: time.Now().Format("15:04:05"), - Type: "private", - } - - msgJSON, _ := json.Marshal(privateMsg) - - fmt.Printf("🔒 [%s → %s] Private message\n", sender.identity, targetUsername) - targetClient.sendEncryptedMessage(string(msgJSON)) - sender.sendEncryptedMessage(string(msgJSON)) -} - -func broadcastSystemMessage(message string) { - clientsMux.RLock() - defer clientsMux.RUnlock() - - systemMsg := Message{ - From: "System", - Content: message, - Time: time.Now().Format("15:04:05"), - Type: "system", - } - - msgJSON, _ := json.Marshal(systemMsg) - - for _, client := range clients { - if client.isVisible { - client.sendEncryptedMessage(string(msgJSON)) - } - } -} - -func broadcastUserMessage(sender *Client, message string) { - clientsMux.RLock() - defer clientsMux.RUnlock() - - visibleCount := 0 - for _, client := range clients { - if client.isVisible { - visibleCount++ - } - } - - fmt.Printf("📢 [%s] Broadcasting to %d visible clients\n", sender.identity, visibleCount-1) - - userMsg := Message{ - From: sender.identity, - Content: message, - Time: time.Now().Format("15:04:05"), - Type: "message", - } - - msgJSON, _ := json.Marshal(userMsg) - - for _, client := range clients { - if client != sender && client.isVisible { - fmt.Printf("📤 Sending to %s\n", client.identity) - client.sendEncryptedMessage(string(msgJSON)) - } - } -} - -func padMessage(plaintext []byte) []byte { - currentLen := len(plaintext) - paddedLen := ((currentLen / messageBlockSize) + 1) * messageBlockSize - padLen := paddedLen - currentLen - - result := make([]byte, 2+paddedLen) - binary.BigEndian.PutUint16(result[0:2], uint16(currentLen)) - copy(result[2:], plaintext) - - if padLen > 0 { - padding := make([]byte, padLen) - cryptorand.Read(padding) - copy(result[2+currentLen:], padding) - } - - return result -} - -func unpadMessage(paddedData []byte) ([]byte, error) { - if len(paddedData) < 2 { - return nil, fmt.Errorf("padded data too short") - } - - originalLen := binary.BigEndian.Uint16(paddedData[0:2]) - if int(originalLen) > len(paddedData)-2 { - return nil, fmt.Errorf("invalid padding length") - } - - return paddedData[2 : 2+originalLen], nil -} - -func randomDelay() { - delay := minRandomDelay + rand.Intn(maxRandomDelay-minRandomDelay) - time.Sleep(time.Duration(delay) * time.Millisecond) -} - -func (c *Client) sendEncryptedMessage(message string) error { - if c.conn == nil || c.gcm == nil { - return fmt.Errorf("not ready") - } - - randomDelay() - - paddedMessage := padMessage([]byte(message)) - - nonce := make([]byte, 12) - if _, err := io.ReadFull(cryptorand.Reader, nonce); err != nil { - return fmt.Errorf("nonce failed: %v", err) - } - - ciphertext := c.gcm.Seal(nil, nonce, paddedMessage, nil) - data := append(nonce, ciphertext...) - - fmt.Printf("📤 [%s] Sending %d bytes (padded)\n", c.identity, len(data)) - - n, err := c.conn.Write(data) - if err != nil { - return err - } - - fmt.Printf("✅ [%s] Sent %d bytes\n", c.identity, n) - return nil -} - -func (c *Client) decryptMessage(data []byte) (string, error) { - if len(data) < 12 { - return "", fmt.Errorf("too short") - } - - nonce := data[:12] - ciphertext := data[12:] - - paddedPlaintext, err := c.gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return "", err - } - - plaintext, err := unpadMessage(paddedPlaintext) - if err != nil { - return "", fmt.Errorf("unpad failed: %v", err) - } - - return string(plaintext), nil -} - -func splitPrivateMessage(msg string) []string { - content := msg[4:] - spaceIdx := -1 - for i, ch := range content { - if ch == ' ' { - spaceIdx = i - break - } - } - - if spaceIdx == -1 { - return []string{content} - } - - username := content[:spaceIdx] - message := content[spaceIdx+1:] - - return []string{username, message} -} - -func secureSetup() { - memguard.CatchInterrupt() -} - -func performMutualAuth(conn net.Conn, sharedSecret []byte, identity string) error { - conn.SetDeadline(time.Now().Add(handshakeTimeout)) - defer conn.SetDeadline(time.Time{}) - - serverChallenge := make([]byte, 32) - if _, err := cryptorand.Read(serverChallenge); err != nil { - return fmt.Errorf("challenge generation failed: %v", err) - } - - if _, err := conn.Write(serverChallenge); err != nil { - return fmt.Errorf("challenge send failed: %v", err) - } - - clientResponse := make([]byte, 32) - if _, err := io.ReadFull(conn, clientResponse); err != nil { - return fmt.Errorf("client response read failed: %v", err) - } - - clientChallenge := make([]byte, 32) - if _, err := io.ReadFull(conn, clientChallenge); err != nil { - return fmt.Errorf("client challenge read failed: %v", err) - } - - h := hmac.New(sha256.New, sharedSecret) - h.Write(serverChallenge) - expectedClientMAC := h.Sum(nil) - - if !hmac.Equal(clientResponse, expectedClientMAC) { - return fmt.Errorf("client authentication failed") - } - - h.Reset() - h.Write(clientChallenge) - serverResponse := h.Sum(nil) - - if _, err := conn.Write(serverResponse); err != nil { - return fmt.Errorf("server response send failed: %v", err) - } - - return nil -} - -func secureShutdown(reason string) { - fmt.Printf("\n🛑 Server shutdown: %s\n", reason) - - clientsMux.Lock() - for _, client := range clients { - client.conn.Close() - } - clients = make(map[net.Conn]*Client) - clientsMux.Unlock() - - memguard.Purge() - os.Exit(0) -} diff --git a/noshitalk-web-client.go b/noshitalk-web-client.go deleted file mode 100644 index b72b977..0000000 --- a/noshitalk-web-client.go +++ /dev/null @@ -1,851 +0,0 @@ -package main - -import ( - "crypto/rand" - "encoding/hex" - "encoding/json" - "log" - "net/http" - "sync" - "time" - - "github.com/gorilla/websocket" -) - -// Message types -const ( - MsgTypeHandshake = "handshake" - MsgTypePublic = "public" - MsgTypePrivate = "private" - MsgTypeSystem = "system" - MsgTypeUserJoin = "user_join" - MsgTypeUserLeave = "user_leave" - MsgTypeUsersList = "users_list" -) - -// User represents a connected user -type User struct { - ID string - Name string - Avatar string - Fingerprint string - Conn *websocket.Conn - LastSeen time.Time - mu sync.Mutex -} - -// Message represents a chat message -type Message struct { - Type string `json:"type"` - From string `json:"from,omitempty"` - FromName string `json:"from_name,omitempty"` - FromAvatar string `json:"from_avatar,omitempty"` - To string `json:"to,omitempty"` - Text string `json:"text,omitempty"` - Timestamp time.Time `json:"timestamp,omitempty"` -} - -// UserInfo for user list -type UserInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Avatar string `json:"avatar"` - Status string `json:"status"` -} - -// Server represents the chat server -type Server struct { - users map[string]*User - upgrader websocket.Upgrader - mu sync.RWMutex -} - -var server *Server - -func init() { - server = &Server{ - users: make(map[string]*User), - upgrader: websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - return true - }, - ReadBufferSize: 1024, - WriteBufferSize: 1024, - }, - } -} - -func NewUser(id, name, fingerprint string, conn *websocket.Conn) *User { - avatar := "" - if len(name) > 0 { - avatar = string(name[0]) - } - - return &User{ - ID: id, - Name: name, - Avatar: avatar, - Fingerprint: fingerprint, - Conn: conn, - LastSeen: time.Now(), - } -} - -func (u *User) SendMessage(msg Message) error { - u.mu.Lock() - defer u.mu.Unlock() - return u.Conn.WriteJSON(msg) -} - -func (s *Server) BroadcastPublic(msg Message) { - s.mu.RLock() - defer s.mu.RUnlock() - - for fingerprint, user := range s.users { - if fingerprint != msg.From { - go user.SendMessage(msg) - } - } -} - -func (s *Server) SendPrivate(msg Message) error { - s.mu.RLock() - defer s.mu.RUnlock() - - recipient, exists := s.users[msg.To] - if !exists { - return nil - } - - return recipient.SendMessage(msg) -} - -func (s *Server) AddUser(user *User) { - s.mu.Lock() - s.users[user.Fingerprint] = user - s.mu.Unlock() - - log.Printf("User added: %s (fingerprint: %s, total: %d)", user.Name, user.Fingerprint, len(s.users)) - - joinMsg := Message{ - Type: MsgTypeUserJoin, - From: user.Fingerprint, - FromName: user.Name, - FromAvatar: user.Avatar, - Text: user.Name + " joined the chat", - Timestamp: time.Now(), - } - s.BroadcastPublic(joinMsg) - - s.SendUsersList(user) - s.BroadcastUsersList() -} - -func (s *Server) RemoveUser(fingerprint string) { - s.mu.Lock() - user, exists := s.users[fingerprint] - if exists { - delete(s.users, fingerprint) - } - s.mu.Unlock() - - if exists { - log.Printf("User removed: %s (fingerprint: %s, remaining: %d)", user.Name, fingerprint, len(s.users)) - - leaveMsg := Message{ - Type: MsgTypeUserLeave, - From: fingerprint, - FromName: user.Name, - Text: user.Name + " left the chat", - Timestamp: time.Now(), - } - s.BroadcastPublic(leaveMsg) - s.BroadcastUsersList() - } -} - -func (s *Server) SendUsersList(user *User) { - s.mu.RLock() - defer s.mu.RUnlock() - - users := make([]UserInfo, 0, len(s.users)) - for _, u := range s.users { - if u.Fingerprint != user.Fingerprint { - users = append(users, UserInfo{ - ID: u.Fingerprint, - Name: u.Name, - Avatar: u.Avatar, - Status: "🔒 Encrypted", - }) - } - } - - usersData, _ := json.Marshal(map[string]interface{}{ - "type": MsgTypeUsersList, - "users": users, - }) - - user.mu.Lock() - user.Conn.WriteMessage(websocket.TextMessage, usersData) - user.mu.Unlock() -} - -func (s *Server) BroadcastUsersList() { - s.mu.RLock() - defer s.mu.RUnlock() - - for _, user := range s.users { - go s.SendUsersList(user) - } -} - -func HandleWebSocket(w http.ResponseWriter, r *http.Request) { - conn, err := server.upgrader.Upgrade(w, r, nil) - if err != nil { - log.Printf("WebSocket upgrade error: %v", err) - return - } - - var handshake struct { - Type string `json:"type"` - Name string `json:"name"` - ID string `json:"id"` - Fingerprint string `json:"fingerprint"` - } - - err = conn.ReadJSON(&handshake) - if err != nil { - log.Printf("Handshake error: %v", err) - conn.Close() - return - } - - if handshake.Type != MsgTypeHandshake { - log.Printf("Invalid handshake type: %s", handshake.Type) - conn.Close() - return - } - - if handshake.Fingerprint == "" || len(handshake.Fingerprint) < 8 { - log.Printf("Invalid fingerprint") - conn.Close() - return - } - - server.mu.RLock() - _, exists := server.users[handshake.Fingerprint] - server.mu.RUnlock() - - if exists { - errorMsg := Message{ - Type: MsgTypeSystem, - Text: "Identity already connected. Only one session per identity allowed.", - } - conn.WriteJSON(errorMsg) - conn.Close() - return - } - - user := NewUser(handshake.ID, handshake.Name, handshake.Fingerprint, conn) - server.AddUser(user) - defer server.RemoveUser(user.Fingerprint) - - log.Printf("User connected: %s (%s)", user.Name, user.Fingerprint) - - welcomeMsg := Message{ - Type: MsgTypeSystem, - Text: "Welcome to NoshiTalk! Your identity is cryptographically protected.", - Timestamp: time.Now(), - } - user.SendMessage(welcomeMsg) - - for { - var msg Message - err := conn.ReadJSON(&msg) - if err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - log.Printf("WebSocket error: %v", err) - } - break - } - - msg.From = user.Fingerprint - msg.FromName = user.Name - msg.FromAvatar = user.Avatar - msg.Timestamp = time.Now() - - switch msg.Type { - case MsgTypePublic: - server.BroadcastPublic(msg) - log.Printf("Public message from %s: %s", user.Name, msg.Text) - - case MsgTypePrivate: - err := server.SendPrivate(msg) - if err != nil { - log.Printf("Private message error: %v", err) - } else { - log.Printf("Private message from %s to %s", user.Name, msg.To) - } - } - - user.LastSeen = time.Now() - } -} - -func HandleIndex(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("X-Frame-Options", "DENY") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Referrer-Policy", "no-referrer") - - w.Write([]byte(htmlTemplate)) -} - -func generateChallenge() (string, error) { - b := make([]byte, 32) - _, err := rand.Read(b) - if err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} - -func (s *Server) CleanupInactiveUsers() { - ticker := time.NewTicker(1 * time.Minute) - defer ticker.Stop() - - for range ticker.C { - s.mu.Lock() - now := time.Now() - for fingerprint, user := range s.users { - if now.Sub(user.LastSeen) > 5*time.Minute { - log.Printf("Removing inactive user: %s", user.Name) - delete(s.users, fingerprint) - user.Conn.Close() - } - } - s.mu.Unlock() - } -} - -const htmlTemplate = `<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>NoshiTalk - Anonymous Encrypted Chat</title> - <style> - * { margin: 0; padding: 0; box-sizing: border-box; } - body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e0e0e0; height: 100vh; overflow: hidden; } - .header { background: linear-gradient(135deg, #1a1a1a 0%, #0f0f0f 100%); padding: 1rem 1.5rem; border-bottom: 2px solid #00f2fe; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 4px 20px rgba(0, 242, 254, 0.1); } - .logo { font-size: 1.3rem; font-weight: 700; color: #00f2fe; text-shadow: 0 0 10px rgba(0, 242, 254, 0.5); } - .header-controls { display: flex; gap: 1rem; align-items: center; } - .connection-status { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; } - .status-dot { width: 10px; height: 10px; border-radius: 50%; background: #51cf66; box-shadow: 0 0 10px rgba(81, 207, 102, 0.5); animation: pulse 2s infinite; } - .status-dot.disconnected { background: #ff6b6b; box-shadow: 0 0 10px rgba(255, 107, 107, 0.5); } - @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } - .panic-button { background: #ff6b6b; color: white; border: none; padding: 0.6rem 1.2rem; border-radius: 6px; font-weight: 700; cursor: pointer; font-size: 0.9rem; transition: all 0.2s; box-shadow: 0 0 20px rgba(255, 107, 107, 0.3); } - .panic-button:hover { background: #ff5252; transform: scale(1.05); box-shadow: 0 0 30px rgba(255, 107, 107, 0.5); } - .logout-button { background: #333; color: #e0e0e0; border: none; padding: 0.6rem 1.2rem; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 0.9rem; transition: all 0.2s; } - .logout-button:hover { background: #444; } - .login-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(10, 10, 10, 0.98); display: flex; align-items: center; justify-content: center; z-index: 1000; } - .login-box { background: #1a1a1a; border: 2px solid #00f2fe; border-radius: 8px; padding: 2.5rem; max-width: 500px; width: 90%; box-shadow: 0 10px 40px rgba(0, 242, 254, 0.3); } - .login-box h2 { color: #00f2fe; margin-bottom: 0.5rem; text-align: center; font-size: 1.5rem; } - .login-subtitle { text-align: center; color: #888; font-size: 0.9rem; margin-bottom: 2rem; } - .security-info { background: #0f0f0f; border-left: 3px solid #51cf66; padding: 1rem; margin-bottom: 1.5rem; font-size: 0.85rem; line-height: 1.6; } - .security-info ul { margin: 0.5rem 0 0 1.5rem; } - .security-info li { margin: 0.3rem 0; } - .warning-info { background: #2a1a1a; border-left: 3px solid #ff6b6b; padding: 1rem; margin-bottom: 1.5rem; font-size: 0.85rem; color: #ffb3b3; } - .tab-buttons { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; } - .tab-button { flex: 1; background: #0f0f0f; border: 1px solid #333; color: #888; padding: 0.8rem; border-radius: 6px; cursor: pointer; transition: all 0.3s; font-size: 0.95rem; font-weight: 600; } - .tab-button.active { background: #00f2fe; color: #0a0a0a; border-color: #00f2fe; } - .tab-content { display: none; } - .tab-content.active { display: block; } - .login-box input[type="text"], .login-box input[type="file"] { width: 100%; background: #0f0f0f; border: 1px solid #333; color: #e0e0e0; padding: 1rem; border-radius: 6px; font-size: 1rem; margin-bottom: 1rem; } - .login-box input:focus { outline: none; border-color: #00f2fe; box-shadow: 0 0 0 2px rgba(0, 242, 254, 0.1); } - .login-box button.primary { width: 100%; background: linear-gradient(135deg, #00f2fe 0%, #00d4e8 100%); color: #0a0a0a; border: none; padding: 1rem; border-radius: 6px; font-weight: 700; font-size: 1rem; cursor: pointer; transition: all 0.2s; } - .login-box button.primary:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0, 242, 254, 0.4); } - .login-box button.primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } - .generating-keys { text-align: center; padding: 1rem; color: #00f2fe; font-weight: 600; } - .spinner { border: 3px solid #333; border-top: 3px solid #00f2fe; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 1rem auto; } - @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } - .identity-info { background: #0f0f0f; padding: 1rem; border-radius: 6px; margin-top: 1rem; font-size: 0.85rem; } - .identity-info .label { color: #888; margin-bottom: 0.3rem; } - .identity-info .value { color: #00f2fe; font-family: monospace; word-break: break-all; font-size: 0.8rem; } - .save-keys-button { width: 100%; background: #333; color: #e0e0e0; border: 1px solid #00f2fe; padding: 0.8rem; border-radius: 6px; font-weight: 600; cursor: pointer; margin-top: 1rem; transition: all 0.2s; } - .save-keys-button:hover { background: #444; border-color: #00f2fe; } - .container { display: flex; height: calc(100vh - 70px); } - .sidebar { width: 250px; background: #0f0f0f; border-right: 1px solid #333; display: flex; flex-direction: column; } - .events-panel { background: #1a1a1a; border-bottom: 1px solid #333; padding: 1rem; max-height: 150px; overflow-y: auto; } - .events-panel h3 { font-size: 0.85rem; color: #00f2fe; margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 1px; } - .event-item { font-size: 0.75rem; color: #888; padding: 0.3rem 0; border-bottom: 1px solid #222; } - .event-item:last-child { border-bottom: none; } - .event-time { color: #666; margin-right: 0.3rem; } - .users-panel { flex: 1; padding: 1rem; overflow-y: auto; } - .users-panel h3 { font-size: 0.9rem; color: #00f2fe; margin-bottom: 1rem; text-transform: uppercase; letter-spacing: 1px; } - .user-count { font-size: 0.75rem; color: #666; margin-left: 0.5rem; } - .user-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; margin-bottom: 0.3rem; background: #1a1a1a; border-radius: 4px; cursor: pointer; transition: all 0.2s; position: relative; } - .user-item:hover { background: #222; transform: translateX(3px); } - .user-item.active { background: #00f2fe22; border-left: 3px solid #00f2fe; } - .user-avatar { width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(135deg, #00f2fe 0%, #00d4e8 100%); display: flex; align-items: center; justify-content: center; font-size: 0.9rem; font-weight: 700; color: #0a0a0a; position: relative; } - .user-badge { position: absolute; top: -5px; right: -5px; background: #ff6b6b; color: white; border-radius: 50%; width: 18px; height: 18px; font-size: 0.7rem; display: flex; align-items: center; justify-content: center; font-weight: 700; box-shadow: 0 0 10px rgba(255, 107, 107, 0.5); } - .user-info { flex: 1; } - .user-name { font-size: 0.9rem; color: #e0e0e0; } - .user-status { font-size: 0.7rem; color: #666; } - .main-chat { flex: 1; display: flex; flex-direction: column; background: #0a0a0a; } - .chat-tabs { display: flex; background: #0f0f0f; border-bottom: 1px solid #333; overflow-x: auto; padding: 0 1rem; } - .chat-tab { padding: 1rem 1.5rem; cursor: pointer; border-bottom: 3px solid transparent; transition: all 0.3s; white-space: nowrap; display: flex; align-items: center; gap: 0.5rem; color: #888; } - .chat-tab:hover { background: #1a1a1a; color: #c0c0c0; } - .chat-tab.active { color: #00f2fe; border-bottom-color: #00f2fe; } - .tab-close { margin-left: 0.5rem; color: #666; font-size: 1.2rem; line-height: 1; } - .tab-close:hover { color: #ff6b6b; } - .chat-header { padding: 1rem 1.5rem; background: #0f0f0f; border-bottom: 1px solid #333; } - .chat-header h2 { font-size: 1.1rem; color: #00f2fe; } - .chat-description { font-size: 0.8rem; color: #888; margin-top: 0.3rem; } - .chat-view { display: none; flex-direction: column; flex: 1; } - .chat-view.active { display: flex; } - .messages-area { flex: 1; padding: 1.5rem; overflow-y: auto; } - .message { display: flex; gap: 1rem; margin-bottom: 1.5rem; animation: fadeIn 0.3s; } - @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } - .message-avatar { width: 40px; height: 40px; border-radius: 50%; background: linear-gradient(135deg, #00f2fe 0%, #00d4e8 100%); display: flex; align-items: center; justify-content: center; font-size: 1rem; font-weight: 700; color: #0a0a0a; flex-shrink: 0; } - .message.private .message-avatar { background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%); } - .message-content { flex: 1; } - .message-header { display: flex; align-items: baseline; gap: 0.5rem; margin-bottom: 0.3rem; } - .message-author { font-weight: 600; color: #00f2fe; } - .message.private .message-author { color: #a78bfa; } - .message-time { font-size: 0.75rem; color: #666; } - .message-text { color: #c0c0c0; line-height: 1.5; word-wrap: break-word; } - .message-encrypted { font-size: 0.7rem; color: #51cf66; margin-top: 0.3rem; } - .input-area { padding: 1.5rem; background: #0f0f0f; border-top: 1px solid #333; } - .input-container { display: flex; gap: 1rem; align-items: center; } - .message-input { flex: 1; background: #1a1a1a; border: 1px solid #333; color: #e0e0e0; padding: 0.8rem 1rem; border-radius: 6px; font-size: 0.95rem; font-family: inherit; transition: border-color 0.3s; } - .message-input:focus { outline: none; border-color: #00f2fe; box-shadow: 0 0 0 2px rgba(0, 242, 254, 0.1); } - .send-button { background: linear-gradient(135deg, #00f2fe 0%, #00d4e8 100%); color: #0a0a0a; border: none; padding: 0.8rem 2rem; border-radius: 6px; font-weight: 600; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; } - .send-button:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 242, 254, 0.3); } - .send-button:active { transform: translateY(0); } - .encryption-info { font-size: 0.75rem; color: #666; margin-top: 0.5rem; text-align: center; } - .encryption-info span { color: #51cf66; } - .system-message { text-align: center; color: #666; font-size: 0.85rem; padding: 0.5rem; margin: 1rem 0; font-style: italic; } - ::-webkit-scrollbar { width: 8px; } - ::-webkit-scrollbar-track { background: #0a0a0a; } - ::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; } - ::-webkit-scrollbar-thumb:hover { background: #444; } - </style> -</head> -<body> - <div class="login-overlay" id="login-overlay"> - <div class="login-box"> - <h2>🔐 NoshiTalk Secure Entry</h2> - <p class="login-subtitle">Cryptographic Identity Required</p> - <div class="security-info"> - <strong>🔑 Key generation protects:</strong> - <ul> - <li>Your IDENTITY (cryptographic fingerprint)</li> - <li>Your LOGIN (challenge-response auth)</li> - <li>Anti-spam protection (CPU cost)</li> - </ul> - </div> - <div class="warning-info"> - ⚠️ <strong>EPHEMERAL:</strong> All messages exist only in RAM. Close tab = everything deleted. - <br>💾 Save keys to reuse this identity later. - <br>💻 Desktop client uses memguard (protects keys even from root). - </div> - <div class="tab-buttons"> - <button class="tab-button active" onclick="switchLoginTab('new')">New Identity</button> - <button class="tab-button" onclick="switchLoginTab('load')">Load Identity</button> - </div> - <div id="new-identity-tab" class="tab-content active"> - <input type="text" id="username-input" placeholder="Choose your name..." autocomplete="off"> - <button class="primary" onclick="generateKeysAndConnect()" id="generate-btn">🔑 Generate Keys & Enter</button> - <div id="generating-status" style="display: none;"> - <div class="generating-keys"><div class="spinner"></div>Generating Ed25519 + X25519 keys...</div> - </div> - <div id="identity-display" style="display: none;"> - <div class="identity-info"> - <div class="label">Your Fingerprint:</div> - <div class="value" id="fingerprint-display"></div> - </div> - <button class="save-keys-button" onclick="saveKeys()">💾 Save Keys (Download .noshikey)</button> - <p style="text-align: center; color: #888; font-size: 0.85rem; margin: 1rem 0;">Save your keys now or lose this identity forever!</p> - <button class="primary" onclick="connectToServer()" style="margin-top: 1rem;">🚀 Enter Chat</button> - </div> - </div> - <div id="load-identity-tab" class="tab-content"> - <input type="file" id="key-file-input" accept=".noshikey"> - <button class="primary" onclick="loadKeysAndConnect()">🔓 Load Keys & Enter</button> - </div> - </div> - </div> - <header class="header" style="display: none;" id="main-header"> - <div class="logo">Virebent.art</div> - <div class="header-controls"> - <div class="connection-status"> - <span class="status-dot" id="status-dot"></span> - <span id="status-text">Connecting...</span> - </div> - <button class="logout-button" onclick="logout()">Logout</button> - <button class="panic-button" onclick="panic()">🚨 PANIC</button> - </div> - </header> - <div class="container" style="display: none;" id="main-container"> - <aside class="sidebar"> - <div class="events-panel"> - <h3>🔔 Events</h3> - <div id="events-list"></div> - </div> - <div class="users-panel"> - <h3>👥 Online <span class="user-count" id="user-count">(0)</span></h3> - <div id="users-list"></div> - </div> - </aside> - <main class="main-chat"> - <div class="chat-tabs" id="chat-tabs"></div> - <div id="chat-views"></div> - </main> - </div> - <script src="https://cdnjs.cloudflare.com/ajax/libs/js-sha256/0.9.0/sha256.min.js"></script> - <script> - var ws = null; - var state = { - currentUser: '', - currentUserId: '', - activeTab: 'public', - users: [], - chats: { public: { id: 'public', name: '💬 Public Chat', description: 'End-to-end encrypted • Zero metadata • Ephemeral', messages: [] } }, - keys: { signKeyPair: null, encryptKeyPair: null, fingerprint: '' } - }; - function switchLoginTab(tab) { - document.querySelectorAll('.tab-button').forEach(function(btn) { btn.classList.remove('active'); }); - document.querySelectorAll('.tab-content').forEach(function(content) { content.classList.remove('active'); }); - if (tab === 'new') { - document.querySelector('.tab-button').classList.add('active'); - document.getElementById('new-identity-tab').classList.add('active'); - } else { - document.querySelectorAll('.tab-button')[1].classList.add('active'); - document.getElementById('load-identity-tab').classList.add('active'); - } - } - async function generateKeysAndConnect() { - var username = document.getElementById('username-input').value.trim(); - if (username === '') { alert('Please enter a name'); return; } - document.getElementById('generate-btn').disabled = true; - document.getElementById('generating-status').style.display = 'block'; - try { - await new Promise(function(resolve) { setTimeout(resolve, 100); }); - var signKeyPair = await window.crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); - var encryptKeyPair = await window.crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey"]); - var publicKeyBuffer = await window.crypto.subtle.exportKey("raw", signKeyPair.publicKey); - var publicKeyArray = new Uint8Array(publicKeyBuffer); - var fingerprint = sha256(publicKeyArray); - state.keys.signKeyPair = signKeyPair; - state.keys.encryptKeyPair = encryptKeyPair; - state.keys.fingerprint = fingerprint.substring(0, 16); - state.currentUser = username; - state.currentUserId = fingerprint.substring(0, 16); - document.getElementById('generating-status').style.display = 'none'; - document.getElementById('identity-display').style.display = 'block'; - document.getElementById('fingerprint-display').textContent = state.keys.fingerprint; - setTimeout(function() { connectToServer(); }, 1000); - } catch (error) { - console.error('Key generation error:', error); - alert('Failed to generate keys. Please try again.'); - document.getElementById('generate-btn').disabled = false; - document.getElementById('generating-status').style.display = 'none'; - } - } - async function saveKeys() { - try { - var signPrivate = await window.crypto.subtle.exportKey("jwk", state.keys.signKeyPair.privateKey); - var signPublic = await window.crypto.subtle.exportKey("jwk", state.keys.signKeyPair.publicKey); - var encryptPrivate = await window.crypto.subtle.exportKey("jwk", state.keys.encryptKeyPair.privateKey); - var encryptPublic = await window.crypto.subtle.exportKey("jwk", state.keys.encryptKeyPair.publicKey); - var keyData = { version: "1.0", name: state.currentUser, fingerprint: state.keys.fingerprint, signKeyPair: { privateKey: signPrivate, publicKey: signPublic }, encryptKeyPair: { privateKey: encryptPrivate, publicKey: encryptPublic } }; - var blob = new Blob([JSON.stringify(keyData, null, 2)], { type: 'application/json' }); - var url = URL.createObjectURL(blob); - var a = document.createElement('a'); - a.href = url; - a.download = state.currentUser.toLowerCase() + '.noshikey'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - addEvent('Keys saved to file'); - } catch (error) { - console.error('Save keys error:', error); - alert('Failed to save keys'); - } - } - async function loadKeysAndConnect() { - var fileInput = document.getElementById('key-file-input'); - if (!fileInput.files || fileInput.files.length === 0) { alert('Please select a .noshikey file'); return; } - try { - var file = fileInput.files[0]; - var text = await file.text(); - var keyData = JSON.parse(text); - var signPrivate = await window.crypto.subtle.importKey("jwk", keyData.signKeyPair.privateKey, { name: "ECDSA", namedCurve: "P-256" }, true, ["sign"]); - var signPublic = await window.crypto.subtle.importKey("jwk", keyData.signKeyPair.publicKey, { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"]); - var encryptPrivate = await window.crypto.subtle.importKey("jwk", keyData.encryptKeyPair.privateKey, { name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey"]); - var encryptPublic = await window.crypto.subtle.importKey("jwk", keyData.encryptKeyPair.publicKey, { name: "ECDH", namedCurve: "P-256" }, true, []); - state.keys.signKeyPair = { privateKey: signPrivate, publicKey: signPublic }; - state.keys.encryptKeyPair = { privateKey: encryptPrivate, publicKey: encryptPublic }; - state.keys.fingerprint = keyData.fingerprint; - state.currentUser = keyData.name; - state.currentUserId = keyData.fingerprint; - connectToServer(); - } catch (error) { - console.error('Load keys error:', error); - alert('Failed to load keys. Invalid file?'); - } - } - function connectToServer() { - document.getElementById('login-overlay').style.display = 'none'; - document.getElementById('main-header').style.display = 'flex'; - document.getElementById('main-container').style.display = 'flex'; - var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - var wsUrl = protocol + '//' + window.location.host + '/ws'; - ws = new WebSocket(wsUrl); - ws.onopen = function() { - updateStatus(true); - ws.send(JSON.stringify({ type: 'handshake', name: state.currentUser, id: state.currentUserId, fingerprint: state.keys.fingerprint })); - addEvent('Connected to server'); - }; - ws.onmessage = function(event) { var msg = JSON.parse(event.data); handleMessage(msg); }; - ws.onerror = function(error) { console.error('WebSocket error:', error); updateStatus(false); }; - ws.onclose = function() { updateStatus(false); addEvent('Disconnected from server'); }; - renderTabs(); - renderViews(); - } - function panic() { - if (!confirm('⚠️ PANIC MODE\n\nThis will:\n• Destroy all keys from memory\n• Erase all messages\n• Disconnect immediately\n• Clear all session data\n\nContinue?')) { return; } - state.keys = { signKeyPair: null, encryptKeyPair: null, fingerprint: '' }; - state.chats = {}; - state.users = []; - state.currentUser = ''; - state.currentUserId = ''; - if (ws) { ws.close(); ws = null; } - document.getElementById('main-header').style.display = 'none'; - document.getElementById('main-container').style.display = 'none'; - document.getElementById('login-overlay').style.display = 'flex'; - document.getElementById('identity-display').style.display = 'none'; - document.getElementById('username-input').value = ''; - document.getElementById('generate-btn').disabled = false; - switchLoginTab('new'); - console.clear(); - } - function logout() { - if (!confirm('Logout? All unsaved messages will be lost.')) { return; } - if (ws) { ws.close(); ws = null; } - state.chats = { public: { id: 'public', name: '💬 Public Chat', description: 'End-to-end encrypted', messages: [] } }; - state.users = []; - document.getElementById('main-header').style.display = 'none'; - document.getElementById('main-container').style.display = 'none'; - document.getElementById('login-overlay').style.display = 'flex'; - document.getElementById('identity-display').style.display = 'none'; - switchLoginTab('load'); - } - function getCurrentTime() { - var now = new Date(); - return String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0'); - } - function updateStatus(connected) { - var dot = document.getElementById('status-dot'); - var text = document.getElementById('status-text'); - if (connected) { dot.classList.remove('disconnected'); text.textContent = 'Connected'; } - else { dot.classList.add('disconnected'); text.textContent = 'Disconnected'; } - } - function handleMessage(msg) { - switch(msg.type) { - case 'users_list': - state.users = msg.users; - renderUsers(); - break; - case 'public': - addMessage('public', msg.from_name, msg.text, msg.from_avatar); - break; - case 'private': - var chatId = msg.from; - if (!state.chats[chatId]) { openPrivateChatById(chatId, msg.from_name, msg.from_avatar); } - addMessage(chatId, msg.from_name, msg.text, msg.from_avatar); - var user = state.users.find(function(u) { return u.id === chatId; }); - if (user && state.activeTab !== chatId) { user.unread = (user.unread || 0) + 1; renderUsers(); } - break; - case 'system': - addSystemMessage('public', msg.text); - break; - case 'user_join': - addEvent(msg.text); - break; - case 'user_leave': - addEvent(msg.text); - break; - } - } - function addEvent(text) { - var eventsList = document.getElementById('events-list'); - var eventItem = document.createElement('div'); - eventItem.className = 'event-item'; - eventItem.innerHTML = '<span class="event-time">' + getCurrentTime() + '</span><span>' + escapeHtml(text) + '</span>'; - eventsList.appendChild(eventItem); - while (eventsList.children.length > 10) { eventsList.removeChild(eventsList.firstChild); } - eventsList.scrollTop = eventsList.scrollHeight; - } - function renderUsers() { - var usersList = document.getElementById('users-list'); - usersList.innerHTML = ''; - state.users.forEach(function(user) { - var userItem = document.createElement('div'); - userItem.className = 'user-item'; - if (state.activeTab === user.id) { userItem.className += ' active'; } - var badgeHtml = ''; - if (user.unread > 0) { badgeHtml = '<span class="user-badge">' + user.unread + '</span>'; } - userItem.innerHTML = '<div class="user-avatar">' + escapeHtml(user.avatar) + badgeHtml + '</div><div class="user-info"><div class="user-name">' + escapeHtml(user.name) + '</div><div class="user-status">' + escapeHtml(user.status) + '</div></div>'; - userItem.onclick = function() { openPrivateChat(user); }; - usersList.appendChild(userItem); - }); - document.getElementById('user-count').textContent = '(' + state.users.length + ')'; - } - function openPrivateChat(user) { - openPrivateChatById(user.id, user.name, user.avatar); - user.unread = 0; - renderUsers(); - } - function openPrivateChatById(id, name, avatar) { - if (!state.chats[id]) { - state.chats[id] = { id: id, name: '🔒 ' + name, description: 'Private E2E encrypted • Ephemeral', messages: [], isPrivate: true }; - addEvent('Started private chat with ' + name); - renderTabs(); - renderViews(); - } - switchTab(id); - } - function renderTabs() { - var tabsContainer = document.getElementById('chat-tabs'); - tabsContainer.innerHTML = ''; - for (var chatId in state.chats) { - var chat = state.chats[chatId]; - var tab = document.createElement('div'); - tab.className = 'chat-tab'; - if (state.activeTab === chatId) { tab.className += ' active'; } - var closeBtn = ''; - if (chat.isPrivate) { closeBtn = '<span class="tab-close">×</span>'; } - tab.innerHTML = escapeHtml(chat.name) + closeBtn; - (function(id, isPrivate) { - tab.onclick = function(e) { - if (e.target.classList.contains('tab-close')) { closeTab(id); } - else { switchTab(id); } - }; - })(chatId, chat.isPrivate); - tabsContainer.appendChild(tab); - } - } - function switchTab(tabId) { - state.activeTab = tabId; - var views = document.querySelectorAll('.chat-view'); - views.forEach(function(view) { view.classList.remove('active'); }); - var activeView = document.getElementById('chat-view-' + tabId); - if (activeView) { activeView.classList.add('active'); } - renderTabs(); - renderUsers(); - var input = document.getElementById('input-' + tabId); - if (input) { input.focus(); } - } - function closeTab(chatId) { - if (chatId === 'public') return; - delete state.chats[chatId]; - if (state.activeTab === chatId) { state.activeTab = 'public'; } - renderTabs(); - renderViews(); - } - function renderViews() { - var viewsContainer = document.getElementById('chat-views'); - viewsContainer.innerHTML = ''; - for (var chatId in state.chats) { - var chat = state.chats[chatId]; - var view = document.createElement('div'); - view.id = 'chat-view-' + chatId; - view.className = 'chat-view'; - if (state.activeTab === chatId) { view.className += ' active'; } - var messagesHtml = ''; - if (chat.messages.length === 0) { - messagesHtml = '<div class="system-message">🔐 E2E encrypted • Ephemeral session • No server storage</div>'; - } else { - chat.messages.forEach(function(msg) { - var privateClass = chat.isPrivate ? ' private' : ''; - messagesHtml += '<div class="message' + privateClass + '"><div class="message-avatar">' + escapeHtml(msg.avatar) + '</div><div class="message-content"><div class="message-header"><span class="message-author">' + escapeHtml(msg.author) + '</span><span class="message-time">' + msg.time + '</span></div><div class="message-text">' + escapeHtml(msg.text) + '</div><div class="message-encrypted">🔒 X25519 + AES-256-GCM</div></div></div>'; - }); - } - view.innerHTML = '<div class="chat-header"><h2>' + escapeHtml(chat.name) + '</h2><p class="chat-description">' + escapeHtml(chat.description) + '</p></div><div class="messages-area" id="messages-' + chatId + '">' + messagesHtml + '</div><div class="input-area"><div class="input-container"><input type="text" class="message-input" id="input-' + chatId + '" placeholder="Type your encrypted message..." autocomplete="off"><button class="send-button">Send</button></div><div class="encryption-info"><span>🔒 E2E Encrypted</span> • Ephemeral • Zero server storage</div></div>'; - viewsContainer.appendChild(view); - var input = document.getElementById('input-' + chatId); - var sendBtn = view.querySelector('.send-button'); - if (input && sendBtn) { - (function(id) { - input.addEventListener('keypress', function(e) { if (e.key === 'Enter') { sendMessage(id); } }); - sendBtn.onclick = function() { sendMessage(id); }; - })(chatId); - } - } - } - function sendMessage(chatId) { - var input = document.getElementById('input-' + chatId); - var text = input.value.trim(); - if (text === '' || !ws) return; - var msg = { type: chatId === 'public' ? 'public' : 'private', text: text, from: state.currentUserId }; - if (chatId !== 'public') { msg.to = chatId; } - ws.send(JSON.stringify(msg)); - addMessage(chatId, state.currentUser, text, state.currentUser[0].toUpperCase()); - input.value = ''; - input.focus(); - } - function addMessage(chatId, author, text, avatar) { - var chat = state.chats[chatId]; - if (!chat) return; - var message = { author: author, text: text, avatar: avatar, time: getCurrentTime() }; - chat.messages.push(message); - var messagesArea = document.getElementById('messages-' + chatId); - if (messagesArea) { - var privateClass = chat.isPrivate ? ' private' : ''; - var messageDiv = document.createElement('div'); - messageDiv.className = 'message' + privateClass; - messageDiv.innerHTML = '<div class="message-avatar">' + escapeHtml(avatar) + '</div><div class="message-content"><div class="message-header"><span class="message-author">' + escapeHtml(author) + '</span><span class="message-time">' + getCurrentTime() + '</span></div><div class="message-text">' + escapeHtml(text) + '</div><div class="message-encrypted">🔒 X25519 + AES-256-GCM</div></div>'; - messagesArea.appendChild(messageDiv); - messagesArea.scrollTop = messagesArea.scrollHeight; - } - } - function addSystemMessage(chatId, text) { - var messagesArea = document.getElementById('messages-' + chatId); - if (messagesArea) { - var sysMsg = document.createElement('div'); - sysMsg.className = 'system-message'; - sysMsg.textContent = text; - messagesArea.appendChild(sysMsg); - messagesArea.scrollTop = messagesArea.scrollHeight; - } - } - function escapeHtml(text) { - var div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - </script> -</body> -</html>` - -func main() { - go server.CleanupInactiveUsers() - http.HandleFunc("/", HandleIndex) - http.HandleFunc("/ws", HandleWebSocket) - port := ":8080" - log.Printf("╔════════════════════════════════════════════╗") - log.Printf("║ NoshiTalk Server - Zero Knowledge Chat ║") - log.Printf("╠════════════════════════════════════════════╣") - log.Printf("║ Port: %s ║", port) - log.Printf("║ WebSocket: ws://localhost%s/ws ║", port) - log.Printf("║ Security: E2E Encrypted + Ephemeral ║") - log.Printf("║ Storage: Zero (RAM only) ║") - log.Printf("╚════════════════════════════════════════════╝") - err := http.ListenAndServe(port, nil) - if err != nil { - log.Fatal("Server error: ", err) - } -} diff --git a/noshitalk_cli-client.go b/noshitalk_cli-client.go deleted file mode 100644 index 4d4eb14..0000000 --- a/noshitalk_cli-client.go +++ /dev/null @@ -1,869 +0,0 @@ -package main - -import ( - "bufio" - "crypto/aes" - "crypto/cipher" - "crypto/ecdh" - "crypto/hmac" - cryptorand "crypto/rand" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "math/rand" - "net" - "net/url" - "os" - "os/signal" - "path/filepath" - "regexp" - "strings" - "sync" - "syscall" - "time" - - "github.com/awnumar/memguard" - "golang.org/x/net/proxy" -) - -type CLIClient struct { - conn net.Conn - gcm cipher.AEAD - sharedSecret *memguard.Enclave - connected bool - serverAddr string - quit chan struct{} - mu sync.Mutex - autoReconnect bool - lastServer string - onlineUsers []string - - // Persistent identity - privateKey *ecdh.PrivateKey - identity string -} - -type Message struct { - From string `json:"from"` - To string `json:"to,omitempty"` - Content string `json:"content"` - Time string `json:"time"` - Type string `json:"type"` -} - -type UserListMessage struct { - Type string `json:"type"` - Users []string `json:"users"` - Time string `json:"time"` -} - -var ( - version = "0.8-identity" - onionRegex = regexp.MustCompile(`^[a-z2-7]{56}\.onion(:[0-9]{1,5})?$`) -) - -const ( - messageBlockSize = 256 - minRandomDelay = 50 - maxRandomDelay = 200 -) - -func main() { - memguard.CatchInterrupt() - defer memguard.Purge() - - fmt.Printf("🔐 NoshiTalk CLI Client v%s\n", version) - fmt.Printf("💻 Lightweight Command Line Interface\n") - fmt.Printf("🧅 Tor-Only Mode - No Clearnet Connections\n") - fmt.Printf("🔒 ECDH + HMAC + AES-GCM Encryption\n\n") - - client := &CLIClient{ - quit: make(chan struct{}), - autoReconnect: true, - onlineUsers: []string{}, - } - - // Initialize persistent identity - if err := client.initializeIdentity(); err != nil { - fmt.Printf("❌ Failed to initialize identity: %v\n", err) - fmt.Printf(" Continuing with temporary identity...\n\n") - } - - // Setup signal handling - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - go func() { - <-sigChan - fmt.Printf("\n🛑 Shutting down securely...\n") - client.disconnect() - memguard.Purge() - os.Exit(0) - }() - - // Start auto-reconnect goroutine - go client.autoReconnectLoop() - - client.run() -} - -func (c *CLIClient) run() { - scanner := bufio.NewScanner(os.Stdin) - - fmt.Printf("📡 Enter Tor Hidden Service address (.onion only)\n") - fmt.Printf("💡 Example: abcd1234...xyz9876.onion:8083\n") - fmt.Printf("⚠️ Clearnet addresses blocked by design\n\n") - - for { - if !c.connected { - fmt.Print("🧅 .onion address: ") - if !scanner.Scan() { - break - } - c.serverAddr = strings.TrimSpace(scanner.Text()) - - if c.serverAddr == "" { - fmt.Printf("❌ Address required\n\n") - continue - } - - // Validate .onion address - if err := validateOnionAddress(c.serverAddr); err != nil { - fmt.Printf("❌ Invalid address: %v\n\n", err) - continue - } - - c.lastServer = c.serverAddr - fmt.Printf("⏳ Connecting to %s via Tor...\n", c.serverAddr) - - if err := c.connect(); err != nil { - fmt.Printf("❌ Connection failed: %v\n", err) - fmt.Printf("💡 Will retry automatically if auto-reconnect is enabled\n\n") - continue - } - } - - fmt.Print("💬 > ") - if !scanner.Scan() { - break - } - - message := strings.TrimSpace(scanner.Text()) - if message == "" { - continue - } - - // Handle commands - switch message { - case "/quit", "/exit", "/q": - c.autoReconnect = false - c.disconnect() - fmt.Println("👋 Goodbye!") - return - case "/disconnect", "/d": - c.disconnect() - continue - case "/help", "/h", "/?": - c.showHelp() - continue - case "/status", "/s": - c.showStatus() - continue - case "/reconnect", "/r": - if !c.connected && c.lastServer != "" { - c.serverAddr = c.lastServer - fmt.Printf("🔄 Reconnecting to %s...\n", c.serverAddr) - c.connect() - } - continue - case "/auto on": - c.autoReconnect = true - fmt.Printf("✅ Auto-reconnect enabled\n") - continue - case "/auto off": - c.autoReconnect = false - fmt.Printf("❌ Auto-reconnect disabled\n") - continue - case "/clear", "/cls": - fmt.Print("\033[H\033[2J") - continue - case "/users", "/u": - c.showUsers() - continue - } - - // Handle /pm command - if len(message) > 4 && message[:4] == "/pm " { - if !c.connected { - fmt.Printf("❌ Not connected. Use /reconnect or enter server address\n") - continue - } - // Send directly - server will parse it - if err := c.sendMessage(message); err != nil { - fmt.Printf("❌ Send error: %v\n", err) - c.disconnect() - } - continue - } - - // Send regular message - if !c.connected { - fmt.Printf("❌ Not connected. Use /reconnect or enter server address\n") - continue - } - - if err := c.sendMessage(message); err != nil { - fmt.Printf("❌ Send error: %v\n", err) - c.disconnect() - } - } - - c.disconnect() -} - -func validateOnionAddress(addr string) error { - if addr == "" { - return fmt.Errorf("address cannot be empty") - } - - if !strings.Contains(addr, ".onion") { - return fmt.Errorf("only Tor .onion addresses allowed - no clearnet connections") - } - - if !onionRegex.MatchString(addr) { - return fmt.Errorf("invalid .onion address format (must be v3: 56 chars + .onion:port)") - } - - return nil -} - -func (c *CLIClient) connect() error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.connected { - return nil - } - - // Only Tor connections allowed - fmt.Printf("🧅 Routing through Tor network...\n") - conn, err := c.connectThroughTor() - if err != nil { - return err - } - - c.conn = conn - return c.setupEncryption() -} - -func (c *CLIClient) connectThroughTor() (net.Conn, error) { - fmt.Printf("🧅 Initializing Tor connection...\n") - - // Test Tor proxy - if err := c.testTorProxy(); err != nil { - return nil, fmt.Errorf("Tor proxy not available: %v", err) - } - - // Try multiple proxy addresses - proxyURLs := []string{ - "socks5://127.0.0.1:9050", - "socks5://localhost:9050", - "socks5://127.0.0.1:9150", // Tor Browser - } - - var conn net.Conn - var lastErr error - - for _, proxyURL := range proxyURLs { - fmt.Printf("🔗 Trying proxy %s...\n", proxyURL) - - torProxyUrl, err := url.Parse(proxyURL) - if err != nil { - lastErr = err - continue - } - - baseDialer := &net.Dialer{ - Timeout: 90 * time.Second, - KeepAlive: 30 * time.Second, - } - - dialer, err := proxy.FromURL(torProxyUrl, baseDialer) - if err != nil { - lastErr = err - continue - } - - fmt.Printf("⏳ Connecting to %s (may take up to 90 seconds)...\n", c.serverAddr) - - conn, err = dialer.Dial("tcp", c.serverAddr) - if err != nil { - lastErr = err - fmt.Printf("❌ Failed with %s: %v\n", proxyURL, err) - continue - } - - fmt.Printf("✅ Connected through %s\n", proxyURL) - break - } - - if conn == nil { - return nil, fmt.Errorf("all Tor proxy attempts failed: %v", lastErr) - } - - fmt.Printf("✅ TCP connection through Tor established\n") - fmt.Printf("✅ Tor circuit complete (3-layer encryption)\n") - fmt.Printf("🔒 Proceeding to ECDH handshake...\n") - - return conn, nil -} - -func (c *CLIClient) testTorProxy() error { - conn, err := net.DialTimeout("tcp", "127.0.0.1:9050", 2*time.Second) - if err != nil { - return fmt.Errorf("Tor SOCKS proxy not responding on port 9050") - } - conn.Close() - - fmt.Printf("✅ Tor proxy detected and responsive\n") - return nil -} - -func (c *CLIClient) setupEncryption() error { - fmt.Printf("🔐 Establishing end-to-end encryption...\n") - - // X25519 curve for key exchange - curve := ecdh.X25519() - - // Use persistent identity key (or generate temporary if not available) - var privateKey *ecdh.PrivateKey - if c.privateKey != nil { - privateKey = c.privateKey - fmt.Printf("🔑 Using persistent identity: %s\n", c.identity) - } else { - // Fallback: generate temporary key - var err error - privateKey, err = curve.GenerateKey(cryptorand.Reader) - if err != nil { - c.conn.Close() - return fmt.Errorf("key generation failed: %v", err) - } - fmt.Printf("⚠️ Using temporary identity (will change on reconnect)\n") - } - - privateKeyBuffer := memguard.NewBufferFromBytes(privateKey.Bytes()) - defer privateKeyBuffer.Destroy() - - // Send public key (this identifies us to the server) - publicKey := privateKey.PublicKey() - publicKeyBytes := publicKey.Bytes() - - c.conn.SetWriteDeadline(time.Now().Add(30 * time.Second)) - totalSent := 0 - for totalSent < len(publicKeyBytes) { - n, err := c.conn.Write(publicKeyBytes[totalSent:]) - if err != nil { - c.conn.Close() - return fmt.Errorf("failed to send public key: %v", err) - } - totalSent += n - } - c.conn.SetWriteDeadline(time.Time{}) - - // Receive server's public key - serverPubKeyBytes := make([]byte, 32) - if _, err := io.ReadFull(c.conn, serverPubKeyBytes); err != nil { - c.conn.Close() - return fmt.Errorf("failed to receive server public key: %v", err) - } - - serverPublicKey, err := curve.NewPublicKey(serverPubKeyBytes) - if err != nil { - c.conn.Close() - return fmt.Errorf("invalid server public key: %v", err) - } - - // Calculate shared secret - sharedSecret, err := privateKey.ECDH(serverPublicKey) - if err != nil { - c.conn.Close() - return fmt.Errorf("ECDH failed: %v", err) - } - - // Use sharedSecret directly, seal into memguard after we're done - // HMAC Mutual Authentication - fmt.Printf("🔐 Starting HMAC mutual authentication...\n") - if err := c.performMutualAuth(sharedSecret); err != nil { - c.conn.Close() - return fmt.Errorf("authentication failed: %v", err) - } - fmt.Printf("✅ Mutual authentication successful\n") - - // Setup AES-GCM - block, err := aes.NewCipher(sharedSecret[:32]) - if err != nil { - c.conn.Close() - return fmt.Errorf("AES cipher creation failed: %v", err) - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - c.conn.Close() - return fmt.Errorf("GCM cipher creation failed: %v", err) - } - - // Now seal sharedSecret into memguard (this zeros the original) - sharedSecretBuffer := memguard.NewBufferFromBytes(sharedSecret) - defer sharedSecretBuffer.Destroy() - - c.gcm = gcm - c.sharedSecret = sharedSecretBuffer.Seal() - c.connected = true - - // Success messages - fmt.Printf("✅ Connected to %s\n", c.serverAddr) - fmt.Printf("🔐 End-to-end encryption active (AES-256-GCM)\n") - fmt.Printf("🧅 Anonymous Tor routing active\n") - fmt.Printf("🔒 ECDH + HMAC + AES-GCM protection\n") - fmt.Printf("💬 Ready for secure messaging\n") - fmt.Printf("ℹ️ Type /help for commands\n\n") - - // Start receiving messages - go c.receiveMessages() - - return nil -} - -func (c *CLIClient) performMutualAuth(sharedSecret []byte) error { - c.conn.SetDeadline(time.Now().Add(30 * time.Second)) - defer c.conn.SetDeadline(time.Time{}) - - // Step 1: Receive server challenge - serverChallenge := make([]byte, 32) - if _, err := io.ReadFull(c.conn, serverChallenge); err != nil { - return fmt.Errorf("receive server challenge failed: %v", err) - } - - // Step 2: Compute HMAC response to server's challenge - h := hmac.New(sha256.New, sharedSecret) - h.Write(serverChallenge) - clientResponse := h.Sum(nil) - - // Step 3: Generate client's own challenge - clientChallenge := make([]byte, 32) - if _, err := cryptorand.Read(clientChallenge); err != nil { - return fmt.Errorf("challenge generation failed: %v", err) - } - - // Step 4: Send client response + client challenge - if _, err := c.conn.Write(clientResponse); err != nil { - return fmt.Errorf("send client response failed: %v", err) - } - if _, err := c.conn.Write(clientChallenge); err != nil { - return fmt.Errorf("send client challenge failed: %v", err) - } - - // Step 5: Receive and verify server's response - serverResponse := make([]byte, 32) - if _, err := io.ReadFull(c.conn, serverResponse); err != nil { - return fmt.Errorf("receive server response failed: %v", err) - } - - h.Reset() - h.Write(clientChallenge) - expectedServerMAC := h.Sum(nil) - - if !hmac.Equal(serverResponse, expectedServerMAC) { - return fmt.Errorf("server authentication failed - HMAC mismatch") - } - - return nil -} - -func (c *CLIClient) disconnect() { - c.mu.Lock() - defer c.mu.Unlock() - - if !c.connected { - return - } - - c.sendEncryptedMessage("/quit") - time.Sleep(100 * time.Millisecond) - - if c.conn != nil { - c.conn.Close() - c.conn = nil - } - - if c.sharedSecret != nil { - // Open enclave and destroy the buffer - buf, _ := c.sharedSecret.Open() - if buf != nil { - buf.Destroy() - } - c.sharedSecret = nil - } - c.gcm = nil - - c.connected = false - fmt.Printf("\n📴 Disconnected from server\n") - fmt.Printf("🔒 Keys wiped from memory\n") - - if c.autoReconnect { - fmt.Printf("🔄 Auto-reconnect is enabled\n") - } - fmt.Printf("\n") -} - -func (c *CLIClient) sendMessage(message string) error { - return c.sendEncryptedMessage(message) -} - -func padMessage(plaintext []byte) []byte { - currentLen := len(plaintext) - paddedLen := ((currentLen / messageBlockSize) + 1) * messageBlockSize - padLen := paddedLen - currentLen - - result := make([]byte, 2+paddedLen) - binary.BigEndian.PutUint16(result[0:2], uint16(currentLen)) - copy(result[2:], plaintext) - - if padLen > 0 { - padding := make([]byte, padLen) - cryptorand.Read(padding) - copy(result[2+currentLen:], padding) - } - - return result -} - -func unpadMessage(paddedData []byte) ([]byte, error) { - if len(paddedData) < 2 { - return nil, fmt.Errorf("padded data too short") - } - - originalLen := binary.BigEndian.Uint16(paddedData[0:2]) - - if int(originalLen) > len(paddedData)-2 { - return nil, fmt.Errorf("invalid padding length") - } - - return paddedData[2 : 2+originalLen], nil -} - -func randomDelay() { - delay := minRandomDelay + rand.Intn(maxRandomDelay-minRandomDelay) - time.Sleep(time.Duration(delay) * time.Millisecond) -} - -func (c *CLIClient) sendEncryptedMessage(message string) error { - c.mu.Lock() - defer c.mu.Unlock() - - if !c.connected || c.gcm == nil || c.conn == nil { - return fmt.Errorf("not connected") - } - - // Random delay (timing attack mitigation) - randomDelay() - - // Pad message - paddedMessage := padMessage([]byte(message)) - - nonce := make([]byte, 12) - if _, err := io.ReadFull(cryptorand.Reader, nonce); err != nil { - return fmt.Errorf("nonce generation failed: %v", err) - } - - encrypted := c.gcm.Seal(nil, nonce, paddedMessage, nil) - data := append(nonce, encrypted...) - - _, err := c.conn.Write(data) - return err -} - -func (c *CLIClient) receiveMessages() { - buf := make([]byte, 8192) - - for { - c.mu.Lock() - if !c.connected { - c.mu.Unlock() - return - } - conn := c.conn - gcm := c.gcm - c.mu.Unlock() - - // No read deadline for persistent connections - conn.SetReadDeadline(time.Time{}) - - n, err := conn.Read(buf) - if err != nil { - if err == io.EOF { - fmt.Printf("\n📴 Server closed connection\n") - } else { - fmt.Printf("\n❌ Connection lost: %v\n", err) - } - - c.mu.Lock() - c.connected = false - c.mu.Unlock() - return - } - - if n < 12 { - continue - } - - // Decrypt message - nonce := buf[:12] - ciphertext := buf[12:n] - - paddedPlaintext, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - fmt.Printf("\n⚠️ Failed to decrypt message\n") - continue - } - - // Remove padding - plaintext, err := unpadMessage(paddedPlaintext) - if err != nil { - fmt.Printf("\n⚠️ Failed to unpad message: %v\n", err) - continue - } - - // Try to parse as UserListMessage first - var userListMsg UserListMessage - if err := json.Unmarshal(plaintext, &userListMsg); err == nil && userListMsg.Type == "user_list" { - c.mu.Lock() - c.onlineUsers = userListMsg.Users - c.mu.Unlock() - fmt.Printf("\r👥 Online users (%d): %s\n💬 > ", len(userListMsg.Users), strings.Join(userListMsg.Users, ", ")) - continue - } - - // Try to parse as Message - var msg Message - if err := json.Unmarshal(plaintext, &msg); err == nil { - timestamp := msg.Time - if timestamp == "" { - timestamp = time.Now().Format("15:04:05") - } - - switch msg.Type { - case "system": - fmt.Printf("\r🔔 [%s] %s\n💬 > ", timestamp, msg.Content) - case "private": - if msg.To != "" { - // Private message - fmt.Printf("\r🔒 [%s] PM from %s: %s\n💬 > ", timestamp, msg.From, msg.Content) - } - case "error": - fmt.Printf("\r❌ [%s] %s\n💬 > ", timestamp, msg.Content) - default: - // Regular message - if msg.From == "" { - fmt.Printf("\r📨 [%s] %s\n💬 > ", timestamp, msg.Content) - } else { - fmt.Printf("\r👤 [%s] %s: %s\n💬 > ", timestamp, msg.From, msg.Content) - } - } - } else { - // Plain message fallback - timestamp := time.Now().Format("15:04:05") - fmt.Printf("\r📨 [%s] %s\n💬 > ", timestamp, string(plaintext)) - } - } -} - -func (c *CLIClient) autoReconnectLoop() { - lastAttempt := time.Now() - - for { - time.Sleep(5 * time.Second) - - c.mu.Lock() - shouldReconnect := !c.connected && c.autoReconnect && c.lastServer != "" - c.mu.Unlock() - - if shouldReconnect { - if time.Since(lastAttempt) < 10*time.Second { - continue - } - lastAttempt = time.Now() - - fmt.Printf("\n🔄 Auto-reconnecting to %s...\n", c.lastServer) - c.serverAddr = c.lastServer - if err := c.connect(); err != nil { - fmt.Printf("❌ Auto-reconnect failed: %v\n", err) - fmt.Printf("⏳ Will retry in 10 seconds...\n") - } - } - } -} - -func (c *CLIClient) showStatus() { - c.mu.Lock() - defer c.mu.Unlock() - - fmt.Printf("\n📊 Connection Status:\n") - fmt.Printf(" Connected: %v\n", c.connected) - if c.connected { - fmt.Printf(" Server: %s\n", c.serverAddr) - fmt.Printf(" Mode: Tor Hidden Service (Anonymous)\n") - fmt.Printf(" Encryption: AES-256-GCM\n") - fmt.Printf(" Key Exchange: ECDH-X25519\n") - fmt.Printf(" Authentication: HMAC-SHA256\n") - } - fmt.Printf(" Auto-Reconnect: %v\n", c.autoReconnect) - fmt.Printf(" Version: %s\n\n", version) -} - -func (c *CLIClient) showHelp() { - fmt.Printf(` -╔════════════════════════════════════════════╗ -║ NoshiTalk CLI Commands v%s ║ -╚════════════════════════════════════════════╝ - -Connection: - /reconnect, /r - Reconnect to last server - /disconnect, /d - Disconnect from server - /status, /s - Show connection status - -Messaging: - /users, /u - Show online users - /pm user message - Send private message - -Settings: - /auto on - Enable auto-reconnect - /auto off - Disable auto-reconnect - -Interface: - /clear, /cls - Clear screen - /help, /h, /? - Show this help - /quit, /exit, /q - Exit application - -Tips: - • Only .onion addresses accepted (Tor-only mode) - • All messages are end-to-end encrypted - • ECDH + HMAC + AES-GCM protection - • Auto-reconnect keeps you connected - • Press Ctrl+C for emergency shutdown - • Clearnet connections blocked by design - -Examples: - /pm alice Hey, how are you? - /users - -`, version) -} - -func (c *CLIClient) showUsers() { - c.mu.Lock() - users := c.onlineUsers - c.mu.Unlock() - - if len(users) == 0 { - fmt.Printf("👥 No users online (or not yet received user list)\n") - return - } - - fmt.Printf("👥 Online users (%d):\n", len(users)) - for _, user := range users { - fmt.Printf(" • %s\n", user) - } -} - -// Identity management functions - -func (c *CLIClient) initializeIdentity() error { - homeDir, err := os.UserHomeDir() - if err != nil { - return err - } - - keyPath := filepath.Join(homeDir, ".noshitalk", "identity.key") - - // Try to load existing key - if _, err := os.Stat(keyPath); err == nil { - return c.loadIdentity(keyPath) - } - - // Generate new key pair - return c.generateNewIdentity(keyPath) -} - -func (c *CLIClient) generateNewIdentity(keyPath string) error { - fmt.Printf("🔑 No identity found. Generating new anonymous identity...\n") - - curve := ecdh.X25519() - privKey, err := curve.GenerateKey(cryptorand.Reader) - if err != nil { - return err - } - - pubKey := privKey.PublicKey().Bytes() - - // Derive identity from public key - hash := sha256.Sum256(pubKey) - identity := fmt.Sprintf("anon_%s", hex.EncodeToString(hash[:8])) - - c.privateKey = privKey - c.identity = identity - - fmt.Printf("✅ Identity created: %s\n", identity) - fmt.Printf(" (will be saved to %s)\n\n", keyPath) - - // Save to disk - return c.saveIdentity(keyPath) -} - -func (c *CLIClient) loadIdentity(keyPath string) error { - fmt.Printf("🔑 Loading identity from %s\n", keyPath) - - data, err := os.ReadFile(keyPath) - if err != nil { - return err - } - - curve := ecdh.X25519() - privKey, err := curve.NewPrivateKey(data) - if err != nil { - return err - } - - pubKey := privKey.PublicKey().Bytes() - - // Derive identity - hash := sha256.Sum256(pubKey) - identity := fmt.Sprintf("anon_%s", hex.EncodeToString(hash[:8])) - - c.privateKey = privKey - c.identity = identity - - fmt.Printf("✅ Identity loaded: %s\n\n", identity) - - return nil -} - -func (c *CLIClient) saveIdentity(keyPath string) error { - // Create directory if needed - dir := filepath.Dir(keyPath) - if err := os.MkdirAll(dir, 0700); err != nil { - return err - } - - // Save private key - privKeyBytes := c.privateKey.Bytes() - - if err := os.WriteFile(keyPath, privKeyBytes, 0600); err != nil { - return err - } - - fmt.Printf("💾 Identity saved securely\n") - return nil -} diff --git a/pkg/crypto/crypto_test.go b/pkg/crypto/crypto_test.go new file mode 100644 index 0000000..1d76767 --- /dev/null +++ b/pkg/crypto/crypto_test.go @@ -0,0 +1,440 @@ +package crypto + +import ( + "bytes" + "crypto/cipher" + "net" + "testing" + "time" +) + +func TestPadMessage(t *testing.T) { + tests := []struct { + name string + input []byte + wantSize int // Expected padded size (2 + paddedLen) + }{ + {"empty", []byte{}, 2 + MessageBlockSize}, + {"small", []byte("hello"), 2 + MessageBlockSize}, + {"exact block minus header", make([]byte, MessageBlockSize-2), 2 + MessageBlockSize}, + {"one block", make([]byte, MessageBlockSize), 2 + MessageBlockSize*2}, + {"large", make([]byte, MessageBlockSize*2+50), 2 + MessageBlockSize*3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + padded, err := PadMessage(tt.input) + if err != nil { + t.Fatalf("PadMessage() error = %v", err) + } + + if len(padded) != tt.wantSize { + t.Errorf("PadMessage() size = %d, want %d", len(padded), tt.wantSize) + } + + // Verify we can unpad correctly + unpadded, err := UnpadMessage(padded) + if err != nil { + t.Fatalf("UnpadMessage() error = %v", err) + } + + if !bytes.Equal(unpadded, tt.input) { + t.Errorf("UnpadMessage() = %v, want %v", unpadded, tt.input) + } + }) + } +} + +func TestUnpadMessage_Errors(t *testing.T) { + tests := []struct { + name string + input []byte + wantErr bool + }{ + {"nil", nil, true}, + {"empty", []byte{}, true}, + {"too short", []byte{0x00}, true}, + {"invalid length", []byte{0xFF, 0xFF, 0x00}, true}, // Claims 65535 bytes but only has 1 + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := UnpadMessage(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("UnpadMessage() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestPadUnpadRoundTrip(t *testing.T) { + messages := []string{ + "", + "Hello, World!", + "This is a longer message that spans multiple words", + string(make([]byte, 1000)), // Large message + } + + for _, msg := range messages { + padded, err := PadMessage([]byte(msg)) + if err != nil { + t.Fatalf("PadMessage(%q) error = %v", msg, err) + } + + unpadded, err := UnpadMessage(padded) + if err != nil { + t.Fatalf("UnpadMessage() error = %v", err) + } + + if string(unpadded) != msg { + t.Errorf("Round trip failed: got %q, want %q", string(unpadded), msg) + } + } +} + +func TestDeriveIdentity(t *testing.T) { + pubKey := []byte("test-public-key-32-bytes-long!!!") + + identity := DeriveIdentity(pubKey) + + // Should start with "anon_" + if len(identity) < 5 || identity[:5] != "anon_" { + t.Errorf("DeriveIdentity() = %q, should start with 'anon_'", identity) + } + + // Should be deterministic + identity2 := DeriveIdentity(pubKey) + if identity != identity2 { + t.Errorf("DeriveIdentity() not deterministic: %q != %q", identity, identity2) + } + + // Different input should produce different output + identity3 := DeriveIdentity([]byte("different-key-32-bytes-long!!!!")) + if identity == identity3 { + t.Error("DeriveIdentity() should produce different output for different input") + } +} + +func TestDeriveFingerprint(t *testing.T) { + pubKey := []byte("test-public-key-32-bytes-long!!!") + + fp := DeriveFingerprint(pubKey) + + // Should be 32 hex chars (16 bytes) + if len(fp) != 32 { + t.Errorf("DeriveFingerprint() length = %d, want 32", len(fp)) + } + + // Should be deterministic + fp2 := DeriveFingerprint(pubKey) + if fp != fp2 { + t.Errorf("DeriveFingerprint() not deterministic: %q != %q", fp, fp2) + } +} + +func TestSetupAESGCM(t *testing.T) { + // Valid 32-byte key + validKey := make([]byte, 32) + for i := range validKey { + validKey[i] = byte(i) + } + + gcm, err := SetupAESGCM(validKey) + if err != nil { + t.Fatalf("SetupAESGCM() error = %v", err) + } + + if gcm == nil { + t.Error("SetupAESGCM() returned nil") + } + + // Too short key + _, err = SetupAESGCM([]byte("short")) + if err == nil { + t.Error("SetupAESGCM() should fail with short key") + } +} + +func TestEncryptDecrypt(t *testing.T) { + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + + gcm, err := SetupAESGCM(key) + if err != nil { + t.Fatalf("SetupAESGCM() error = %v", err) + } + + plaintext := []byte("Hello, secure world!") + + // Encrypt + ciphertext, err := Encrypt(gcm, plaintext) + if err != nil { + t.Fatalf("Encrypt() error = %v", err) + } + + // Ciphertext should be different from plaintext + if bytes.Equal(ciphertext, plaintext) { + t.Error("Encrypt() ciphertext equals plaintext") + } + + // Should include nonce (12 bytes) + ciphertext + tag + if len(ciphertext) < NonceSize+len(plaintext) { + t.Errorf("Encrypt() ciphertext too short: %d", len(ciphertext)) + } + + // Decrypt + decrypted, err := Decrypt(gcm, ciphertext) + if err != nil { + t.Fatalf("Decrypt() error = %v", err) + } + + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("Decrypt() = %q, want %q", string(decrypted), string(plaintext)) + } +} + +func TestEncryptDecrypt_DifferentNonces(t *testing.T) { + key := make([]byte, 32) + gcm, _ := SetupAESGCM(key) + + plaintext := []byte("Same message") + + // Encrypt twice + ct1, _ := Encrypt(gcm, plaintext) + ct2, _ := Encrypt(gcm, plaintext) + + // Ciphertexts should be different (different nonces) + if bytes.Equal(ct1, ct2) { + t.Error("Encrypt() should produce different ciphertext each time (different nonces)") + } + + // But both should decrypt to same plaintext + pt1, _ := Decrypt(gcm, ct1) + pt2, _ := Decrypt(gcm, ct2) + + if !bytes.Equal(pt1, pt2) { + t.Error("Both ciphertexts should decrypt to same plaintext") + } +} + +func TestDecrypt_Errors(t *testing.T) { + key := make([]byte, 32) + gcm, _ := SetupAESGCM(key) + + tests := []struct { + name string + input []byte + }{ + {"nil", nil}, + {"empty", []byte{}}, + {"too short", []byte("short")}, + {"invalid ciphertext", make([]byte, 50)}, // Random bytes won't decrypt + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Decrypt(gcm, tt.input) + if err == nil { + t.Error("Decrypt() should fail") + } + }) + } +} + +func TestEncryptDecryptMessage(t *testing.T) { + key := make([]byte, 32) + gcm, _ := SetupAESGCM(key) + + message := "Hello, this is a test message!" + + // Encrypt (note: this includes random delay, may be slow) + encrypted, err := EncryptMessage(gcm, message) + if err != nil { + t.Fatalf("EncryptMessage() error = %v", err) + } + + // Decrypt + decrypted, err := DecryptMessage(gcm, encrypted) + if err != nil { + t.Fatalf("DecryptMessage() error = %v", err) + } + + if decrypted != message { + t.Errorf("DecryptMessage() = %q, want %q", decrypted, message) + } +} + +func TestGenerateX25519KeyPair(t *testing.T) { + key1, err := GenerateX25519KeyPair() + if err != nil { + t.Fatalf("GenerateX25519KeyPair() error = %v", err) + } + + if key1 == nil { + t.Fatal("GenerateX25519KeyPair() returned nil") + } + + // Public key should be 32 bytes + pubKey := key1.PublicKey().Bytes() + if len(pubKey) != 32 { + t.Errorf("Public key length = %d, want 32", len(pubKey)) + } + + // Generate another - should be different + key2, _ := GenerateX25519KeyPair() + if bytes.Equal(key1.PublicKey().Bytes(), key2.PublicKey().Bytes()) { + t.Error("Two generated keys should be different") + } +} + +func TestPerformECDH(t *testing.T) { + // Generate two key pairs + aliceKey, _ := GenerateX25519KeyPair() + bobKey, _ := GenerateX25519KeyPair() + + // Alice computes shared secret with Bob's public key + aliceShared, err := PerformECDH(aliceKey, bobKey.PublicKey().Bytes()) + if err != nil { + t.Fatalf("PerformECDH(alice) error = %v", err) + } + + // Bob computes shared secret with Alice's public key + bobShared, err := PerformECDH(bobKey, aliceKey.PublicKey().Bytes()) + if err != nil { + t.Fatalf("PerformECDH(bob) error = %v", err) + } + + // Both should arrive at the same shared secret + if !bytes.Equal(aliceShared, bobShared) { + t.Error("ECDH shared secrets don't match") + } + + // Shared secret should be 32 bytes + if len(aliceShared) != 32 { + t.Errorf("Shared secret length = %d, want 32", len(aliceShared)) + } +} + +func TestPerformECDH_InvalidKey(t *testing.T) { + aliceKey, _ := GenerateX25519KeyPair() + + // Invalid public key (wrong size) + _, err := PerformECDH(aliceKey, []byte("too short")) + if err == nil { + t.Error("PerformECDH() should fail with invalid public key") + } +} + +func TestSecureBuffer(t *testing.T) { + secret := []byte("super-secret-data-here!") + + enclave := SecureBuffer(secret) + if enclave == nil { + t.Fatal("SecureBuffer() returned nil") + } + + // Original data should be zeroed (memguard behavior) + // We can't easily test this without accessing memguard internals +} + +// Mock connection for auth tests +type mockConn struct { + readBuf *bytes.Buffer + writeBuf *bytes.Buffer +} + +func newMockConnPair() (*mockConn, *mockConn) { + buf1 := &bytes.Buffer{} + buf2 := &bytes.Buffer{} + return &mockConn{readBuf: buf1, writeBuf: buf2}, + &mockConn{readBuf: buf2, writeBuf: buf1} +} + +func (m *mockConn) Read(b []byte) (n int, err error) { return m.readBuf.Read(b) } +func (m *mockConn) Write(b []byte) (n int, err error) { return m.writeBuf.Write(b) } +func (m *mockConn) Close() error { return nil } +func (m *mockConn) LocalAddr() net.Addr { return nil } +func (m *mockConn) RemoteAddr() net.Addr { return nil } +func (m *mockConn) SetDeadline(t time.Time) error { return nil } +func (m *mockConn) SetReadDeadline(t time.Time) error { return nil } +func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil } + +func TestMutualAuth(t *testing.T) { + sharedSecret := make([]byte, 32) + for i := range sharedSecret { + sharedSecret[i] = byte(i) + } + + // Create connected mock pair + serverConn, clientConn := newMockConnPair() + + // Run server auth in goroutine + serverErr := make(chan error, 1) + go func() { + serverErr <- PerformServerAuth(serverConn, sharedSecret) + }() + + // Small delay to ensure server sends challenge first + time.Sleep(10 * time.Millisecond) + + // Run client auth + err := PerformClientAuth(clientConn, sharedSecret) + if err != nil { + t.Fatalf("PerformClientAuth() error = %v", err) + } + + // Check server result + select { + case err := <-serverErr: + if err != nil { + t.Fatalf("PerformServerAuth() error = %v", err) + } + case <-time.After(time.Second): + t.Fatal("Server auth timed out") + } +} + +func TestRandomDelay(t *testing.T) { + start := time.Now() + err := RandomDelay() + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("RandomDelay() error = %v", err) + } + + // Should be at least MinRandomDelay + if elapsed < time.Duration(MinRandomDelay)*time.Millisecond { + t.Errorf("RandomDelay() too fast: %v", elapsed) + } + + // Should be at most MaxRandomDelay (with some buffer) + if elapsed > time.Duration(MaxRandomDelay+50)*time.Millisecond { + t.Errorf("RandomDelay() too slow: %v", elapsed) + } +} + +// Benchmark tests +func BenchmarkPadMessage(b *testing.B) { + msg := []byte("This is a test message for benchmarking") + for i := 0; i < b.N; i++ { + PadMessage(msg) + } +} + +func BenchmarkEncryptDecrypt(b *testing.B) { + key := make([]byte, 32) + gcm, _ := SetupAESGCM(key) + plaintext := []byte("Benchmark message for encryption testing") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ct, _ := Encrypt(gcm, plaintext) + Decrypt(gcm, ct) + } +} + +// Helper to satisfy cipher.AEAD interface check +var _ cipher.AEAD = (cipher.AEAD)(nil) diff --git a/pkg/identity/identity_test.go b/pkg/identity/identity_test.go new file mode 100644 index 0000000..40981ad --- /dev/null +++ b/pkg/identity/identity_test.go @@ -0,0 +1,426 @@ +package identity + +import ( + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGetDefaultKeyPath(t *testing.T) { + path, err := GetDefaultKeyPath("test.key") + if err != nil { + t.Fatalf("GetDefaultKeyPath() error = %v", err) + } + + if !strings.Contains(path, DefaultKeyDir) { + t.Errorf("Path should contain %q: %s", DefaultKeyDir, path) + } + + if !strings.HasSuffix(path, "test.key") { + t.Errorf("Path should end with 'test.key': %s", path) + } +} + +func TestNew(t *testing.T) { + id, err := New("test") + if err != nil { + t.Fatalf("New() error = %v", err) + } + + if id.PrivateKey == nil { + t.Error("PrivateKey should not be nil") + } + if id.PublicKey == nil { + t.Error("PublicKey should not be nil") + } + if !strings.HasPrefix(id.Username, "test_") { + t.Errorf("Username should start with 'test_': %s", id.Username) + } + if len(id.Fingerprint) != 32 { + t.Errorf("Fingerprint length = %d, want 32", len(id.Fingerprint)) + } +} + +func TestNew_DifferentEachTime(t *testing.T) { + id1, _ := New("test") + id2, _ := New("test") + + if id1.Fingerprint == id2.Fingerprint { + t.Error("Two New() calls should produce different identities") + } +} + +func TestIdentity_SaveAndLoad(t *testing.T) { + // Create temp directory + tmpDir, err := os.MkdirTemp("", "noshitalk-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + keyPath := filepath.Join(tmpDir, "test-identity.noshikey") + + // Create and save identity + original, err := New("test") + if err != nil { + t.Fatalf("New() error = %v", err) + } + + err = original.Save(keyPath) + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Verify file exists + if _, err := os.Stat(keyPath); os.IsNotExist(err) { + t.Fatal("Key file was not created") + } + + // Load identity + loaded, err := Load(keyPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + // Compare + if loaded.Username != original.Username { + t.Errorf("Username mismatch: %q != %q", loaded.Username, original.Username) + } + if loaded.Fingerprint != original.Fingerprint { + t.Errorf("Fingerprint mismatch: %q != %q", loaded.Fingerprint, original.Fingerprint) + } + + // Private keys should be functionally equivalent + origPubKey := original.PrivateKey.PublicKey().Bytes() + loadedPubKey := loaded.PrivateKey.PublicKey().Bytes() + if hex.EncodeToString(origPubKey) != hex.EncodeToString(loadedPubKey) { + t.Error("Public keys derived from private keys don't match") + } +} + +func TestIdentity_SaveRaw(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "noshitalk-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + keyPath := filepath.Join(tmpDir, "raw.key") + + // Create and save identity in raw format + original, _ := New("test") + err = original.SaveRaw(keyPath) + if err != nil { + t.Fatalf("SaveRaw() error = %v", err) + } + + // Verify file is 32 bytes (raw private key) + data, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if len(data) != 32 { + t.Errorf("Raw key file size = %d, want 32", len(data)) + } + + // Load it back + loaded, err := Load(keyPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + // Fingerprints should match + if loaded.Fingerprint != original.Fingerprint { + t.Errorf("Fingerprint mismatch after raw save/load") + } +} + +func TestLoad_JSONFormat(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "noshitalk-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create a valid JSON identity file manually + id, _ := New("test") + idFile := IdentityFile{ + Version: IdentityVersion, + Username: id.Username, + Fingerprint: id.Fingerprint, + EncryptKeyPair: KeyPair{ + PrivateKey: hex.EncodeToString(id.PrivateKey.Bytes()), + PublicKey: hex.EncodeToString(id.PublicKey.Bytes()), + }, + } + + data, _ := json.Marshal(idFile) + keyPath := filepath.Join(tmpDir, "identity.json") + os.WriteFile(keyPath, data, 0600) + + // Load it + loaded, err := Load(keyPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if loaded.Username != id.Username { + t.Errorf("Username mismatch") + } +} + +func TestLoad_SignKeyPairFormat(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "noshitalk-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create JSON with SignKeyPair instead of EncryptKeyPair + id, _ := New("test") + idFile := IdentityFile{ + Version: IdentityVersion, + Username: id.Username, + Fingerprint: id.Fingerprint, + SignKeyPair: KeyPair{ + PrivateKey: hex.EncodeToString(id.PrivateKey.Bytes()), + PublicKey: hex.EncodeToString(id.PublicKey.Bytes()), + }, + } + + data, _ := json.Marshal(idFile) + keyPath := filepath.Join(tmpDir, "sign-format.json") + os.WriteFile(keyPath, data, 0600) + + // Should still load + loaded, err := Load(keyPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if loaded.Fingerprint != id.Fingerprint { + t.Errorf("Fingerprint mismatch") + } +} + +func TestLoad_Errors(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "noshitalk-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + tests := []struct { + name string + content []byte + wantErr bool + }{ + { + name: "nonexistent file", + content: nil, // Don't create file + wantErr: true, + }, + { + name: "invalid json", + content: []byte("not json at all"), + wantErr: true, + }, + { + name: "empty json", + content: []byte("{}"), + wantErr: true, // No private key + }, + { + name: "invalid hex in private key", + content: []byte(`{"encryptKeyPair":{"privateKey":"not-hex"}}`), + wantErr: true, + }, + { + name: "wrong size raw key", + content: make([]byte, 16), // Should be 32 + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + keyPath := filepath.Join(tmpDir, tt.name+".key") + + if tt.content != nil { + os.WriteFile(keyPath, tt.content, 0600) + } + + _, err := Load(keyPath) + if (err != nil) != tt.wantErr { + t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestLoadOrCreate_ExistingFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "noshitalk-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + keyPath := filepath.Join(tmpDir, "identity.noshikey") + + // Create identity first + original, _ := New("test") + original.Save(keyPath) + + // LoadOrCreate should load existing + loaded, err := LoadOrCreate(keyPath, "test") + if err != nil { + t.Fatalf("LoadOrCreate() error = %v", err) + } + + if loaded.Fingerprint != original.Fingerprint { + t.Error("LoadOrCreate should load existing file, not create new") + } +} + +func TestLoadOrCreate_NewFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "noshitalk-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + keyPath := filepath.Join(tmpDir, "new-identity.noshikey") + + // File doesn't exist - should create + created, err := LoadOrCreate(keyPath, "new") + if err != nil { + t.Fatalf("LoadOrCreate() error = %v", err) + } + + if created == nil { + t.Fatal("LoadOrCreate() returned nil identity") + } + + // File should now exist + if _, err := os.Stat(keyPath); os.IsNotExist(err) { + t.Error("LoadOrCreate should create file if not exists") + } + + // Loading again should get same identity + loaded, _ := Load(keyPath) + if loaded.Fingerprint != created.Fingerprint { + t.Error("Saved and loaded fingerprints don't match") + } +} + +func TestDeriveSharedIdentity(t *testing.T) { + // Same seed should produce same identity + id1, err := DeriveSharedIdentity("my-secret-seed", "derived") + if err != nil { + t.Fatalf("DeriveSharedIdentity() error = %v", err) + } + + id2, _ := DeriveSharedIdentity("my-secret-seed", "derived") + + if id1.Fingerprint != id2.Fingerprint { + t.Error("Same seed should produce same fingerprint") + } + + // Different seed should produce different identity + id3, _ := DeriveSharedIdentity("different-seed", "derived") + if id1.Fingerprint == id3.Fingerprint { + t.Error("Different seeds should produce different fingerprints") + } +} + +func TestGenerateChallenge(t *testing.T) { + challenge, err := GenerateChallenge() + if err != nil { + t.Fatalf("GenerateChallenge() error = %v", err) + } + + if len(challenge) != 32 { + t.Errorf("Challenge length = %d, want 32", len(challenge)) + } + + // Two challenges should be different + challenge2, _ := GenerateChallenge() + if string(challenge) == string(challenge2) { + t.Error("Challenges should be random") + } +} + +func TestIdentityFile_JSONRoundTrip(t *testing.T) { + original := IdentityFile{ + Version: IdentityVersion, + Username: "testuser", + Fingerprint: "abc123def456", + EncryptKeyPair: KeyPair{ + PrivateKey: "0102030405060708", + PublicKey: "09101112131415", + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("Marshal error = %v", err) + } + + var loaded IdentityFile + if err := json.Unmarshal(data, &loaded); err != nil { + t.Fatalf("Unmarshal error = %v", err) + } + + if loaded.Username != original.Username { + t.Errorf("Username mismatch") + } + if loaded.Version != original.Version { + t.Errorf("Version mismatch") + } +} + +func TestSave_CreatesDirectory(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "noshitalk-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Path with non-existent subdirectory + keyPath := filepath.Join(tmpDir, "subdir", "deep", "identity.noshikey") + + id, _ := New("test") + err = id.Save(keyPath) + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Should have created directories + if _, err := os.Stat(keyPath); os.IsNotExist(err) { + t.Error("Save should create parent directories") + } +} + +// Benchmark +func BenchmarkNew(b *testing.B) { + for i := 0; i < b.N; i++ { + New("bench") + } +} + +func BenchmarkSaveLoad(b *testing.B) { + tmpDir, _ := os.MkdirTemp("", "noshitalk-bench-*") + defer os.RemoveAll(tmpDir) + + id, _ := New("bench") + keyPath := filepath.Join(tmpDir, "bench.key") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + id.Save(keyPath) + Load(keyPath) + } +} diff --git a/pkg/protocol/messages_test.go b/pkg/protocol/messages_test.go new file mode 100644 index 0000000..c6211ea --- /dev/null +++ b/pkg/protocol/messages_test.go @@ -0,0 +1,428 @@ +package protocol + +import ( + "encoding/json" + "testing" + "time" +) + +func TestNewMessage(t *testing.T) { + msg := NewMessage("alice", "bob", "Hello!", TypePrivate) + + if msg.From != "alice" { + t.Errorf("From = %q, want 'alice'", msg.From) + } + if msg.To != "bob" { + t.Errorf("To = %q, want 'bob'", msg.To) + } + if msg.Content != "Hello!" { + t.Errorf("Content = %q, want 'Hello!'", msg.Content) + } + if msg.Type != TypePrivate { + t.Errorf("Type = %q, want %q", msg.Type, TypePrivate) + } + if msg.Time == "" { + t.Error("Time should not be empty") + } +} + +func TestNewSystemMessage(t *testing.T) { + msg := NewSystemMessage("Server restarting") + + if msg.From != "System" { + t.Errorf("From = %q, want 'System'", msg.From) + } + if msg.Content != "Server restarting" { + t.Errorf("Content = %q, want 'Server restarting'", msg.Content) + } + if msg.Type != TypeSystem { + t.Errorf("Type = %q, want %q", msg.Type, TypeSystem) + } +} + +func TestNewErrorMessage(t *testing.T) { + msg := NewErrorMessage("Connection failed") + + if msg.From != "System" { + t.Errorf("From = %q, want 'System'", msg.From) + } + if msg.Type != TypeError { + t.Errorf("Type = %q, want %q", msg.Type, TypeError) + } +} + +func TestNewUserListMessage(t *testing.T) { + users := []string{"alice", "bob", "charlie"} + msg := NewUserListMessage(users) + + if msg.Type != TypeUserList { + t.Errorf("Type = %q, want %q", msg.Type, TypeUserList) + } + if len(msg.Users) != 3 { + t.Errorf("Users count = %d, want 3", len(msg.Users)) + } + if msg.Time == "" { + t.Error("Time should not be empty") + } +} + +func TestMessage_ToJSON(t *testing.T) { + msg := NewMessage("alice", "bob", "Test", TypeMessage) + + data, err := msg.ToJSON() + if err != nil { + t.Fatalf("ToJSON() error = %v", err) + } + + // Should be valid JSON + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("Invalid JSON: %v", err) + } + + if parsed["from"] != "alice" { + t.Errorf("JSON from = %v, want 'alice'", parsed["from"]) + } + if parsed["type"] != TypeMessage { + t.Errorf("JSON type = %v, want %q", parsed["type"], TypeMessage) + } +} + +func TestUserListMessage_ToJSON(t *testing.T) { + msg := NewUserListMessage([]string{"alice", "bob"}) + + data, err := msg.ToJSON() + if err != nil { + t.Fatalf("ToJSON() error = %v", err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("Invalid JSON: %v", err) + } + + if parsed["type"] != TypeUserList { + t.Errorf("JSON type = %v, want %q", parsed["type"], TypeUserList) + } + + users, ok := parsed["users"].([]interface{}) + if !ok || len(users) != 2 { + t.Errorf("JSON users incorrect: %v", parsed["users"]) + } +} + +func TestFileOfferMessage_ToJSON(t *testing.T) { + msg := FileOfferMessage{ + Type: TypeFileOffer, + From: "alice", + To: "bob", + FileID: "abc123", + Filename: "test.txt", + Size: 1024, + TotalChunks: 4, + SHA256: "deadbeef", + Compressed: true, + Time: time.Now().Format("15:04:05"), + } + + data, err := msg.ToJSON() + if err != nil { + t.Fatalf("ToJSON() error = %v", err) + } + + var parsed FileOfferMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("Unmarshal error = %v", err) + } + + if parsed.FileID != "abc123" { + t.Errorf("FileID = %q, want 'abc123'", parsed.FileID) + } + if parsed.Size != 1024 { + t.Errorf("Size = %d, want 1024", parsed.Size) + } + if !parsed.Compressed { + t.Error("Compressed should be true") + } +} + +func TestFileChunkMessage_ToJSON(t *testing.T) { + msg := FileChunkMessage{ + Type: TypeFileChunk, + FileID: "abc123", + ChunkIndex: 2, + TotalChunks: 10, + Data: "base64encodeddata", + Time: time.Now().Format("15:04:05"), + } + + data, err := msg.ToJSON() + if err != nil { + t.Fatalf("ToJSON() error = %v", err) + } + + var parsed FileChunkMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("Unmarshal error = %v", err) + } + + if parsed.ChunkIndex != 2 { + t.Errorf("ChunkIndex = %d, want 2", parsed.ChunkIndex) + } + if parsed.TotalChunks != 10 { + t.Errorf("TotalChunks = %d, want 10", parsed.TotalChunks) + } +} + +func TestFileResponseMessage_ToJSON(t *testing.T) { + msg := FileResponseMessage{ + Type: TypeFileAccept, + FileID: "abc123", + From: "bob", + Status: "accepted", + Message: "Transfer starting", + Time: time.Now().Format("15:04:05"), + } + + data, err := msg.ToJSON() + if err != nil { + t.Fatalf("ToJSON() error = %v", err) + } + + var parsed FileResponseMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("Unmarshal error = %v", err) + } + + if parsed.Status != "accepted" { + t.Errorf("Status = %q, want 'accepted'", parsed.Status) + } +} + +func TestKeyRotationMessage_ToJSON(t *testing.T) { + msg := KeyRotationMessage{ + Type: TypeKeyRotation, + PublicKey: []byte{1, 2, 3, 4}, + Nonce: []byte{5, 6, 7, 8}, + Signature: []byte{9, 10, 11, 12}, + Time: time.Now().Format("15:04:05"), + } + + data, err := msg.ToJSON() + if err != nil { + t.Fatalf("ToJSON() error = %v", err) + } + + var parsed KeyRotationMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("Unmarshal error = %v", err) + } + + if len(parsed.PublicKey) != 4 { + t.Errorf("PublicKey length = %d, want 4", len(parsed.PublicKey)) + } +} + +func TestParseMessage(t *testing.T) { + tests := []struct { + name string + input string + wantType string + wantErr bool + }{ + { + name: "message type", + input: `{"type":"message","from":"alice","content":"hi"}`, + wantType: TypeMessage, + }, + { + name: "private type", + input: `{"type":"private","from":"alice","to":"bob"}`, + wantType: TypePrivate, + }, + { + name: "system type", + input: `{"type":"system","content":"welcome"}`, + wantType: TypeSystem, + }, + { + name: "no type field", + input: `{"from":"alice","content":"hi"}`, + wantType: TypeMessage, // Default + }, + { + name: "invalid json", + input: `not json`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msgType, data, err := ParseMessage([]byte(tt.input)) + if (err != nil) != tt.wantErr { + t.Errorf("ParseMessage() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil { + return + } + if msgType != tt.wantType { + t.Errorf("ParseMessage() type = %q, want %q", msgType, tt.wantType) + } + if data == nil { + t.Error("ParseMessage() data should not be nil") + } + }) + } +} + +func TestParseAsMessage(t *testing.T) { + input := `{"type":"message","from":"alice","to":"bob","content":"hello","time":"12:34:56"}` + + msg, err := ParseAsMessage([]byte(input)) + if err != nil { + t.Fatalf("ParseAsMessage() error = %v", err) + } + + if msg.From != "alice" { + t.Errorf("From = %q, want 'alice'", msg.From) + } + if msg.To != "bob" { + t.Errorf("To = %q, want 'bob'", msg.To) + } + if msg.Content != "hello" { + t.Errorf("Content = %q, want 'hello'", msg.Content) + } +} + +func TestParseAsUserList(t *testing.T) { + input := `{"type":"user_list","users":["alice","bob","charlie"],"time":"12:34:56"}` + + msg, err := ParseAsUserList([]byte(input)) + if err != nil { + t.Fatalf("ParseAsUserList() error = %v", err) + } + + if msg.Type != TypeUserList { + t.Errorf("Type = %q, want %q", msg.Type, TypeUserList) + } + if len(msg.Users) != 3 { + t.Errorf("Users count = %d, want 3", len(msg.Users)) + } +} + +func TestParseAsFileOffer(t *testing.T) { + input := `{"type":"file_offer","from":"alice","to":"bob","file_id":"abc","filename":"test.txt","size":100,"total_chunks":1}` + + msg, err := ParseAsFileOffer([]byte(input)) + if err != nil { + t.Fatalf("ParseAsFileOffer() error = %v", err) + } + + if msg.FileID != "abc" { + t.Errorf("FileID = %q, want 'abc'", msg.FileID) + } + if msg.Filename != "test.txt" { + t.Errorf("Filename = %q, want 'test.txt'", msg.Filename) + } +} + +func TestParseAsFileChunk(t *testing.T) { + input := `{"type":"file_chunk","file_id":"abc","chunk_index":5,"total_chunks":10,"data":"base64"}` + + msg, err := ParseAsFileChunk([]byte(input)) + if err != nil { + t.Fatalf("ParseAsFileChunk() error = %v", err) + } + + if msg.ChunkIndex != 5 { + t.Errorf("ChunkIndex = %d, want 5", msg.ChunkIndex) + } +} + +func TestParseAsFileResponse(t *testing.T) { + input := `{"type":"file_accept","file_id":"abc","from":"bob","status":"accepted","message":"ok"}` + + msg, err := ParseAsFileResponse([]byte(input)) + if err != nil { + t.Fatalf("ParseAsFileResponse() error = %v", err) + } + + if msg.Status != "accepted" { + t.Errorf("Status = %q, want 'accepted'", msg.Status) + } +} + +func TestParseAs_InvalidJSON(t *testing.T) { + invalid := []byte("not json") + + _, err := ParseAsMessage(invalid) + if err == nil { + t.Error("ParseAsMessage should fail on invalid JSON") + } + + _, err = ParseAsUserList(invalid) + if err == nil { + t.Error("ParseAsUserList should fail on invalid JSON") + } + + _, err = ParseAsFileOffer(invalid) + if err == nil { + t.Error("ParseAsFileOffer should fail on invalid JSON") + } + + _, err = ParseAsFileChunk(invalid) + if err == nil { + t.Error("ParseAsFileChunk should fail on invalid JSON") + } + + _, err = ParseAsFileResponse(invalid) + if err == nil { + t.Error("ParseAsFileResponse should fail on invalid JSON") + } +} + +// Test message type constants +func TestMessageTypeConstants(t *testing.T) { + types := map[string]string{ + "TypeMessage": TypeMessage, + "TypePrivate": TypePrivate, + "TypeSystem": TypeSystem, + "TypeError": TypeError, + "TypeUserList": TypeUserList, + "TypeUserJoin": TypeUserJoin, + "TypeUserLeave": TypeUserLeave, + "TypeHandshake": TypeHandshake, + "TypePublic": TypePublic, + "TypeKeyRotation": TypeKeyRotation, + "TypeFileOffer": TypeFileOffer, + "TypeFileChunk": TypeFileChunk, + "TypeFileAccept": TypeFileAccept, + "TypeFileReject": TypeFileReject, + "TypeFileComplete": TypeFileComplete, + } + + for name, value := range types { + if value == "" { + t.Errorf("%s should not be empty", name) + } + } +} + +// Benchmark +func BenchmarkMessage_ToJSON(b *testing.B) { + msg := NewMessage("alice", "bob", "Test message content", TypeMessage) + b.ResetTimer() + for i := 0; i < b.N; i++ { + msg.ToJSON() + } +} + +func BenchmarkParseMessage(b *testing.B) { + data := []byte(`{"type":"message","from":"alice","to":"bob","content":"hello","time":"12:34:56"}`) + b.ResetTimer() + for i := 0; i < b.N; i++ { + ParseMessage(data) + } +} diff --git a/pkg/tor/tor_test.go b/pkg/tor/tor_test.go new file mode 100644 index 0000000..b6411e0 --- /dev/null +++ b/pkg/tor/tor_test.go @@ -0,0 +1,282 @@ +package tor + +import ( + "strings" + "testing" +) + +func TestValidateOnionAddress(t *testing.T) { + tests := []struct { + name string + addr string + wantErr bool + }{ + // Valid v3 .onion addresses + { + name: "valid v3 onion with port", + addr: "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion:8080", + wantErr: false, + }, + { + name: "valid v3 onion without port", + addr: "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion", + wantErr: false, + }, + { + name: "valid v3 onion lowercase", + addr: "vwxyz234567abcdefghijklmnopqrstuvwxyz234567abcdefghijk.onion:443", + wantErr: false, + }, + + // Invalid addresses + { + name: "empty", + addr: "", + wantErr: true, + }, + { + name: "clearnet domain", + addr: "example.com:8080", + wantErr: true, + }, + { + name: "clearnet IP", + addr: "192.168.1.1:8080", + wantErr: true, + }, + { + name: "v2 onion (too short)", + addr: "abcdefghijklmnop.onion:8080", + wantErr: true, + }, + { + name: "uppercase not allowed", + addr: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUV.onion", + wantErr: true, + }, + { + name: "invalid characters", + addr: "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrst01.onion", + wantErr: true, // 0 and 1 not in base32 + }, + { + name: "too long", + addr: "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz.onion", + wantErr: true, + }, + { + name: "missing .onion", + addr: "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv", + wantErr: true, + }, + { + name: "invalid port", + addr: "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion:999999", + wantErr: true, + }, + { + name: "localhost", + addr: "localhost:8080", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateOnionAddress(tt.addr) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateOnionAddress(%q) error = %v, wantErr %v", tt.addr, err, tt.wantErr) + } + }) + } +} + +func TestValidateOnionAddress_ErrorMessages(t *testing.T) { + tests := []struct { + addr string + errContains string + }{ + {"", "empty"}, + {"example.com", ".onion"}, + {"short.onion", "invalid"}, + } + + for _, tt := range tests { + err := ValidateOnionAddress(tt.addr) + if err == nil { + t.Errorf("Expected error for %q", tt.addr) + continue + } + if !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tt.errContains)) { + t.Errorf("Error %q should contain %q", err.Error(), tt.errContains) + } + } +} + +func TestOnionRegex(t *testing.T) { + // Valid patterns + validAddrs := []string{ + "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion", + "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion:80", + "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion:8080", + "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion:65535", + } + + for _, addr := range validAddrs { + if !OnionRegex.MatchString(addr) { + t.Errorf("OnionRegex should match %q", addr) + } + } + + // Invalid patterns + invalidAddrs := []string{ + "tooshort.onion", + "example.com", + "127.0.0.1:8080", + "UPPERCASE.onion", + } + + for _, addr := range invalidAddrs { + if OnionRegex.MatchString(addr) { + t.Errorf("OnionRegex should NOT match %q", addr) + } + } +} + +func TestDefaultProxyAddresses(t *testing.T) { + if len(DefaultProxyAddresses) == 0 { + t.Error("DefaultProxyAddresses should not be empty") + } + + for _, addr := range DefaultProxyAddresses { + if !strings.HasPrefix(addr, "socks5://") { + t.Errorf("Proxy address should start with socks5://: %s", addr) + } + } +} + +func TestNewDialer(t *testing.T) { + dialer := NewDialer() + + if dialer == nil { + t.Fatal("NewDialer() returned nil") + } + + if len(dialer.ProxyAddresses) == 0 { + t.Error("Dialer should have default proxy addresses") + } + + if dialer.Timeout == 0 { + t.Error("Dialer should have default timeout") + } + + if dialer.KeepAlive == 0 { + t.Error("Dialer should have default keepalive") + } +} + +func TestDialer_CustomSettings(t *testing.T) { + dialer := NewDialer() + dialer.ProxyAddresses = []string{"socks5://custom:1234"} + + if len(dialer.ProxyAddresses) != 1 { + t.Error("Should allow custom proxy addresses") + } +} + +// Note: These tests don't actually connect to Tor (that would require Tor running) +// They just test the validation and configuration logic + +func TestConnect_InvalidAddress(t *testing.T) { + _, err := Connect("invalid-address", nil) + if err == nil { + t.Error("Connect should fail with invalid address") + } +} + +func TestConnect_EmptyAddress(t *testing.T) { + _, err := Connect("", nil) + if err == nil { + t.Error("Connect should fail with empty address") + } +} + +func TestDialer_Dial_InvalidAddress(t *testing.T) { + dialer := NewDialer() + _, err := dialer.Dial("not-an-onion") + if err == nil { + t.Error("Dial should fail with invalid address") + } +} + +func TestConnectWithCallback_InvalidAddress(t *testing.T) { + var progressMessages []string + callback := func(msg string) { + progressMessages = append(progressMessages, msg) + } + + _, err := ConnectWithCallback("invalid", nil, callback) + if err == nil { + t.Error("ConnectWithCallback should fail with invalid address") + } +} + +func TestTestProxyAvailable_InvalidURL(t *testing.T) { + err := TestProxyAvailable("not-a-valid-url") + if err == nil { + t.Error("TestProxyAvailable should fail with invalid URL") + } +} + +func TestTestProxyAvailable_NotRunning(t *testing.T) { + // This proxy shouldn't exist + err := TestProxyAvailable("socks5://127.0.0.1:59999") + if err == nil { + t.Error("TestProxyAvailable should fail when proxy not running") + } +} + +func TestDialer_IsAvailable_NoProxy(t *testing.T) { + dialer := &Dialer{ + ProxyAddresses: []string{"socks5://127.0.0.1:59999"}, // Non-existent + } + + if dialer.IsAvailable() { + t.Error("IsAvailable should return false when no proxy running") + } +} + +// Test constants +func TestConstants(t *testing.T) { + if DefaultConnectionTimeout == 0 { + t.Error("DefaultConnectionTimeout should not be zero") + } + if DefaultKeepAlive == 0 { + t.Error("DefaultKeepAlive should not be zero") + } + if ProxyTestTimeout == 0 { + t.Error("ProxyTestTimeout should not be zero") + } +} + +// Benchmark validation +func BenchmarkValidateOnionAddress_Valid(b *testing.B) { + addr := "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion:8080" + for i := 0; i < b.N; i++ { + ValidateOnionAddress(addr) + } +} + +func BenchmarkValidateOnionAddress_Invalid(b *testing.B) { + addr := "example.com" + for i := 0; i < b.N; i++ { + ValidateOnionAddress(addr) + } +} + +func BenchmarkOnionRegex_Match(b *testing.B) { + addr := "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion:8080" + for i := 0; i < b.N; i++ { + OnionRegex.MatchString(addr) + } +} |
