1
0
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:
ayanami-desu
2026-08-16 02:24:36 +08:00
committed by GitHub
parent 7259bbb8bf
commit 9b9dc8a06c
16 changed files with 7339 additions and 348 deletions
+41 -21
View File
@@ -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
+1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -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
}
+9
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+67 -31
View File
@@ -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) {
+1 -1
View File
@@ -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)
}
+36 -8
View File
@@ -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
View File
@@ -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")
+27 -2
View File
@@ -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)
}
}
+8 -1
View File
@@ -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")
+15
View File
@@ -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")
}
}
+89 -8
View File
@@ -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, ",")
File diff suppressed because it is too large Load Diff