火山素材接口认证 Key 与 V4 签名

这里的“验证 Key”不是一把单独的火山素材密钥。调用 Modelink 的火山素材兼容接口时,先在 Modelink 控制台创建一个 API Key;这个 Key 用于查询项目,也同时作为火山 V4 签名中的 Access Key 和签名密钥。

信息

GET /v1/asset-projects 使用 Authorization: Bearer,用于查询当前 Key 可用的 ProjectName。素材 Action 使用 POST /volcengine/assets/,必须针对最终 URL 和原始请求体生成 HMAC-SHA256 签名。两种请求使用同一个 API Key。

如果你直接调用火山方舟官方域名,请在火山控制台创建官方 AK/SK,并将它们传给同样的签名算法;不要把官方 AK/SK 和 Modelink API Key 混用。本文的请求示例默认使用 Modelink 的兼容入口。

先准备 API Key

  1. 在控制台创建 Key

    打开 Modelink 控制台的 API Key 页面,创建一个专用于素材服务的 Key。名称建议包含环境和用途,例如 staging-volcengine-assetsprod-avatar-assets

    创建后立即复制完整 Key。出于安全原因,后续页面通常只展示脱敏值。

  2. 只在服务端配置 Key

    将 Key 注入服务端环境变量或密钥管理服务。中国大陆和海外入口分别如下:

    export MODELINK_API_KEY="替换为你的 Modelink API Key"
    export MODELINK_BASE_URL="https://api.qnaigc.com"
    # 海外部署:export MODELINK_BASE_URL="https://api.modelink.ai"

    不要把 Key 写进浏览器代码、移动端包、日志、截图或公开仓库。

先验证 Key 的项目权限

素材 Action 的 ProjectName 不能自行填写,也不能使用 default。先调用项目查询接口,并选择返回结果中与你的素材类型匹配的项目:

curl "$MODELINK_BASE_URL/v1/asset-projects" \
  -H "Authorization: Bearer $MODELINK_API_KEY"

成功响应示例:

{
  "data": [
    {
      "project_name": "cv1-bogic",
      "supported_asset_types": ["aigc", "liveness_face"]
    }
  ]
}

将返回的项目名保存下来:

export PROJECT_NAME="cv1-bogic"

aigc 用于虚拟人像素材,liveness_face 用于需要真人认证的素材。如果 data 为空,说明当前 Key 尚未分配素材项目或对应能力,请先联系项目管理员;这不是签名格式问题。

V4 签名需要哪些值

每个素材 Action 都发送到下面的固定路径,末尾斜杠不能省略:

POST /volcengine/assets/?Action=<Action>&Version=2024-01-01

签名 helper 会从 Host 推断区域;Modelink 入口使用默认区域 cn-beijing,火山官方 Host(例如 ark.cn-beijing.volces.com)会从域名读取区域。签名覆盖的请求头顺序固定为:

content-type;host;x-content-sha256;x-date

最终请求必须带上以下头:

Authorization: HMAC-SHA256 Credential=<access-key>/<date>/<region>/ark/request, SignedHeaders=content-type;host;x-content-sha256;x-date, Signature=<hex-signature>
X-Date: <UTC yyyyMMddTHHmmssZ>
X-Content-Sha256: <request-body-sha256>
Content-Type: application/json

注意事项:

  • X-Date 使用 UTC,服务端允许的时钟偏差为 5 分钟。
  • X-Content-Sha256 必须对最终发送的请求体原始字节计算,签名后不能再次序列化 JSON。
  • ActionVersion 是查询参数,必须把最终顺序和编码后的值用于签名。
  • 每个请求都重新生成 X-Date、摘要和 Authorization,不能跨请求复用。

在线生成签名请求头

下面的组件使用浏览器内置 Web Crypto,在本地生成与 volc_sign.py 等价的四个请求头。它不会创建真实 API Key,也不会将 AK/SK 发送到服务器;真实 Key 仍需在控制台创建。

在线生成 V4 签名请求头

计算仅在当前浏览器完成,不会发送或保存你的 AK/SK。

必须包含最终的 Action、Version 和末尾斜杠;签名后不要改变 URL。

摘要和签名使用这里的原始字符;发送时必须使用完全相同的 body 字节。

警告

只建议用临时测试凭据体验此组件。生产环境不要在浏览器输入 Secret Key;应在服务端生成签名并将请求发往素材接口。

保存签名 helper

下面的实现根据 volc_sign.py 的规则整理,只有 Python 标准库依赖。将代码保存为 volc_sign.py。它同时支持 Modelink 兼容 Host 和火山官方 ark.<region>.volces.com Host。

#!/usr/bin/env python3
"""火山/BytePlus Signature V4 helper。"""

from __future__ import annotations

import hashlib
import hmac
import urllib.parse
from datetime import datetime, timezone

DEFAULT_REGION = "cn-beijing"
SERVICE = "ark"
SIGNED_HEADERS = "content-type;host;x-content-sha256;x-date"


def _hmac_sha256(key: bytes, message: str) -> bytes:
    return hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest()


