Skip to content

Commit 11362de

Browse files
committed
feat: add HMAC challenge/response authentication for relay
- Implement HMAC‑SHA256 challenge/response for relay registration and agent connections, rejecting unauthenticated peers. - Add deadline‑based timeout to protect against hanging handshakes and replay attacks. - Refactor secure handshake to use per‑connection random challenge instead of a static string. - Update README with detailed security guidance and replay protection notes. - Introduce extensive unit tests for relay authentication and secure connection round‑trip. - Bump Go version to 1.25 and clean up .gitignore.
1 parent b2ccf4b commit 11362de

10 files changed

Lines changed: 325 additions & 16 deletions

File tree

.gitignore

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,3 @@
66

77
# ignore vscode workspace files
88
.vscode/
9-
10-
# ignore test files
11-
*_test.go

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Agent Activity Log
2+
3+
| Date (UTC) | Agent | Summary |
4+
| --- | --- | --- |
5+
| 2025-11-03 | Code | Reviewed tunnel handshake, added HMAC challenge/response (including relay auth), expanded regression tests, refreshed security guidance, and updated Go module metadata. |

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,11 @@ tunnel_addr: proxy.host:9000
156156

157157
## Security
158158

159-
- Uses AES-CTR with separate IVs for encrypt/decrypt.
160-
- HMAC-SHA256 handshake to authenticate peers.
159+
- Uses AES-CTR with separate IVs for encrypt/decrypt and fresh IVs per connection.
160+
- Performs an HMAC-SHA256 challenge/response handshake to authenticate peers and block replay attempts.
161161
- High-entropy ciphertext; no plaintext leaks over the tunnel.
162+
- Provide a high-entropy shared secret (at least 32 random bytes) and rotate it periodically.
163+
- Relay registrations and agent attachments complete a pre-tunnel HMAC challenge so only trusted peers can bind to the relay.
162164

163165
## License
164166

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/lonepie/reverse-soxy
22

3-
go 1.24
3+
go 1.25
44

