Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/auth-verify-with-key.md
Original file line number Diff line number Diff line change
@@ -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. 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
21 changes: 20 additions & 1 deletion auth/accesstoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package auth

import (
"crypto/ed25519"
"time"

"github.com/golang-jwt/jwt/v5"
Expand All @@ -37,6 +38,7 @@ const (
type AccessToken struct {
apiKey string
secret string
privateKey ed25519.PrivateKey
grant ClaimGrants
validFor time.Duration
allowSensitiveCredentials bool
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -211,5 +224,11 @@ func (t *AccessToken) ToJWT() (string, error) {
},
ClaimGrants: t.grant,
}
if t.privateKey != nil {
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))
}
21 changes: 21 additions & 0 deletions auth/accesstoken_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package auth

import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"strings"
Expand All @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions auth/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ 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")
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
Expand Down
72 changes: 68 additions & 4 deletions auth/verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
package auth

import (
"crypto/ed25519"
"fmt"
"time"

"github.com/golang-jwt/jwt/v5"
Expand All @@ -23,30 +25,50 @@ 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 (
allowedSigningMethods = []string{jwt.SigningMethodHS256.Alg()}
// 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))
)

// 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
keyID string // kid header
typ string // typ header
}

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

v := &APIKeyTokenVerifier{
raw: raw,
apiKey: claims.Issuer,
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
}
Expand All @@ -62,19 +84,61 @@ 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
}
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.
// 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:
if k.Secret == "" {
return nil, nil, ErrKeysMissing
}
return v.verify(jwt.SigningMethodHS256, []byte(k.Secret))
case 2:
Comment thread
davidzhao marked this conversation as resolved.
Resolved
if len(k.PublicKey) == 0 {
return nil, nil, ErrKeysMissing
}
if len(k.PublicKey) != ed25519.PublicKeySize {
return nil, nil, ErrInvalidPublicKey
}
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)
}
}

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
Expand Down
133 changes: 133 additions & 0 deletions auth/verifier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
package auth_test

import (
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"testing"
"time"
Expand Down Expand Up @@ -176,3 +178,134 @@ 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("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,
"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)
})
}
Loading