PAYATHON 2026

使用 Vault 安全存储用户 PEM / KEY (PFX) 并用于 fetch

支付老李

结论

HashiCorp Vault 的 KV v2 可以存储 PEM、私钥或 PFX,但仅创建一个 Vault role,无法保证”只有上传者能够访问”。要实现用户隔离,需要同时控制两层权限:

  1. 应用层根据登录会话验证证书归属,不能让客户端随意指定 userId。
  2. Vault 层通过独立路径和 policy 限制访问范围。

如果所有 Vault 操作都共用一个服务器令牌,Vault 只能确认服务器是否有权限,无法识别当前登录的用户。用户之间的隔离主要由应用层完成。如果希望 Vault 也能识别用户,需要让每个用户通过 JWT/OIDC 等方式登录 Vault,再使用基于 Vault Identity 的模板化 policy。

支付调用通常由后端服务访问 Vault,并由应用校验证书归属。不要把 Vault token、私钥或 PFX 返回给浏览器。

推荐的数据结构

证书可以按以下路径保存:

secret/data/client-certs/<internalUserId>/<certificateId>

KV v2 中对应的数据如下:

{
  "data": {
    "format": "pem",
    "certificate": "-----BEGIN CERTIFICATE-----\n...",
    "privateKey": "-----BEGIN PRIVATE KEY-----\n...",
    "passphrase": "..."
  }
}

PFX 是二进制数据,写入 JSON 前需要转换为 Base64:

{
  "data": {
    "format": "pfx",
    "pfxBase64": "...",
    "passphrase": "..."
  }
}

internalUserId 必须来自经过验证的登录会话,例如 req.user.id,不能直接使用请求体或 URL 中未经校验的用户 ID。certificateId 建议由服务器生成 UUID,并在业务数据库中保存以下非敏感元数据:

certificateId
ownerUserId
vaultPath
format
createdAt
expiresAt
status

业务数据库中不要保存私钥、PFX 内容或口令。

Vault 权限设计

常用方案:服务器身份访问 Vault

后端可以通过 Kubernetes Auth、AppRole 或云平台工作负载身份等机制获取短期 Vault token。服务器 policy 只需允许访问证书目录:

path "secret/data/client-certs/*" {
  capabilities = ["create", "update", "read"]
}

path "secret/metadata/client-certs/*" {
  capabilities = ["read", "delete"]
}

这个 token 仍然可以访问多个用户的证书。因此,每次读取、更新或删除前,应用都必须检查:

当前登录用户 ID === 证书记录的 ownerUserId

不要让用户提交完整的 Vault 路径。应用应根据数据库记录获取或构造路径。

Vault 直接识别用户

如果每个用户都通过 Vault 支持的 JWT/OIDC Auth 登录,可以按 Vault Identity 的实体 ID 划分路径,并用模板化 policy 限制访问:

path "secret/data/client-certs/{{identity.entity.id}}/*" {
  capabilities = ["create", "update", "read"]
}

path "secret/metadata/client-certs/{{identity.entity.id}}/*" {
  capabilities = ["read", "delete"]
}

证书路径也要相应调整:

secret/data/client-certs/<vaultEntityId>/<certificateId>

这种模式不需要为每张证书创建一个 role。role 通常用于配置 JWT/OIDC/AppRole 的认证规则,资源路径的授权则由 policy 负责。

Identity 模板的具体语法和可用属性可能受到 Vault 配置与版本影响,部署前应根据实际使用的 Vault 版本进行验证。对于普通 Web 应用,让浏览器直接持有 Vault token 往往会增加令牌泄露和跨域管理风险,因此一般仍由后端代理这些操作。

写入 Vault

下面的示例直接调用 KV v2 HTTP API。假设挂载点是 secret,vaultToken 是后端获取的短期令牌:

async function vaultRequest(path, { method = "GET", body, vaultToken }) {
  const response = await fetch(`${process.env.VAULT_ADDR}/v1/${path}`, {
    method,
    headers: {
      "X-Vault-Token": vaultToken,
      ...(body ? { "Content-Type": "application/json" } : {})
    },
    body: body ? JSON.stringify(body) : undefined
  });

  if (!response.ok) {
    const message = await response.text();
    throw new Error(`Vault request failed (${response.status}): ${message}`);
  }

  if (response.status === 204) {
    return null;
  }

  return response.json();
}

写入 PEM:

async function storePemCertificate({
  userId,
  certificateId,
  certificate,
  privateKey,
  passphrase,
  vaultToken
}) {
  const path = `secret/data/client-certs/${encodeURIComponent(userId)}/${encodeURIComponent(certificateId)}`;

  await vaultRequest(path, {
    method: "POST",
    vaultToken,
    body: {
      data: {
        format: "pem",
        certificate,
        privateKey,
        passphrase
      }
    }
  });
}

写入 PFX:

async function storePfxCertificate({
  userId,
  certificateId,
  pfxBuffer,
  passphrase,
  vaultToken
}) {
  const path = `secret/data/client-certs/${encodeURIComponent(userId)}/${encodeURIComponent(certificateId)}`;

  await vaultRequest(path, {
    method: "POST",
    vaultToken,
    body: {
      data: {
        format: "pfx",
        pfxBase64: pfxBuffer.toString("base64"),
        passphrase
      }
    }
  });
}

实际接口还要限制上传大小。写入前需要解析证书,检查格式和有效期,确认私钥与证书匹配,并验证加密算法是否在允许范围内。任何情况下都不要把私钥内容写入日志。

