Files
glchat/internal/voice/token.go
T

233 lines
8.0 KiB
Go
Raw Normal View History

// Package voice выдаёт токены доступа к LiveKit и хранит голосовые состояния
// (AGENT.md 7.14): собственный JWT-подписыватель без внешних зависимостей.
package voice
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// ErrDisabled — LiveKit не настроен: ключи не заданы.
var ErrDisabled = errors.New("voice.disabled")
// TokenIssuer подписывает токены доступа LiveKit (HS256).
type TokenIssuer struct {
apiKey string
apiSecret string
ttl time.Duration
now func() time.Time
}
// NewIssuer создаёт подписывателя токенов.
func NewIssuer(apiKey, apiSecret string, ttl time.Duration) *TokenIssuer {
if ttl <= 0 {
ttl = time.Hour
}
return &TokenIssuer{apiKey: apiKey, apiSecret: apiSecret, ttl: ttl, now: time.Now}
}
// SetClock подменяет источник времени (тесты).
func (i *TokenIssuer) SetClock(now func() time.Time) { i.now = now }
// Enabled сообщает, настроен ли LiveKit.
func (i *TokenIssuer) Enabled() bool {
return i != nil && i.apiKey != "" && i.apiSecret != ""
}
// Grants — права участника комнаты LiveKit.
type Grants struct {
RoomJoin bool `json:"roomJoin"`
CanPublish bool `json:"canPublish"`
CanSubscribe bool `json:"canSubscribe"`
CanPublishData bool `json:"canPublishData"`
CanPublishAudio bool `json:"canPublishSources,omitempty"`
}
// Claims — полезная нагрузка токена LiveKit.
type Claims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Name string `json:"name,omitempty"`
Metadata string `json:"metadata,omitempty"`
Video struct {
Room string `json:"room"`
RoomJoin bool `json:"roomJoin"`
CanPublish bool `json:"canPublish"`
CanSubscribe bool `json:"canSubscribe"`
CanPublishData bool `json:"canPublishData"`
RoomAdmin bool `json:"roomAdmin,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Recorder bool `json:"recorder,omitempty"`
} `json:"video"`
IssuedAt int64 `json:"iat"`
NotBefore int64 `json:"nbf"`
ExpiresAt int64 `json:"exp"`
}
// Issue собирает и подписывает токен доступа к комнате.
func (i *TokenIssuer) Issue(room, identity, displayName string, grants Grants) (string, error) {
if !i.Enabled() {
return "", ErrDisabled
}
now := i.now().UTC()
claims := Claims{Issuer: i.apiKey, Subject: identity, Name: displayName}
claims.Video.Room = room
claims.Video.RoomJoin = grants.RoomJoin
claims.Video.CanPublish = grants.CanPublish
claims.Video.CanSubscribe = grants.CanSubscribe
claims.Video.CanPublishData = grants.CanPublishData
claims.IssuedAt = now.Unix()
claims.NotBefore = now.Add(-10 * time.Second).Unix()
claims.ExpiresAt = now.Add(i.ttl).Unix()
header, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
if err != nil {
return "", err
}
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
signingInput := encodeSegment(header) + "." + encodeSegment(payload)
mac := hmac.New(sha256.New, []byte(i.apiSecret))
if _, err := mac.Write([]byte(signingInput)); err != nil {
return "", err
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
// RoomName собирает имя комнаты LiveKit: комнаты инстанса не пересекаются.
func RoomName(guildID, channelID uint64) string {
return fmt.Sprintf("guild_%d_channel_%d", guildID, channelID)
}
// ParseRoomName разбирает имя комнаты, если нужно понять, куда подключён клиент.
func ParseRoomName(room string) (guildID, channelID uint64, ok bool) {
if _, err := fmt.Sscanf(room, "guild_%d_channel_%d", &guildID, &channelID); err != nil {
return 0, 0, false
}
return guildID, channelID, true
}
func encodeSegment(payload []byte) string {
return base64.RawURLEncoding.EncodeToString(payload)
}
// VerifyWebhook проверяет подпись вебхука LiveKit: это JWT, подписанный тем же
// API-секретом, а в payload лежит base64(sha256(тело запроса)) (AGENT.md 7.14).
func (i *TokenIssuer) VerifyWebhook(authorization string, body []byte) error {
if !i.Enabled() {
return ErrDisabled
}
token := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
if token == "" {
return errors.New("voice.webhook_unauthorized")
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return errors.New("voice.webhook_unauthorized")
}
signingInput := parts[0] + "." + parts[1]
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return errors.New("voice.webhook_unauthorized")
}
mac := hmac.New(sha256.New, []byte(i.apiSecret))
if _, err := mac.Write([]byte(signingInput)); err != nil {
return err
}
if !hmac.Equal(signature, mac.Sum(nil)) {
return errors.New("voice.webhook_unauthorized")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return errors.New("voice.webhook_unauthorized")
}
var claims struct {
ExpiresAt int64 `json:"exp"`
SHA256 string `json:"sha256"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return errors.New("voice.webhook_unauthorized")
}
if claims.ExpiresAt > 0 && i.now().UTC().Unix() > claims.ExpiresAt {
return errors.New("voice.webhook_expired")
}
if claims.SHA256 != "" {
digest := sha256.Sum256(body)
if subtleCompare(claims.SHA256, base64.StdEncoding.EncodeToString(digest[:])) {
return nil
}
// LiveKit может присылать и url-safe вариант кодирования.
if subtleCompare(claims.SHA256, base64.RawURLEncoding.EncodeToString(digest[:])) {
return nil
}
return errors.New("voice.webhook_body_mismatch")
}
return nil
}
// IssueWebhookForTest подписывает тело вебхука так же, как это делает LiveKit:
// нужен для проверки верификатора и тестов обработчика.
func (i *TokenIssuer) IssueWebhookForTest(body []byte) (string, error) {
if !i.Enabled() {
return "", ErrDisabled
}
digest := sha256.Sum256(body)
claims := map[string]any{
"exp": i.now().UTC().Add(time.Minute).Unix(),
"sha256": base64.StdEncoding.EncodeToString(digest[:]),
}
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
header, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
if err != nil {
return "", err
}
signingInput := encodeSegment(header) + "." + encodeSegment(payload)
mac := hmac.New(sha256.New, []byte(i.apiSecret))
if _, err := mac.Write([]byte(signingInput)); err != nil {
return "", err
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
// subtleCompare сравнивает строки за постоянное время.
func subtleCompare(a, b string) bool {
return hmac.Equal([]byte(a), []byte(b))
}
// ResolveLiveKitURL приводит адрес к виду, понятному браузеру: /rtc проксируется
// тем же доменом, поэтому оставляем как есть, но проверяем схему.
func ResolveLiveKitURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", ErrDisabled
}
if !strings.HasPrefix(trimmed, "ws://") && !strings.HasPrefix(trimmed, "wss://") {
return "", fmt.Errorf("voice.invalid_url: %s", trimmed)
}
return strings.TrimSuffix(trimmed, "/"), nil
}
// VoiceURLForClient отдаёт адрес LiveKit так, как его должен использовать
// клиент: пусто, если голос не настроен.
func (i *TokenIssuer) VoiceURLForClient(publicURL string) string {
if !i.Enabled() {
return ""
}
resolved, err := ResolveLiveKitURL(publicURL)
if err != nil {
return ""
}
return resolved
}