55
require (
66
github.com/fatih/color v1.13.0

internal/proxy/agent.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ func RunAgentRelay(relayAddr, secret string, maxRetries int) {
5353
time.Sleep(5 * time.Second)
5454
continue
5555
}
56+
if err := answerRelayChallenge(rawConn, secret); err != nil {
57+
rawConn.Close()
58+
logger.Error("AgentRelay auth failed: %v", err)
59+
time.Sleep(5 * time.Second)
60+
continue
61+
}
5662
// secure handshake
5763
secureConn, err := NewSecureClientConn(rawConn, secret)
5864
if err != nil {

internal/proxy/proxy.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,9 @@ func RunProxyRelay(relayAddr string, socksAddr string, secret string) {
336336
if _, err := rawConn.Write([]byte("REGISTER")); err != nil {
337337
logger.Fatalf("Register header send error: %v", err)
338338
}
339+
if err := answerRelayChallenge(rawConn, secret); err != nil {
340+
logger.Fatalf("Relay auth failed: %v", err)
341+
}
339342
// secure handshake as server
340343
secureConn, err := NewSecureServerConn(rawConn, secret)
341344
if err != nil {

internal/proxy/relay.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package proxy
22

33
import (
4+
"crypto/hmac"
5+
"crypto/rand"
6+
"crypto/sha256"
47
"fmt"
58
"io"
69
"net"
710
"strings"
811
"sync"
12+
"time"
913

1014
"github.com/lonepie/reverse-soxy/internal/logger"
1115
)
@@ -43,14 +47,79 @@ func handleRelayConn(conn net.Conn, secret string) {
4347
header := strings.TrimSpace(string(hdr))
4448
switch header {
4549
case "REGISTER":
50+
if err := verifyRelayPeer(conn, secret); err != nil {
51+
logger.Error("Relay REGISTER auth failed: %v", err)
52+
return
53+
}
4654
registerProxy(conn)
4755
case "AGENT":
56+
if err := verifyRelayPeer(conn, secret); err != nil {
57+
logger.Error("Relay AGENT auth failed: %v", err)
58+
return
59+
}
4860
handleAgent(conn)
4961
default:
5062
logger.Error("Unknown relay header: %s", header)
5163
}
5264
}
5365

66+
const relayAuthTimeout = 10 * time.Second
67+
68+
func verifyRelayPeer(conn net.Conn, secret string) error {
69+
if secret == "" {
70+
return errAuthFailed
71+
}
72+
if err := conn.SetDeadline(time.Now().Add(relayAuthTimeout)); err != nil {
73+
return err
74+
}
75+
defer conn.SetDeadline(time.Time{})
76+
77+
challenge := make([]byte, handshakeChallengeSize)
78+
if _, err := rand.Read(challenge); err != nil {
79+
return err
80+
}
81+
if _, err := conn.Write(challenge); err != nil {
82+
return err
83+
}
84+
85+
expected := hmac.New(sha256.New, deriveKey(secret))
86+
expected.Write(challenge)
87+
88+
response := make([]byte, expected.Size())
89+
if _, err := io.ReadFull(conn, response); err != nil {
90+
return err
91+
}
92+
if !hmac.Equal(response, expected.Sum(nil)) {
93+
return errAuthFailed
94+
}
95+
96+
return nil
97+
}
98+
99+
func answerRelayChallenge(conn net.Conn, secret string) error {
100+
if secret == "" {
101+
return errAuthFailed
102+
}
103+
if err := conn.SetDeadline(time.Now().Add(relayAuthTimeout)); err != nil {
104+
return err
105+
}
106+
defer conn.SetDeadline(time.Time{})
107+
108+
challenge := make([]byte, handshakeChallengeSize)
109+
if _, err := io.ReadFull(conn, challenge); err != nil {
110+
return err
111+
}
112+
113+
mac := hmac.New(sha256.New, deriveKey(secret))
114+
mac.Write(challenge)
115+
116+
if _, err := conn.Write(mac.Sum(nil)); err != nil {
117+
return err
118+
}
119+
120+
return nil
121+
}
122+
54123
func registerProxy(conn net.Conn) {
55124
regMu.Lock()
56125
registry = append(registry, conn)
@@ -117,6 +186,9 @@ func RunRegister(relayAddr, secret string) {
117186
}
118187
defer conn.Close()
119188
conn.Write([]byte("REGISTER"))
189+
if err := answerRelayChallenge(conn, secret); err != nil {
190+
logger.Fatalf("Register auth failed: %v", err)
191+
}
120192
logger.Info("Registered with relay %s", relayAddr)
121193
select {} // hold open
122194
}

internal/proxy/relay_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package proxy
2+
3+
import (
4+
"errors"
5+
"net"
6+
"testing"
7+
"time"
8+
)
9+
10+
func TestRegisterProxy(t *testing.T) {
11+
// reset registry
12+
regMu.Lock()
13+
registry = nil
14+
regMu.Unlock()
15+
// simulate a proxy registration
16+
c1, c2 := net.Pipe()
17+
// run registerProxy in goroutine to avoid blocking
18+
go registerProxy(c1)
19+
// allow goroutine to append
20+
time.Sleep(10 * time.Millisecond)
21+
regMu.Lock()
22+
if len(registry) != 1 {
23+
t.Fatalf("expected registry length 1, got %d", len(registry))
24+
}
25+
if registry[0] != c1 {
26+
t.Fatalf("expected registry[0] to be c1")
27+
}
28+
// cleanup
29+
registry = nil
30+
regMu.Unlock()
31+
c1.Close()
32+
c2.Close()
33+
}
34+
35+
func TestHandleRelayConnRegister(t *testing.T) {
36+
// reset registry
37+
regMu.Lock()
38+
registry = nil
39+
regMu.Unlock()
40+
client, server := net.Pipe()
41+
// start handler before writing to avoid blocking
42+
go handleRelayConn(server, "secret")
43+
// write REGISTER header (8 bytes)
44+
if _, err := client.Write([]byte("REGISTER")); err != nil {
45+
t.Fatalf("failed to write header: %v", err)
46+
}
47+
if err := answerRelayChallenge(client, "secret"); err != nil {
48+
t.Fatalf("challenge response failed: %v", err)
49+
}
50+
// allow time for processing
51+
time.Sleep(10 * time.Millisecond)
52+
regMu.Lock()
53+
if len(registry) != 1 {
54+
t.Fatalf("expected registry length 1 after handleRelayConn, got %d", len(registry))
55+
}
56+
if registry[0] != server {
57+
t.Fatalf("expected registered conn to be server")
58+
}
59+
t.Log("Registry registered successfully")
60+
regMu.Unlock()
61+
client.Close()
62+
server.Close()
63+
}
64+
65+
func TestVerifyRelayPeerRejectsWrongSecret(t *testing.T) {
66+
server, client := net.Pipe()
67+
defer server.Close()
68+
defer client.Close()
69+
70+
errCh := make(chan error, 1)
71+
go func() {
72+
errCh <- verifyRelayPeer(server, "secret")
73+
}()
74+
75+
if err := answerRelayChallenge(client, "wrong"); err != nil {
76+
t.Fatalf("answerRelayChallenge failed: %v", err)
77+
}
78+
79+
err := <-errCh
80+
if !errors.Is(err, errAuthFailed) {
81+
t.Fatalf("expected errAuthFailed, got %v", err)
82+
}
83+
}
84+
85+
func TestRelayChallengeSuccess(t *testing.T) {
86+
server, client := net.Pipe()
87+
defer server.Close()
88+
defer client.Close()
89+
90+
errCh := make(chan error, 1)
91+
go func() {
92+
errCh <- verifyRelayPeer(server, "secret")
93+
}()
94+
95+
if err := answerRelayChallenge(client, "secret"); err != nil {
96+
t.Fatalf("answerRelayChallenge failed: %v", err)
97+
}
98+
99+
if err := <-errCh; err != nil {
100+
t.Fatalf("verifyRelayPeer returned error: %v", err)
101+
}
102+
}

internal/proxy/secure.go

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import (
1414

1515
var errAuthFailed = errors.New("authentication failed")
1616

17+
const handshakeTimeout = 2 * time.Minute
18+
const handshakeChallengeSize = sha256.Size
19+
1720
func deriveKey(secret string) []byte {
1821
h := sha256.Sum256([]byte(secret))
1922
return h[:]
@@ -36,9 +39,15 @@ func (s *secureConn) Write(p []byte) (int, error) {
3639
// NewSecureClientConn performs HMAC auth and AES-CTR encryption on a client-side tunnel connection
3740
func NewSecureClientConn(conn net.Conn, secret string) (net.Conn, error) {
3841
key := deriveKey(secret)
39-
// send HMAC handshake
42+
conn.SetDeadline(time.Now().Add(handshakeTimeout))
43+
defer conn.SetDeadline(time.Time{})
44+
// read server challenge and respond with HMAC to prevent replay
45+
challenge := make([]byte, handshakeChallengeSize)
46+
if _, err := io.ReadFull(conn, challenge); err != nil {
47+
return nil, err
48+
}
4049
mac := hmac.New(sha256.New, key)
41-
mac.Write([]byte("handshake"))
50+
mac.Write(challenge)
4251
if _, err := conn.Write(mac.Sum(nil)); err != nil {
4352
return nil, err
4453
}
@@ -62,8 +71,6 @@ func NewSecureClientConn(conn net.Conn, secret string) (net.Conn, error) {
6271
if _, err := conn.Write(ivDec); err != nil {
6372
return nil, err
6473
}
65-
// clear deadlines after handshake
66-
conn.SetDeadline(time.Time{})
6774
// create independent CTR streams
6875
enc := cipher.NewCTR(block, ivEnc)
6976
dec := cipher.NewCTR(block, ivDec)
@@ -73,15 +80,23 @@ func NewSecureClientConn(conn net.Conn, secret string) (net.Conn, error) {
7380
// NewSecureServerConn performs HMAC auth and AES-CTR encryption on a server-side tunnel connection
7481
func NewSecureServerConn(conn net.Conn, secret string) (net.Conn, error) {
7582
key := deriveKey(secret)
76-
// read HMAC handshake
77-
expectedMacLen := sha256.Size
78-
bufMac := make([]byte, expectedMacLen)
79-
if _, err := io.ReadFull(conn, bufMac); err != nil {
83+
conn.SetDeadline(time.Now().Add(handshakeTimeout))
84+
defer conn.SetDeadline(time.Time{})
85+
// send challenge to defend against replayed handshakes
86+
challenge := make([]byte, handshakeChallengeSize)
87+
if _, err := rand.Read(challenge); err != nil {
88+
return nil, err
89+
}
90+
if _, err := conn.Write(challenge); err != nil {
8091
return nil, err
8192
}
8293
mac := hmac.New(sha256.New, key)
83-
mac.Write([]byte("handshake"))
84-
if !hmac.Equal(bufMac, mac.Sum(nil)) {
94+
mac.Write(challenge)
95+
recvMac := make([]byte, mac.Size())
96+
if _, err := io.ReadFull(conn, recvMac); err != nil {
97+
return nil, err
98+
}
99+
if !hmac.Equal(recvMac, mac.Sum(nil)) {
85100
return nil, errAuthFailed
86101
}
87102
// read IVs from client

0 commit comments

Comments
 (0)