# Wbi签名

自 2023 年三月起，B站 Web 端部分接口开始使用 Wbi 鉴权方式，即一种独立于 [APP 鉴权](APP.md) 与其他 Cookie 鉴权的方式，表现在 REST API 请求时在 query 中添加了`w_rid`和`wts`字段，为一种 Web 端的风控手段

这些接口涵盖”用户投稿视频“、”用户投稿专栏“、”首页推送“、”推广信息“、”热搜“、”视频信息“、”视频取流“、”搜索“等待主要查询性业务接口，如果请求这些 REST API 缺失`w_rid`和`wts`字段，则会在数次请求后返回`-403:非法访问`这样的风控错误

感谢 [#631](https://github.com/SocialSisterYi/bilibili-API-collect/issues/631) 的研究与逆向工程

## Wbi签名算法

1. 获取实时口令

   从 [nav 接口](../../login/login_info.md#导航栏用户信息) 中获取`img_url`、`sub_url`两个字段的参数，并保存备用（如存入 localStorage），相关内容节选如下：

   **注：`img_url`、`sub_url`两个字段的值看似为存于 BFS 中的 png 图片 url，实则只是经过伪装的实时 Token，故无需且不能试图访问这两个 url**

   ```json
   "wbi_img": {
       "img_url": "https://i0.hdslb.com/bfs/wbi/653657f524a547ac981ded72ea172057.png",
       "sub_url": "https://i0.hdslb.com/bfs/wbi/6e4909c702f846728e64f6007736a338.png"
   },
   ```
   这两个 Key 均为 url 中末尾路径的无扩展名的文件名，即`img_key=653657f524a547ac981ded72ea172057`，`sub_key=6e4909c702f846728e64f6007736a338`

   这两个 Key 的值无关登录 Session 与 IP，属于全站统一使用的，但**每日都会变化**，使用时应做好**缓存和刷新**处理

2. 打乱重排实时口令

   把上一步获取到的`sub_key`拼接在`img_key`后面 **（这里不是`img_url`和`sub_url`）** 作为一个整体，将这个整体进行特定的顺序的字符打乱重排，再将重排后的字符串截取前 32 字符的切片，作为一个新的变量`mixin_key`，重排映射表长为 64，内容如下：

   ```javascript
   const mixinKeyEncTab = [
       46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
       33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
       61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
       36, 20, 34, 44, 52
   ]
   ```

   打乱重排内容如下（以上述第 1 步的参数作为输入）

   ```
   72136226c6a73669787ee4fd02a74c27
   ```

3. 为参数中添加`wts`时间戳

   若下方内容为欲签名的请求参数（以 js obj 为例）

   ```javascript
   {
        foo: '114',
        bar: '514',
        zab: 1919810
   }
   ```

   `wts`字段的值应为以秒为单位的 Unix TimeStamp，如`1684746387`

   将`wts`参数添加在参数列表中，即：

   ```javascript
   {
        foo: '114',
        bar: '514',
        zab: 1919810,
        wts: 1684746387
   }
   ```

4. 将欲签名的请求参数排序后编码

   按照 Key 升序排序并进行 url query 编码后的结果应为：

   ```
   bar=514&foo=114&wts=1684746387&zab=1919810
   ```

   请注意，如果参数值为中文或特殊字符，则进行 url query 编码后的字符串中参数值对应的字母必须是**大写字母** （部分库会编码为小写字母）

   例如
   ```javascript
   {
        foo: 'one one four',
        bar: '五一四',
        baz: 1919810
   }
   ```

    应该被编码为

   ```
   bar=%E4%BA%94%E4%B8%80%E5%9B%9B&baz=1919810&foo=one%20one%20four
   ```

5. 计算`w_rid`并添加在其后

   在上一步得出的 url query 字符串后拼接第 2 步计算得出的`mixin_key`（作为盐）

   ```
   bar=514&foo=114&wts=1684746387&zab=191981072136226c6a73669787ee4fd02a74c27
   ```

   对这个整体进行 **md5 Hash 运算**（32-bit 字符小写），得到的值便是 Wbi Sign，也就是参数`w_rid`

   ```
   90efcab09403023875b8516f07e9f9de
   ```

   最后一步，把这个计算出的值作为参数`w_rid`添加在原始参数列表后，也就完成了一次 Wbi Sign，可以调用 REST API 进行请求了

   ```
   bar=514&foo=114&wts=1684746387&zab=1919810&w_rid=90efcab09403023875b8516f07e9f9de
   ```

## Wbi签名算法实现Demo

该 Demo 提供 [Python](#Python)、[JavaScript](#JavaScript)、[Golang](#Golang)、[C#](#CSharp)和[Java](#Java) 语言

### Python

需要`requests`依赖

```python
from functools import reduce
from hashlib import md5
import urllib.parse
import time
import requests

mixinKeyEncTab = [
    46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
    33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
    61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
    36, 20, 34, 44, 52
]

def getMixinKey(orig: str):
    '对 imgKey 和 subKey 进行字符顺序打乱编码'
    return reduce(lambda s, i: s + orig[i], mixinKeyEncTab, '')[:32]

def encWbi(params: dict, img_key: str, sub_key: str):
    '为请求参数进行 wbi 签名'
    mixin_key = getMixinKey(img_key + sub_key)
    curr_time = round(time.time())
    params['wts'] = curr_time                                   # 添加 wts 字段
    params = dict(sorted(params.items()))                       # 按照 key 重排参数
    # 过滤 value 中的 "!'()*" 字符
    params = {
        k : ''.join(filter(lambda chr: chr not in "!'()*", str(v)))
        for k, v 
        in params.items()
    }
    query = urllib.parse.urlencode(params)                      # 序列化参数
    wbi_sign = md5((query + mixin_key).encode()).hexdigest()    # 计算 w_rid
    params['w_rid'] = wbi_sign
    return params

def getWbiKeys() -> tuple[str, str]:
    '获取最新的 img_key 和 sub_key'
    resp = requests.get('https://api.bilibili.com/x/web-interface/nav')
    resp.raise_for_status()
    json_content = resp.json()
    img_url: str = json_content['data']['wbi_img']['img_url']
    sub_url: str = json_content['data']['wbi_img']['sub_url']
    img_key = img_url.rsplit('/', 1)[1].split('.')[0]
    sub_key = sub_url.rsplit('/', 1)[1].split('.')[0]
    return img_key, sub_key

img_key, sub_key = getWbiKeys()

signed_params = encWbi(
    params={
        'foo': '114',
        'bar': '514',
        'baz': 1919810
    },
    img_key=img_key,
    sub_key=sub_key
)
query = urllib.parse.urlencode(signed_params)
print(signed_params)
print(query)
```

输出内容分别是进行 Wbi 签名的后参数的 key-Value 以及 url query 形式

```
{'bar': '514', 'baz': '1919810', 'foo': '114', 'wts': '1684746387', 'w_rid': 'd3cbd2a2316089117134038bf4caf442'}
bar=514&baz=1919810&foo=114&wts=1684746387&w_rid=d3cbd2a2316089117134038bf4caf442
```

### JavaScript

需要 `axios`、`md5` 依赖

```javascript
import md5 from 'md5'
import axios from 'axios'

const mixinKeyEncTab = [
    46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
    33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
    61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
    36, 20, 34, 44, 52
]

// 对 imgKey 和 subKey 进行字符顺序打乱编码
function getMixinKey(orig) {
    let temp = ''
    mixinKeyEncTab.forEach((n) => {
        temp += orig[n]
    })
    return temp.slice(0, 32)
}

// 为请求参数进行 wbi 签名
function encWbi(params, img_key, sub_key) {
    const mixin_key = getMixinKey(img_key + sub_key),
        curr_time = Math.round(Date.now() / 1000),
        chr_filter = /[!'()*]/g
    let query = []
    Object.assign(params, { wts: curr_time }) // 添加 wts 字段
    // 按照 key 重排参数
    Object.keys(params).sort().forEach((key) => {
        query.push(
            `${encodeURIComponent(key)}=${encodeURIComponent(
                // 过滤 value 中的 "!'()*" 字符
                params[key].toString().replace(chr_filter, '')
            )}`
        )
    })
    query = query.join('&')
    const wbi_sign = md5(query + mixin_key) // 计算 w_rid
    return query + '&w_rid=' + wbi_sign
}

// 获取最新的 img_key 和 sub_key
async function getWbiKeys() {
    const resp = await axios({
        url: 'https://api.bilibili.com/x/web-interface/nav',
        method: 'get',
        responseType: 'json'
    }),
        json_content = resp.data,
        img_url = json_content.data.wbi_img.img_url,
        sub_url = json_content.data.wbi_img.sub_url

    return {
        img_key: img_url.slice(
            img_url.lastIndexOf('/') + 1,
            img_url.lastIndexOf('.')
        ),
        sub_key: sub_url.slice(
            sub_url.lastIndexOf('/') + 1,
            sub_url.lastIndexOf('.')
        )
    }
}

getWbiKeys().then((wbi_keys) => {
    const query = encWbi(
        {
            foo: '114',
            bar: '514',
            baz: 1919810
        },
        wbi_keys.img_key, 
        wbi_keys.sub_key
    )
    console.log(query)
})
```

输出内容为进行 Wbi 签名的后参数的 url query 形式

```
bar=514&baz=1919810&foo=114&wts=1684805578&w_rid=bb97e15f28edf445a0e4420d36f0157e
```

### Golang

需要 `github.com/tidwall/gjson` 作为依赖

```golang
package main

import (
        "crypto/md5"
        "encoding/hex"
        "fmt"
        "io"
        "net/http"
        "net/url"
        "sort"
        "strconv"
        "strings"
        "sync"
        "time"

        "github.com/tidwall/gjson"
)

var (
        mixinKeyEncTab = []int{
                46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
                33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
                61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
                36, 20, 34, 44, 52,
        }
        cache          sync.Map
        lastUpdateTime time.Time
)

func main() {
        urlStr := "https://api.bilibili.com/x/space/wbi/acc/info?mid=1850091"
        newUrlStr, err := signAndGenerateURL(urlStr)
        if err != nil {
                fmt.Printf("Error: %s", err)
                return
        }
        req, err := http.NewRequest("GET", newUrlStr, nil)
        if err != nil {
                fmt.Printf("Error: %s", err)
                return
        }
        req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
        response, err := http.DefaultClient.Do(req)
        if err != nil {
                fmt.Printf("Request failed: %s", err)
                return
        }
        defer response.Body.Close()
        body, err := io.ReadAll(response.Body)
        if err != nil {
                fmt.Printf("Failed to read response: %s", err)
                return
        }
        fmt.Println(string(body))
}

func signAndGenerateURL(urlStr string) (string, error) {
        urlObj, err := url.Parse(urlStr)
        if err != nil {
                return "", err
        }
        imgKey, subKey := getWbiKeysCached()
        query := urlObj.Query()
        params := map[string]string{}
        for k, v := range query {
                params[k] = v[0]
        }
        newParams := encWbi(params, imgKey, subKey)
        for k, v := range newParams {
                query.Set(k, v)
        }
        urlObj.RawQuery = query.Encode()
        newUrlStr := urlObj.String()
        return newUrlStr, nil
}

func encWbi(params map[string]string, imgKey, subKey string) map[string]string {
        mixinKey := getMixinKey(imgKey + subKey)
        currTime := strconv.FormatInt(time.Now().Unix(), 10)
        params["wts"] = currTime

        // Sort keys
        keys := make([]string, 0, len(params))
        for k := range params {
                keys = append(keys, k)
        }
        sort.Strings(keys)

        // Remove unwanted characters
        for k, v := range params {
                v = sanitizeString(v)
                params[k] = v
        }

        // Build URL parameters
        query := url.Values{}
        for _, k := range keys {
                query.Set(k, params[k])
        }
        queryStr := query.Encode()

        // Calculate w_rid
        hash := md5.Sum([]byte(queryStr + mixinKey))
        params["w_rid"] = hex.EncodeToString(hash[:])
        return params
}

func getMixinKey(orig string) string {
        var str strings.Builder
        for _, v := range mixinKeyEncTab {
                if v < len(orig) {
                        str.WriteByte(orig[v])
                }
        }
        return str.String()[:32]
}

func sanitizeString(s string) string {
        unwantedChars := []string{"!", "'", "(", ")", "*"}
        for _, char := range unwantedChars {
                s = strings.ReplaceAll(s, char, "")
        }
        return s
}

func updateCache() {
        if time.Since(lastUpdateTime).Minutes() < 10 {
                return
        }
        imgKey, subKey := getWbiKeys()
        cache.Store("imgKey", imgKey)
        cache.Store("subKey", subKey)
        lastUpdateTime = time.Now()
}

func getWbiKeysCached() (string, string) {
        updateCache()
        imgKeyI, _ := cache.Load("imgKey")
        subKeyI, _ := cache.Load("subKey")
        return imgKeyI.(string), subKeyI.(string)
}

func getWbiKeys() (string, string) {
        resp, err := http.Get("https://api.bilibili.com/x/web-interface/nav")
        if err != nil {
                fmt.Printf("Error: %s", err)
                return "", ""
        }
        defer resp.Body.Close()
        body, err := io.ReadAll(resp.Body)
        if err != nil {
                fmt.Printf("Error: %s", err)
                return "", ""
        }
        json := string(body)
        imgURL := gjson.Get(json, "data.wbi_img.img_url").String()
        subURL := gjson.Get(json, "data.wbi_img.sub_url").String()
        imgKey := strings.Split(strings.Split(imgURL, "/")[len(strings.Split(imgURL, "/"))-1], ".")[0]
        subKey := strings.Split(strings.Split(subURL, "/")[len(strings.Split(subURL, "/"))-1], ".")[0]
        return imgKey, subKey
}
```

### CSharp

无需依赖外部库

```cs
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;

class Program
{
    private static HttpClient _httpClient = new();

    private static readonly int[] MixinKeyEncTab =
    {
        46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39,
        12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63,
        57, 62, 11, 36, 20, 34, 44, 52
    };

    //对 imgKey 和 subKey 进行字符顺序打乱编码
    private static string GetMixinKey(string orig)
    {
        return MixinKeyEncTab.Aggregate("", (s, i) => s + orig[i])[..32];
    }

    private static Dictionary<string, string> EncWbi(Dictionary<string, string> parameters, string imgKey,
        string subKey)
    {
        string mixinKey = GetMixinKey(imgKey + subKey);
        string currTime = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
        //添加 wts 字段
        parameters["wts"] = currTime;
        // 按照 key 重排参数
        parameters = parameters.OrderBy(p => p.Key).ToDictionary(p => p.Key, p => p.Value);
        //过滤 value 中的 "!'()*" 字符
        parameters = parameters.ToDictionary(
            kvp => kvp.Key,
            kvp => new string(kvp.Value.Where(chr => !"!'()*".Contains(chr)).ToArray())
        );
        // 序列化参数
        string query = new FormUrlEncodedContent(parameters).ReadAsStringAsync().Result;
        //计算 w_rid
        using MD5 md5 = MD5.Create();
        byte[] hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes(query + mixinKey));
        string wbiSign = BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
        parameters["w_rid"] = wbiSign;

        return parameters;
    }

    // 获取最新的 img_key 和 sub_key
    private static async Task<(string, string)> GetWbiKeys()
    {
        HttpResponseMessage responseMessage = await _httpClient.SendAsync(new HttpRequestMessage
        {
            Method = HttpMethod.Get,
            RequestUri = new Uri("https://api.bilibili.com/x/web-interface/nav"),
        });

        JsonNode response = JsonNode.Parse(await responseMessage.Content.ReadAsStringAsync())!;

        string imgUrl = (string)response["data"]!["wbi_img"]!["img_url"]!;
        imgUrl = imgUrl.Split("/")[^1].Split(".")[0];

        string subUrl = (string)response["data"]!["wbi_img"]!["sub_url"]!;
        subUrl = subUrl.Split("/")[^1].Split(".")[0];
        return (imgUrl, subUrl);
    }

    public static async Task Main()
    {
        var (imgKey, subKey) = await GetWbiKeys();

        Dictionary<string, string> signedParams = EncWbi(
            parameters: new Dictionary<string, string>
            {
                { "foo", "114" },
                { "bar", "514" },
                { "baz", "1919810" }
            },
            imgKey: imgKey,
            subKey: subKey
        );

        string query = await new FormUrlEncodedContent(signedParams).ReadAsStringAsync();

        Console.WriteLine(query);
    }
}
```
输出内容为进行 Wbi 签名的后参数的 url query 形式

```
bar=514&baz=1919810&foo=114&wts=1687541921&w_rid=26e82b1b9b3a11dbb1807a9228a40d3b
```

### Java

需要 `hutool` 依赖

```java
package com.example.demo;

import cn.hutool.crypto.SecureUtil;

import java.util.*;

public class WbiTest {
    private static final int[] mixinKeyEncTab = new int[]{
            46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
            33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
            61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
            36, 20, 34, 44, 52
    };

    public static String getMixinKey(String imgKey, String subKey) {
        String s = imgKey + subKey;
        StringBuilder key = new StringBuilder();
        for (int i = 0; i < 32; i++) {
            key.append(s.charAt(mixinKeyEncTab[i]));
        }
        return key.toString();
    }

   public static void main(String[] args) {
      String imgKey = "653657f524a547ac981ded72ea172057";
      String subKey = "6e4909c702f846728e64f6007736a338";
      String mixinKey = getMixinKey(imgKey, subKey);
      System.out.println(mixinKey);
      //72136226c6a73669787ee4fd02a74c27
      //{
      //     foo: 'one one four',
      //     bar: '五一四',
      //     baz: 1919810
      //}
      LinkedHashMap<String, Object> map = new LinkedHashMap<>();
      map.put("foo", "one one four");
      map.put("bar", "五一四");
      map.put("baz", 1919810);
      map.put("wts", System.currentTimeMillis() / 1000);
      StringJoiner param = new StringJoiner("&");
      //排序 + 拼接字符串
      map.entrySet().stream()
              .sorted(Map.Entry.comparingByKey())
              .forEach(entry -> param.add(entry.getKey() + "=" + URLUtil.encode(entry.getValue().toString())));
      String s = param + mixinKey;
      String wbiSign = SecureUtil.md5(s);
      System.out.println(wbiSign);
      String finalParam = param + "&w_rid=" + wbiSign;
      System.out.println(finalParam);
   }
}
```
