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:
Feng Ruohang
2026-09-09 18:50:12 +08:00
parent d1105bbb3d
commit 48e1846525
19 changed files with 10083 additions and 45 deletions
@@ -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)