Merge TLS default key exchange compatibility fixes

Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
Feng Ruohang
2026-09-09 19:31:35 +08:00
19 changed files with 10083 additions and 45 deletions
+20
View File
@@ -89,6 +89,26 @@ The S3 API, `MINIO_*` variables, `minio_*` metrics, `x-minio-*` headers, `/minio
Every divergence from upstream is listed in the code-verified [compatibility audit](https://silo.pgsty.com/compatibility/server/). Treat each release as a downstream upgrade: pin versions, read the [release notes](https://silo.pgsty.com/tags/silo/), and keep a rollback path.
### TLS and Go upgrades
TLS key exchange follows Go's defaults across the S3 listener, node links,
replication, identity providers, etcd, and external HTTP services. If an endpoint
cannot accept ML-KEM, `GODEBUG=tlsmlkem=0` disables the default hybrid exchanges
for the process; certificate verification remains enabled. This option does not
disable ML-DSA signatures or resolve every TLS reset. Prefer updating the
incompatible endpoint before removing the temporary setting.
If only the new SecP hybrids cause problems, `GODEBUG=tlssecpmlkem=0` disables
those groups while retaining X25519MLKEM768.
For builds targeting Go 1.27, setting either `SSL_CERT_FILE` or `SSL_CERT_DIR`
on macOS replaces Keychain trust with on-disk roots and Go's verifier. Stale or
incomplete CA paths can break previously trusted connections; unset inherited
values to restore Keychain trust. Explicit certificates in the configured `CAs`
directory remain additive to the selected root pool.
Go 1.27 binaries require macOS 13 or later. See the
[Go release notes](https://go.dev/doc/go1.27) and the
[SILO stack investigation](docs/investigations/go127-stack.md).
## Security & Contributing
Report vulnerabilities privately as described in [`SECURITY.md`](SECURITY.md); every fix ships with a public [advisory](https://silo.pgsty.com/blog/security/). Contributions are accepted inbound=outbound under AGPL-3.0-or-later with no CLA — only DCO sign-off (`git commit -s`) is required; see [`CONTRIBUTING.md`](CONTRIBUTING.md).
+2
View File
@@ -115,7 +115,9 @@ func collect(repo string) (manifest, error) {
fset := token.NewFileSet()
for _, rel := range files {
// Investigation artifacts contain synthetic routes and archived configurations.
if rel == "SILO_REBRANDING_MIGRATION.md" ||
strings.HasPrefix(rel, "docs/investigations/") ||
strings.HasPrefix(rel, "buildscripts/rebrand-guard/") ||
strings.HasPrefix(rel, "buildscripts/helm-migration-guard/") {
continue
+4 -6
View File
@@ -51,9 +51,8 @@ func initGlobalGrid(ctx context.Context, eps EndpointServerPools) error {
grid.ContextDialer(xhttp.DialContextWithLookupHost(lookupHost, xhttp.NewInternodeDialContext(rest.DefaultTimeout, globalTCPOptions.ForWebsocket()))),
newCachedAuthToken(),
&tls.Config{
RootCAs: globalRootCAs,
CipherSuites: crypto.TLSCiphers(),
CurvePreferences: crypto.TLSCurveIDs(),
RootCAs: globalRootCAs,
CipherSuites: crypto.TLSCiphers(),
}),
Local: local,
Hosts: hosts,
@@ -84,9 +83,8 @@ func initGlobalLockGrid(ctx context.Context, eps EndpointServerPools) error {
grid.ContextDialer(xhttp.DialContextWithLookupHost(lookupHost, xhttp.NewInternodeDialContext(rest.DefaultTimeout, globalTCPOptions.ForWebsocket()))),
newCachedAuthToken(),
&tls.Config{
RootCAs: globalRootCAs,
CipherSuites: crypto.TLSCiphers(),
CurvePreferences: crypto.TLSCurveIDs(),
RootCAs: globalRootCAs,
CipherSuites: crypto.TLSCiphers(),
}, grid.RouteLockPath),
Local: local,
Hosts: hosts,
+133
View File
@@ -0,0 +1,133 @@
// Copyright (c) 2026 Pigsty
// SPDX-License-Identifier: AGPL-3.0-or-later
package cmd
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"testing"
"time"
"github.com/minio/minio/internal/config/etcd"
)
func TestOutboundTLSKeyExchangeDefaults(t *testing.T) {
for _, debug := range []string{"tlsmlkem=0", "tlsmlkem=1"} {
t.Run(debug, func(t *testing.T) {
t.Setenv("GODEBUG", debug)
for _, version := range []uint16{tls.VersionTLS12, tls.VersionTLS13} {
t.Run(tls.VersionName(version), func(t *testing.T) {
hellos := make(chan []tls.CurveID, 1)
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "ok")
}))
server.TLS = &tls.Config{
MinVersion: version, MaxVersion: version,
GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
select {
case hellos <- slices.Clone(hello.SupportedCurves):
default:
}
return nil, nil
},
}
server.StartTLS()
t.Cleanup(server.Close)
roots := x509.NewCertPool()
roots.AddCert(server.Certificate())
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "public.crt"), filepath.Join(dir, "private.key")
cert := server.TLS.Certificates[0]
key, err := x509.MarshalPKCS8PrivateKey(cert.PrivateKey)
if err != nil {
t.Fatal(err)
}
for path, block := range map[string]*pem.Block{
certFile: {Type: "CERTIFICATE", Bytes: cert.Certificate[0]},
keyFile: {Type: "PRIVATE KEY", Bytes: key},
} {
if err := os.WriteFile(path, pem.EncodeToMemory(block), 0o600); err != nil {
t.Fatal(err)
}
}
t.Setenv(etcd.EnvEtcdEndpoints, server.URL)
t.Setenv(etcd.EnvEtcdClientCert, "")
t.Setenv(etcd.EnvEtcdClientCertKey, "")
etcdConfig, err := etcd.LookupConfig(etcd.DefaultKVS, roots)
if err != nil {
t.Fatal(err)
}
for name, transport := range map[string]*http.Transport{
"external": NewHTTPTransport(),
"internode": NewInternodeHTTPTransport(1)().(*http.Transport),
"replication": NewRemoteTargetHTTPTransport(false)(),
"cloud-client-cert": NewHTTPTransportWithClientCerts(certFile, keyFile).(*http.Transport),
"etcd": {TLSClientConfig: etcdConfig.TLS},
} {
t.Run(name, func(t *testing.T) {
defer transport.CloseIdleConnections()
transport.Proxy = nil
transport.TLSClientConfig.RootCAs = roots
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
resp, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK || resp.TLS.Version != version {
t.Fatalf("status %d, TLS %s", resp.StatusCode, tls.VersionName(resp.TLS.Version))
}
curves := <-hellos
if got, want := slices.Contains(curves, tls.X25519MLKEM768), debug == "tlsmlkem=1"; got != want {
t.Errorf("ML-KEM offered = %v, want %v; curves %v", got, want, curves)
}
})
}
})
}
})
}
}
func TestServerTLSKeyExchangeDefaults(t *testing.T) {
for _, debug := range []string{"tlsmlkem=0", "tlsmlkem=1"} {
t.Run(debug, func(t *testing.T) {
t.Setenv("GODEBUG", debug)
// Reuse httptest's certificate with the actual Server TLS constructor.
seed := httptest.NewTLSServer(http.NotFoundHandler())
cert := seed.TLS.Certificates[0]
seed.Close()
server := httptest.NewUnstartedServer(http.NotFoundHandler())
server.TLS = newTLSConfig(func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return &cert, nil })
server.StartTLS()
defer server.Close()
roots := x509.NewCertPool()
leaf, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
t.Fatal(err)
}
roots.AddCert(leaf)
for _, curve := range []tls.CurveID{tls.X25519MLKEM768, tls.CurveP256} {
conn, err := tls.Dial("tcp", server.Listener.Addr().String(), &tls.Config{
RootCAs: roots, MinVersion: tls.VersionTLS13, CurvePreferences: []tls.CurveID{curve},
})
wantSuccess := curve == tls.CurveP256 || debug == "tlsmlkem=1"
if (err == nil) != wantSuccess {
t.Errorf("curve %v: error %v, want success %v", curve, err, wantSuccess)
}
if conn != nil {
_ = conn.Close()
}
}
})
}
}
+23 -28
View File
@@ -608,13 +608,12 @@ func GetDefaultConnSettings() xhttp.ConnSettings {
// connections.
func NewInternodeHTTPTransport(maxIdleConnsPerHost int) func() http.RoundTripper {
return xhttp.ConnSettings{
LookupHost: globalDNSCache.LookupHost,
DialTimeout: rest.DefaultTimeout,
RootCAs: globalRootCAs,
CipherSuites: crypto.TLSCiphers(),
CurvePreferences: crypto.TLSCurveIDs(),
EnableHTTP2: false,
TCPOptions: globalTCPOptions,
LookupHost: globalDNSCache.LookupHost,
DialTimeout: rest.DefaultTimeout,
RootCAs: globalRootCAs,
CipherSuites: crypto.TLSCiphers(),
EnableHTTP2: false,
TCPOptions: globalTCPOptions,
}.NewInternodeHTTPTransport(maxIdleConnsPerHost)
}
@@ -622,13 +621,12 @@ func NewInternodeHTTPTransport(maxIdleConnsPerHost int) func() http.RoundTripper
// used while communicating with the cloud backends.
func NewHTTPTransportWithClientCerts(clientCert, clientKey string) http.RoundTripper {
s := xhttp.ConnSettings{
LookupHost: globalDNSCache.LookupHost,
DialTimeout: defaultDialTimeout,
RootCAs: globalRootCAs,
CipherSuites: crypto.TLSCiphersBackwardCompatible(),
CurvePreferences: crypto.TLSCurveIDs(),
TCPOptions: globalTCPOptions,
EnableHTTP2: false,
LookupHost: globalDNSCache.LookupHost,
DialTimeout: defaultDialTimeout,
RootCAs: globalRootCAs,
CipherSuites: crypto.TLSCiphersBackwardCompatible(),
TCPOptions: globalTCPOptions,
EnableHTTP2: false,
}
if clientCert != "" && clientKey != "" {
@@ -660,13 +658,12 @@ const defaultDialTimeout = 5 * time.Second
// NewHTTPTransportWithTimeout allows setting a timeout.
func NewHTTPTransportWithTimeout(timeout time.Duration) *http.Transport {
return xhttp.ConnSettings{
LookupHost: globalDNSCache.LookupHost,
DialTimeout: defaultDialTimeout,
RootCAs: globalRootCAs,
TCPOptions: globalTCPOptions,
CipherSuites: crypto.TLSCiphersBackwardCompatible(),
CurvePreferences: crypto.TLSCurveIDs(),
EnableHTTP2: false,
LookupHost: globalDNSCache.LookupHost,
DialTimeout: defaultDialTimeout,
RootCAs: globalRootCAs,
TCPOptions: globalTCPOptions,
CipherSuites: crypto.TLSCiphersBackwardCompatible(),
EnableHTTP2: false,
}.NewHTTPTransportWithTimeout(timeout)
}
@@ -674,12 +671,11 @@ func NewHTTPTransportWithTimeout(timeout time.Duration) *http.Transport {
// used while communicating with the remote replication targets.
func NewRemoteTargetHTTPTransport(insecure bool) func() *http.Transport {
return xhttp.ConnSettings{
LookupHost: globalDNSCache.LookupHost,
RootCAs: globalRootCAs,
CipherSuites: crypto.TLSCiphersBackwardCompatible(),
CurvePreferences: crypto.TLSCurveIDs(),
TCPOptions: globalTCPOptions,
EnableHTTP2: false,
LookupHost: globalDNSCache.LookupHost,
RootCAs: globalRootCAs,
CipherSuites: crypto.TLSCiphersBackwardCompatible(),
TCPOptions: globalTCPOptions,
EnableHTTP2: false,
}.NewRemoteTargetHTTPTransport(insecure)
}
@@ -986,7 +982,6 @@ func newTLSConfig(getCert certs.GetCertificateFunc) *tls.Config {
} else {
tlsConfig.CipherSuites = crypto.TLSCiphersBackwardCompatible()
}
tlsConfig.CurvePreferences = crypto.TLSCurveIDs()
return tlsConfig
}
File diff suppressed because it is too large Load Diff
+184
View File
@@ -0,0 +1,184 @@
# SILO stack: Go 1.27 compatibility audit
2026-09-09. Scope: the maintained Server, silo-pkg, mcli, and Console. This extends
the [OIDC #154 investigation](issue-154.md) to other paths using the same TLS
configuration and to adjacent standard-library changes. It records local
validation performed before commit. No pushes, issue comments, releases, or
production changes were made during the audit.
## Findings and changes
| Component | Finding | Change |
| --- | --- | --- |
| Server, baseline `d1105bbb3d4a0afa33b3a4ac11b821235038ed0e` | Eight TLS configuration sites explicitly use the same ML-KEM-containing curve list, overriding `tlsmlkem=0` in Go 1.27. | Remove the eight assignments and obsolete `TLSCurveIDs` helper. Retain Go defaults in HTTP clients, replication, cloud client-certificate transport, both grid links, etcd, and the S3 listener. Add wire-level regression tests. |
| silo-pkg, baseline `a92c54d` | No built-in explicit PQ curve list. Web environment transport uses defaults; LDAP/OIDC accept caller configuration. | Add a real web-environment TLS regression test and document runtime-default selection. Keep the Go 1.26 library floor. |
| mcli, baseline `fcd5cad8` | S3/Admin transport and alias/TOFU dialer use default curves. No instance of the Server's override bug found. | Add real S3/Admin transport and alias-dialer handshake tests; update Go/TLS upgrade notes. |
| Console, baseline `c103d08ec` | IdP, SILO/STS, Prometheus, and webhook clients share a transport using defaults. The HTTPS listener explicitly uses only P-256. | Add real IdP/SILO-client handshake tests; update Go/TLS upgrade notes. Retain the existing listener policy. |
The Server's general external HTTP transport is also used by OpenID discovery
and JWKS, identity plugins, notification/lambda checks, audit/log webhooks, and
S3 cloud backends. Fixing only the two OIDC callers would leave those other paths
affected. The broader correction supersedes the earlier OIDC-only candidate.
The retained defaults follow Go's implementation rather than duplicating its
`GODEBUG` parser. Go 1.27 explicitly changed the interaction between manually
selected curves and the `tlsmlkem`/`tlssecpmlkem` default controls. See the
[Go TLS release notes](https://go.dev/doc/go1.27#crypto/tls).
With no opt-out, default curves additionally include SecP256r1MLKEM768 and
SecP384r1MLKEM1024. With `GODEBUG=tlsmlkem=0`, hybrid exchanges are disabled for
default-configured TLS throughout the process. This is an intentional change
from the old fixed subset. `GODEBUG=tlssecpmlkem=0` disables just the SecP
hybrids while retaining X25519MLKEM768. TLS versions, cipher-suite policy,
certificate and hostname checks, client certificates, proxies, and HTTP/2 choices are not
relaxed by this patch. No automatic fallback after a TLS error is introduced.
## Reproduction and regression evidence
Before changing product code, the new Server test failed for all five tested
outbound constructors with `tlsmlkem=0`: general external HTTP, internode HTTP,
replication, cloud client certificates, and etcd. The server observed
`[X25519MLKEM768 X25519 P256 P384 P521]` in every case. Both TLS 1.2 and TLS 1.3
peers reproduced the problem. The inbound Server also accepted a PQ-only client
despite the same opt-out.
After the change, those tests pass on darwin/arm64 and linux/arm64. They exercise
20 outbound combinations (five constructors, two peer TLS versions, two debug
settings), plus inbound classical/PQ-only peers with the opt-out enabled and
disabled. The inbound opt-out case rejects a PQ-only peer while continuing to
accept P-256. The etcd test exercises its actual TLS configuration, not an etcd
cluster; the client-certificate test exercises construction/loading, not a full
mutual-authentication service.
The mc, Console, and silo-pkg handshake tests pass without product code changes.
All use a trusted synthetic certificate and inspect a real ClientHello. They
check both disabling and retaining ML-KEM. Console also retains its existing
unknown-CA, hostname, and endpoint-scoping regression checks.
A freshly source-built complete Linux Server then passed three isolated
integration scenarios using the existing synthetic IdP fixture:
| Scenario | Result |
| --- | --- |
| ML-KEM-intolerant IdP + `tlsmlkem=0` | Discovery/JWKS, IAM, Console, and authenticated Admin calls succeed; login 204 and bucket list 200. |
| Normal TLS 1.3 IdP, no opt-out | Same successful login, token exchange, STS, session, and bucket-list chain. |
| Add OIDC to a running Server | Actual Admin API accepts the provider under the compatibility setting. |
Both login scenarios reject modified JWT signatures and a wrong audience: no
session cookie is issued and authenticated bucket access returns 403. Login
currently reports these authentication failures as 500; that existing error
mapping is outside this TLS change. Curl in the same namespace returns HTTP/2
200. The containers use a pre-existing generic Debian image, `--network none`,
and no published ports; no Server or Console image was downloaded or used.
The fixture drives real Console HTTP APIs, not rendered browser interaction.
This is a conditional interoperability reproduction, not proof of the actual
customer ingress behavior. Grid's existing tests pass, but a production-style
distributed TLS cluster, external cloud providers, and real etcd/LDAP/Keycloak
deployments were not exercised. Disabling ML-KEM does not disable new ML-DSA
signature offers and does not repair an ingress that rejects those offers.
## Other Go changes checked
### macOS root certificates: a confirmed upgrade-visible change
Using the same public synthetic CA and `certs.GetRootCAs`, fresh-process probes
produce the following results when `SSL_CERT_FILE` points at that CA and
`SSL_CERT_DIR` points at an empty directory:
| Compiler / main module | CA from environment trusted? | Explicit CA argument trusted? |
| --- | --- | --- |
| Go 1.26.5 / `go 1.26.0` | No | Yes |
| Go 1.27.1 / `go 1.26.0` | No | Yes |
| Go 1.27.1 / `go 1.27.1` | Yes | Yes |
The Go 1.26 module built by Go 1.27 carries
`DefaultGODEBUG=...x509sslcertoverrideplatform=0`; explicitly setting that option
to `1` enables the new behavior. A Go 1.27 application can explicitly set it to
`0` to recover the previous platform behavior. The diagnostic source is
[cert-roots.go](issue-154/cert-roots.go). The consuming application's defaults
apply to library calls as well, so silo-pkg's older `go` directive does not
prevent the behavior in Server, mc, or Console.
This is expected standard-library behavior, not a reason to silently discard
configured CA variables or skip verification. Setting either variable replaces
Keychain trust with on-disk roots and Go's verifier; stale or incomplete paths
can break previously trusted connections. Unset inherited variables to restore
Keychain trust; explicit additional CAs still work. The three application
READMEs and the package README now document this. The package's Windows loader enumerates
the native ROOT store directly and does not call `SystemCertPool`; it does not
inherit this particular new setting. Windows behavior was reviewed in source,
not runtime-tested.
### JSON, HTTP, timers, and other compatibility controls
- **JSON:** checked owned JSON error handling and exercised policy/condition,
config, and authentication tests. `quick` uses typed `SyntaxError` and
`UnmarshalTypeError`; no owned decision depending on changed standard JSON
error text was found. silo-pkg's full suite passes with both Go compilers;
mc's command and Console's API/auth suites pass under Go 1.27. No broad
`nojsonv2` opt-out or serialization rewrite is justified by these results.
- **HTTP response closing:** Go 1.27's standard-library drain is bounded at
256 KiB and 50 ms. mc's actual early-close/cancel regression passes, including
the compressed S3 Select stream. Existing owned drain helpers can still have
independent timeout concerns; those are not newly caused by this Go change.
- **ALPN and custom connections:** mc's TLS dialer returns a real `*tls.Conn`;
its deadline wrapper is on the TCP dial path. No new accidental HTTP/2 opt-in
from the expanded `ConnectionState` interface support was identified in
these transports. Console keeps its existing transport policy.
- **Removed switches:** no maintained runtime/config reliance on `tlsrsakex`,
`tls3des`, `tls10server`, `x509keypairleaf`, or `asynctimerchan` was found.
Existing explicit cipher policies continue to be explicit. Timer uses in
package certificate reload and license refresh do not depend on buffered
timer-channel length/capacity. Unix EOF error changes do not expose a matching
owned error-type assumption on the reviewed Unix-socket paths.
- **Platform floor:** application READMEs now identify macOS 13 as the minimum
for Go 1.27 binaries. No Windows, old macOS, or PowerPC runtime claim is made.
## Validation and delivery
- Server: focused TLS tests on macOS and Linux; macOS race run; crypto, HTTP,
grid, and all `internal/config/...` tests; full Linux Server build and the
three integration scenarios above.
- silo-pkg: `make test` (lint and full race suite) on Go 1.27.1; full suite on
Go 1.26.5, preserving the library floor.
- mc: complete `./cmd` suite, including the new TLS test and existing S3 Select
early-cancel test.
- Console: complete `./api/... ./pkg/...` suites, including identity and STS
validation and the new handshake tests.
Go lint reports zero issues in all four repositories. Server's optional spelling
check is skipped because `typos` is not installed. Its lint target used the
already-installed matching golangci-lint v2.13.1 after a redundant download was
stopped; mc's lint was rerun serially after the global linter lock prevented the
first concurrent attempt. These tooling retries did not require source changes.
An independent Fable 5.1 Max review reproduced the old-code failures and ran the
new TLS tests with the race detector. Its shuffled mc and Console suites passed.
One shuffled silo-pkg run failed the untouched `certs.TestValidPairAfterWrite`;
that test passed three plain reruns, the same shuffle seed, and the full certs
package rerun. The certs package runs in a separate test binary from the changed
env tests; this was classified as an existing timing flake.
The review also found that committing the diagnostic fixture made rebrand CI
count synthetic IdP paths as product routes. The guard now excludes
`docs/investigations/`; the product compatibility baseline is unchanged.
The full proposed file set passes the guard, and a negative control adding a
product route still fails. After the review corrections, a fresh Linux Server
build passed all three integration scenarios again; the new binary identity and
rerun results are recorded in the evidence file's `post_review_validation`.
All source and dependency choices remain those of the maintained PGSTY stack.
`go.mod` and `go.sum` are unchanged in every repository. The Server uses its
existing pinned Console/pkg/mc modules; the companion changes are tests and
documentation, so no replacement graph or unpublished dependency version is
needed to build the runtime fix.
Validation used separate worktrees for Server, silo-pkg, mc, and Console, leaving
the original repositories and their `main` branches untouched. Build identities,
fixture results, and command outcomes are recorded in
[go127-stack-evidence.json](go127-stack-evidence.json). Absolute paths and branch
names in the captured evidence identify the environment at recording time.
The recorded module graph identifies the dependencies selected for those
builds; `go.sum` also retains checksums for unselected versions. Local image IDs
and temporary paths are historical evidence, not portable setup instructions.
+403
View File
@@ -0,0 +1,403 @@
# SILO #154OIDC discovery 连接重置调查
前两轮调查时间:2026-09-09;公开 issue 最后核对于 07:53 UTC。第二轮补充同源码、同依赖、不同 Go 工具链的 Linux 完整 Server 对照和候选补丁认证链路验证。
**后续更新:用户已授权扩展至整个 SILO 技术栈并修复。现已确认 Server 其他 TLS 路径也存在同类覆盖问题,并在产品工作区完成统一使用 Go 默认曲线的修复。当前实现、验证和交付状态见 [全栈调查](go127-stack.md)。下文保留前两轮的诊断与当时的 OIDC 局部候选;“未修改产品”和“不要扩大范围”等表述仅适用于当时的调查阶段,局部候选已被后续全路径修复取代。**
## 判断与处理顺序
**目前可以确认是 Server 发出的 discovery GET 失败,随后 IAM 初始化等待,Console 初始化也被延后;还不能确认真实连接由谁、在哪个协议阶段重置。优先调查 SILO/Go 客户端与 IdP 前置 TLS 终止器、代理或 WAF 的互操作。** 现有证据不支持将其定性为证书错误、Keycloak 配置错误、JWT 校验错误或 `coreos/go-oidc` 回归。
有两项与版本相关、可在本地验证的 TLS 差异:
1. **Go 1.27 改变了 `GODEBUG=tlsmlkem=0` 与显式 `CurvePreferences` 的关系。** SILO 两个版本都显式包含 X25519MLKEM768。旧版 Go 1.26.5 会根据该环境选项移除它;Go 1.27.1 保留显式配置。在模拟拒绝 ML-KEM 的入口上,能重现“相同选项下旧 Server 启动成功,新 Server 持续 reset”。**客户是否设置过这个选项尚未知,不能把条件性复现当成客户根因。**
2. **Go 1.27 的 ClientHello 新增 ML-DSA 签名算法。** 没有上述环境选项时,新旧版本也会发送不同的握手。人为拒绝新算法编号的入口同样能产生旧成功、新失败。真实入口是否存在这种行为尚未知。
另外,HTTP User-Agent 从 `MinIO` 变成了 `Silo`;若连接在 TLS 完成、GET 发出之后被重置,应优先查 WAF、User-Agent 规则和 HTTP 路由,而不是继续调整 TLS。
最短路径:**先在故障进程所在环境拿到实际 Go 版本、是否设置 `tlsmlkem=0`、目标 IP 和 TLS 完成与否;再针对已证实的分支处理。** 优先修正入口兼容性或错误路由。若确认是上述环境选项失效,只为 OpenID 出站请求恢复 Go 默认曲线选择,是目前最小的代码候选。现阶段不宜做全局 TLS 改动或整体依赖回退。
## 第二轮结论:已隔离 Go 因素,局部候选修复通过 Linux 验证
**可以复现一种由 Go 1.26.5 → 1.27.1 单独触发的兼容性回归。建议保留 Go、针对已确认分支修复;整体回退只用于临时恢复服务。** 这里的“已确认”指本地实验机制,仍不等于已经确认客户入口的根因。
四个完整 Server 都从本地源码编译为 `linux/arm64`,在现成通用 Debian 12 基础镜像的 `--network none` 容器中运行;Server、合成 IdP、Console 和 curl 共用同一 loopback 网络,没有暴露端口。没有下载或运行 Server/Console 镜像。旧源码为 `d88f46ccee345a9c2fabe2d221d9a9e56bc11aec`,当前源码为 `d1105bbb3d4a0afa33b3a4ac11b821235038ed0e`
旧源码两次构建使用完全相同的 `go.mod``go.sum` 和实际链接模块版本,仅替换编译器;当前源码与候选补丁构建的依赖图也完全相同。实际二进制 SHA-256、`go version -m` 依赖和构建选项保存在 [Linux 证据](issue-154/linux-evidence.json)。
下表的入口**人为设置为见到 X25519MLKEM768 就发 TCP RST**Server 都设置 `GODEBUG=tlsmlkem=0`
| 源码与编译器 | Server 初始 ClientHello | 完整 Server 结果 | 同容器 curl |
| --- | --- | --- | --- |
| 同一份旧源码 + Go 1.26.5 | 275 字节,无 ML-KEM | discovery、JWKS、IAM、Console 正常;cluster 200 | HTTP/2 200 |
| 同一份旧源码 + Go 1.27.1 | 1509 字节,仍包含 ML-KEM | `connection reset by peer`IAM 等待;cluster 503Console 未启动 | HTTP/2 200 |
| 当前源码 + Go 1.27.1 | 1509 字节,仍包含 ML-KEM | 同样失败 | HTTP/2 200 |
| 当前源码 + 局部候选补丁 + Go 1.27.1 | 287 字节,无 ML-KEM | 完整启动及合成 OIDC 登录成功 | HTTP/2 200 |
这将该条件下的回归定位到工具链行为,而非 Console、pkg、mc 或 `coreos/go-oidc` 升级。Go 1.27 的发布说明明确将此作为有意改变:`tlsmlkem` / `tlssecpmlkem` 只控制默认曲线集合,显式指定的集合可以继续启用这些算法。SILO 现有曲线列表显式包含该算法,因而原来的兼容开关在这条路径失效。[Go 1.27 crypto/tls 说明](https://go.dev/doc/go1.27#crypto/tls)。
共完成 **12 个 Linux 场景**,其中失败用例按预期失败:
- 正常 TLS 1.2 / P-256 / RSA / AES-256-GCM 的 IdP,旧源码用两个 Go 版本编译均正常,证明不是 Go 1.27 普遍无法连接这套 TLS。
- 上表四个对照均符合预期;候选补丁如果不设置 `tlsmlkem=0`,仍被 ML-KEM 拒绝规则拦截。补丁恢复显式兼容选项的作用,不会自行关闭后量子算法。
- 候选补丁在上述 TLS 1.2 兼容场景、以及无 GODEBUG 的正常 TLS 1.3 场景,都完成 Console 登录信息获取 → IdP authorization redirect → callback → token exchange → STS 凭据 → Console 会话 → 桶列表读取。登录 API 为 204,桶列表为 200。
- 两个登录场景分别提交错误签名与错误 audience 的 JWT,登录返回 500、桶列表返回 403,没有生成会话 cookie。这里只确认认证未放行,没有将现有 500 状态码行为改为另一项修复。
- 不信任 CA 时,候选 Server 仍因 `x509: certificate signed by unknown authority` 停在 IAM 初始化。没有关闭证书校验。
- 不配置 OIDC 启动后,经实际 Admin API 添加同一合成 provider:当前源码失败且返回 reset;候选补丁成功。
- 入口改为拒绝 ML-DSA 签名编号,候选补丁加 `tlsmlkem=0` 仍失败。这是另一条机制,当前补丁没有解决它。
认证流程由 Python 驱动真实 Console HTTP API;IdP 使用一次性合成授权码和自行签发的实验 JWT,没有客户账户。没有执行浏览器页面交互、登出、真实 Keycloak 或生产数据升级测试。TLS 1.3 对照实际协商 TLS 1.3/P-256,不是所有新增后量子曲线的互操作覆盖。
### 修复与回退的取舍
[候选补丁](issue-154/openid-default-curves.patch) 只有三个文件:增加 OpenID 专用 transport helper,将其 `TLSClientConfig.CurvePreferences` 设为 `nil`,再替换 IAM 初始化和 OpenID 配置校验两个调用点。测试时仅应用于隔离源码副本,当前产品工作区未应用;Go 版本与所有依赖保持不变。它让 Go 默认策略和现有兼容开关接管外部 IdP 的密钥交换,其他 TLS 参数继续来自现有构造函数。
如果客户证据确认此分支,建议采用该局部修复,并在客户实际入口复验。如果客户没有设置 `tlsmlkem=0`,它不能单独解释旧版成功:旧版默认也发送 ML-KEM。此时应继续区分 ML-DSA、其他握手变化、HTTP/WAF 规则与网络路径;不把此补丁直接宣称为 #154 的完整修复。
**不建议将当前产品直接改回 Go 1.26。** 实测以 Go 1.26.7 和 `GOTOOLCHAIN=local` 读取当前源码即被 `go.mod requires go >= 1.27.1` 拒绝;所固定的 Server、Console、mc 均声明 Go 1.27.1。回退需要进一步调整这些模块及可能的传递依赖,不是只换一个编译器版本。这个报错证明当前依赖图不能原样回编,并不证明经过额外适配后绝对无法回编。报告中已恢复服务的旧版可作为临时运行状态,不能把这种处置等同于完成兼容修复。
第二轮的运行脚本是 [run-linux.py](issue-154/run-linux.py),结果是 [linux-evidence.json](issue-154/linux-evidence.json),具体构建和运行步骤见文末。没有 issue 评论、提交、推送、合并或发布。
候选副本另通过 Go 1.27.1、darwin/arm64、CGO 关闭的 `go test -mod=readonly -count=1 ./internal/http ./internal/config/identity/openid`;补丁可以干净应用到调查基线。这些包级测试与上述 Linux 集成验证分别记录,不将其当成 Linux 单元测试结果。
## 范围、版本与公开证据
- 初始工作区干净,处于 detached HEAD;调查分支为 `codex/investigate-oidc-154`,基线与远端 main 均为 `d1105bbb3d4a0afa33b3a4ac11b821235038ed0e`
- 已读取 `/Users/vonng/pgsty/silo/AGENTS.md` 及工作区适用说明。维护范围为 PGSTY 的 Server、Console、mc、silo-pkg;上游 MinIO 仅作参考。
- 第一轮 Server 与 transport 探针为本地源码构建的 darwin/arm64 程序,第二轮完整 Server 交叉构建为 linux/arm64;均使用 `CGO_ENABLED=0 GOWORK=off`。fixture 与 Admin 辅助程序也由本机 Go 构建。历史源码使用 `git archive` 导入隔离临时目录。没有下载或运行 Server/Console Docker 镜像,没有访问客户端点,没有改动真实服务或数据,没有评论 issue、推送、合并或发布。
- 产品源码、`go.mod``go.sum` 未修改。本目录中的 Go 文件是显式运行的调查工具,带 `//go:build ignore`,不进入正常构建。
| 项目 | 旧版:2026-08-04 | 报告故障版:2026-09-03 | 调查时 main |
| --- | --- | --- | --- |
| 完整 tag | `RELEASE.2026-08-04T00-00-00Z` | `RELEASE.2026-09-03T13-18-01Z` | 无新 release 声明 |
| 源码 commit | `d88f46ccee345a9c2fabe2d221d9a9e56bc11aec` | `9b11dc9469e650815b775cb47b039610644f5da4` | `d1105bbb3d4a0afa33b3a4ac11b821235038ed0e` |
| go.mod / Docker 构建定义 | Go 1.26.5 | Go 1.27.1 | Go 1.27.1 |
| 本地 Server / 探针实际编译器 | Go 1.26.5 | Go 1.27.1 | Go 1.27.1 |
| Console replacement | `v0.0.0-20260804042150-b952a1202869` | `v0.0.0-20260903111932-464a59d73ada` | `v0.0.0-20260908142700-c103d08ec36a` |
| mc replacement | `v0.0.0-20260801042411-ad10a2a10b76` | `v0.0.0-20260903063637-a2ef95c035d9` | `v0.0.0-20260909015522-fcd5cad8247f` |
| PGSTY silo-pkg | v3.11.0,替换历史 minio/pkg 路径 | v3.13.2,直接依赖 | v3.13.3,直接依赖 |
| coreos/go-oidc/v3 | v3.17.0 | v3.21.0 | v3.21.0 |
| x/crypto | v0.54.0 | v0.56.0 | v0.56.0 |
| x/net | v0.57.0 | v0.58.0 | v0.58.0 |
| x/oauth2 | v0.36.0 | v0.36.0 | v0.36.0 |
版本依据:[旧版 go.mod](https://github.com/pgsty/silo/blob/d88f46ccee345a9c2fabe2d221d9a9e56bc11aec/go.mod)、[故障版 go.mod](https://github.com/pgsty/silo/blob/9b11dc9469e650815b775cb47b039610644f5da4/go.mod)、[本次 main go.mod](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/go.mod)。还核对了各 tag 的 `Dockerfile.goreleaser` 和 release workflow。它们声明相应 Go 构建版本、禁用 CGO;历史归档、本次探针和 Server 均用 `go version -m` 核实实际编译器与 replacement。**这些不是客户实际镜像 digest 或其内二进制的取证,后者仍需 `--version` / build info 确认。**
[Issue #154](https://github.com/pgsty/silo/issues/154) 当前 OPEN,最后更新时间 `2026-09-08T05:30:42Z`,评论数为 0。报告包含升级后 Server 初始化失败、旧版回退恢复、测试实例添加 OIDC 失败,以及 curl 成功的输出。
curl 那次连接验证了所收到的证书链,并选择 TLS 1.2、`ECDHE-RSA-AES256-GCM-SHA384`、P-256 和 HTTP/2;解析得到两个 IPv4,记录中实际访问了其中一个。**公开命令使用 `docker run --rm` 新建容器,并非 `docker exec` 进入原故障容器;同镜像不能证明同网络命名空间、环境变量、CA 挂载、DNS 结果或出口。** 域名、realm、IP 已脱敏,本次不推测其真实值。
## 实际请求链
### IAM 与配置校验
```text
Server startup
IAMSys.Init
openid.LookupConfig
parseDiscoveryDoc: GET .well-known/openid-configuration
PopulatePublicKey: GET discovery 中的 jwks_uri
IAM store 初始化
Console 初始化
Console 添加 OIDC
AdminClient.AddOrUpdateIDPConfig
Server addOrUpdateIDPHandler
validateConfig(identity_openid)
同一个 openid.LookupConfig / NewHTTPTransport
```
`parseDiscoveryDoc` 是 SILO 自己的实现,用标准库 `http.Client` 发 GET,收到成功响应后才解码 JSON;这次日志中的 `Get ... read tcp ... reset` 发生在该调用返回响应之前,不能进一步区分 TLS 与 HTTP。请求本身不需要客户 client secret、token 或私钥。[IAM 调用点](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/iam.go#L278-L288)、[discovery 实现](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/internal/config/identity/openid/jwt.go#L261-L285)、[JWKS 实现](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/internal/config/identity/openid/jwt.go#L89-L110)。
Console 的添加表单通过 Admin API 触发 Server 配置校验。校验成功才写入配置;本地已重现该 API 返回同样的 reset,恢复 IdP 后再次创建成功并要求重启。[Console 调用](https://github.com/pgsty/silo-console/blob/c103d08ec36a/api/admin_idp.go#L79-L114)、[Admin 客户端](https://github.com/pgsty/silo-console/blob/c103d08ec36a/api/client-admin.go#L593-L595)、[Server 校验和保存边界](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/admin-handlers-idp-config.go#L127-L150)、[OpenID 配置校验](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/config-current.go#L353-L359)。
`coreos/go-oidc.NewProvider` 在 Server 中的使用是 `MockOpenIDTestUserInteraction` 测试辅助函数,不是这次 IAM discovery 调用链。升级此依赖不能单独解释或修复该 GET。`crypto/tls` 和这里的 `net/http` 属于 Go 标准库,不能用 go.mod 中 `x/crypto``x/net` 的版本代替它们的实际行为。[辅助函数](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/utils.go#L994-L1012)。
### transport 参数与环境
| 项目 | 实际行为及意义 |
| --- | --- |
| 构造 | `NewHTTPTransport()``NewHTTPTransportWithTimeout(time.Minute)``xhttp.ConnSettings`。每次 IAM 初始化重试都会重新构造。 |
| TLS 版本 | 未显式设置 Min/Max;所比较 Go 工具链的正常默认范围是 TLS 1.2–1.3。证书、主机名验证开启。 |
| 密码套件 | 显式 `TLSCiphersBackwardCompatible()`,包含 curl 成功使用的 ECDHE-RSA/AES-256-GCM。没有理由为本 issue 添加旧 RSA、3DES 或 SHA-1 例外。 |
| 曲线 | 显式 `{X25519MLKEM768, P256, X25519, P384, P521}`,两个 release 和当前 main 相同。实际上线顺序还受 Go 实现控制,见实验。 |
| ALPN / HTTP | `EnableHTTP2=false`,同时设置自定义 TLS config 和 DialContext;实测 ClientHello 没有 ALPNGET 使用 HTTP/1.1。三个版本一致。`GODEBUG=http2client=0` 对此基线路径无修复价值。 |
| 代理 | `http.ProxyFromEnvironment`HTTPS URL 使用 `HTTPS_PROXY` / `https_proxy``NO_PROXY` / `no_proxy`。这两版标准库均优先非空大写值,不使用 `ALL_PROXY`;设置在进程内缓存。不能根据 curl 的路由推断它。注意 go.mod 的 x/net v0.58.0 代码不是这里所用的标准库 vendored 实现。 |
| DNS | `globalDNSCache.LookupHost`dnscache v0.1.1;默认刷新窗口在容器/Kubernetes 为 30 秒,其他环境为 10 分钟,可配置。按返回地址逐个尝试 TCP,首次 TCP 成功就返回;随后 TLS/HTTP 失败不会回到这个循环尝试另一 IP。代码注释称随机选择,但该循环没有 shuffle。 |
| Linux TCP 参数 | 使用 SILO 的自定义 dialer,包含 TCP fast open/keepalive 等设置,部分参数来自 Server CLI,包括 interface、buffer、user timeout。第二轮执行了完整 Linux Server 的正常 CLI 初始化,但只验证 loopback;不能排除实际出口设备、路由或非默认 CLI 参数的交互。 |
| CA | `silo-pkg/certs.GetRootCAs` 加载系统根、Kubernetes CA 目录和 `certs/CAs`;Server 还加入自己的公开服务证书。相关 pkg CA 加载实现未在这次版本比较中变化;实际文件和路径仍可能因部署变化不同。 |
| 超时 | TCP 拨号 5 秒,TLS 握手 10 秒,响应头 1 分钟;discovery/JWKS 的 Client 没有总超时,discovery 使用的 Request 也没有调用方 context。响应体停滞不受响应头超时保护。 |
| 复用 | keep-alive 开启,idle 15 秒,TLS session cache 100。一次初始化内 discovery 与 JWKS 可复用连接;初始化重试的新 transport 没有旧连接或 session。持续首次握手失败不能用清理空闲连接解释。 |
| 请求标识 | UA 包含产品、OS、架构、模式和构建信息,产品名从 `MinIO` 变为 `Silo`;传输层禁用自动压缩。UA 规则只能在 HTTPS 被终止、HTTP 请求可见之后起作用。 |
源码:[构造与超时](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/utils.go#L651-L670)、[HTTP/TLS 参数](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/internal/http/transports.go#L44-L94)、[密码套件与曲线](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/internal/crypto/crypto.go#L52-L78)、[DNS 拨号循环](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/internal/http/dial_dnscache.go#L42-L84)、[缓存刷新](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/common-main.go#L551-L578)、[CA 加载](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/server-main.go#L383-L395)、[UA](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/update.go#L228-L266)。
比较旧 tag 与故障 tagOpenID discovery、transport、曲线实现的变动仅为 pkg 导入路径调整;IAM 另有品牌日志变更。比较故障 tag 与本次 main,这些关键实现没有变动。因此不能把当前依赖推进当成 #154 已解决的证据。
**Console 登录阶段是另一条出站路径。** 当前及故障版 Console 通过 `GetConsoleHTTPClient` / `GlobalTransport` 再获取 discovery、交换令牌,TLS config 未指定 CurvePreferences,仍做完整证书验证;不能把“添加配置走 Server”推广为所有 Console OIDC 请求都走 Server transport。任何修复最终都必须验证完整登录。`CONSOLE_MINIO_SERVER_TLS_SKIP_VERIFY` 只针对 SILO 端点,不能作为 IdP 修复。[Console transport](https://github.com/pgsty/silo-console/blob/c103d08ec36a/api/config.go#L68-L121)、[IdP 客户端](https://github.com/pgsty/silo-console/blob/c103d08ec36a/api/tls.go#L59-L70)、[Console discovery](https://github.com/pgsty/silo-console/blob/c103d08ec36a/pkg/auth/idp/oauth2/provider.go#L428-L449)。
## 本地实验与能排除的假设
工具与原始结果放在 [issue-154/](issue-154/)
- `probe.go` 直接调用 `cmd.NewHTTPTransport()`,用同版本 `certs.GetRootCAs` 装入实验 CA;记录 TCP、TLS、HTTP 阶段。诊断参数仅修改被比较的一项。
- `fixture.go` 仅监听 IPv4 loopback;使用即时生成的 RSA 测试证书与专用 CA,提供 discovery 和有效 JWKS。正常基线限制 TLS 1.2、P-256、`TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384`,支持 HTTP/1.1 和 HTTP/2。
- `admin-check.go` 仅允许 loopback Server,验证 Console 使用的 Admin API;使用本地虚构配置和临时凭据。
- `evidence.json` 保存版本、握手参数、分阶段 trace 和完整 Server 实验结果;无客户信息、私钥或 token。
- 第二轮扩展 fixture 的合成授权码、token 和 JWKS 流程;`run-linux.py` 驱动 Linux 完整 Server 及 Console API`linux-evidence.json` 保存十二组场景和构建身份。敏感运行值不写入结果。
### 正常服务及握手差异
三个源码版本的实际 transport 都能通过完整证书验证,以 TLS 1.2 / AES-256-GCM / HTTP/1.1 获取测试文档。**Go 1.27 本身并非不能连接 TLS 1.2、RSA 证书或这套密码套件。** 正常服务也接受诊断性的 HTTP/2。
| 构建与选项 | fixture 读到的初始握手字节数 | 支持的 group ID | 新增 ML-DSA 签名编号 |
| --- | ---: | --- | --- |
| 旧源码 + Go 1.26.5,默认 | 1497 | 4588, 29, 23, 24, 25 | 无 |
| 故障源码 + Go 1.27.1,默认 | 1509 | 同上 | 0x0904, 0x0905, 0x0906 |
| 当前源码 + Go 1.27.1,默认 | 1509 | 同上 | 同上 |
| 旧源码 + Go 1.26.5`tlsmlkem=0` | 275 | 29, 23, 24, 25 | 无 |
| 故障/当前源码 + Go 1.27.1`tlsmlkem=0` | 1509 | 4588, 29, 23, 24, 25 | 有 |
| 旧源码、旧依赖,只改为 Go 1.27.1 | 1509 | 4588, 29, 23, 24, 25 | 有;`tlsmlkem=0` 也不再移除 4588 |
4588 是 X25519MLKEM76829 是 X2551923/24/25 是 P-256/384/521。这些字节数包含本地 fixture 收到的 TLS record,测试 URL 是 IP,没有 DNS SNI;不能直接当成客户网络中的包长或 MTU 证据。默认新旧差异约 12 字节,没有证据支持“本次才突然出现巨大 ML-KEM 握手”的说法。设置上述环境选项时的差异则显著不同。
旧源码保留旧依赖、仅更换 Go 编译器后,行为随编译器变化,隔离了本次发现与 silo-pkg/Console 版本更新之间的关系。Go 1.27 官方说明也明确记录显式曲线配置不再受这些默认值开关限制,并新增 ML-DSA 支持。[Go 1.27 发布说明](https://go.dev/doc/go1.27)。本地进一步核对了两版 `crypto/tls/defaults.go``common.go``curvePreferences`/`supportsCurve``handshake_client.go`
### 主动拒绝与对照结果
下列拒绝规则是人为设置的模型,**只证明机制可以产生相同症状,不证明真实 Keycloak 或其入口使用这些规则**。
| fixture 规则 | 对照结果 | 能支持的结论 |
| --- | --- | --- |
| ClientHello 带 ML-KEM 就发送 TCP RST | 旧版默认也失败;旧版 + `tlsmlkem=0` 成功;故障版 + 同选项失败;故障版显式 classical 曲线成功 | 需要旧环境选项或其他变化,才能用该机制解释升级回归。仅说 ML-KEM 不兼容不充分。 |
| ClientHello 带 ML-DSA 编号就 RST | 旧版默认成功;故障版默认/仅 classical 都失败;故障版 TLS 1.2-only 成功 | 新的签名算法列表是另一种可区分机制。ML-DSA 与 ML-KEM 不是同一项。TLS 1.2-only 会同时改变多项 ClientHello,成功不等于唯一定位 ML-DSA。 |
| 必须提供 h2 ALPN | 旧、新 Server transport 默认都失败;`-h2` 成功 | 可以解释 curl 与 Server 的不同,单独不能解释新旧版本差异。 |
| TLS 完成后,对 `Silo` UA 的 GET 发送 RST | 同一个新 transport`MinIO` UA 成功、`Silo` UA 失败 | 报告的外层 `Get ... reset` 错误也可能来自 HTTP 层,必须先判断 TLS 是否完成。 |
| 不信任测试 CA | 返回 `*tls.CertificateVerificationError` / x509 类错误 | 与人为 RST 的错误不同;没有证据要求跳过证书验证。仍须比较客户实际连接收到的链。 |
| 正常连接复用 | 第二个请求 `reused=true`;关闭 idle 连接后重新建 TCP 可恢复 TLS session | keep-alive 与 TLS session 复用是两件事。首次新 transport 就失败时,此分支优先级低。 |
### 完整 Server、IAM 和恢复
使用三个本地编译的完整 Server,独立空数据/配置目录、独立 CA 和临时凭据;未登录真实 IdP。
| 场景 | 实际结果 |
| --- | --- |
| 旧版、故障版连接正常 IdP | discovery + JWKS 成功;一条 TLS 连接;cluster health 200Console HTTP 200Admin ListUsers 成功。 |
| 当前版连接持续 reset 的 IdP | IAM 持续等待;cluster health 503`X-Minio-Server-Status: iam-offline`;Console 端口尚未提供服务;5 秒限时的 Admin ListUsers 未完成。 |
| 上述场景恢复 IdP,不重启 Server | 约 0.43 秒后 cluster/Console 200ListUsers 成功。该时间是单次本地样本,不是恢复 SLA。 |
| 旧版 + `tlsmlkem=0`IdP 拒绝 ML-KEM | 完整启动成功。 |
| 故障版 + `tlsmlkem=0`,相同拒绝规则 | IAM 等待、cluster 503;撤掉规则后约 1.38 秒内完整恢复,无需重启。 |
| 当前版 discovery 成功、JWKS 返回 503 | 同样阻塞 IAMJWKS 恢复后约 0.33 秒内恢复。只验证 discovery 200 不够。 |
| 当前版先不配置 OIDC,再经 Admin API 添加 | IdP reset 时返回同型 `Get ... read tcp ... connection reset by peer`;恢复后同名创建成功,`restart=true`。失败校验没有保存该 provider。 |
在本地 IAM 阻塞的场景中,`/minio/health/live``/minio/health/ready` **仍为 200**。判断这次恢复应使用 `/minio/health/cluster` 并验证受认证操作和 Console,不能只看 ready。源码上 cluster 的 `checkHealth` 检查 IAM,而 ready 没有此检查;这是已有行为,本文不扩展为一次健康检查重构。[health 检查](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/healthcheck-handler.go#L32-L65)、[ready 检查](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/healthcheck-handler.go#L129-L186)。
现有 IAM 重试间隔随机为 0–3 秒,GET 本身还会占用网络等待时间;初始化成功前不会继续创建 IAM storeConsole 又在 IAM 调用返回后启动。外部连接恢复可由现有重试自行恢复;这不意味着靠增加重试能修复持续不兼容。缺少整体请求期限、请求取消的部分是另外一个可单独修复的启动健壮性问题。[重试逻辑](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/iam.go#L342-L365)、[Console 启动顺序](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/server-main.go#L1007-L1027)。
已通过:`CGO_ENABLED=0 GOWORK=off go test -mod=readonly -count=1 ./internal/http ./internal/config/identity/openid`。这些测试没有替代真实 Keycloak 登录或 Linux 故障网络的验证。
## 最少补充证据与诊断命令
先收集第一轮,按结果才展开后续。所有请求只取公开 discovery,不需要客户端密钥、密码、token 或私钥。不要求环境变量全量导出、配置导出或证书私钥。
### 第一轮:运行身份、网络一致性、协议阶段
在**已有故障容器/Pod 的实际网络环境**中运行;旧版也做同样检查。不要以新建默认网络容器替代。如果实际进程经启动脚本修改过环境,探针也应使用修改后的相关环境和同一 CA 挂载。
```sh
# 选择实际 Server 可执行文件;记录输出中的版本、Go、OS/架构。
silo --version
# 旧镜像的程序名可能是 minio。
# LinuxPID 设为实际 Server 进程 PID,不预设一定是 1。
# 只输出 Go 调试选项以及相关环境项是否存在,不输出代理凭据/地址。
tr '\0' '\n' < "/proc/$PID/environ" | awk '
/^GODEBUG=/ { print; next }
/^(HTTP_PROXY|HTTPS_PROXY|NO_PROXY|ALL_PROXY|http_proxy|https_proxy|no_proxy|all_proxy|SSL_CERT_FILE|SSL_CERT_DIR)=/ {
split($0, a, "="); print a[1] "=<set>"
}'
# OIDC_URL 仅在本地设为原 config_url;URL 不应带凭据或令牌。
export OIDC_URL
curl --http1.1 --connect-timeout 5 --max-time 20 -sS -o /dev/null \
-w 'ip=%{remote_ip} http=%{http_version} status=%{http_code} verify=%{ssl_verify_result}\n' "$OIDC_URL"
curl --http2 --connect-timeout 5 --max-time 20 -sS -o /dev/null \
-w 'ip=%{remote_ip} http=%{http_version} status=%{http_code} verify=%{ssl_verify_result}\n' "$OIDC_URL"
# 由维护者从对应源码构建的探针;OIDC_CA 是与 Server 相同的 CAs 目录。
./oidc-probe -ca "$OIDC_CA"
```
探针从 `OIDC_URL` 读取 URL。URL、响应体、请求头不会打印;输出的 IP 可按一致映射替换为 IP-A/IP-B。它使用真实 Server transport 构造函数,但添加 20 秒总期限、不跟随重定向、限制响应体读取为 1 MiB,并使用 `issue-154-probe` UA;因此是定位连接阶段的工具,不是完整 OIDC 流程。它没有执行 Server 的 CLI 初始化,也不继承该进程的 `--interface`、socket buffer、TCP user timeout 或存量 DNS 缓存;若使用这些非默认参数,必须对齐后才能归因。若 Server 将自己的公开服务证书也作为根信任,需要把相同公开证书加入探针的临时 CA 目录。
读结果的方法:
- `tls_start``tls_done ... err=reset`:先查 TLS ClientHello、代理 CONNECT 或 TLS 终止设备;尚不能从客户端确定 RST 由终端还是中间设备发出。
- `tls_done ... err=none``wrote_request` 后 reset:检查 HTTP/WAF/UA/入口路由。此时更换证书信任或密钥交换没有针对性。
- 出现 x509 类错误:比较该进程实际收到的证书链、SNI、系统 CA 和自定义 CA;保持验证开启。
- 两次 curl 的 IP 不同,或探针目标与 curl 不同:先做同 IP 对照。curl HTTP/1.1 成功也不代表 Go ClientHello 相同。
- 仅 curl h2 成功:确认实际协商的 `http_version` 是 2,再检查入口 HTTP/1.1 支持;不要先全局启用 Server HTTP/2。
### 只对命中的分支做 A/B
```sh
# TLS 阶段失败:仅移除 hybrid key exchange,仍支持 TLS 1.3 和验证证书。
./oidc-probe -ca "$OIDC_CA" -classical
# 如果旧进程确实使用 tlsmlkem=0,验证最小候选是否恢复其效果。
# 应保留原 GODEBUG 的其他相关选项;以下假定没有需要保留的其他值。
GODEBUG=tlsmlkem=0 ./oidc-probe -ca "$OIDC_CA" -default-curves
# 只有 classical 仍失败时,作为鉴别实验测试 TLS 1.2-only。
./oidc-probe -ca "$OIDC_CA" -tls12
# 只有 HTTP/ALPN 对照指向此分支时才测试。
./oidc-probe -ca "$OIDC_CA" -h2
# HTTP 阶段才失败:UA_OLD / UA_NEW 是两版实际请求的完整 UA,非密钥。
./oidc-probe -ca "$OIDC_CA" -ua "$UA_OLD"
./oidc-probe -ca "$OIDC_CA" -ua "$UA_NEW"
```
若 first-hop 使用代理,先比较两进程的代理选择和 `NO_PROXY`,不公开含密码的代理 URL。只在明确允许直连的部署中使用 `-direct`。确认直连后,可针对每个 DNS 地址运行以下两项,保留原 URL 主机名与 SNI,不能直接把 HTTPS URL 改成 IP
```sh
curl --noproxy '*' --resolve "$OIDC_HOST:443:$IP_A" --http1.1 \
--connect-timeout 5 --max-time 20 -sS -o /dev/null \
-w 'ip=%{remote_ip} status=%{http_code} verify=%{ssl_verify_result}\n' "$OIDC_URL"
./oidc-probe -ca "$OIDC_CA" -direct -ip "$IP_A"
# 对 IP-B 重复;端口不是 443 时据实修改 --resolve。
```
仅第二次请求失败时才补 `-n 2``-n 2 -fresh`;后者重建 TCP,但同一 transport 的 TLS session cache 仍保留。若仍无法区分,下一步要的是入口侧同一时间窗口的握手失败原因/命中规则,或由客户自行脱敏后的 ClientHello 参数和 RST 阶段,不是完整认证流量包。
## 条件性最小修复方案
### 1. 路由、代理或入口策略差异已证实
优先统一有问题的 IdP 入口、修正具体域名的代理/NO_PROXY 或后端节点配置;更新错误拒绝合法 ClientHello 的 TLS 终止器/WAF。证书仍按原 hostname 和有效 CA 验证,OIDC issuer 不随意改名。若是 `Silo` UA 被规则拒绝,调整该规则;不把全产品 UA 改回 MinIO。
这是配置层处理,可以不改 SILO。工作量取决于入口归属;成功标准是原版故障二进制在原网络环境中完成 discovery、JWKS 和完整登录,而非仅 curl 200。
### 2. 证实旧环境依赖 `tlsmlkem=0`,且 classical / default-curves 对照成功
最小代码候选是为外部 OpenID 请求使用 Go 的默认曲线集合,恢复已有 GODEBUG 选项的效果;保留当前 trust pool、SNI、密码套件、超时、代理及 HTTP/1.1 行为。候选代码如下,**已在隔离副本完成上述验证,尚未应用到产品工作区**:
```go
func NewOpenIDHTTPTransport() *http.Transport {
tr := NewHTTPTransport()
tr.TLSClientConfig.CurvePreferences = nil
return tr
}
```
当时的候选只将 `cmd/iam.go``cmd/config-current.go` 两处 OpenID 调用改用该 helper,通过现有 UA wrapper 传入 `openid.LookupConfig`。后续排查确认节点互联、复制、远端存储等连接也存在同样的问题,此局部方案已被 [SILO 跨组件修复](go127-stack.md) 取代;请勿再应用下面归档的 OIDC-only 补丁。
候选已在真实 transport 探针及第二轮完整 Linux Server 中验证:`CurvePreferences=nil` + Go 1.27.1 + `tlsmlkem=0` 会移除 ML-KEM,并通过 `reject-mlkem` fixture、配置添加和合成登录。它仍不能通过 `reject-mldsa` fixture,说明此方案针对的是一个明确分支。
影响需要明确:
- 不设置 GODEBUG 时会采用完整 Go 默认集合,实测额外提供 SecP256r1MLKEM768 和 SecP384r1MLKEM1024;这也是一项兼容性变化。第二轮默认 TLS 1.3 登录通过,仍不能称为完全无行为变化或所有曲线均已覆盖。
- `GODEBUG` 是进程级设置,其他使用 Go 默认曲线的客户端也可能受到影响;本次 helper 的改动范围是 OpenID,但该环境选项本身不是逐 provider 开关。
- 禁用 hybrid 后仍有标准 ECDHE/TLS 和证书验证,失去的是相应后量子密钥交换保护。优先修正入口,临时兼容设置应有撤销条件;不能自动遇到 reset 就降级重试。
- 当前 Console IdP transport 本来使用默认曲线,同一进程中的该 GODEBUG 策略可以与之保持一致;第二轮合成登录链路已经验证,真实 IdP 和浏览器验收仍待进行。
- 隔离源码副本中的候选实现及上述本地验证已完成;真实端点 A/B 证据、生产适用性和发布是尚未完成的步骤。
### 3. 未设置上述选项,或 classical 仍失败
不要套用方案 2。若同 IP、同代理、同 UA 下仅 Go 1.27 失败,优先收集入口对新签名算法/扩展的处理证据。`-tls12` 成功只能缩小到 ClientHello/TLS 特征集合;它同时移除 hybrid key share 和 TLS 1.3 特有签名编号,不能唯一证明 ML-DSA。
优先更新入口或获得 Go 上游可复现用例。只有真实证据证明 TLS 1.2 兼容模式是必要且有效、入口又短期无法处理时,才评估**明确限定于该 IdP**的临时 TLS 1.2 选项,并保留现代 ECDHE/AEAD 和证书验证。不要把 `MaxVersion=TLS12` 全局写死,不引入自定义 ClientHello/TLS 栈或未受支持的“关闭 ML-DSA”环境选项。该分支尚不足以确定补丁或工期。
### 4. 启动健壮性可单独改进
如需小幅改善可诊断性,应单独给 discovery/JWKS 加明确阶段标识和有限总请求期限,让 IAM 重试可感知取消;在请求完成前发生 stall 时及时释放资源。日志避免输出 client secret/token,错误保留原始 cause。保留已配置 OIDC 的失败关闭行为及 IdP 恢复后的自动初始化,不自动关闭 OIDC,也不把无限重试包装成根因修复。
这类改动约 0.5–1 个工程日,可分别回归 stalled body、取消、重试后恢复;它不会使持续 RST 的 TLS 连接成功,不应替代前面鉴别。
## 临时处置与验收边界
当前可沿用报告中已经恢复服务的旧版回退状态,尽快完成上述定位。不要将旧版本长期保留视为解决方案,也不要为了它整体降级新版本依赖。本次空数据实验不证明任意生产数据/配置的降级兼容;再次切换版本前应沿既有备份和升级边界操作。
修复应按以下范围验证,均从 PGSTY 源码本地构建:
1. 对确认的分支增加最小回归:实际 OpenID transport、匹配的 ClientHello/入口拒绝条件、默认设置与明确 opt-out;确保通用和 internode transport 不变。
2. TLS 1.2(报告密码套件)与 TLS 1.3、有效自定义 CA、错误 CA 和 hostname 拒绝;适用时覆盖代理及多地址入口。不放宽证书、JWT 签名或 audience 等认证校验。
3. 完整 Server 的 discovery 与 JWKS、IdP 中断后恢复、cluster health、受认证 Admin 操作;Console 添加配置、浏览器重定向、回调、令牌交换、STS 授权和登出。mc 添加/读取配置路径应一致。
4. 在实际 Linux 容器/Pod 网络和每个 IdP 入口地址复验。证据应绑定最终 Server/Console/pkg/mc commit 和编译器;发布、镜像及生产可用性属于后续独立验收。
第二轮完成 Linux loopback 上的合成 OAuth token/STS 登录;本次没有真实 Keycloak、真实 Linux 故障路径、浏览器页面或生产数据升级测试。当前 main 对正常 fixture 成功,并不能据此宣告 #154 修复。下一项最有价值的新增证据是**实际进程的 `tlsmlkem` 设置和一个带 TLS 完成事件的同环境 GET trace**。
## 复现实验
从本任务 worktree 根目录执行;工具均使用本地源码,输出目录必须是新建临时目录。
```sh
LAB_DIR=$(mktemp -d)
CGO_ENABLED=0 GOWORK=off go build -mod=readonly -tags kqueue \
-o "$LAB_DIR/silo" .
CGO_ENABLED=0 GOWORK=off go build -mod=readonly -tags kqueue \
-o "$LAB_DIR/probe" docs/investigations/issue-154/probe.go
go build -o "$LAB_DIR/fixture" docs/investigations/issue-154/fixture.go
go build -mod=readonly -o "$LAB_DIR/admin-check" docs/investigations/issue-154/admin-check.go
"$LAB_DIR/fixture" -dir "$LAB_DIR/idp" > "$LAB_DIR/hello.jsonl" 2> "$LAB_DIR/fixture.log" &
FIXTURE_PID=$!
trap 'kill "$FIXTURE_PID" 2>/dev/null || true' EXIT
attempt=0
while [ ! -s "$LAB_DIR/idp/url" ] && [ "$attempt" -lt 50 ]; do
sleep 0.1
attempt=$((attempt + 1))
done
test -s "$LAB_DIR/idp/url" || exit 1
# 私钥仅保存在 fixture 内存。
OIDC_URL="$(cat "$LAB_DIR/idp/url")/.well-known/openid-configuration"
export OIDC_URL
"$LAB_DIR/probe" -ca "$LAB_DIR/idp/ca.pem"
printf '%s' reject-mlkem > "$LAB_DIR/idp/mode"
GODEBUG=tlsmlkem=0 "$LAB_DIR/probe" -ca "$LAB_DIR/idp/ca.pem"
GODEBUG=tlsmlkem=0 "$LAB_DIR/probe" -ca "$LAB_DIR/idp/ca.pem" -default-curves
printf '%s' reject-mldsa > "$LAB_DIR/idp/mode"
"$LAB_DIR/probe" -ca "$LAB_DIR/idp/ca.pem" -classical
"$LAB_DIR/probe" -ca "$LAB_DIR/idp/ca.pem" -tls12
kill "$FIXTURE_PID"
```
完整 Server 实验使用上述临时目录下的 `certs/CAs` 和数据目录,设置虚构的 `MINIO_IDENTITY_OPENID_CLIENT_ID`、fixture URL、临时 root 凭据,并显式指定 `--config-dir``--certs-dir`、loopback API/Console 地址。用 mode 文件切换 `reset`/`bad-jwks``normal`,同时检查 cluster health 和 Admin ListUsers;结果已保存在 `evidence.json``admin-check add` 只操作 `LAB_SERVER=127.0.0.1:port` 的实验实例,读取临时 `LAB_USER``LAB_PASSWORD``LAB_OIDC_URL`,使用虚构 OIDC secret。
跨版本构建时从各 tag `git archive` 获取源码,把 `probe.go` 放入该 module 根目录;旧版探针仅将 certs 导入替换为 `github.com/minio/pkg/v3/certs`,由旧版 go.mod 选择 PGSTY v3.11.0。分别强制 `GOTOOLCHAIN=go1.26.5` / `go1.27.1`,使用 `-mod=readonly`,不修改历史 go.mod。另将旧源码原依赖用 Go 1.27.1 构建,隔离工具链因素。若为 Linux 客户端构建探针,按已确认的架构设置 `GOOS=linux GOARCH=amd64``arm64`,不使用 Server 镜像代替。
### 第二轮 Linux 完整 Server 对照
从本任务根目录运行以下 Bash 命令。需要预先准备 Go 1.26.5、1.27.1 及源码依赖缓存,以及带 Python 3、curl/OpenSSL/HTTP2 的通用 Linux 基础镜像。`GOTOOLCHAIN` 明确选定编译器,`-mod=readonly` 保留依赖版本;实际测试构建用已安装工具链的绝对路径配合 `GOTOOLCHAIN=local`,等价地避免自动切换编译器。以下固定 `arm64` 与本次实验一致。
```bash
LAB_DIR=$(mktemp -d)
mkdir -p "$LAB_DIR/old" "$LAB_DIR/head" "$LAB_DIR/candidate" "$LAB_DIR/bin" "$LAB_DIR/out"
git archive d88f46ccee345a9c2fabe2d221d9a9e56bc11aec | tar -x -C "$LAB_DIR/old"
git archive d1105bbb3d4a0afa33b3a4ac11b821235038ed0e | tar -x -C "$LAB_DIR/head"
git archive d1105bbb3d4a0afa33b3a4ac11b821235038ed0e | tar -x -C "$LAB_DIR/candidate"
git -C "$LAB_DIR/candidate" apply "$PWD/docs/investigations/issue-154/openid-default-curves.patch"
(cd "$LAB_DIR/old" && CGO_ENABLED=0 GOWORK=off GOOS=linux GOARCH=arm64 \
GOTOOLCHAIN=go1.26.5 go build -mod=readonly -tags kqueue -o "$LAB_DIR/bin/old-go126" .)
(cd "$LAB_DIR/old" && CGO_ENABLED=0 GOWORK=off GOOS=linux GOARCH=arm64 \
GOTOOLCHAIN=go1.27.1 go build -mod=readonly -tags kqueue -o "$LAB_DIR/bin/old-go127" .)
(cd "$LAB_DIR/head" && CGO_ENABLED=0 GOWORK=off GOOS=linux GOARCH=arm64 \
GOTOOLCHAIN=go1.27.1 go build -mod=readonly -tags kqueue -o "$LAB_DIR/bin/head-go127" .)
(cd "$LAB_DIR/candidate" && CGO_ENABLED=0 GOWORK=off GOOS=linux GOARCH=arm64 \
GOTOOLCHAIN=go1.27.1 go build -mod=readonly -tags kqueue -o "$LAB_DIR/bin/candidate-go127" .)
CGO_ENABLED=0 GOWORK=off GOOS=linux GOARCH=arm64 GOTOOLCHAIN=go1.27.1 \
go build -mod=readonly -o "$LAB_DIR/bin/fixture" docs/investigations/issue-154/fixture.go
CGO_ENABLED=0 GOWORK=off GOOS=linux GOARCH=arm64 GOTOOLCHAIN=go1.27.1 \
go build -mod=readonly -o "$LAB_DIR/bin/admin-check" docs/investigations/issue-154/admin-check.go
# 本机已存在的通用 Debian 12 arm64 基础镜像;不拉取镜像,不映射端口。
LINUX_BASE_IMAGE=sha256:307af7711e2e04ab75759cb42a1eef45c43c4404894c0e30dd19f742b107b922
docker run --rm --pull=never --network none \
--mount "type=bind,source=$LAB_DIR/bin,target=/lab/bin,readonly" \
--mount "type=bind,source=$LAB_DIR/out,target=/lab/out" \
--mount "type=bind,source=$PWD/docs/investigations/issue-154/run-linux.py,target=/lab/run-linux.py,readonly" \
--entrypoint python3 "$LINUX_BASE_IMAGE" /lab/run-linux.py
```
每组场景输出一行摘要,同时在独立输出目录保存 `result.json`。脚本用 `finally` 终止其 Server/fixture,容器结束自动删除。它没有导出会话 cookie、授权码、JWT、state 或临时密码;原始运行目录只用于该次隔离实验,交付证据只保留握手、结果和构建身份。
@@ -0,0 +1,41 @@
//go:build ignore
// Loopback-only lab client for the Admin API used by Console's OIDC form.
package main
import (
"context"
"fmt"
"net"
"os"
"time"
"github.com/minio/madmin-go/v3"
)
func main() {
endpoint := os.Getenv("LAB_SERVER")
host, _, err := net.SplitHostPort(endpoint)
if err != nil || host != "127.0.0.1" {
panic("LAB_SERVER must use IPv4 loopback")
}
a, err := madmin.New(endpoint, os.Getenv("LAB_USER"), os.Getenv("LAB_PASSWORD"), false)
if err != nil {
panic(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if len(os.Args) > 1 && os.Args[1] == "add" {
restart, err := a.AddOrUpdateIDPConfig(ctx, "openid", "local154", "enable=on client_id=local154 client_secret=local154-placeholder config_url="+os.Getenv("LAB_OIDC_URL"), false)
fmt.Printf("add restart=%v err=%v\n", restart, err)
if err != nil {
os.Exit(1)
}
return
}
users, err := a.ListUsers(ctx)
fmt.Printf("list_users count=%d err=%v\n", len(users), err)
if err != nil {
os.Exit(1)
}
}
@@ -0,0 +1,41 @@
//go:build ignore
// Run only with the public, synthetic CA made by fixture.go.
package main
import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"os"
"runtime"
"github.com/pgsty/silo-pkg/v3/certs"
)
func main() {
if len(os.Args) != 3 {
panic("usage: cert-roots synthetic-ca.pem explicit-ca-path-or-empty")
}
data, err := os.ReadFile(os.Args[1])
if err != nil {
panic(err)
}
block, _ := pem.Decode(data)
if block == nil || block.Type != "CERTIFICATE" {
panic("expected public certificate")
}
certificate, err := x509.ParseCertificate(block.Bytes)
if err != nil {
panic(err)
}
roots, err := certs.GetRootCAs(os.Args[2])
if err != nil {
panic(err)
}
_, err = certificate.Verify(x509.VerifyOptions{Roots: roots})
_ = json.NewEncoder(os.Stdout).Encode(map[string]any{
"go": runtime.Version(), "os": runtime.GOOS,
"explicit_ca": os.Args[2] != "", "trusted": err == nil,
})
}
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
//go:build ignore
// Loopback-only synthetic OIDC/TLS fixture. Only disposable lab identities are used.
// Rejection modes model hypotheses; they are not evidence about the user's IdP.
package main
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"flag"
"fmt"
"math/big"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
)
type observedConn struct {
net.Conn
readBytes int
}
func (c *observedConn) Read(p []byte) (int, error) {
n, e := c.Conn.Read(p)
c.readBytes += n
return n, e
}
func (c *observedConn) reset() { _ = c.Conn.(*net.TCPConn).SetLinger(0); _ = c.Conn.Close() }
type observedListener struct{ net.Listener }
func (l observedListener) Accept() (net.Conn, error) {
c, e := l.Listener.Accept()
if e != nil {
return nil, e
}
return &observedConn{Conn: c}, nil
}
func main() {
dir := flag.String("dir", "", "isolated output directory for public CA, URL, and mode file")
tls13 := flag.Bool("tls13", false, "allow TLS 1.3 in addition to the TLS 1.2 baseline")
flag.Parse()
if *dir == "" {
panic("-dir required")
}
must(os.MkdirAll(*dir, 0700))
key, err := rsa.GenerateKey(rand.Reader, 2048)
must(err)
root := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "issue-154 local CA"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature}
rootDER, err := x509.CreateCertificate(rand.Reader, root, root, &key.PublicKey, key)
must(err)
leaf := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "localhost"}, DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, NotBefore: root.NotBefore, NotAfter: root.NotAfter, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, KeyUsage: x509.KeyUsageDigitalSignature}
leafDER, err := x509.CreateCertificate(rand.Reader, leaf, root, &key.PublicKey, key)
must(err)
must(os.WriteFile(filepath.Join(*dir, "ca.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER}), 0600))
ln, err := net.Listen("tcp", "127.0.0.1:0")
must(err)
base := "https://" + ln.Addr().String()
must(os.WriteFile(filepath.Join(*dir, "url"), []byte(base), 0600))
mode := func() string { b, _ := os.ReadFile(filepath.Join(*dir, "mode")); return strings.TrimSpace(string(b)) }
var mu sync.Mutex
log := func(v any) { mu.Lock(); defer mu.Unlock(); _ = json.NewEncoder(os.Stdout).Encode(v) }
tc := &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{leafDER, rootDER}, PrivateKey: key}}, MinVersion: tls.VersionTLS12, MaxVersion: tls.VersionTLS12, CurvePreferences: []tls.CurveID{tls.CurveP256}, CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384}}
if *tls13 {
tc.MaxVersion = tls.VersionTLS13
}
peerConfig := tc.Clone()
peerConfig.NextProtos = []string{"h2", "http/1.1"}
// net/http validates HTTP/2 support before GetConfigForClient; the fixture
// then deliberately selects only the reported AES-256 suite.
tc.CipherSuites = append(tc.CipherSuites, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256)
tc.GetConfigForClient = func(chi *tls.ClientHelloInfo) (*tls.Config, error) {
m := mode()
c := chi.Conn.(*observedConn)
log(map[string]any{"event": "hello", "mode": m, "bytes_read": c.readBytes, "curves": chi.SupportedCurves, "signatures": chi.SignatureSchemes, "alpn": chi.SupportedProtos, "versions": chi.SupportedVersions})
reject := m == "reset" || m == "reject-mlkem" && slices.Contains(chi.SupportedCurves, tls.CurveID(4588)) || m == "reject-mldsa" && slices.Contains(chi.SignatureSchemes, tls.SignatureScheme(0x0904)) || m == "require-h2" && !slices.Contains(chi.SupportedProtos, "h2")
if reject {
c.reset()
return nil, errors.New("synthetic ClientHello rejection")
}
return peerConfig, nil
}
server := &http.Server{TLSConfig: tc, ReadHeaderTimeout: 5 * time.Second}
var codes sync.Map
server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log(map[string]any{"event": "request", "path": r.URL.Path, "protocol": r.Proto, "tls": r.TLS.Version, "cipher": r.TLS.CipherSuite, "resumed": r.TLS.DidResume, "ua": r.UserAgent()})
if mode() == "reject-silo-ua" && strings.HasPrefix(r.UserAgent(), "Silo") {
c, _, e := w.(http.Hijacker).Hijack()
if e == nil {
c.(*tls.Conn).NetConn().(*observedConn).reset()
}
return
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/authorize":
q := r.URL.Query()
redirect, err := url.Parse(q.Get("redirect_uri"))
if err != nil || redirect.Scheme != "http" || redirect.Hostname() != "127.0.0.1" || redirect.Path != "/oauth_callback" || q.Get("client_id") != "local154" {
http.Error(w, "loopback lab authorization only", 400)
return
}
codeBytes := make([]byte, 18)
_, err = rand.Read(codeBytes)
must(err)
code := base64.RawURLEncoding.EncodeToString(codeBytes)
codes.Store(code, q.Get("nonce"))
values := redirect.Query()
values.Set("code", code)
values.Set("state", q.Get("state"))
redirect.RawQuery = values.Encode()
http.Redirect(w, r, redirect.String(), http.StatusFound)
case "/token":
if r.Method != http.MethodPost || r.ParseForm() != nil {
http.Error(w, "bad token request", 400)
return
}
id, secret, ok := r.BasicAuth()
if !ok {
id, secret = r.Form.Get("client_id"), r.Form.Get("client_secret")
}
nonce, found := codes.LoadAndDelete(r.Form.Get("code"))
if id != "local154" || secret != "local154-placeholder" || !found || r.Form.Get("grant_type") != "authorization_code" {
w.WriteHeader(400)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"})
return
}
audience := id
if mode() == "bad-audience" {
audience = "different-lab-client"
}
claims, _ := json.Marshal(map[string]any{"iss": base, "sub": "local154-user", "aud": audience, "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), "policy": "readwrite", "nonce": nonce})
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","kid":"local-154","typ":"JWT"}`))
payload := header + "." + base64.RawURLEncoding.EncodeToString(claims)
hash := sha256.Sum256([]byte(payload))
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:])
must(err)
if mode() == "bad-signature" {
signature[0] ^= 1
}
token := payload + "." + base64.RawURLEncoding.EncodeToString(signature)
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": token, "id_token": token, "token_type": "Bearer", "expires_in": 3600})
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]any{"issuer": base, "jwks_uri": base + "/jwks", "authorization_endpoint": base + "/authorize", "token_endpoint": base + "/token", "response_types_supported": []string{"code"}, "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{"RS256"}, "scopes_supported": []string{"openid"}})
case "/jwks":
if mode() == "bad-jwks" {
http.Error(w, "synthetic JWKS outage", 503)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]any{"kty": "RSA", "kid": "local-154", "use": "sig", "alg": "RS256", "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), "e": "AQAB"}}})
default:
http.NotFound(w, r)
}
})
fmt.Fprintln(os.Stderr, base)
must(server.ServeTLS(observedListener{ln}, "", ""))
}
func must(err error) {
if err != nil {
panic(err)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
# Historical OIDC-only candidate; superseded by ../go127-stack.md. Do not apply on top of the stack fix.
--- a/cmd/utils.go
+++ b/cmd/utils.go
@@ -654,6 +654,14 @@
return NewHTTPTransportWithTimeout(1 * time.Minute)
}
+// NewOpenIDHTTPTransport uses Go defaults for external identity-provider key exchange.
+// This lets tlsmlkem/tlssecpmlkem configure their documented default sets.
+func NewOpenIDHTTPTransport() *http.Transport {
+ tr := NewHTTPTransport()
+ tr.TLSClientConfig.CurvePreferences = nil
+ return tr
+}
+
// Default values for dial timeout
const defaultDialTimeout = 5 * time.Second
--- a/cmd/iam.go
+++ b/cmd/iam.go
@@ -277,7 +277,7 @@
for {
if !openidInit {
openidConfig, err := openid.LookupConfig(s,
- xhttp.WithUserAgent(NewHTTPTransport(), func() string {
+ xhttp.WithUserAgent(NewOpenIDHTTPTransport(), func() string {
return getUserAgent(getMinioMode())
}), xhttp.DrainBody, globalSite.Region())
if err != nil {
--- a/cmd/config-current.go
+++ b/cmd/config-current.go
@@ -352,7 +352,7 @@
}
case config.IdentityOpenIDSubSys:
if _, err := openid.LookupConfig(s,
- xhttp.WithUserAgent(NewHTTPTransport(), func() string {
+ xhttp.WithUserAgent(NewOpenIDHTTPTransport(), func() string {
return getUserAgent(getMinioMode())
}), xhttp.DrainBody, globalSite.Region()); err != nil {
return err
+165
View File
@@ -0,0 +1,165 @@
//go:build ignore
// Diagnostic GET using the Server's actual transport constructor.
// Build explicitly from the SILO module root; see ../issue-154.md.
package main
import (
"context"
"crypto/tls"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/http/httptrace"
"net/url"
"os"
"runtime"
"strings"
"sync"
"time"
"github.com/minio/minio/cmd"
xhttp "github.com/minio/minio/internal/http"
"github.com/pgsty/silo-pkg/v3/certs"
)
func main() {
endpoint := flag.String("url", os.Getenv("OIDC_URL"), "discovery URL; no credentials or query string")
ca := flag.String("ca", "", "same CA file or certs/CAs directory as Server")
h2 := flag.Bool("h2", false, "diagnostic: opt in to HTTP/2")
classical := flag.Bool("classical", false, "diagnostic: omit hybrid key exchange only")
defaultCurves := flag.Bool("default-curves", false, "diagnostic: let Go choose curves and honor its GODEBUG defaults")
tls12 := flag.Bool("tls12", false, "diagnostic: TLS 1.2 only; keeps certificate verification")
direct := flag.Bool("direct", false, "diagnostic: bypass environment proxy")
ip := flag.String("ip", "", "diagnostic: pin destination IP, preserving Host/SNI; requires -direct")
fresh := flag.Bool("fresh", false, "diagnostic: close idle connections between requests")
ua := flag.String("ua", "issue-154-probe", "HTTP User-Agent; supply actual Server UA to investigate a WAF")
n := flag.Int("n", 1, "number of GETs (1 to 3)")
flag.Parse()
u, err := url.Parse(*endpoint)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || *n < 1 || *n > 3 {
fmt.Fprintln(os.Stderr, "require an HTTPS URL without credentials/query/fragment and -n between 1 and 3")
os.Exit(2)
}
if *ip != "" && (!*direct || net.ParseIP(*ip) == nil) {
fmt.Fprintln(os.Stderr, "-ip requires a literal IP and -direct")
os.Exit(2)
}
if *classical && *defaultCurves {
fmt.Fprintln(os.Stderr, "choose at most one of -classical and -default-curves")
os.Exit(2)
}
tr := cmd.NewHTTPTransport()
tr.TLSClientConfig.RootCAs, err = certs.GetRootCAs(*ca)
if err != nil {
fmt.Fprintln(os.Stderr, "CA loading failed; check the local CA path")
os.Exit(2)
}
if *h2 {
tr.ForceAttemptHTTP2 = true
}
if *classical {
tr.TLSClientConfig.CurvePreferences = []tls.CurveID{tls.CurveP256, tls.X25519, tls.CurveP384, tls.CurveP521}
}
if *defaultCurves {
tr.TLSClientConfig.CurvePreferences = nil
}
if *tls12 {
tr.TLSClientConfig.MinVersion = tls.VersionTLS12
tr.TLSClientConfig.MaxVersion = tls.VersionTLS12
}
if *direct {
tr.Proxy = nil
}
if *ip != "" {
base := tr.DialContext
tr.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, e := net.SplitHostPort(address)
if e != nil {
return nil, e
}
if host == u.Hostname() {
address = net.JoinHostPort(*ip, port)
}
return base(ctx, network, address)
}
}
defer tr.CloseIdleConnections()
var mu sync.Mutex
log := func(format string, args ...any) { mu.Lock(); defer mu.Unlock(); fmt.Printf(format+"\n", args...) }
log("go=%s os=%s arch=%s h2=%v classical=%v default_curves=%v tls12=%v", runtime.Version(), runtime.GOOS, runtime.GOARCH, *h2, *classical, *defaultCurves, *tls12)
// Deliberately print no URL, headers, body, client ID, secret, or token.
req, _ := http.NewRequest(http.MethodGet, u.String(), nil)
proxy := "direct"
if tr.Proxy != nil {
p, e := tr.Proxy(req)
if e != nil {
log("proxy_selection_error=%T", e)
os.Exit(2)
}
if p != nil {
proxy = p.Scheme + " proxy (address omitted)"
}
}
log("route=%s curves=%v", proxy, tr.TLSClientConfig.CurvePreferences)
client := &http.Client{Transport: xhttp.WithUserAgent(tr, func() string { return *ua }), Timeout: 20 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
failed := false
for i := 0; i < *n; i++ {
if *fresh {
tr.CloseIdleConnections()
}
log("request=%d", i+1)
trace := &httptrace.ClientTrace{
DNSDone: func(d httptrace.DNSDoneInfo) { log("dns_addresses=%v err=%s", d.Addrs, errorClass(d.Err)) },
ConnectStart: func(network, addr string) { log("connect=%s %s", network, addr) },
ConnectDone: func(_, addr string, e error) { log("connected=%s err=%s", addr, errorClass(e)) },
TLSHandshakeStart: func() { log("tls_start") },
TLSHandshakeDone: func(s tls.ConnectionState, e error) {
log("tls_done=0x%x cipher=%s alpn=%q resumed=%v verified_chains=%d err=%s", s.Version, tls.CipherSuiteName(s.CipherSuite), s.NegotiatedProtocol, s.DidResume, len(s.VerifiedChains), errorClass(e))
},
GotConn: func(c httptrace.GotConnInfo) { log("got_conn=%s reused=%v", c.Conn.RemoteAddr(), c.Reused) },
WroteRequest: func(w httptrace.WroteRequestInfo) { log("wrote_request err=%s", errorClass(w.Err)) },
GotFirstResponseByte: func() { log("first_response_byte") },
}
r := req.Clone(httptrace.WithClientTrace(context.Background(), trace))
resp, e := client.Do(r)
if e != nil {
log("get_error=%s", errorClass(e))
failed = true
continue
}
log("status=%d protocol=%s", resp.StatusCode, resp.Proto)
_, e = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if e != nil || resp.StatusCode != http.StatusOK {
failed = true
log("body_error=%s", errorClass(e))
}
}
if failed {
os.Exit(1)
}
}
func errorClass(err error) string {
if err == nil {
return "none"
}
if errors.Is(err, context.DeadlineExceeded) {
return "deadline"
}
// Error text may contain a private URL. Emit only category and concrete type.
category := "other"
s := err.Error()
for _, k := range []string{"connection reset by peer", "x509:", "TLS handshake timeout", "connection refused", "EOF"} {
if strings.Contains(s, k) {
category = k
break
}
}
return fmt.Sprintf("%s (%T)", category, err)
}
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Bounded, loopback-only full-Server comparison. See the investigation report.
Run in an isolated generic Linux container with locally built binaries in
/lab/bin and a new disposable /lab/out. No customer identities or endpoints.
"""
import http.cookiejar
import json
import os
from pathlib import Path
import secrets
import shutil
import socket
import ssl
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
ROOT = Path("/lab")
OUT = ROOT / "out"
BASE = {k: v for k, v in os.environ.items()
if not k.startswith(("MINIO_", "SILO_", "CONSOLE_"))
and k.lower() not in {"http_proxy", "https_proxy", "all_proxy", "no_proxy", "godebug"}}
def port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def request(url, opener=None, payload=None):
headers = {"Origin": f"http://{urllib.parse.urlparse(url).netloc}"}
if payload is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=None if payload is None else json.dumps(payload).encode(), headers=headers)
try:
response = (opener.open if opener else urllib.request.urlopen)(req, timeout=2)
except urllib.error.HTTPError as err:
response = err
except (urllib.error.URLError, TimeoutError):
return 0, {}, b""
with response:
return response.code, dict(response.headers), response.read(1 << 20)
def stop(proc):
proc.terminate()
try:
proc.wait(timeout=4)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=2)
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def login(console, ca):
jar = http.cookiejar.CookieJar()
client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
status, _, raw = request(console + "/api/v1/login", client)
details = json.loads(raw)
rules = details.get("redirectRules", [])
assert status == 200 and len(rules) == 1, (status, details)
auth_url = rules[0]["redirect"]
provider = urllib.request.build_opener(NoRedirect(), urllib.request.HTTPSHandler(context=ssl.create_default_context(cafile=str(ca))))
status, headers, _ = request(auth_url, provider)
callback = headers.get("Location", headers.get("location", ""))
assert status == 302 and callback.startswith(console + "/oauth_callback?"), (status, callback)
values = urllib.parse.parse_qs(urllib.parse.urlparse(callback).query)
callback_status, _, _ = request(callback, client)
status, _, _ = request(console + "/api/v1/login/oauth2/auth", client,
{"code": values["code"][0], "state": values["state"][0]})
buckets_status, _, _ = request(console + "/api/v1/buckets", client)
# Do not record cookies, codes, JWTs, or the state value.
return {"callback_status": callback_status, "login_status": status,
"buckets_status": buckets_status, "session_cookie": any(c.name == "token" for c in jar)}
def run(name, binary, mode="normal", debug=None, tls13=False,
expected=True, trusted=True, oidc=True, oauth=False, add=False):
d = OUT / name
(d / "certs/CAs").mkdir(parents=True, exist_ok=False)
idp = d / "idp"
idp.mkdir()
(idp / "mode").write_text(mode)
with (d / "fixture.jsonl").open("w") as events, (d / "fixture.stderr").open("w") as errors, (d / "server.log").open("w") as logs:
fixture = subprocess.Popen([str(ROOT / "bin/fixture"), "-dir", str(idp), *(["-tls13"] if tls13 else [])], env=BASE, stdout=events, stderr=errors)
server = None
try:
until = time.monotonic() + 5
while not (idp / "url").is_file() and time.monotonic() < until:
time.sleep(.05)
assert (idp / "url").is_file(), "fixture did not initialize"
url = (idp / "url").read_text() + "/.well-known/openid-configuration"
if trusted:
shutil.copyfile(idp / "ca.pem", d / "certs/CAs/lab.pem")
sport, cport = port(), port()
address = f"127.0.0.1:{sport}"
api, console = "http://" + address, f"http://127.0.0.1:{cport}"
password = secrets.token_urlsafe(24)
env = dict(BASE, MINIO_ROOT_USER="local154", MINIO_ROOT_PASSWORD=password, MINIO_BROWSER="on")
if debug:
env["GODEBUG"] = debug
if oidc:
env.update(MINIO_IDENTITY_OPENID_CONFIG_URL=url,
MINIO_IDENTITY_OPENID_CLIENT_ID="local154",
MINIO_IDENTITY_OPENID_CLIENT_SECRET="local154-placeholder",
MINIO_IDENTITY_OPENID_REDIRECT_URI=console + "/oauth_callback")
server = subprocess.Popen([str(ROOT / "bin" / binary), "--config-dir", str(d / "config"), "--certs-dir", str(d / "certs"), "server", "--address", address, "--console-address", f"127.0.0.1:{cport}", str(d / "data")], env=env, stdout=logs, stderr=subprocess.STDOUT)
until = time.monotonic() + 15
while time.monotonic() < until:
assert server.poll() is None, "Server exited; inspect its local log"
status, _, _ = request(api + "/minio/health/cluster")
if status == 200 and request(console)[0] == 200:
break
if not expected and (d / "server.log").read_text().count("Waiting for OpenID") >= 2:
break
time.sleep(.1)
result = {"case": name, "binary": binary, "mode": mode, "godebug": debug, "tls13": tls13,
"cluster": request(api + "/minio/health/cluster")[0],
"ready": request(api + "/minio/health/ready")[0], "console": request(console)[0]}
assert result["cluster"] == (200 if expected else 503), result
aenv = dict(BASE, LAB_SERVER=address, LAB_USER="local154", LAB_PASSWORD=password, LAB_OIDC_URL=url)
if expected:
admin = subprocess.run([str(ROOT / "bin/admin-check")], env=aenv, capture_output=True, text=True, timeout=7)
result["admin_list_ok"] = admin.returncode == 0
assert result["admin_list_ok"], admin.stdout
curl = subprocess.run(["curl", "--cacert", str(idp / "ca.pem"), "--http2", "--max-time", "3", "-sS", "-o", "/dev/null", "-w", "%{http_code} %{http_version}", url], env=BASE, capture_output=True, text=True, timeout=5)
result["curl"] = {"exit": curl.returncode, "status_protocol": curl.stdout}
if add:
attempt = subprocess.run([str(ROOT / "bin/admin-check"), "add"], env=aenv, capture_output=True, text=True, timeout=7)
result["add_ok"] = attempt.returncode == 0
result["add_reset"] = "connection reset by peer" in attempt.stdout
assert result["add_ok"] == binary.startswith("candidate"), result
if oauth:
result["oauth"] = login(console, idp / "ca.pem")
assert result["oauth"]["login_status"] == 204 and result["oauth"]["buckets_status"] == 200, result
for bad in ("bad-signature", "bad-audience"):
(idp / "mode").write_text(bad)
result[bad] = login(console, idp / "ca.pem")
assert result[bad]["login_status"] >= 400 and result[bad]["buckets_status"] >= 400, result
# Public handshake metadata only; no authorization parameters.
result["events"] = [json.loads(line) for line in (d / "fixture.jsonl").read_text().splitlines()]
(d / "result.json").write_text(json.dumps(result, indent=2) + "\n")
print(json.dumps({k: v for k, v in result.items() if k != "events"}), flush=True)
finally:
if server is not None:
stop(server)
stop(fixture)
if __name__ == "__main__":
run("old126-normal", "old-go126")
run("old127-normal", "old-go127")
run("old126-compat", "old-go126", "reject-mlkem", "tlsmlkem=0")
run("old127-compat", "old-go127", "reject-mlkem", "tlsmlkem=0", expected=False)
run("head127-compat", "head-go127", "reject-mlkem", "tlsmlkem=0", expected=False)
run("candidate127-compat-login", "candidate-go127", "reject-mlkem", "tlsmlkem=0", oauth=True)
run("candidate127-no-optout", "candidate-go127", "reject-mlkem", expected=False)
run("candidate127-tls13-login", "candidate-go127", tls13=True, oauth=True)
run("candidate127-untrusted", "candidate-go127", trusted=False, expected=False)
run("candidate127-mldsa", "candidate-go127", "reject-mldsa", "tlsmlkem=0", expected=False)
run("head127-add", "head-go127", "reject-mlkem", "tlsmlkem=0", oidc=False, add=True)
run("candidate127-add", "candidate-go127", "reject-mlkem", "tlsmlkem=0", oidc=False, add=True)
-1
View File
@@ -166,7 +166,6 @@ func LookupConfig(kvs config.KVS, rootCAs *x509.CertPool) (Config, error) {
NextProtos: []string{"http/1.1", "h2"},
ClientSessionCache: tls.NewLRUClientSessionCache(64),
CipherSuites: crypto.TLSCiphersBackwardCompatible(),
CurvePreferences: crypto.TLSCurveIDs(),
}
// This is only to support client side certificate authentication
// https://coreos.com/etcd/docs/latest/op-guide/security.html
-6
View File
@@ -70,9 +70,3 @@ func TLSCiphersBackwardCompatible() []uint16 {
tls.TLS_RSA_WITH_AES_256_CBC_SHA,
}
}
// TLSCurveIDs returns a list of supported elliptic curve IDs
// in preference order.
func TLSCurveIDs() []tls.CurveID {
return []tls.CurveID{tls.X25519MLKEM768, tls.CurveP256, tls.X25519, tls.CurveP384, tls.CurveP521}
}
+2 -4
View File
@@ -46,9 +46,8 @@ type ConnSettings struct {
DialTimeout time.Duration
// TLS Settings
RootCAs *x509.CertPool
CipherSuites []uint16
CurvePreferences []tls.CurveID
RootCAs *x509.CertPool
CipherSuites []uint16
// HTTP2
EnableHTTP2 bool
@@ -70,7 +69,6 @@ func (s ConnSettings) getDefaultTransport(maxIdleConnsPerHost int) *http.Transpo
tlsClientConfig := tls.Config{
RootCAs: s.RootCAs,
CipherSuites: s.CipherSuites,
CurvePreferences: s.CurvePreferences,
ClientSessionCache: tls.NewLRUClientSessionCache(tlsClientSessionCacheSize),
}