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