> ## Documentation Index
> Fetch the complete documentation index at: https://www.ayrshare.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 轮换签名密钥

> 通过 24 小时双签名宽限期安全地轮换你的 webhook 签名密钥

export const PlansAvailable = ({plans = [], maxPackRequired}) => {
  let displayPlans = plans;
  if (plans && plans.length === 1) {
    const lowerCasePlan = plans[0].toLowerCase();
    if (lowerCasePlan === "business") {
      displayPlans = ["Launch", "Business", "Enterprise"];
    } else if (lowerCasePlan === "premium") {
      displayPlans = ["Premium", "Launch", "Business", "Enterprise"];
    }
  }
  return <Note>
Available on {displayPlans.length === 1 ? "the " : ""}
{displayPlans.join(", ").replace(/\b\w/g, l => l.toUpperCase())}{" "}
{displayPlans.length > 1 ? "plans" : "plan"}.

{maxPackRequired && <span onClick={() => window.open('https://www.ayrshare.com/docs/additional/maxpack', '_self')} className="flex items-center mt-2 cursor-pointer">
 <span className="px-1.5 py-0.5 rounded text-sm" style={{
    backgroundColor: '#C264B6',
    color: 'white',
    fontSize: '12px'
  }}>
   Max Pack required
 </span>
</span>}
</Note>;
};

export const HeaderAPI = ({noProfileKey, profileKeyRequired}) => <>
    <ParamField header="Authorization" type="string" required>
      <a href="/docs/apis/overview#authorization">API Key</a> of the Primary Profile.
      <br />
      <br />
      Format: <code>Authorization: Bearer API_KEY</code>
    </ParamField>
    {!noProfileKey && (profileKeyRequired ? <ParamField header="Profile-Key" type="string" required>
          <a href="/docs/apis/overview#profile-key-format">Profile Key</a> of a User Profile.
          <br />
          <br />
          Format: <code>Profile-Key: PROFILE_KEY</code>
        </ParamField> : <ParamField header="Profile-Key" type="string">
          <a href="/docs/apis/overview#profile-key-format">Profile Key</a> of a User Profile.
          <br />
          <br />
          Format: <code>Profile-Key: PROFILE_KEY</code>
        </ParamField>)}
  </>;

<PlansAvailable plans={["premium"]} maxPackRequired={false} />

## 概览

