From 6fc9f6a2ee3c010b5376887b387a32f243240026 Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Thu, 22 Oct 2026 10:27:24 +0800 Subject: [PATCH] Accept compatible authenticated Host API patch capabilities --- .github/workflows/security-release-gate.yml | 6 +- COMPATIBILITY.md | 8 +++ README.md | 8 +-- proxy/key_backend_capability.go | 18 +++++- proxy/key_backend_capability_test.go | 71 +++++++++++++++++++++ scripts/validate | 2 +- scripts/version | 2 +- 7 files changed, 104 insertions(+), 11 deletions(-) diff --git a/.github/workflows/security-release-gate.yml b/.github/workflows/security-release-gate.yml index ec0664c..9620d90 100644 --- a/.github/workflows/security-release-gate.yml +++ b/.github/workflows/security-release-gate.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 60 env: - VERSION_OVERRIDE: v0.23.16 + VERSION_OVERRIDE: v0.23.17 SOURCE_DATE_EPOCH: "0" TRIVY_IMAGE: aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 BUILDER_IMAGE: pasturestack-websocket-proxy-dapper:ubuntu26 @@ -34,7 +34,7 @@ jobs: set -o pipefail make ci 2>&1 | tee evidence/build-test.log cp bin/websocket-proxy evidence/websocket-proxy-linux-amd64 - cp dist/artifacts/websocket-proxy-0.23.16-linux-amd64.tar.xz evidence/first-build.tar.xz + cp dist/artifacts/websocket-proxy-0.23.17-linux-amd64.tar.xz evidence/first-build.tar.xz docker run --rm --entrypoint /bin/bash \ -v "$PWD/evidence:/evidence:ro" "$BUILDER_IMAGE" \ -lc 'go version -m /evidence/websocket-proxy-linux-amd64' >evidence/product.buildinfo @@ -46,7 +46,7 @@ jobs: set -o pipefail rm -rf bin dist make package 2>&1 | tee evidence/reproducible-build.log - cp dist/artifacts/websocket-proxy-0.23.16-linux-amd64.tar.xz evidence/second-build.tar.xz + cp dist/artifacts/websocket-proxy-0.23.17-linux-amd64.tar.xz evidence/second-build.tar.xz cmp evidence/first-build.tar.xz evidence/second-build.tar.xz sha256sum evidence/first-build.tar.xz evidence/second-build.tar.xz \ | tee evidence/package-sha256.txt diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index bedc076..22de752 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -2,6 +2,14 @@ The migration preserves existing `/v1`, `/v2-beta`, and `/v2` proxy paths, backend connection routes, subscription behavior, token validation inputs, generated client schemas, and proxy-protocol handling. +Verified API Key streams require an authenticated backend Host ticket and the +exact `key-audit-v1,key-delegation-v1` declaration. Compatible Host API patches +use canonical `0.38.x` versions with patch >=5, including `0.38.5` and `0.38.6`; +unknown major/minor families, prereleases, leading zeros, malformed values, and +duplicate version or capability parameters remain rejected. This operational +gate never replaces Engine authorization or live revision/expiry checks, and +does not add a capability requirement to ordinary non-Key streams. + Preferred settings use `platform-*`, `PLATFORM_*`, and `PROXY_PLATFORM_ADDRESS`. Historical `cattle-*`, `CATTLE_*`, and `PROXY_CATTLE_ADDRESS` aliases remain only as runtime configuration contracts. diff --git a/README.md b/README.md index 1fff40b..79bb2c0 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Capabilities -The source package version is `0.23.16`, rebuilt with Go 1.27.3 for the crypto/x509 fix CVE-2026-111943. Target-bound API Key tickets require exact target, operation, revision, expiry, and ticket digest matches from the trusted Engine. Handshake and periodic live checks fail closed. Persistent console secrets cannot extend an expired or revoked ticket; revocation also clears replay data. Legacy and full-without-expiry token authentication remains unchanged. +The source package version is `0.23.17`, built with Go 1.27.3 and supporting protocol-compatible Host API patch releases. Target-bound API Key tickets require exact target, operation, revision, expiry, and ticket digest matches from the trusted Engine. Handshake and periodic live checks fail closed. Persistent console secrets cannot extend an expired or revoked ticket; revocation also clears replay data. Legacy and full-without-expiry token authentication remains unchanged. -Every verified API Key stream requires an authenticated Host API `0.38.5` connection declaring `key-audit-v1,key-delegation-v1`. Missing capabilities prevent forwarding any execution payload. The Proxy submits only the signed stream ticket and that connection's signed backend ticket to the Engine's fixed audit-only failure endpoint; it returns `BackendAuditCapabilityUnavailable` (503) only after durable acceptance, or `AuditUnavailable` (503) when evidence cannot be accepted. Ordinary non-Key streams do not gain this capability requirement. +Every verified API Key stream requires an authenticated Host API connection declaring exactly `key-audit-v1,key-delegation-v1`, with a canonical numeric `0.38.x` version whose patch is at least `5`. Unknown major/minor families, duplicate or malformed declarations, prereleases, and leading-zero versions fail closed. Missing capabilities prevent forwarding any execution payload. The Proxy submits only the signed stream ticket and that connection's signed backend ticket to the Engine's fixed audit-only failure endpoint; it returns `BackendAuditCapabilityUnavailable` (503) only after durable acceptance, or `AuditUnavailable` (503) when evidence cannot be accepted. Ordinary non-Key streams do not gain this capability requirement. An expired, signed Key-traced ticket is rejected before creating a backend or persistent session. Its fixed denial receipt uses the ticket's verified identity and an authenticated host proof; signature-only parsing is never an authorization path. Each handshake attempt has a separate audit receipt. Tokens without verified Key attribution retain their existing authentication behavior and cannot claim someone else's audit history. @@ -42,9 +42,9 @@ make build make package ``` -The default producer version is `0.23.16`; `VERSION_OVERRIDE` accepts only numeric semantic versions. Packaging +The default producer version is `0.23.17`; `VERSION_OVERRIDE` accepts only numeric semantic versions. Packaging rejects brand or maintenance suffixes and produces the deterministic, -versioned `websocket-proxy-0.23.16-linux-amd64.tar.xz` asset. Install it with the matching official Engine and host-api package; scoped stream protection cannot be provided by replacing the proxy alone. Package publication and deployment are explicit operations. +versioned `websocket-proxy-0.23.17-linux-amd64.tar.xz` asset. Install only a published artifact with the matching official Engine and host-api package; scoped stream protection cannot be provided by replacing the proxy alone. Package publication and deployment are explicit operations. See [COMPATIBILITY.md](COMPATIBILITY.md), [SECURITY.md](SECURITY.md), and [ORIGIN.md](ORIGIN.md). diff --git a/proxy/key_backend_capability.go b/proxy/key_backend_capability.go index b5ab766..ab2010a 100644 --- a/proxy/key_backend_capability.go +++ b/proxy/key_backend_capability.go @@ -6,6 +6,8 @@ import ( "errors" "net/http" "net/url" + "strconv" + "strings" "time" "github.com/PastureStack/websocket-proxy/common" @@ -134,8 +136,20 @@ type keyTraceInitializer interface { } func declaredKeyCapabilities(req *http.Request) bool { - query := req.URL.Query() - return len(query["hostApiVersion"]) == 1 && query.Get("hostApiVersion") == "0.38.5" && len(query["hostApiCapabilities"]) == 1 && query.Get("hostApiCapabilities") == "key-audit-v1,key-delegation-v1" + query, err := url.ParseQuery(req.URL.RawQuery) + return err == nil && len(query["hostApiVersion"]) == 1 && compatibleKeyBackendVersion(query.Get("hostApiVersion")) && + len(query["hostApiCapabilities"]) == 1 && query.Get("hostApiCapabilities") == "key-audit-v1,key-delegation-v1" +} + +// Patch releases in the established 0.38 family retain the explicit v1 wire +// contract. This is not a claim that an unknown major/minor is compatible. +func compatibleKeyBackendVersion(version string) bool { + patch, ok := strings.CutPrefix(version, "0.38.") + if !ok { + return false + } + number, err := strconv.ParseUint(patch, 10, 64) + return err == nil && number >= 5 && strconv.FormatUint(number, 10) == patch } // The caller must have verified the JWT before considering these claims. diff --git a/proxy/key_backend_capability_test.go b/proxy/key_backend_capability_test.go index 64993c5..c35b644 100644 --- a/proxy/key_backend_capability_test.go +++ b/proxy/key_backend_capability_test.go @@ -118,6 +118,77 @@ func (m *capabilityManager) addBackendWithKeyCapabilities(_ string, ws *websocke func (*capabilityManager) removeBackend(string, string) {} func (*capabilityManager) closeConnection(string, string) error { return nil } +func TestDeclaredKeyCapabilitiesCompatiblePatch(t *testing.T) { + for _, version := range []string{"0.38.5", "0.38.6", "0.38.7", "0.38.10", "0.38.100"} { + query := url.Values{"hostApiVersion": {version}, "hostApiCapabilities": {"key-audit-v1,key-delegation-v1"}, "mode": {"host"}} + if !declaredKeyCapabilities(&http.Request{URL: &url.URL{RawQuery: query.Encode()}}) { + t.Errorf("compatible canonical patch %q rejected", version) + } + } + for _, version := range []string{"", "0.38.0", "0.38.4", "0.38", "0.38.5.0", "v0.38.5", "00.38.5", "0.038.5", + "0.38.05", "0.38.+5", "0.38.-5", "0.38.5-alpha", "0.38.5+build", " 0.38.5", "0.38.5 ", "0.38.5\n", + "0.38.18446744073709551616", "0.39.5", "1.38.5", "0.37.999"} { + query := url.Values{"hostApiVersion": {version}, "hostApiCapabilities": {"key-audit-v1,key-delegation-v1"}} + if declaredKeyCapabilities(&http.Request{URL: &url.URL{RawQuery: query.Encode()}}) { + t.Errorf("unsupported version %q accepted", version) + } + } + valid := "hostApiVersion=0.38.6&hostApiCapabilities=key-audit-v1,key-delegation-v1" + for _, query := range []string{ + "hostApiCapabilities=key-audit-v1,key-delegation-v1", "hostApiVersion=0.38.6", + valid + "&hostApiVersion=0.38.6", valid + "&hostApiVersion=", + valid + "&hostApiCapabilities=key-audit-v1,key-delegation-v1", valid + "&hostApiCapabilities=", + "hostApiVersion=0.38.6&hostApiCapabilities=", "hostApiVersion=0.38.6&hostApiCapabilities=key-audit-v1", + "hostApiVersion=0.38.6&hostApiCapabilities=key-delegation-v1,key-audit-v1", + "hostApiVersion=0.38.6&hostApiCapabilities=key-audit-v1,key-delegation-v2", + valid + ";invalid", valid + "&hostApiVersion=%zz", + } { + if declaredKeyCapabilities(&http.Request{URL: &url.URL{RawQuery: query}}) { + t.Errorf("malformed or ambiguous declaration accepted: %q", query) + } + } +} + +func TestCompatiblePatchBackendRegistrationKeepsKeyAndOrdinaryAuth(t *testing.T) { + scoped, _, verifier, _ := scopedProxyFixture(t) + full := testutils.CreateTokenWithPayload(map[string]interface{}{"hostUuid": "h1", "apiKeyAudit": map[string]interface{}{"version": 1}}, testutils.ParseTestPrivateKey()) + ordinary := testutils.CreateTokenWithPayload(map[string]interface{}{"hostUuid": "h1"}, testutils.ParseTestPrivateKey()) + for _, version := range []string{"0.38.5", "0.38.6"} { + t.Run(version, func(t *testing.T) { + manager := &capabilityManager{registered: make(chan bool, 1)} + handler := &BackendHandler{proxyManager: manager, parsedPublicKey: testutils.ParseTestPublicKey()} + server := httptest.NewServer(handler) + defer server.Close() + backendToken := testutils.CreateTokenWithPayload(map[string]interface{}{"reportedUuid": "h1"}, testutils.ParseTestPrivateKey()) + query := url.Values{"hostApiVersion": {version}, "hostApiCapabilities": {"key-audit-v1,key-delegation-v1"}} + headers := http.Header{"Authorization": []string{"Bearer " + backendToken}} + ws, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http")+"/?"+query.Encode(), headers) + if err != nil { + t.Fatal("authenticated compatible backend registration failed") + } + defer ws.Close() + var capable bool + select { + case capable = <-manager.registered: + case <-time.After(time.Second): + t.Fatal("backend registration barrier timed out") + } + if !capable || manager.calls.Load() != 1 { + t.Fatal("authenticated compatible patch lost capability") + } + backend := &capabilityTestBackend{capable: capable} + frontend := &FrontendHandler{backend: backend, parsedPublicKey: testutils.ParseTestPublicKey(), delegations: verifier} + for _, raw := range []string{scoped, full, ordinary} { + request := httptest.NewRequest("GET", "http://proxy/v2-beta/exec/", nil) + request.Header.Set("Authorization", "Bearer "+raw) + if _, host, err := frontend.auth(request); err != nil || host != "h1" { + t.Fatal("registered capability did not preserve Key and ordinary authentication") + } + } + }) + } +} + func TestBackendCapabilityCannotBeForgedWithoutAnAuthenticatedReportedHostTicket(t *testing.T) { manager := &capabilityManager{registered: make(chan bool, 1)} handler := &BackendHandler{proxyManager: manager, parsedPublicKey: testutils.ParseTestPublicKey()} diff --git a/scripts/validate b/scripts/validate index 61bc112..6a31746 100755 --- a/scripts/validate +++ b/scripts/validate @@ -30,7 +30,7 @@ if [ "${#maintained_go_files[@]}" -gt 0 ]; then fi echo Running: version and dependency policy -test "$(VERSION_OVERRIDE=v0.23.16 bash -c 'source scripts/version; printf %s "$VERSION"')" = "0.23.16" +test "$(VERSION_OVERRIDE=v0.23.17 bash -c 'source scripts/version; printf %s "$VERSION"')" = "0.23.17" if VERSION_OVERRIDE=v0.23.12-maintenance.1 bash -c 'source scripts/version' >/dev/null 2>&1; then echo "non-numeric version suffix was accepted" >&2 exit 1 diff --git a/scripts/version b/scripts/version index 00935e5..1bdea2e 100755 --- a/scripts/version +++ b/scripts/version @@ -12,5 +12,5 @@ if [ -n "${VERSION_OVERRIDE:-}" ]; then fi VERSION=${VERSION_OVERRIDE#v} else - VERSION="0.23.16" + VERSION="0.23.17" fi