From 1610c18a2f998dd3019e8370ed16691cb599950c Mon Sep 17 00:00:00 2001 From: nekohasekai Date: Fri, 24 Apr 2026 09:04:27 +0800 Subject: [PATCH] Add Windows TLS engine --- adapter/certificate.go | 1 + adapter/certificate_darwin.go | 17 + common/certificate/anchors_darwin.h | 17 + common/certificate/anchors_darwin.m | 42 + common/certificate/store.go | 46 +- common/certificate/store_darwin.go | 167 ++ common/certificate/store_other.go | 13 + common/httpclient/apple_transport_darwin.go | 64 +- common/httpclient/apple_transport_darwin.h | 3 +- common/httpclient/apple_transport_darwin.m | 44 +- .../httpclient/apple_transport_darwin_test.go | 115 +- common/httpclient/client.go | 2 +- common/schannel/doc.go | 5 + common/schannel/schannel_windows.go | 719 +++++ common/schannel/schannel_windows_test.go | 188 ++ common/schannel/syscall_windows.go | 28 + common/schannel/types_windows.go | 161 ++ common/schannel/zsyscall_windows.go | 99 + common/tls/apple_client.go | 209 +- common/tls/apple_client_platform.go | 268 +- .../apple_client_platform_benchmark_test.go | 278 ++ common/tls/apple_client_platform_darwin.h | 8 +- common/tls/apple_client_platform_darwin.m | 403 ++- .../apple_client_platform_dispatch_test.go | 50 + ...ent_platform_dispatch_testhelper_darwin.go | 44 + common/tls/apple_client_platform_test.go | 325 ++- common/tls/client.go | 4 +- common/tls/std_client.go | 14 +- common/tls/system_client.go | 218 ++ common/tls/windows_client.go | 848 ++++++ common/tls/windows_client_stub.go | 15 + common/tls/windows_client_test.go | 2505 +++++++++++++++++ constant/tls.go | 2 + docs/configuration/shared/tls.md | 40 +- docs/configuration/shared/tls.zh.md | 39 +- 35 files changed, 6513 insertions(+), 488 deletions(-) create mode 100644 adapter/certificate_darwin.go create mode 100644 common/certificate/anchors_darwin.h create mode 100644 common/certificate/anchors_darwin.m create mode 100644 common/certificate/store_darwin.go create mode 100644 common/certificate/store_other.go create mode 100644 common/schannel/doc.go create mode 100644 common/schannel/schannel_windows.go create mode 100644 common/schannel/schannel_windows_test.go create mode 100644 common/schannel/syscall_windows.go create mode 100644 common/schannel/types_windows.go create mode 100644 common/schannel/zsyscall_windows.go create mode 100644 common/tls/apple_client_platform_benchmark_test.go create mode 100644 common/tls/apple_client_platform_dispatch_test.go create mode 100644 common/tls/apple_client_platform_dispatch_testhelper_darwin.go create mode 100644 common/tls/system_client.go create mode 100644 common/tls/windows_client.go create mode 100644 common/tls/windows_client_stub.go create mode 100644 common/tls/windows_client_test.go diff --git a/adapter/certificate.go b/adapter/certificate.go index 0998e130..dfed642d 100644 --- a/adapter/certificate.go +++ b/adapter/certificate.go @@ -10,6 +10,7 @@ import ( type CertificateStore interface { LifecycleService Pool() *x509.CertPool + ExclusiveAnchors() bool } func RootPoolFromContext(ctx context.Context) *x509.CertPool { diff --git a/adapter/certificate_darwin.go b/adapter/certificate_darwin.go new file mode 100644 index 00000000..ddcdb55f --- /dev/null +++ b/adapter/certificate_darwin.go @@ -0,0 +1,17 @@ +//go:build darwin && cgo + +package adapter + +import "unsafe" + +type AppleAnchors interface { + Retain() AppleAnchors + Release() + // Ref returns the underlying CFArrayRef, or nil if the anchor set is empty. + Ref() unsafe.Pointer +} + +type AppleCertificateStore interface { + CertificateStore + AppleAnchors() AppleAnchors +} diff --git a/common/certificate/anchors_darwin.h b/common/certificate/anchors_darwin.h new file mode 100644 index 00000000..f535f5ca --- /dev/null +++ b/common/certificate/anchors_darwin.h @@ -0,0 +1,17 @@ +#ifndef BOX_CERTIFICATE_ANCHORS_DARWIN_H +#define BOX_CERTIFICATE_ANCHORS_DARWIN_H + +#include +#include + +// box_certificate_anchors_from_der wraps an array of DER-encoded certificate +// blobs into a retained CFArrayRef of SecCertificateRef, returned as an opaque +// pointer. The caller owns the returned reference and must call +// box_certificate_release_anchors. Returns NULL when no blobs were accepted. +void *box_certificate_anchors_from_der(const uint8_t *const *ders, const size_t *lens, size_t count); + +// box_certificate_release_anchors drops one reference from a CFArray handle +// previously returned by box_certificate_anchors_from_der. No-op on NULL. +void box_certificate_release_anchors(void *anchors); + +#endif diff --git a/common/certificate/anchors_darwin.m b/common/certificate/anchors_darwin.m new file mode 100644 index 00000000..e9f471c6 --- /dev/null +++ b/common/certificate/anchors_darwin.m @@ -0,0 +1,42 @@ +#import "anchors_darwin.h" + +#import +#import + +void *box_certificate_anchors_from_der(const uint8_t *const *ders, const size_t *lens, size_t count) { + if (count == 0 || ders == NULL || lens == NULL) { + return NULL; + } + CFMutableArrayRef certificates = CFArrayCreateMutable(NULL, (CFIndex)count, &kCFTypeArrayCallBacks); + if (certificates == NULL) { + return NULL; + } + for (size_t index = 0; index < count; index++) { + if (ders[index] == NULL || lens[index] == 0) { + continue; + } + CFDataRef data = CFDataCreate(NULL, ders[index], (CFIndex)lens[index]); + if (data == NULL) { + continue; + } + SecCertificateRef certificate = SecCertificateCreateWithData(NULL, data); + CFRelease(data); + if (certificate == NULL) { + continue; + } + CFArrayAppendValue(certificates, certificate); + CFRelease(certificate); + } + if (CFArrayGetCount(certificates) == 0) { + CFRelease(certificates); + return NULL; + } + return certificates; +} + +void box_certificate_release_anchors(void *anchors) { + if (anchors == NULL) { + return; + } + CFRelease((CFTypeRef)anchors); +} diff --git a/common/certificate/store.go b/common/certificate/store.go index 0e0c25b1..54b2251f 100644 --- a/common/certificate/store.go +++ b/common/certificate/store.go @@ -1,6 +1,7 @@ package certificate import ( + "bytes" "context" "crypto/x509" "io/fs" @@ -25,11 +26,11 @@ type Store struct { storeType string systemPool *x509.CertPool currentPool *x509.CertPool - currentPEM []string certificate string certificatePaths []string certificateDirectoryPaths []string watcher *fswatch.Watcher + platform storePlatform } func NewStore(ctx context.Context, logger logger.Logger, options option.CertificateOptions) (*Store, error) { @@ -114,10 +115,18 @@ func (s *Store) Start(stage adapter.StartStage) error { } func (s *Store) Close() error { - if s.watcher != nil { - return s.watcher.Close() + watcher := s.watcher + s.watcher = nil + + var closeErr error + if watcher != nil { + closeErr = watcher.Close() } - return nil + platformErr := s.closePlatform() + if platformErr != nil { + closeErr = platformErr + } + return closeErr } func (s *Store) Pool() *x509.CertPool { @@ -130,37 +139,35 @@ func (s *Store) StoreKind() string { return s.storeType } -func (s *Store) CurrentPEM() []string { - s.access.RLock() - defer s.access.RUnlock() - return append([]string(nil), s.currentPEM...) +func (s *Store) ExclusiveAnchors() bool { + return s.storeType != C.CertificateStoreSystem } func (s *Store) update() error { currentPool, err := s.newBasePool() - var currentPEM []string if err != nil { return err } + pemBuffer := new(bytes.Buffer) switch s.storeType { case C.CertificateStoreMozilla: pemContent := mozillaIncludedPEM() if !currentPool.AppendCertsFromPEM([]byte(pemContent)) { return E.New("invalid Mozilla included certificate PEM") } - currentPEM = append(currentPEM, pemContent) + appendPEMBlock(pemBuffer, string(pemContent)) case C.CertificateStoreChrome: pemContent := chromeIncludedPEM() if !currentPool.AppendCertsFromPEM([]byte(pemContent)) { return E.New("invalid Chrome included certificate PEM") } - currentPEM = append(currentPEM, pemContent) + appendPEMBlock(pemBuffer, string(pemContent)) } if s.certificate != "" { if !currentPool.AppendCertsFromPEM([]byte(s.certificate)) { return E.New("invalid certificate PEM strings") } - currentPEM = append(currentPEM, s.certificate) + appendPEMBlock(pemBuffer, s.certificate) } for _, path := range s.certificatePaths { pemContent, err := os.ReadFile(path) @@ -170,7 +177,7 @@ func (s *Store) update() error { if !currentPool.AppendCertsFromPEM(pemContent) { return E.New("invalid certificate PEM file: ", path) } - currentPEM = append(currentPEM, string(pemContent)) + appendPEMBlock(pemBuffer, string(pemContent)) } var firstErr error for _, directoryPath := range s.certificateDirectoryPaths { @@ -184,7 +191,7 @@ func (s *Store) update() error { for _, directoryEntry := range directoryEntries { pemContent, err := os.ReadFile(filepath.Join(directoryPath, directoryEntry.Name())) if err == nil && currentPool.AppendCertsFromPEM(pemContent) { - currentPEM = append(currentPEM, string(pemContent)) + appendPEMBlock(pemBuffer, string(pemContent)) } } } @@ -194,8 +201,15 @@ func (s *Store) update() error { s.access.Lock() defer s.access.Unlock() s.currentPool = currentPool - s.currentPEM = currentPEM - return nil + return s.updatePlatformLocked(pemBuffer.Bytes()) +} + +func appendPEMBlock(buffer *bytes.Buffer, block string) { + existing := buffer.Bytes() + if len(existing) > 0 && existing[len(existing)-1] != '\n' { + buffer.WriteByte('\n') + } + buffer.WriteString(block) } func (s *Store) newBasePool() (*x509.CertPool, error) { diff --git a/common/certificate/store_darwin.go b/common/certificate/store_darwin.go new file mode 100644 index 00000000..bbc8d4ba --- /dev/null +++ b/common/certificate/store_darwin.go @@ -0,0 +1,167 @@ +//go:build darwin && cgo + +package certificate + +/* +#cgo CFLAGS: -x objective-c -fobjc-arc +#cgo LDFLAGS: -framework Foundation -framework Security + +#include +#include "anchors_darwin.h" +*/ +import "C" + +import ( + "crypto/sha256" + "encoding/pem" + "runtime" + "sync/atomic" + "unsafe" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" +) + +var ( + _ adapter.AppleCertificateStore = (*Store)(nil) + _ adapter.AppleAnchors = (*appleAnchors)(nil) +) + +type storePlatform struct { + anchors *appleAnchors + hash [sha256.Size]byte +} + +type appleAnchors struct { + cfArray unsafe.Pointer + refs atomic.Int32 +} + +func newAppleAnchors(pemBytes []byte) (*appleAnchors, error) { + anchors := &appleAnchors{} + anchors.refs.Store(1) + if len(pemBytes) == 0 { + return anchors, nil + } + derBlocks := decodeCertificatePEM(pemBytes) + if len(derBlocks) == 0 { + return nil, E.New("parse certificate PEM") + } + pointerSize := C.size_t(unsafe.Sizeof((*C.uint8_t)(nil))) + lenSize := C.size_t(unsafe.Sizeof(C.size_t(0))) + pointersC := (**C.uint8_t)(C.malloc(pointerSize * C.size_t(len(derBlocks)))) + defer C.free(unsafe.Pointer(pointersC)) + lensC := (*C.size_t)(C.malloc(lenSize * C.size_t(len(derBlocks)))) + defer C.free(unsafe.Pointer(lensC)) + pointersSlice := unsafe.Slice(pointersC, len(derBlocks)) + lensSlice := unsafe.Slice(lensC, len(derBlocks)) + var pinner runtime.Pinner + defer pinner.Unpin() + for index, der := range derBlocks { + pinner.Pin(&der[0]) + pointersSlice[index] = (*C.uint8_t)(unsafe.Pointer(&der[0])) + lensSlice[index] = C.size_t(len(der)) + } + cfArray := C.box_certificate_anchors_from_der(pointersC, lensC, C.size_t(len(derBlocks))) + if cfArray == nil { + return nil, E.New("parse certificate PEM") + } + anchors.cfArray = cfArray + return anchors, nil +} + +// NewAppleAnchors parses the given PEM and returns a ref-counted handle +// wrapping a CFArray of SecCertificateRef. The caller owns the returned +// reference and must call Release when finished. Returns an error when +// pemBytes is non-empty but contains no usable CERTIFICATE blocks. +func NewAppleAnchors(pemBytes []byte) (adapter.AppleAnchors, error) { + return newAppleAnchors(pemBytes) +} + +// AcquireAnchors returns a retained AppleAnchors handle, preferring the +// per-config userAnchors over the process-wide certificate store. Returns +// nil when neither source is available. Callers must Release the handle. +func AcquireAnchors(userAnchors adapter.AppleAnchors, store adapter.CertificateStore) adapter.AppleAnchors { + if userAnchors != nil { + return userAnchors.Retain() + } + if store == nil { + return nil + } + apple, loaded := store.(adapter.AppleCertificateStore) + if !loaded { + return nil + } + return apple.AppleAnchors() +} + +func (a *appleAnchors) Retain() adapter.AppleAnchors { + a.refs.Add(1) + return a +} + +func (a *appleAnchors) Release() { + if a.refs.Add(-1) != 0 { + return + } + if a.cfArray != nil { + C.box_certificate_release_anchors(a.cfArray) + } +} + +func (a *appleAnchors) Ref() unsafe.Pointer { + return a.cfArray +} + +func (s *Store) AppleAnchors() adapter.AppleAnchors { + s.access.RLock() + defer s.access.RUnlock() + if s.platform.anchors == nil { + return nil + } + return s.platform.anchors.Retain() +} + +func (s *Store) updatePlatformLocked(pemBytes []byte) error { + hash := sha256.Sum256(pemBytes) + if s.platform.anchors != nil && s.platform.hash == hash { + return nil + } + newAnchors, err := newAppleAnchors(pemBytes) + if err != nil { + return err + } + old := s.platform.anchors + s.platform.anchors = newAnchors + s.platform.hash = hash + if old != nil { + old.Release() + } + return nil +} + +func (s *Store) closePlatform() error { + s.access.Lock() + defer s.access.Unlock() + if s.platform.anchors != nil { + s.platform.anchors.Release() + s.platform.anchors = nil + } + return nil +} + +func decodeCertificatePEM(pemBytes []byte) [][]byte { + var blocks [][]byte + rest := pemBytes + for { + block, next := pem.Decode(rest) + if block == nil { + break + } + if block.Type == "CERTIFICATE" && len(block.Bytes) > 0 { + blocks = append(blocks, block.Bytes) + } + rest = next + } + return blocks +} diff --git a/common/certificate/store_other.go b/common/certificate/store_other.go new file mode 100644 index 00000000..c2d68ed2 --- /dev/null +++ b/common/certificate/store_other.go @@ -0,0 +1,13 @@ +//go:build !(darwin && cgo) + +package certificate + +type storePlatform struct{} + +func (s *Store) updatePlatformLocked(_ []byte) error { + return nil +} + +func (s *Store) closePlatform() error { + return nil +} diff --git a/common/httpclient/apple_transport_darwin.go b/common/httpclient/apple_transport_darwin.go index b9174009..4619dc58 100644 --- a/common/httpclient/apple_transport_darwin.go +++ b/common/httpclient/apple_transport_darwin.go @@ -25,6 +25,8 @@ import ( "time" "unsafe" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/certificate" "github.com/sagernet/sing-box/common/proxybridge" boxTLS "github.com/sagernet/sing-box/common/tls" "github.com/sagernet/sing-box/option" @@ -37,6 +39,15 @@ import ( const applePinnedHashSize = sha256.Size +var ( + newAppleUserAnchors = certificate.NewAppleAnchors + newAppleProxyBridge = proxybridge.New + newAppleTransportSession = func(shared *appleTransportShared) (unsafe.Pointer, error) { + session, err := shared.newSession() + return unsafe.Pointer(session), err + } +) + func verifyApplePinnedPublicKeySHA256(flatHashes []byte, leafCertificate []byte) error { if len(flatHashes)%applePinnedHashSize != 0 { return E.New("invalid pinned public key list") @@ -64,8 +75,9 @@ type appleSessionConfig struct { minVersion uint16 maxVersion uint16 insecure bool - anchorPEM string anchorOnly bool + userAnchors adapter.AppleAnchors + store adapter.CertificateStore pinnedPublicKeySHA256s []byte } @@ -89,7 +101,13 @@ func newAppleTransport(ctx context.Context, logger logger.ContextLogger, rawDial if err != nil { return nil, err } - bridge, err := proxybridge.New(ctx, logger, "apple http proxy", rawDialer) + releaseConfig := true + defer func() { + if releaseConfig { + sessionConfig.close() + } + }() + bridge, err := newAppleProxyBridge(ctx, logger, "apple http proxy", rawDialer) if err != nil { return nil, err } @@ -100,11 +118,13 @@ func newAppleTransport(ctx context.Context, logger logger.ContextLogger, rawDial timeFunc: ntp.TimeFuncFromContext(ctx), } shared.refs.Store(1) - session, err := shared.newSession() + sessionRef, err := newAppleTransportSession(shared) if err != nil { bridge.Close() return nil, err } + session := (*C.box_apple_http_session_t)(sessionRef) + releaseConfig = false return &appleTransport{ shared: shared, session: session, @@ -142,7 +162,7 @@ func newAppleSessionConfig(ctx context.Context, options option.HTTPClientOptions if len(tlsOptions.ALPN) > 0 { return appleSessionConfig{}, E.New("tls.alpn is unsupported in Apple HTTP engine") } - validated, err := boxTLS.ValidateAppleTLSOptions(ctx, tlsOptions, "Apple HTTP engine") + validated, err := boxTLS.ValidateSystemTLSOptions(ctx, tlsOptions, "Apple HTTP engine") if err != nil { return appleSessionConfig{}, err } @@ -152,13 +172,23 @@ func newAppleSessionConfig(ctx context.Context, options option.HTTPClientOptions minVersion: validated.MinVersion, maxVersion: validated.MaxVersion, insecure: tlsOptions.Insecure || len(tlsOptions.CertificatePublicKeySHA256) > 0, - anchorPEM: validated.AnchorPEM, - anchorOnly: validated.AnchorOnly, + anchorOnly: validated.Exclusive, + store: validated.Store, + } + if len(validated.UserPEM) > 0 { + userAnchors, anchorsErr := newAppleUserAnchors(validated.UserPEM) + if anchorsErr != nil { + return appleSessionConfig{}, anchorsErr + } + config.userAnchors = userAnchors } if len(tlsOptions.CertificatePublicKeySHA256) > 0 { config.pinnedPublicKeySHA256s = make([]byte, 0, len(tlsOptions.CertificatePublicKeySHA256)*applePinnedHashSize) for _, hashValue := range tlsOptions.CertificatePublicKeySHA256 { if len(hashValue) != applePinnedHashSize { + if config.userAnchors != nil { + config.userAnchors.Release() + } return appleSessionConfig{}, E.New("invalid certificate_public_key_sha256 length: ", len(hashValue)) } config.pinnedPublicKeySHA256s = append(config.pinnedPublicKeySHA256s, hashValue...) @@ -167,12 +197,20 @@ func newAppleSessionConfig(ctx context.Context, options option.HTTPClientOptions return config, nil } +func (c *appleSessionConfig) close() { + if c.userAnchors != nil { + c.userAnchors.Release() + c.userAnchors = nil + } +} + func (s *appleTransportShared) retain() { s.refs.Add(1) } func (s *appleTransportShared) release() error { if s.refs.Add(-1) == 0 { + s.config.close() return s.bridge.Close() } return nil @@ -185,16 +223,17 @@ func (s *appleTransportShared) newSession() (*C.box_apple_http_session_t, error) defer C.free(unsafe.Pointer(cProxyUsername)) cProxyPassword := C.CString(s.bridge.Password()) defer C.free(unsafe.Pointer(cProxyPassword)) - var cAnchorPEM *C.char - if s.config.anchorPEM != "" { - cAnchorPEM = C.CString(s.config.anchorPEM) - defer C.free(unsafe.Pointer(cAnchorPEM)) - } var pinnedPointer *C.uint8_t if len(s.config.pinnedPublicKeySHA256s) > 0 { pinnedPointer = (*C.uint8_t)(C.CBytes(s.config.pinnedPublicKeySHA256s)) defer C.free(unsafe.Pointer(pinnedPointer)) } + anchors := certificate.AcquireAnchors(s.config.userAnchors, s.config.store) + var anchorsRef unsafe.Pointer + if anchors != nil { + anchorsRef = anchors.Ref() + defer anchors.Release() + } cConfig := C.box_apple_http_session_config_t{ proxy_host: cProxyHost, proxy_port: C.int(s.bridge.Port()), @@ -203,8 +242,7 @@ func (s *appleTransportShared) newSession() (*C.box_apple_http_session_t, error) min_tls_version: C.uint16_t(s.config.minVersion), max_tls_version: C.uint16_t(s.config.maxVersion), insecure: C.bool(s.config.insecure), - anchor_pem: cAnchorPEM, - anchor_pem_len: C.size_t(len(s.config.anchorPEM)), + anchors_cf: anchorsRef, anchor_only: C.bool(s.config.anchorOnly), pinned_public_key_sha256: pinnedPointer, pinned_public_key_sha256_len: C.size_t(len(s.config.pinnedPublicKeySHA256s)), diff --git a/common/httpclient/apple_transport_darwin.h b/common/httpclient/apple_transport_darwin.h index 26d6a77b..4774a6b4 100644 --- a/common/httpclient/apple_transport_darwin.h +++ b/common/httpclient/apple_transport_darwin.h @@ -13,8 +13,7 @@ typedef struct box_apple_http_session_config { uint16_t min_tls_version; uint16_t max_tls_version; bool insecure; - const char *anchor_pem; - size_t anchor_pem_len; + void *anchors_cf; bool anchor_only; const uint8_t *pinned_public_key_sha256; size_t pinned_public_key_sha256_len; diff --git a/common/httpclient/apple_transport_darwin.m b/common/httpclient/apple_transport_darwin.m index d7c09350..1e72a3e0 100644 --- a/common/httpclient/apple_transport_darwin.m +++ b/common/httpclient/apple_transport_darwin.m @@ -36,44 +36,6 @@ static void box_set_error_from_nserror(char **error_out, NSError *error) { box_set_error_string(error_out, error.localizedDescription ?: error.description); } -static NSArray *box_parse_certificates_from_pem(const char *pem, size_t pem_len) { - if (pem == NULL || pem_len == 0) { - return @[]; - } - NSString *content = [[NSString alloc] initWithBytes:pem length:pem_len encoding:NSUTF8StringEncoding]; - if (content == nil) { - return @[]; - } - NSString *beginMarker = @"-----BEGIN CERTIFICATE-----"; - NSString *endMarker = @"-----END CERTIFICATE-----"; - NSMutableArray *certificates = [NSMutableArray array]; - NSUInteger searchFrom = 0; - while (searchFrom < content.length) { - NSRange beginRange = [content rangeOfString:beginMarker options:0 range:NSMakeRange(searchFrom, content.length - searchFrom)]; - if (beginRange.location == NSNotFound) { - break; - } - NSUInteger bodyStart = beginRange.location + beginRange.length; - NSRange endRange = [content rangeOfString:endMarker options:0 range:NSMakeRange(bodyStart, content.length - bodyStart)]; - if (endRange.location == NSNotFound) { - break; - } - NSString *base64Section = [content substringWithRange:NSMakeRange(bodyStart, endRange.location - bodyStart)]; - NSArray *components = [base64Section componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; - NSString *base64Content = [components componentsJoinedByString:@""]; - NSData *der = [[NSData alloc] initWithBase64EncodedString:base64Content options:0]; - if (der != nil) { - SecCertificateRef certificate = SecCertificateCreateWithData(NULL, (__bridge CFDataRef)der); - if (certificate != NULL) { - [certificates addObject:(__bridge id)certificate]; - CFRelease(certificate); - } - } - searchFrom = endRange.location + endRange.length; - } - return certificates; -} - static bool box_evaluate_trust(SecTrustRef trustRef, NSArray *anchors, bool anchor_only, NSDate *verifyDate) { if (trustRef == NULL) { return false; @@ -249,7 +211,11 @@ box_apple_http_session_t *box_apple_http_session_create( if (config != NULL) { delegate.insecure = config->insecure; delegate.anchorOnly = config->anchor_only; - delegate.anchors = box_parse_certificates_from_pem(config->anchor_pem, config->anchor_pem_len); + if (config->anchors_cf != NULL) { + delegate.anchors = (__bridge NSArray *)config->anchors_cf; + } else { + delegate.anchors = @[]; + } if (config->pinned_public_key_sha256 != NULL && config->pinned_public_key_sha256_len > 0) { delegate.pinnedPublicKeyHashes = [NSData dataWithBytes:config->pinned_public_key_sha256 length:config->pinned_public_key_sha256_len]; } diff --git a/common/httpclient/apple_transport_darwin_test.go b/common/httpclient/apple_transport_darwin_test.go index 47c7de6d..17485cc3 100644 --- a/common/httpclient/apple_transport_darwin_test.go +++ b/common/httpclient/apple_transport_darwin_test.go @@ -19,13 +19,16 @@ import ( "strings" "testing" "time" + "unsafe" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/proxybridge" boxTLS "github.com/sagernet/sing-box/common/tls" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/route" "github.com/sagernet/sing/common/json/badoption" + commonLogger "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/service" @@ -58,6 +61,23 @@ type appleHTTPTestServer struct { publicKeyHash []byte } +type appleTestAnchors struct { + ref unsafe.Pointer + releases int +} + +func (a *appleTestAnchors) Retain() adapter.AppleAnchors { + return a +} + +func (a *appleTestAnchors) Release() { + a.releases++ +} + +func (a *appleTestAnchors) Ref() unsafe.Pointer { + return a.ref +} + func TestNewAppleSessionConfig(t *testing.T) { serverCertificate, serverCertificatePEM := newAppleHTTPTestCertificate(t, "localhost") serverHash := certificatePublicKeySHA256(t, serverCertificate.Certificate[0]) @@ -103,8 +123,14 @@ func TestNewAppleSessionConfig(t *testing.T) { if !config.anchorOnly { t.Fatal("expected anchor_only") } - if !strings.Contains(config.anchorPEM, "BEGIN CERTIFICATE") { - t.Fatalf("unexpected anchor pem: %q", config.anchorPEM) + if config.userAnchors == nil { + t.Fatal("expected user anchors") + } + if config.userAnchors.Ref() == nil { + t.Fatal("expected non-empty user anchors") + } + if config.store != nil { + t.Fatal("unexpected store reference") } if len(config.pinnedPublicKeySHA256s) != 0 { t.Fatalf("unexpected pinned hashes: %d", len(config.pinnedPublicKeySHA256s)) @@ -137,8 +163,8 @@ func TestNewAppleSessionConfig(t *testing.T) { if !bytes.Equal(config.pinnedPublicKeySHA256s[applePinnedHashSize:], otherHash) { t.Fatal("unexpected second pin") } - if config.anchorPEM != "" { - t.Fatalf("unexpected anchor pem: %q", config.anchorPEM) + if config.userAnchors != nil { + t.Fatal("unexpected user anchors") } if config.anchorOnly { t.Fatal("unexpected anchor_only") @@ -392,6 +418,46 @@ func TestAppleTransportVerifyPublicKeySHA256(t *testing.T) { } } +func TestNewAppleTransportClosesSessionConfigOnBridgeFailure(t *testing.T) { + _, serverCertificatePEM := newAppleHTTPTestCertificate(t, "localhost") + restoreAppleTransportFactories(t) + testAnchors := &appleTestAnchors{ref: unsafe.Pointer(new(int))} + newAppleUserAnchors = func([]byte) (adapter.AppleAnchors, error) { + return testAnchors, nil + } + newAppleProxyBridge = func(context.Context, commonLogger.ContextLogger, string, N.Dialer) (*proxybridge.Bridge, error) { + return nil, errors.New("bridge boom") + } + + _, err := newAppleTransport(newAppleHTTPTestContext(), log.NewNOPFactory().NewLogger("httpclient"), &appleHTTPTestDialer{}, appleTransportAnchorOptions(serverCertificatePEM)) + if err == nil || !strings.Contains(err.Error(), "bridge boom") { + t.Fatalf("unexpected error: %v", err) + } + if testAnchors.releases != 1 { + t.Fatalf("expected 1 anchor release, got %d", testAnchors.releases) + } +} + +func TestNewAppleTransportClosesSessionConfigOnSessionFailure(t *testing.T) { + _, serverCertificatePEM := newAppleHTTPTestCertificate(t, "localhost") + restoreAppleTransportFactories(t) + testAnchors := &appleTestAnchors{ref: unsafe.Pointer(new(int))} + newAppleUserAnchors = func([]byte) (adapter.AppleAnchors, error) { + return testAnchors, nil + } + newAppleTransportSession = func(*appleTransportShared) (unsafe.Pointer, error) { + return nil, errors.New("session boom") + } + + _, err := newAppleTransport(newAppleHTTPTestContext(), log.NewNOPFactory().NewLogger("httpclient"), &appleHTTPTestDialer{}, appleTransportAnchorOptions(serverCertificatePEM)) + if err == nil || !strings.Contains(err.Error(), "session boom") { + t.Fatalf("unexpected error: %v", err) + } + if testAnchors.releases != 1 { + t.Fatalf("expected 1 anchor release, got %d", testAnchors.releases) + } +} + func TestAppleTransportRoundTripHTTPS(t *testing.T) { requests := make(chan appleHTTPObservedRequest, 1) server := startAppleHTTPTestServer(t, func(w http.ResponseWriter, r *http.Request) { @@ -665,7 +731,8 @@ func TestAppleTransportLifecycle(t *testing.T) { assertAppleHTTPSucceeds(t, transport, server.URL("/reset")) innerTransport := transport.(*appleTransport) - if err := innerTransport.Close(); err != nil { + err := innerTransport.Close() + if err != nil { t.Fatal(err) } @@ -722,10 +789,7 @@ func (s *appleHTTPTestServer) URL(path string) string { func newAppleHTTPTestTransport(t *testing.T, server *appleHTTPTestServer, options option.HTTPClientOptions) innerTransport { t.Helper() - ctx := service.ContextWith[adapter.ConnectionManager]( - context.Background(), - route.NewConnectionManager(log.NewNOPFactory().NewLogger("connection")), - ) + ctx := newAppleHTTPTestContext() dialer := &appleHTTPTestDialer{ hostMap: make(map[string]string), } @@ -743,6 +807,39 @@ func newAppleHTTPTestTransport(t *testing.T, server *appleHTTPTestServer, option return transport } +func newAppleHTTPTestContext() context.Context { + return service.ContextWith[adapter.ConnectionManager]( + context.Background(), + route.NewConnectionManager(log.NewNOPFactory().NewLogger("connection")), + ) +} + +func appleTransportAnchorOptions(certificatePEM string) option.HTTPClientOptions { + return option.HTTPClientOptions{ + Version: 2, + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "localhost", + MinVersion: "1.2", + Certificate: badoption.Listable[string]{certificatePEM}, + }, + }, + } +} + +func restoreAppleTransportFactories(t *testing.T) { + t.Helper() + oldAnchors := newAppleUserAnchors + oldBridge := newAppleProxyBridge + oldSession := newAppleTransportSession + t.Cleanup(func() { + newAppleUserAnchors = oldAnchors + newAppleProxyBridge = oldBridge + newAppleTransportSession = oldSession + }) +} + func (d *appleHTTPTestDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { host := destination.AddrString() if destination.IsDomain() { diff --git a/common/httpclient/client.go b/common/httpclient/client.go index a6fde9c0..9dbb8cc1 100644 --- a/common/httpclient/client.go +++ b/common/httpclient/client.go @@ -50,7 +50,7 @@ func NewTransport(ctx context.Context, logger logger.ContextLogger, tag string, } managedTransport.epoch.Store(&transportEpoch{transport: inner}) return managedTransport, nil - case C.TLSEngineDefault, "go": + case "", C.TLSEngineGo: cheapRebuild = true default: return nil, E.New("unknown HTTP engine: ", options.Engine) diff --git a/common/schannel/doc.go b/common/schannel/doc.go new file mode 100644 index 00000000..bdcb2f1a --- /dev/null +++ b/common/schannel/doc.go @@ -0,0 +1,5 @@ +// Package schannel wraps the Windows Schannel security provider (SSPI) for +// client-side TLS. The public API is implemented on Windows only; on other +// platforms the package is empty and intended for transitive imports from +// build-tagged callers. +package schannel diff --git a/common/schannel/schannel_windows.go b/common/schannel/schannel_windows.go new file mode 100644 index 00000000..d912ca23 --- /dev/null +++ b/common/schannel/schannel_windows.go @@ -0,0 +1,719 @@ +package schannel + +import ( + "bytes" + "crypto/tls" + "encoding/binary" + "os" + "sync" + "syscall" + "unsafe" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/sys/windows" +) + +const clientCredentialFlags = schCredManualCredValidation | schCredNoDefaultCreds | schUseStrongCrypto + +var versionCheck = sync.OnceValue(func() error { + major, _, build := windows.RtlGetNtVersionNumbers() + build &= 0xffff + if major < 10 || (major == 10 && build < 17763) { + return E.New("Windows TLS engine requires Windows build 17763 or later (Windows 10 version 1809, Windows Server 2019, or newer)") + } + return nil +}) + +// CheckPlatform returns an error when the running Windows version does not +// support the SCH_CREDENTIALS structure used by this package. +func CheckPlatform() error { + return versionCheck() +} + +type clientCredentialKey struct { + disabledProtocols uint32 + flags uint32 +} + +type clientCredential struct { + key clientCredentialKey + once sync.Once + handle secHandle + tlsParams tlsParameters + err error +} + +var clientCredentialCache sync.Map + +func cachedClientCredential(minVersion, maxVersion uint16) (*clientCredential, error) { + key := clientCredentialKey{ + disabledProtocols: disabledProtocolsMask(minVersion, maxVersion), + flags: clientCredentialFlags, + } + actual, _ := clientCredentialCache.LoadOrStore(key, &clientCredential{key: key}) + credential := actual.(*clientCredential) + credential.once.Do(func() { + credential.err = credential.acquire() + }) + if credential.err != nil { + clientCredentialCache.Delete(key) + return nil, credential.err + } + return credential, nil +} + +func (c *clientCredential) acquire() error { + c.tlsParams.grbitDisabledProtocols = c.key.disabledProtocols + sch := schCredentials{ + dwVersion: schCredentialsVersion, + dwFlags: c.key.flags, + cTlsParameters: 1, + pTlsParameters: &c.tlsParams, + } + pkg, err := windows.UTF16PtrFromString(unispNameW) + if err != nil { + return err + } + var expiry windows.Filetime + status := sspiAcquireCredentialsHandle( + nil, + pkg, + secPkgCredOutbound, + nil, + unsafe.Pointer(&sch), + 0, + 0, + &c.handle, + &expiry, + ) + if status != secEOK { + return sspiError("AcquireCredentialsHandle", status) + } + return nil +} + +// ClientContext owns the per-connection Schannel security context and drives +// it through handshake and application-data phases. +type ClientContext struct { + credential *clientCredential + handle secHandle + targetName *uint16 + + // alpnBuffer is the SEC_APPLICATION_PROTOCOLS blob; kept alive for the + // duration of the first handshake call. + alpnBuffer []byte + + firstCall bool + valid bool +} + +// NewClientContext allocates a new client context, reuses the Schannel +// credential handle for the supplied TLS version bounds, and advertises ALPN +// protocols through an SECBUFFER_APPLICATION_PROTOCOLS buffer on the first +// handshake call. +func NewClientContext(minVersion, maxVersion uint16, serverName string, alpn []string) (*ClientContext, error) { + if minVersion != 0 && maxVersion != 0 && minVersion > maxVersion { + return nil, os.ErrInvalid + } + err := CheckPlatform() + if err != nil { + return nil, err + } + targetName, err := windows.UTF16PtrFromString(serverName) + if err != nil { + return nil, err + } + credential, err := cachedClientCredential(minVersion, maxVersion) + if err != nil { + return nil, err + } + c := &ClientContext{ + credential: credential, + targetName: targetName, + firstCall: true, + } + if len(alpn) > 0 { + c.alpnBuffer, err = encodeAlpnBuffer(alpn) + if err != nil { + return nil, err + } + } + return c, nil +} + +// Close releases the per-connection security context. Safe to call multiple +// times. +func (c *ClientContext) Close() { + if c == nil { + return + } + if c.valid { + sspiDeleteSecurityContext(&c.handle) + c.valid = false + c.handle = secHandle{} + } +} + +type StepResult struct { + // Output must be written to the peer verbatim before the next Step call. + // When Done is true, leftover input[Consumed:] is the first application + // ciphertext — not more handshake bytes. + Output []byte + Consumed int + Done bool + Incomplete bool +} + +// Step drives one handshake iteration. Input may be nil on the first call. +// Callers must write Output to the peer, append more peer bytes when +// Incomplete is true, and loop until Done is true. +func (c *ClientContext) Step(input []byte) (StepResult, error) { + var inputDesc *secBufferDesc + var inputBufs [2]secBuffer + if c.firstCall { + if len(c.alpnBuffer) > 0 { + inputBufs[0].bufferType = secbufferApplicationProtocols + inputBufs[0].cbBuffer = uint32(len(c.alpnBuffer)) + inputBufs[0].pvBuffer = &c.alpnBuffer[0] + inputDesc = &secBufferDesc{ + ulVersion: secbufferVersion, + cBuffers: 1, + pBuffers: &inputBufs[0], + } + } + } else { + if len(input) == 0 { + return StepResult{}, E.New("schannel: empty handshake input after first step") + } + inputBufs[0].bufferType = secbufferToken + inputBufs[0].cbBuffer = uint32(len(input)) + inputBufs[0].pvBuffer = &input[0] + inputBufs[1].bufferType = secbufferEmpty + inputDesc = &secBufferDesc{ + ulVersion: secbufferVersion, + cBuffers: 2, + pBuffers: &inputBufs[0], + } + } + + result, terminal, err := c.runInitializeSecurityContext(inputDesc, "InitializeSecurityContext") + if err != nil || terminal { + return result, err + } + + switch { + case c.firstCall: + result.Consumed = 0 + case inputBufs[1].bufferType == secbufferExtra && inputBufs[1].cbBuffer > 0: + consumed, extraErr := consumedFromExtra(&inputBufs[1], len(input)) + if extraErr != nil { + return result, extraErr + } + result.Consumed = consumed + default: + result.Consumed = len(input) + } + + c.firstCall = false + c.alpnBuffer = nil + return result, nil +} + +// StreamSizes must be called after Step returns Done=true. +func (c *ClientContext) StreamSizes() (header, trailer, maxMessage uint32, err error) { + var sizes secPkgContextStreamSizes + status := sspiQueryContextAttributes(&c.handle, secpkgAttrStreamSizes, unsafe.Pointer(&sizes)) + if status != secEOK { + return 0, 0, 0, sspiError("QueryContextAttributes(stream sizes)", status) + } + return sizes.cbHeader, sizes.cbTrailer, sizes.cbMaximumMessage, nil +} + +// Encrypt wraps a plaintext chunk into a TLS record using the supplied +// backing buffer which must have room for header + plaintext + trailer bytes. +// Plaintext is copied into buffer starting at `header` offset before calling +// EncryptMessage. Returns the encrypted record as a slice into buffer. +func (c *ClientContext) Encrypt(header, trailer uint32, plaintext []byte, buffer []byte) ([]byte, error) { + if len(buffer) < int(header)+len(plaintext)+int(trailer) { + return nil, E.New("schannel: encrypt buffer too small") + } + copy(buffer[header:], plaintext) + headerPtr := &buffer[0] + dataPtr := &buffer[header] + trailerPtr := &buffer[int(header)+len(plaintext)] + + bufs := [4]secBuffer{ + {cbBuffer: header, bufferType: secbufferStreamHeader, pvBuffer: headerPtr}, + {cbBuffer: uint32(len(plaintext)), bufferType: secbufferData, pvBuffer: dataPtr}, + {cbBuffer: trailer, bufferType: secbufferStreamTrailer, pvBuffer: trailerPtr}, + {bufferType: secbufferEmpty}, + } + desc := secBufferDesc{ + ulVersion: secbufferVersion, + cBuffers: 4, + pBuffers: &bufs[0], + } + status := sspiEncryptMessage(&c.handle, 0, &desc, 0) + if status != secEOK { + return nil, sspiError("EncryptMessage", status) + } + total := int(bufs[0].cbBuffer + bufs[1].cbBuffer + bufs[2].cbBuffer) + return buffer[:total], nil +} + +type DecryptResult struct { + // Plaintext aliases memory inside the input buffer passed to Decrypt; + // callers must copy before the next Decrypt call reuses that buffer. + Plaintext []byte + // ConsumedTotal is the number of input bytes Schannel consumed, i.e. + // input[ConsumedTotal:] are unprocessed leftover ciphertext. + ConsumedTotal int + // RenegotiateToken aliases the post-handshake token that must be fed back + // through InitializeSecurityContext after SEC_I_RENEGOTIATE. + RenegotiateToken []byte + Incomplete bool + Renegotiate bool + Expired bool +} + +// Decrypt processes a chunk of TLS ciphertext in-place. The returned Plaintext +// aliases memory inside input until the next Decrypt call; callers must copy +// the bytes they want to keep. +func (c *ClientContext) Decrypt(input []byte) (DecryptResult, error) { + var result DecryptResult + if len(input) == 0 { + result.Incomplete = true + return result, nil + } + bufs := [4]secBuffer{ + {cbBuffer: uint32(len(input)), bufferType: secbufferData, pvBuffer: &input[0]}, + {bufferType: secbufferEmpty}, + {bufferType: secbufferEmpty}, + {bufferType: secbufferEmpty}, + } + desc := secBufferDesc{ + ulVersion: secbufferVersion, + cBuffers: 4, + pBuffers: &bufs[0], + } + status := sspiDecryptMessage(&c.handle, &desc, 0, nil) + switch status { + case secEOK: + case secEIncompleteMessage: + result.Incomplete = true + return result, nil + case secIContextExpired: + result.Expired = true + return result, nil + case secIRenegotiate: + result.Renegotiate = true + default: + return result, sspiError("DecryptMessage", status) + } + return parseDecryptResult(input, bufs[:], result.Renegotiate) +} + +// PostHandshake processes a TLS 1.3 post-handshake message +// (NewSessionTicket, KeyUpdate) after DecryptMessage returned +// SEC_I_RENEGOTIATE. Pass the token preserved from Decrypt on the first call; +// pass more peer bytes on subsequent calls when Incomplete. +func (c *ClientContext) PostHandshake(input []byte) (StepResult, error) { + var inputDesc *secBufferDesc + var inputBufs [2]secBuffer + if len(input) > 0 { + inputBufs[0].bufferType = secbufferToken + inputBufs[0].cbBuffer = uint32(len(input)) + inputBufs[0].pvBuffer = &input[0] + inputBufs[1].bufferType = secbufferEmpty + inputDesc = &secBufferDesc{ + ulVersion: secbufferVersion, + cBuffers: 2, + pBuffers: &inputBufs[0], + } + } + + result, terminal, err := c.runInitializeSecurityContext(inputDesc, "InitializeSecurityContext(post-handshake)") + if err != nil || terminal { + return result, err + } + + if len(input) > 0 && inputBufs[1].bufferType == secbufferExtra && inputBufs[1].cbBuffer > 0 { + consumed, extraErr := consumedFromExtra(&inputBufs[1], len(input)) + if extraErr != nil { + return result, extraErr + } + result.Consumed = consumed + } else { + result.Consumed = len(input) + } + return result, nil +} + +func parseDecryptResult(input []byte, bufs []secBuffer, renegotiate bool) (DecryptResult, error) { + var result DecryptResult + var dataBuffer, extraBuffer *secBuffer + for index := range bufs { + switch bufs[index].bufferType { + case secbufferData: + dataBuffer = &bufs[index] + case secbufferExtra: + extraBuffer = &bufs[index] + } + } + if dataBuffer != nil && dataBuffer.cbBuffer > 0 && dataBuffer.pvBuffer != nil { + result.Plaintext = unsafe.Slice(dataBuffer.pvBuffer, int(dataBuffer.cbBuffer)) + } + if extraBuffer != nil && extraBuffer.cbBuffer > 0 { + consumed, err := consumedFromExtra(extraBuffer, len(input)) + if err != nil { + return result, err + } + result.ConsumedTotal = consumed + } else { + result.ConsumedTotal = len(input) + } + if renegotiate { + result.Renegotiate = true + if extraBuffer != nil && extraBuffer.cbBuffer > 0 { + result.RenegotiateToken = input[result.ConsumedTotal:] + } else { + result.RenegotiateToken = input + } + } + return result, nil +} + +// ApplicationProtocol returns the empty string when ALPN was not negotiated. +func (c *ClientContext) ApplicationProtocol() (string, error) { + var info secPkgContextApplicationProtocol + status := sspiQueryContextAttributes(&c.handle, secpkgAttrApplicationProtocol, unsafe.Pointer(&info)) + if status != secEOK { + return "", sspiError("QueryContextAttributes(application protocol)", status) + } + if info.protoNegoStatus != secApplicationProtocolNegotiationStatusSuccess { + return "", nil + } + size := int(info.protocolIDSize) + if size > len(info.protocolID) { + return "", E.New("schannel: invalid ALPN protocol size") + } + return string(info.protocolID[:size]), nil +} + +// ConnectionInfo reports the negotiated TLS version and cipher suite. +// cipherSuite may be zero when the Windows build does not return a +// mappable cipher name. +func (c *ClientContext) ConnectionInfo() (version, cipherSuite uint16, err error) { + var info secPkgContextConnectionInfo + status := sspiQueryContextAttributes(&c.handle, secpkgAttrConnectionInfo, unsafe.Pointer(&info)) + if status != secEOK { + return 0, 0, sspiError("QueryContextAttributes(connection info)", status) + } + version = sspProtocolToTLSVersion(info.dwProtocol) + + var cipherInfo secPkgContextCipherInfo + cipherInfo.dwVersion = 1 + status = sspiQueryContextAttributes(&c.handle, secpkgAttrCipherInfo, unsafe.Pointer(&cipherInfo)) + if status == secEOK { + cipherSuite = cipherSuiteID(windows.UTF16ToString(cipherInfo.szCipherSuite[:])) + } + return version, cipherSuite, nil +} + +func cipherSuiteID(name string) uint16 { + for _, suite := range tls.CipherSuites() { + if suite.Name == name { + return suite.ID + } + } + for _, suite := range tls.InsecureCipherSuites() { + if suite.Name == name { + return suite.ID + } + } + return 0 +} + +// RemoteCertificateChain returns freshly allocated DER bytes ordered +// leaf → intermediates. +func (c *ClientContext) RemoteCertificateChain() ([][]byte, error) { + var leaf *windows.CertContext + status := sspiQueryContextAttributes(&c.handle, secpkgAttrRemoteCertContext, unsafe.Pointer(&leaf)) + if status != secEOK { + return nil, sspiError("QueryContextAttributes(remote cert context)", status) + } + if leaf == nil { + return nil, nil + } + defer windows.CertFreeCertificateContext(leaf) + + chain, err := buildCertChainDER(leaf) + if err != nil { + return [][]byte{certContextDER(leaf)}, nil + } + return chain, nil +} + +const handshakeContextReq = iscReqSequenceDetect | + iscReqReplayDetect | + iscReqConfidentiality | + iscReqAllocateMemory | + iscReqStream | + iscReqUseSuppliedCreds | + iscReqManualCredValidation | + iscReqExtendedError + +// runInitializeSecurityContext returns terminal=true when the result is +// final (error or more-data-needed), signalling that the caller must skip +// extra-buffer post-processing. +func (c *ClientContext) runInitializeSecurityContext(inputDesc *secBufferDesc, opLabel string) (StepResult, bool, error) { + var outputBufs [1]secBuffer + outputBufs[0].bufferType = secbufferToken + outputDesc := secBufferDesc{ + ulVersion: secbufferVersion, + cBuffers: 1, + pBuffers: &outputBufs[0], + } + var ctxIn *secHandle + if c.valid { + ctxIn = &c.handle + } + var contextAttr uint32 + var expiry windows.Filetime + status := sspiInitializeSecurityContext( + &c.credential.handle, + ctxIn, + c.targetName, + handshakeContextReq, + 0, + 0, + inputDesc, + 0, + &c.handle, + &outputDesc, + &contextAttr, + &expiry, + ) + + switch status { + case secEOK, secICompleteNeeded, secICompleteAndContinue, secIContinueNeeded: + c.valid = true + } + if status == secICompleteNeeded || status == secICompleteAndContinue { + completeStatus := sspiCompleteAuthToken(&c.handle, &outputDesc) + if completeStatus != secEOK { + if outputBufs[0].pvBuffer != nil { + sspiFreeContextBuffer(outputBufs[0].pvBuffer) + } + return StepResult{}, true, sspiError("CompleteAuthToken", completeStatus) + } + } + + var result StepResult + if outputBufs[0].cbBuffer > 0 && outputBufs[0].pvBuffer != nil { + result.Output = unsafeSliceCopy(outputBufs[0].pvBuffer, int(outputBufs[0].cbBuffer)) + sspiFreeContextBuffer(outputBufs[0].pvBuffer) + } + + switch status { + case secEOK, secICompleteNeeded: + result.Done = true + return result, false, nil + case secIContinueNeeded, secICompleteAndContinue: + return result, false, nil + case secEIncompleteMessage: + c.valid = true + result.Incomplete = true + return result, true, nil + default: + return result, true, sspiError(opLabel, status) + } +} + +func consumedFromExtra(extraBuf *secBuffer, inputLen int) (int, error) { + extraLen := int(extraBuf.cbBuffer) + if extraLen > inputLen { + return 0, E.New("schannel: SECBUFFER_EXTRA exceeds input length") + } + return inputLen - extraLen, nil +} + +func disabledProtocolsMask(minVersion, maxVersion uint16) uint32 { + allowed := uint32(0) + versions := []struct { + id uint16 + mask uint32 + }{ + {tls.VersionTLS10, spProtTLS10Client}, + {tls.VersionTLS11, spProtTLS11Client}, + {tls.VersionTLS12, spProtTLS12Client}, + {tls.VersionTLS13, spProtTLS13Client}, + } + effectiveMin := minVersion + if effectiveMin == 0 { + effectiveMin = tls.VersionTLS12 + if maxVersion != 0 && maxVersion < tls.VersionTLS12 { + effectiveMin = versions[0].id + } + } + effectiveMax := maxVersion + if effectiveMax == 0 { + effectiveMax = tls.VersionTLS13 + } + for _, v := range versions { + if v.id >= effectiveMin && v.id <= effectiveMax { + allowed |= v.mask + } + } + if allowed == 0 { + return 0 + } + return spProtAllTLSClients &^ allowed +} + +func sspProtocolToTLSVersion(sp uint32) uint16 { + switch { + case sp&spProtTLS13Client != 0: + return tls.VersionTLS13 + case sp&spProtTLS12Client != 0: + return tls.VersionTLS12 + case sp&spProtTLS11Client != 0: + return tls.VersionTLS11 + case sp&spProtTLS10Client != 0: + return tls.VersionTLS10 + } + return 0 +} + +func encodeAlpnBuffer(protocols []string) ([]byte, error) { + var protoList []byte + for _, proto := range protocols { + if len(proto) == 0 || len(proto) > 255 { + return nil, E.New("schannel: invalid ALPN protocol: ", proto) + } + protoList = append(protoList, byte(len(proto))) + protoList = append(protoList, []byte(proto)...) + } + if len(protoList) > 0xFFFF { + return nil, E.New("schannel: ALPN list too long") + } + // Layout: + // uint32 ProtocolListsSize + // uint32 ProtoNegoExt + // uint16 ProtocolListSize + // bytes ProtocolList + inner := 4 + 2 + len(protoList) + buffer := make([]byte, 4+inner) + binary.LittleEndian.PutUint32(buffer[0:4], uint32(inner)) + binary.LittleEndian.PutUint32(buffer[4:8], secApplicationProtocolNegotiationExtALPN) + binary.LittleEndian.PutUint16(buffer[8:10], uint16(len(protoList))) + copy(buffer[10:], protoList) + return buffer, nil +} + +func unsafeSliceCopy(ptr *byte, size int) []byte { + if ptr == nil || size <= 0 { + return nil + } + out := make([]byte, size) + copy(out, unsafe.Slice(ptr, size)) + return out +} + +func certContextDER(ctx *windows.CertContext) []byte { + if ctx == nil || ctx.EncodedCert == nil || ctx.Length == 0 { + return nil + } + out := make([]byte, ctx.Length) + copy(out, unsafe.Slice(ctx.EncodedCert, int(ctx.Length))) + return out +} + +func buildCertChainDER(leaf *windows.CertContext) ([][]byte, error) { + var chainPara windows.CertChainPara + chainPara.Size = uint32(unsafe.Sizeof(chainPara)) + var chainCtx *windows.CertChainContext + err := windows.CertGetCertificateChain(0, leaf, nil, leaf.Store, &chainPara, 0, 0, &chainCtx) + if err != nil { + return nil, err + } + defer windows.CertFreeCertificateChain(chainCtx) + return extractCertChainDER(chainCtx) +} + +func extractCertChainDER(chainCtx *windows.CertChainContext) ([][]byte, error) { + if chainCtx == nil || chainCtx.ChainCount == 0 || chainCtx.Chains == nil { + return nil, E.New("schannel: empty certificate chain") + } + chains := unsafe.Slice(chainCtx.Chains, int(chainCtx.ChainCount)) + chain := chains[0] + if chain == nil || chain.NumElements == 0 || chain.Elements == nil { + return nil, E.New("schannel: empty certificate chain") + } + elements := unsafe.Slice(chain.Elements, int(chain.NumElements)) + if len(elements) > 1 && + chain.TrustStatus.ErrorStatus&windows.CERT_TRUST_IS_PARTIAL_CHAIN == 0 && + isSelfSignedCertContext(elements[len(elements)-1].CertContext) { + elements = elements[:len(elements)-1] + } + derChain := make([][]byte, 0, len(elements)) + for index, element := range elements { + if element == nil || element.CertContext == nil { + return nil, E.New("schannel: missing certificate chain element ", index) + } + der := certContextDER(element.CertContext) + if len(der) == 0 { + return nil, E.New("schannel: empty certificate chain element ", index) + } + derChain = append(derChain, der) + } + return derChain, nil +} + +func isSelfSignedCertContext(ctx *windows.CertContext) bool { + if ctx == nil || ctx.CertInfo == nil { + return false + } + return bytes.Equal( + certNameBlobBytes(ctx.CertInfo.Issuer), + certNameBlobBytes(ctx.CertInfo.Subject), + ) +} + +func certNameBlobBytes(blob windows.CertNameBlob) []byte { + if blob.Size == 0 || blob.Data == nil { + return nil + } + return unsafe.Slice(blob.Data, int(blob.Size)) +} + +func sspiError(where string, status syscall.Errno) error { + return E.New("schannel: ", where, ": ", formatStatus(status)) +} + +var statusNames = map[syscall.Errno]string{ + secEUnsupportedFunc: "SEC_E_UNSUPPORTED_FUNCTION", + secEInternalError: "SEC_E_INTERNAL_ERROR", + secEInvalidToken: "SEC_E_INVALID_TOKEN", + secELogonDenied: "SEC_E_LOGON_DENIED", + secEMessageAltered: "SEC_E_MESSAGE_ALTERED", + secENoAuthenticatingAuthority: "SEC_E_NO_AUTHENTICATING_AUTHORITY", + secEContextExpired: "SEC_E_CONTEXT_EXPIRED", + secEIncompleteMessage: "SEC_E_INCOMPLETE_MESSAGE", + secEIncompleteCreds: "SEC_E_INCOMPLETE_CREDENTIALS", + secEBufferTooSmall: "SEC_E_BUFFER_TOO_SMALL", + secEWrongPrincipal: "SEC_E_WRONG_PRINCIPAL", + secEIllegalMessage: "SEC_E_ILLEGAL_MESSAGE", + secECertUnknown: "SEC_E_CERT_UNKNOWN", + secECertExpired: "SEC_E_CERT_EXPIRED", + secEAlgorithmMismatch: "SEC_E_ALGORITHM_MISMATCH", +} + +func formatStatus(status syscall.Errno) string { + name, loaded := statusNames[status] + if !loaded { + return status.Error() + } + return name + ": " + status.Error() +} diff --git a/common/schannel/schannel_windows_test.go b/common/schannel/schannel_windows_test.go new file mode 100644 index 00000000..35bcaabb --- /dev/null +++ b/common/schannel/schannel_windows_test.go @@ -0,0 +1,188 @@ +//go:build windows + +package schannel + +import ( + "bytes" + "crypto/tls" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +func TestExtractCertChainDERExcludesSelfSignedRoot(t *testing.T) { + leaf := certContextForTest([]byte("leaf"), []byte("intermediate"), []byte("leaf")) + intermediate := certContextForTest([]byte("intermediate"), []byte("root"), []byte("intermediate")) + root := certContextForTest([]byte("root"), []byte("root"), []byte("root")) + + chainCtx := certChainContextForTest(leaf, intermediate, root) + derChain, err := extractCertChainDER(chainCtx) + if err != nil { + t.Fatal(err) + } + if len(derChain) != 2 { + t.Fatalf("expected 2 certificates, got %d", len(derChain)) + } + if !bytes.Equal(derChain[0], []byte("leaf")) { + t.Fatalf("unexpected leaf certificate: %q", string(derChain[0])) + } + if !bytes.Equal(derChain[1], []byte("intermediate")) { + t.Fatalf("unexpected intermediate certificate: %q", string(derChain[1])) + } +} + +func TestExtractCertChainDERKeepsLastIntermediateWithoutRoot(t *testing.T) { + leaf := certContextForTest([]byte("leaf"), []byte("intermediate"), []byte("leaf")) + intermediate := certContextForTest([]byte("intermediate"), []byte("root"), []byte("intermediate")) + + chainCtx := certChainContextForTest(leaf, intermediate) + derChain, err := extractCertChainDER(chainCtx) + if err != nil { + t.Fatal(err) + } + if len(derChain) != 2 { + t.Fatalf("expected 2 certificates, got %d", len(derChain)) + } + if !bytes.Equal(derChain[1], []byte("intermediate")) { + t.Fatalf("unexpected last certificate: %q", string(derChain[1])) + } +} + +func TestDisabledProtocolsMask(t *testing.T) { + testCases := []struct { + name string + minVersion uint16 + maxVersion uint16 + want uint32 + }{ + { + name: "default range", + want: spProtAllTLSClients &^ (spProtTLS12Client | spProtTLS13Client), + }, + { + name: "default minimum with explicit max", + maxVersion: tls.VersionTLS12, + want: spProtAllTLSClients &^ spProtTLS12Client, + }, + { + name: "explicit tls10 range", + minVersion: tls.VersionTLS10, + maxVersion: tls.VersionTLS13, + want: 0, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + got := disabledProtocolsMask(testCase.minVersion, testCase.maxVersion) + if got != testCase.want { + t.Fatalf("disabledProtocolsMask(%#x, %#x) = %#x, want %#x", testCase.minVersion, testCase.maxVersion, got, testCase.want) + } + }) + } +} + +func TestClientCredentialCacheReusesVersionRange(t *testing.T) { + if err := CheckPlatform(); err != nil { + t.Skip(err) + } + first, err := NewClientContext(tls.VersionTLS12, tls.VersionTLS13, "localhost", []string{"h2"}) + if err != nil { + t.Fatal(err) + } + defer first.Close() + second, err := NewClientContext(tls.VersionTLS12, tls.VersionTLS13, "example.com", []string{"http/1.1"}) + if err != nil { + t.Fatal(err) + } + defer second.Close() + if first.credential != second.credential { + t.Fatal("expected same TLS version range to reuse credential") + } + + tls12Only, err := NewClientContext(tls.VersionTLS12, tls.VersionTLS12, "localhost", nil) + if err != nil { + t.Fatal(err) + } + defer tls12Only.Close() + if first.credential == tls12Only.credential { + t.Fatal("expected distinct TLS version range to use a distinct credential") + } + if first.credential.key.disabledProtocols != disabledProtocolsMask(tls.VersionTLS12, tls.VersionTLS13) { + t.Fatalf("unexpected cached disabled protocol mask: %#x", first.credential.key.disabledProtocols) + } +} + +func TestParseDecryptResultKeepsRenegotiateExtraToken(t *testing.T) { + input := []byte("plain-ticket") + result, err := parseDecryptResult(input, []secBuffer{ + {bufferType: secbufferExtra, cbBuffer: 6}, + }, true) + if err != nil { + t.Fatal(err) + } + if !result.Renegotiate { + t.Fatal("expected Renegotiate to be true") + } + if result.ConsumedTotal != len(input)-6 { + t.Fatalf("unexpected consumed total: %d", result.ConsumedTotal) + } + if !bytes.Equal(result.RenegotiateToken, []byte("ticket")) { + t.Fatalf("unexpected renegotiate token: %q", string(result.RenegotiateToken)) + } +} + +func TestParseDecryptResultKeepsRenegotiateWholeBufferWithoutExtra(t *testing.T) { + input := []byte("ticket") + result, err := parseDecryptResult(input, []secBuffer{ + {bufferType: secbufferData, cbBuffer: uint32(len(input)), pvBuffer: &input[0]}, + }, true) + if err != nil { + t.Fatal(err) + } + if !result.Renegotiate { + t.Fatal("expected Renegotiate to be true") + } + if result.ConsumedTotal != len(input) { + t.Fatalf("unexpected consumed total: %d", result.ConsumedTotal) + } + if !bytes.Equal(result.RenegotiateToken, input) { + t.Fatalf("unexpected renegotiate token: %q", string(result.RenegotiateToken)) + } +} + +func certChainContextForTest(certs ...*windows.CertContext) *windows.CertChainContext { + elements := make([]*windows.CertChainElement, 0, len(certs)) + for _, cert := range certs { + elements = append(elements, &windows.CertChainElement{CertContext: cert}) + } + simpleChain := &windows.CertSimpleChain{ + NumElements: uint32(len(elements)), + Elements: &elements[0], + } + chains := []*windows.CertSimpleChain{simpleChain} + return &windows.CertChainContext{ + ChainCount: 1, + Chains: &chains[0], + } +} + +func certContextForTest(der, issuer, subject []byte) *windows.CertContext { + certInfo := &windows.CertInfo{ + Issuer: certNameBlobForTest(issuer), + Subject: certNameBlobForTest(subject), + } + return &windows.CertContext{ + EncodedCert: &der[0], + Length: uint32(len(der)), + CertInfo: certInfo, + } +} + +func certNameBlobForTest(value []byte) windows.CertNameBlob { + return windows.CertNameBlob{ + Size: uint32(len(value)), + Data: (*byte)(unsafe.Pointer(&value[0])), + } +} diff --git a/common/schannel/syscall_windows.go b/common/schannel/syscall_windows.go new file mode 100644 index 00000000..654869b6 --- /dev/null +++ b/common/schannel/syscall_windows.go @@ -0,0 +1,28 @@ +package schannel + +import ( + "syscall" + "unsafe" +) + +//go:generate go run golang.org/x/sys/windows/mkwinsyscall -output zsyscall_windows.go syscall_windows.go + +// secur32.dll — SSPI / Schannel interface + +//sys sspiAcquireCredentialsHandle(principal *uint16, pkgname *uint16, credentialUse uint32, logonID *uint64, authData unsafe.Pointer, getKeyFn uintptr, getKeyArg uintptr, credential *secHandle, expiry *windows.Filetime) (ret syscall.Errno) = secur32.AcquireCredentialsHandleW +//sys sspiFreeCredentialsHandle(credential *secHandle) (ret syscall.Errno) = secur32.FreeCredentialsHandle +//sys sspiInitializeSecurityContext(credential *secHandle, context *secHandle, targetName *uint16, contextReq uint32, reserved1 uint32, targetDataRep uint32, input *secBufferDesc, reserved2 uint32, newContext *secHandle, output *secBufferDesc, contextAttr *uint32, expiry *windows.Filetime) (ret syscall.Errno) = secur32.InitializeSecurityContextW +//sys sspiDeleteSecurityContext(context *secHandle) (ret syscall.Errno) = secur32.DeleteSecurityContext +//sys sspiQueryContextAttributes(context *secHandle, attribute uint32, buffer unsafe.Pointer) (ret syscall.Errno) = secur32.QueryContextAttributesW +//sys sspiEncryptMessage(context *secHandle, qop uint32, message *secBufferDesc, sequenceNumber uint32) (ret syscall.Errno) = secur32.EncryptMessage +//sys sspiDecryptMessage(context *secHandle, message *secBufferDesc, sequenceNumber uint32, qop *uint32) (ret syscall.Errno) = secur32.DecryptMessage +//sys sspiFreeContextBuffer(buffer *byte) (ret syscall.Errno) = secur32.FreeContextBuffer + +// mkwinsyscall does not emit CompleteAuthToken for this package, so bind it manually. +var procCompleteAuthToken = modsecur32.NewProc("CompleteAuthToken") + +func sspiCompleteAuthToken(context *secHandle, token *secBufferDesc) (ret syscall.Errno) { + r0, _, _ := syscall.SyscallN(procCompleteAuthToken.Addr(), uintptr(unsafe.Pointer(context)), uintptr(unsafe.Pointer(token))) + ret = syscall.Errno(r0) + return +} diff --git a/common/schannel/types_windows.go b/common/schannel/types_windows.go new file mode 100644 index 00000000..5d0bc134 --- /dev/null +++ b/common/schannel/types_windows.go @@ -0,0 +1,161 @@ +package schannel + +import ( + "syscall" + + "golang.org/x/sys/windows" +) + +const ( + unispNameW = "Microsoft Unified Security Protocol Provider" + + schCredentialsVersion = 5 + + secPkgCredOutbound = 2 + + iscReqSequenceDetect = 0x00000008 + iscReqReplayDetect = 0x00000004 + iscReqConfidentiality = 0x00000010 + iscReqAllocateMemory = 0x00000100 + iscReqStream = 0x00008000 + iscReqUseSuppliedCreds = 0x00000080 + iscReqManualCredValidation = 0x00080000 + iscReqExtendedError = 0x00004000 + + secbufferEmpty = 0 + secbufferData = 1 + secbufferToken = 2 + secbufferExtra = 5 + secbufferStreamTrailer = 6 + secbufferStreamHeader = 7 + secbufferApplicationProtocols = 18 + secbufferVersion = 0 + + secApplicationProtocolNegotiationExtALPN = 2 + + secApplicationProtocolNegotiationStatusSuccess = 1 + + schCredManualCredValidation = 0x00000008 + schCredNoDefaultCreds = 0x00000010 + schUseStrongCrypto = 0x00400000 + + spProtTLS10Client = 0x00000080 + spProtTLS11Client = 0x00000200 + spProtTLS12Client = 0x00000800 + spProtTLS13Client = 0x00002000 + + spProtAllTLSClients = spProtTLS10Client | spProtTLS11Client | spProtTLS12Client | spProtTLS13Client + + secpkgAttrStreamSizes = 4 + secpkgAttrConnectionInfo = 0x5A + secpkgAttrApplicationProtocol = 0x23 + secpkgAttrCipherInfo = 0x64 + secpkgAttrRemoteCertContext = 0x53 +) + +const ( + secEOK = syscall.Errno(windows.SEC_E_OK) + secICompleteNeeded = syscall.Errno(windows.SEC_I_COMPLETE_NEEDED) + secICompleteAndContinue = syscall.Errno(windows.SEC_I_COMPLETE_AND_CONTINUE) + secIContinueNeeded = syscall.Errno(windows.SEC_I_CONTINUE_NEEDED) + secIContextExpired = syscall.Errno(windows.SEC_I_CONTEXT_EXPIRED) + secIRenegotiate = syscall.Errno(windows.SEC_I_RENEGOTIATE) + secEIncompleteMessage = syscall.Errno(windows.SEC_E_INCOMPLETE_MESSAGE) + secEIncompleteCreds = syscall.Errno(windows.SEC_E_INCOMPLETE_CREDENTIALS) + secEBufferTooSmall = syscall.Errno(windows.SEC_E_BUFFER_TOO_SMALL) + secEMessageAltered = syscall.Errno(windows.SEC_E_MESSAGE_ALTERED) + secEContextExpired = syscall.Errno(windows.SEC_E_CONTEXT_EXPIRED) + secEUnsupportedFunc = syscall.Errno(windows.SEC_E_UNSUPPORTED_FUNCTION) + secEInvalidToken = syscall.Errno(windows.SEC_E_INVALID_TOKEN) + secELogonDenied = syscall.Errno(windows.SEC_E_LOGON_DENIED) + secEIllegalMessage = syscall.Errno(windows.SEC_E_ILLEGAL_MESSAGE) + secEWrongPrincipal = syscall.Errno(windows.SEC_E_WRONG_PRINCIPAL) + secECertUnknown = syscall.Errno(windows.SEC_E_CERT_UNKNOWN) + secECertExpired = syscall.Errno(windows.SEC_E_CERT_EXPIRED) + secEAlgorithmMismatch = syscall.Errno(windows.SEC_E_ALGORITHM_MISMATCH) + secEInternalError = syscall.Errno(windows.SEC_E_INTERNAL_ERROR) + secENoAuthenticatingAuthority = syscall.Errno(windows.SEC_E_NO_AUTHENTICATING_AUTHORITY) +) + +type secHandle struct { + lower uintptr + upper uintptr +} + +type secBuffer struct { + cbBuffer uint32 + bufferType uint32 + pvBuffer *byte +} + +type secBufferDesc struct { + ulVersion uint32 + cBuffers uint32 + pBuffers *secBuffer +} + +type schCredentials struct { + dwVersion uint32 + dwCredFormat uint32 + cCreds uint32 + paCred uintptr + hRootStore windows.Handle + cMappers uint32 + aphMappers uintptr + dwSessionLifespan uint32 + dwFlags uint32 + cTlsParameters uint32 + pTlsParameters *tlsParameters +} + +type tlsParameters struct { + cAlpnIds uint32 + rgstrAlpnIds uintptr + grbitDisabledProtocols uint32 + cDisabledCrypto uint32 + pDisabledCrypto uintptr + dwFlags uint32 +} + +type secPkgContextStreamSizes struct { + cbHeader uint32 + cbTrailer uint32 + cbMaximumMessage uint32 + cBuffers uint32 + cbBlockSize uint32 +} + +type secPkgContextConnectionInfo struct { + dwProtocol uint32 + aiCipher uint32 + dwCipherStrength uint32 + aiHash uint32 + dwHashStrength uint32 + aiExch uint32 + dwExchStrength uint32 +} + +type secPkgContextApplicationProtocol struct { + protoNegoStatus uint32 + protoNegoExt uint32 + protocolIDSize byte + protocolID [255]byte +} + +type secPkgContextCipherInfo struct { + dwVersion uint32 + dwProtocol uint32 + dwCipherSuite uint32 + dwBaseCipherSuite uint32 + szCipherSuite [64]uint16 + szCipher [64]uint16 + dwCipherLen uint32 + dwCipherBlockLen uint32 + szHash [64]uint16 + dwHashLen uint32 + szExchange [64]uint16 + dwMinExchangeLen uint32 + dwMaxExchangeLen uint32 + szCertificate [64]uint16 + dwKeyType uint32 +} diff --git a/common/schannel/zsyscall_windows.go b/common/schannel/zsyscall_windows.go new file mode 100644 index 00000000..85a7bc80 --- /dev/null +++ b/common/schannel/zsyscall_windows.go @@ -0,0 +1,99 @@ +// Code generated by 'go generate'; DO NOT EDIT. + +package schannel + +import ( + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +var _ unsafe.Pointer + +// Do the interface allocations only once for common +// Errno values. +const ( + errnoERROR_IO_PENDING = 997 +) + +var ( + errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING) + errERROR_EINVAL error = syscall.EINVAL +) + +// errnoErr returns common boxed Errno values, to prevent +// allocations at runtime. +func errnoErr(e syscall.Errno) error { + switch e { + case 0: + return errERROR_EINVAL + case errnoERROR_IO_PENDING: + return errERROR_IO_PENDING + } + // TODO: add more here, after collecting data on the common + // error values see on Windows. (perhaps when running + // all.bat?) + return e +} + +var ( + modsecur32 = windows.NewLazySystemDLL("secur32.dll") + + procAcquireCredentialsHandleW = modsecur32.NewProc("AcquireCredentialsHandleW") + procDecryptMessage = modsecur32.NewProc("DecryptMessage") + procDeleteSecurityContext = modsecur32.NewProc("DeleteSecurityContext") + procEncryptMessage = modsecur32.NewProc("EncryptMessage") + procFreeContextBuffer = modsecur32.NewProc("FreeContextBuffer") + procFreeCredentialsHandle = modsecur32.NewProc("FreeCredentialsHandle") + procInitializeSecurityContextW = modsecur32.NewProc("InitializeSecurityContextW") + procQueryContextAttributesW = modsecur32.NewProc("QueryContextAttributesW") +) + +func sspiAcquireCredentialsHandle(principal *uint16, pkgname *uint16, credentialUse uint32, logonID *uint64, authData unsafe.Pointer, getKeyFn uintptr, getKeyArg uintptr, credential *secHandle, expiry *windows.Filetime) (ret syscall.Errno) { + r0, _, _ := syscall.SyscallN(procAcquireCredentialsHandleW.Addr(), uintptr(unsafe.Pointer(principal)), uintptr(unsafe.Pointer(pkgname)), uintptr(credentialUse), uintptr(unsafe.Pointer(logonID)), uintptr(authData), uintptr(getKeyFn), uintptr(getKeyArg), uintptr(unsafe.Pointer(credential)), uintptr(unsafe.Pointer(expiry))) + ret = syscall.Errno(r0) + return +} + +func sspiDecryptMessage(context *secHandle, message *secBufferDesc, sequenceNumber uint32, qop *uint32) (ret syscall.Errno) { + r0, _, _ := syscall.SyscallN(procDecryptMessage.Addr(), uintptr(unsafe.Pointer(context)), uintptr(unsafe.Pointer(message)), uintptr(sequenceNumber), uintptr(unsafe.Pointer(qop))) + ret = syscall.Errno(r0) + return +} + +func sspiDeleteSecurityContext(context *secHandle) (ret syscall.Errno) { + r0, _, _ := syscall.SyscallN(procDeleteSecurityContext.Addr(), uintptr(unsafe.Pointer(context))) + ret = syscall.Errno(r0) + return +} + +func sspiEncryptMessage(context *secHandle, qop uint32, message *secBufferDesc, sequenceNumber uint32) (ret syscall.Errno) { + r0, _, _ := syscall.SyscallN(procEncryptMessage.Addr(), uintptr(unsafe.Pointer(context)), uintptr(qop), uintptr(unsafe.Pointer(message)), uintptr(sequenceNumber)) + ret = syscall.Errno(r0) + return +} + +func sspiFreeContextBuffer(buffer *byte) (ret syscall.Errno) { + r0, _, _ := syscall.SyscallN(procFreeContextBuffer.Addr(), uintptr(unsafe.Pointer(buffer))) + ret = syscall.Errno(r0) + return +} + +func sspiFreeCredentialsHandle(credential *secHandle) (ret syscall.Errno) { + r0, _, _ := syscall.SyscallN(procFreeCredentialsHandle.Addr(), uintptr(unsafe.Pointer(credential))) + ret = syscall.Errno(r0) + return +} + +func sspiInitializeSecurityContext(credential *secHandle, context *secHandle, targetName *uint16, contextReq uint32, reserved1 uint32, targetDataRep uint32, input *secBufferDesc, reserved2 uint32, newContext *secHandle, output *secBufferDesc, contextAttr *uint32, expiry *windows.Filetime) (ret syscall.Errno) { + r0, _, _ := syscall.SyscallN(procInitializeSecurityContextW.Addr(), uintptr(unsafe.Pointer(credential)), uintptr(unsafe.Pointer(context)), uintptr(unsafe.Pointer(targetName)), uintptr(contextReq), uintptr(reserved1), uintptr(targetDataRep), uintptr(unsafe.Pointer(input)), uintptr(reserved2), uintptr(unsafe.Pointer(newContext)), uintptr(unsafe.Pointer(output)), uintptr(unsafe.Pointer(contextAttr)), uintptr(unsafe.Pointer(expiry))) + ret = syscall.Errno(r0) + return +} + +func sspiQueryContextAttributes(context *secHandle, attribute uint32, buffer unsafe.Pointer) (ret syscall.Errno) { + r0, _, _ := syscall.SyscallN(procQueryContextAttributesW.Addr(), uintptr(unsafe.Pointer(context)), uintptr(attribute), uintptr(buffer)) + ret = syscall.Errno(r0) + return +} diff --git a/common/tls/apple_client.go b/common/tls/apple_client.go index 01043fd3..441c29b3 100644 --- a/common/tls/apple_client.go +++ b/common/tls/apple_client.go @@ -4,218 +4,41 @@ package tls import ( "context" - "net" - "os" - "strings" - "time" "github.com/sagernet/sing-box/adapter" - boxConstant "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/common/certificate" "github.com/sagernet/sing-box/option" - E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" - "github.com/sagernet/sing/common/ntp" - "github.com/sagernet/sing/service" ) -type appleCertificateStore interface { - StoreKind() string - CurrentPEM() []string -} +const appleTLSEngineName = "Apple TLS engine" type appleClientConfig struct { - serverName string - nextProtos []string - handshakeTimeout time.Duration - minVersion uint16 - maxVersion uint16 - insecure bool - anchorPEM string - anchorOnly bool - certificatePublicKeySHA256 [][]byte - timeFunc func() time.Time -} - -func (c *appleClientConfig) ServerName() string { - return c.serverName -} - -func (c *appleClientConfig) SetServerName(serverName string) { - c.serverName = serverName -} - -func (c *appleClientConfig) NextProtos() []string { - return c.nextProtos -} - -func (c *appleClientConfig) SetNextProtos(nextProto []string) { - c.nextProtos = append(c.nextProtos[:0], nextProto...) -} - -func (c *appleClientConfig) HandshakeTimeout() time.Duration { - return c.handshakeTimeout -} - -func (c *appleClientConfig) SetHandshakeTimeout(timeout time.Duration) { - c.handshakeTimeout = timeout -} - -func (c *appleClientConfig) STDConfig() (*STDConfig, error) { - return nil, E.New("unsupported usage for Apple TLS engine") -} - -func (c *appleClientConfig) Client(conn net.Conn) (Conn, error) { - return nil, os.ErrInvalid + systemTLSConfig + userPEM []byte } func (c *appleClientConfig) Clone() Config { return &appleClientConfig{ - serverName: c.serverName, - nextProtos: append([]string(nil), c.nextProtos...), - handshakeTimeout: c.handshakeTimeout, - minVersion: c.minVersion, - maxVersion: c.maxVersion, - insecure: c.insecure, - anchorPEM: c.anchorPEM, - anchorOnly: c.anchorOnly, - certificatePublicKeySHA256: append([][]byte(nil), c.certificatePublicKeySHA256...), - timeFunc: c.timeFunc, + systemTLSConfig: c.systemTLSConfig.clone(), + userPEM: append([]byte(nil), c.userPEM...), } } +func (c *appleClientConfig) resolveAnchors() (adapter.AppleAnchors, error) { + if len(c.userPEM) > 0 { + return certificate.NewAppleAnchors(c.userPEM) + } + return certificate.AcquireAnchors(nil, c.store), nil +} + func newAppleClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions, allowEmptyServerName bool) (Config, error) { - validated, err := ValidateAppleTLSOptions(ctx, options, "Apple TLS engine") + base, validated, err := newSystemTLSConfig(ctx, serverAddress, options, allowEmptyServerName, appleTLSEngineName) if err != nil { return nil, err } - - var serverName string - if options.ServerName != "" { - serverName = options.ServerName - } else if serverAddress != "" { - serverName = serverAddress - } - if serverName == "" && !options.Insecure && !allowEmptyServerName { - return nil, errMissingServerName - } - - var handshakeTimeout time.Duration - if options.HandshakeTimeout > 0 { - handshakeTimeout = options.HandshakeTimeout.Build() - } else { - handshakeTimeout = boxConstant.TCPTimeout - } - return &appleClientConfig{ - serverName: serverName, - nextProtos: append([]string(nil), options.ALPN...), - handshakeTimeout: handshakeTimeout, - minVersion: validated.MinVersion, - maxVersion: validated.MaxVersion, - insecure: options.Insecure || len(options.CertificatePublicKeySHA256) > 0, - anchorPEM: validated.AnchorPEM, - anchorOnly: validated.AnchorOnly, - certificatePublicKeySHA256: append([][]byte(nil), options.CertificatePublicKeySHA256...), - timeFunc: ntp.TimeFuncFromContext(ctx), + systemTLSConfig: base, + userPEM: append([]byte(nil), validated.UserPEM...), }, nil } - -type AppleTLSValidated struct { - MinVersion uint16 - MaxVersion uint16 - AnchorPEM string - AnchorOnly bool -} - -func ValidateAppleTLSOptions(ctx context.Context, options option.OutboundTLSOptions, engineName string) (AppleTLSValidated, error) { - if options.Reality != nil && options.Reality.Enabled { - return AppleTLSValidated{}, E.New("reality is unsupported in ", engineName) - } - if options.UTLS != nil && options.UTLS.Enabled { - return AppleTLSValidated{}, E.New("utls is unsupported in ", engineName) - } - if options.ECH != nil && options.ECH.Enabled { - return AppleTLSValidated{}, E.New("ech is unsupported in ", engineName) - } - if options.DisableSNI { - return AppleTLSValidated{}, E.New("disable_sni is unsupported in ", engineName) - } - if len(options.CipherSuites) > 0 { - return AppleTLSValidated{}, E.New("cipher_suites is unsupported in ", engineName) - } - if len(options.CurvePreferences) > 0 { - return AppleTLSValidated{}, E.New("curve_preferences is unsupported in ", engineName) - } - if len(options.ClientCertificate) > 0 || options.ClientCertificatePath != "" || len(options.ClientKey) > 0 || options.ClientKeyPath != "" { - return AppleTLSValidated{}, E.New("client certificate is unsupported in ", engineName) - } - if options.Fragment || options.RecordFragment { - return AppleTLSValidated{}, E.New("tls fragment is unsupported in ", engineName) - } - if options.KernelTx || options.KernelRx { - return AppleTLSValidated{}, E.New("ktls is unsupported in ", engineName) - } - if options.Spoof != "" || options.SpoofMethod != "" { - return AppleTLSValidated{}, E.New("spoof is unsupported in ", engineName) - } - if len(options.CertificatePublicKeySHA256) > 0 && (len(options.Certificate) > 0 || options.CertificatePath != "") { - return AppleTLSValidated{}, E.New("certificate_public_key_sha256 is conflict with certificate or certificate_path") - } - var minVersion uint16 - if options.MinVersion != "" { - var err error - minVersion, err = ParseTLSVersion(options.MinVersion) - if err != nil { - return AppleTLSValidated{}, E.Cause(err, "parse min_version") - } - } - var maxVersion uint16 - if options.MaxVersion != "" { - var err error - maxVersion, err = ParseTLSVersion(options.MaxVersion) - if err != nil { - return AppleTLSValidated{}, E.Cause(err, "parse max_version") - } - } - anchorPEM, anchorOnly, err := AppleAnchorPEM(ctx, options) - if err != nil { - return AppleTLSValidated{}, err - } - return AppleTLSValidated{ - MinVersion: minVersion, - MaxVersion: maxVersion, - AnchorPEM: anchorPEM, - AnchorOnly: anchorOnly, - }, nil -} - -func AppleAnchorPEM(ctx context.Context, options option.OutboundTLSOptions) (string, bool, error) { - if len(options.Certificate) > 0 { - return strings.Join(options.Certificate, "\n"), true, nil - } - if options.CertificatePath != "" { - content, err := os.ReadFile(options.CertificatePath) - if err != nil { - return "", false, E.Cause(err, "read certificate") - } - return string(content), true, nil - } - - certificateStore := service.FromContext[adapter.CertificateStore](ctx) - if certificateStore == nil { - return "", false, nil - } - store, ok := certificateStore.(appleCertificateStore) - if !ok { - return "", false, nil - } - - switch store.StoreKind() { - case boxConstant.CertificateStoreSystem, "": - return strings.Join(store.CurrentPEM(), "\n"), false, nil - case boxConstant.CertificateStoreMozilla, boxConstant.CertificateStoreChrome, boxConstant.CertificateStoreNone: - return strings.Join(store.CurrentPEM(), "\n"), true, nil - default: - return "", false, E.New("unsupported certificate store for Apple TLS engine: ", store.StoreKind()) - } -} diff --git a/common/tls/apple_client_platform.go b/common/tls/apple_client_platform.go index 9e7d6e73..9e2b7b53 100644 --- a/common/tls/apple_client_platform.go +++ b/common/tls/apple_client_platform.go @@ -20,24 +20,25 @@ import ( "math" "net" "os" + "runtime/cgo" "strings" "sync" - "syscall" "time" "unsafe" - "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" "golang.org/x/sys/unix" ) func (c *appleClientConfig) ClientHandshake(ctx context.Context, conn net.Conn) (Conn, error) { - rawSyscallConn, ok := common.Cast[syscall.Conn](conn) + tcpConn, ok := N.UnwrapReader(conn).(*net.TCPConn) if !ok { return nil, E.New("apple TLS: requires fd-backed TCP connection") } - syscallConn, err := rawSyscallConn.SyscallConn() + syscallConn, err := tcpConn.SyscallConn() if err != nil { return nil, E.Cause(err, "access raw connection") } @@ -61,8 +62,14 @@ func (c *appleClientConfig) ClientHandshake(ctx context.Context, conn net.Conn) alpnPtr := cStringOrNil(alpn) defer cFree(alpnPtr) - anchorPEMPtr := cStringOrNil(c.anchorPEM) - defer cFree(anchorPEMPtr) + anchors, err := c.resolveAnchors() + if err != nil { + return nil, err + } + var anchorsRef unsafe.Pointer + if anchors != nil { + anchorsRef = anchors.Ref() + } var ( hasVerifyTime bool @@ -82,13 +89,15 @@ func (c *appleClientConfig) ClientHandshake(ctx context.Context, conn net.Conn) C.uint16_t(c.minVersion), C.uint16_t(c.maxVersion), C.bool(c.insecure), - anchorPEMPtr, - C.size_t(len(c.anchorPEM)), + anchorsRef, C.bool(c.anchorOnly), C.bool(hasVerifyTime), C.int64_t(verifyTimeUnixMilli), &errorPtr, ) + if anchors != nil { + anchors.Release() + } if client == nil { if errorPtr != nil { defer C.free(unsafe.Pointer(errorPtr)) @@ -138,21 +147,27 @@ func (c *appleClientConfig) ClientHandshake(ctx context.Context, conn net.Conn) }, nil } -const appleTLSHandshakePollInterval = 100 * time.Millisecond +const ( + appleTLSHandshakePollInterval = 100 * time.Millisecond + appleTLSWriteChunkSize = 32 * 1024 +) func waitAppleTLSClientReady(ctx context.Context, client *C.box_apple_tls_client_t) error { for { - if err := ctx.Err(); err != nil { + err := ctx.Err() + if err != nil { C.box_apple_tls_client_cancel(client) return err } waitTimeout := appleTLSHandshakePollInterval - if deadline, loaded := ctx.Deadline(); loaded { + deadline, loaded := ctx.Deadline() + if loaded { remaining := time.Until(deadline) if remaining <= 0 { C.box_apple_tls_client_cancel(client) - if err := ctx.Err(); err != nil { + err = ctx.Err() + if err != nil { return err } return context.DeadlineExceeded @@ -201,6 +216,11 @@ type appleTLSConn struct { writeTimedOut bool } +var ( + _ N.ExtendedConn = (*appleTLSConn)(nil) + _ N.ReadWaitCreator = (*appleTLSConn)(nil) +) + func (c *appleTLSConn) Read(p []byte) (int, error) { c.readAccess.Lock() defer c.readAccess.Unlock() @@ -211,6 +231,29 @@ func (c *appleTLSConn) Read(p []byte) (int, error) { return 0, nil } + return c.readIntoLocked(p) +} + +func (c *appleTLSConn) ReadBuffer(buffer *buf.Buffer) error { + c.readAccess.Lock() + defer c.readAccess.Unlock() + if buffer.IsFull() { + return io.ErrShortBuffer + } + startLen := buffer.Len() + n, err := c.readIntoLocked(buffer.FreeBytes()) + buffer.Truncate(startLen + n) + return err +} + +func (c *appleTLSConn) readIntoLocked(p []byte) (int, error) { + if c.readEOF { + return 0, io.EOF + } + if len(p) == 0 { + return 0, nil + } + timeoutMs, err := c.prepareReadTimeout() if err != nil { return 0, err @@ -256,34 +299,55 @@ func (c *appleTLSConn) Write(p []byte) (int, error) { return 0, nil } - timeoutMs, err := c.prepareWriteTimeout() - if err != nil { - return 0, err - } - client, err := c.acquireClient() if err != nil { return 0, err } defer c.releaseClient() - var errorPtr *C.char - n := C.box_apple_tls_client_write(client, unsafe.Pointer(&p[0]), C.size_t(len(p)), C.int(timeoutMs), &errorPtr) - switch { - case n == -2: - c.markWriteTimedOut() - return 0, os.ErrDeadlineExceeded - case n >= 0: - return int(n), nil + deadline, err := c.prepareWriteDeadline() + if err != nil { + return 0, err } - if errorPtr != nil { - defer C.free(unsafe.Pointer(errorPtr)) - if c.isClosed() { - return 0, net.ErrClosed + var written int + for written < len(p) { + timeoutMs, expired := deadlineTimeoutMs(deadline) + if expired { + C.box_apple_tls_client_cancel(client) + c.markWriteTimedOut() + return written, os.ErrDeadlineExceeded } - return 0, E.New(C.GoString(errorPtr)) + chunkSize := min(len(p)-written, appleTLSWriteChunkSize) + chunk := p[written : written+chunkSize] + var errorPtr *C.char + n := C.box_apple_tls_client_write(client, unsafe.Pointer(&chunk[0]), C.size_t(len(chunk)), C.int(timeoutMs), &errorPtr) + switch { + case n == -2: + c.markWriteTimedOut() + return written, os.ErrDeadlineExceeded + case n >= 0: + written += int(n) + if int(n) != len(chunk) { + return written, io.ErrShortWrite + } + continue + } + return written, c.errorFromPointer(errorPtr) } - return 0, net.ErrClosed + return written, nil +} + +func (c *appleTLSConn) WriteBuffer(buffer *buf.Buffer) error { + defer buffer.Release() + _, err := c.Write(buffer.Bytes()) + return err +} + +func (c *appleTLSConn) CreateReadWaiter() (N.ReadWaiter, bool) { + return &appleTLSReadWaiter{ + conn: c, + results: make(chan *C.box_apple_tls_read_result_t, 1), + }, true } func (c *appleTLSConn) Close() error { @@ -358,18 +422,18 @@ func (c *appleTLSConn) prepareReadTimeout() (int, error) { return timeoutMs, nil } -func (c *appleTLSConn) prepareWriteTimeout() (int, error) { +func (c *appleTLSConn) prepareWriteDeadline() (time.Time, error) { c.deadlineAccess.Lock() defer c.deadlineAccess.Unlock() if c.writeTimedOut { - return 0, os.ErrDeadlineExceeded + return time.Time{}, os.ErrDeadlineExceeded } - timeoutMs, expired := deadlineTimeoutMs(c.writeDeadline) + _, expired := deadlineTimeoutMs(c.writeDeadline) if expired { c.writeTimedOut = true - return 0, os.ErrDeadlineExceeded + return time.Time{}, os.ErrDeadlineExceeded } - return timeoutMs, nil + return c.writeDeadline, nil } func (c *appleTLSConn) markReadTimedOut() { @@ -422,6 +486,138 @@ func (c *appleTLSConn) releaseClient() { c.ioGroup.Done() } +func (c *appleTLSConn) errorFromPointer(errorPtr *C.char) error { + if errorPtr != nil { + defer C.free(unsafe.Pointer(errorPtr)) + if c.isClosed() { + return net.ErrClosed + } + return E.New(C.GoString(errorPtr)) + } + return net.ErrClosed +} + +type appleTLSReadWaiter struct { + conn *appleTLSConn + options N.ReadWaitOptions + results chan *C.box_apple_tls_read_result_t +} + +var _ N.ReadWaiter = (*appleTLSReadWaiter)(nil) + +func (w *appleTLSReadWaiter) InitializeReadWaiter(options N.ReadWaitOptions) (needCopy bool) { + w.options = options + if w.results == nil { + w.results = make(chan *C.box_apple_tls_read_result_t, 1) + } + return false +} + +func (w *appleTLSReadWaiter) WaitReadBuffer() (*buf.Buffer, error) { + c := w.conn + c.readAccess.Lock() + defer c.readAccess.Unlock() + if c.readEOF { + return nil, io.EOF + } + maximumLen := readWaitFreeLen(w.options) + if maximumLen <= 0 { + return nil, io.ErrShortBuffer + } + timeoutMs, err := c.prepareReadTimeout() + if err != nil { + return nil, err + } + client, err := c.acquireClient() + if err != nil { + return nil, err + } + defer c.releaseClient() + + handle := cgo.NewHandle(w) + defer handle.Delete() + var errorPtr *C.char + if !bool(C.box_apple_tls_client_read_async(client, C.size_t(maximumLen), C.uintptr_t(handle), &errorPtr)) { + return nil, c.errorFromPointer(errorPtr) + } + + var result *C.box_apple_tls_read_result_t + if timeoutMs >= 0 { + timer := time.NewTimer(time.Duration(timeoutMs) * time.Millisecond) + defer timer.Stop() + select { + case result = <-w.results: + case <-timer.C: + C.box_apple_tls_client_cancel(client) + result = <-w.results + if result != nil { + C.box_apple_tls_read_result_free(result) + } + c.markReadTimedOut() + return nil, os.ErrDeadlineExceeded + } + } else { + result = <-w.results + } + return c.readWaitResultToBuffer(result, w.options) +} + +func (c *appleTLSConn) readWaitResultToBuffer(result *C.box_apple_tls_read_result_t, options N.ReadWaitOptions) (*buf.Buffer, error) { + defer C.box_apple_tls_read_result_free(result) + buffer := options.NewBuffer() + if buffer.IsFull() { + buffer.Release() + return nil, io.ErrShortBuffer + } + startLen := buffer.Len() + var eof C.bool + var errorPtr *C.char + n := C.box_apple_tls_read_result_copy(result, unsafe.Pointer(&buffer.FreeBytes()[0]), C.size_t(buffer.FreeLen()), &eof, &errorPtr) + if n < 0 { + buffer.Release() + return nil, c.errorFromPointer(errorPtr) + } + if bool(eof) { + c.readEOF = true + if n == 0 { + buffer.Release() + return nil, io.EOF + } + } + if n == 0 { + buffer.Release() + return nil, io.ErrNoProgress + } + buffer.Truncate(startLen + int(n)) + options.PostReturn(buffer) + return buffer, nil +} + +func readWaitFreeLen(options N.ReadWaitOptions) int { + if options.IncreaseBuffer { + return 65535 - options.FrontHeadroom - options.RearHeadroom + } + if options.MTU > 0 { + return options.MTU + } + return buf.BufferSize - options.FrontHeadroom - options.RearHeadroom +} + +//export box_apple_tls_read_callback +func box_apple_tls_read_callback(callbackHandle C.uintptr_t, result *C.box_apple_tls_read_result_t) { + handle := cgo.Handle(callbackHandle) + waiter, ok := handle.Value().(*appleTLSReadWaiter) + if !ok { + C.box_apple_tls_read_result_free(result) + return + } + select { + case waiter.results <- result: + default: + C.box_apple_tls_read_result_free(result) + } +} + func (c *appleTLSConn) NetConn() net.Conn { return c.rawConn } diff --git a/common/tls/apple_client_platform_benchmark_test.go b/common/tls/apple_client_platform_benchmark_test.go new file mode 100644 index 00000000..27fcf153 --- /dev/null +++ b/common/tls/apple_client_platform_benchmark_test.go @@ -0,0 +1,278 @@ +//go:build darwin && cgo + +package tls + +import ( + "bytes" + stdtls "crypto/tls" + "crypto/x509" + "errors" + "io" + "net" + "testing" + + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/json/badoption" + N "github.com/sagernet/sing/common/network" +) + +const ( + appleTLSBenchmarkReadPayloadSize = 16 * 1024 + appleTLSBenchmarkWritePayloadSize = 48 * 1024 +) + +func BenchmarkAppleClientReadBuffer(b *testing.B) { + payload := bytes.Repeat([]byte{'r'}, appleTLSBenchmarkReadPayloadSize) + start := make(chan struct{}) + clientConn, serverResult := newAppleBenchmarkClientConn(b, func(conn *stdtls.Conn) error { + <-start + for range b.N { + if err := writeBenchmarkPayload(conn, payload); err != nil { + return err + } + } + return nil + }) + defer clientConn.Close() + + extendedConn := clientConn.(N.ExtendedConn) + b.ReportAllocs() + b.SetBytes(int64(len(payload))) + b.ReportMetric(float64(len(payload)), "payload_B") + b.ResetTimer() + close(start) + target := b.N * len(payload) + var received int + for received < target { + buffer := buf.NewSize(len(payload)) + err := extendedConn.ReadBuffer(buffer) + if err != nil { + buffer.Release() + b.Fatal(err) + } + received += buffer.Len() + buffer.Release() + } + b.StopTimer() + if err := <-serverResult; err != nil { + b.Fatal(err) + } +} + +func BenchmarkAppleClientReadWaiter(b *testing.B) { + payload := bytes.Repeat([]byte{'w'}, appleTLSBenchmarkReadPayloadSize) + start := make(chan struct{}) + clientConn, serverResult := newAppleBenchmarkClientConn(b, func(conn *stdtls.Conn) error { + <-start + for range b.N { + if err := writeBenchmarkPayload(conn, payload); err != nil { + return err + } + } + return nil + }) + defer clientConn.Close() + + readWaiter, ok := clientConn.(N.ReadWaitCreator).CreateReadWaiter() + if !ok { + b.Fatal("expected read waiter") + } + readWaiter.InitializeReadWaiter(N.ReadWaitOptions{ + MTU: appleTLSBenchmarkReadPayloadSize, + }) + b.ReportAllocs() + b.SetBytes(int64(len(payload))) + b.ReportMetric(float64(len(payload)), "payload_B") + b.ResetTimer() + close(start) + target := b.N * len(payload) + var received int + for received < target { + buffer, err := readWaiter.WaitReadBuffer() + if err != nil { + if errors.Is(err, io.ErrNoProgress) { + continue + } + b.Fatal(err) + } + received += buffer.Len() + buffer.Release() + } + b.StopTimer() + if err := <-serverResult; err != nil { + b.Fatal(err) + } +} + +func BenchmarkAppleClientWriteBuffer(b *testing.B) { + payload := bytes.Repeat([]byte{'x'}, appleTLSBenchmarkWritePayloadSize) + start := make(chan struct{}) + clientConn, serverResult := newAppleBenchmarkClientConn(b, func(conn *stdtls.Conn) error { + <-start + _, err := io.CopyN(io.Discard, conn, int64(b.N*len(payload))) + return err + }) + defer clientConn.Close() + + extendedConn := clientConn.(N.ExtendedConn) + b.ReportAllocs() + b.SetBytes(int64(len(payload))) + b.ReportMetric(float64(len(payload)), "payload_B") + b.ReportMetric(float64(appleTLSWriteChunkSize), "write_chunk_B") + b.ResetTimer() + close(start) + for range b.N { + buffer := buf.NewSize(len(payload)) + _, err := buffer.Write(payload) + if err != nil { + buffer.Release() + b.Fatal(err) + } + err = extendedConn.WriteBuffer(buffer) + if err != nil { + b.Fatal(err) + } + } + b.StopTimer() + if err := <-serverResult; err != nil { + b.Fatal(err) + } +} + +func BenchmarkStdlibClientReadBuffer(b *testing.B) { + payload := bytes.Repeat([]byte{'r'}, appleTLSBenchmarkReadPayloadSize) + start := make(chan struct{}) + clientConn, serverResult := newStdlibBenchmarkClientConn(b, func(conn *stdtls.Conn) error { + <-start + for range b.N { + if err := writeBenchmarkPayload(conn, payload); err != nil { + return err + } + } + return nil + }) + defer clientConn.Close() + + b.ReportAllocs() + b.SetBytes(int64(len(payload))) + b.ReportMetric(float64(len(payload)), "payload_B") + b.ResetTimer() + close(start) + target := b.N * len(payload) + var received int + for received < target { + buffer := buf.NewSize(len(payload)) + n, err := clientConn.Read(buffer.FreeBytes()) + if n > 0 { + buffer.Truncate(buffer.Len() + n) + } + received += buffer.Len() + buffer.Release() + if err != nil && received < target { + b.Fatal(err) + } + } + b.StopTimer() + if err := <-serverResult; err != nil { + b.Fatal(err) + } +} + +func BenchmarkStdlibClientWriteBuffer(b *testing.B) { + payload := bytes.Repeat([]byte{'x'}, appleTLSBenchmarkWritePayloadSize) + start := make(chan struct{}) + clientConn, serverResult := newStdlibBenchmarkClientConn(b, func(conn *stdtls.Conn) error { + <-start + _, err := io.CopyN(io.Discard, conn, int64(b.N*len(payload))) + return err + }) + defer clientConn.Close() + + b.ReportAllocs() + b.SetBytes(int64(len(payload))) + b.ReportMetric(float64(len(payload)), "payload_B") + b.ResetTimer() + close(start) + for range b.N { + buffer := buf.NewSize(len(payload)) + _, err := buffer.Write(payload) + if err != nil { + buffer.Release() + b.Fatal(err) + } + _, err = clientConn.Write(buffer.Bytes()) + buffer.Release() + if err != nil { + b.Fatal(err) + } + } + b.StopTimer() + if err := <-serverResult; err != nil { + b.Fatal(err) + } +} + +func newAppleBenchmarkClientConn(b *testing.B, handler func(*stdtls.Conn) error) (Conn, <-chan error) { + b.Helper() + + serverCertificate, serverCertificatePEM := newAppleTestCertificate(b, "localhost") + serverResult, serverAddress := startAppleTLSIOTestServer(b, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + }, handler) + + clientConn, err := newAppleTestClientConn(b, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: "apple", + ServerName: "localhost", + MinVersion: "1.2", + MaxVersion: "1.2", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + b.Fatal(err) + } + return clientConn, serverResult +} + +func newStdlibBenchmarkClientConn(b *testing.B, handler func(*stdtls.Conn) error) (*stdtls.Conn, <-chan error) { + b.Helper() + + serverCertificate, serverCertificatePEM := newAppleTestCertificate(b, "localhost") + serverResult, serverAddress := startAppleTLSIOTestServer(b, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + }, handler) + + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM([]byte(serverCertificatePEM)) { + b.Fatal("parse benchmark certificate") + } + dialer := &net.Dialer{ + Timeout: appleTLSTestTimeout, + } + clientConn, err := stdtls.DialWithDialer(dialer, "tcp", serverAddress, &stdtls.Config{ + ServerName: "localhost", + RootCAs: roots, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + }) + if err != nil { + b.Fatal(err) + } + return clientConn, serverResult +} + +func writeBenchmarkPayload(writer io.Writer, payload []byte) error { + for len(payload) > 0 { + n, err := writer.Write(payload) + if err != nil { + return err + } + payload = payload[n:] + } + return nil +} diff --git a/common/tls/apple_client_platform_darwin.h b/common/tls/apple_client_platform_darwin.h index 9d765835..43b3e031 100644 --- a/common/tls/apple_client_platform_darwin.h +++ b/common/tls/apple_client_platform_darwin.h @@ -4,6 +4,7 @@ #include typedef struct box_apple_tls_client box_apple_tls_client_t; +typedef struct box_apple_tls_read_result box_apple_tls_read_result_t; typedef struct box_apple_tls_state { uint16_t version; @@ -22,8 +23,7 @@ box_apple_tls_client_t *box_apple_tls_client_create( uint16_t min_version, uint16_t max_version, bool insecure, - const char *anchor_pem, - size_t anchor_pem_len, + void *anchors_cf, bool anchor_only, bool has_verify_time, int64_t verify_time_unix_millis, @@ -35,5 +35,9 @@ void box_apple_tls_client_cancel(box_apple_tls_client_t *client); void box_apple_tls_client_free(box_apple_tls_client_t *client); ssize_t box_apple_tls_client_read(box_apple_tls_client_t *client, void *buffer, size_t buffer_len, int timeout_msec, bool *eof_out, char **error_out); ssize_t box_apple_tls_client_write(box_apple_tls_client_t *client, const void *buffer, size_t buffer_len, int timeout_msec, char **error_out); +bool box_apple_tls_client_read_async(box_apple_tls_client_t *client, size_t maximum_len, uintptr_t callback_handle, char **error_out); +ssize_t box_apple_tls_read_result_copy(box_apple_tls_read_result_t *result, void *buffer, size_t buffer_len, bool *eof_out, char **error_out); +void box_apple_tls_read_result_free(box_apple_tls_read_result_t *result); bool box_apple_tls_client_copy_state(box_apple_tls_client_t *client, box_apple_tls_state_t *state, char **error_out); void box_apple_tls_state_free(box_apple_tls_state_t *state); +ssize_t box_apple_tls_copy_dispatch_data_for_test(const void *first, size_t first_len, const void *second, size_t second_len, void *buffer, size_t buffer_len, char **error_out); diff --git a/common/tls/apple_client_platform_darwin.m b/common/tls/apple_client_platform_darwin.m index d03f9fff..f4d0eb67 100644 --- a/common/tls/apple_client_platform_darwin.m +++ b/common/tls/apple_client_platform_darwin.m @@ -21,6 +21,7 @@ typedef struct box_apple_tls_client { void *connection; void *queue; void *ready_semaphore; + void *anchors; atomic_int ref_count; atomic_bool ready; atomic_bool ready_done; @@ -28,6 +29,14 @@ typedef struct box_apple_tls_client { box_apple_tls_state_t state; } box_apple_tls_client_t; +struct box_apple_tls_read_result { + void *content; + bool eof; + char *error; +}; + +extern void box_apple_tls_read_callback(uintptr_t callback_handle, box_apple_tls_read_result_t *result); + static nw_connection_t box_apple_tls_connection(box_apple_tls_client_t *client) { if (client == NULL || client->connection == NULL) { return nil; @@ -49,6 +58,20 @@ static dispatch_semaphore_t box_apple_tls_ready_semaphore(box_apple_tls_client_t return (__bridge dispatch_semaphore_t)client->ready_semaphore; } +static NSArray *box_apple_tls_client_anchors(box_apple_tls_client_t *client) { + if (client == NULL || client->anchors == NULL) { + return nil; + } + return (__bridge NSArray *)client->anchors; +} + +static dispatch_data_t box_apple_tls_read_result_content(box_apple_tls_read_result_t *result) { + if (result == NULL || result->content == NULL) { + return nil; + } + return (__bridge dispatch_data_t)result->content; +} + static void box_apple_tls_state_reset(box_apple_tls_state_t *state) { if (state == NULL) { return; @@ -62,6 +85,9 @@ static void box_apple_tls_state_reset(box_apple_tls_state_t *state) { static void box_apple_tls_client_destroy(box_apple_tls_client_t *client) { free(client->ready_error); box_apple_tls_state_reset(&client->state); + if (client->anchors != NULL) { + CFRelease((CFTypeRef)client->anchors); + } if (client->ready_semaphore != NULL) { CFBridgingRelease(client->ready_semaphore); } @@ -113,6 +139,51 @@ static void box_set_error_from_nw_error(char **error_out, nw_error_t error) { CFRelease(cfError); } +static ssize_t box_apple_tls_dispatch_data_copy(dispatch_data_t content, void *buffer, size_t buffer_len, char **error_out) { + if (content == nil) { + return 0; + } + size_t content_size = dispatch_data_get_size(content); + if (content_size == 0) { + return 0; + } + if (buffer == NULL) { + box_set_error_message(error_out, "apple TLS: read buffer unavailable"); + return -1; + } + __block size_t copied = 0; + __block bool overflow = false; + bool complete = dispatch_data_apply(content, ^bool(dispatch_data_t region, size_t offset, const void *region_buffer, size_t region_size) { + (void)region; + (void)offset; + if (region_size == 0) { + return true; + } + if (region_buffer == NULL || region_size > buffer_len - copied) { + overflow = true; + return false; + } + memcpy((uint8_t *)buffer + copied, region_buffer, region_size); + copied += region_size; + return true; + }); + if (!complete || overflow) { + box_set_error_message(error_out, "apple TLS: read buffer too small"); + return -1; + } + return (ssize_t)copied; +} + +ssize_t box_apple_tls_copy_dispatch_data_for_test(const void *first, size_t first_len, const void *second, size_t second_len, void *buffer, size_t buffer_len, char **error_out) { + @autoreleasepool { + dispatch_queue_t queue = dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0); + dispatch_data_t first_data = first_len > 0 ? dispatch_data_create(first, first_len, queue, DISPATCH_DATA_DESTRUCTOR_DEFAULT) : dispatch_data_empty; + dispatch_data_t second_data = second_len > 0 ? dispatch_data_create(second, second_len, queue, DISPATCH_DATA_DESTRUCTOR_DEFAULT) : dispatch_data_empty; + dispatch_data_t content = dispatch_data_create_concat(first_data, second_data); + return box_apple_tls_dispatch_data_copy(content, buffer, buffer_len, error_out); + } +} + static char *box_apple_tls_metadata_copy_negotiated_protocol(sec_protocol_metadata_t metadata) { static box_sec_protocol_metadata_string_accessor_f copy_fn; static box_sec_protocol_metadata_string_accessor_f get_fn; @@ -170,44 +241,6 @@ static NSArray *box_split_lines(const char *content, size_t content_ return lines; } -static NSArray *box_parse_certificates_from_pem(const char *pem, size_t pem_len) { - if (pem == NULL || pem_len == 0) { - return @[]; - } - NSString *content = [[NSString alloc] initWithBytes:pem length:pem_len encoding:NSUTF8StringEncoding]; - if (content == nil) { - return @[]; - } - NSString *beginMarker = @"-----BEGIN CERTIFICATE-----"; - NSString *endMarker = @"-----END CERTIFICATE-----"; - NSMutableArray *certificates = [NSMutableArray array]; - NSUInteger searchFrom = 0; - while (searchFrom < content.length) { - NSRange beginRange = [content rangeOfString:beginMarker options:0 range:NSMakeRange(searchFrom, content.length - searchFrom)]; - if (beginRange.location == NSNotFound) { - break; - } - NSUInteger bodyStart = beginRange.location + beginRange.length; - NSRange endRange = [content rangeOfString:endMarker options:0 range:NSMakeRange(bodyStart, content.length - bodyStart)]; - if (endRange.location == NSNotFound) { - break; - } - NSString *base64Section = [content substringWithRange:NSMakeRange(bodyStart, endRange.location - bodyStart)]; - NSArray *components = [base64Section componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; - NSString *base64Content = [components componentsJoinedByString:@""]; - NSData *der = [[NSData alloc] initWithBase64EncodedString:base64Content options:0]; - if (der != nil) { - SecCertificateRef certificate = SecCertificateCreateWithData(NULL, (__bridge CFDataRef)der); - if (certificate != NULL) { - [certificates addObject:(__bridge id)certificate]; - CFRelease(certificate); - } - } - searchFrom = endRange.location + endRange.length; - } - return certificates; -} - static bool box_evaluate_trust(sec_trust_t trust, NSArray *anchors, bool anchor_only, NSDate *verify_date) { bool result = false; SecTrustRef trustRef = sec_trust_copy_ref(trust); @@ -328,8 +361,7 @@ box_apple_tls_client_t *box_apple_tls_client_create( uint16_t min_version, uint16_t max_version, bool insecure, - const char *anchor_pem, - size_t anchor_pem_len, + void *anchors_cf, bool anchor_only, bool has_verify_time, int64_t verify_time_unix_millis, @@ -346,9 +378,11 @@ box_apple_tls_client_t *box_apple_tls_client_create( atomic_init(&client->ref_count, 1); atomic_init(&client->ready, false); atomic_init(&client->ready_done, false); + if (anchors_cf != NULL) { + client->anchors = (void *)CFRetain(anchors_cf); + } NSArray *alpnList = box_split_lines(alpn, alpn_len); - NSArray *anchors = box_parse_certificates_from_pem(anchor_pem, anchor_pem_len); NSDate *verifyDate = nil; if (has_verify_time) { verifyDate = [NSDate dateWithTimeIntervalSince1970:(NSTimeInterval)verify_time_unix_millis / 1000.0]; @@ -372,13 +406,16 @@ box_apple_tls_client_t *box_apple_tls_client_create( if (client->state.version == 0) { box_apple_tls_state_load(metadata, &client->state); } - complete(insecure || box_evaluate_trust(trust, anchors, anchor_only, verifyDate)); + complete(insecure || box_evaluate_trust(trust, box_apple_tls_client_anchors(client), anchor_only, verifyDate)); }, box_apple_tls_client_queue(client)); }, NW_PARAMETERS_DEFAULT_CONFIGURATION); nw_connection_t connection = box_apple_tls_create_connection(connected_socket, parameters); if (connection == NULL) { close(connected_socket); + if (client->anchors != NULL) { + CFRelease((CFTypeRef)client->anchors); + } if (client->ready_semaphore != NULL) { CFBridgingRelease(client->ready_semaphore); } @@ -485,128 +522,202 @@ void box_apple_tls_client_free(box_apple_tls_client_t *client) { } ssize_t box_apple_tls_client_read(box_apple_tls_client_t *client, void *buffer, size_t buffer_len, int timeout_msec, bool *eof_out, char **error_out) { - nw_connection_t connection = box_apple_tls_connection(client); - if (connection == nil) { - box_set_error_message(error_out, "apple TLS: invalid client"); - return -1; - } + @autoreleasepool { + nw_connection_t connection = box_apple_tls_connection(client); + if (connection == nil) { + box_set_error_message(error_out, "apple TLS: invalid client"); + return -1; + } - dispatch_semaphore_t read_semaphore = dispatch_semaphore_create(0); - __block NSData *content_data = nil; - __block bool read_eof = false; - __block char *local_error = NULL; + dispatch_semaphore_t read_semaphore = dispatch_semaphore_create(0); + __block size_t content_len = 0; + __block bool read_eof = false; + __block char *local_error = NULL; - nw_connection_receive(connection, 1, (uint32_t)buffer_len, ^(dispatch_data_t content, nw_content_context_t context, bool is_complete, nw_error_t error) { - if (content != NULL) { - const void *mapped = NULL; - size_t mapped_len = 0; - dispatch_data_t mapped_data = dispatch_data_create_map(content, &mapped, &mapped_len); - if (mapped != NULL && mapped_len > 0) { - content_data = [NSData dataWithBytes:mapped length:mapped_len]; + nw_connection_receive(connection, 1, (uint32_t)buffer_len, ^(dispatch_data_t content, nw_content_context_t context, bool is_complete, nw_error_t error) { + @autoreleasepool { + if (content != NULL) { + ssize_t copied = box_apple_tls_dispatch_data_copy(content, buffer, buffer_len, &local_error); + if (copied >= 0) { + content_len = (size_t)copied; + } + } + if (error != NULL && content_len == 0 && local_error == NULL) { + box_set_error_from_nw_error(&local_error, error); + } + if (is_complete && (context == NULL || nw_content_context_get_is_final(context))) { + read_eof = true; + } + dispatch_semaphore_signal(read_semaphore); } - (void)mapped_data; - } - if (error != NULL && content_data.length == 0) { - box_set_error_from_nw_error(&local_error, error); - } - if (is_complete && (context == NULL || nw_content_context_get_is_final(context))) { - read_eof = true; - } - dispatch_semaphore_signal(read_semaphore); - }); + }); - dispatch_time_t wait_deadline = DISPATCH_TIME_FOREVER; - if (timeout_msec >= 0) { - wait_deadline = dispatch_time(DISPATCH_TIME_NOW, (int64_t)timeout_msec * NSEC_PER_MSEC); - } - long wait_result = dispatch_semaphore_wait(read_semaphore, wait_deadline); - if (wait_result != 0) { - nw_connection_cancel(connection); - dispatch_semaphore_wait(read_semaphore, DISPATCH_TIME_FOREVER); + dispatch_time_t wait_deadline = DISPATCH_TIME_FOREVER; + if (timeout_msec >= 0) { + wait_deadline = dispatch_time(DISPATCH_TIME_NOW, (int64_t)timeout_msec * NSEC_PER_MSEC); + } + long wait_result = dispatch_semaphore_wait(read_semaphore, wait_deadline); + if (wait_result != 0) { + nw_connection_cancel(connection); + dispatch_semaphore_wait(read_semaphore, DISPATCH_TIME_FOREVER); + if (local_error != NULL) { + free(local_error); + local_error = NULL; + } + return -2; + } if (local_error != NULL) { - free(local_error); - local_error = NULL; + if (error_out != NULL) { + *error_out = local_error; + } else { + free(local_error); + } + return -1; } - return -2; - } - if (local_error != NULL) { - if (error_out != NULL) { - *error_out = local_error; - } else { - free(local_error); + if (eof_out != NULL) { + *eof_out = read_eof; } - return -1; + return (ssize_t)content_len; } - if (eof_out != NULL) { - *eof_out = read_eof; - } - if (content_data == nil || content_data.length == 0) { - return 0; - } - memcpy(buffer, content_data.bytes, content_data.length); - return (ssize_t)content_data.length; } ssize_t box_apple_tls_client_write(box_apple_tls_client_t *client, const void *buffer, size_t buffer_len, int timeout_msec, char **error_out) { - nw_connection_t connection = box_apple_tls_connection(client); - if (connection == nil) { - box_set_error_message(error_out, "apple TLS: invalid client"); - return -1; - } - if (buffer_len == 0) { - return 0; - } - - void *content_copy = malloc(buffer_len); - dispatch_queue_t queue = box_apple_tls_client_queue(client); - if (content_copy == NULL) { - free(content_copy); - box_set_error_message(error_out, "apple TLS: out of memory"); - return -1; - } - if (queue == nil) { - free(content_copy); - box_set_error_message(error_out, "apple TLS: invalid client"); - return -1; - } - memcpy(content_copy, buffer, buffer_len); - dispatch_data_t content = dispatch_data_create(content_copy, buffer_len, queue, ^{ - free(content_copy); - }); - - dispatch_semaphore_t write_semaphore = dispatch_semaphore_create(0); - __block char *local_error = NULL; - - nw_connection_send(connection, content, NW_CONNECTION_DEFAULT_STREAM_CONTEXT, false, ^(nw_error_t error) { - if (error != NULL) { - box_set_error_from_nw_error(&local_error, error); + @autoreleasepool { + nw_connection_t connection = box_apple_tls_connection(client); + if (connection == nil) { + box_set_error_message(error_out, "apple TLS: invalid client"); + return -1; + } + if (buffer_len == 0) { + return 0; } - dispatch_semaphore_signal(write_semaphore); - }); - dispatch_time_t wait_deadline = DISPATCH_TIME_FOREVER; - if (timeout_msec >= 0) { - wait_deadline = dispatch_time(DISPATCH_TIME_NOW, (int64_t)timeout_msec * NSEC_PER_MSEC); - } - long wait_result = dispatch_semaphore_wait(write_semaphore, wait_deadline); - if (wait_result != 0) { - nw_connection_cancel(connection); - dispatch_semaphore_wait(write_semaphore, DISPATCH_TIME_FOREVER); + void *content_copy = malloc(buffer_len); + if (content_copy == NULL) { + box_set_error_message(error_out, "apple TLS: out of memory"); + return -1; + } + dispatch_queue_t queue = box_apple_tls_client_queue(client); + if (queue == nil) { + free(content_copy); + box_set_error_message(error_out, "apple TLS: invalid client"); + return -1; + } + memcpy(content_copy, buffer, buffer_len); + dispatch_data_t content = dispatch_data_create(content_copy, buffer_len, queue, ^{ + free(content_copy); + }); + + dispatch_semaphore_t write_semaphore = dispatch_semaphore_create(0); + __block char *local_error = NULL; + + nw_connection_send(connection, content, NW_CONNECTION_DEFAULT_STREAM_CONTEXT, false, ^(nw_error_t error) { + @autoreleasepool { + if (error != NULL) { + box_set_error_from_nw_error(&local_error, error); + } + dispatch_semaphore_signal(write_semaphore); + } + }); + + dispatch_time_t wait_deadline = DISPATCH_TIME_FOREVER; + if (timeout_msec >= 0) { + wait_deadline = dispatch_time(DISPATCH_TIME_NOW, (int64_t)timeout_msec * NSEC_PER_MSEC); + } + long wait_result = dispatch_semaphore_wait(write_semaphore, wait_deadline); + if (wait_result != 0) { + nw_connection_cancel(connection); + dispatch_semaphore_wait(write_semaphore, DISPATCH_TIME_FOREVER); + if (local_error != NULL) { + free(local_error); + local_error = NULL; + } + return -2; + } if (local_error != NULL) { - free(local_error); - local_error = NULL; + if (error_out != NULL) { + *error_out = local_error; + } else { + free(local_error); + } + return -1; } - return -2; + return (ssize_t)buffer_len; } - if (local_error != NULL) { - if (error_out != NULL) { - *error_out = local_error; - } else { - free(local_error); +} + +bool box_apple_tls_client_read_async(box_apple_tls_client_t *client, size_t maximum_len, uintptr_t callback_handle, char **error_out) { + @autoreleasepool { + nw_connection_t connection = box_apple_tls_connection(client); + if (connection == nil) { + box_set_error_message(error_out, "apple TLS: invalid client"); + return false; } - return -1; + if (maximum_len == 0) { + box_set_error_message(error_out, "apple TLS: empty read buffer"); + return false; + } + uint32_t receive_len = maximum_len > UINT32_MAX ? UINT32_MAX : (uint32_t)maximum_len; + nw_connection_receive(connection, 1, receive_len, ^(dispatch_data_t content, nw_content_context_t context, bool is_complete, nw_error_t error) { + @autoreleasepool { + box_apple_tls_read_result_t *result = calloc(1, sizeof(box_apple_tls_read_result_t)); + if (result == NULL) { + box_apple_tls_read_callback(callback_handle, NULL); + return; + } + size_t content_size = content != NULL ? dispatch_data_get_size(content) : 0; + if (content_size > 0) { + result->content = (__bridge_retained void *)content; + } + if (error != NULL && content_size == 0) { + box_set_error_from_nw_error(&result->error, error); + } + if (is_complete && (context == NULL || nw_content_context_get_is_final(context))) { + result->eof = true; + } + box_apple_tls_read_callback(callback_handle, result); + } + }); + return true; } - return (ssize_t)buffer_len; +} + +ssize_t box_apple_tls_read_result_copy(box_apple_tls_read_result_t *result, void *buffer, size_t buffer_len, bool *eof_out, char **error_out) { + @autoreleasepool { + if (result == NULL) { + box_set_error_message(error_out, "apple TLS: read result unavailable"); + return -1; + } + if (result->error != NULL) { + if (error_out != NULL) { + *error_out = result->error; + result->error = NULL; + } else { + free(result->error); + result->error = NULL; + } + return -1; + } + if (eof_out != NULL) { + *eof_out = result->eof; + } + dispatch_data_t content = box_apple_tls_read_result_content(result); + if (content == nil) { + return 0; + } + return box_apple_tls_dispatch_data_copy(content, buffer, buffer_len, error_out); + } +} + +void box_apple_tls_read_result_free(box_apple_tls_read_result_t *result) { + if (result == NULL) { + return; + } + free(result->error); + if (result->content != NULL) { + CFBridgingRelease(result->content); + } + free(result); } bool box_apple_tls_client_copy_state(box_apple_tls_client_t *client, box_apple_tls_state_t *state, char **error_out) { diff --git a/common/tls/apple_client_platform_dispatch_test.go b/common/tls/apple_client_platform_dispatch_test.go new file mode 100644 index 00000000..65261a92 --- /dev/null +++ b/common/tls/apple_client_platform_dispatch_test.go @@ -0,0 +1,50 @@ +//go:build darwin && cgo + +package tls + +import ( + "bytes" + "strings" + "testing" +) + +func TestAppleTLSDispatchDataCopySegments(t *testing.T) { + first := []byte("hello ") + second := []byte("world") + + buffer := make([]byte, len(first)+len(second)) + n, errorMessage := appleTLSCopyDispatchDataForTest(first, second, buffer) + if n < 0 { + t.Fatalf("copy failed: %s", errorMessage) + } + if int(n) != len(buffer) { + t.Fatalf("copied %d bytes, want %d", n, len(buffer)) + } + if !bytes.Equal(buffer, []byte("hello world")) { + t.Fatalf("unexpected copy result: %q", string(buffer)) + } +} + +func TestAppleTLSDispatchDataCopyRejectsSmallBuffer(t *testing.T) { + first := []byte("hello") + second := []byte("world") + + buffer := make([]byte, len(first)+len(second)-1) + n, errorMessage := appleTLSCopyDispatchDataForTest(first, second, buffer) + if n != -1 { + t.Fatalf("copied %d bytes, want error", n) + } + if !strings.Contains(errorMessage, "read buffer too small") { + t.Fatalf("unexpected error: %q", errorMessage) + } +} + +func TestAppleTLSDispatchDataCopyEmpty(t *testing.T) { + n, errorMessage := appleTLSCopyDispatchDataForTest(nil, nil, nil) + if n != 0 { + t.Fatalf("copied %d bytes, want 0", n) + } + if errorMessage != "" { + t.Fatalf("unexpected error: %q", errorMessage) + } +} diff --git a/common/tls/apple_client_platform_dispatch_testhelper_darwin.go b/common/tls/apple_client_platform_dispatch_testhelper_darwin.go new file mode 100644 index 00000000..0d80d429 --- /dev/null +++ b/common/tls/apple_client_platform_dispatch_testhelper_darwin.go @@ -0,0 +1,44 @@ +//go:build darwin && cgo + +package tls + +/* +#include +#include "apple_client_platform_darwin.h" +*/ +import "C" + +import "unsafe" + +func appleTLSCopyDispatchDataForTest(first, second []byte, buffer []byte) (int, string) { + var firstPtr unsafe.Pointer + if len(first) > 0 { + firstPtr = C.CBytes(first) + defer C.free(firstPtr) + } + var secondPtr unsafe.Pointer + if len(second) > 0 { + secondPtr = C.CBytes(second) + defer C.free(secondPtr) + } + var bufferPtr unsafe.Pointer + if len(buffer) > 0 { + bufferPtr = unsafe.Pointer(&buffer[0]) + } + var errPtr *C.char + n := C.box_apple_tls_copy_dispatch_data_for_test( + firstPtr, + C.size_t(len(first)), + secondPtr, + C.size_t(len(second)), + bufferPtr, + C.size_t(len(buffer)), + &errPtr, + ) + if errPtr == nil { + return int(n), "" + } + errorMessage := C.GoString(errPtr) + C.free(unsafe.Pointer(errPtr)) + return int(n), errorMessage +} diff --git a/common/tls/apple_client_platform_test.go b/common/tls/apple_client_platform_test.go index 6c915f68..18d040fb 100644 --- a/common/tls/apple_client_platform_test.go +++ b/common/tls/apple_client_platform_test.go @@ -3,17 +3,21 @@ package tls import ( + "bytes" "context" stdtls "crypto/tls" "errors" + "io" "net" "os" "testing" "time" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/buf" "github.com/sagernet/sing/common/json/badoption" "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" ) const appleTLSTestTimeout = 5 * time.Second @@ -28,6 +32,11 @@ type appleTLSServerResult struct { err error } +var ( + _ N.ExtendedConn = (*appleTLSConn)(nil) + _ N.ReadWaitCreator = (*appleTLSConn)(nil) +) + func TestAppleClientHandshakeAppliesALPNAndVersion(t *testing.T) { serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost") for index := 0; index < appleTLSSuccessHandshakeLoops; index++ { @@ -75,6 +84,29 @@ func TestAppleClientHandshakeAppliesALPNAndVersion(t *testing.T) { } } +func TestAppleClientHandshakeRejectsOpaqueConn(t *testing.T) { + clientConfig, err := NewClientWithOptions(ClientOptions{ + Context: context.Background(), + Logger: logger.NOP(), + Options: option.OutboundTLSOptions{ + Enabled: true, + Engine: "apple", + ServerName: "localhost", + Insecure: true, + }, + }) + if err != nil { + t.Fatal(err) + } + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + _, err = ClientHandshake(context.Background(), clientConn, clientConfig) + if err == nil { + t.Fatal("expected handshake to reject non-TCP connection") + } +} + func TestAppleClientHandshakeRejectsVersionMismatch(t *testing.T) { serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost") serverResult, serverAddress := startAppleTLSTestServer(t, &stdtls.Config{ @@ -209,6 +241,237 @@ func TestAppleClientHandshakeRecoversAfterFailure(t *testing.T) { } } +func TestAppleClientConfigCloneWithInlineCertificate(t *testing.T) { + serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost") + clientConfig, err := NewClientWithOptions(ClientOptions{ + Context: context.Background(), + Logger: logger.NOP(), + Options: option.OutboundTLSOptions{ + Enabled: true, + Engine: "apple", + ServerName: "localhost", + MinVersion: "1.2", + MaxVersion: "1.2", + ALPN: badoption.Listable[string]{"h2", "http/1.1"}, + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }, + }) + if err != nil { + t.Fatal(err) + } + + clone := clientConfig.Clone() + clone.SetServerName("other") + clone.SetNextProtos([]string{"http/1.1"}) + if clientConfig.ServerName() == "other" { + t.Fatal("Clone shares server name with original") + } + nextProtos := clientConfig.NextProtos() + if len(nextProtos) != 2 || nextProtos[0] != "h2" || nextProtos[1] != "http/1.1" { + t.Fatalf("Clone shares ALPN slice with original: %v", nextProtos) + } + + for index := 0; index < appleTLSFailureRecoveryLoops; index++ { + serverResult, serverAddress := startAppleTLSTestServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + NextProtos: []string{"h2"}, + }) + + handshakeConfig := clientConfig.Clone() + handshakeConfig.SetNextProtos([]string{"h2"}) + clientConn, err := dialAppleTestClientConn(t, serverAddress, handshakeConfig) + if err != nil { + t.Fatalf("iteration %d: %v", index, err) + } + + clientState := clientConn.ConnectionState() + if clientState.NegotiatedProtocol != "h2" { + _ = clientConn.Close() + t.Fatalf("iteration %d: unexpected negotiated protocol: %q", index, clientState.NegotiatedProtocol) + } + _ = clientConn.Close() + + result := <-serverResult + if result.err != nil { + t.Fatalf("iteration %d: %v", index, result.err) + } + } +} + +func TestAppleClientReadBuffer(t *testing.T) { + serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost") + payload := []byte("apple tls read buffer payload") + serverResult, serverAddress := startAppleTLSIOTestServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + }, func(conn *stdtls.Conn) error { + _, err := conn.Write(payload) + return err + }) + + clientConn, err := newAppleTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: "apple", + ServerName: "localhost", + MinVersion: "1.2", + MaxVersion: "1.2", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + + extendedConn := clientConn.(N.ExtendedConn) + const ( + frontHeadroom = 17 + rearHeadroom = 19 + ) + buffer := buf.NewSize(frontHeadroom + len(payload) + rearHeadroom) + defer buffer.Release() + buffer.Resize(frontHeadroom, 0) + buffer.Reserve(rearHeadroom) + err = extendedConn.ReadBuffer(buffer) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(buffer.Bytes(), payload) { + t.Fatalf("unexpected payload: %q", buffer.Bytes()) + } + if buffer.Start() != frontHeadroom { + t.Fatalf("unexpected front headroom: %d", buffer.Start()) + } + if buffer.FreeLen() != 0 { + t.Fatalf("unexpected reserved free length before PostReturn: %d", buffer.FreeLen()) + } + buffer.OverCap(rearHeadroom) + if buffer.FreeLen() != rearHeadroom { + t.Fatalf("unexpected rear headroom after PostReturn: %d", buffer.FreeLen()) + } + + if err = <-serverResult; err != nil { + t.Fatal(err) + } +} + +func TestAppleClientWriteBuffer(t *testing.T) { + serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost") + payload := bytes.Repeat([]byte("apple-write-buffer-"), 3000) + serverResult, serverAddress := startAppleTLSIOTestServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + }, func(conn *stdtls.Conn) error { + received := make([]byte, len(payload)) + _, err := io.ReadFull(conn, received) + if err != nil { + return err + } + if !bytes.Equal(received, payload) { + return errors.New("payload mismatch") + } + return nil + }) + + clientConn, err := newAppleTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: "apple", + ServerName: "localhost", + MinVersion: "1.2", + MaxVersion: "1.2", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + + extendedConn := clientConn.(N.ExtendedConn) + buffer := buf.NewSize(len(payload)) + _, err = buffer.Write(payload) + if err != nil { + t.Fatal(err) + } + err = extendedConn.WriteBuffer(buffer) + if err != nil { + t.Fatal(err) + } + if buffer.RawCap() != 0 { + t.Fatalf("buffer was not released: raw cap %d", buffer.RawCap()) + } + if err = <-serverResult; err != nil { + t.Fatal(err) + } +} + +func TestAppleClientCreateReadWaiter(t *testing.T) { + serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost") + payload := []byte("apple tls read waiter payload") + serverResult, serverAddress := startAppleTLSIOTestServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + }, func(conn *stdtls.Conn) error { + _, err := conn.Write(payload) + return err + }) + + clientConn, err := newAppleTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: "apple", + ServerName: "localhost", + MinVersion: "1.2", + MaxVersion: "1.2", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + + readWaitCreator := clientConn.(N.ReadWaitCreator) + readWaiter, ok := readWaitCreator.CreateReadWaiter() + if !ok { + t.Fatal("expected read waiter") + } + const ( + frontHeadroom = 11 + rearHeadroom = 13 + ) + needCopy := readWaiter.InitializeReadWaiter(N.ReadWaitOptions{ + FrontHeadroom: frontHeadroom, + RearHeadroom: rearHeadroom, + MTU: len(payload), + }) + if needCopy { + t.Fatal("expected owned read waiter buffer") + } + buffer, err := readWaiter.WaitReadBuffer() + if err != nil { + t.Fatal(err) + } + defer buffer.Release() + if !bytes.Equal(buffer.Bytes(), payload) { + t.Fatalf("unexpected payload: %q", buffer.Bytes()) + } + if buffer.Start() != frontHeadroom { + t.Fatalf("unexpected front headroom: %d", buffer.Start()) + } + if buffer.FreeLen() != rearHeadroom { + t.Fatalf("unexpected rear headroom: %d", buffer.FreeLen()) + } + if buffer.Cap() != buffer.RawCap() { + t.Fatalf("capacity was not restored: cap=%d raw=%d", buffer.Cap(), buffer.RawCap()) + } + + if err = <-serverResult; err != nil { + t.Fatal(err) + } +} + func TestAppleClientReadDeadline(t *testing.T) { serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost") serverDone, serverAddress := startAppleTLSSilentServer(t, &stdtls.Config{ @@ -359,7 +622,52 @@ func startAppleTLSSilentServer(t *testing.T, tlsConfig *stdtls.Config) (chan<- s return done, listener.Addr().String() } -func newAppleTestCertificate(t *testing.T, serverName string) (stdtls.Certificate, string) { +func startAppleTLSIOTestServer(t testing.TB, tlsConfig *stdtls.Config, handler func(*stdtls.Conn) error) (<-chan error, string) { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + listener.Close() + }) + + if tcpListener, isTCP := listener.(*net.TCPListener); isTCP { + err = tcpListener.SetDeadline(time.Now().Add(appleTLSTestTimeout)) + if err != nil { + t.Fatal(err) + } + } + + result := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + result <- err + return + } + defer conn.Close() + + err = conn.SetDeadline(time.Now().Add(appleTLSTestTimeout)) + if err != nil { + result <- err + return + } + + tlsConn := stdtls.Server(conn, tlsConfig) + defer tlsConn.Close() + err = tlsConn.Handshake() + if err != nil { + result <- err + return + } + result <- handler(tlsConn) + }() + return result, listener.Addr().String() +} + +func newAppleTestCertificate(t testing.TB, serverName string) (stdtls.Certificate, string) { t.Helper() privateKeyPEM, certificatePEM, err := GenerateCertificate(nil, nil, time.Now, serverName, time.Now().Add(time.Hour)) @@ -423,14 +731,11 @@ func startAppleTLSTestServer(t *testing.T, tlsConfig *stdtls.Config) (<-chan app return result, listener.Addr().String() } -func newAppleTestClientConn(t *testing.T, serverAddress string, options option.OutboundTLSOptions) (Conn, error) { +func newAppleTestClientConn(t testing.TB, serverAddress string, options option.OutboundTLSOptions) (Conn, error) { t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), appleTLSTestTimeout) - t.Cleanup(cancel) - clientConfig, err := NewClientWithOptions(ClientOptions{ - Context: ctx, + Context: context.Background(), Logger: logger.NOP(), ServerAddress: "", Options: options, @@ -438,6 +743,14 @@ func newAppleTestClientConn(t *testing.T, serverAddress string, options option.O if err != nil { return nil, err } + return dialAppleTestClientConn(t, serverAddress, clientConfig) +} + +func dialAppleTestClientConn(t testing.TB, serverAddress string, clientConfig Config) (Conn, error) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), appleTLSTestTimeout) + t.Cleanup(cancel) conn, err := net.DialTimeout("tcp", serverAddress, appleTLSTestTimeout) if err != nil { diff --git a/common/tls/client.go b/common/tls/client.go index b5b975bf..51343841 100644 --- a/common/tls/client.go +++ b/common/tls/client.go @@ -98,9 +98,11 @@ func NewClientWithOptions(options ClientOptions) (Config, error) { options.Logger.Warn("enabling kTLS RX will definitely reduce performance, please checkout https://sing-box.sagernet.org/configuration/shared/tls/#kernel_rx") } switch options.Options.Engine { - case C.TLSEngineDefault, "go": + case "", C.TLSEngineGo: case C.TLSEngineApple: return newAppleClient(options.Context, options.Logger, options.ServerAddress, options.Options, options.AllowEmptyServerName) + case C.TLSEngineWindows: + return newWindowsClient(options.Context, options.Logger, options.ServerAddress, options.Options, options.AllowEmptyServerName) default: return nil, E.New("unknown tls engine: ", options.Options.Engine) } diff --git a/common/tls/std_client.go b/common/tls/std_client.go index 031a256f..65310396 100644 --- a/common/tls/std_client.go +++ b/common/tls/std_client.go @@ -271,19 +271,7 @@ func verifyConnection(rootCAs *x509.CertPool, timeFunc func() time.Time, serverN if serverName == "" { return errMissingServerName } - verifyOptions := x509.VerifyOptions{ - Roots: rootCAs, - DNSName: serverName, - Intermediates: x509.NewCertPool(), - } - for _, cert := range state.PeerCertificates[1:] { - verifyOptions.Intermediates.AddCert(cert) - } - if timeFunc != nil { - verifyOptions.CurrentTime = timeFunc() - } - _, err := state.PeerCertificates[0].Verify(verifyOptions) - return err + return verifySystemTLSPeer(rootCAs, serverName, timeFunc, state.PeerCertificates) } } diff --git a/common/tls/system_client.go b/common/tls/system_client.go new file mode 100644 index 00000000..356030ec --- /dev/null +++ b/common/tls/system_client.go @@ -0,0 +1,218 @@ +package tls + +import ( + "context" + "crypto/x509" + "net" + "os" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/service" +) + +type systemTLSConfig struct { + serverName string + nextProtos []string + handshakeTimeout time.Duration + minVersion uint16 + maxVersion uint16 + insecure bool + anchorOnly bool + certificatePublicKeySHA256 [][]byte + timeFunc func() time.Time + store adapter.CertificateStore +} + +func (c *systemTLSConfig) ServerName() string { + return c.serverName +} + +func (c *systemTLSConfig) SetServerName(serverName string) { + c.serverName = serverName +} + +func (c *systemTLSConfig) NextProtos() []string { + return c.nextProtos +} + +func (c *systemTLSConfig) SetNextProtos(nextProto []string) { + c.nextProtos = append([]string(nil), nextProto...) +} + +func (c *systemTLSConfig) HandshakeTimeout() time.Duration { + return c.handshakeTimeout +} + +func (c *systemTLSConfig) SetHandshakeTimeout(timeout time.Duration) { + c.handshakeTimeout = timeout +} + +func (c *systemTLSConfig) STDConfig() (*STDConfig, error) { + return nil, E.New("STDConfig is unsupported for the system TLS engine") +} + +func (c *systemTLSConfig) Client(conn net.Conn) (Conn, error) { + return nil, os.ErrInvalid +} + +func (c *systemTLSConfig) clone() systemTLSConfig { + return systemTLSConfig{ + serverName: c.serverName, + nextProtos: append([]string(nil), c.nextProtos...), + handshakeTimeout: c.handshakeTimeout, + minVersion: c.minVersion, + maxVersion: c.maxVersion, + insecure: c.insecure, + anchorOnly: c.anchorOnly, + certificatePublicKeySHA256: append([][]byte(nil), c.certificatePublicKeySHA256...), + timeFunc: c.timeFunc, + store: c.store, + } +} + +type SystemTLSValidated struct { + MinVersion uint16 + MaxVersion uint16 + UserPEM []byte + Exclusive bool + Store adapter.CertificateStore +} + +func ValidateSystemTLSOptions(ctx context.Context, options option.OutboundTLSOptions, engineName string) (SystemTLSValidated, error) { + if options.Reality != nil && options.Reality.Enabled { + return SystemTLSValidated{}, E.New("reality is unsupported in ", engineName) + } + if options.UTLS != nil && options.UTLS.Enabled { + return SystemTLSValidated{}, E.New("utls is unsupported in ", engineName) + } + if options.ECH != nil && options.ECH.Enabled { + return SystemTLSValidated{}, E.New("ech is unsupported in ", engineName) + } + if options.DisableSNI { + return SystemTLSValidated{}, E.New("disable_sni is unsupported in ", engineName) + } + if len(options.CipherSuites) > 0 { + return SystemTLSValidated{}, E.New("cipher_suites is unsupported in ", engineName) + } + if len(options.CurvePreferences) > 0 { + return SystemTLSValidated{}, E.New("curve_preferences is unsupported in ", engineName) + } + if len(options.ClientCertificate) > 0 || options.ClientCertificatePath != "" || len(options.ClientKey) > 0 || options.ClientKeyPath != "" { + return SystemTLSValidated{}, E.New("client certificate is unsupported in ", engineName) + } + if options.Fragment || options.RecordFragment { + return SystemTLSValidated{}, E.New("tls fragment is unsupported in ", engineName) + } + if options.KernelTx || options.KernelRx { + return SystemTLSValidated{}, E.New("ktls is unsupported in ", engineName) + } + if options.Spoof != "" || options.SpoofMethod != "" { + return SystemTLSValidated{}, E.New("spoof is unsupported in ", engineName) + } + if len(options.CertificatePublicKeySHA256) > 0 && (len(options.Certificate) > 0 || options.CertificatePath != "") { + return SystemTLSValidated{}, E.New("certificate_public_key_sha256 is conflict with certificate or certificate_path") + } + var minVersion uint16 + if options.MinVersion != "" { + parsed, err := ParseTLSVersion(options.MinVersion) + if err != nil { + return SystemTLSValidated{}, E.Cause(err, "parse min_version") + } + minVersion = parsed + } + var maxVersion uint16 + if options.MaxVersion != "" { + parsed, err := ParseTLSVersion(options.MaxVersion) + if err != nil { + return SystemTLSValidated{}, E.Cause(err, "parse max_version") + } + maxVersion = parsed + } + userPEM, exclusive, store, err := resolveSystemAnchors(ctx, options) + if err != nil { + return SystemTLSValidated{}, err + } + return SystemTLSValidated{ + MinVersion: minVersion, + MaxVersion: maxVersion, + UserPEM: userPEM, + Exclusive: exclusive, + Store: store, + }, nil +} + +func resolveSystemAnchors(ctx context.Context, options option.OutboundTLSOptions) ([]byte, bool, adapter.CertificateStore, error) { + if len(options.Certificate) > 0 { + return []byte(strings.Join(options.Certificate, "\n")), true, nil, nil + } + if options.CertificatePath != "" { + content, err := os.ReadFile(options.CertificatePath) + if err != nil { + return nil, false, nil, E.Cause(err, "read certificate") + } + return content, true, nil, nil + } + store := service.FromContext[adapter.CertificateStore](ctx) + if store == nil { + return nil, false, nil, nil + } + return nil, store.ExclusiveAnchors(), store, nil +} + +func newSystemTLSConfig(ctx context.Context, serverAddress string, options option.OutboundTLSOptions, allowEmptyServerName bool, engineName string) (systemTLSConfig, SystemTLSValidated, error) { + validated, err := ValidateSystemTLSOptions(ctx, options, engineName) + if err != nil { + return systemTLSConfig{}, SystemTLSValidated{}, err + } + var serverName string + if options.ServerName != "" { + serverName = options.ServerName + } else if serverAddress != "" { + serverName = serverAddress + } + if serverName == "" && !options.Insecure && !allowEmptyServerName { + return systemTLSConfig{}, SystemTLSValidated{}, errMissingServerName + } + handshakeTimeout := C.TCPTimeout + if options.HandshakeTimeout > 0 { + handshakeTimeout = options.HandshakeTimeout.Build() + } + return systemTLSConfig{ + serverName: serverName, + nextProtos: append([]string(nil), options.ALPN...), + handshakeTimeout: handshakeTimeout, + minVersion: validated.MinVersion, + maxVersion: validated.MaxVersion, + insecure: options.Insecure || len(options.CertificatePublicKeySHA256) > 0, + anchorOnly: validated.Exclusive, + certificatePublicKeySHA256: append([][]byte(nil), options.CertificatePublicKeySHA256...), + timeFunc: ntp.TimeFuncFromContext(ctx), + store: validated.Store, + }, validated, nil +} + +func verifySystemTLSPeer(roots *x509.CertPool, serverName string, timeFunc func() time.Time, peerCertificates []*x509.Certificate) error { + if len(peerCertificates) == 0 { + return E.New("no peer certificates") + } + intermediates := x509.NewCertPool() + for _, cert := range peerCertificates[1:] { + intermediates.AddCert(cert) + } + verifyOptions := x509.VerifyOptions{ + Roots: roots, + Intermediates: intermediates, + DNSName: serverName, + } + if timeFunc != nil { + verifyOptions.CurrentTime = timeFunc() + } + _, err := peerCertificates[0].Verify(verifyOptions) + return err +} diff --git a/common/tls/windows_client.go b/common/tls/windows_client.go new file mode 100644 index 00000000..9a865c0a --- /dev/null +++ b/common/tls/windows_client.go @@ -0,0 +1,848 @@ +//go:build windows + +package tls + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "io" + "net" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/common/schannel" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" +) + +const ( + windowsTLSEngineName = "Windows TLS engine" + handshakeReadChunkSize = 8192 + readScratchSize = 16 * 1024 + readWaitCiphertextChunkSize = 4096 +) + +type windowsClientConfig struct { + systemTLSConfig + userRoots *x509.CertPool +} + +func (c *windowsClientConfig) Clone() Config { + return &windowsClientConfig{ + systemTLSConfig: c.systemTLSConfig.clone(), + userRoots: c.userRoots, + } +} + +func newWindowsClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions, allowEmptyServerName bool) (Config, error) { + err := schannel.CheckPlatform() + if err != nil { + return nil, err + } + base, validated, err := newSystemTLSConfig(ctx, serverAddress, options, allowEmptyServerName, windowsTLSEngineName) + if err != nil { + return nil, err + } + var userRoots *x509.CertPool + if len(validated.UserPEM) > 0 { + userRoots = x509.NewCertPool() + if !userRoots.AppendCertsFromPEM(validated.UserPEM) { + return nil, E.New("parse certificate PEM") + } + } + return &windowsClientConfig{ + systemTLSConfig: base, + userRoots: userRoots, + }, nil +} + +func (c *windowsClientConfig) ClientHandshake(ctx context.Context, conn net.Conn) (Conn, error) { + deadline, hasDeadline := ctx.Deadline() + if hasDeadline { + deadlineErr := conn.SetDeadline(deadline) + if deadlineErr != nil { + return nil, E.Cause(deadlineErr, "set handshake deadline") + } + defer conn.SetDeadline(time.Time{}) + } + + client, err := schannel.NewClientContext(c.minVersion, c.maxVersion, c.serverName, c.nextProtos) + if err != nil { + return nil, err + } + + handshakeOK := false + defer func() { + if !handshakeOK { + client.Close() + } + }() + + stopCancel := installHandshakeCancel(ctx, conn) + defer stopCancel() + + scratch := make([]byte, handshakeReadChunkSize) + leftover, err := driveHandshake(ctx, conn, client, scratch) + if err != nil { + return nil, err + } + state, rawCerts, err := buildConnectionState(c.serverName, client) + if err != nil { + return nil, err + } + err = c.verifyPeerCertificates(state.PeerCertificates) + if err != nil { + return nil, err + } + if len(c.certificatePublicKeySHA256) > 0 { + err = VerifyPublicKeySHA256(c.certificatePublicKeySHA256, rawCerts) + if err != nil { + return nil, err + } + } + header, trailer, maxMessage, err := client.StreamSizes() + if err != nil { + return nil, err + } + + handshakeOK = true + tlsConn := &windowsTLSConn{ + rawConn: conn, + client: client, + state: state, + header: header, + trailer: trailer, + maxMessage: maxMessage, + cipher: leftover, + } + return tlsConn, nil +} + +func driveHandshake(ctx context.Context, conn net.Conn, client *schannel.ClientContext, scratch []byte) ([]byte, error) { + readMore := func() ([]byte, error) { + more, err := readTLSRaw(conn, scratch, true) + if err != nil { + return nil, handshakeIOError(ctx, err, "read handshake") + } + return more, nil + } + writeOut := func(data []byte) error { + _, err := conn.Write(data) + if err != nil { + return handshakeIOError(ctx, err, "write handshake") + } + return nil + } + leftover, err := driveSteps(nil, client.Step, readMore, writeOut) + if err != nil { + return nil, E.Cause(err, "tls handshake") + } + return leftover, nil +} + +func driveSteps( + initial []byte, + step func([]byte) (schannel.StepResult, error), + readMore func() ([]byte, error), + writeOut func([]byte) error, +) ([]byte, error) { + buffer := initial + for { + result, stepErr := step(buffer) + if stepErr != nil { + return nil, stepErr + } + if len(result.Output) > 0 { + writeErr := writeOut(result.Output) + if writeErr != nil { + return nil, writeErr + } + } + if result.Incomplete { + // readMore reuses scratch storage, so keep the buffered handshake + // bytes in stable memory before the next read overwrites them. + buffer = append([]byte(nil), buffer...) + more, readErr := readMore() + if readErr != nil { + return nil, readErr + } + buffer = append(buffer, more...) + continue + } + if result.Consumed > len(buffer) { + return nil, E.New("schannel: Consumed > input length") + } + buffer = buffer[result.Consumed:] + if result.Done { + return buffer, nil + } + if len(buffer) == 0 { + more, readErr := readMore() + if readErr != nil { + return nil, readErr + } + buffer = append(buffer, more...) + } + } +} + +// installHandshakeCancel unblocks an in-flight read/write by forcing an +// immediate deadline on conn when ctx is cancelled. The returned cleanup +// waits for a racing cancel to finish and clears the forced deadline. +func installHandshakeCancel(ctx context.Context, conn net.Conn) func() { + var fired atomic.Bool + done := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { + defer close(done) + fired.Store(true) + _ = conn.SetDeadline(time.Now()) + }) + return func() { + if stop() { + return + } + <-done + if fired.Load() { + _ = conn.SetDeadline(time.Time{}) + } + } +} + +func handshakeIOError(ctx context.Context, err error, message string) error { + ctxErr := ctx.Err() + if ctxErr != nil && isTimeoutError(err) { + return ctxErr + } + return E.Cause(err, message) +} + +func readTLSRaw(conn net.Conn, scratch []byte, requireMore bool) ([]byte, error) { + n, err := conn.Read(scratch) + if n > 0 { + return scratch[:n], nil + } + if err != nil { + if requireMore && errors.Is(err, io.EOF) { + return nil, io.ErrUnexpectedEOF + } + return nil, err + } + return nil, io.ErrUnexpectedEOF +} + +func isTimeoutError(err error) bool { + if errors.Is(err, os.ErrDeadlineExceeded) { + return true + } + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} + +func buildConnectionState(serverName string, client *schannel.ClientContext) (tls.ConnectionState, [][]byte, error) { + version, cipherSuite, err := client.ConnectionInfo() + if err != nil { + return tls.ConnectionState{}, nil, err + } + alpn, err := client.ApplicationProtocol() + if err != nil { + return tls.ConnectionState{}, nil, err + } + rawCerts, err := client.RemoteCertificateChain() + if err != nil { + return tls.ConnectionState{}, nil, err + } + peerCertificates := make([]*x509.Certificate, 0, len(rawCerts)) + for index, der := range rawCerts { + cert, parseErr := x509.ParseCertificate(der) + if parseErr != nil { + return tls.ConnectionState{}, nil, E.Cause(parseErr, "parse peer certificate ", index) + } + peerCertificates = append(peerCertificates, cert) + } + return tls.ConnectionState{ + Version: version, + HandshakeComplete: true, + CipherSuite: cipherSuite, + NegotiatedProtocol: alpn, + ServerName: serverName, + PeerCertificates: peerCertificates, + }, rawCerts, nil +} + +func (c *windowsClientConfig) verifyPeerCertificates(peerCertificates []*x509.Certificate) error { + if c.insecure { + return nil + } + var roots *x509.CertPool + switch { + case c.userRoots != nil: + roots = c.userRoots + case c.store != nil: + roots = c.store.Pool() + } + return verifySystemTLSPeer(roots, c.serverName, c.timeFunc, peerCertificates) +} + +type windowsTLSConn struct { + rawConn net.Conn + client *schannel.ClientContext + state tls.ConnectionState + header uint32 + trailer uint32 + maxMessage uint32 + + readAccess sync.Mutex + writeAccess sync.Mutex + contextAccess sync.RWMutex + + writeState sync.Mutex + writeStateOnce sync.Once + writeReady *sync.Cond + postHandshake bool + writeActive bool + + cipher []byte + plain []byte + readScratch []byte + writeScratch []byte + readEOF bool + + deadlineAccess sync.Mutex + readDeadline time.Time + writeDeadline time.Time + closed atomic.Bool +} + +var ( + _ N.ExtendedConn = (*windowsTLSConn)(nil) + _ N.ReadWaitCreator = (*windowsTLSConn)(nil) +) + +type ( + windowsTLSAppendCipherFunc func(requireMore bool) error + windowsTLSReadRawFunc func(requireMore bool) ([]byte, error) +) + +func (c *windowsTLSConn) Read(p []byte) (int, error) { + c.readAccess.Lock() + defer c.readAccess.Unlock() + if len(p) == 0 { + return 0, nil + } + if c.isClosed() { + return 0, net.ErrClosed + } + return c.readIntoLocked(p, c.appendRaw, c.readRaw) +} + +func (c *windowsTLSConn) ReadBuffer(buffer *buf.Buffer) error { + c.readAccess.Lock() + defer c.readAccess.Unlock() + if buffer.IsFull() { + return io.ErrShortBuffer + } + if c.isClosed() { + return net.ErrClosed + } + startLen := buffer.Len() + n, err := c.readIntoLocked(buffer.FreeBytes(), c.appendRaw, c.readRaw) + buffer.Truncate(startLen + n) + return err +} + +func (c *windowsTLSConn) readIntoLocked(p []byte, appendCipher windowsTLSAppendCipherFunc, readRaw windowsTLSReadRawFunc) (int, error) { + plaintext, err := c.readPlaintextLocked(appendCipher, readRaw) + if err != nil { + return 0, err + } + n := copy(p, plaintext) + if n < len(plaintext) { + c.plain = append([]byte(nil), plaintext[n:]...) + } + return n, nil +} + +func (c *windowsTLSConn) readPlaintextLocked(appendCipher windowsTLSAppendCipherFunc, readRaw windowsTLSReadRawFunc) ([]byte, error) { + if len(c.plain) > 0 { + plaintext := c.plain + c.plain = nil + return plaintext, nil + } + if c.readEOF { + return nil, io.EOF + } + + cleanup, err := c.applyReadDeadline() + if err != nil { + return nil, err + } + defer cleanup() + + for { + if len(c.cipher) > 0 { + result, decryptErr := c.decrypt(c.cipher) + if decryptErr != nil { + return nil, decryptErr + } + if result.Expired { + c.readEOF = true + return nil, io.EOF + } + if !result.Incomplete { + plaintext := result.Plaintext + if result.Renegotiate && len(plaintext) > 0 { + plaintext = append([]byte(nil), plaintext...) + } + nextCipher := c.cipher[result.ConsumedTotal:] + if len(result.RenegotiateToken) > 0 { + nextCipher = result.RenegotiateToken + } + c.cipher = nextCipher + if len(c.cipher) == 0 { + c.cipher = nil + } + if result.Renegotiate { + postErr := c.drivePostHandshake(readRaw) + if postErr != nil { + return nil, postErr + } + } + if len(plaintext) > 0 { + return plaintext, nil + } + continue + } + } + err = appendCipher(len(c.cipher) > 0) + if err != nil { + return nil, err + } + } +} + +func (c *windowsTLSConn) drivePostHandshake(readRaw windowsTLSReadRawFunc) error { + initial := c.cipher + c.cipher = nil + err := c.beginPostHandshakeWrite() + if err != nil { + return err + } + defer c.finishPostHandshakeWrite() + c.contextAccess.Lock() + if c.client == nil { + c.contextAccess.Unlock() + return net.ErrClosed + } + writeFailed := false + readMore := func() ([]byte, error) { + more, err := readRaw(true) + if err != nil { + return nil, E.Cause(err, "tls post-handshake read") + } + return more, nil + } + writeOut := func(data []byte) error { + err := c.writePostHandshakeReplyLocked(data) + if err != nil { + writeFailed = true + return E.Cause(err, "tls post-handshake write") + } + return nil + } + leftover, err := driveSteps(initial, c.client.PostHandshake, readMore, writeOut) + c.contextAccess.Unlock() + if err != nil { + if writeFailed { + _ = c.Close() + } + return E.Cause(err, "tls post-handshake") + } + if len(leftover) > 0 { + c.cipher = leftover + } + return nil +} + +func (c *windowsTLSConn) writePostHandshakeReplyLocked(data []byte) error { + c.deadlineAccess.Lock() + deadline := c.readDeadline + c.deadlineAccess.Unlock() + cleanup, err := c.applyDeadline(deadline, c.rawConn.SetWriteDeadline) + if err != nil { + return err + } + defer cleanup() + _, err = c.rawConn.Write(data) + return err +} + +func (c *windowsTLSConn) decrypt(input []byte) (schannel.DecryptResult, error) { + c.contextAccess.RLock() + defer c.contextAccess.RUnlock() + if c.client == nil { + return schannel.DecryptResult{}, net.ErrClosed + } + return c.client.Decrypt(input) +} + +func (c *windowsTLSConn) encrypt(plaintext []byte) ([]byte, error) { + c.contextAccess.RLock() + defer c.contextAccess.RUnlock() + if c.client == nil { + return nil, net.ErrClosed + } + if c.writeScratch == nil { + c.writeScratch = make([]byte, int(c.header)+int(c.maxMessage)+int(c.trailer)) + } + return c.client.Encrypt(c.header, c.trailer, plaintext, c.writeScratch) +} + +func (c *windowsTLSConn) readRaw(requireMore bool) ([]byte, error) { + if c.readScratch == nil { + c.readScratch = make([]byte, readScratchSize) + } + return readTLSRaw(c.rawConn, c.readScratch, requireMore) +} + +func (c *windowsTLSConn) appendRaw(requireMore bool) error { + more, err := c.readRaw(requireMore) + if err != nil { + return err + } + c.cipher = append(c.cipher, more...) + return nil +} + +func (c *windowsTLSConn) Write(p []byte) (int, error) { + err := c.beginWrite() + if err != nil { + return 0, err + } + defer c.finishWrite() + if len(p) == 0 { + return 0, nil + } + if c.isClosed() { + return 0, net.ErrClosed + } + + cleanup, err := c.applyWriteDeadline() + if err != nil { + return 0, err + } + defer cleanup() + + total := 0 + chunkSize := int(c.maxMessage) + for len(p) > 0 { + chunk := p + if len(chunk) > chunkSize { + chunk = chunk[:chunkSize] + } + encrypted, encryptErr := c.encrypt(chunk) + if encryptErr != nil { + if errors.Is(encryptErr, net.ErrClosed) { + return total, net.ErrClosed + } + return total, E.Cause(encryptErr, "tls encrypt") + } + _, writeErr := c.rawConn.Write(encrypted) + if writeErr != nil { + _ = c.Close() + return total, E.Cause(writeErr, "tls write") + } + total += len(chunk) + p = p[len(chunk):] + } + return total, nil +} + +func (c *windowsTLSConn) WriteBuffer(buffer *buf.Buffer) error { + defer buffer.Release() + _, err := c.Write(buffer.Bytes()) + return err +} + +func (c *windowsTLSConn) CreateReadWaiter() (N.ReadWaiter, bool) { + rawWaiter, ok := bufio.CreateReadWaiter(c.rawConn) + if !ok { + return nil, false + } + return &windowsTLSReadWaiter{ + conn: c, + rawWaiter: rawWaiter, + }, true +} + +func (c *windowsTLSConn) Close() error { + if !c.closed.CompareAndSwap(false, true) { + return nil + } + ready := c.writeCondition() + c.writeState.Lock() + ready.Broadcast() + c.writeState.Unlock() + closeErr := c.rawConn.Close() + c.contextAccess.Lock() + if c.client != nil { + c.client.Close() + c.client = nil + } + c.contextAccess.Unlock() + return closeErr +} + +func (c *windowsTLSConn) LocalAddr() net.Addr { + return c.rawConn.LocalAddr() +} + +func (c *windowsTLSConn) RemoteAddr() net.Addr { + return c.rawConn.RemoteAddr() +} + +func (c *windowsTLSConn) SetDeadline(t time.Time) error { + c.deadlineAccess.Lock() + defer c.deadlineAccess.Unlock() + err := c.rawConn.SetDeadline(t) + if err != nil { + return err + } + c.readDeadline = t + c.writeDeadline = t + return nil +} + +func (c *windowsTLSConn) SetReadDeadline(t time.Time) error { + c.deadlineAccess.Lock() + defer c.deadlineAccess.Unlock() + err := c.rawConn.SetReadDeadline(t) + if err != nil { + return err + } + c.readDeadline = t + return nil +} + +func (c *windowsTLSConn) SetWriteDeadline(t time.Time) error { + c.deadlineAccess.Lock() + defer c.deadlineAccess.Unlock() + err := c.rawConn.SetWriteDeadline(t) + if err != nil { + return err + } + c.writeDeadline = t + return nil +} + +func (c *windowsTLSConn) NetConn() net.Conn { + return c.rawConn +} + +func (c *windowsTLSConn) HandshakeContext(ctx context.Context) error { + return nil +} + +func (c *windowsTLSConn) ConnectionState() ConnectionState { + return c.state +} + +func (c *windowsTLSConn) applyReadDeadline() (func(), error) { + c.deadlineAccess.Lock() + deadline := c.readDeadline + c.deadlineAccess.Unlock() + return c.applyDeadline(deadline, c.rawConn.SetReadDeadline) +} + +func (c *windowsTLSConn) applyWriteDeadline() (func(), error) { + c.deadlineAccess.Lock() + deadline := c.writeDeadline + c.deadlineAccess.Unlock() + return c.applyDeadline(deadline, c.rawConn.SetWriteDeadline) +} + +func (c *windowsTLSConn) applyDeadline(deadline time.Time, set func(time.Time) error) (func(), error) { + if deadline.IsZero() { + return func() {}, nil + } + if !deadline.After(time.Now()) { + return nil, os.ErrDeadlineExceeded + } + err := set(deadline) + if err != nil { + return nil, err + } + return func() { _ = set(time.Time{}) }, nil +} + +func (c *windowsTLSConn) beginWrite() error { + ready := c.writeCondition() + c.writeState.Lock() + for c.postHandshake || c.writeActive { + if c.closed.Load() { + c.writeState.Unlock() + return net.ErrClosed + } + ready.Wait() + } + c.writeActive = true + c.writeState.Unlock() + + c.writeAccess.Lock() + if c.closed.Load() { + c.writeAccess.Unlock() + c.writeState.Lock() + c.writeActive = false + ready.Broadcast() + c.writeState.Unlock() + return net.ErrClosed + } + return nil +} + +func (c *windowsTLSConn) finishWrite() { + c.writeAccess.Unlock() + ready := c.writeCondition() + c.writeState.Lock() + c.writeActive = false + ready.Broadcast() + c.writeState.Unlock() +} + +func (c *windowsTLSConn) beginPostHandshakeWrite() error { + ready := c.writeCondition() + c.writeState.Lock() + c.postHandshake = true + for c.writeActive { + if c.closed.Load() { + c.postHandshake = false + ready.Broadcast() + c.writeState.Unlock() + return net.ErrClosed + } + ready.Wait() + } + c.writeActive = true + c.writeState.Unlock() + + c.writeAccess.Lock() + if c.closed.Load() { + c.writeAccess.Unlock() + c.writeState.Lock() + c.writeActive = false + c.postHandshake = false + ready.Broadcast() + c.writeState.Unlock() + return net.ErrClosed + } + return nil +} + +func (c *windowsTLSConn) finishPostHandshakeWrite() { + c.writeAccess.Unlock() + ready := c.writeCondition() + c.writeState.Lock() + c.writeActive = false + c.postHandshake = false + ready.Broadcast() + c.writeState.Unlock() +} + +func (c *windowsTLSConn) writeCondition() *sync.Cond { + c.writeStateOnce.Do(func() { + c.writeReady = sync.NewCond(&c.writeState) + }) + return c.writeReady +} + +func (c *windowsTLSConn) isClosed() bool { + return c.closed.Load() +} + +type windowsTLSReadWaiter struct { + conn *windowsTLSConn + rawWaiter N.ReadWaiter + options N.ReadWaitOptions +} + +var _ N.ReadWaiter = (*windowsTLSReadWaiter)(nil) + +func (w *windowsTLSReadWaiter) InitializeReadWaiter(options N.ReadWaitOptions) (needCopy bool) { + w.options = options + w.rawWaiter.InitializeReadWaiter(N.ReadWaitOptions{ + MTU: readWaitCiphertextChunkSize, + }) + return false +} + +func (w *windowsTLSReadWaiter) WaitReadBuffer() (*buf.Buffer, error) { + c := w.conn + c.readAccess.Lock() + defer c.readAccess.Unlock() + if c.isClosed() { + return nil, net.ErrClosed + } + plaintext, err := c.readPlaintextLocked(w.appendRaw, w.readRaw) + if err != nil { + return nil, err + } + buffer := w.options.NewBuffer() + n, writeErr := buffer.Write(plaintext) + if writeErr != nil { + buffer.Release() + return nil, writeErr + } + if n == 0 { + buffer.Release() + return nil, io.ErrShortBuffer + } + if n < len(plaintext) { + c.plain = append([]byte(nil), plaintext[n:]...) + } + w.options.PostReturn(buffer) + return buffer, nil +} + +func (w *windowsTLSReadWaiter) appendRaw(requireMore bool) error { + rawBuffer, err := w.readRawBuffer(requireMore) + if err != nil { + return err + } + w.conn.cipher = append(w.conn.cipher, rawBuffer.Bytes()...) + rawBuffer.Release() + return nil +} + +func (w *windowsTLSReadWaiter) readRaw(requireMore bool) ([]byte, error) { + rawBuffer, err := w.readRawBuffer(requireMore) + if err != nil { + return nil, err + } + data := append([]byte(nil), rawBuffer.Bytes()...) + rawBuffer.Release() + return data, nil +} + +func (w *windowsTLSReadWaiter) readRawBuffer(requireMore bool) (*buf.Buffer, error) { + rawBuffer, err := w.rawWaiter.WaitReadBuffer() + if err != nil { + if requireMore && errors.Is(err, io.EOF) { + return nil, io.ErrUnexpectedEOF + } + return nil, err + } + if rawBuffer == nil || rawBuffer.Len() == 0 { + if rawBuffer != nil { + rawBuffer.Release() + } + return nil, io.ErrUnexpectedEOF + } + return rawBuffer, nil +} diff --git a/common/tls/windows_client_stub.go b/common/tls/windows_client_stub.go new file mode 100644 index 00000000..7ad506a7 --- /dev/null +++ b/common/tls/windows_client_stub.go @@ -0,0 +1,15 @@ +//go:build !windows + +package tls + +import ( + "context" + + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +func newWindowsClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions, allowEmptyServerName bool) (Config, error) { + return nil, E.New("Windows TLS engine is not available on non-Windows platforms") +} diff --git a/common/tls/windows_client_test.go b/common/tls/windows_client_test.go new file mode 100644 index 00000000..13c46522 --- /dev/null +++ b/common/tls/windows_client_test.go @@ -0,0 +1,2505 @@ +//go:build windows + +package tls + +import ( + "bytes" + "context" + "crypto/sha256" + stdtls "crypto/tls" + "crypto/x509" + "errors" + "io" + "net" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/sagernet/sing-box/common/schannel" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" +) + +const windowsTLSTestTimeout = 5 * time.Second + +var ( + _ N.ExtendedConn = (*windowsTLSConn)(nil) + _ N.ReadWaitCreator = (*windowsTLSConn)(nil) +) + +func newTestWindowsTLSConn(rawConn net.Conn) *windowsTLSConn { + return &windowsTLSConn{rawConn: rawConn} +} + +// writePostHandshakeReply wraps writePostHandshakeReplyLocked with the +// writeAccess locking and auto-close behavior that drivePostHandshake +// composes from beginPostHandshakeWrite/finishPostHandshakeWrite plus the +// writeFailed → Close branch. +// Kept here as a test seam. +func (c *windowsTLSConn) writePostHandshakeReply(data []byte) error { + c.writeAccess.Lock() + defer c.writeAccess.Unlock() + err := c.writePostHandshakeReplyLocked(data) + if err != nil { + _ = c.Close() + } + return err +} + +type windowsTLSServerResult struct { + state stdtls.ConnectionState + err error +} + +type windowsTestDeadlineConn struct { + access sync.Mutex + readCalled chan struct{} + writeCalled chan struct{} + readCalledOnce sync.Once + writeCalledOnce sync.Once + readDeadline time.Time + writeDeadline time.Time + readDeadlines []time.Time + writeDeadlines []time.Time +} + +type windowsTestWriteGateConn struct { + writeCalled chan struct{} + releaseWrite chan struct{} +} + +type windowsTestIOConn struct { + access sync.Mutex + readErr error + writeErr error + writeN int + writeCalls int + closed bool +} + +func (c *windowsTestDeadlineConn) Read(_ []byte) (int, error) { + if c.readCalled != nil { + c.readCalledOnce.Do(func() { + close(c.readCalled) + }) + } + for { + c.access.Lock() + deadline := c.readDeadline + c.access.Unlock() + if deadline.IsZero() { + time.Sleep(5 * time.Millisecond) + continue + } + if !deadline.After(time.Now()) { + return 0, os.ErrDeadlineExceeded + } + time.Sleep(time.Until(deadline)) + return 0, os.ErrDeadlineExceeded + } +} + +func (c *windowsTestDeadlineConn) Write(_ []byte) (int, error) { + if c.writeCalled != nil { + c.writeCalledOnce.Do(func() { + close(c.writeCalled) + }) + } + for { + c.access.Lock() + deadline := c.writeDeadline + c.access.Unlock() + if deadline.IsZero() { + time.Sleep(5 * time.Millisecond) + continue + } + if !deadline.After(time.Now()) { + return 0, os.ErrDeadlineExceeded + } + time.Sleep(time.Until(deadline)) + return 0, os.ErrDeadlineExceeded + } +} + +func (c *windowsTestDeadlineConn) Close() error { + return nil +} + +func (c *windowsTestDeadlineConn) LocalAddr() net.Addr { + return windowsTestAddr("local") +} + +func (c *windowsTestDeadlineConn) RemoteAddr() net.Addr { + return windowsTestAddr("remote") +} + +func (c *windowsTestDeadlineConn) SetDeadline(t time.Time) error { + c.access.Lock() + c.readDeadline = t + c.writeDeadline = t + c.readDeadlines = append(c.readDeadlines, t) + c.writeDeadlines = append(c.writeDeadlines, t) + c.access.Unlock() + return nil +} + +func (c *windowsTestDeadlineConn) SetReadDeadline(t time.Time) error { + c.access.Lock() + c.readDeadline = t + c.readDeadlines = append(c.readDeadlines, t) + c.access.Unlock() + return nil +} + +func (c *windowsTestDeadlineConn) SetWriteDeadline(t time.Time) error { + c.access.Lock() + c.writeDeadline = t + c.writeDeadlines = append(c.writeDeadlines, t) + c.access.Unlock() + return nil +} + +func (c *windowsTestDeadlineConn) recordedWriteDeadlines() []time.Time { + c.access.Lock() + defer c.access.Unlock() + return append([]time.Time(nil), c.writeDeadlines...) +} + +func (c *windowsTestDeadlineConn) recordedReadDeadlines() []time.Time { + c.access.Lock() + defer c.access.Unlock() + return append([]time.Time(nil), c.readDeadlines...) +} + +func (c *windowsTestWriteGateConn) Read(_ []byte) (int, error) { + return 0, io.EOF +} + +func (c *windowsTestWriteGateConn) Write(p []byte) (int, error) { + close(c.writeCalled) + <-c.releaseWrite + return len(p), nil +} + +func (c *windowsTestWriteGateConn) Close() error { + return nil +} + +func (c *windowsTestWriteGateConn) LocalAddr() net.Addr { + return windowsTestAddr("local") +} + +func (c *windowsTestWriteGateConn) RemoteAddr() net.Addr { + return windowsTestAddr("remote") +} + +func (c *windowsTestWriteGateConn) SetDeadline(time.Time) error { + return nil +} + +func (c *windowsTestWriteGateConn) SetReadDeadline(time.Time) error { + return nil +} + +func (c *windowsTestWriteGateConn) SetWriteDeadline(time.Time) error { + return nil +} + +func (c *windowsTestIOConn) Read(_ []byte) (int, error) { + return 0, c.readErr +} + +func (c *windowsTestIOConn) Write(p []byte) (int, error) { + c.access.Lock() + defer c.access.Unlock() + c.writeCalls++ + if c.writeErr == nil { + return len(p), nil + } + n := c.writeN + if n <= 0 || n > len(p) { + n = 0 + } + return n, c.writeErr +} + +func (c *windowsTestIOConn) Close() error { + c.access.Lock() + c.closed = true + c.access.Unlock() + return nil +} + +func (c *windowsTestIOConn) LocalAddr() net.Addr { + return windowsTestAddr("local") +} + +func (c *windowsTestIOConn) RemoteAddr() net.Addr { + return windowsTestAddr("remote") +} + +func (c *windowsTestIOConn) SetDeadline(time.Time) error { + return nil +} + +func (c *windowsTestIOConn) SetReadDeadline(time.Time) error { + return nil +} + +func (c *windowsTestIOConn) SetWriteDeadline(time.Time) error { + return nil +} + +func (c *windowsTestIOConn) isClosed() bool { + c.access.Lock() + defer c.access.Unlock() + return c.closed +} + +func (c *windowsTestIOConn) totalWriteCalls() int { + c.access.Lock() + defer c.access.Unlock() + return c.writeCalls +} + +type windowsTestAddr string + +func (a windowsTestAddr) Network() string { + return "test" +} + +func (a windowsTestAddr) String() string { + return string(a) +} + +type windowsOpaqueConn struct { + net.Conn +} + +func TestWindowsClientHandshakeTLS12(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + serverResult, serverAddress := startWindowsTLSTestServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + NextProtos: []string{"h2"}, + }) + + clientConn, err := newWindowsTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: "1.2", + MaxVersion: "1.2", + ALPN: badoption.Listable[string]{"h2"}, + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + + clientState := clientConn.ConnectionState() + if clientState.Version != stdtls.VersionTLS12 { + t.Fatalf("unexpected negotiated version: %x", clientState.Version) + } + if clientState.NegotiatedProtocol != "h2" { + t.Fatalf("unexpected negotiated protocol: %q", clientState.NegotiatedProtocol) + } + if !clientState.HandshakeComplete { + t.Fatal("HandshakeComplete is false") + } + if len(clientState.PeerCertificates) == 0 { + t.Fatal("no peer certificates") + } + + result := <-serverResult + if result.err != nil { + t.Fatal(result.err) + } + if result.state.Version != stdtls.VersionTLS12 { + t.Fatalf("server negotiated unexpected version: %x", result.state.Version) + } + if result.state.NegotiatedProtocol != "h2" { + t.Fatalf("server negotiated unexpected protocol: %q", result.state.NegotiatedProtocol) + } +} + +func TestWindowsClientHandshakeWrappedConn(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + serverResult, serverAddress := startWindowsTLSTestServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + }) + + ctx, cancel := context.WithTimeout(context.Background(), windowsTLSTestTimeout) + t.Cleanup(cancel) + + clientConfig, err := NewClientWithOptions(ClientOptions{ + Context: ctx, + Logger: logger.NOP(), + Options: option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: "1.2", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }, + }) + if err != nil { + t.Fatal(err) + } + rawConn, err := net.DialTimeout(N.NetworkTCP, serverAddress, windowsTLSTestTimeout) + if err != nil { + t.Fatal(err) + } + tlsConn, err := ClientHandshake(ctx, windowsOpaqueConn{Conn: rawConn}, clientConfig) + if err != nil { + rawConn.Close() + t.Fatal(err) + } + _ = tlsConn.Close() + + result := <-serverResult + if result.err != nil { + t.Fatal(result.err) + } + if result.state.Version != stdtls.VersionTLS12 { + t.Fatalf("server negotiated unexpected version: %x", result.state.Version) + } +} + +func TestWindowsClientHandshakeTLS13(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + serverResult, serverAddress := startWindowsTLSTestServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS13, + MaxVersion: stdtls.VersionTLS13, + NextProtos: []string{"h2"}, + }) + + clientConn, err := newWindowsTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: "1.3", + ALPN: badoption.Listable[string]{"h2"}, + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + + clientState := clientConn.ConnectionState() + if clientState.Version != stdtls.VersionTLS13 { + t.Fatalf("expected TLS 1.3, got %x", clientState.Version) + } + if clientState.NegotiatedProtocol != "h2" { + t.Fatalf("expected negotiated protocol h2, got %q", clientState.NegotiatedProtocol) + } + + result := <-serverResult + if result.err != nil { + t.Fatal(result.err) + } + if result.state.Version != stdtls.VersionTLS13 { + t.Fatalf("server negotiated unexpected version: %x", result.state.Version) + } +} + +func TestWindowsClientHandshakeALPNNoOverlap(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + _, serverAddress := startWindowsTLSTestServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + NextProtos: []string{"http/1.1"}, + }) + + clientConn, err := newWindowsTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: "1.2", + MaxVersion: "1.2", + ALPN: badoption.Listable[string]{"h2"}, + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + // Go's TLS server returns a TLS alert when the client advertises ALPN but + // the server has no overlap. The handshake fails. + if err == nil { + _ = clientConn.Close() + t.Fatal("expected handshake to fail with no ALPN overlap") + } +} + +func TestWindowsClientHandshakeMultipleALPN(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + _, serverAddress := startWindowsTLSTestServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + NextProtos: []string{"h2", "http/1.1"}, + }) + + clientConn, err := newWindowsTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: "1.2", + ALPN: badoption.Listable[string]{"spdy/3", "h2"}, + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + + // Schannel follows the standard selection: first protocol offered by + // the client that the server also supports. Here: spdy/3 is not in the + // server list but h2 is, so h2 wins. + if got := clientConn.ConnectionState().NegotiatedProtocol; got != "h2" { + t.Fatalf("expected h2, got %q", got) + } +} + +func TestWindowsClientHandshakeRejectsVersionMismatch(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + serverResult, serverAddress := startWindowsTLSTestServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS13, + MaxVersion: stdtls.VersionTLS13, + }) + + clientConn, err := newWindowsTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MaxVersion: "1.2", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err == nil { + clientConn.Close() + t.Fatal("expected version mismatch handshake to fail") + } + + result := <-serverResult + if result.err == nil { + t.Fatal("expected server handshake to fail on version mismatch") + } +} + +func TestWindowsClientHandshakeRejectsServerNameMismatch(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + _, serverAddress := startWindowsTLSTestServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + }) + + clientConn, err := newWindowsTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "example.com", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err == nil { + clientConn.Close() + t.Fatal("expected server name mismatch handshake to fail") + } +} + +func TestWindowsClientHandshakeRejectsUntrustedCA(t *testing.T) { + serverCertificate, _ := newWindowsTestCertificate(t, "localhost") + _, serverAddress := startWindowsTLSTestServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + }) + + clientConn, err := newWindowsTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + }) + if err == nil { + clientConn.Close() + t.Fatal("expected untrusted CA handshake to fail") + } +} + +func TestWindowsClientHandshakeInsecureSkipsValidation(t *testing.T) { + serverCertificate, _ := newWindowsTestCertificate(t, "localhost") + _, serverAddress := startWindowsTLSTestServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + }) + + // Server name mismatch but insecure=true → handshake succeeds. + clientConn, err := newWindowsTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "example.com", + Insecure: true, + }) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + + if !clientConn.ConnectionState().HandshakeComplete { + t.Fatal("expected handshake to complete with insecure=true") + } +} + +func TestWindowsClientHandshakeHonorsPublicKeyPinSuccess(t *testing.T) { + serverCertificate, _ := newWindowsTestCertificate(t, "localhost") + pin := publicKeyPin(t, serverCertificate.Leaf) + _, serverAddress := startWindowsTLSTestServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + }) + + clientConn, err := newWindowsTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + CertificatePublicKeySHA256: [][]byte{pin}, + }) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() +} + +func TestWindowsClientHandshakeHonorsPublicKeyPinFailure(t *testing.T) { + serverCertificate, _ := newWindowsTestCertificate(t, "localhost") + wrongPin := sha256.Sum256([]byte("not the public key")) + _, serverAddress := startWindowsTLSTestServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + }) + + clientConn, err := newWindowsTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + CertificatePublicKeySHA256: [][]byte{wrongPin[:]}, + }) + if err == nil { + clientConn.Close() + t.Fatal("expected public-key pin mismatch to fail") + } +} + +func TestWindowsClientHandshakeContextCancellation(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + + clientHelloRead := make(chan struct{}, 1) + serverDone := make(chan struct{}) + defer close(serverDone) + go func() { + c, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer c.Close() + buffer := make([]byte, 8192) + n, readErr := c.Read(buffer) + if n > 0 { + clientHelloRead <- struct{}{} + } + if readErr != nil { + return + } + <-serverDone + }() + + _, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + + ctx, cancel := context.WithCancel(context.Background()) + clientConfig, err := NewClientWithOptions(ClientOptions{ + Context: ctx, + Logger: logger.NOP(), + Options: option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }, + }) + if err != nil { + t.Fatal(err) + } + + conn, err := net.DialTimeout("tcp", listener.Addr().String(), windowsTLSTestTimeout) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + handshakeDone := make(chan error, 1) + go func() { + _, err := ClientHandshake(ctx, conn, clientConfig) + handshakeDone <- err + }() + + select { + case <-clientHelloRead: + case <-time.After(2 * time.Second): + t.Fatal("server did not receive the client hello") + } + + cancel() + + select { + case err := <-handshakeDone: + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("handshake did not return after cancellation") + } +} + +func TestWindowsClientHandshakeTimeout(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + + // Accept but never respond. + go func() { + c, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer c.Close() + time.Sleep(3 * time.Second) + }() + + _, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + + ctx, cancel := context.WithTimeout(context.Background(), windowsTLSTestTimeout) + defer cancel() + + clientConfig, err := NewClientWithOptions(ClientOptions{ + Context: ctx, + Logger: logger.NOP(), + Options: option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + HandshakeTimeout: badoption.Duration(300 * time.Millisecond), + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }, + }) + if err != nil { + t.Fatal(err) + } + + conn, err := net.DialTimeout("tcp", listener.Addr().String(), windowsTLSTestTimeout) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + start := time.Now() + _, err = ClientHandshake(ctx, conn, clientConfig) + elapsed := time.Since(start) + if err == nil { + t.Fatal("expected handshake to time out") + } + if elapsed > 2*time.Second { + t.Fatalf("handshake took %v, expected ~300ms timeout", elapsed) + } +} + +func TestWindowsClientRoundtrip(t *testing.T) { + clientConn, serverDone := startWindowsEchoServer(t, stdtls.VersionTLS12) + defer clientConn.Close() + + _, err := clientConn.Write([]byte("ping")) + if err != nil { + t.Fatalf("write: %v", err) + } + reply := make([]byte, 4) + _, err = io.ReadFull(clientConn, reply) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(reply) != "ping" { + t.Fatalf("unexpected reply: %q", string(reply)) + } + + clientConn.Close() + <-serverDone +} + +func TestWindowsClientRoundtripTLS13(t *testing.T) { + clientConn, serverDone := startWindowsEchoServer(t, stdtls.VersionTLS13) + defer clientConn.Close() + + payload := []byte("hello tls 1.3") + _, err := clientConn.Write(payload) + if err != nil { + t.Fatalf("write: %v", err) + } + reply := make([]byte, len(payload)) + _, err = io.ReadFull(clientConn, reply) + if err != nil { + t.Fatalf("read: %v", err) + } + if !bytes.Equal(payload, reply) { + t.Fatalf("unexpected reply: %q", string(reply)) + } + + clientConn.Close() + <-serverDone +} + +func TestWindowsClientReadBuffer(t *testing.T) { + payload := []byte("windows tls read buffer payload") + clientConn, serverErr := startWindowsPayloadServer(t, stdtls.VersionTLS12, payload) + defer clientConn.Close() + + const ( + frontHeadroom = 8 + rearHeadroom = 8 + ) + buffer := buf.NewSize(len(payload) + frontHeadroom + rearHeadroom) + defer buffer.Release() + buffer.Resize(frontHeadroom, 0) + buffer.Reserve(rearHeadroom) + + err := clientConn.ReadBuffer(buffer) + if err != nil { + t.Fatalf("ReadBuffer: %v", err) + } + if buffer.Start() != frontHeadroom { + t.Fatalf("expected front headroom %d, got %d", frontHeadroom, buffer.Start()) + } + if !bytes.Equal(buffer.Bytes(), payload) { + t.Fatalf("unexpected payload: %q", string(buffer.Bytes())) + } + if err = <-serverErr; err != nil { + t.Fatal(err) + } +} + +func TestWindowsClientWriteBuffer(t *testing.T) { + clientConn, serverDone := startWindowsEchoEngineServer(t, stdtls.VersionTLS12) + defer clientConn.Close() + + payload := []byte("windows tls write buffer payload") + buffer := buf.NewSize(len(payload)) + _, err := buffer.Write(payload) + if err != nil { + t.Fatal(err) + } + + err = clientConn.WriteBuffer(buffer) + if err != nil { + t.Fatalf("WriteBuffer: %v", err) + } + if buffer.RawCap() != 0 { + t.Fatalf("expected WriteBuffer to release buffer, raw cap %d", buffer.RawCap()) + } + + reply := make([]byte, len(payload)) + _, err = io.ReadFull(clientConn, reply) + if err != nil { + t.Fatalf("read echo: %v", err) + } + if !bytes.Equal(reply, payload) { + t.Fatalf("unexpected echo: %q", string(reply)) + } + + clientConn.Close() + <-serverDone +} + +func TestWindowsClientCreateReadWaiter(t *testing.T) { + payload := []byte("windows tls read waiter payload") + clientConn, serverErr := startWindowsPayloadServer(t, stdtls.VersionTLS12, payload) + defer clientConn.Close() + + readWaiter, created := bufio.CreateReadWaiter(clientConn) + if !created { + t.Fatal("expected read waiter") + } + readWaiter.InitializeReadWaiter(N.ReadWaitOptions{ + FrontHeadroom: 7, + RearHeadroom: 5, + MTU: len(payload), + }) + + buffer, err := readWaiter.WaitReadBuffer() + if err != nil { + t.Fatalf("WaitReadBuffer: %v", err) + } + defer buffer.Release() + if buffer.Start() != 7 { + t.Fatalf("expected front headroom 7, got %d", buffer.Start()) + } + if buffer.FreeLen() < 5 { + t.Fatalf("expected rear headroom at least 5, got %d", buffer.FreeLen()) + } + if !bytes.Equal(buffer.Bytes(), payload) { + t.Fatalf("unexpected payload: %q", string(buffer.Bytes())) + } + if err = <-serverErr; err != nil { + t.Fatal(err) + } +} + +func TestWindowsClientCreateReadWaiterFallback(t *testing.T) { + tlsConn := newTestWindowsTLSConn(&windowsTestIOConn{}) + _, created := tlsConn.CreateReadWaiter() + if created { + t.Fatal("expected read waiter fallback") + } +} + +func TestWindowsClientTLS13PostHandshakeConcurrentWrite(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + + const payloadSize = 4 << 20 + const prefixSize = 32 << 10 + reply := []byte("tls13 post-handshake reply") + + serverErr := make(chan error, 1) + prefixRead := make(chan struct{}) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + serverErr <- acceptErr + return + } + defer conn.Close() + + tlsConn := stdtls.Server(conn, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS13, + MaxVersion: stdtls.VersionTLS13, + }) + defer tlsConn.Close() + + err := tlsConn.SetDeadline(time.Now().Add(2 * windowsTLSTestTimeout)) + if err != nil { + serverErr <- err + return + } + err = tlsConn.Handshake() + if err != nil { + serverErr <- err + return + } + prefix := make([]byte, prefixSize) + _, err = io.ReadFull(tlsConn, prefix) + if err != nil { + serverErr <- err + return + } + close(prefixRead) + _, err = tlsConn.Write(reply) + if err != nil { + serverErr <- err + return + } + _, err = io.Copy(io.Discard, io.LimitReader(tlsConn, int64(payloadSize-prefixSize))) + if err != nil { + serverErr <- err + return + } + serverErr <- nil + }() + + clientConn, err := newWindowsTestClientConn(t, listener.Addr().String(), option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: "1.3", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + + payload := make([]byte, payloadSize) + for index := range payload { + payload[index] = byte(index % 251) + } + writeDone := make(chan error, 1) + go func() { + _, err := clientConn.Write(payload) + writeDone <- err + }() + + select { + case <-prefixRead: + case <-time.After(2 * time.Second): + t.Fatal("server did not observe the client write") + } + + replyBuffer := make([]byte, len(reply)) + _, err = io.ReadFull(clientConn, replyBuffer) + if err != nil { + t.Fatalf("read: %v", err) + } + if !bytes.Equal(reply, replyBuffer) { + t.Fatalf("unexpected reply: %q", string(replyBuffer)) + } + + writeErr := <-writeDone + if writeErr != nil { + t.Fatalf("write: %v", writeErr) + } + serverErrValue := <-serverErr + if serverErrValue != nil { + t.Fatal(serverErrValue) + } +} + +func TestWindowsClientLargeMessage(t *testing.T) { + clientConn, serverDone := startWindowsEchoServer(t, stdtls.VersionTLS12) + defer clientConn.Close() + + // 1 MiB exercises multiple TLS records and the chunking logic. + // Writes must run concurrently with reads to avoid TCP-buffer deadlock. + payload := make([]byte, 1<<20) + for index := range payload { + payload[index] = byte(index % 251) + } + writeErr := make(chan error, 1) + go func() { + _, err := clientConn.Write(payload) + writeErr <- err + }() + + reply := make([]byte, len(payload)) + _, err := io.ReadFull(clientConn, reply) + if err != nil { + t.Fatalf("read: %v", err) + } + writeResult := <-writeErr + if writeResult != nil { + t.Fatalf("write: %v", writeResult) + } + if !bytes.Equal(payload, reply) { + t.Fatal("payload mismatch after round-trip") + } + + clientConn.Close() + <-serverDone +} + +func TestWindowsClientFullDuplexLargePayload(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + + const payloadSize = 2 << 20 + clientPayload := make([]byte, payloadSize) + serverPayload := make([]byte, payloadSize) + for index := range clientPayload { + clientPayload[index] = byte(index % 251) + serverPayload[index] = byte((index + 97) % 251) + } + + serverErr := make(chan error, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + serverErr <- acceptErr + return + } + defer conn.Close() + + tlsConn := stdtls.Server(conn, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + }) + defer tlsConn.Close() + err := tlsConn.SetDeadline(time.Now().Add(2 * windowsTLSTestTimeout)) + if err != nil { + serverErr <- err + return + } + err = tlsConn.Handshake() + if err != nil { + serverErr <- err + return + } + + readDone := make(chan error, 1) + writeDone := make(chan error, 1) + go func() { + received := make([]byte, len(clientPayload)) + _, readErr := io.ReadFull(tlsConn, received) + if readErr == nil && !bytes.Equal(received, clientPayload) { + readErr = errors.New("client payload mismatch") + } + readDone <- readErr + }() + go func() { + _, writeErr := tlsConn.Write(serverPayload) + writeDone <- writeErr + }() + if readErr := <-readDone; readErr != nil { + serverErr <- readErr + return + } + serverErr <- <-writeDone + }() + + clientConn, err := newWindowsTestEngineConn(t, listener.Addr().String(), option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: "1.2", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + + writeDone := make(chan error, 1) + go func() { + n, writeErr := clientConn.Write(clientPayload) + if writeErr == nil && n != len(clientPayload) { + writeErr = io.ErrShortWrite + } + writeDone <- writeErr + }() + + reply := make([]byte, len(serverPayload)) + _, err = io.ReadFull(clientConn, reply) + if err != nil { + t.Fatalf("read: %v", err) + } + if !bytes.Equal(reply, serverPayload) { + t.Fatal("server payload mismatch") + } + if writeErr := <-writeDone; writeErr != nil { + t.Fatalf("write: %v", writeErr) + } + if err = <-serverErr; err != nil { + t.Fatal(err) + } +} + +func TestWindowsClientMultipleRoundtrips(t *testing.T) { + clientConn, serverDone := startWindowsEchoServer(t, stdtls.VersionTLS12) + defer clientConn.Close() + + for i := 0; i < 100; i++ { + payload := []byte("msg" + string(rune('A'+(i%26)))) + _, err := clientConn.Write(payload) + if err != nil { + t.Fatalf("write %d: %v", i, err) + } + reply := make([]byte, len(payload)) + _, err = io.ReadFull(clientConn, reply) + if err != nil { + t.Fatalf("read %d: %v", i, err) + } + if !bytes.Equal(payload, reply) { + t.Fatalf("iteration %d: expected %q got %q", i, payload, reply) + } + } + + clientConn.Close() + <-serverDone +} + +func TestWindowsClientConcurrentReadWrite(t *testing.T) { + clientConn, serverDone := startWindowsEchoServer(t, stdtls.VersionTLS12) + defer clientConn.Close() + + const messageCount = 200 + const messageSize = 64 + payloads := make([][]byte, messageCount) + for i := range payloads { + buffer := make([]byte, messageSize) + for j := range buffer { + buffer[j] = byte(i + j) + } + payloads[i] = buffer + } + + readErr := make(chan error, 1) + readBack := make(chan []byte, messageCount) + go func() { + for i := 0; i < messageCount; i++ { + reply := make([]byte, messageSize) + _, err := io.ReadFull(clientConn, reply) + if err != nil { + readErr <- err + return + } + readBack <- reply + } + readErr <- nil + }() + + for i, payload := range payloads { + _, err := clientConn.Write(payload) + if err != nil { + t.Fatalf("write %d: %v", i, err) + } + } + + readResult := <-readErr + if readResult != nil { + t.Fatal(readResult) + } + for i := 0; i < messageCount; i++ { + got := <-readBack + if !bytes.Equal(payloads[i], got) { + t.Fatalf("iteration %d: payload mismatch", i) + } + } + + clientConn.Close() + <-serverDone +} + +func TestWindowsClientServerCloseReturnsEOF(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + + done := make(chan struct{}) + go func() { + defer close(done) + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer conn.Close() + tlsConn := stdtls.Server(conn, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + }) + _ = tlsConn.Handshake() + // Send close_notify then exit. + _ = tlsConn.Close() + }() + + clientConn, err := newWindowsTestClientConn(t, listener.Addr().String(), option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: "1.2", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + + buffer := make([]byte, 16) + _, err = clientConn.Read(buffer) + if !errors.Is(err, io.EOF) { + t.Fatalf("expected io.EOF, got %v", err) + } + <-done +} + +func TestWindowsClientCloseUnblocksRead(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + _, serverAddress := startWindowsTLSSilentServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + }) + + clientConn, err := newWindowsTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: "1.2", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + + readDone := make(chan error, 1) + go func() { + buffer := make([]byte, 16) + _, err := clientConn.Read(buffer) + readDone <- err + }() + + time.Sleep(100 * time.Millisecond) + clientConn.Close() + + select { + case err := <-readDone: + if err == nil { + t.Fatal("expected Read to return an error after Close") + } + case <-time.After(2 * time.Second): + t.Fatal("Read did not return within 2s after Close") + } +} + +func TestWindowsClientReadAfterCloseReturnsError(t *testing.T) { + clientConn, serverDone := startWindowsEchoServer(t, stdtls.VersionTLS12) + clientConn.Close() + <-serverDone + + buffer := make([]byte, 16) + _, err := clientConn.Read(buffer) + if err == nil { + t.Fatal("expected Read after Close to return error") + } +} + +func TestWindowsClientReadAfterCloseDoesNotServeBufferedPlaintext(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + + serverDone := make(chan struct{}) + serverErr := make(chan error, 1) + payload := bytes.Repeat([]byte("buffered plaintext "), 32) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + serverErr <- acceptErr + return + } + defer conn.Close() + + tlsConn := stdtls.Server(conn, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + }) + defer tlsConn.Close() + + err := tlsConn.SetDeadline(time.Now().Add(windowsTLSTestTimeout)) + if err != nil { + serverErr <- err + return + } + err = tlsConn.Handshake() + if err != nil { + serverErr <- err + return + } + _, err = tlsConn.Write(payload) + if err != nil { + serverErr <- err + return + } + <-serverDone + serverErr <- nil + }() + + clientConn, err := newWindowsTestClientConn(t, listener.Addr().String(), option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: "1.2", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + + buffer := make([]byte, 8) + n, err := clientConn.Read(buffer) + if err != nil { + t.Fatalf("first read: %v", err) + } + if n != len(buffer) { + t.Fatalf("expected first read to fill the buffer, got %d", n) + } + + clientConn.Close() + close(serverDone) + + _, err = clientConn.Read(make([]byte, len(payload))) + if !errors.Is(err, net.ErrClosed) { + t.Fatalf("expected net.ErrClosed, got %v", err) + } + serverErrValue := <-serverErr + if serverErrValue != nil { + t.Fatal(serverErrValue) + } +} + +func TestWindowsClientWriteAfterCloseReturnsError(t *testing.T) { + clientConn, serverDone := startWindowsEchoServer(t, stdtls.VersionTLS12) + clientConn.Close() + <-serverDone + + _, err := clientConn.Write([]byte("after close")) + if err == nil { + t.Fatal("expected Write after Close to return error") + } +} + +func TestWindowsClientReadDeadline(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + serverDone, serverAddress := startWindowsTLSSilentServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + }) + + clientConn, err := newWindowsTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: "1.2", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + defer close(serverDone) + + err = clientConn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) + if err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + + readDone := make(chan error, 1) + buffer := make([]byte, 64) + go func() { + _, readErr := clientConn.Read(buffer) + readDone <- readErr + }() + + select { + case readErr := <-readDone: + if !errors.Is(readErr, os.ErrDeadlineExceeded) { + t.Fatalf("expected os.ErrDeadlineExceeded, got %v", readErr) + } + case <-time.After(2 * time.Second): + t.Fatal("Read did not return within 2s after deadline") + } +} + +func TestWindowsClientSetReadDeadlinePreExpired(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + serverDone, serverAddress := startWindowsTLSSilentServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + }) + + clientConn, err := newWindowsTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: "1.2", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + defer close(serverDone) + + err = clientConn.SetReadDeadline(time.Now().Add(-time.Second)) + if err != nil { + t.Fatalf("SetReadDeadline past: %v", err) + } + + buffer := make([]byte, 16) + _, err = clientConn.Read(buffer) + if !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("expected os.ErrDeadlineExceeded, got %v", err) + } + + // Clearing the deadline must restore normal blocking behaviour. + err = clientConn.SetReadDeadline(time.Time{}) + if err != nil { + t.Fatalf("SetReadDeadline zero: %v", err) + } + err = clientConn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) + if err != nil { + t.Fatalf("SetReadDeadline future: %v", err) + } + start := time.Now() + _, err = clientConn.Read(buffer) + elapsed := time.Since(start) + if !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("expected os.ErrDeadlineExceeded after re-arm, got %v", err) + } + if elapsed < 150*time.Millisecond { + t.Fatalf("Read returned too fast (%v), pre-expired flag leaked", elapsed) + } +} + +func TestWindowsClientSetDeadlinePropagatesToRawConn(t *testing.T) { + rawConn := &windowsTestDeadlineConn{} + tlsConn := newTestWindowsTLSConn(rawConn) + + deadline := time.Now().Add(time.Second) + err := tlsConn.SetDeadline(deadline) + if err != nil { + t.Fatalf("SetDeadline: %v", err) + } + + readDeadlines := rawConn.recordedReadDeadlines() + if len(readDeadlines) != 1 { + t.Fatalf("expected 1 read deadline update, got %d", len(readDeadlines)) + } + if !readDeadlines[0].Equal(deadline) { + t.Fatalf("expected read deadline %v, got %v", deadline, readDeadlines[0]) + } + + writeDeadlines := rawConn.recordedWriteDeadlines() + if len(writeDeadlines) != 1 { + t.Fatalf("expected 1 write deadline update, got %d", len(writeDeadlines)) + } + if !writeDeadlines[0].Equal(deadline) { + t.Fatalf("expected write deadline %v, got %v", deadline, writeDeadlines[0]) + } +} + +func TestWindowsClientSetReadDeadlineCancelsBlockedRead(t *testing.T) { + rawConn := &windowsTestDeadlineConn{ + readCalled: make(chan struct{}), + } + tlsConn := newTestWindowsTLSConn(rawConn) + tlsConn.readScratch = make([]byte, 16) + + readErrCh := make(chan error, 1) + go func() { + _, err := tlsConn.Read(make([]byte, 1)) + readErrCh <- err + }() + + select { + case <-rawConn.readCalled: + case <-time.After(time.Second): + t.Fatal("Read did not reach the raw connection") + } + + deadline := time.Now().Add(150 * time.Millisecond) + err := tlsConn.SetReadDeadline(deadline) + if err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + + select { + case err = <-readErrCh: + if !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("expected os.ErrDeadlineExceeded, got %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Read did not return after SetReadDeadline") + } + + readDeadlines := rawConn.recordedReadDeadlines() + if len(readDeadlines) != 1 { + t.Fatalf("expected 1 read deadline update, got %d", len(readDeadlines)) + } + if !readDeadlines[0].Equal(deadline) { + t.Fatalf("expected read deadline %v, got %v", deadline, readDeadlines[0]) + } +} + +func TestWindowsClientSetWriteDeadlineCancelsBlockedWrite(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + serverDone, serverAddress := startWindowsTLSSilentServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + }) + defer close(serverDone) + + tlsConn, err := newWindowsTestEngineConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: "1.2", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + + originalRawConn := tlsConn.rawConn + rawConn := &windowsTestDeadlineConn{ + writeCalled: make(chan struct{}), + } + tlsConn.rawConn = rawConn + t.Cleanup(func() { + _ = originalRawConn.Close() + _ = tlsConn.Close() + }) + + writeErrCh := make(chan error, 1) + go func() { + _, err := tlsConn.Write([]byte("ping")) + writeErrCh <- err + }() + + select { + case <-rawConn.writeCalled: + case <-time.After(time.Second): + t.Fatal("Write did not reach the raw connection") + } + + deadline := time.Now().Add(150 * time.Millisecond) + err = tlsConn.SetWriteDeadline(deadline) + if err != nil { + t.Fatalf("SetWriteDeadline: %v", err) + } + + select { + case err = <-writeErrCh: + if !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("expected os.ErrDeadlineExceeded, got %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Write did not return after SetWriteDeadline") + } + + writeDeadlines := rawConn.recordedWriteDeadlines() + if len(writeDeadlines) != 1 { + t.Fatalf("expected 1 write deadline update, got %d", len(writeDeadlines)) + } + if !writeDeadlines[0].Equal(deadline) { + t.Fatalf("expected write deadline %v, got %v", deadline, writeDeadlines[0]) + } +} + +func TestWindowsClientPostHandshakeReplyUsesReadDeadline(t *testing.T) { + rawConn := &windowsTestDeadlineConn{} + tlsConn := newTestWindowsTLSConn(rawConn) + + readDeadline := time.Now().Add(150 * time.Millisecond) + err := tlsConn.SetReadDeadline(readDeadline) + if err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + + start := time.Now() + err = tlsConn.writePostHandshakeReply([]byte("reply")) + elapsed := time.Since(start) + if !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("expected os.ErrDeadlineExceeded, got %v", err) + } + if elapsed < 100*time.Millisecond { + t.Fatalf("post-handshake write returned too fast: %v", elapsed) + } + + deadlines := rawConn.recordedWriteDeadlines() + if len(deadlines) != 2 { + t.Fatalf("expected 2 write deadline updates, got %d", len(deadlines)) + } + if !deadlines[0].Equal(readDeadline) { + t.Fatalf("expected first write deadline %v, got %v", readDeadline, deadlines[0]) + } + if !deadlines[1].IsZero() { + t.Fatalf("expected write deadline cleanup, got %v", deadlines[1]) + } +} + +func TestWindowsClientPostHandshakeReplyPreExpiredReadDeadline(t *testing.T) { + rawConn := &windowsTestDeadlineConn{} + tlsConn := newTestWindowsTLSConn(rawConn) + + err := tlsConn.SetReadDeadline(time.Now().Add(-time.Second)) + if err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + + start := time.Now() + err = tlsConn.writePostHandshakeReply([]byte("reply")) + elapsed := time.Since(start) + if !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("expected os.ErrDeadlineExceeded, got %v", err) + } + if elapsed > 50*time.Millisecond { + t.Fatalf("pre-expired post-handshake write returned too slowly: %v", elapsed) + } + + deadlines := rawConn.recordedWriteDeadlines() + if len(deadlines) != 0 { + t.Fatalf("expected no write deadline update for pre-expired read deadline, got %d", len(deadlines)) + } +} + +func TestDriveStepsPreservesBufferedHandshakeBytes(t *testing.T) { + scratch := make([]byte, 8) + copy(scratch, "abc") + + readCalls := 0 + stepCalls := 0 + leftover, err := driveSteps( + scratch[:3], + func(input []byte) (schannel.StepResult, error) { + stepCalls++ + switch stepCalls { + case 1: + if string(input) != "abc" { + t.Fatalf("first step input = %q, want %q", input, "abc") + } + return schannel.StepResult{Incomplete: true}, nil + case 2: + if string(input) != "abcdef" { + t.Fatalf("second step input = %q, want %q", input, "abcdef") + } + return schannel.StepResult{Consumed: len(input), Done: true}, nil + default: + t.Fatalf("unexpected step call %d", stepCalls) + return schannel.StepResult{}, nil + } + }, + func() ([]byte, error) { + readCalls++ + copy(scratch, "def") + return scratch[:3], nil + }, + func([]byte) error { return nil }, + ) + if err != nil { + t.Fatal(err) + } + if readCalls != 1 { + t.Fatalf("readMore called %d times, want 1", readCalls) + } + if len(leftover) != 0 { + t.Fatalf("leftover = %q, want empty", leftover) + } +} + +func TestWindowsTLSRawReadEOFAtRecordBoundary(t *testing.T) { + rawConn := &windowsTestIOConn{readErr: io.EOF} + _, err := readTLSRaw(rawConn, make([]byte, 16), false) + if !errors.Is(err, io.EOF) { + t.Fatalf("expected io.EOF, got %v", err) + } +} + +func TestWindowsTLSRawReadEOFWithPendingRecord(t *testing.T) { + rawConn := &windowsTestIOConn{readErr: io.EOF} + _, err := readTLSRaw(rawConn, make([]byte, 16), true) + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("expected io.ErrUnexpectedEOF, got %v", err) + } +} + +func TestWindowsClientPostHandshakeReplyWaitsForWriteAccess(t *testing.T) { + rawConn := &windowsTestWriteGateConn{ + writeCalled: make(chan struct{}), + releaseWrite: make(chan struct{}), + } + tlsConn := newTestWindowsTLSConn(rawConn) + + tlsConn.writeAccess.Lock() + errCh := make(chan error, 1) + go func() { + errCh <- tlsConn.writePostHandshakeReply([]byte("reply")) + }() + + select { + case <-rawConn.writeCalled: + t.Fatal("post-handshake write bypassed writeAccess") + case <-time.After(100 * time.Millisecond): + } + + tlsConn.writeAccess.Unlock() + + select { + case <-rawConn.writeCalled: + case <-time.After(time.Second): + t.Fatal("post-handshake write did not resume after writeAccess release") + } + + close(rawConn.releaseWrite) + err := <-errCh + if err != nil { + t.Fatal(err) + } +} + +func TestWindowsClientPostHandshakeWritePreemptsNewWrite(t *testing.T) { + tlsConn := newTestWindowsTLSConn(&windowsTestIOConn{}) + err := tlsConn.beginWrite() + if err != nil { + t.Fatal(err) + } + + postHandshakeReady := make(chan error, 1) + go func() { + postHandshakeReady <- tlsConn.beginPostHandshakeWrite() + }() + + deadline := time.After(time.Second) + for { + tlsConn.writeState.Lock() + pending := tlsConn.postHandshake + tlsConn.writeState.Unlock() + if pending { + break + } + select { + case <-deadline: + t.Fatal("post-handshake write did not become pending") + default: + time.Sleep(time.Millisecond) + } + } + + writeReady := make(chan error, 1) + go func() { + writeReady <- tlsConn.beginWrite() + }() + + tlsConn.finishWrite() + + select { + case err = <-postHandshakeReady: + if err != nil { + t.Fatal(err) + } + case err = <-writeReady: + t.Fatalf("new write preempted post-handshake write: %v", err) + case <-time.After(time.Second): + t.Fatal("post-handshake write did not resume") + } + + select { + case err = <-writeReady: + t.Fatalf("new write acquired before post-handshake finished: %v", err) + case <-time.After(100 * time.Millisecond): + } + + tlsConn.finishPostHandshakeWrite() + select { + case err = <-writeReady: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("new write did not resume after post-handshake write") + } + tlsConn.finishWrite() +} + +func TestWindowsClientPostHandshakeReplyErrorClosesConn(t *testing.T) { + rawConn := &windowsTestIOConn{ + writeErr: os.ErrDeadlineExceeded, + writeN: 1, + } + tlsConn := newTestWindowsTLSConn(rawConn) + + err := tlsConn.writePostHandshakeReply([]byte("reply")) + if !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("expected os.ErrDeadlineExceeded, got %v", err) + } + if !rawConn.isClosed() { + t.Fatal("expected raw conn to be closed") + } + if !tlsConn.isClosed() { + t.Fatal("expected tls conn to be closed") + } +} + +func TestWindowsClientWriteErrorClosesConn(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + serverDone, serverAddress := startWindowsTLSSilentServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + MaxVersion: stdtls.VersionTLS12, + }) + defer close(serverDone) + + tlsConn, err := newWindowsTestEngineConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: "1.2", + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + + originalRawConn := tlsConn.rawConn + rawConn := &windowsTestIOConn{ + writeErr: os.ErrDeadlineExceeded, + writeN: 1, + } + tlsConn.rawConn = rawConn + t.Cleanup(func() { + _ = originalRawConn.Close() + _ = tlsConn.Close() + }) + + _, err = tlsConn.Write([]byte("ping")) + if !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("expected os.ErrDeadlineExceeded, got %v", err) + } + if !rawConn.isClosed() { + t.Fatal("expected raw conn to be closed") + } + if !tlsConn.isClosed() { + t.Fatal("expected tls conn to be closed") + } + + _, err = tlsConn.Write([]byte("again")) + if !errors.Is(err, net.ErrClosed) { + t.Fatalf("expected net.ErrClosed on second write, got %v", err) + } + if rawConn.totalWriteCalls() != 1 { + t.Fatalf("expected exactly 1 raw write, got %d", rawConn.totalWriteCalls()) + } + + _, err = tlsConn.Read(make([]byte, 1)) + if !errors.Is(err, net.ErrClosed) { + t.Fatalf("expected net.ErrClosed on read after write failure, got %v", err) + } +} + +func TestWindowsClientConnectionStateFields(t *testing.T) { + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + _, serverAddress := startWindowsTLSTestServer(t, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: stdtls.VersionTLS12, + NextProtos: []string{"h2"}, + }) + + clientConn, err := newWindowsTestClientConn(t, serverAddress, option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: "1.2", + ALPN: badoption.Listable[string]{"h2"}, + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + + state := clientConn.ConnectionState() + if state.ServerName != "localhost" { + t.Errorf("ServerName: expected localhost, got %q", state.ServerName) + } + if state.NegotiatedProtocol != "h2" { + t.Errorf("NegotiatedProtocol: expected h2, got %q", state.NegotiatedProtocol) + } + if !state.HandshakeComplete { + t.Error("HandshakeComplete: expected true") + } + if state.Version < stdtls.VersionTLS12 || state.Version > stdtls.VersionTLS13 { + t.Errorf("Version: expected TLS 1.2–1.3, got %x", state.Version) + } + if len(state.PeerCertificates) == 0 { + t.Fatal("PeerCertificates: expected at least one certificate") + } + // CipherSuite may be 0 when the Schannel name does not map to a Go + // constant; just ensure it's consistent with the protocol. + if state.Version == stdtls.VersionTLS13 && state.CipherSuite != 0 { + switch state.CipherSuite { + case stdtls.TLS_AES_128_GCM_SHA256, stdtls.TLS_AES_256_GCM_SHA384, stdtls.TLS_CHACHA20_POLY1305_SHA256: + default: + t.Errorf("unexpected TLS 1.3 cipher suite: %x", state.CipherSuite) + } + } +} + +func TestWindowsClientNetConnReturnsUnderlying(t *testing.T) { + clientConn, serverDone := startWindowsEchoServer(t, stdtls.VersionTLS12) + defer func() { <-serverDone }() + defer clientConn.Close() + + underlying := clientConn.NetConn() + if _, isTCP := underlying.(*net.TCPConn); !isTCP { + t.Fatalf("NetConn returned %T, expected *net.TCPConn", underlying) + } +} + +func TestNewWindowsClientMissingServerName(t *testing.T) { + _, err := NewClientWithOptions(ClientOptions{ + Context: context.Background(), + Logger: logger.NOP(), + Options: option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + }, + }) + if err == nil { + t.Fatal("expected missing server_name error") + } +} + +func TestNewWindowsClientInsecureAllowsMissingServerName(t *testing.T) { + _, err := NewClientWithOptions(ClientOptions{ + Context: context.Background(), + Logger: logger.NOP(), + Options: option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + Insecure: true, + }, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestWindowsClientConfigSTDConfigReturnsError(t *testing.T) { + config, err := NewClientWithOptions(ClientOptions{ + Context: context.Background(), + Logger: logger.NOP(), + Options: option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + }, + }) + if err != nil { + t.Fatal(err) + } + _, err = config.STDConfig() + if err == nil { + t.Fatal("expected STDConfig() to return error for Windows engine") + } + if !strings.Contains(err.Error(), "system TLS engine") { + t.Fatalf("expected error to name the engine, got %q", err.Error()) + } +} + +func TestWindowsClientConfigClientReturnsErrInvalid(t *testing.T) { + config, err := NewClientWithOptions(ClientOptions{ + Context: context.Background(), + Logger: logger.NOP(), + Options: option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + }, + }) + if err != nil { + t.Fatal(err) + } + _, err = config.Client(nil) + if !errors.Is(err, os.ErrInvalid) { + t.Fatalf("expected os.ErrInvalid, got %v", err) + } +} + +func TestWindowsClientConfigClone(t *testing.T) { + config, err := NewClientWithOptions(ClientOptions{ + Context: context.Background(), + Logger: logger.NOP(), + Options: option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + ALPN: badoption.Listable[string]{"h2", "http/1.1"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + clone := config.Clone() + + // Mutating the clone must not affect the original. + clone.SetServerName("other") + clone.SetNextProtos([]string{"h3"}) + if config.ServerName() == "other" { + t.Error("Clone shares server name with original") + } + if len(config.NextProtos()) != 2 { + t.Error("Clone shares ALPN slice with original") + } +} + +func TestValidateWindowsTLSOptionsRejections(t *testing.T) { + cases := []struct { + name string + options option.OutboundTLSOptions + needle string + }{ + {"reality", option.OutboundTLSOptions{ + Enabled: true, Engine: C.TLSEngineWindows, ServerName: "x", + Reality: &option.OutboundRealityOptions{Enabled: true, ShortID: "abc"}, + }, "reality"}, + {"utls", option.OutboundTLSOptions{ + Enabled: true, Engine: C.TLSEngineWindows, ServerName: "x", + UTLS: &option.OutboundUTLSOptions{Enabled: true}, + }, "utls"}, + {"ech", option.OutboundTLSOptions{ + Enabled: true, Engine: C.TLSEngineWindows, ServerName: "x", + ECH: &option.OutboundECHOptions{Enabled: true}, + }, "ech"}, + {"disable_sni", option.OutboundTLSOptions{ + Enabled: true, Engine: C.TLSEngineWindows, ServerName: "x", + DisableSNI: true, + }, "disable_sni"}, + {"cipher_suites", option.OutboundTLSOptions{ + Enabled: true, Engine: C.TLSEngineWindows, ServerName: "x", + CipherSuites: []string{"TLS_AES_128_GCM_SHA256"}, + }, "cipher_suites"}, + {"curve_preferences", option.OutboundTLSOptions{ + Enabled: true, Engine: C.TLSEngineWindows, ServerName: "x", + CurvePreferences: []option.CurvePreference{option.CurvePreference(29)}, + }, "curve_preferences"}, + {"client_certificate", option.OutboundTLSOptions{ + Enabled: true, Engine: C.TLSEngineWindows, ServerName: "x", + ClientCertificate: badoption.Listable[string]{"pem"}, + }, "client certificate"}, + {"fragment", option.OutboundTLSOptions{ + Enabled: true, Engine: C.TLSEngineWindows, ServerName: "x", + Fragment: true, + }, "tls fragment"}, + {"record_fragment", option.OutboundTLSOptions{ + Enabled: true, Engine: C.TLSEngineWindows, ServerName: "x", + RecordFragment: true, + }, "tls fragment"}, + {"kernel_tx", option.OutboundTLSOptions{ + Enabled: true, Engine: C.TLSEngineWindows, ServerName: "x", + KernelTx: true, + }, "ktls"}, + {"kernel_rx", option.OutboundTLSOptions{ + Enabled: true, Engine: C.TLSEngineWindows, ServerName: "x", + KernelRx: true, + }, "ktls"}, + {"spoof", option.OutboundTLSOptions{ + Enabled: true, Engine: C.TLSEngineWindows, ServerName: "x", + Spoof: "decoy.example", + }, "spoof"}, + {"pin_and_cert_conflict", option.OutboundTLSOptions{ + Enabled: true, Engine: C.TLSEngineWindows, ServerName: "x", + Certificate: badoption.Listable[string]{"-----BEGIN CERTIFICATE-----"}, + CertificatePublicKeySHA256: [][]byte{make([]byte, 32)}, + }, "certificate_public_key_sha256"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewClientWithOptions(ClientOptions{ + Context: context.Background(), + Logger: logger.NOP(), + Options: tc.options, + }) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.needle) + } + if !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tc.needle)) { + t.Fatalf("expected error to contain %q, got %q", tc.needle, err.Error()) + } + }) + } +} + +func startWindowsTLSSilentServer(t *testing.T, tlsConfig *stdtls.Config) (chan<- struct{}, string) { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + + if tcpListener, isTCP := listener.(*net.TCPListener); isTCP { + err = tcpListener.SetDeadline(time.Now().Add(windowsTLSTestTimeout)) + if err != nil { + t.Fatal(err) + } + } + + done := make(chan struct{}) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer conn.Close() + deadlineErr := conn.SetDeadline(time.Now().Add(windowsTLSTestTimeout)) + if deadlineErr != nil { + return + } + tlsConn := stdtls.Server(conn, tlsConfig) + defer tlsConn.Close() + handshakeErr := tlsConn.Handshake() + if handshakeErr != nil { + return + } + handshakeErr = conn.SetDeadline(time.Time{}) + if handshakeErr != nil { + return + } + <-done + }() + return done, listener.Addr().String() +} + +// sharedWindowsTestCertificate caches a localhost certificate so the RSA key +// generation runs once per test binary instead of once per test. +var sharedWindowsTestCertificate = sync.OnceValues(func() (stdtls.Certificate, string) { + return generateWindowsTestCertificate("localhost") +}) + +func newWindowsTestCertificate(t *testing.T, serverName string) (stdtls.Certificate, string) { + t.Helper() + if serverName == "localhost" { + return sharedWindowsTestCertificate() + } + return generateWindowsTestCertificate(serverName) +} + +func generateWindowsTestCertificate(serverName string) (stdtls.Certificate, string) { + privateKeyPEM, certificatePEM, err := GenerateCertificate(nil, nil, time.Now, serverName, time.Now().Add(time.Hour)) + if err != nil { + panic(err) + } + certificate, err := stdtls.X509KeyPair(certificatePEM, privateKeyPEM) + if err != nil { + panic(err) + } + leaf, err := x509.ParseCertificate(certificate.Certificate[0]) + if err != nil { + panic(err) + } + certificate.Leaf = leaf + return certificate, string(certificatePEM) +} + +func publicKeyPin(t *testing.T, cert *x509.Certificate) []byte { + t.Helper() + pub, err := x509.MarshalPKIXPublicKey(cert.PublicKey) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(pub) + return sum[:] +} + +func startWindowsTLSTestServer(t *testing.T, tlsConfig *stdtls.Config) (<-chan windowsTLSServerResult, string) { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + + if tcpListener, isTCP := listener.(*net.TCPListener); isTCP { + err = tcpListener.SetDeadline(time.Now().Add(windowsTLSTestTimeout)) + if err != nil { + t.Fatal(err) + } + } + + result := make(chan windowsTLSServerResult, 1) + go func() { + defer close(result) + + conn, err := listener.Accept() + if err != nil { + result <- windowsTLSServerResult{err: err} + return + } + defer conn.Close() + + err = conn.SetDeadline(time.Now().Add(windowsTLSTestTimeout)) + if err != nil { + result <- windowsTLSServerResult{err: err} + return + } + + tlsConn := stdtls.Server(conn, tlsConfig) + defer tlsConn.Close() + + err = tlsConn.Handshake() + if err != nil { + result <- windowsTLSServerResult{err: err} + return + } + + result <- windowsTLSServerResult{state: tlsConn.ConnectionState()} + }() + + return result, listener.Addr().String() +} + +func newWindowsTestClientConn(t *testing.T, serverAddress string, options option.OutboundTLSOptions) (Conn, error) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), windowsTLSTestTimeout) + t.Cleanup(cancel) + + clientConfig, err := NewClientWithOptions(ClientOptions{ + Context: ctx, + Logger: logger.NOP(), + ServerAddress: "", + Options: options, + }) + if err != nil { + return nil, err + } + + conn, err := net.DialTimeout("tcp", serverAddress, windowsTLSTestTimeout) + if err != nil { + return nil, err + } + + tlsConn, err := ClientHandshake(ctx, conn, clientConfig) + if err != nil { + conn.Close() + return nil, err + } + return tlsConn, nil +} + +func newWindowsTestEngineConn(t *testing.T, serverAddress string, options option.OutboundTLSOptions) (*windowsTLSConn, error) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), windowsTLSTestTimeout) + t.Cleanup(cancel) + + clientConfig, err := NewClientWithOptions(ClientOptions{ + Context: ctx, + Logger: logger.NOP(), + ServerAddress: "", + Options: options, + }) + if err != nil { + return nil, err + } + + engineConfig, ok := clientConfig.(*windowsClientConfig) + if !ok { + return nil, errors.New("unexpected windows config type") + } + + conn, err := net.DialTimeout("tcp", serverAddress, windowsTLSTestTimeout) + if err != nil { + return nil, err + } + + tlsConn, err := engineConfig.ClientHandshake(ctx, conn) + if err != nil { + conn.Close() + return nil, err + } + + engineConn, ok := tlsConn.(*windowsTLSConn) + if !ok { + tlsConn.Close() + return nil, errors.New("unexpected windows conn type") + } + return engineConn, nil +} + +func startWindowsPayloadServer(t *testing.T, minVersion uint16, payload []byte) (*windowsTLSConn, <-chan error) { + t.Helper() + + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + + serverErr := make(chan error, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + serverErr <- acceptErr + return + } + defer conn.Close() + + tlsConn := stdtls.Server(conn, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: minVersion, + MaxVersion: minVersion, + }) + defer tlsConn.Close() + + handshakeErr := tlsConn.Handshake() + if handshakeErr != nil { + serverErr <- handshakeErr + return + } + _, writeErr := tlsConn.Write(payload) + serverErr <- writeErr + }() + + version := "1.2" + if minVersion == stdtls.VersionTLS13 { + version = "1.3" + } + clientConn, err := newWindowsTestEngineConn(t, listener.Addr().String(), option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: version, + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + return clientConn, serverErr +} + +// startWindowsEchoServer brings up a TLS echo server with a self-signed cert +// and dials an engine client against it. The returned channel closes after +// the server goroutine exits. +func startWindowsEchoServer(t *testing.T, minVersion uint16) (Conn, <-chan struct{}) { + t.Helper() + + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + + done := make(chan struct{}) + go func() { + defer close(done) + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer conn.Close() + + tlsConn := stdtls.Server(conn, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: minVersion, + MaxVersion: minVersion, + }) + defer tlsConn.Close() + + handshakeErr := tlsConn.Handshake() + if handshakeErr != nil { + return + } + + buffer := make([]byte, 32*1024) + for { + n, readErr := tlsConn.Read(buffer) + if n > 0 { + _, writeErr := tlsConn.Write(buffer[:n]) + if writeErr != nil { + return + } + } + if readErr != nil { + return + } + } + }() + + version := "1.2" + if minVersion == stdtls.VersionTLS13 { + version = "1.3" + } + clientConn, err := newWindowsTestClientConn(t, listener.Addr().String(), option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: version, + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + return clientConn, done +} + +func startWindowsEchoEngineServer(t *testing.T, minVersion uint16) (*windowsTLSConn, <-chan struct{}) { + t.Helper() + + serverCertificate, serverCertificatePEM := newWindowsTestCertificate(t, "localhost") + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + + done := make(chan struct{}) + go func() { + defer close(done) + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer conn.Close() + + tlsConn := stdtls.Server(conn, &stdtls.Config{ + Certificates: []stdtls.Certificate{serverCertificate}, + MinVersion: minVersion, + MaxVersion: minVersion, + }) + defer tlsConn.Close() + + handshakeErr := tlsConn.Handshake() + if handshakeErr != nil { + return + } + + buffer := make([]byte, 32*1024) + for { + n, readErr := tlsConn.Read(buffer) + if n > 0 { + _, writeErr := tlsConn.Write(buffer[:n]) + if writeErr != nil { + return + } + } + if readErr != nil { + return + } + } + }() + + version := "1.2" + if minVersion == stdtls.VersionTLS13 { + version = "1.3" + } + clientConn, err := newWindowsTestEngineConn(t, listener.Addr().String(), option.OutboundTLSOptions{ + Enabled: true, + Engine: C.TLSEngineWindows, + ServerName: "localhost", + MinVersion: version, + Certificate: badoption.Listable[string]{serverCertificatePEM}, + }) + if err != nil { + t.Fatal(err) + } + return clientConn, done +} diff --git a/constant/tls.go b/constant/tls.go index c81740a4..fdd813b7 100644 --- a/constant/tls.go +++ b/constant/tls.go @@ -4,5 +4,7 @@ const ACMETLS1Protocol = "acme-tls/1" const ( TLSEngineDefault = "" + TLSEngineGo = "go" TLSEngineApple = "apple" + TLSEngineWindows = "windows" ) diff --git a/docs/configuration/shared/tls.md b/docs/configuration/shared/tls.md index f7e510b3..3b74e890 100644 --- a/docs/configuration/shared/tls.md +++ b/docs/configuration/shared/tls.md @@ -8,6 +8,7 @@ icon: material/new-box :material-plus: [handshake_timeout](#handshake_timeout) :material-plus: [spoof](#spoof) :material-plus: [spoof_method](#spoof_method) + :material-plus: [engine](#engine) :material-delete-clock: [acme](#acme-fields) !!! quote "Changes in sing-box 1.13.0" @@ -195,6 +196,8 @@ Enable TLS. #### engine +!!! question "Since sing-box 1.14.0" + ==Client only== TLS engine to use. @@ -203,15 +206,40 @@ Values: * `go` (default) * `apple` +* `windows` -`apple` uses Network.framework, only available on Apple platforms and only supports **direct** TCP TLS client connections. +Supported fields: -!!! warning "" +* `server_name` +* `insecure` +* `alpn` +* `min_version` +* `max_version` +* `certificate` / `certificate_path` +* `certificate_public_key_sha256` +* `handshake_timeout` - Experimental only: due to the high memory overhead of both CGO and Network.framework, - do not use in hot paths on iOS and tvOS. - If you want to circumvent TLS fingerprint-based proxy censorship, - use [NaiveProxy](/configuration/outbound/naive/) instead. +Unsupported fields: + +* `disable_sni` +* `cipher_suites` +* `curve_preferences` +* `client_certificate` / `client_certificate_path` / `client_key` / `client_key_path` +* `fragment` / `record_fragment` +* `kernel_tx` / `kernel_rx` +* `ech` +* `utls` +* `reality` + +!!! note "" + + `windows` uses Schannel via SSPI. Only available on Windows build 17763 or later (Windows 10 version 1809, Windows Server 2019, or newer). + +!!! note "" + + TLS 1.3 is only negotiated on Windows 11 or Windows Server 2022 and newer. On older Windows versions, Schannel caps the connection at TLS 1.2 even when `max_version` is `1.3`. + +The default version range is TLS 1.2 to TLS 1.3, matching the `go` engine. Supported fields: diff --git a/docs/configuration/shared/tls.zh.md b/docs/configuration/shared/tls.zh.md index 7ae851ed..3cc411da 100644 --- a/docs/configuration/shared/tls.zh.md +++ b/docs/configuration/shared/tls.zh.md @@ -8,6 +8,7 @@ icon: material/new-box :material-plus: [handshake_timeout](#handshake_timeout) :material-plus: [spoof](#spoof) :material-plus: [spoof_method](#spoof_method) + :material-plus: [engine](#engine) :material-delete-clock: [acme](#acme-字段) !!! quote "sing-box 1.13.0 中的更改" @@ -195,6 +196,8 @@ TLS 版本值: #### engine +!!! question "自 sing-box 1.14.0 起" + ==仅客户端== 要使用的 TLS 引擎。 @@ -203,14 +206,40 @@ TLS 版本值: * `go`(默认) * `apple` +* `windows` -`apple` 使用 Network.framework,仅在 Apple 平台可用,且仅支持 **直接** TCP TLS 客户端连接。 +支持的字段: -!!! warning "" +* `server_name` +* `insecure` +* `alpn` +* `min_version` +* `max_version` +* `certificate` / `certificate_path` +* `certificate_public_key_sha256` +* `handshake_timeout` - 仅供实验用途:由于 CGO 和 Network.framework 占用的内存都很多, - 不应在 iOS 和 tvOS 的热路径中使用。 - 如果您想规避基于 TLS 指纹的代理审查,应使用 [NaiveProxy](/zh/configuration/outbound/naive/)。 +不支持的字段: + +* `disable_sni` +* `cipher_suites` +* `curve_preferences` +* `client_certificate` / `client_certificate_path` / `client_key` / `client_key_path` +* `fragment` / `record_fragment` +* `kernel_tx` / `kernel_rx` +* `ech` +* `utls` +* `reality` + +!!! note "" + + `windows` 通过 SSPI 使用 Schannel,仅在 Windows build 17763 及以上可用,包括 Windows 10 版本 1809、Windows Server 2019 及后续版本。 + +!!! note "" + + TLS 1.3 仅在 Windows 11 或 Windows Server 2022 及后续版本上协商。在更早的 Windows 版本上,即使 `max_version` 设为 `1.3`,Schannel 也会把连接上限固定在 TLS 1.2。 + +默认版本范围为 TLS 1.2 到 TLS 1.3,与 `go` 引擎一致。证书验证在 Go 侧基于 Schannel 返回的证书链执行,默认使用系统证书存储。当设置了 `certificate` 或 `certificate_path` 时,这些根证书会替代系统存储。 支持的字段: