Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,6 @@ dist

# qa-bundle-upload
!qa-bundle-upload/dist

fetch-kms-secrets/keys/*.pem
fetch-kms-secrets/keys/*.json
145 changes: 145 additions & 0 deletions fetch-kms-secrets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# fetch-kms-secrets

复合 action(composite action):通过 GitHub OIDC → STS 从火山引擎 KMS 拉取一份加密
secret,用混合加密 AES-256-GCM + RSA-OAEP-SHA256 解密,并把解出的 JSON 键值对写入
`$GITHUB_ENV`,供后续 step 使用。

下游仓库不存储任何长期有效的火山引擎 AK/SK,也不存储明文 secret —— 只持有 RSA 私钥
(建议放在组织级 GitHub Secret)。

```
GitHub OIDC → STS AssumeRoleWithOIDC → 临时 AK/SK → KMS GetSecretValue
→ 混合加密(AES-256-GCM + RSA-OAEP-SHA256)解密 → JSON 键值对 → $GITHUB_ENV
```

混合加密中的非对称包裹(RSA-OAEP)这一层是刻意设计的:它把"能读 KMS"(IAM/STS)和
"能解密并使用"(GitHub Secret)拆成两个相互独立的授权边界(控制面)。

## KMS secret 载荷契约

每个 KMS `SecretValue` 都是单个不透明的 base64 字符串。解密后得到一个扁平的 JSON
对象,其各个 key 会成为环境变量:

```json
{
"CODACY_PROJECT_TOKEN": "<明文值>",
"ANOTHER_KEY": "<明文值>"
}
```

该字符串由混合加密产生 —— AES-256-GCM 封装 JSON 明文,RSA-OAEP-SHA256 包裹一次性的
AES 密钥。由于 RSA 永远只包裹这 32 字节的密钥,JSON 载荷在实际使用中没有体积上限。
打包布局(base64 编码之前):`MAGIC('OK1') | wrappedKeyLen(2,BE) | wrappedKey |
iv(12) | tag(16) | ciphertext`。

加密时使用密钥对的公钥;对应私钥通过 `decrypt-private-key` 传入。打包格式由
[`scripts/envelope.js`](./scripts/envelope.js) 单点定义,**action 运行时与 ops 工具
共用同一实现**,不会漂移。

## ops 工具:`scripts/secret-tool.js`

一个零依赖的命令行工具,涵盖密钥生成、加密、解密三步:

```bash
# 1) 生成配对公私钥(默认 RSA-4096;私钥文件权限 600)
node scripts/secret-tool.js keygen ./keys
# → ./keys/kms-decrypt-private.pem (放入 GitHub Secret KMS_DECRYPT_PRIVATE_KEY)
# → ./keys/kms-encrypt-public.pem (用于加密)

# 2) 加密:把 { ENV_KEY: "value" } 的 JSON 封成 OK1 token,作为 KMS secret 的值上传
echo '{"CODACY_PROJECT_TOKEN":"<value>"}' \
| node scripts/secret-tool.js encrypt ./keys/kms-encrypt-public.pem

# 3) 解密:本地离线核对 token(打印明文,切勿在 CI 日志里运行)
node scripts/secret-tool.js decrypt ./keys/kms-decrypt-private.pem token.txt
```

JSON / token 可由文件参数传入,省略时则读 stdin。密钥管理与 KMS 载荷约定详见
[Secret Manager 接入规范](https://onekeyhq.atlassian.net/wiki/spaces/ONEKEY/pages/1797652515)。

## 输入(Inputs)

| 名称 | 必填 | 默认值 | 说明 |
| --------------------- | ---- | ---------------- | ------------------------------------------------------------- |
| `volc-account-id` | 是 | — | 火山引擎账号 ID;用作 OIDC token 的 audience。 |
| `volc-role-trn` | 是 | — | 通过 OIDC 扮演的 IAM 角色 TRN。 |
| `volc-kms-region` | 是 | — | KMS 区域,如 `cn-beijing`(`kms.<region>.volcengineapi.com`);无默认值。 |
| `secret-names` | 是 | — | 待拉取的 KMS secret 名称列表,每行一个。 |
| `decrypt-private-key` | 是 | — | PEM 编码的 RSA 私钥,用于解包 AES 密钥(RSA-OAEP-SHA256)。 |

## 输出(Outputs)

| 名称 | 说明 |
| -------------- | ------------------------------------------------------------------- |
| `fetched-keys` | 写入 `$GITHUB_ENV` 的环境变量名 JSON 数组;**不暴露**对应的值。 |

## 用法

调用方 workflow **必须**声明 `permissions.id-token: write` —— 这是 GitHub 平台限制,
无法在复合 action 内部设置。

```yaml
jobs:
example:
runs-on: ubuntu-24.04
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4

- name: Fetch KMS secrets
uses: OneKeyHQ/actions/fetch-kms-secrets@<SHA>
with:
volc-account-id: ${{ vars.VOLC_ACCOUNT_ID }}
volc-role-trn: ${{ vars.VOLC_CODACY_ROLE_TRN }}
volc-kms-region: ${{ vars.VOLC_KMS_REGION }}
secret-names: |
production-codacy-ci-secrets-json
decrypt-private-key: ${{ secrets.KMS_DECRYPT_PRIVATE_KEY }}

- name: Use a decrypted value
run: echo "token starts with $(echo "$CODACY_PROJECT_TOKEN" | cut -c1-4)..."
```

最佳实践:把 `KMS_DECRYPT_PRIVATE_KEY` 存为**组织级** GitHub Secret,这样下游仓库无需
逐仓库配置即可继承;把账号 ID 和角色 TRN 存为组织级 GitHub **Variables**。

ops 的一次性配置(IAM 角色 + 锁定到 `oidc:iss/aud/sub` 的信任策略,关键角色另加
`oidc:job_workflow_ref`;`kms:GetSecretValue` 权限收敛到具体 secret;上传 KMS 信封;
创建组织级 secret / variables)详见
[Secret Manager 接入规范](https://onekeyhq.atlassian.net/wiki/spaces/ONEKEY/pages/1797652515)。

## 安全不变量

- 每个解密后的值在写入 `$GITHUB_ENV` 之前,都会先经过 `::add-mask::` 登记;多行值
会逐行登记(`::add-mask::` 是按行生效的,否则第一行之后会泄漏)。
- STS 临时凭据在签发后立即 mask。
- 远端响应体与 RSA 密钥材料绝不回显到错误信息中。
- 私钥仅通过环境变量传入 node 脚本(`scripts/fetch-and-decrypt.js`)—— 绝不落盘。
- `volc-kms-region` 会按 `^[a-z0-9-]+$` 白名单校验后才拼进 KMS 主机名,防止被构造的
区域值把请求(连同 STS 临时凭据)重定向到攻击者主机;请始终从可信 Variable 注入。

## 测试

零依赖,用 node 内置 test runner:

```bash
npm test # 等价于 node --test scripts/*.test.js
```

覆盖信封往返(含 action 运行时与 CLI 共用核心的一致性)、GCM 防篡改、坏信封拒绝、
keygen 权限/拒绝覆盖、V4 签名结构、运行时 fail-fast 等。

## 失败模式

| 报错现象 | 可能原因 |
| ---------------------------------------------------- | ----------------------------------------------------------------- |
| `GitHub OIDC env not present` | 调用方 workflow 缺少 `permissions.id-token: write`。 |
| `invalid volc-kms-region` | `volc-kms-region` 含非法字符(只允许小写字母/数字/连字符)。 |
| `STS AssumeRoleWithOIDC failed` | 信任策略 `sub` / `aud` / `job_workflow_ref` 不匹配,或未授权。 |
| `KMS GetSecretValue failed (SecretsManagerServiceNotOpen)` | 火山引擎账号尚未开通「密钥管理服务-凭据管理 / Secrets Manager」,需在控制台开通。 |
| `KMS GetSecretValue failed` | secret 在该区域不存在,或 IAM 缺少 `kms:GetSecretValue` 权限。 |
| `unrecognized envelope (expected magic 'OK1')` | SecretValue 不是由 `scripts/secret-tool.js encrypt` 生成的。 |
| `RSA-OAEP-SHA256 unwrap … failed` | 私钥与加密所用公钥不匹配。 |
| `AES-256-GCM decrypt/auth failed` | 信封损坏,或是为另一把密钥加密的。 |
41 changes: 41 additions & 0 deletions fetch-kms-secrets/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: 'Fetch KMS Secrets'
description: 'Exchange GitHub OIDC for Volcengine STS, fetch a KMS secret, hybrid-decrypt (AES-256-GCM + RSA-OAEP-SHA256) to JSON k/v, expose to subsequent steps.'
author: 'OneKey'
branding:
icon: 'lock'
color: 'purple'

inputs:
volc-account-id:
description: 'Volcengine account ID. Used as the OIDC token audience.'
required: true
volc-role-trn:
description: 'TRN of the IAM role to assume via OIDC.'
required: true
volc-kms-region:
description: 'Volcengine KMS region, e.g. cn-beijing. No default — must be set explicitly.'
required: true
secret-names:
description: 'KMS secret names to fetch. One per line.'
required: true
decrypt-private-key:
description: 'PEM-encoded RSA private key that unwraps the AES key (RSA-OAEP-SHA256).'
required: true

outputs:
fetched-keys:
description: 'JSON array of env-var key names written to $GITHUB_ENV. Values are never exposed.'
value: ${{ steps.fetch.outputs.fetched-keys }}

runs:
using: 'composite'
steps:
- id: fetch
shell: bash
env:
VOLC_ACCOUNT_ID: ${{ inputs.volc-account-id }}
VOLC_ROLE_TRN: ${{ inputs.volc-role-trn }}
VOLC_KMS_REGION: ${{ inputs.volc-kms-region }}
KMS_SECRET_NAMES: ${{ inputs.secret-names }}
KMS_DECRYPT_PRIVATE_KEY: ${{ inputs.decrypt-private-key }}
run: node "${GITHUB_ACTION_PATH}/scripts/fetch-and-decrypt.js"
10 changes: 10 additions & 0 deletions fetch-kms-secrets/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "fetch-kms-secrets",
"version": "1.0.0",
"private": true,
"description": "Composite action: GitHub OIDC -> Volcengine STS -> KMS GetSecretValue -> hybrid decrypt -> $GITHUB_ENV",
"scripts": {
"test": "node --test scripts/envelope.test.js scripts/secret-tool.test.js"
},
"license": "Apache-2.0"
}
129 changes: 129 additions & 0 deletions fetch-kms-secrets/scripts/envelope.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
'use strict';

// Single source of truth for the fetch-kms-secrets envelope format ("OK1").
// Hybrid encryption — AES-256-GCM seals the JSON payload, RSA-OAEP-SHA256 wraps
// the one-time AES key. Shared by the action runtime (fetch-and-decrypt.js) AND
// the ops CLI (secret-tool.js) so the pack/unpack logic can never drift.
//
// RSA only ever wraps the 32-byte AES key, so the JSON payload has no practical
// size limit (a plain RSA-over-the-whole-blob scheme caps at ~190 B on a
// 2048-bit key). Packed layout (before base64):
// MAGIC(3) | wrappedKeyLen(2, BE) | wrappedKey(N) | iv(12) | tag(16) | ciphertext
// MAGIC 'OK1' is a format/version marker that also fast-rejects foreign payloads.

const crypto = require('crypto');

const MAGIC = Buffer.from('OK1', 'ascii');
const IV_BYTES = 12;
const TAG_BYTES = 16;
const KEY_BYTES = 32; // AES-256
const OAEP = {
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256',
};

// Both seal and open require a flat { ENV_KEY: "value" } object with string
// values — the env-injection contract. Throws on any violation.
function assertFlatStringObject(obj, label) {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
throw new Error(`${label} must be a flat JSON object of { ENV_KEY: "value" }`);
}
const keys = Object.keys(obj);
if (keys.length === 0) throw new Error(`${label} is empty`);
for (const k of keys) {
if (typeof obj[k] !== 'string') {
throw new Error(`${label}: value of '${k}' must be a string`);
}
}
return keys;
}

// seal: { K: "v", ... } + RSA public key PEM -> base64 "OK1" token.
function seal(publicKeyPem, obj) {
assertFlatStringObject(obj, 'plaintext');

const aesKey = crypto.randomBytes(KEY_BYTES);
const iv = crypto.randomBytes(IV_BYTES);
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
const ciphertext = Buffer.concat([
cipher.update(JSON.stringify(obj), 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
const wrappedKey = crypto.publicEncrypt({ key: publicKeyPem, ...OAEP }, aesKey);

const wrappedKeyLen = Buffer.alloc(2);
wrappedKeyLen.writeUInt16BE(wrappedKey.length, 0);
return Buffer.concat([
MAGIC,
wrappedKeyLen,
wrappedKey,
iv,
tag,
ciphertext,
]).toString('base64');
}

// open: base64 "OK1" token + RSA private key PEM -> { K: "v", ... }.
// Throws Error with a clear, material-free message on any failure (never echoes
// ciphertext or key bytes — callers may surface the message into logs).
function open(privateKeyPem, token) {
if (typeof token !== 'string' || token.length === 0) {
throw new Error('token is empty');
}
// Buffer.from(.,'base64') is lenient; the MAGIC + length checks below are what
// actually reject malformed input, so no try/catch is needed here.
const packed = Buffer.from(token, 'base64');
const framingBytes = MAGIC.length + 2 + IV_BYTES + TAG_BYTES;
if (packed.length < framingBytes) {
throw new Error('token is too short to be a valid envelope');
}
if (!packed.subarray(0, MAGIC.length).equals(MAGIC)) {
throw new Error("unrecognized envelope (expected magic 'OK1')");
}

let off = MAGIC.length;
const wrappedKeyLen = packed.readUInt16BE(off);
off += 2;
if (packed.length < off + wrappedKeyLen + IV_BYTES + TAG_BYTES) {
throw new Error('token is truncated (wrapped-key length mismatch)');
}
const wrappedKey = packed.subarray(off, off + wrappedKeyLen);
off += wrappedKeyLen;
const iv = packed.subarray(off, off + IV_BYTES);
off += IV_BYTES;
const tag = packed.subarray(off, off + TAG_BYTES);
off += TAG_BYTES;
const ciphertext = packed.subarray(off);

let aesKey;
try {
aesKey = crypto.privateDecrypt({ key: privateKeyPem, ...OAEP }, wrappedKey);
} catch (e) {
throw new Error(`RSA-OAEP-SHA256 unwrap of the AES key failed (${e.message})`);
}
if (aesKey.length !== KEY_BYTES) {
throw new Error('unwrapped AES key is not 256-bit');
}

let plaintext;
try {
const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, iv);
decipher.setAuthTag(tag);
plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
} catch (e) {
// GCM auth failure means tampering or wrong key.
throw new Error(`AES-256-GCM decrypt/auth failed (${e.message})`);
}

let obj;
try {
obj = JSON.parse(plaintext.toString('utf8'));
} catch (e) {
throw new Error('decrypted content is not valid JSON');
}
assertFlatStringObject(obj, 'decrypted payload');
return obj;
}

module.exports = { seal, open };
Loading