Ayrshare 会使用你的**签名密钥**作为 key,对每一次 webhook 投递的 payload 计算 [HMAC-SHA256](https://en.wikipedia.org/wiki/HMAC) 签名,以便你的接收方能够确认该投递确实来自 Ayrshare。有关验证过程,请参见 [Webhook 安全性](/docs/apis/webhooks/overview#webhook-security)。

轮换你的签名密钥可以让你按固定周期替换它,或在你怀疑密钥已泄露时立即替换。为了让轮换过程安全,每次轮换后 Ayrshare 都会打开一个**24 小时的宽限期**,在此期间投递会同时用你的**旧密钥和新密钥**进行签名。这样你可以按自己的节奏更新接收方,而不会丢弃或拒绝任何一次投递 —— 这与 Stripe 和 GitHub 采用的模式一致。

<Note>
  签名密钥是**按 profile 统一的**:每个 User Profile (UID) 只有一个签名密钥,
  它会对该 profile 已注册的**每一个** webhook action 进行签名。不存在按 action
  单独设置的签名密钥 —— 设置或轮换该密钥会同时改变该 profile 上所有 action
  使用的密钥。
</Note>

## 通过控制台轮换

你可以从开发者控制台的 [Webhooks 页面](https://app.ayrshare.com/webhooks) 设置或轮换你的签名密钥。当该 profile 至少注册了一个 webhook 后,**Signing Secret** 面板会显示在你的 webhook 列表上方。

<Steps>
  <Step title="打开 Signing Secret 面板">
    前往 [Webhooks 页面](https://app.ayrshare.com/webhooks)。如果尚未配置签名密钥,面板会显示 **No signing secret configured** 以及一个 **Set Signing Secret** 按钮。如果已经配置,则会显示 **Signing secret configured** 以及一个 **Rotate** 按钮。
  </Step>

  <Step title="设置或轮换">
    点击 **Set Signing Secret**(首次设置)或 **Rotate**(已有密钥)。弹窗会打开,其中预填并显示一个高强度、随机生成的密钥。你可以 **Copy** 复制它、**Regenerate** 重新生成一个新的,或切换 **paste my own** 使用你自己的值。
  </Step>

  <Step title="确认">
    请将密钥复制到安全的地方 —— 它只显示一次,之后无法再从 UI 中获取 —— 然后确认提交。会出现一个成功提示,面板也会随之更新。
  </Step>

  <Step title="更新你的接收方">
    在轮换(而非首次设置)时,面板会显示一个已激活的宽限期指示器,并且 **Rotate** 按钮在窗口关闭之前不可用。你有 24 小时将新密钥部署到你的接收方。
  </Step>
</Steps>

## 通过 API 轮换

只需一次调用即可轮换(或设置)签名密钥。该调用会创建一个新密钥,将该 profile 的密钥引用指向它,并在已有密钥存在时,将被替换掉的密钥记录为“旧密钥”,过期时间为 24 小时后。

### Header 参数

<HeaderAPI />

### Body 参数

<ParamField body="secret" type="string" required>
  新的签名密钥值。可以是任何非空字符串。建议使用长度较长、熵较高的随机值(例如 32 个随机字节的 base64url 编码)。
</ParamField>

<RequestExample>
  ```bash cURL theme={"system"}
  curl --request POST \
    --url https://api.ayrshare.com/api/hook/webhook/secret \
    --header 'Authorization: Bearer YOUR_API_KEY' \
    --header 'Content-Type: application/json' \
    --header 'Profile-Key: YOUR_PROFILE_KEY' \
    --data '{
      "secret": "your-new-signing-secret"
    }'
  ```
</RequestExample>

<ResponseExample>
  ```json 200: Response theme={"system"}
  {
    "status": "success",
    "action": "webhook",
    "refId": "3dc079614bdc3f281d9" // User Profile Ref Id
  }
  ```
</ResponseExample>

响应中**绝不**会返回明文 `secret`,也绝不会被记录到日志。响应中携带的是面向客户端的 `refId`(UID 的哈希),而不是 UID 本身。`Profile-Key` header 是可选的,对多 profile 账户来说,它用于把此次轮换限定到单个 User Profile。

若缺失或为空的 `secret`,会返回一个已映射的错误(`code: 101`,“Missing/incorrect parameter”),HTTP 状态为 `400`,并且不会对你现有的密钥做任何改动。通过 API 进行首次设置(没有现有密钥)会创建密钥,不会记录旧密钥,也不会打开宽限期。

## 安全轮换流程

由于有 24 小时的宽限期,并不存在严格要求的操作顺序 —— 你的接收方在整个过程中都能正常工作。推荐的顺序如下:

<Steps>
  <Step title="轮换密钥">
    通过控制台或 API 进行轮换。Ayrshare 会立即开始同时使用你的旧密钥和新密钥对投递进行签名。
  </Step>

  <Step title="更新你的接收方">
    在 24 小时内,将新密钥部署到你的 webhook 接收方,以便使用新值进行验证。
  </Step>

  <Step title="等待窗口关闭">
    24 小时之后,Ayrshare 会自动清除旧密钥,只使用新密钥进行签名。你这一侧无需再做任何操作。
  </Step>
</Steps>

<Tip>
  如果你在宽限期仍然打开时再次进行轮换,那么刚刚被替换掉的密钥会成为新的
  “旧密钥”,并会开启一个全新的 24 小时窗口。同一时刻只保留一个旧密钥。
</Tip>

## 在宽限期内验证签名

在宽限期之外,已签名的投递会携带标准的 header(参见 [Webhook 安全性](/docs/apis/webhooks/overview#webhook-security)):

```bash theme={"system"}
X-Authorization-Timestamp : <Unix Timestamp In Seconds>
X-Authorization-Content-SHA256 : <current-sig>
X-Authorization-Content-SHA256-V2 : v1=<current-sig>
```

在轮换后的 24 小时窗口内,新的 `X-Authorization-Content-SHA256-V2` header 会**同时**列出两个签名,当前签名在前,以逗号分隔:

```bash theme={"system"}
X-Authorization-Timestamp : <Unix Timestamp In Seconds>
X-Authorization-Content-SHA256 : <current-sig>
X-Authorization-Content-SHA256-V2 : v1=<current-sig>,v1=<previous-sig>
```

<Note>
  `X-Authorization-Content-SHA256` 保持不变:它始终只携带使用当前密钥计算出的
  单个 HMAC,以保持向后兼容。双签名只会出现在新的
  `X-Authorization-Content-SHA256-V2` header 中。
</Note>

`X-Authorization-Content-SHA256-V2` 中的每一个值都带有一个方案标签前缀。`v1=` 表示 HMAC-SHA256 签名,其计算方式与 `X-Authorization-Content-SHA256` 完全一致。只要一次投递被签名,`-V2` header 就一定会出现 —— 它至少携带 `v1=<current-sig>` —— 因此你可以将其作为稳定的接收方契约来依赖。

在轮换期间(或宽限期之外)验证一次投递的方式:

<Steps>
  <Step title="计算 HMAC">
    使用你本地配置的签名密钥,对**原始请求 body** 计算 HMAC-SHA256。
  </Step>

  <Step title="与每一个列出的签名进行比对">
    读取 `X-Authorization-Content-SHA256-V2`,按逗号拆分,从每个值中去掉 `v1=` 前缀,只要你计算出的 HMAC 与**任意一个**列出的 `v1=` 签名相匹配,就把该投递视为可信。
  </Step>
</Steps>

只要有**任意一个**列出的签名匹配就接受 —— 这正是让轮换实现零停机的关键:仍配置旧密钥的接收方会匹配到 `v1=<previous-sig>`,而已更新为新密钥的接收方会匹配到 `v1=<current-sig>` —— 在整个窗口期内两者都能成功。

### 接收方验证示例

```javascript Node.js theme={"system"}
import crypto from "crypto";

// secret is the signing secret currently configured on your receiver.
function isAuthenticWebhook(rawBody, headers, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody) // the raw, unparsed request body
    .digest("hex");

  const headerValue = headers["x-authorization-content-sha256-v2"] || "";

  // Accept if ANY v1= signature in the header matches our computed HMAC.
  return headerValue
    .split(",")
    .map((part) => part.trim())
    .filter((part) => part.startsWith("v1="))
    .map((part) => part.slice("v1=".length))
    .some((sig) => {
      const sigBuf = Buffer.from(sig);
      const expectedBuf = Buffer.from(expected);
      // timingSafeEqual throws on length mismatch — treat as not authentic.
      return (
        sigBuf.length === expectedBuf.length &&
        crypto.timingSafeEqual(sigBuf, expectedBuf)
      );
    });
}
```

<Warning>
  务必对**原始**的请求 body 字节(与接收到时完全一致)计算 HMAC,
  而不是对重新序列化后的 JSON 对象计算 —— 重新序列化可能改变空白字符
  或 key 的顺序,从而导致验证失败。请使用常量时间比较(例如
  `crypto.timingSafeEqual`)以避免时序攻击。
</Warning>

如果某次投递对应的当前密钥记录已丢失,该投递会以**未签名**方式(不带任何签名 header)送达,而不是失败。如果只是旧密钥记录已丢失,那么旧签名会被跳过,当前签名仍会出现在两个 header 中。
