From cd948a0abe52957d29032cc5674d915001329fb6 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Sat, 14 Nov 2026 14:56:34 -0800 Subject: [PATCH 1/3] auth: support for EdDSA public/private keys required for asymmetric keys, which will be implemented in the LK ecosystem --- .changeset/auth-verify-with-key.md | 5 ++ auth/accesstoken.go | 18 ++++- auth/accesstoken_test.go | 21 ++++++ auth/interfaces.go | 4 ++ auth/verifier.go | 54 +++++++++++++-- auth/verifier_test.go | 102 +++++++++++++++++++++++++++++ 6 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 .changeset/auth-verify-with-key.md diff --git a/.changeset/auth-verify-with-key.md b/.changeset/auth-verify-with-key.md new file mode 100644 index 000000000..1dc608101 --- /dev/null +++ b/.changeset/auth-verify-with-key.md @@ -0,0 +1,5 @@ +--- +"github.com/livekit/protocol": patch +--- + +auth: `VerifyWithKey` verifies a token with the algorithm its key picks, HS256 for a version 1 key and EdDSA for a version 2 key, and `NewAccessTokenWithPrivateKey` signs a token with EdDSA. `ParseAPIToken` accepts EdDSA tokens; `Verify` stays HS256 only. Version 2 keys are not supported by any server yet, so do not use them diff --git a/auth/accesstoken.go b/auth/accesstoken.go index d0daf35b0..599ce62c8 100644 --- a/auth/accesstoken.go +++ b/auth/accesstoken.go @@ -15,6 +15,7 @@ package auth import ( + "crypto/ed25519" "time" "github.com/golang-jwt/jwt/v5" @@ -37,6 +38,7 @@ const ( type AccessToken struct { apiKey string secret string + privateKey ed25519.PrivateKey grant ClaimGrants validFor time.Duration allowSensitiveCredentials bool @@ -49,6 +51,17 @@ func NewAccessToken(key string, secret string) *AccessToken { } } +// NewAccessTokenWithPrivateKey returns a token that ToJWT signs with EdDSA, +// for a version 2 key. +// +// Do not use it yet: no LiveKit server or SDK supports version 2 keys. +func NewAccessTokenWithPrivateKey(key string, privateKey ed25519.PrivateKey) *AccessToken { + return &AccessToken{ + apiKey: key, + privateKey: privateKey, + } +} + func (t *AccessToken) SetIdentity(identity string) *AccessToken { t.grant.Identity = identity return t @@ -185,7 +198,7 @@ func (t *AccessToken) GetGrants() *ClaimGrants { } func (t *AccessToken) ToJWT() (string, error) { - if t.apiKey == "" || t.secret == "" { + if t.apiKey == "" || (t.secret == "" && len(t.privateKey) != ed25519.PrivateKeySize) { return "", ErrKeysMissing } @@ -211,5 +224,8 @@ func (t *AccessToken) ToJWT() (string, error) { }, ClaimGrants: t.grant, } + if t.privateKey != nil { + return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(t.privateKey) + } return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(t.secret)) } diff --git a/auth/accesstoken_test.go b/auth/accesstoken_test.go index d4574c9de..aeac545c3 100644 --- a/auth/accesstoken_test.go +++ b/auth/accesstoken_test.go @@ -15,6 +15,7 @@ package auth import ( + "crypto/ed25519" "encoding/base64" "encoding/json" "strings" @@ -35,6 +36,26 @@ func TestAccessToken(t *testing.T) { token := NewAccessToken("", "") _, err := token.ToJWT() require.Equal(t, ErrKeysMissing, err) + + apiKey, _ := apiKeypair() + _, err = NewAccessTokenWithPrivateKey(apiKey, nil).ToJWT() + require.Equal(t, ErrKeysMissing, err) + _, err = NewAccessTokenWithPrivateKey(apiKey, make(ed25519.PrivateKey, 16)).ToJWT() + require.Equal(t, ErrKeysMissing, err) + }) + + t.Run("private key signs with EdDSA", func(t *testing.T) { + apiKey, _ := apiKeypair() + _, priv, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + value, err := NewAccessTokenWithPrivateKey(apiKey, priv).SetIdentity("user").ToJWT() + require.NoError(t, err) + + decoded := tokenClaims{} + token, _, err := unverifiedParser.ParseUnverified(value, &decoded) + require.NoError(t, err) + require.Equal(t, "EdDSA", token.Method.Alg()) + require.Equal(t, apiKey, decoded.Issuer) }) t.Run("generates a decode-able key", func(t *testing.T) { diff --git a/auth/interfaces.go b/auth/interfaces.go index 3f9b33f83..c912b2fe0 100644 --- a/auth/interfaces.go +++ b/auth/interfaces.go @@ -22,6 +22,10 @@ import ( var ( ErrKeysMissing = errors.New("missing API key or secret key") + + ErrKeyAlgorithmMismatch = errors.New("token signing algorithm does not match the API key") + ErrUnsupportedKeyVersion = errors.New("unsupported API key version") + ErrInvalidPublicKey = errors.New("invalid API key public key") ) //counterfeiter:generate . TokenVerifier diff --git a/auth/verifier.go b/auth/verifier.go index 60a476d35..f59cb25de 100644 --- a/auth/verifier.go +++ b/auth/verifier.go @@ -15,6 +15,8 @@ package auth import ( + "crypto/ed25519" + "fmt" "time" "github.com/golang-jwt/jwt/v5" @@ -24,21 +26,34 @@ import ( const tokenLeeway = time.Minute var ( - allowedSigningMethods = []string{jwt.SigningMethodHS256.Alg()} + // The token is parsed before its key is known, so both methods are allowed here. + // The key picks the one method that verifies the signature. + allowedSigningMethods = []string{jwt.SigningMethodHS256.Alg(), jwt.SigningMethodEdDSA.Alg()} unverifiedParser = jwt.NewParser(jwt.WithValidMethods(allowedSigningMethods)) ) +// VerificationKey is what a key record gives the verifier. +// +// Do not use version 2 keys yet: no LiveKit server or SDK supports them. +type VerificationKey struct { + Version int // 1: HS256 with Secret, 2: EdDSA with PublicKey + Secret string // version 1 + PublicKey ed25519.PublicKey // version 2 +} + type APIKeyTokenVerifier struct { raw string identity string apiKey string + alg string } // ParseAPIToken parses an encoded JWT token without verifying its signature. -// The signature is verified later by Verify, which requires the API secret. +// The signature is verified later by Verify or VerifyWithKey, which require the key. func ParseAPIToken(raw string) (*APIKeyTokenVerifier, error) { claims := &jwt.RegisteredClaims{} - if _, _, err := unverifiedParser.ParseUnverified(raw, claims); err != nil { + token, _, err := unverifiedParser.ParseUnverified(raw, claims) + if err != nil { return nil, err } @@ -46,6 +61,7 @@ func ParseAPIToken(raw string) (*APIKeyTokenVerifier, error) { raw: raw, apiKey: claims.Issuer, identity: claims.Subject, + alg: token.Method.Alg(), } if v.identity == "" { v.identity = claims.ID @@ -62,6 +78,7 @@ func (v *APIKeyTokenVerifier) Identity() string { return v.identity } +// Verify verifies a token of a version 1 key: HS256 with the API secret. func (v *APIKeyTokenVerifier) Verify(key interface{}) (*jwt.RegisteredClaims, *ClaimGrants, error) { if key == nil || key == "" { return nil, nil, ErrKeysMissing @@ -69,12 +86,41 @@ func (v *APIKeyTokenVerifier) Verify(key interface{}) (*jwt.RegisteredClaims, *C if s, ok := key.(string); ok { key = []byte(s) } + return v.verify(jwt.SigningMethodHS256, key) +} + +// VerifyWithKey picks the algorithm from the key, never from the token header: +// HS256 with the secret for version 1, EdDSA with the public key for version 2. +func (v *APIKeyTokenVerifier) VerifyWithKey(k VerificationKey) (*jwt.RegisteredClaims, *ClaimGrants, error) { + switch k.Version { + case 1: + if k.Secret == "" { + return nil, nil, ErrKeysMissing + } + return v.verify(jwt.SigningMethodHS256, []byte(k.Secret)) + case 2: + if len(k.PublicKey) == 0 { + return nil, nil, ErrKeysMissing + } + if len(k.PublicKey) != ed25519.PublicKeySize { + return nil, nil, ErrInvalidPublicKey + } + return v.verify(jwt.SigningMethodEdDSA, k.PublicKey) + default: + return nil, nil, fmt.Errorf("%w: %d", ErrUnsupportedKeyVersion, k.Version) + } +} + +func (v *APIKeyTokenVerifier) verify(method jwt.SigningMethod, key interface{}) (*jwt.RegisteredClaims, *ClaimGrants, error) { + if v.alg != method.Alg() { + return nil, nil, fmt.Errorf("%w: key requires %s, token uses %s", ErrKeyAlgorithmMismatch, method.Alg(), v.alg) + } claims := &tokenClaims{} _, err := jwt.ParseWithClaims(v.raw, claims, func(*jwt.Token) (interface{}, error) { return key, nil }, - jwt.WithValidMethods(allowedSigningMethods), + jwt.WithValidMethods([]string{method.Alg()}), jwt.WithIssuer(v.apiKey), jwt.WithLeeway(tokenLeeway), // Without this, a token carrying no exp claim passes validation and diff --git a/auth/verifier_test.go b/auth/verifier_test.go index dd5d23c71..eb838eda0 100644 --- a/auth/verifier_test.go +++ b/auth/verifier_test.go @@ -15,6 +15,8 @@ package auth_test import ( + "crypto/ed25519" + "crypto/rand" "encoding/json" "testing" "time" @@ -176,3 +178,103 @@ func TestVerifier(t *testing.T) { require.False(t, *decoded.Video.CanPublishData) }) } + +func TestVerifyWithKey(t *testing.T) { + apiKey := "APID3B67uxk4Nj2GKiRPibAZ9" + secret := "YHC-CUhbQhGeVCaYgn1BNA++" + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + v1Key := auth.VerificationKey{Version: 1, Secret: secret} + v2Key := auth.VerificationKey{Version: 2, PublicKey: pub} + + grant := &auth.VideoGrant{RoomJoin: true, Room: "myroom"} + hsToken, err := auth.NewAccessToken(apiKey, secret).SetVideoGrant(grant).SetIdentity("me").ToJWT() + require.NoError(t, err) + edToken, err := auth.NewAccessTokenWithPrivateKey(apiKey, priv).SetVideoGrant(grant).SetIdentity("me").ToJWT() + require.NoError(t, err) + + parse := func(t *testing.T, token string) *auth.APIKeyTokenVerifier { + v, err := auth.ParseAPIToken(token) + require.NoError(t, err) + require.Equal(t, apiKey, v.APIKey()) + require.Equal(t, "me", v.Identity()) + return v + } + + t.Run("v1 key verifies HS256", func(t *testing.T) { + _, decoded, err := parse(t, hsToken).VerifyWithKey(v1Key) + require.NoError(t, err) + require.Equal(t, grant, decoded.Video) + }) + + t.Run("v2 key verifies EdDSA", func(t *testing.T) { + _, decoded, err := parse(t, edToken).VerifyWithKey(v2Key) + require.NoError(t, err) + require.Equal(t, grant, decoded.Video) + }) + + t.Run("v2 key refuses HS256", func(t *testing.T) { + _, _, err := parse(t, hsToken).VerifyWithKey(v2Key) + require.ErrorIs(t, err, auth.ErrKeyAlgorithmMismatch) + }) + + t.Run("v1 key refuses EdDSA", func(t *testing.T) { + _, _, err := parse(t, edToken).VerifyWithKey(v1Key) + require.ErrorIs(t, err, auth.ErrKeyAlgorithmMismatch) + }) + + t.Run("v2 key refuses HS256 signed with the public key as secret", func(t *testing.T) { + // algorithm confusion: the attacker knows the public key and uses it as an HMAC secret + token, err := auth.NewAccessToken(apiKey, string(pub)).SetVideoGrant(grant).SetIdentity("me").ToJWT() + require.NoError(t, err) + _, _, err = parse(t, token).VerifyWithKey(v2Key) + require.ErrorIs(t, err, auth.ErrKeyAlgorithmMismatch) + }) + + t.Run("v2 key refuses EdDSA signed with another key", func(t *testing.T) { + _, otherPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + token, err := auth.NewAccessTokenWithPrivateKey(apiKey, otherPriv).SetIdentity("me").ToJWT() + require.NoError(t, err) + _, _, err = parse(t, token).VerifyWithKey(v2Key) + require.Error(t, err) + }) + + t.Run("expired EdDSA token is refused", func(t *testing.T) { + token, err := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{ + "iss": apiKey, + "sub": "me", + "exp": jwt.NewNumericDate(time.Now().Add(-time.Hour)), + }).SignedString(priv) + require.NoError(t, err) + _, _, err = parse(t, token).VerifyWithKey(v2Key) + require.ErrorIs(t, err, jwt.ErrTokenExpired) + }) + + t.Run("v1 key with empty secret is refused", func(t *testing.T) { + _, _, err := parse(t, hsToken).VerifyWithKey(auth.VerificationKey{Version: 1}) + require.ErrorIs(t, err, auth.ErrKeysMissing) + }) + + t.Run("v2 key with empty or wrong-size public key is refused", func(t *testing.T) { + _, _, err := parse(t, edToken).VerifyWithKey(auth.VerificationKey{Version: 2}) + require.ErrorIs(t, err, auth.ErrKeysMissing) + _, _, err = parse(t, edToken).VerifyWithKey(auth.VerificationKey{Version: 2, PublicKey: pub[:16]}) + require.ErrorIs(t, err, auth.ErrInvalidPublicKey) + }) + + t.Run("unknown version is refused", func(t *testing.T) { + for _, version := range []int{0, 3} { + _, _, err := parse(t, hsToken).VerifyWithKey(auth.VerificationKey{Version: version, Secret: secret, PublicKey: pub}) + require.ErrorIs(t, err, auth.ErrUnsupportedKeyVersion) + } + }) + + t.Run("Verify refuses EdDSA", func(t *testing.T) { + v := parse(t, edToken) + _, _, err := v.Verify(secret) + require.ErrorIs(t, err, auth.ErrKeyAlgorithmMismatch) + _, _, err = v.Verify(pub) + require.ErrorIs(t, err, auth.ErrKeyAlgorithmMismatch) + }) +} From 835b5054ff29f0302343ec7cda1b95ab493732d9 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Sat, 14 Nov 2026 17:15:17 -0800 Subject: [PATCH 2/3] add typ for v2 keys --- auth/accesstoken.go | 5 ++++- auth/interfaces.go | 2 ++ auth/verifier.go | 24 +++++++++++++++++++++--- auth/verifier_test.go | 31 +++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/auth/accesstoken.go b/auth/accesstoken.go index 599ce62c8..1554c2a18 100644 --- a/auth/accesstoken.go +++ b/auth/accesstoken.go @@ -225,7 +225,10 @@ func (t *AccessToken) ToJWT() (string, error) { ClaimGrants: t.grant, } if t.privateKey != nil { - return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(t.privateKey) + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + token.Header["kid"] = t.apiKey + token.Header["typ"] = TokenTypeV2 + return token.SignedString(t.privateKey) } return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(t.secret)) } diff --git a/auth/interfaces.go b/auth/interfaces.go index c912b2fe0..8539f4cdf 100644 --- a/auth/interfaces.go +++ b/auth/interfaces.go @@ -26,6 +26,8 @@ var ( ErrKeyAlgorithmMismatch = errors.New("token signing algorithm does not match the API key") ErrUnsupportedKeyVersion = errors.New("unsupported API key version") ErrInvalidPublicKey = errors.New("invalid API key public key") + ErrKeyIDMismatch = errors.New("token kid header does not match its issuer") + ErrTokenTypeMismatch = errors.New("token typ header does not match the API key version") ) //counterfeiter:generate . TokenVerifier diff --git a/auth/verifier.go b/auth/verifier.go index f59cb25de..3eb9cc158 100644 --- a/auth/verifier.go +++ b/auth/verifier.go @@ -25,9 +25,11 @@ import ( // matches go-jose's previous DefaultLeeway for validation tolerance. const tokenLeeway = time.Minute +// TokenTypeV2 is the typ header of a token signed with a version 2 key. +const TokenTypeV2 = "livekit-v2+jwt" + var ( - // The token is parsed before its key is known, so both methods are allowed here. - // The key picks the one method that verifies the signature. + // Parsing runs before the key is known, so it allows both. The key picks one. allowedSigningMethods = []string{jwt.SigningMethodHS256.Alg(), jwt.SigningMethodEdDSA.Alg()} unverifiedParser = jwt.NewParser(jwt.WithValidMethods(allowedSigningMethods)) ) @@ -46,6 +48,8 @@ type APIKeyTokenVerifier struct { identity string apiKey string alg string + keyID string // kid header + typ string // typ header } // ParseAPIToken parses an encoded JWT token without verifying its signature. @@ -63,6 +67,8 @@ func ParseAPIToken(raw string) (*APIKeyTokenVerifier, error) { identity: claims.Subject, alg: token.Method.Alg(), } + v.keyID, _ = token.Header["kid"].(string) + v.typ, _ = token.Header["typ"].(string) if v.identity == "" { v.identity = claims.ID } @@ -91,6 +97,8 @@ func (v *APIKeyTokenVerifier) Verify(key interface{}) (*jwt.RegisteredClaims, *C // VerifyWithKey picks the algorithm from the key, never from the token header: // HS256 with the secret for version 1, EdDSA with the public key for version 2. +// A version 2 token must also name the API key in its kid header and have the +// type TokenTypeV2. func (v *APIKeyTokenVerifier) VerifyWithKey(k VerificationKey) (*jwt.RegisteredClaims, *ClaimGrants, error) { switch k.Version { case 1: @@ -105,7 +113,17 @@ func (v *APIKeyTokenVerifier) VerifyWithKey(k VerificationKey) (*jwt.RegisteredC if len(k.PublicKey) != ed25519.PublicKeySize { return nil, nil, ErrInvalidPublicKey } - return v.verify(jwt.SigningMethodEdDSA, k.PublicKey) + claims, grants, err := v.verify(jwt.SigningMethodEdDSA, k.PublicKey) + if err != nil { + return nil, nil, err + } + if v.keyID != v.apiKey { + return nil, nil, ErrKeyIDMismatch + } + if v.typ != TokenTypeV2 { + return nil, nil, ErrTokenTypeMismatch + } + return claims, grants, nil default: return nil, nil, fmt.Errorf("%w: %d", ErrUnsupportedKeyVersion, k.Version) } diff --git a/auth/verifier_test.go b/auth/verifier_test.go index eb838eda0..c4cc57204 100644 --- a/auth/verifier_test.go +++ b/auth/verifier_test.go @@ -240,6 +240,37 @@ func TestVerifyWithKey(t *testing.T) { require.Error(t, err) }) + t.Run("v2 key refuses a token with no kid or a kid that is not the issuer", func(t *testing.T) { + for _, kid := range []string{"", "APIother"} { + tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{ + "iss": apiKey, + "sub": "me", + "exp": jwt.NewNumericDate(time.Now().Add(time.Hour)), + }) + if kid != "" { + tok.Header["kid"] = kid + } + token, err := tok.SignedString(priv) + require.NoError(t, err) + _, _, err = parse(t, token).VerifyWithKey(v2Key) + require.ErrorIs(t, err, auth.ErrKeyIDMismatch) + } + }) + + t.Run("v2 key refuses a token that is not of the v2 type", func(t *testing.T) { + // jwt sets typ to JWT by default + tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{ + "iss": apiKey, + "sub": "me", + "exp": jwt.NewNumericDate(time.Now().Add(time.Hour)), + }) + tok.Header["kid"] = apiKey + token, err := tok.SignedString(priv) + require.NoError(t, err) + _, _, err = parse(t, token).VerifyWithKey(v2Key) + require.ErrorIs(t, err, auth.ErrTokenTypeMismatch) + }) + t.Run("expired EdDSA token is refused", func(t *testing.T) { token, err := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{ "iss": apiKey, From ff56171ecdd64d2e8d703318867f55bd75f95d6d Mon Sep 17 00:00:00 2001 From: David Zhao Date: Sat, 14 Nov 2026 21:28:09 -0800 Subject: [PATCH 3/3] update --- .changeset/auth-verify-with-key.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/auth-verify-with-key.md b/.changeset/auth-verify-with-key.md index 1dc608101..869262420 100644 --- a/.changeset/auth-verify-with-key.md +++ b/.changeset/auth-verify-with-key.md @@ -2,4 +2,4 @@ "github.com/livekit/protocol": patch --- -auth: `VerifyWithKey` verifies a token with the algorithm its key picks, HS256 for a version 1 key and EdDSA for a version 2 key, and `NewAccessTokenWithPrivateKey` signs a token with EdDSA. `ParseAPIToken` accepts EdDSA tokens; `Verify` stays HS256 only. Version 2 keys are not supported by any server yet, so do not use them +auth: `VerifyWithKey` verifies a token with the algorithm its key picks, HS256 for a version 1 key and EdDSA for a version 2 key, and `NewAccessTokenWithPrivateKey` signs a token with EdDSA. `ParseAPIToken` accepts EdDSA tokens; `Verify` stays HS256 only. A version 2 token names its API key in the `kid` header and has the `typ` header `livekit-v2+jwt`, and `VerifyWithKey` refuses one that does not. Version 2 keys are not supported by any server yet, so do not use them