From c0089b2c11a3e83f70c4776c6e5f9acd084f92a6 Mon Sep 17 00:00:00 2001 From: bobiqqq Date: Wed, 26 Aug 2026 11:55:52 +0300 Subject: [PATCH] =?UTF-8?q?tiktak:=20keygen=20=D0=BD=D0=B0=20HMAC=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=BF=D1=80=D0=B5=D0=B4=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D1=83=D0=B5=D0=BC=D0=BE=D0=B3=D0=BE=20legacy?= =?UTF-8?q?-=D0=B0=D0=BB=D0=B3=D0=BE=D1=80=D0=B8=D1=82=D0=BC=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share-токен приватного видео считался в legacy/keygen.a только от video id, без ключа: seed=(seed*17+42)%62, out[i]=alpha[seed]. После первой итерации состояние схлопывалось в id%62, то есть на весь сервис приходилось 62 различных токена. Разбирать бинарь не требовалось: 62 своих приватных видео дают таблицу токенов ко всем чужим. Форж токена => POST /access => строка в таблице access => haveAccess() отдаёт и description, и субтитры, и .webm совершенно легально, мимо патча /vtt/. Теперь HMAC-SHA256(secret, vid), те же сигнатуры и тот же 30-символьный base62, cgo и keygen.a из сборки выпали (образ собирается под любую архитектуру). Сверка constant-time через hmac.Equal. Совместимость со старыми токенами намеренно не сохранена: раунды ещё не начинались, приватных видео нет. Co-Authored-By: Claude Opus 5 --- services/tiktak/keygen/keygen.go | 47 ++++++++++++++++++--------- services/tiktak/keygen/keygen_test.go | 34 +++++++++++++++++++ 2 files changed, 65 insertions(+), 16 deletions(-) create mode 100644 services/tiktak/keygen/keygen_test.go diff --git a/services/tiktak/keygen/keygen.go b/services/tiktak/keygen/keygen.go index 61aba65..169d17b 100644 --- a/services/tiktak/keygen/keygen.go +++ b/services/tiktak/keygen/keygen.go @@ -1,27 +1,42 @@ package keygen -/* -#cgo LDFLAGS: ${SRCDIR}/legacy/keygen.a -lm -#include -*/ -import "C" import ( - "strings" - "unsafe" + "crypto/hmac" + "crypto/sha256" + "strconv" +) + +// The legacy C implementation (legacy/keygen.a, no source shipped) derived the +// share token from the video id ALONE -- no key, no salt. Worse, its state +// collapsed to (seed*17+42)%62 after the first round, so the token depended +// only on id%62: 62 distinct tokens for the whole service. Anyone could upload +// 62 private videos, copy the tokens off their own /home and unlock every +// private video on every team's box. Replaced with a keyed HMAC. +// +// Same signatures, same 30-char base62 alphabet, so nothing else changes. +// keygen.h / legacy/keygen.a are no longer part of the build (cgo is gone). +const ( + tokenLen = 30 + alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + + // Server-side secret. Rotating it invalidates every token already handed + // out, so change it only between rounds. + secret = "D1gkxNv7_HphLJjNuFag_stjaByXLZ1Y0l12RGppZtw" ) func GenerateKey(vid int) string { - var key [C.TOK_SIZE]byte - keyPtr := (*C.char)(unsafe.Pointer(&key[0])) - C.GenerateToken(C.int(vid), keyPtr) - res := strings.Builder{} - for _, v := range key { - res.WriteByte(v) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(strconv.Itoa(vid))) + sum := mac.Sum(nil) + + out := make([]byte, tokenLen) + for i := 0; i < tokenLen; i++ { + out[i] = alphabet[int(sum[i%len(sum)])%len(alphabet)] } - return res.String() + return string(out) } func ValidateKey(token string, vid int) bool { - res := C.ValidateToken(C.int(vid), C.CString(token)) - return res == 1 + // Constant-time: never leak how much of the token was correct. + return hmac.Equal([]byte(token), []byte(GenerateKey(vid))) } diff --git a/services/tiktak/keygen/keygen_test.go b/services/tiktak/keygen/keygen_test.go new file mode 100644 index 0000000..6aa3685 --- /dev/null +++ b/services/tiktak/keygen/keygen_test.go @@ -0,0 +1,34 @@ +package keygen + +import "testing" + +func TestTokenProperties(t *testing.T) { + seen := map[string]int{} + for vid := 1; vid <= 5000; vid++ { + tok := GenerateKey(vid) + if len(tok) != 30 { + t.Fatalf("vid %d: len %d, want 30", vid, len(tok)) + } + for _, ch := range tok { + if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9')) { + t.Fatalf("vid %d: bad char %q", vid, ch) + } + } + if prev, dup := seen[tok]; dup { + t.Fatalf("COLLISION: vid %d and vid %d share a token", prev, vid) + } + seen[tok] = vid + if !ValidateKey(tok, vid) { + t.Fatalf("vid %d: own token rejected", vid) + } + if ValidateKey(tok, vid+1) || ValidateKey(tok, vid+62) { + t.Fatalf("vid %d: token accepted for another video", vid) + } + } + t.Logf("5000 ids -> %d distinct tokens (legacy gave 62)", len(seen)) + for _, bad := range []string{"", "aaaa", GenerateKey(1)[:29], GenerateKey(1) + "x"} { + if ValidateKey(bad, 1) { + t.Fatalf("garbage token %q accepted", bad) + } + } +}