使用 Vault 安全存储用户 PEM / KEY (PFX) 并用于 fetch
结论
HashiCorp Vault 的 KV v2 可以存储 PEM、私钥或 PFX,但仅创建一个 Vault role,无法保证”只有上传者能够访问”。要实现用户隔离,需要同时控制两层权限:
- 应用层根据登录会话验证证书归属,不能让客户端随意指定
userId。 - 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 存储时,支付请求期间仍会把私钥加载到应用进程的内存中。
备注:内容仅供参考。
