Skip to content

Commit d7e6233

Browse files
committed
feat(allanime): Implement dynamic per-epoch key derivation for AllAnime API
- Introduced a new key derivation mechanism that fetches the epoch and partB from the referer page, and derives the AES key using a mask from the entry bundle. - Updated the AllAnimeClient to use the derived keys for both the aaReq token and decrypting the `tobeparsed` blob. - Refactored tests to ensure proper key fetching and caching behavior, including regression tests for the new key derivation process. - Adjusted transport methods to utilize the new key management, ensuring compatibility with the updated AllAnime API. - Added comprehensive tests to validate the key derivation process and ensure no network calls are made when a valid cached key is available. - Updated documentation and comments to reflect the changes in key management and API interaction.
1 parent fdcb48e commit d7e6233

9 files changed

Lines changed: 565 additions & 118 deletions

File tree

internal/scraper/providers/allanime/aareq_test.go

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,10 @@ import (
3030
// aaReqPayload mirrors the JSON the token wraps, so the test can assert each
3131
// field the server checks.
3232
type aaReqPayload struct {
33-
V int `json:"v"`
34-
TS int64 `json:"ts"`
35-
Epoch int `json:"epoch"`
36-
BuildID string `json:"buildId"`
37-
QH string `json:"qh"`
33+
V int `json:"v"`
34+
TS int64 `json:"ts"`
35+
Epoch int `json:"epoch"`
36+
QH string `json:"qh"`
3837
}
3938

4039
// decryptAAReqAsServer performs the exact validation an AllAnime edge does:
@@ -70,26 +69,25 @@ func TestBuildAAReq_ServerCanValidateEntireContract(t *testing.T) {
7069
const qh = allAnimePersistedQueryHash
7170
const now = int64(1751990400000) // fixed wall clock (ms)
7271

73-
token, err := buildAAReqAt(qh, now)
72+
token, err := buildAAReqAt(qh, allAnimeKey, testAAEpoch, now)
7473
require.NoError(t, err)
7574

7675
payload, iv := decryptAAReqAsServer(t, token)
7776

7877
// Payload fields the server checks.
7978
assert.Equal(t, 1, payload.V, "protocol version field")
80-
assert.Equal(t, 4128, payload.Epoch, "epoch must be the fixed 4128 constant")
81-
assert.Equal(t, "9", payload.BuildID, "buildId must be the fixed \"9\" constant")
79+
assert.Equal(t, 4128, payload.Epoch, "epoch must be the per-epoch value bound into the token")
8280
assert.Equal(t, qh, payload.QH, "qh must be the persisted-query hash")
8381

8482
// Timestamp must be floored to the 5-minute window.
8583
wantTS := (now / aaReqWindowMillis) * aaReqWindowMillis
8684
assert.Equal(t, wantTS, payload.TS, "ts must be floored to the 5-minute bucket")
8785

88-
// The IV on the wire must equal SHA-256("4128:9:<qh>:<ts>")[:12] — the
86+
// The IV on the wire must equal SHA-256("<epoch>:<qh>:<ts>")[:12] — the
8987
// server re-derives it from the decrypted payload and compares.
90-
seed := fmt.Sprintf("4128:9:%s:%d", qh, payload.TS)
88+
seed := fmt.Sprintf("%s:%s:%d", testAAEpoch, qh, payload.TS)
9189
sum := sha256.Sum256([]byte(seed))
92-
assert.Equal(t, sum[:12], iv, "IV must be the first 12 bytes of SHA-256 over the fixed seed")
90+
assert.Equal(t, sum[:12], iv, "IV must be the first 12 bytes of SHA-256 over the epoch:qh:ts seed")
9391
}
9492

9593
func TestBuildAAReq_DeterministicWithinWindow(t *testing.T) {
@@ -100,9 +98,9 @@ func TestBuildAAReq_DeterministicWithinWindow(t *testing.T) {
10098
// Two calls in the same 5-minute bucket must produce byte-identical tokens
10199
// (GCM with a fixed key+iv+plaintext is deterministic). This is what lets
102100
// the server cache/verify without clock skew within the window.
103-
a, err := buildAAReqAt(qh, base+1000)
101+
a, err := buildAAReqAt(qh, allAnimeKey, testAAEpoch, base+1000)
104102
require.NoError(t, err)
105-
b, err := buildAAReqAt(qh, base+aaReqWindowMillis-1)
103+
b, err := buildAAReqAt(qh, allAnimeKey, testAAEpoch, base+aaReqWindowMillis-1)
106104
require.NoError(t, err)
107105
assert.Equal(t, a, b, "same 5-minute window ⇒ identical token")
108106
}
@@ -115,9 +113,9 @@ func TestBuildAAReq_ChangesAcrossWindows(t *testing.T) {
115113
// Crossing into the next window changes ts, which changes both the payload
116114
// and the derived IV, so the token must differ — a stale token would be
117115
// rejected by the server.
118-
a, err := buildAAReqAt(qh, base)
116+
a, err := buildAAReqAt(qh, allAnimeKey, testAAEpoch, base)
119117
require.NoError(t, err)
120-
b, err := buildAAReqAt(qh, base+aaReqWindowMillis)
118+
b, err := buildAAReqAt(qh, allAnimeKey, testAAEpoch, base+aaReqWindowMillis)
121119
require.NoError(t, err)
122120
assert.NotEqual(t, a, b, "next 5-minute window ⇒ different token")
123121
}
@@ -129,9 +127,9 @@ func TestBuildAAReq_BoundToQueryHash(t *testing.T) {
129127
// The qh is bound into both the payload and the IV seed, so two different
130128
// hashes must yield different tokens — the token cannot be replayed for a
131129
// different query.
132-
a, err := buildAAReqAt("d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec", now)
130+
a, err := buildAAReqAt("d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec", allAnimeKey, testAAEpoch, now)
133131
require.NoError(t, err)
134-
b, err := buildAAReqAt("0000000000000000000000000000000000000000000000000000000000000000", now)
132+
b, err := buildAAReqAt("0000000000000000000000000000000000000000000000000000000000000000", allAnimeKey, testAAEpoch, now)
135133
require.NoError(t, err)
136134
assert.NotEqual(t, a, b, "token must be bound to the query hash")
137135
}
@@ -141,7 +139,7 @@ func TestBuildAAReq_ProductionUsesLiveClock(t *testing.T) {
141139
// The exported wrapper must produce a currently-valid token (non-empty,
142140
// server-decryptable) using the real clock — guards against the wrapper
143141
// being accidentally short-circuited.
144-
token, err := buildAAReq(allAnimePersistedQueryHash)
142+
token, err := buildAAReq(allAnimePersistedQueryHash, &aaKeys{key: allAnimeKey, epoch: testAAEpoch})
145143
require.NoError(t, err)
146144
require.NotEmpty(t, token)
147145
payload, _ := decryptAAReqAsServer(t, token)

internal/scraper/providers/allanime/client.go

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,41 +2,36 @@
22
package allanime
33

44
import (
5+
"bytes"
56
"net/http"
67
"regexp"
78
"sync"
9+
"time"
810

911
"github.com/alvarorichard/Goanime/internal/scraper/netx"
1012
"github.com/alvarorichard/Goanime/internal/util"
1113
)
1214

1315
const (
1416
// AllAnimeReferer is the Referer/Origin AllAnime binds its API to. Rotated
15-
// 2026-07-08 to youtu-chan.com (ani-cli PR #1772); requests carrying the old
16-
// allmanga.to referer now receive a stripped response.
17-
AllAnimeReferer = "https://youtu-chan.com"
18-
AllAnimeBase = "allanime.day"
19-
AllAnimeAPI = "https://api.allanime.day/api"
20-
21-
// allAnimeKeyHex is the AES-256 key (32 bytes, hex) used for BOTH the
22-
// aaReq request-signing token and the `tobeparsed` response decryption.
23-
// Rotated 2026-07-08 (ani-cli PR #1772); the old SHA-256("Xot36i3lK3:v1")
24-
// derivation no longer matches, so the key is now carried literally.
25-
allAnimeKeyHex = "22196fa6afca95309fdabe9a3534b87cd2454e50efeabfcbdbdfd3de678b3982"
17+
// 2026-07-22 to mkissa.to (ani-cli PR #1779); requests carrying the old
18+
// youtu-chan.com referer now receive a stripped response.
19+
AllAnimeReferer = "https://mkissa.to"
20+
// AllAnimeBase is the host that internal ("--"-encoded) source URLs resolve
21+
// to (e.g. the /clock.json embeds). Unchanged by the mkissa migration.
22+
AllAnimeBase = "allanime.day"
23+
// AllAnimeAPI is the GraphQL endpoint. Moved off api.allanime.day to the
24+
// mkissa mirror 2026-07-22 (ani-cli PR #1779).
25+
AllAnimeAPI = "https://api.mkissa.net/api"
2626

2727
// allAnimePersistedQueryHash is the Apollo persistedQuery sha256 for the
2828
// `episode { sourceUrls / tobeparsed }` query, and doubles as the `qh`
29-
// field bound into the aaReq token.
30-
allAnimePersistedQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"
29+
// field bound into the aaReq token. Rotated 2026-07-22 (ani-cli PR #1779).
30+
allAnimePersistedQueryHash = "f4662f4b7510b26795dd53ef824a0bf1740fbbc5d1273fab18222ac831bca8d0"
3131

3232
// allAnimePersistedQueryOrigin is the Origin the GET path must send.
3333
// AllAnime returns a stripped (no `tobeparsed`) response for any other Origin.
34-
allAnimePersistedQueryOrigin = "https://youtu-chan.com"
35-
36-
// allAnimeAAReqEpoch and allAnimeAAReqBuildID are fixed protocol constants
37-
// bound into the aaReq token's payload and IV derivation (ani-cli PR #1772).
38-
allAnimeAAReqEpoch = "4128"
39-
allAnimeAAReqBuildID = "9"
34+
allAnimePersistedQueryOrigin = "https://mkissa.to"
4035
)
4136

4237
// Pre-compiled regexes for AllAnime scraper (avoid per-call compilation)
@@ -52,6 +47,12 @@ type AllAnimeClient struct {
5247
referer string
5348
apiBase string
5449
userAgent string
50+
51+
// keyMu guards the cached per-epoch AES material (keys/keysExp). The key is
52+
// scraped from the mkissa CDN bundle (fetchAAKeys) and reused until keysExp.
53+
keyMu sync.Mutex
54+
keys *aaKeys
55+
keysExp time.Time
5556
}
5657

5758
// allAnimeClientInstance is a singleton for connection reuse
@@ -74,12 +75,15 @@ func NewAllAnimeClient() *AllAnimeClient {
7475
}
7576

7677
// NewClientForTest returns a client whose API base points at a test server.
77-
// Only for tests.
78+
// Only for tests. A fixed fixture key is injected so the client never scrapes
79+
// the live mkissa key bundle over the network during tests.
7880
func NewClientForTest(serverURL string) *AllAnimeClient {
7981
return &AllAnimeClient{
8082
client: util.GetFastClient(),
8183
referer: AllAnimeReferer,
8284
apiBase: serverURL,
8385
userAgent: netx.UserAgent,
86+
keys: &aaKeys{key: bytes.Repeat([]byte{0x2a}, 32), epoch: "0"},
87+
keysExp: time.Now().Add(time.Hour),
8488
}
8589
}

internal/scraper/providers/allanime/client_test.go

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,33 @@ import (
2929
// Test helpers
3030
// ---------------------------------------------------------------------------
3131

32-
// newTestClient builds an AllAnimeClient pointed at the given httptest server.
32+
// allAnimeKey / allAnimeKeyHex are a FIXED fixture key for the offline crypto
33+
// round-trip tests. Production derives the real key dynamically per epoch (see
34+
// fetchAAKeys); these only need to be a valid 32-byte AES-256 key shared by the
35+
// encrypt and decrypt sides of the tests.
36+
const allAnimeKeyHex = "22196fa6afca95309fdabe9a3534b87cd2454e50efeabfcbdbdfd3de678b3982"
37+
38+
var allAnimeKey = func() []byte {
39+
k, err := hex.DecodeString(allAnimeKeyHex)
40+
if err != nil || len(k) != 32 {
41+
panic("allanime test: bad fixture key")
42+
}
43+
return k
44+
}()
45+
46+
// testAAEpoch is the fixture epoch the injected key is paired with.
47+
const testAAEpoch = "4128"
48+
49+
// newTestClient builds an AllAnimeClient pointed at the given httptest server,
50+
// with the fixture key injected so getAAKeys never scrapes the live bundle.
3351
func newTestClient(serverURL string) *AllAnimeClient {
3452
return &AllAnimeClient{
3553
client: util.GetFastClient(),
3654
referer: AllAnimeReferer,
3755
apiBase: serverURL,
3856
userAgent: netx.UserAgent,
57+
keys: &aaKeys{key: allAnimeKey, epoch: testAAEpoch},
58+
keysExp: time.Now().Add(time.Hour),
3959
}
4060
}
4161

@@ -262,8 +282,8 @@ func TestAllAnimeGetLinksClassifiesHTMLBodyAsSourceUnavailable(t *testing.T) {
262282

263283
func TestAllAnimeKeyMatchesOpenSSL(t *testing.T) {
264284
t.Parallel()
265-
// Literal 32-byte hex key, rotated 2026-07-08 (ani-cli PR #1772). It is no
266-
// longer derived from a passphrase; it must equal the constant verbatim.
285+
// The production key is now derived per epoch (fetchAAKeys); this pins the
286+
// offline test fixture key as a valid 32-byte AES-256 key.
267287
assert.Equal(t, allAnimeKeyHex, hex.EncodeToString(allAnimeKey))
268288
}
269289

@@ -393,7 +413,7 @@ func TestDecodeToBeParsedRoundTrip(t *testing.T) {
393413
plaintext := `{"data":{"episode":{"sourceUrls":[{"sourceUrl":"--504c4c484b021717","sourceName":"TestProvider"}]}}}`
394414
blob := encryptToBeParsed(t, plaintext)
395415

396-
sources, err := decodeToBeParsed(blob)
416+
sources, err := decodeToBeParsed(blob, allAnimeKey)
397417
require.NoError(t, err)
398418
require.Len(t, sources, 1)
399419
assert.Equal(t, "TestProvider", sources[0].sourceName)
@@ -409,7 +429,7 @@ func TestDecodeToBeParsedMultipleSources(t *testing.T) {
409429
]}}}`
410430
blob := encryptToBeParsed(t, plaintext)
411431

412-
sources, err := decodeToBeParsed(blob)
432+
sources, err := decodeToBeParsed(blob, allAnimeKey)
413433
require.NoError(t, err)
414434
require.Len(t, sources, 3)
415435
assert.Equal(t, "Provider1", sources[0].sourceName)
@@ -421,14 +441,14 @@ func TestDecodeToBeParsedMultipleSources(t *testing.T) {
421441
func TestDecodeToBeParsedTooShort(t *testing.T) {
422442
t.Parallel()
423443
blob := base64.StdEncoding.EncodeToString([]byte("short"))
424-
_, err := decodeToBeParsed(blob)
444+
_, err := decodeToBeParsed(blob, allAnimeKey)
425445
require.Error(t, err)
426446
assert.Contains(t, err.Error(), "too short")
427447
}
428448

429449
func TestDecodeToBeParsedBadBase64(t *testing.T) {
430450
t.Parallel()
431-
_, err := decodeToBeParsed("!!!not-base64!!!")
451+
_, err := decodeToBeParsed("!!!not-base64!!!", allAnimeKey)
432452
require.Error(t, err)
433453
assert.Contains(t, err.Error(), "base64")
434454
}
@@ -437,7 +457,7 @@ func TestDecodeToBeParsedExactly12BytesNoCiphertext(t *testing.T) {
437457
t.Parallel()
438458
// 12 bytes < 29 (GCM minimum: 1 version + 12 nonce + 16 tag) → too short
439459
blob := base64.StdEncoding.EncodeToString(make([]byte, 12))
440-
_, err := decodeToBeParsed(blob)
460+
_, err := decodeToBeParsed(blob, allAnimeKey)
441461
require.Error(t, err)
442462
assert.Contains(t, err.Error(), "too short")
443463
}
@@ -446,7 +466,7 @@ func TestDecodeToBeParsedExactly13BytesMinimal(t *testing.T) {
446466
t.Parallel()
447467
// 13 bytes < 29 → too short
448468
blob := base64.StdEncoding.EncodeToString(make([]byte, 13))
449-
_, err := decodeToBeParsed(blob)
469+
_, err := decodeToBeParsed(blob, allAnimeKey)
450470
assert.Error(t, err)
451471
assert.Contains(t, err.Error(), "too short")
452472
}
@@ -456,7 +476,7 @@ func TestDecodeToBeParsedExactly28BytesTooShort(t *testing.T) {
456476
// GCM minimum is 29 bytes (1 version + 12 nonce + 16 tag + 0 plaintext).
457477
// 28 bytes trips the length guard before any crypto runs.
458478
blob := base64.StdEncoding.EncodeToString(make([]byte, 28))
459-
_, err := decodeToBeParsed(blob)
479+
_, err := decodeToBeParsed(blob, allAnimeKey)
460480
require.Error(t, err)
461481
assert.Contains(t, err.Error(), "too short")
462482
}
@@ -466,7 +486,7 @@ func TestDecodeToBeParsedAllZeroBytesFailsAuth(t *testing.T) {
466486
// A 29-byte all-zero blob passes the length guard but is not a valid GCM
467487
// message (the zero tag won't authenticate), so Open rejects it.
468488
blob := base64.StdEncoding.EncodeToString(make([]byte, 29))
469-
_, err := decodeToBeParsed(blob)
489+
_, err := decodeToBeParsed(blob, allAnimeKey)
470490
require.Error(t, err)
471491
assert.Contains(t, err.Error(), "GCM decrypt failed")
472492
}
@@ -487,7 +507,7 @@ func TestDecodeToBeParsedCorruptedCiphertext(t *testing.T) {
487507
}
488508
corruptBlob := base64.StdEncoding.EncodeToString(raw)
489509

490-
_, err = decodeToBeParsed(corruptBlob)
510+
_, err = decodeToBeParsed(corruptBlob, allAnimeKey)
491511
require.Error(t, err, "tampered ciphertext must not produce a usable result")
492512
assert.Contains(t, err.Error(), "GCM decrypt failed",
493513
"GCM authenticates — tampering must be rejected at the cipher layer")
@@ -503,7 +523,7 @@ func TestDecodeToBeParsedTruncatedCiphertext(t *testing.T) {
503523
// 16 bytes < 29 minimum → "too short" before GCM is even attempted
504524
truncated := base64.StdEncoding.EncodeToString(raw[:16])
505525

506-
_, err = decodeToBeParsed(truncated)
526+
_, err = decodeToBeParsed(truncated, allAnimeKey)
507527
assert.Error(t, err, "truncated blob should fail")
508528
}
509529

@@ -513,7 +533,7 @@ func TestDecodeToBeParsedRegexFallbackSourceUrlBeforeSourceName(t *testing.T) {
513533
plaintext := `[{"sourceUrl":"--5959","sourceName":"Fallback1"}]`
514534
blob := encryptToBeParsed(t, plaintext)
515535

516-
sources, err := decodeToBeParsed(blob)
536+
sources, err := decodeToBeParsed(blob, allAnimeKey)
517537
require.NoError(t, err)
518538
require.Len(t, sources, 1)
519539
assert.Equal(t, "Fallback1", sources[0].sourceName)
@@ -526,7 +546,7 @@ func TestDecodeToBeParsedRegexFallbackReversedFieldOrder(t *testing.T) {
526546
plaintext := `[{"sourceName":"Reversed","sourceUrl":"--0a0b"}]`
527547
blob := encryptToBeParsed(t, plaintext)
528548

529-
sources, err := decodeToBeParsed(blob)
549+
sources, err := decodeToBeParsed(blob, allAnimeKey)
530550
require.NoError(t, err)
531551
require.Len(t, sources, 1)
532552
assert.Equal(t, "Reversed", sources[0].sourceName)
@@ -542,7 +562,7 @@ func TestDecodeToBeParsedDeterministicWithFixedNonce(t *testing.T) {
542562
blob2 := encryptToBeParsedWithNonce(t, plaintext, nonce)
543563
assert.Equal(t, blob1, blob2, "same nonce + plaintext must produce same blob")
544564

545-
sources, err := decodeToBeParsed(blob1)
565+
sources, err := decodeToBeParsed(blob1, allAnimeKey)
546566
require.NoError(t, err)
547567
require.Len(t, sources, 1)
548568
assert.Equal(t, "Det", sources[0].sourceName)
@@ -558,7 +578,7 @@ func TestDecodeToBeParsedLargePayload(t *testing.T) {
558578
plaintext := `{"data":{"episode":{"sourceUrls":[` + strings.Join(entries, ",") + `]}}}`
559579
blob := encryptToBeParsed(t, plaintext)
560580

561-
sources, err := decodeToBeParsed(blob)
581+
sources, err := decodeToBeParsed(blob, allAnimeKey)
562582
require.NoError(t, err)
563583
assert.Len(t, sources, 100)
564584
}
@@ -573,7 +593,7 @@ func TestExtractSourceURLsHandlesToBeParsed(t *testing.T) {
573593
blob := encryptToBeParsed(t, plaintext)
574594
response := buildToBeParsedResponse(blob)
575595

576-
urls := NewAllAnimeClient().extractSourceURLs(response)
596+
urls := newTestClient("").extractSourceURLs(response)
577597
require.Len(t, urls, 1)
578598
assert.Equal(t, "https://allanime.day/clock.json", urls[0])
579599
}
@@ -614,7 +634,7 @@ func TestExtractSourceURLsToBeParsedFallsBackToStandard(t *testing.T) {
614634
// Response has "tobeparsed" but with garbage blob; should fall back to sourceUrls
615635
response := `{"data":{"episode":{"tobeparsed":"not-valid-base64","sourceUrls":[{"sourceUrl":"--0809","sourceName":"Fallback"}]}}}`
616636

617-
urls := NewAllAnimeClient().extractSourceURLs(response)
637+
urls := newTestClient("").extractSourceURLs(response)
618638
require.Len(t, urls, 1)
619639
assert.Equal(t, "01", urls[0])
620640
}
@@ -1868,7 +1888,7 @@ func TestDecodeToBeParsedCrossValidateWithOpenSSL(t *testing.T) {
18681888
blob := base64.StdEncoding.EncodeToString(payload)
18691889

18701890
// Decrypt using production code
1871-
sources, err := decodeToBeParsed(blob)
1891+
sources, err := decodeToBeParsed(blob, allAnimeKey)
18721892
require.NoError(t, err)
18731893
require.Len(t, sources, 1)
18741894
assert.Equal(t, "TestProvider", sources[0].sourceName)
@@ -1970,7 +1990,7 @@ func TestDecodeToBeParsedNoPanicOnMalformed(t *testing.T) {
19701990
t.Run(fmt.Sprintf("input_%d", i), func(t *testing.T) {
19711991
t.Parallel()
19721992
// Must not panic
1973-
_, _ = decodeToBeParsed(input)
1993+
_, _ = decodeToBeParsed(input, allAnimeKey)
19741994
})
19751995
}
19761996
}

0 commit comments

Comments
 (0)