按当前登录用户读取

先根据 certificateId 查询数据库中的元数据,再检查记录的所有者:

async function getOwnedCertificate({
  currentUserId,
  certificateId,
  certificateRepository,
  vaultToken
}) {
  const record = await certificateRepository.findById(certificateId);

  if (!record || record.ownerUserId !== currentUserId) {
    // 不区分“不存在”和“属于其他用户”,避免泄露资源信息
    const error = new Error("Certificate not found");
    error.statusCode = 404;
    throw error;
  }

  const result = await vaultRequest(record.vaultPath, {
    vaultToken
  });

  return result.data.data;
}

KV v2 读取响应中的秘密位于 result.data.data,不是顶层的 data。

在支付请求中使用证书

以下示例适用于支持 agent 选项的 node-fetch 调用方式。Node.js 内置的 fetch 基于 Undici,TLS 的配置方式不同,不能直接假定 agent 会生效。

import https from "node:https";
import fs from "node:fs";

async function payment({
  bodyData,
  currentUserId,
  certificateId,
  certificateRepository,
  vaultToken
}) {
  const secret = await getOwnedCertificate({
    currentUserId,
    certificateId,
    certificateRepository,
    vaultToken
  });

  const agentOptions = {
    // 支付提供商的受信 CA 通常是服务端配置,不应由用户上传决定。
    ca: fs.readFileSync("/secure/config/Swish_TLS_RootCA.pem")
  };

  if (secret.format === "pfx") {
    agentOptions.pfx = Buffer.from(secret.pfxBase64, "base64");
    agentOptions.passphrase = secret.passphrase;
  } else if (secret.format === "pem") {
    agentOptions.cert = secret.certificate;
    agentOptions.key = secret.privateKey;
    agentOptions.passphrase = secret.passphrase;
  } else {
    throw new Error("Unsupported certificate format");
  }

  const agent = new https.Agent(agentOptions);

  try {
    const response = await fetch(
      "https://mss.cpc.getswish.net/swish-cpcapi/api/v1/paymentrequests",
      {
        agent,
        method: "POST",
        body: JSON.stringify(bodyData),
        headers: {
          "Content-Type": "application/json"
        }
      }
    );

    if (response.status !== 201) {
      const responseBody = await response.text();
      throw new Error(
        `Payment provider returned ${response.status}: ${responseBody}`
      );
    }

    const location = response.headers.get("location");

    if (!location) {
      throw new Error("Payment response does not contain a Location header");
    }

    return location.split("/").filter(Boolean).at(-1);
  } finally {
    agent.destroy();

    // 只能尽力缩短二进制副本的驻留时间;JavaScript 字符串无法可靠清零。
    if (Buffer.isBuffer(agentOptions.pfx)) {
      agentOptions.pfx.fill(0);
    }
  }
}

不要全局缓存包含不同用户私钥的 https.Agent。如果必须复用连接,应使用不可伪造的证书标识隔离缓存,同时设置很短的生命周期和可靠的失效机制。

删除证书

KV v2 提供两种删除方式:

  • 删除指定版本或最新版本,数据在满足条件时可以恢复。
  • 删除 metadata,同时删除该 secret 的所有版本及元数据,通常用于最终删除。

最终删除需要使用 metadata API:

async function deleteOwnedCertificate({
  currentUserId,
  certificateId,
  certificateRepository,
  vaultToken
}) {
  const record = await certificateRepository.findById(certificateId);

  if (!record || record.ownerUserId !== currentUserId) {
    const error = new Error("Certificate not found");
    error.statusCode = 404;
    throw error;
  }

  const metadataPath = record.vaultPath.replace(
    "secret/data/",
    "secret/metadata/"
  );

  await vaultRequest(metadataPath, {
    method: "DELETE",
    vaultToken
  });

  await certificateRepository.delete(certificateId);
}

生产环境中不要通过字符串替换生成路径。最好使用统一的路径构造函数,分别生成 data 和 metadata 路径。还要处理 Vault 删除成功但数据库删除失败的情况,可以使用状态字段、幂等操作或后台补偿任务。

重要注意事项

  • Vault 保护静态存储并提供访问审计,但不会自动理解应用中的登录会话。
  • 不要使用长期 root token。应使用工作负载认证、短期 token 和最小权限 policy,并正确处理续租与撤销。
  • 不要让浏览器获得私钥、PFX、口令或服务器使用的 Vault token。
  • 不要把用户证书直接写入固定的本地目录,否则可能出现路径穿越、残留文件和并发覆盖等问题。
  • 上传接口应限制文件大小、格式和数量,并检查证书与私钥是否配对。
  • 日志、异常跟踪和 APM 中需要过滤 Vault 响应、请求体、PEM、PFX、口令和 token。
  • 审计日志可以记录操作者、证书 ID、操作类型和结果,但不能记录秘密内容。
  • 如果连服务器管理员或应用进程都不能读取私钥,就需要使用客户端加密或外部 HSM。但后端如果要在用户离线时自动调用支付服务,就必须具备解密能力,这与严格意义上的”只有用户本人能访问”存在天然冲突。
  • 如果支付提供商允许,优先考虑使用 HSM、云 KMS 或托管证书服务执行私钥运算,让私钥保持不可导出。使用普通 KV Vault 存储时,支付请求期间仍会把私钥加载到应用进程的内存中。

备注:内容仅供参考。