mirror of
https://github.com/MetaCubeX/mihomo.git
synced 2026-10-10 04:03:11 +08:00
fix: survive server-initiated soft reset and rekey for openvpn (#3109)
This commit is contained in:
+41
-21
@@ -61,6 +61,7 @@ type OpenVPNOption struct {
|
||||
PeerInfo map[string]string `proxy:"peer-info,omitempty"`
|
||||
Ping int `proxy:"ping,omitempty"`
|
||||
PingRestart int `proxy:"ping-restart,omitempty"`
|
||||
TranWindow *int `proxy:"tran-window,omitempty"`
|
||||
HandshakeTimeout int `proxy:"handshake-timeout,omitempty"`
|
||||
MTU int `proxy:"mtu,omitempty"`
|
||||
UDP bool `proxy:"udp,omitempty"`
|
||||
@@ -71,36 +72,55 @@ type OpenVPNOption struct {
|
||||
Dns []string `proxy:"dns,omitempty"`
|
||||
}
|
||||
|
||||
func openVPNTransitionWindow(value *int) (time.Duration, bool, error) {
|
||||
if value == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
if *value < 0 {
|
||||
return 0, false, errors.New("openvpn tran-window must be non-negative")
|
||||
}
|
||||
if int64(*value) > int64((time.Duration(1<<63-1))/time.Second) {
|
||||
return 0, false, errors.New("openvpn tran-window is too large")
|
||||
}
|
||||
return time.Duration(*value) * time.Second, true, nil
|
||||
}
|
||||
|
||||
func NewOpenVPN(option OpenVPNOption) (*OpenVPN, error) {
|
||||
if option.HandshakeTimeout < 0 {
|
||||
return nil, errors.New("openvpn handshake timeout must be non-negative")
|
||||
}
|
||||
transitionWindow, transitionWindowSet, err := openVPNTransitionWindow(option.TranWindow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
option.IPStack.normalize()
|
||||
if err := option.IPStack.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &ovpn.ClientConfig{
|
||||
RemoteHost: option.Server,
|
||||
RemotePort: uint16(option.Port),
|
||||
Proto: option.Proto,
|
||||
Dev: option.Dev,
|
||||
Cipher: option.Cipher,
|
||||
DataCiphers: option.DataCiphers,
|
||||
FallbackCipher: option.DataCipherFallback,
|
||||
Auth: option.Auth,
|
||||
CompLZO: option.CompLZO,
|
||||
CA: []byte(option.CA),
|
||||
Cert: []byte(option.Cert),
|
||||
Key: []byte(option.Key),
|
||||
TLSAuth: []byte(option.TLSAuth),
|
||||
KeyDirection: option.KeyDirection,
|
||||
TLSCrypt: []byte(option.TLSCrypt),
|
||||
TLSCryptV2: []byte(option.TLSCryptV2),
|
||||
Username: option.Username,
|
||||
Password: option.Password,
|
||||
PeerInfo: option.PeerInfo,
|
||||
PingInterval: time.Duration(option.Ping) * time.Second,
|
||||
PingRestart: time.Duration(option.PingRestart) * time.Second,
|
||||
RemoteHost: option.Server,
|
||||
RemotePort: uint16(option.Port),
|
||||
Proto: option.Proto,
|
||||
Dev: option.Dev,
|
||||
Cipher: option.Cipher,
|
||||
DataCiphers: option.DataCiphers,
|
||||
FallbackCipher: option.DataCipherFallback,
|
||||
Auth: option.Auth,
|
||||
CompLZO: option.CompLZO,
|
||||
CA: []byte(option.CA),
|
||||
Cert: []byte(option.Cert),
|
||||
Key: []byte(option.Key),
|
||||
TLSAuth: []byte(option.TLSAuth),
|
||||
KeyDirection: option.KeyDirection,
|
||||
TLSCrypt: []byte(option.TLSCrypt),
|
||||
TLSCryptV2: []byte(option.TLSCryptV2),
|
||||
Username: option.Username,
|
||||
Password: option.Password,
|
||||
PeerInfo: option.PeerInfo,
|
||||
PingInterval: time.Duration(option.Ping) * time.Second,
|
||||
PingRestart: time.Duration(option.PingRestart) * time.Second,
|
||||
TransitionWindow: transitionWindow,
|
||||
TransitionWindowSet: transitionWindowSet,
|
||||
}
|
||||
if err := cfg.Prepare(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1443,6 +1443,7 @@ proxies: # socks5
|
||||
# UV_DEVICE_ID: "laptop-001"
|
||||
# ping: 10 # 默认值为 0
|
||||
# ping-restart: 60 # 默认值为 0
|
||||
# tran-window: 3600 # 旧 data key 在 rekey 后保留的秒数;默认 3600,显式设为 0 表示立即过期,应与服务端 --tran-window 对齐
|
||||
# handshake-timeout: 30 # 单位为秒;配置后握手时不受外层连接超时影响;默认值为 0,表示仅使用外层连接超时
|
||||
# mtu: 1500
|
||||
udp: true
|
||||
|
||||
+1409
-109
File diff suppressed because it is too large
Load Diff
@@ -56,8 +56,10 @@ type ClientConfig struct {
|
||||
|
||||
PeerInfo map[string]string
|
||||
|
||||
PingInterval time.Duration
|
||||
PingRestart time.Duration
|
||||
PingInterval time.Duration
|
||||
PingRestart time.Duration
|
||||
TransitionWindow time.Duration
|
||||
TransitionWindowSet bool
|
||||
|
||||
TLSCryptKey []byte
|
||||
TLSAuthKey []byte
|
||||
@@ -324,6 +326,9 @@ func (c *ClientConfig) ValidateInstallScriptSubset() error {
|
||||
if c.PingRestart < 0 {
|
||||
return errors.New("openvpn ping restart must be positive")
|
||||
}
|
||||
if c.TransitionWindow < 0 {
|
||||
return errors.New("openvpn transition window must be non-negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package openvpn
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testCert = `-----BEGIN CERTIFICATE-----
|
||||
@@ -89,6 +90,14 @@ func TestClientConfigRejectsUnsupportedProto(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientConfigRejectsNegativeTransitionWindow(t *testing.T) {
|
||||
cfg := yamlStyleConfig()
|
||||
cfg.TransitionWindow = -time.Second
|
||||
if err := cfg.Prepare(); err == nil || !strings.Contains(err.Error(), "transition window") {
|
||||
t.Fatalf("negative transition window accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientConfigAllowsMissingTLSCrypt(t *testing.T) {
|
||||
cfg := yamlStyleConfig()
|
||||
cfg.TLSCrypt = nil
|
||||
|
||||
+893
-101
File diff suppressed because it is too large
Load Diff
+1082
-30
File diff suppressed because it is too large
Load Diff
+67
-31
@@ -63,6 +63,15 @@ type DataChannel struct {
|
||||
|
||||
mu sync.Mutex
|
||||
sendPacketID uint32
|
||||
// recvEvidence latches true once a data packet labeled with this key ID
|
||||
// decrypted successfully. The peer only labels outbound packets with a
|
||||
// key whose authentication completed (OpenVPN tls_pre_encrypt /
|
||||
// handle_data_channel_packet require KS_AUTH_TRUE), so this is the
|
||||
// reliable signal that this epoch has been activated by the peer and can
|
||||
// replace the lame-duck for outbound traffic. Stored per-epoch so a
|
||||
// back-to-back rekey cannot attribute an older epoch's evidence to a
|
||||
// newer key. Guarded by d.mu.
|
||||
recvEvidence bool
|
||||
recvHighest uint32
|
||||
recvWindow uint64
|
||||
recvSeen bool
|
||||
@@ -72,7 +81,7 @@ type DataChannel struct {
|
||||
randOffset int
|
||||
}
|
||||
|
||||
func NewDataChannel(keys *KeyMaterial, cipherName, authName string, peerID uint32) (*DataChannel, error) {
|
||||
func NewDataChannel(keys *KeyMaterial, cipherName, authName string, peerID uint32, keyID uint8) (*DataChannel, error) {
|
||||
if keys == nil {
|
||||
return nil, errors.New("nil openvpn key material")
|
||||
}
|
||||
@@ -91,8 +100,9 @@ func NewDataChannel(keys *KeyMaterial, cipherName, authName string, peerID uint3
|
||||
d := &DataChannel{
|
||||
sendAEAD: send,
|
||||
recvAEAD: recv,
|
||||
keyID: keyID & KeyIDMask,
|
||||
peerID: peerID,
|
||||
header: dataHeader(peerID, 0),
|
||||
header: dataHeader(peerID, keyID),
|
||||
}
|
||||
copy(d.sendImplicitIV[4:], keys.SendHMACKey[:DataChannelIVSize-4])
|
||||
copy(d.recvImplicitIV[4:], keys.RecvHMACKey[:DataChannelIVSize-4])
|
||||
@@ -121,8 +131,9 @@ func NewDataChannel(keys *KeyMaterial, cipherName, authName string, peerID uint3
|
||||
recvHMACKey: append([]byte(nil), keys.RecvHMACKey[:authSize]...),
|
||||
authHash: authHash,
|
||||
authSize: authSize,
|
||||
keyID: keyID & KeyIDMask,
|
||||
peerID: peerID,
|
||||
header: dataHeader(peerID, 0),
|
||||
header: dataHeader(peerID, keyID),
|
||||
}
|
||||
d.sendMACPool.New = func() any {
|
||||
return hmac.New(d.authHash, d.sendHMACKey)
|
||||
@@ -188,11 +199,20 @@ func (d *DataChannel) Encrypt(packet []byte) ([]byte, error) {
|
||||
return nil, errors.New("nil openvpn data channel")
|
||||
}
|
||||
|
||||
packetID := d.nextPacketID()
|
||||
if d.sendAEAD != nil {
|
||||
return d.encryptAEAD(packet, packetID)
|
||||
packetID, err := d.nextPacketID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.encryptCBC(packet, packetID)
|
||||
var encrypted []byte
|
||||
if d.sendAEAD != nil {
|
||||
encrypted, err = d.encryptAEAD(packet, packetID)
|
||||
} else {
|
||||
encrypted, err = d.encryptCBC(packet, packetID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
func (d *DataChannel) encryptAEAD(packet []byte, packetID uint32) ([]byte, error) {
|
||||
@@ -235,7 +255,7 @@ func (d *DataChannel) encryptCBC(packet []byte, packetID uint32) ([]byte, error)
|
||||
ciphertext[i] = byte(padding)
|
||||
}
|
||||
cipher.NewCBCEncrypter(d.sendBlock, iv).CryptBlocks(ciphertext, ciphertext)
|
||||
_ = d.hmacAppend(&d.sendMACPool, authenticated, out[len(header):len(header)])
|
||||
d.hmacCopy(&d.sendMACPool, authenticated, out[len(header):])
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -339,11 +359,37 @@ func dataPacketHeaderSize(packet []byte) (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataChannel) nextPacketID() uint32 {
|
||||
const dataPacketIDRekeyThreshold = uint32(0xFF000000)
|
||||
|
||||
var errDataPacketIDExhausted = errors.New("openvpn data packet id reached rekey threshold")
|
||||
|
||||
func (d *DataChannel) nextPacketID() (uint32, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
// OpenVPN starts a soft reset once packet_id_close_to_wrapping reaches
|
||||
// this threshold. This client cannot initiate that reset yet, so surface
|
||||
// the condition and let the adapter reconnect instead of silently
|
||||
// blackholing packets or approaching nonce reuse.
|
||||
if d.sendPacketID >= dataPacketIDRekeyThreshold {
|
||||
return 0, errDataPacketIDExhausted
|
||||
}
|
||||
d.sendPacketID++
|
||||
return d.sendPacketID
|
||||
return d.sendPacketID, nil
|
||||
}
|
||||
|
||||
// MarkPeerActive records that a packet labeled with this key ID decrypted
|
||||
// successfully, i.e. the peer has activated this epoch.
|
||||
func (d *DataChannel) MarkPeerActive() {
|
||||
d.mu.Lock()
|
||||
d.recvEvidence = true
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
// PeerActive reports whether the peer has activated this epoch.
|
||||
func (d *DataChannel) PeerActive() bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.recvEvidence
|
||||
}
|
||||
|
||||
func (d *DataChannel) acceptPacketID(packetID uint32) error {
|
||||
@@ -419,16 +465,6 @@ func (d *DataChannel) fillCBCIV(iv []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func dataChannelHMAC(newHash func() hash.Hash, key, data []byte) []byte {
|
||||
return dataChannelHMACAppend(newHash, key, data, nil)
|
||||
}
|
||||
|
||||
func dataChannelHMACAppend(newHash func() hash.Hash, key, data, dst []byte) []byte {
|
||||
mac := hmac.New(newHash, key)
|
||||
_, _ = mac.Write(data)
|
||||
return mac.Sum(dst)
|
||||
}
|
||||
|
||||
func (d *DataChannel) hmacAppend(pool *sync.Pool, data, dst []byte) []byte {
|
||||
mac := pool.Get().(hash.Hash)
|
||||
defer pool.Put(mac)
|
||||
@@ -437,17 +473,17 @@ func (d *DataChannel) hmacAppend(pool *sync.Pool, data, dst []byte) []byte {
|
||||
return mac.Sum(dst)
|
||||
}
|
||||
|
||||
func pkcs7Pad(plain []byte, blockSize int) []byte {
|
||||
padding := blockSize - len(plain)%blockSize
|
||||
if padding == 0 {
|
||||
padding = blockSize
|
||||
}
|
||||
out := make([]byte, len(plain)+padding)
|
||||
copy(out, plain)
|
||||
for i := len(plain); i < len(out); i++ {
|
||||
out[i] = byte(padding)
|
||||
}
|
||||
return out
|
||||
// hmacCopy writes the HMAC of data into dst (which must have enough
|
||||
// capacity), returning the number of bytes written. Unlike hmacAppend it
|
||||
// does not rely on mac.Sum(dst) appending into dst's backing array — it
|
||||
// always writes the tag into dst explicitly.
|
||||
func (d *DataChannel) hmacCopy(pool *sync.Pool, data, dst []byte) int {
|
||||
mac := pool.Get().(hash.Hash)
|
||||
defer pool.Put(mac)
|
||||
mac.Reset()
|
||||
_, _ = mac.Write(data)
|
||||
n := copy(dst, mac.Sum(nil))
|
||||
return n
|
||||
}
|
||||
|
||||
func pkcs7Unpad(padded []byte, blockSize int) ([]byte, error) {
|
||||
|
||||
@@ -131,7 +131,7 @@ func TestParsePushReplyNcpCiphers(t *testing.T) {
|
||||
|
||||
func TestInstallScriptPeerInfoWithDataCiphers(t *testing.T) {
|
||||
info := InstallScriptPeerInfo(CipherAES128GCM, []string{CipherAES256GCM, CipherAES128GCM, CipherChaCha20Poly1305}, "", nil)
|
||||
want := "IV_VER=mihomo-openvpn\nIV_PROTO=6\nIV_CIPHERS=AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305\n"
|
||||
want := "IV_VER=mihomo-openvpn\nIV_PROTO=22\nIV_CIPHERS=AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305\n"
|
||||
if info != want {
|
||||
t.Fatalf("unexpected peer-info:\n got %q\nwant %q", info, want)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/aes"
|
||||
"crypto/sha1"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -21,11 +22,11 @@ func TestDataChannelAESGCMV2RoundTrip(t *testing.T) {
|
||||
RecvCipherKey: clientKeys.SendCipherKey,
|
||||
RecvHMACKey: clientKeys.SendHMACKey,
|
||||
}
|
||||
client, err := NewDataChannel(clientKeys, CipherAES128GCM, AuthSHA256, 7)
|
||||
client, err := NewDataChannel(clientKeys, CipherAES128GCM, AuthSHA256, 7, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := NewDataChannel(serverKeys, CipherAES128GCM, AuthSHA256, 7)
|
||||
server, err := NewDataChannel(serverKeys, CipherAES128GCM, AuthSHA256, 7, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -75,11 +76,11 @@ func TestDataChannelAcceptsOutOfOrderPacketsWithinReplayWindow(t *testing.T) {
|
||||
RecvCipherKey: clientKeys.SendCipherKey,
|
||||
RecvHMACKey: clientKeys.SendHMACKey,
|
||||
}
|
||||
client, err := NewDataChannel(clientKeys, CipherAES128GCM, AuthSHA256, 7)
|
||||
client, err := NewDataChannel(clientKeys, CipherAES128GCM, AuthSHA256, 7, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := NewDataChannel(serverKeys, CipherAES128GCM, AuthSHA256, 7)
|
||||
server, err := NewDataChannel(serverKeys, CipherAES128GCM, AuthSHA256, 7, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -154,11 +155,11 @@ func TestDataChannelChaCha20Poly1305V2RoundTrip(t *testing.T) {
|
||||
RecvCipherKey: clientKeys.SendCipherKey,
|
||||
RecvHMACKey: clientKeys.SendHMACKey,
|
||||
}
|
||||
client, err := NewDataChannel(clientKeys, CipherChaCha20Poly1305, AuthSHA256, 7)
|
||||
client, err := NewDataChannel(clientKeys, CipherChaCha20Poly1305, AuthSHA256, 7, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := NewDataChannel(serverKeys, CipherChaCha20Poly1305, AuthSHA256, 7)
|
||||
server, err := NewDataChannel(serverKeys, CipherChaCha20Poly1305, AuthSHA256, 7, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -194,11 +195,11 @@ func TestDataChannelAESCBCSHA1V2RoundTrip(t *testing.T) {
|
||||
RecvCipherKey: clientKeys.SendCipherKey,
|
||||
RecvHMACKey: clientKeys.SendHMACKey,
|
||||
}
|
||||
client, err := NewDataChannel(clientKeys, CipherAES128CBC, AuthSHA1, 7)
|
||||
client, err := NewDataChannel(clientKeys, CipherAES128CBC, AuthSHA1, 7, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := NewDataChannel(serverKeys, CipherAES128CBC, AuthSHA1, 7)
|
||||
server, err := NewDataChannel(serverKeys, CipherAES128CBC, AuthSHA1, 7, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -240,3 +241,30 @@ func TestDataChannelAESCBCSHA1V2RoundTrip(t *testing.T) {
|
||||
t.Fatal("expected HMAC authentication failure after IV tamper")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataChannelStopsAtPacketIDRekeyThreshold(t *testing.T) {
|
||||
for _, cipher := range []string{CipherAES128GCM, CipherAES128CBC, CipherChaCha20Poly1305} {
|
||||
t.Run(cipher, func(t *testing.T) {
|
||||
keys := &KeyMaterial{
|
||||
SendCipherKey: bytes.Repeat([]byte{0x11}, 32),
|
||||
SendHMACKey: bytes.Repeat([]byte{0x22}, maxHMACKeyLength),
|
||||
RecvCipherKey: bytes.Repeat([]byte{0x33}, 32),
|
||||
RecvHMACKey: bytes.Repeat([]byte{0x44}, maxHMACKeyLength),
|
||||
}
|
||||
channel, err := NewDataChannel(keys, cipher, AuthSHA256, 7, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channel.sendPacketID = dataPacketIDRekeyThreshold - 1
|
||||
if _, err := channel.Encrypt([]byte{0x45, 0, 0, 20}); err != nil {
|
||||
t.Fatalf("final packet before rekey threshold: %v", err)
|
||||
}
|
||||
if _, err := channel.Encrypt([]byte{0x45, 0, 0, 20}); !errors.Is(err, errDataPacketIDExhausted) {
|
||||
t.Fatalf("packet at rekey threshold returned %v", err)
|
||||
}
|
||||
if packetID := channel.sendPacketID; packetID != dataPacketIDRekeyThreshold {
|
||||
t.Fatalf("packet ID advanced after threshold: got %#x", packetID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+111
-15
@@ -1,6 +1,7 @@
|
||||
package openvpn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
@@ -87,15 +88,28 @@ func (r *KeyMethod2Record) MarshalClient() ([]byte, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var errKeyMethodPacketTooShort = errors.New("key method 2 packet too short")
|
||||
|
||||
func ParseServerKeyMethod2Record(packet []byte) (*KeyMethod2Record, error) {
|
||||
record, _, err := ParseServerKeyMethod2RecordConsumed(packet)
|
||||
return record, err
|
||||
}
|
||||
|
||||
// ParseServerKeyMethod2RecordConsumed parses a server key-method-2 record and
|
||||
// reports how many bytes were consumed so following TLS control data
|
||||
// (PUSH_REPLY / AUTH_FAILED) can be preserved.
|
||||
//
|
||||
// OpenVPN 2.6 may omit the optional username, password and peer-info strings
|
||||
// after the mandatory options string.
|
||||
func ParseServerKeyMethod2RecordConsumed(packet []byte) (*KeyMethod2Record, int, error) {
|
||||
if len(packet) < 4+1+keySourceRandomSize*2 {
|
||||
return nil, errors.New("key method 2 packet too short")
|
||||
return nil, 0, errKeyMethodPacketTooShort
|
||||
}
|
||||
if binary.BigEndian.Uint32(packet[:4]) != 0 {
|
||||
return nil, errors.New("invalid key method 2 prefix")
|
||||
return nil, 0, errors.New("invalid key method 2 prefix")
|
||||
}
|
||||
if packet[4]&0x0f != KeyMethod2 {
|
||||
return nil, fmt.Errorf("unsupported key method %d", packet[4])
|
||||
return nil, 0, fmt.Errorf("unsupported key method %d", packet[4])
|
||||
}
|
||||
offset := 5
|
||||
record := &KeyMethod2Record{}
|
||||
@@ -107,12 +121,91 @@ func ParseServerKeyMethod2Record(packet []byte) (*KeyMethod2Record, error) {
|
||||
var err error
|
||||
record.Options, offset, err = readOpenVPNString(packet, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read options: %w", err)
|
||||
return nil, 0, fmt.Errorf("read options: %w", err)
|
||||
}
|
||||
record.Username, offset, _ = readOpenVPNString(packet, offset)
|
||||
record.Password, offset, _ = readOpenVPNString(packet, offset)
|
||||
record.PeerInfo, _, _ = readOpenVPNString(packet, offset)
|
||||
return record, nil
|
||||
// Username / password / peer-info are written by OpenVPN 2.6 even when
|
||||
// empty. Only stop early when the remaining bytes are a following TLS
|
||||
// control message (PUSH_REPLY / AUTH_FAILED). A truncated length/value
|
||||
// is not a shortened record — the caller must keep reading.
|
||||
if record.Username, offset, err = readKM2TrailingString(packet, offset); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if record.Password, offset, err = readKM2TrailingString(packet, offset); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if record.PeerInfo, offset, err = readKM2TrailingString(packet, offset); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return record, offset, nil
|
||||
}
|
||||
|
||||
func readKM2TrailingString(packet []byte, offset int) (string, int, error) {
|
||||
s, next, err := readOpenVPNString(packet, offset)
|
||||
if err == nil {
|
||||
return s, next, nil
|
||||
}
|
||||
if errors.Is(err, ioStringEOF) && looksLikeFollowingTLSControl(packet[offset:]) {
|
||||
return "", offset, nil
|
||||
}
|
||||
return "", offset, err
|
||||
}
|
||||
|
||||
// RecordComplete reports whether a full key-method-2 server record is present,
|
||||
// and returns the consumed offset. It requires all four strings (OpenVPN 2.6
|
||||
// writes them even when empty) so a standard record fragmented across TLS
|
||||
// reads is not accepted prematurely.
|
||||
func RecordComplete(packet []byte) (complete bool, consumed int) {
|
||||
if len(packet) < 4+1+keySourceRandomSize*2 {
|
||||
return false, 0
|
||||
}
|
||||
if binary.BigEndian.Uint32(packet[:4]) != 0 {
|
||||
return false, 0
|
||||
}
|
||||
if packet[4]&0x0f != KeyMethod2 {
|
||||
return false, 0
|
||||
}
|
||||
offset := 5 + keySourceRandomSize*2
|
||||
if !km2StrComplete(packet, offset) {
|
||||
return false, 0
|
||||
}
|
||||
offset += 2 + int(binary.BigEndian.Uint16(packet[offset:offset+2]))
|
||||
for i := 0; i < 3; i++ {
|
||||
if !km2StrComplete(packet, offset) {
|
||||
return false, 0
|
||||
}
|
||||
offset += 2 + int(binary.BigEndian.Uint16(packet[offset:offset+2]))
|
||||
}
|
||||
return true, offset
|
||||
}
|
||||
|
||||
func km2StrComplete(packet []byte, offset int) bool {
|
||||
if offset+2 > len(packet) {
|
||||
return false
|
||||
}
|
||||
size := int(binary.BigEndian.Uint16(packet[offset : offset+2]))
|
||||
if size == 0 {
|
||||
return true
|
||||
}
|
||||
return offset+2+size <= len(packet)
|
||||
}
|
||||
|
||||
func looksLikeFollowingTLSControl(b []byte) bool {
|
||||
for len(b) > 0 && b[0] == 0 {
|
||||
b = b[1:]
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
return bytes.HasPrefix(b, []byte("PUSH_REPLY")) ||
|
||||
bytes.HasPrefix(b, []byte("AUTH_FAILED")) ||
|
||||
bytes.HasPrefix(b, []byte("PUSH_REQUEST")) ||
|
||||
bytes.HasPrefix(b, []byte("AUTH_PENDING")) ||
|
||||
bytes.HasPrefix(b, []byte("INFO_PRE")) ||
|
||||
bytes.HasPrefix(b, []byte("INFO")) ||
|
||||
bytes.HasPrefix(b, []byte("RESTART")) ||
|
||||
bytes.HasPrefix(b, []byte("HALT")) ||
|
||||
bytes.HasPrefix(b, []byte("EXIT")) ||
|
||||
bytes.HasPrefix(b, []byte("CR_RESPONSE"))
|
||||
}
|
||||
|
||||
func DeriveClientKeyMaterial(sources KeySource2, clientSession, serverSession SessionID, cipherKeyLen int) (*KeyMaterial, error) {
|
||||
@@ -192,7 +285,11 @@ func InstallScriptPeerInfo(cipher string, dataCiphers []string, compLZO string,
|
||||
}
|
||||
ivCiphers = strings.Join(normalized, ":")
|
||||
}
|
||||
info := fmt.Sprintf("IV_VER=%s\nIV_PROTO=6\n%sIV_CIPHERS=%s\n", ivVer, lzo, ivCiphers)
|
||||
// IV_PROTO advertises DATA_V2 (bit 1), REQUEST_PUSH (bit 2) and
|
||||
// AUTH_PENDING keyword support (bit 4). The parser supports
|
||||
// AUTH_PENDING,timeout N, so capability and behavior must agree.
|
||||
const ivProto = (1 << 1) | (1 << 2) | (1 << 4) // 22
|
||||
info := fmt.Sprintf("IV_VER=%s\nIV_PROTO=%d\n%sIV_CIPHERS=%s\n", ivVer, ivProto, lzo, ivCiphers)
|
||||
// Append user-defined peer-info entries (e.g. IV_HWADDR, UV_*) after the
|
||||
// built-in fields. Keys are sorted so the output is deterministic.
|
||||
keys := make([]string, 0, len(peerInfo))
|
||||
@@ -232,19 +329,18 @@ func readOpenVPNString(packet []byte, offset int) (string, int, error) {
|
||||
return "", offset, ioStringEOF
|
||||
}
|
||||
size := int(binary.BigEndian.Uint16(packet[offset : offset+2]))
|
||||
offset += 2
|
||||
if size == 0 {
|
||||
return "", offset, nil
|
||||
return "", offset + 2, nil
|
||||
}
|
||||
if offset+size > len(packet) {
|
||||
if offset+2+size > len(packet) {
|
||||
// Do not consume the length prefix: leftover bytes may be PUSH_REPLY.
|
||||
return "", offset, ioStringEOF
|
||||
}
|
||||
raw := packet[offset : offset+size]
|
||||
offset += size
|
||||
raw := packet[offset+2 : offset+2+size]
|
||||
if raw[len(raw)-1] == 0 {
|
||||
raw = raw[:len(raw)-1]
|
||||
}
|
||||
return string(raw), offset, nil
|
||||
return string(raw), offset + 2 + size, nil
|
||||
}
|
||||
|
||||
var ioStringEOF = errors.New("openvpn string truncated")
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestInstallScriptOptionsCBCSHA1(t *testing.T) {
|
||||
func TestInstallScriptPeerInfo(t *testing.T) {
|
||||
// Without user-defined peer-info the output is unchanged (backward compatible).
|
||||
base := InstallScriptPeerInfo(CipherAES128GCM, nil, "", nil)
|
||||
if base != "IV_VER=mihomo-openvpn\nIV_PROTO=6\nIV_CIPHERS=AES-128-GCM\n" {
|
||||
if base != "IV_VER=mihomo-openvpn\nIV_PROTO=22\nIV_CIPHERS=AES-128-GCM\n" {
|
||||
t.Fatalf("unexpected default peer-info: %q", base)
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ func TestInstallScriptPeerInfo(t *testing.T) {
|
||||
"IV_LZO": "0",
|
||||
"IV_CIPHERS": "AES-256-CBC",
|
||||
})
|
||||
want = "IV_VER=custom-client/1.0\nIV_PROTO=6\nIV_LZO=1\nIV_CIPHERS=AES-128-GCM\n"
|
||||
want = "IV_VER=custom-client/1.0\nIV_PROTO=22\nIV_LZO=1\nIV_CIPHERS=AES-128-GCM\n"
|
||||
if overridden != want {
|
||||
t.Fatalf("unexpected overridden peer-info:\n got %q\nwant %q", overridden, want)
|
||||
}
|
||||
@@ -144,3 +144,28 @@ func TestParseServerKeyMethod2Record(t *testing.T) {
|
||||
t.Fatalf("unexpected server randoms")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServerKeyMethod2RecordShortenedPreservesTail(t *testing.T) {
|
||||
var packet []byte
|
||||
packet = binary.BigEndian.AppendUint32(packet, 0)
|
||||
packet = append(packet, KeyMethod2)
|
||||
packet = append(packet, bytes.Repeat([]byte{1}, keySourceRandomSize)...)
|
||||
packet = append(packet, bytes.Repeat([]byte{2}, keySourceRandomSize)...)
|
||||
packet = appendOpenVPNString(packet, "server-options")
|
||||
packet = append(packet, []byte("PUSH_REPLY,ifconfig 10.8.0.2 255.255.255.0\x00")...)
|
||||
|
||||
record, consumed, err := ParseServerKeyMethod2RecordConsumed(packet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.Options != "server-options" {
|
||||
t.Fatalf("options = %q", record.Options)
|
||||
}
|
||||
if record.Username != "" || record.Password != "" || record.PeerInfo != "" {
|
||||
t.Fatalf("optional strings should be empty: %#v", record)
|
||||
}
|
||||
tail := packet[consumed:]
|
||||
if !bytes.HasPrefix(tail, []byte("PUSH_REPLY")) {
|
||||
t.Fatalf("expected leftover PUSH_REPLY, got %q", tail)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ControlCryptor interface {
|
||||
@@ -94,6 +95,9 @@ type ControlPacket struct {
|
||||
|
||||
MessageID uint32
|
||||
Payload []byte
|
||||
// receivedAt is local-only metadata recording when a valid soft reset was
|
||||
// accepted. It is never serialized.
|
||||
receivedAt time.Time
|
||||
}
|
||||
|
||||
func opcodeKeyID(opcode Opcode, keyID uint8) byte {
|
||||
@@ -108,7 +112,7 @@ func (p ControlPacket) EncodePlain() ([]byte, error) {
|
||||
if !p.Opcode.IsControl() {
|
||||
return nil, fmt.Errorf("opcode %s is not a control opcode", p.Opcode)
|
||||
}
|
||||
if len(p.AckIDs) > 255 {
|
||||
if len(p.AckIDs) > reliableAckSize {
|
||||
return nil, fmt.Errorf("too many ack ids: %d", len(p.AckIDs))
|
||||
}
|
||||
|
||||
@@ -143,6 +147,9 @@ func DecodeControlPlain(opcode Opcode, plain []byte) (ackIDs []uint32, ackRemote
|
||||
return nil, SessionID{}, 0, nil, errors.New("control payload too short")
|
||||
}
|
||||
ackLen := int(plain[0])
|
||||
if ackLen > reliableAckSize {
|
||||
return nil, SessionID{}, 0, nil, fmt.Errorf("control ack array exceeds %d entries", reliableAckSize)
|
||||
}
|
||||
offset := 1
|
||||
if len(plain) < offset+ackLen*4 {
|
||||
return nil, SessionID{}, 0, nil, errors.New("control ack array truncated")
|
||||
|
||||
@@ -157,3 +157,18 @@ func TestAckPacketRejectsTrailingPayload(t *testing.T) {
|
||||
t.Fatal("expected trailing payload error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlPacketRejectsOversizedACKArray(t *testing.T) {
|
||||
packet := ControlPacket{
|
||||
Opcode: PAckV1,
|
||||
AckIDs: make([]uint32, reliableAckSize+1),
|
||||
}
|
||||
if _, err := packet.EncodePlain(); err == nil {
|
||||
t.Fatal("oversized ACK array encoded")
|
||||
}
|
||||
plain := make([]byte, 1+(reliableAckSize+1)*4+SessionIDSize)
|
||||
plain[0] = reliableAckSize + 1
|
||||
if _, _, _, _, err := DecodeControlPlain(PAckV1, plain); err == nil {
|
||||
t.Fatal("oversized ACK array decoded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package openvpn
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const PushRequest = "PUSH_REQUEST"
|
||||
@@ -25,16 +28,52 @@ type PushReply struct {
|
||||
// Cipher is the single cipher pushed by the server via the "cipher"
|
||||
// option (legacy or fallback).
|
||||
Cipher string
|
||||
|
||||
// AuthToken is the most recently pushed auth-token / auth-token-user
|
||||
// pair. Empty when the server does not rotate credentials.
|
||||
AuthTokenUser string
|
||||
AuthTokenPass string
|
||||
|
||||
// PushContinuation mirrors OpenVPN's "push-continuation N": 2 marks an
|
||||
// intermediate multi-segment PUSH_REPLY, 1 marks the final segment, 0
|
||||
// means a single segment.
|
||||
PushContinuation int
|
||||
// HasPushReply distinguishes a parsed PUSH_REPLY from standalone control
|
||||
// metadata such as AUTH_PENDING carried in the same accumulator.
|
||||
HasPushReply bool
|
||||
|
||||
// AuthPendingTimeout is the deferred-auth window advertised by
|
||||
// AUTH_PENDING,timeout N. Zero when no AUTH_PENDING was seen.
|
||||
AuthPendingTimeout time.Duration
|
||||
// authPendingUntil anchors that window to the TLS key establishment time,
|
||||
// matching OpenVPN key_state.established and preventing delayed messages or
|
||||
// later final PUSH_REPLY segments from restarting it.
|
||||
authPendingUntil time.Time
|
||||
// hasAuthPending distinguishes an explicit timeout of zero from no
|
||||
// AUTH_PENDING message.
|
||||
hasAuthPending bool
|
||||
}
|
||||
|
||||
func ParsePushReply(message string) (*PushReply, error) {
|
||||
reply, err := parsePushReplyInner(message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(reply.Prefixes) == 0 {
|
||||
return nil, fmt.Errorf("openvpn push reply missing ifconfig address")
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func parsePushReplyInner(message string) (*PushReply, error) {
|
||||
message = strings.TrimRight(message, "\x00")
|
||||
if !strings.HasPrefix(message, "PUSH_REPLY") {
|
||||
return nil, fmt.Errorf("unexpected openvpn push message %q", message)
|
||||
}
|
||||
reply := &PushReply{
|
||||
Raw: message,
|
||||
PeerID: PeerIDUnset,
|
||||
Raw: message,
|
||||
PeerID: PeerIDUnset,
|
||||
HasPushReply: true,
|
||||
}
|
||||
for _, option := range splitPushOptions(message) {
|
||||
fields := strings.Fields(option)
|
||||
@@ -93,8 +132,6 @@ func ParsePushReply(message string) (*PushReply, error) {
|
||||
case "block-ipv6":
|
||||
reply.BlockIPv6 = true
|
||||
case "data-ciphers", "ncp-ciphers":
|
||||
// "data-ciphers" (OpenVPN 2.5+) or "ncp-ciphers" (2.4 legacy name).
|
||||
// Value is a colon-separated list of cipher names.
|
||||
if len(fields) >= 2 {
|
||||
for _, c := range strings.Split(fields[1], ":") {
|
||||
c = strings.TrimSpace(c)
|
||||
@@ -104,18 +141,62 @@ func ParsePushReply(message string) (*PushReply, error) {
|
||||
}
|
||||
}
|
||||
case "cipher":
|
||||
// Legacy single cipher push, or fallback cipher.
|
||||
if len(fields) >= 2 {
|
||||
reply.Cipher = strings.TrimSpace(fields[1])
|
||||
}
|
||||
case "auth-token":
|
||||
if len(fields) >= 2 {
|
||||
reply.AuthTokenPass = strings.TrimSpace(fields[1])
|
||||
}
|
||||
case "auth-token-user":
|
||||
if len(fields) >= 2 {
|
||||
user, err := decodeAuthTokenUser(fields[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode auth-token-user: %w", err)
|
||||
}
|
||||
reply.AuthTokenUser = user
|
||||
}
|
||||
case "push-continuation":
|
||||
if len(fields) != 2 {
|
||||
return nil, errors.New("invalid push-continuation")
|
||||
}
|
||||
n, err := strconv.Atoi(fields[1])
|
||||
if err != nil || n < 0 || n > 2 {
|
||||
return nil, fmt.Errorf("invalid push-continuation %q", fields[1])
|
||||
}
|
||||
reply.PushContinuation = n
|
||||
}
|
||||
}
|
||||
if len(reply.Prefixes) == 0 {
|
||||
return nil, fmt.Errorf("openvpn push reply missing ifconfig address")
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func (p *PushReply) AuthToken() (user, pass string, ok bool) {
|
||||
if p == nil || p.AuthTokenPass == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return p.AuthTokenUser, p.AuthTokenPass, true
|
||||
}
|
||||
|
||||
func decodeAuthTokenUser(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
decoded, err := decodeBase64Auth(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func decodeBase64Auth(raw string) (string, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
data, err = base64.RawStdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func splitPushOptions(message string) []string {
|
||||
message = strings.TrimRight(message, "\x00")
|
||||
parts := strings.Split(message, ",")
|
||||
|
||||
+3543
-19
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user