Skip to content

Commit be4d24e

Browse files
committed
feat: test and fix implementation credential creation with expiry for TURN authentication
1 parent 4b44993 commit be4d24e

3 files changed

Lines changed: 197 additions & 44 deletions

File tree

pkg/service/roommanager.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,12 +1047,11 @@ func (r *RoomManager) iceServersForParticipant(apiKey string, participant types.
10471047
urls = append(urls, fmt.Sprintf("turns:%s:443?transport=tcp", r.config.TURN.Domain))
10481048
}
10491049
if len(urls) > 0 {
1050-
// BEGIN OPENVIDU BLOCK
1051-
username := r.turnAuthHandler.CreateUsername(apiKey, participant.ID(), r.config.TURN.CredentialTTL)
1050+
// BEGIN OPENVIDU BLOCK — atomic so username and password share the same expiry.
1051+
username, password, err := r.turnAuthHandler.CreateCredentials(apiKey, participant.ID(), r.config.TURN.CredentialTTL)
10521052
// END OPENVIDU BLOCK
1053-
password, err := r.turnAuthHandler.CreatePassword(apiKey, participant.ID())
10541053
if err != nil {
1055-
participant.GetLogger().Warnw("could not create turn password", err)
1054+
participant.GetLogger().Warnw("could not create turn credentials", err)
10561055
hasSTUN = false
10571056
} else {
10581057
iceServers = append(iceServers, &livekit.ICEServer{

pkg/service/turn.go

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -243,21 +243,55 @@ func NewTURNAuthHandler(keyProvider auth.KeyProvider) *TURNAuthHandler {
243243
}
244244
}
245245

246-
// BEGIN OPENVIDU BLOCK — CreateUsername/ParseUsername gained an optional expiry.
247-
// Username plaintext format (before base62 encoding):
246+
// BEGIN OPENVIDU BLOCK — Credential format with expiry binding.
248247
//
249-
// apiKey|pID (legacy, ttl <= 0 → no expiry)
250-
// apiKey|pID|<unixSec> (with expiry)
248+
// Username plaintext (before base62 encoding):
251249
//
252-
// Expiry is carried in the username so HandleAuth can reject expired creds before
253-
// doing any HMAC work, and because the username is covered by the TURN long-term
254-
// auth key — an attacker cannot strip it without invalidating the key.
255-
func (h *TURNAuthHandler) CreateUsername(apiKey string, pID livekit.ParticipantID, ttl time.Duration) string {
250+
// apiKey|pID (legacy, no expiry)
251+
// apiKey|pID|<unixSeconds> (with expiry)
252+
//
253+
// Password plaintext (before SHA256+base62):
254+
//
255+
// secret|pID (legacy, no expiry)
256+
// secret|pID|<unixSeconds> (with expiry)
257+
//
258+
// The expiry is bound into BOTH the username and the password hash. This
259+
// matters for the TTL to be enforceable: the TURN long-term-credential auth
260+
// key is MD5(username:realm:password), so without expiry binding, an attacker
261+
// who leaked a 3-part credential could simply re-encode a 2-part username
262+
// (stripping the expiry) and the server-side password would be unchanged —
263+
// the auth key would match and MESSAGE-INTEGRITY would pass. Binding the
264+
// expiry into the password makes the stripped-username form compute a
265+
// different password server-side, so the attacker's captured password no
266+
// longer produces a matching key.
267+
268+
// CreateCredentials generates a matched (username, password) pair. The expiry
269+
// is computed once from ttl and bound into both sides, so the pair cannot be
270+
// re-encoded into a different username form without invalidating the password.
271+
// ttl<=0 emits the legacy no-expiry forms (opt-out via config).
272+
func (h *TURNAuthHandler) CreateCredentials(apiKey string, pID livekit.ParticipantID, ttl time.Duration) (username string, password string, err error) {
273+
expiry := h.expiryFor(ttl)
274+
password, err = h.CreatePassword(apiKey, pID, expiry)
275+
if err != nil {
276+
return "", "", err
277+
}
278+
return h.CreateUsername(apiKey, pID, expiry), password, nil
279+
}
280+
281+
func (h *TURNAuthHandler) expiryFor(ttl time.Duration) time.Time {
256282
if ttl <= 0 {
283+
return time.Time{}
284+
}
285+
return h.now().Add(ttl)
286+
}
287+
288+
// CreateUsername encodes an apiKey/pID pair into an opaque TURN username.
289+
// Zero-value expiry emits the legacy 2-part form.
290+
func (h *TURNAuthHandler) CreateUsername(apiKey string, pID livekit.ParticipantID, expiry time.Time) string {
291+
if expiry.IsZero() {
257292
return base62.EncodeToString([]byte(fmt.Sprintf("%s|%s", apiKey, pID)))
258293
}
259-
expiry := h.now().Add(ttl).Unix()
260-
return base62.EncodeToString([]byte(fmt.Sprintf("%s|%s|%d", apiKey, pID, expiry)))
294+
return base62.EncodeToString([]byte(fmt.Sprintf("%s|%s|%d", apiKey, pID, expiry.Unix())))
261295
}
262296

263297
// ParseUsername decodes the username and returns the embedded fields.
@@ -284,18 +318,28 @@ func (h *TURNAuthHandler) ParseUsername(username string) (apiKey string, pID liv
284318

285319
// END OPENVIDU BLOCK
286320

287-
func (h *TURNAuthHandler) CreatePassword(apiKey string, pID livekit.ParticipantID) (string, error) {
321+
// CreatePassword derives the TURN long-term credential password.
322+
// Zero-value expiry emits the legacy hash for backward compatibility; a non-zero
323+
// expiry binds into the hash so a stripped-username attack (3-part → 2-part)
324+
// produces a mismatched password server-side. See the BLOCK comment above.
325+
func (h *TURNAuthHandler) CreatePassword(apiKey string, pID livekit.ParticipantID, expiry time.Time) (string, error) {
288326
secret := h.keyProvider.GetSecret(apiKey)
289327
if secret == "" {
290328
return "", ErrInvalidAPIKey
291329
}
292-
keyInput := fmt.Sprintf("%s|%s", secret, pID)
293-
sum := sha256.Sum256([]byte(keyInput))
330+
var input string
331+
if expiry.IsZero() {
332+
input = fmt.Sprintf("%s|%s", secret, pID)
333+
} else {
334+
input = fmt.Sprintf("%s|%s|%d", secret, pID, expiry.Unix())
335+
}
336+
sum := sha256.Sum256([]byte(input))
294337
return base62.EncodeToString(sum[:]), nil
295338
}
296339

297340
func (h *TURNAuthHandler) HandleAuth(username, realm string, srcAddr net.Addr) (key []byte, ok bool) {
298-
// BEGIN OPENVIDU BLOCK — parse once via ParseUsername and validate expiry.
341+
// BEGIN OPENVIDU BLOCK — parse once via ParseUsername, validate expiry,
342+
// and derive the password with the SAME expiry that's in the username.
299343
apiKey, pID, expiry, err := h.ParseUsername(username)
300344
if err != nil {
301345
return nil, false
@@ -307,7 +351,7 @@ func (h *TURNAuthHandler) HandleAuth(username, realm string, srcAddr net.Addr) (
307351
return nil, false
308352
}
309353
}
310-
password, err := h.CreatePassword(apiKey, pID)
354+
password, err := h.CreatePassword(apiKey, pID, expiry)
311355
if err != nil {
312356
logger.Warnw("could not create TURN password", err, "username", username)
313357
return nil, false

0 commit comments

Comments
 (0)