def _sha256_hex(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def _rfc3986_escape(value: str) -> str:
    return urllib.parse.quote(value, safe="-_.~")


def _canonical_query(raw_query: str) -> str:
    if not raw_query:
        return ""
    pairs = urllib.parse.parse_qsl(raw_query, keep_blank_values=True)
    pairs.sort(key=lambda item: (item[0], item[1]))
    return "&".join(
        f"{_rfc3986_escape(key)}={_rfc3986_escape(value)}"
        for key, value in pairs
    )


def _region_from_host(host: str) -> str:
    try:
        hostname = urllib.parse.urlparse(f"//{host}").hostname
    except ValueError:
        return DEFAULT_REGION

    if not hostname:
        return DEFAULT_REGION
    hostname = hostname.lower()
    prefix = "ark."
    if not hostname.startswith(prefix):
        return DEFAULT_REGION

    rest = hostname.removeprefix(prefix)
    for suffix in (".byteplusapi.com", ".volces.com"):
        if rest.endswith(suffix):
            region = rest.removesuffix(suffix)
            if region and "." not in region:
                return region
    return DEFAULT_REGION


def sign_headers(
    *,
    access_key: str,
    secret_key: str,
    method: str,
    host: str,
    path: str,
    raw_query: str,
    body: bytes,
    now: datetime | None = None,
) -> dict[str, str]:
    """Return Content-Type, X-Date, X-Content-Sha256 and Authorization."""
    now = now or datetime.now(timezone.utc)
    region = _region_from_host(host)
    x_date = now.strftime("%Y%m%dT%H%M%SZ")
    short_date = now.strftime("%Y%m%d")
    body_hash = _sha256_hex(body)

    canonical_headers = (
        "content-type:application/json\n"
        f"host:{host}\n"
        f"x-content-sha256:{body_hash}\n"
        f"x-date:{x_date}\n"
    )
    canonical_request = (
        f"{method}\n"
        f"{path}\n"
        f"{_canonical_query(raw_query)}\n"
        f"{canonical_headers}\n"
        f"{SIGNED_HEADERS}\n"
        f"{body_hash}"
    )

    credential_scope = f"{short_date}/{region}/{SERVICE}/request"
    string_to_sign = (
        "HMAC-SHA256\n"
        f"{x_date}\n"
        f"{credential_scope}\n"
        f"{_sha256_hex(canonical_request.encode('utf-8'))}"
    )

    # 火山/BytePlus 风格的派生密钥直接以 SK 为种子。
    k_date = _hmac_sha256(secret_key.encode("utf-8"), short_date)
    k_region = _hmac_sha256(k_date, region)
    k_service = _hmac_sha256(k_region, SERVICE)
    k_signing = _hmac_sha256(k_service, "request")
    signature = hmac.new(
        k_signing,
        string_to_sign.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    authorization = (
        f"HMAC-SHA256 Credential={access_key}/{credential_scope}, "
        f"SignedHeaders={SIGNED_HEADERS}, "
        f"Signature={signature}"
    )
    return {
        "Content-Type": "application/json",
        "X-Date": x_date,
        "X-Content-Sha256": body_hash,
        "Authorization": authorization,
    }

用只读 Action 验证签名

创建 verify_asset_key.py,用 ListAssetGroups 做最小验证。这个请求不会创建或删除素材;返回空列表也代表签名和项目权限通过。

#!/usr/bin/env python3
import json
import os
import urllib.error
import urllib.request
from urllib.parse import urlsplit

from volc_sign import sign_headers


api_key = os.environ["MODELINK_API_KEY"]
base_url = os.environ.get("MODELINK_BASE_URL", "https://api.qnaigc.com").rstrip("/")
project_name = os.environ["PROJECT_NAME"]
url = (
    f"{base_url}/volcengine/assets/"
    "?Action=ListAssetGroups&Version=2024-01-01"
)
payload = {
    "Filter": {"GroupType": "AIGC"},
    "PageNumber": 1,
    "PageSize": 10,
    "ProjectName": project_name,
}

# body 先序列化成最终字节,再同时用于摘要、签名和发送。
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode(
    "utf-8"
)
parsed = urlsplit(url)
headers = sign_headers(
    access_key=api_key,
    secret_key=api_key,
    method="POST",
    host=parsed.netloc,
    path=parsed.path,
    raw_query=parsed.query,
    body=body,
)
request = urllib.request.Request(url, data=body, headers=headers, method="POST")

try:
    with urllib.request.urlopen(request, timeout=30) as response:
        print(response.status)
        print(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
    print(error.code)
    print(error.read().decode("utf-8"))

运行:

python verify_asset_key.py

验证成功时,响应的 ResponseMetadata.Action 应为 ListAssetGroups;如果没有素材组,Result 为空列表是正常结果。拿到 Active 素材后,再按火山协议 · 虚拟人像素材管理火山协议 · 真人人像素材管理继续操作。

常见失败原因

现象优先检查
401 或 Bearer 鉴权失败GET /v1/asset-projects 是否使用 Authorization: Bearer <API Key>,Key 是否被禁用或复制不完整
403 或签名无效access_keysecret_key 是否都使用同一个 Key;Host、路径末尾斜杠、Action/Version 查询参数是否与实际请求一致
请求提示时间无效X-Date 是否为 UTC,客户端时钟是否与服务端相差超过 5 分钟
X-Content-Sha256 不匹配摘要、签名和发送是否使用了同一份原始 body;不要让 HTTP 客户端再次格式化 JSON
项目或素材权限错误ProjectName 是否来自当前 Key 的 /v1/asset-projects 响应,且没有写成 default
火山官方域名签名失败Host 是否完整包含 ark.<region>.volces.com,并使用对应官方 AK/SK,而不是 Modelink API Key

完整字段、响应和错误码请参阅火山素材兼容接口

警告

签名验证通过后仍应在服务端实施权限隔离、请求日志脱敏、超时和重试控制。尤其不要为了排查问题把完整 API Key 或签名密钥发到聊天、工单或前端日志中。