diff options
| author | Claude <noreply@anthropic.com> | 2025-11-23 10:38:12 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2025-11-23 10:38:12 +0000 |
| commit | 3d5286365747c7d2418be16544116f67964b8c48 (patch) | |
| tree | dc0040ee69ced3d8578587461e0fe98abd61e2a9 /pkg | |
| parent | 69a75acea56bbff39fa1c81d4813ebcb4a617f06 (diff) | |
| download | noshitalk-3d5286365747c7d2418be16544116f67964b8c48.tar.gz noshitalk-3d5286365747c7d2418be16544116f67964b8c48.tar.xz noshitalk-3d5286365747c7d2418be16544116f67964b8c48.zip | |
Add tests, Makefile, update README, remove duplicates
- Add comprehensive test suites for all shared packages:
- pkg/crypto: padding, encryption, ECDH, auth tests
- pkg/protocol: message serialization tests
- pkg/identity: identity save/load tests
- pkg/tor: onion address validation tests
- Add Makefile with build automation targets
- Update README with new modular architecture docs
- Remove duplicate monolithic .go files from root
(code now organized in cmd/ and pkg/ directories)
Diffstat (limited to 'pkg')
| -rw-r--r-- | pkg/crypto/crypto_test.go | 440 | ||||
| -rw-r--r-- | pkg/identity/identity_test.go | 426 | ||||
| -rw-r--r-- | pkg/protocol/messages_test.go | 428 | ||||
| -rw-r--r-- | pkg/tor/tor_test.go | 282 |
4 files changed, 1576 insertions, 0 deletions
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) + } +} |
