From 12e8874517f1d33f807915dbff9017e8e080c725 Mon Sep 17 00:00:00 2001 From: Theodor Midtlien Date: Mon, 31 Aug 2026 18:01:14 +0200 Subject: [PATCH] [client, relay, management] Bump go version to 1.26 and go-quic to v0.62.0 (#7359) * Bump go version to 1.26 and go-quic to v0.62.0 * Replace deprecated ecdsa public key assembly and add tests for jwt * Update goversioninfo * Pin go toolchain to 1.26.7 --- .devcontainer/Dockerfile | 2 +- .github/workflows/golang-test-linux.yml | 2 +- .github/workflows/release.yml | 4 +- CONTRIBUTING.md | 4 +- client/testutil/privileged/runner_test.go | 2 +- client/ui/build/docker/Dockerfile.cross | 2 +- client/ui/build/docker/Dockerfile.server | 2 +- combined/Dockerfile.multistage | 2 +- docs/testing-privileged.md | 2 +- e2e/harness/Dockerfile.client | 2 +- go.mod | 14 +- go.sum | 16 +- management/Dockerfile.multistage | 2 +- proxy/Dockerfile | 2 +- proxy/Dockerfile.multistage | 2 +- shared/auth/jwt/validator.go | 66 +++++-- shared/auth/jwt/validator_test.go | 214 ++++++++++++++++++++++ 17 files changed, 294 insertions(+), 46 deletions(-) create mode 100644 shared/auth/jwt/validator_test.go diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 0661e0c71..9b6a0edfd 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25-bookworm +FROM golang:1.26.7-bookworm RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends\ diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index c93e36e4e..f24dfbe9d 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -233,7 +233,7 @@ jobs: -e GOCACHE=${CONTAINER_GOCACHE} \ -e GOMODCACHE=${CONTAINER_GOMODCACHE} \ -e CONTAINER=${CONTAINER} \ - golang:1.25-alpine \ + golang:1.26.7-alpine \ sh -c ' \ apk update; apk add --no-cache \ ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d1945451..c1bbe9c44 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -215,7 +215,7 @@ jobs: echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV - name: Install goversioninfo - run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e + run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@b66839b # v1.7.0 - name: Generate windows syso amd64 run: goversioninfo -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso - name: Generate windows syso arm64 @@ -435,7 +435,7 @@ jobs: tar -xf llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64.tar.xz echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH - name: Install goversioninfo - run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e + run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@b66839b # v1.7.0 - name: Install wails3 CLI # Version derived from go.mod so the binding generator always matches # the wails runtime the binary links against. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9dea37ec8..aef749cfa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -192,7 +192,7 @@ dependencies are installed. Here is a short guide on how that can be done. ### Requirements -#### Go 1.25 +#### Go 1.26 Follow the installation guide from https://go.dev/ @@ -200,7 +200,7 @@ Follow the installation guide from https://go.dev/ The desktop UI client (`client/ui`) is built with [Wails v3](https://v3.wails.io/) and a React frontend rendered in a WebView. To build it you need: -- Go ≥ 1.25 +- Go ≥ 1.26 - Node ≥ 20 and **pnpm** (`corepack enable && corepack prepare pnpm@latest --activate`) - The `wails3` CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@latest` - The `task` runner: `go install github.com/go-task/task/v3/cmd/task@latest` diff --git a/client/testutil/privileged/runner_test.go b/client/testutil/privileged/runner_test.go index d1945894d..157005d3e 100644 --- a/client/testutil/privileged/runner_test.go +++ b/client/testutil/privileged/runner_test.go @@ -25,7 +25,7 @@ import ( // (.github/workflows/golang-test-linux.yml, test_client_on_docker). const ( containerImage = "golang" - containerTag = "1.25-alpine" + containerTag = "1.26.7-alpine" ) const ( diff --git a/client/ui/build/docker/Dockerfile.cross b/client/ui/build/docker/Dockerfile.cross index a487b8db0..55c0d69e1 100644 --- a/client/ui/build/docker/Dockerfile.cross +++ b/client/ui/build/docker/Dockerfile.cross @@ -13,7 +13,7 @@ # docker run --rm -v $(pwd):/app wails-cross windows amd64 # docker run --rm -v $(pwd):/app wails-cross windows arm64 -FROM golang:1.25-bookworm +FROM golang:1.26.7-bookworm ARG TARGETARCH diff --git a/client/ui/build/docker/Dockerfile.server b/client/ui/build/docker/Dockerfile.server index 58fb64f76..57183f1d2 100644 --- a/client/ui/build/docker/Dockerfile.server +++ b/client/ui/build/docker/Dockerfile.server @@ -2,7 +2,7 @@ # Multi-stage build for minimal image size # Build stage -FROM golang:alpine AS builder +FROM golang:1.26.7-alpine AS builder WORKDIR /app diff --git a/combined/Dockerfile.multistage b/combined/Dockerfile.multistage index 79746819d..011379c2f 100644 --- a/combined/Dockerfile.multistage +++ b/combined/Dockerfile.multistage @@ -1,4 +1,4 @@ -FROM golang:1.25-bookworm AS builder +FROM golang:1.26.7-bookworm AS builder WORKDIR /app # Install build dependencies diff --git a/docs/testing-privileged.md b/docs/testing-privileged.md index cf2f23171..72e8a0f8f 100644 --- a/docs/testing-privileged.md +++ b/docs/testing-privileged.md @@ -32,7 +32,7 @@ list; both are optional and default to the full privileged suite. 1. Skips immediately when it detects it is already inside the container (`DOCKER_CI=true`), so the privileged tests run in place instead of recursing. -2. Otherwise spins up a `golang:1.25-alpine` container (matching CI), +2. Otherwise spins up a `golang:1.26.7-alpine` container (matching CI), bind-mounts the repo and the host Go build/module caches, installs the required packages, and runs `go test -tags 'devcert privileged'` over the client packages. diff --git a/e2e/harness/Dockerfile.client b/e2e/harness/Dockerfile.client index 74a3ec245..4c76b95c6 100644 --- a/e2e/harness/Dockerfile.client +++ b/e2e/harness/Dockerfile.client @@ -3,7 +3,7 @@ # artifact), so this mirrors its alpine runtime + entrypoint while compiling the # CGO-free client inline. BuildKit cache mounts keep rebuilds incremental. -FROM golang:1.25-bookworm AS builder +FROM golang:1.26.7-bookworm AS builder WORKDIR /src COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod go mod download diff --git a/go.mod b/go.mod index cede9c22d..a2fe1e55b 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,10 @@ module github.com/netbirdio/netbird -go 1.25.5 +go 1.26.0 -toolchain go1.25.12 +// Pin the toolchain to a patch release >= go1.26.2 +// See https://go.dev/issue/77875. +toolchain go1.26.7 require ( cunicu.li/go-rosenpass v0.5.42 @@ -101,13 +103,13 @@ require ( github.com/pkg/sftp v1.13.9 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 - github.com/quic-go/quic-go v0.59.1 + github.com/quic-go/quic-go v0.62.0 github.com/redis/go-redis/v9 v9.7.3 github.com/rs/xid v1.3.0 github.com/shirou/gopsutil/v4 v4.25.8 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.1 github.com/testcontainers/testcontainers-go v0.37.0 github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 @@ -289,7 +291,6 @@ require ( github.com/pion/transport/v2 v2.2.4 // indirect github.com/pion/turn/v4 v4.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/pquerna/otp v1.5.0 // indirect github.com/prometheus/common v0.67.5 // indirect @@ -299,7 +300,7 @@ require ( github.com/ryanuber/go-glob v1.0.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/tinylib/msgp v1.6.3 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect @@ -315,6 +316,7 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/tools v0.49.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect diff --git a/go.sum b/go.sum index e5bf6248d..3e0b4f5dc 100644 --- a/go.sum +++ b/go.sum @@ -582,8 +582,10 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= -github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= -github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8= +github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -619,8 +621,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= @@ -630,8 +632,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 h1:LqUos1oR5iuuzorFnSvxsHNdYdCHB/DfI82CuT58wbI= @@ -717,6 +719,8 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= goauthentik.io/api/v3 v3.2023051.3 h1:NebAhD/TeTWNo/9X3/Uj+rM5fG1HaiLOlKTNLQv9Qq4= goauthentik.io/api/v3 v3.2023051.3/go.mod h1:nYECml4jGbp/541hj8GcylKQG1gVBsKppHy4+7G8u4U= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/management/Dockerfile.multistage b/management/Dockerfile.multistage index 619f84615..5d037f1b1 100644 --- a/management/Dockerfile.multistage +++ b/management/Dockerfile.multistage @@ -1,4 +1,4 @@ -FROM golang:1.25-bookworm AS builder +FROM golang:1.26.7-bookworm AS builder WORKDIR /app # Install build dependencies diff --git a/proxy/Dockerfile b/proxy/Dockerfile index 22c4cbfaa..5944d944e 100644 --- a/proxy/Dockerfile +++ b/proxy/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25-alpine AS builder +FROM golang:1.26.7-alpine AS builder WORKDIR /app RUN echo "netbird:x:1000:1000:netbird:/var/lib/netbird:/sbin/nologin" > /tmp/passwd && \ diff --git a/proxy/Dockerfile.multistage b/proxy/Dockerfile.multistage index 4f360a811..d1db32296 100644 --- a/proxy/Dockerfile.multistage +++ b/proxy/Dockerfile.multistage @@ -1,4 +1,4 @@ -FROM golang:1.25-alpine AS builder +FROM golang:1.26.7-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ diff --git a/shared/auth/jwt/validator.go b/shared/auth/jwt/validator.go index cf18b2cf6..62e127751 100644 --- a/shared/auth/jwt/validator.go +++ b/shared/auth/jwt/validator.go @@ -289,36 +289,64 @@ func getPublicKey(token *jwt.Token, jwks *Jwks) (interface{}, error) { return nil, errKeyNotFound } -func getPublicKeyFromECDSA(jwk JSONWebKey) (publicKey *ecdsa.PublicKey, err error) { +func curveFromName(crv string) (elliptic.Curve, error) { + switch crv { + case p256: + return elliptic.P256(), nil + case p384: + return elliptic.P384(), nil + case p521: + return elliptic.P521(), nil + default: + return nil, fmt.Errorf("unsupported elliptic curve %q", crv) + } +} + +func getPublicKeyFromECDSA(jwk JSONWebKey) (*ecdsa.PublicKey, error) { if jwk.X == "" || jwk.Y == "" || jwk.Crv == "" { return nil, fmt.Errorf("ecdsa key incomplete") } - var xCoordinate []byte - if xCoordinate, err = base64.RawURLEncoding.DecodeString(jwk.X); err != nil { + curve, err := curveFromName(jwk.Crv) + if err != nil { return nil, err } - var yCoordinate []byte - if yCoordinate, err = base64.RawURLEncoding.DecodeString(jwk.Y); err != nil { - return nil, err + xCoordinate, err := base64.RawURLEncoding.DecodeString(jwk.X) + if err != nil { + return nil, fmt.Errorf("decode ecdsa x coordinate: %w", err) } - publicKey = &ecdsa.PublicKey{} - - var curve elliptic.Curve - switch jwk.Crv { - case p256: - curve = elliptic.P256() - case p384: - curve = elliptic.P384() - case p521: - curve = elliptic.P521() + yCoordinate, err := base64.RawURLEncoding.DecodeString(jwk.Y) + if err != nil { + return nil, fmt.Errorf("decode ecdsa y coordinate: %w", err) } - publicKey.Curve = curve - publicKey.X = big.NewInt(0).SetBytes(xCoordinate) - publicKey.Y = big.NewInt(0).SetBytes(yCoordinate) + var x, y big.Int + x.SetBytes(xCoordinate) + y.SetBytes(yCoordinate) + + bits := curve.Params().BitSize + if x.BitLen() > bits { + return nil, fmt.Errorf("ecdsa x coordinate is %d bits, exceeds curve %s field size of %d bits", x.BitLen(), jwk.Crv, bits) + } + if y.BitLen() > bits { + return nil, fmt.Errorf("ecdsa y coordinate is %d bits, exceeds curve %s field size of %d bits", y.BitLen(), jwk.Crv, bits) + } + + // Round up: P-521's field is 521 bits, so a coordinate needs 66 bytes, not 65. + size := (bits + 7) / 8 + + // Assemble the SEC 1 uncompressed point (0x04 || X || Y) + point := make([]byte, 1+2*size) + point[0] = 4 + x.FillBytes(point[1 : 1+size]) + y.FillBytes(point[1+size:]) + + publicKey, err := ecdsa.ParseUncompressedPublicKey(curve, point) + if err != nil { + return nil, fmt.Errorf("parse ecdsa public key: %w", err) + } return publicKey, nil } diff --git a/shared/auth/jwt/validator_test.go b/shared/auth/jwt/validator_test.go new file mode 100644 index 000000000..a5b3f4a39 --- /dev/null +++ b/shared/auth/jwt/validator_test.go @@ -0,0 +1,214 @@ +package jwt + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ecdsaJWK builds a JWK for pub using uncompressed-point encoding +func ecdsaJWK(t *testing.T, kid string, pub *ecdsa.PublicKey, crv string, size int) JSONWebKey { + t.Helper() + + point, err := pub.Bytes() + require.NoError(t, err) + require.Len(t, point, 1+2*size) + require.Equal(t, byte(4), point[0], "expected uncompressed point") + + return JSONWebKey{ + Kty: "EC", + Kid: kid, + Use: "sig", + Crv: crv, + X: base64.RawURLEncoding.EncodeToString(point[1 : 1+size]), + Y: base64.RawURLEncoding.EncodeToString(point[1+size:]), + } +} + +func TestGetPublicKeyFromECDSA_RoundTrip(t *testing.T) { + tests := []struct { + crv string + curve elliptic.Curve + size int + }{ + {p256, elliptic.P256(), 32}, + {p384, elliptic.P384(), 48}, + {p521, elliptic.P521(), 66}, + } + + for _, tc := range tests { + t.Run(tc.crv, func(t *testing.T) { + priv, err := ecdsa.GenerateKey(tc.curve, rand.Reader) + require.NoError(t, err) + + got, err := getPublicKeyFromECDSA(ecdsaJWK(t, "kid", &priv.PublicKey, tc.crv, tc.size)) + require.NoError(t, err) + assert.True(t, priv.PublicKey.Equal(got), "parsed key differs from the original") + }) + } +} + +// TestGetPublicKeyFromECDSA_ShortCoordinate covers IdPs that strip leading zero +// bytes from a coordinate instead of padding to the curve's field size. +func TestGetPublicKeyFromECDSA_ShortCoordinate(t *testing.T) { + var ( + priv *ecdsa.PrivateKey + point []byte + ) + for i := 0; i < 10000; i++ { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + p, err := key.PublicKey.Bytes() + require.NoError(t, err) + + if p[1] == 0 || p[33] == 0 { + priv, point = key, p + break + } + } + require.NotNil(t, priv, "no key with a leading zero coordinate byte was generated") + + jwk := JSONWebKey{ + Kty: "EC", + Kid: "kid", + Crv: p256, + X: base64.RawURLEncoding.EncodeToString(bytes.TrimLeft(point[1:33], "\x00")), + Y: base64.RawURLEncoding.EncodeToString(bytes.TrimLeft(point[33:], "\x00")), + } + + got, err := getPublicKeyFromECDSA(jwk) + require.NoError(t, err) + assert.True(t, priv.PublicKey.Equal(got)) +} + +func TestGetPublicKeyFromECDSA_Invalid(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + valid := ecdsaJWK(t, "kid", &priv.PublicKey, p256, 32) + + offCurve := valid + x, err := base64.RawURLEncoding.DecodeString(valid.X) + require.NoError(t, err) + x[31] ^= 0xff + offCurve.X = base64.RawURLEncoding.EncodeToString(x) + + // 33 non-zero bytes is 264 bits, past P-256's 256-bit field. + oversized := valid + oversized.X = base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0xff}, 33)) + + // P-521 coordinates occupy 66 bytes but only 521 bits, so a full 66-byte + // 0xff value (528 bits) is over the field size without being over the byte + // length. Only a bit-length bound catches this. + overP521 := JSONWebKey{ + Kty: "EC", + Crv: p521, + X: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0xff}, 66)), + Y: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0xff}, 66)), + } + + zeroPoint := valid + zeroPoint.X = base64.RawURLEncoding.EncodeToString(make([]byte, 32)) + zeroPoint.Y = base64.RawURLEncoding.EncodeToString(make([]byte, 32)) + + tests := []struct { + name string + jwk JSONWebKey + errContains string + }{ + {name: "missing crv", jwk: JSONWebKey{Kty: "EC", X: valid.X, Y: valid.Y}}, + {name: "missing x", jwk: JSONWebKey{Kty: "EC", Crv: p256, Y: valid.Y}}, + {name: "unsupported curve", jwk: JSONWebKey{Kty: "EC", Crv: "P-224", X: valid.X, Y: valid.Y}, errContains: "unsupported elliptic curve"}, + {name: "undecodable x", jwk: JSONWebKey{Kty: "EC", Crv: p256, X: "!!not base64!!!", Y: valid.Y}, errContains: "decode ecdsa x coordinate"}, + {name: "coordinate over field size", jwk: oversized, errContains: "exceeds curve P-256 field size of 256 bits"}, + {name: "p521 coordinate over field size", jwk: overP521, errContains: "exceeds curve P-521 field size of 521 bits"}, + {name: "off-curve point", jwk: offCurve, errContains: "parse ecdsa public key"}, + {name: "point at infinity", jwk: zeroPoint, errContains: "parse ecdsa public key"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + key, err := getPublicKeyFromECDSA(tc.jwk) + require.Error(t, err) + assert.Nil(t, key) + if tc.errContains != "" { + assert.ErrorContains(t, err, tc.errContains) + } + }) + } +} + +// TestValidateAndParse_ECDSA verifies an ES256-signed token end to end, proving +// the parsed key actually validates signatures. +func TestValidateAndParse_ECDSA(t *testing.T) { + const ( + kid = "es256-kid" + issuer = "https://issuer.example.com/" + audience = "netbird" + ) + + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + jwks, err := json.Marshal(Jwks{Keys: []JSONWebKey{ecdsaJWK(t, kid, &priv.PublicKey, p256, 32)}}) + require.NoError(t, err) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jwks) + })) + defer srv.Close() + + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ + "iss": issuer, + "aud": audience, + "sub": "user-1", + "iat": time.Now().Add(-time.Minute).Unix(), + "exp": time.Now().Add(time.Hour).Unix(), + }) + token.Header["kid"] = kid + + signed, err := token.SignedString(priv) + require.NoError(t, err) + + v := NewValidator(issuer, []string{audience}, srv.URL, false) + + parsed, err := v.ValidateAndParse(context.Background(), signed) + require.NoError(t, err) + require.True(t, parsed.Valid) + + claims, ok := parsed.Claims.(jwt.MapClaims) + require.True(t, ok) + assert.Equal(t, "user-1", claims["sub"]) + + // A token signed by a different key of the same curve must be rejected. + other, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + forged := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ + "iss": issuer, + "aud": audience, + "sub": "user-1", + "iat": time.Now().Add(-time.Minute).Unix(), + "exp": time.Now().Add(time.Hour).Unix(), + }) + forged.Header["kid"] = kid + + forgedSigned, err := forged.SignedString(other) + require.NoError(t, err) + + _, err = v.ValidateAndParse(context.Background(), forgedSigned) + require.Error(t, err) +}