mirror of
https://github.com/pgsty/minio.git
synced 2026-09-10 04:24:03 +03:00
fix(tls): honor Go key exchange defaults across transports
Remove the eight explicit curve overrides so Go 1.27 honors tlsmlkem=0 across Server listeners, node links and outbound transports. Remove the unused shared curve option and add wire-level regression coverage. Document CA trust and TLS upgrade behavior, retain the investigation artifacts, and exclude their synthetic routes from the rebrand guard. The product compatibility baseline remains unchanged. Validation: focused race tests, HTTP tests, lint, compatibility guard positive/negative controls, and a fresh Linux build with three isolated OIDC integration scenarios all pass. Adversarial review: Claude Code Fable 5.1, max effort. Final verdict: APPROVE FOR COMMIT. Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -0,0 +1,403 @@
|
||||
# SILO #154:OIDC 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 503;Console 未启动 | 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 没有 ALPN,GET 使用 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 与故障 tag,OpenID 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 是 X25519MLKEM768,29 是 X25519,23/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 200;Console HTTP 200;Admin ListUsers 成功。 |
|
||||
| 当前版连接持续 reset 的 IdP | IAM 持续等待;cluster health 503,`X-Minio-Server-Status: iam-offline`;Console 端口尚未提供服务;5 秒限时的 Admin ListUsers 未完成。 |
|
||||
| 上述场景恢复 IdP,不重启 Server | 约 0.43 秒后 cluster/Console 200,ListUsers 成功。该时间是单次本地样本,不是恢复 SLA。 |
|
||||
| 旧版 + `tlsmlkem=0`,IdP 拒绝 ML-KEM | 完整启动成功。 |
|
||||
| 故障版 + `tlsmlkem=0`,相同拒绝规则 | IAM 等待、cluster 503;撤掉规则后约 1.38 秒内完整恢复,无需重启。 |
|
||||
| 当前版 discovery 成功、JWKS 返回 503 | 同样阻塞 IAM;JWKS 恢复后约 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 store,Console 又在 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。
|
||||
|
||||
# Linux:PID 设为实际 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
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user