[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
This commit is contained in:
Theodor Midtlien
2026-08-31 18:01:14 +02:00
committed by GitHub
parent 24959e1ed9
commit 12e8874517
17 changed files with 294 additions and 46 deletions

View File

@@ -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\

View File

@@ -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; \

View File

@@ -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.

View File

@@ -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`

View File

@@ -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 (

View File

@@ -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

View File

@@ -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

View File

@@ -1,4 +1,4 @@
FROM golang:1.25-bookworm AS builder
FROM golang:1.26.7-bookworm AS builder
WORKDIR /app
# Install build dependencies

View File

@@ -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.

View File

@@ -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

14
go.mod
View File

@@ -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

16
go.sum
View File

@@ -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=

View File

@@ -1,4 +1,4 @@
FROM golang:1.25-bookworm AS builder
FROM golang:1.26.7-bookworm AS builder
WORKDIR /app
# Install build dependencies

View File

@@ -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 && \

View File

@@ -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 ./

View File

@@ -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
}

View File

@@ -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)
}