summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Makefile183
-rw-r--r--README.md405
-rw-r--r--noshitalk-client.go947
-rw-r--r--noshitalk-server.go1005
-rw-r--r--noshitalk-web-client.go851
-rw-r--r--noshitalk_cli-client.go869
-rw-r--r--pkg/crypto/crypto_test.go440
-rw-r--r--pkg/identity/identity_test.go426
-rw-r--r--pkg/protocol/messages_test.go428
-rw-r--r--pkg/tor/tor_test.go282
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"
diff --git a/README.md b/README.md
index 47972ee..536ac7a 100644
--- a/README.md
+++ b/README.md
@@ -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)
+ }
+}