Skip to content

Commit a1f4f58

Browse files
committed
Initial commit
0 parents  commit a1f4f58

File tree

8 files changed

+471
-0
lines changed

8 files changed

+471
-0
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/input-only-proxy
2+
/examples/*.pem
3+
/examples/client-ssh*
4+
/examples/*.sock

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Flashbots
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# input-only-proxy
2+
3+
A TLS proxy that accepts input-only data from clients authenticated via SSH ed25519 keys.
4+
5+
## Use Case
6+
7+
You have a server where clients already have trusted SSH key. You want to accept input-only data from those same identity over TLS, without giving them shell access or any output channel.
8+
9+
## How It Works
10+
11+
1. Client's SSH ed25519 key is converted to a TLS certificate using `ssh2cert`
12+
2. Proxy accepts TLS connections, verifying client cert matches the trusted SSH public key
13+
3. Data flows one-way: TLS client -> Unix domain socket (no data flows back)
14+
4. Only one connection allowed at a time; new connections close previous ones
15+
16+
## Timing Side-Channel Resistance
17+
18+
The proxy uses a buffered channel between TLS reads and UDS writes. If the UDS consumer is slow (buffer fills), the client is immediately disconnected rather than blocking TLS reads. This prevents timing information about the UDS consumer from leaking back to the external TLS client.
19+
20+
## Usage
21+
22+
### 1. Convert SSH key to TLS certificate
23+
24+
```bash
25+
# Generate client SSH key (if you don't have one)
26+
ssh-keygen -t ed25519 -f client-ssh -N ""
27+
28+
# Convert to TLS cert/key pair
29+
go run ./ssh2cert/ client-ssh client-key.pem client-cert.pem
30+
```
31+
32+
### 2. Start the proxy
33+
34+
```bash
35+
# Generate server certificate
36+
openssl req -x509 -newkey ed25519 -keyout server-key.pem -out server-cert.pem \
37+
-days 365 -nodes -subj "/CN=localhost"
38+
39+
# Run proxy
40+
go run . \
41+
-cert server-cert.pem \
42+
-key server-key.pem \
43+
-client-key client-ssh.pub \
44+
-listen 127.0.0.1:8443 \
45+
-socket /path/to/app.sock
46+
```
47+
48+
### 3. Connect with openssl s_client
49+
50+
```bash
51+
echo "Hello world" | openssl s_client \
52+
-connect 127.0.0.1:8443 \
53+
-cert client-cert.pem \
54+
-key client-key.pem \
55+
-quiet
56+
```
57+
58+
## Flags
59+
60+
| Flag | Description |
61+
|------|-------------|
62+
| `-cert` | Server TLS certificate file |
63+
| `-key` | Server TLS private key file |
64+
| `-client-key` | Client SSH ed25519 public key file |
65+
| `-listen` | Address to listen on (default `:8443`) |
66+
| `-socket` | Unix domain socket path |
67+
| `-buffer` | Buffer size in messages (default 1024) |
68+
| `-v` | Verbose logging |

examples/demo.sh

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/bin/bash
2+
set -ex
3+
4+
cleanup() {
5+
echo "Cleaning up..."
6+
[ -n "$PROXY_PID" ] && kill "$PROXY_PID" 2>/dev/null || true
7+
[ -n "$SOCAT_PID" ] && kill "$SOCAT_PID" 2>/dev/null || true
8+
9+
rm -f server-cert.pem server-key.pem \
10+
client-ssh client-ssh.pub \
11+
client-cert.pem client-key.pem \
12+
proxy.sock
13+
}
14+
trap cleanup EXIT
15+
16+
echo "=== Generating server certificate ==="
17+
openssl req -x509 -newkey ed25519 -keyout server-key.pem -out server-cert.pem \
18+
-days 365 -nodes -subj "/CN=localhost" 2>/dev/null
19+
20+
echo "=== Generating client SSH key ==="
21+
ssh-keygen -t ed25519 -f client-ssh -N "" -q
22+
23+
echo "=== Converting client SSH key to PEM ==="
24+
go run ../ssh2cert/ client-ssh client-key.pem client-cert.pem
25+
26+
SOCKET_PATH="proxy.sock"
27+
28+
echo "=== Starting proxy ==="
29+
go run ../ \
30+
-cert server-cert.pem \
31+
-key server-key.pem \
32+
-client-key client-ssh.pub \
33+
-listen 127.0.0.1:8443 \
34+
-socket "$SOCKET_PATH" \
35+
-v &
36+
PROXY_PID=$!
37+
sleep 0.5
38+
39+
echo "=== Starting socat on UDS ==="
40+
socat -v -v -u UNIX-LISTEN:"$SOCKET_PATH",fork /dev/null &
41+
SOCAT_PID=$!
42+
sleep 0.5
43+
44+
# Send data through the proxy using openssl s_client
45+
echo "=== Sending data through TLS connection ==="
46+
echo "Hello world" | openssl s_client \
47+
-connect 127.0.0.1:8443 \
48+
-cert client-cert.pem \
49+
-key client-key.pem \
50+
-quiet \
51+
2>/dev/null || true &
52+
sleep 0.1
53+
kill $! || true # openssl doesn't alays close connection after EOF for some reason
54+
55+
echo "Press any key to continue..."
56+
read

go.mod

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module input-only-proxy
2+
3+
go 1.21
4+
5+
require (
6+
golang.org/x/crypto v0.31.0
7+
golang.org/x/term v0.27.0
8+
)
9+
10+
require golang.org/x/sys v0.28.0 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
2+
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
3+
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
4+
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
5+
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
6+
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=

main.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"crypto/ed25519"
6+
"crypto/tls"
7+
"crypto/x509"
8+
"errors"
9+
"flag"
10+
"io"
11+
"log"
12+
"net"
13+
"os"
14+
"sync"
15+
16+
"golang.org/x/crypto/ssh"
17+
)
18+
19+
var verbose bool
20+
21+
func parseSSHPubKey(path string) (ed25519.PublicKey, error) {
22+
type sshPubKey struct {
23+
Algo string
24+
Data []byte
25+
}
26+
27+
data, err := os.ReadFile(path)
28+
if err != nil {
29+
return nil, err
30+
}
31+
32+
pub, _, _, _, err := ssh.ParseAuthorizedKey(data)
33+
if err != nil {
34+
return nil, err
35+
}
36+
37+
var key sshPubKey
38+
if err := ssh.Unmarshal(pub.Marshal(), &key); err != nil {
39+
return nil, err
40+
}
41+
42+
if key.Algo != "ssh-ed25519" {
43+
return nil, errors.New("not ed25519")
44+
}
45+
46+
return ed25519.PublicKey(key.Data), nil
47+
}
48+
49+
var (
50+
activeConn net.Conn
51+
activeConnMu sync.Mutex
52+
)
53+
54+
func main() {
55+
certFile := flag.String("cert", "", "Server TLS certificate file")
56+
keyFile := flag.String("key", "", "Server TLS private key file")
57+
clientKeyFile := flag.String("client-key", "", "Client SSH ed25519 public key file")
58+
listenAddr := flag.String("listen", ":8443", "Address to listen on (host:port)")
59+
socketPath := flag.String("socket", "", "Unix domain socket path")
60+
bufferSize := flag.Int("buffer", 1024, "Buffer size in messages")
61+
flag.BoolVar(&verbose, "v", false, "Verbose logging")
62+
flag.Parse()
63+
64+
if *certFile == "" || *keyFile == "" || *clientKeyFile == "" || *socketPath == "" {
65+
log.Fatal("Required flags: -cert, -key, -client-key, -socket")
66+
}
67+
68+
serverCert, err := tls.LoadX509KeyPair(*certFile, *keyFile)
69+
if err != nil {
70+
log.Fatalf("Failed to load server certificate: %v", err)
71+
}
72+
73+
expectedKey, err := parseSSHPubKey(*clientKeyFile)
74+
if err != nil {
75+
log.Fatalf("Failed to parse client SSH public key: %v", err)
76+
}
77+
log.Printf("Loaded client public key from %s", *clientKeyFile)
78+
79+
tlsConfig := &tls.Config{
80+
Certificates: []tls.Certificate{serverCert},
81+
ClientAuth: tls.RequireAnyClientCert,
82+
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
83+
cert, err := x509.ParseCertificate(rawCerts[0])
84+
if err != nil {
85+
return err
86+
}
87+
if ed, ok := cert.PublicKey.(ed25519.PublicKey); ok && bytes.Equal(ed, expectedKey) {
88+
return nil
89+
}
90+
return errors.New("key mismatch")
91+
},
92+
}
93+
94+
listener, err := tls.Listen("tcp", *listenAddr, tlsConfig)
95+
if err != nil {
96+
log.Fatalf("Failed to listen on %s: %v", *listenAddr, err)
97+
}
98+
defer listener.Close()
99+
100+
log.Printf("Listening on %s, forwarding to %s", *listenAddr, *socketPath)
101+
102+
for {
103+
conn, err := listener.Accept()
104+
if err != nil {
105+
log.Printf("Accept error: %v", err)
106+
continue
107+
}
108+
109+
tlsConn := conn.(*tls.Conn)
110+
if err := tlsConn.Handshake(); err != nil {
111+
log.Printf("TLS handshake failed: %v", err)
112+
conn.Close()
113+
continue
114+
}
115+
116+
activeConnMu.Lock()
117+
if activeConn != nil {
118+
log.Printf("Closing previous connection from %s", activeConn.RemoteAddr())
119+
activeConn.Close()
120+
}
121+
activeConn = conn
122+
activeConnMu.Unlock()
123+
124+
log.Printf("Connection accepted from %s", conn.RemoteAddr())
125+
go handleConnection(conn, *socketPath, *bufferSize)
126+
}
127+
}
128+
129+
func handleConnection(conn net.Conn, socketPath string, bufferSize int) {
130+
defer conn.Close()
131+
defer func() {
132+
activeConnMu.Lock()
133+
if activeConn == conn {
134+
activeConn = nil
135+
}
136+
activeConnMu.Unlock()
137+
}()
138+
139+
udsConn, err := net.Dial("unix", socketPath)
140+
if err != nil {
141+
log.Printf("Failed to connect to UDS %s: %v", socketPath, err)
142+
return
143+
}
144+
defer udsConn.Close()
145+
146+
ch := make(chan []byte, bufferSize)
147+
148+
// Writer goroutine: reads from channel and writes to UDS
149+
var wg sync.WaitGroup
150+
wg.Add(1)
151+
go func() {
152+
defer wg.Done()
153+
for data := range ch {
154+
if verbose {
155+
log.Printf("Writing %d bytes to UDS: %q", len(data), string(data))
156+
}
157+
if _, err := udsConn.Write(data); err != nil {
158+
log.Printf("UDS write error: %v", err)
159+
return
160+
}
161+
}
162+
}()
163+
164+
// Reader: reads from TLS conn and sends to channel (non-blocking)
165+
buf := make([]byte, 1024)
166+
for {
167+
n, err := conn.Read(buf)
168+
if err != nil {
169+
if err != io.EOF {
170+
log.Printf("Read error: %v", err)
171+
}
172+
break
173+
}
174+
175+
data := make([]byte, n)
176+
copy(data, buf[:n])
177+
178+
select {
179+
case ch <- data:
180+
default:
181+
log.Printf("Buffer full, disconnecting client")
182+
conn.Close()
183+
break
184+
}
185+
}
186+
187+
close(ch)
188+
wg.Wait()
189+
log.Printf("Connection closed from %s", conn.RemoteAddr())
190+
}

0 commit comments

Comments
 (0)