From d27dd5ca82d523739cbedd1119ae061b526e1a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?qwang1113=20=28=E7=8E=8B=E5=BC=BA=29?= Date: Sun, 31 May 2026 21:21:41 +0800 Subject: [PATCH] feat: add fetch-kms-secrets composite action GitHub OIDC -> Volcengine STS AssumeRoleWithOIDC -> KMS GetSecretValue -> hybrid (AES-256-GCM + RSA-OAEP-SHA256) decrypt -> JSON k/v -> $GITHUB_ENV. Zero runtime deps. scripts/envelope.js is the single source of truth for the OK1 envelope (shared by the runtime and the ops CLI); scripts/secret-tool.js provides keygen/encrypt/decrypt. Hardening: per-line masking for multi-line secret values; allowlist validation of volc-kms-region before it is interpolated into the KMS host. Unit tests (node --test) cover the envelope, runtime/CLI parity, V4 signing, keygen, and fail-fast paths. --- .gitignore | 3 + fetch-kms-secrets/README.md | 145 ++++++++ fetch-kms-secrets/action.yml | 41 +++ fetch-kms-secrets/package.json | 10 + fetch-kms-secrets/scripts/envelope.js | 129 +++++++ fetch-kms-secrets/scripts/envelope.test.js | 147 ++++++++ .../scripts/fetch-and-decrypt.js | 344 ++++++++++++++++++ fetch-kms-secrets/scripts/secret-tool.js | 150 ++++++++ fetch-kms-secrets/scripts/secret-tool.test.js | 97 +++++ 9 files changed, 1066 insertions(+) create mode 100644 fetch-kms-secrets/README.md create mode 100644 fetch-kms-secrets/action.yml create mode 100644 fetch-kms-secrets/package.json create mode 100644 fetch-kms-secrets/scripts/envelope.js create mode 100644 fetch-kms-secrets/scripts/envelope.test.js create mode 100644 fetch-kms-secrets/scripts/fetch-and-decrypt.js create mode 100644 fetch-kms-secrets/scripts/secret-tool.js create mode 100644 fetch-kms-secrets/scripts/secret-tool.test.js diff --git a/.gitignore b/.gitignore index 7dc4587..b5410ec 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,6 @@ dist # qa-bundle-upload !qa-bundle-upload/dist + +fetch-kms-secrets/keys/*.pem +fetch-kms-secrets/keys/*.json diff --git a/fetch-kms-secrets/README.md b/fetch-kms-secrets/README.md new file mode 100644 index 0000000..228bec5 --- /dev/null +++ b/fetch-kms-secrets/README.md @@ -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":""}' \ + | 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..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@ + 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` | 信封损坏,或是为另一把密钥加密的。 | diff --git a/fetch-kms-secrets/action.yml b/fetch-kms-secrets/action.yml new file mode 100644 index 0000000..d099483 --- /dev/null +++ b/fetch-kms-secrets/action.yml @@ -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" diff --git a/fetch-kms-secrets/package.json b/fetch-kms-secrets/package.json new file mode 100644 index 0000000..171a36f --- /dev/null +++ b/fetch-kms-secrets/package.json @@ -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" +} diff --git a/fetch-kms-secrets/scripts/envelope.js b/fetch-kms-secrets/scripts/envelope.js new file mode 100644 index 0000000..9a955f5 --- /dev/null +++ b/fetch-kms-secrets/scripts/envelope.js @@ -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 }; diff --git a/fetch-kms-secrets/scripts/envelope.test.js b/fetch-kms-secrets/scripts/envelope.test.js new file mode 100644 index 0000000..0227fd8 --- /dev/null +++ b/fetch-kms-secrets/scripts/envelope.test.js @@ -0,0 +1,147 @@ +'use strict'; + +// Tests for the OK1 envelope core, the action's decrypt wrapper, the masking +// helper, and the V4 signer. Run: node --test scripts/ (or: npm test) +// Zero deps — only node builtins, matching the action itself. + +const { test } = require('node:test'); +const assert = require('node:assert'); +const crypto = require('node:crypto'); +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); + +const { seal, open } = require('./envelope'); +const { decryptSecretBlob, signKmsRequest, maskLines } = require('./fetch-and-decrypt'); + +const FETCH = path.join(__dirname, 'fetch-and-decrypt.js'); + +// One 2048-bit keypair for the whole suite (small key on purpose — proves the +// hybrid scheme is not bound by the RSA size limit). +const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, +}); + +// ---- envelope round-trip ------------------------------------------------- + +test('round-trip: seal -> open returns the original object', () => { + const obj = { CODACY_PROJECT_TOKEN: 'tok_abc', OTHER: 'second' }; + assert.deepStrictEqual(open(privateKey, seal(publicKey, obj)), obj); +}); + +test('round-trip preserves newlines and unicode in values', () => { + const obj = { PEM: '-----BEGIN-----\nline2\n-----END-----', UTF: 'café—✓' }; + assert.deepStrictEqual(open(privateKey, seal(publicKey, obj)), obj); +}); + +test('the action runtime decrypts exactly what seal produces (no drift)', () => { + const obj = { A: 'one', B: 'two' }; + const token = seal(publicKey, obj); + assert.deepStrictEqual(decryptSecretBlob(privateKey, 'secret', token), obj); +}); + +test('big payload (~5 KB) round-trips on a 2048-bit key', () => { + const obj = {}; + for (let i = 0; i < 50; i += 1) obj[`KEY_${i}`] = 'x'.repeat(100); + assert.ok(JSON.stringify(obj).length > 2000); + assert.deepStrictEqual(open(privateKey, seal(publicKey, obj)), obj); +}); + +test('token carries the OK1 magic', () => { + const token = seal(publicKey, { K: 'v' }); + assert.strictEqual(Buffer.from(token, 'base64').subarray(0, 3).toString(), 'OK1'); +}); + +// ---- envelope rejection paths -------------------------------------------- + +test('tampered ciphertext is rejected by GCM auth', () => { + const buf = Buffer.from(seal(publicKey, { K: 'v' }), 'base64'); + buf[buf.length - 1] ^= 0xff; + assert.throws(() => open(privateKey, buf.toString('base64')), /AES-256-GCM decrypt\/auth failed/); +}); + +test('wrong private key is rejected at RSA unwrap', () => { + const other = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + }).privateKey; + assert.throws(() => open(other, seal(publicKey, { K: 'v' })), /RSA-OAEP-SHA256 unwrap .* failed/); +}); + +test('malformed tokens are rejected with clear messages', () => { + assert.throws(() => open(privateKey, ''), /token is empty/); + assert.throws(() => open(privateKey, Buffer.from('OK1').toString('base64')), /too short/); + const big = Buffer.concat([Buffer.from('NOPE'), Buffer.alloc(40)]).toString('base64'); + assert.throws(() => open(privateKey, big), /expected magic 'OK1'/); + // valid magic, framing passes, but wrappedKeyLen (0xFFFF) overruns the buffer + const trunc = Buffer.concat([Buffer.from('OK1'), Buffer.from([0xff, 0xff]), Buffer.alloc(35)]); + assert.throws(() => open(privateKey, trunc.toString('base64')), /truncated \(wrapped-key length mismatch\)/); +}); + +test('seal validates the plaintext shape', () => { + assert.throws(() => seal(publicKey, {}), /is empty/); + assert.throws(() => seal(publicKey, { K: 123 }), /must be a string/); + assert.throws(() => seal(publicKey, ['a']), /flat JSON object/); +}); + +// ---- maskLines (multi-line leak fix) ------------------------------------- + +test('maskLines masks a single-line value whole, any length', () => { + assert.deepStrictEqual(maskLines('abc'), ['abc']); + assert.deepStrictEqual(maskLines('ab'), ['ab']); + assert.deepStrictEqual(maskLines(''), []); +}); + +test('maskLines masks each substantial line of a multi-line value', () => { + const pem = '-----BEGIN KEY-----\nMIIBdummybase64line\n-----END KEY-----'; + assert.deepStrictEqual(maskLines(pem), [ + '-----BEGIN KEY-----', + 'MIIBdummybase64line', + '-----END KEY-----', + ]); + // braces / blank lines are skipped so masking can't corrupt unrelated logs + assert.deepStrictEqual(maskLines('{\n "k": "longsecretvalue"\n}\n'), [' "k": "longsecretvalue"']); +}); + +// ---- V4 signer structure ------------------------------------------------- + +test('signKmsRequest emits a well-formed Volcengine V4 Authorization', () => { + const signed = signKmsRequest({ + sts: { accessKeyId: 'AKtest', secretAccessKey: 'SKtest', sessionToken: 'STStest' }, + region: 'cn-beijing', + host: 'kms.cn-beijing.volcengineapi.com', + queryParams: { Action: 'GetSecretValue', Version: '2021-02-18' }, + bodyString: 'SecretName=foo', + }); + assert.match( + signed.headers.Authorization, + /^HMAC-SHA256 Credential=AKtest\/\d{8}\/cn-beijing\/kms\/request, SignedHeaders=content-type;host;x-content-sha256;x-date;x-security-token, Signature=[0-9a-f]{64}$/, + ); + assert.match(signed.canonicalQuery, /Action=GetSecretValue/); + assert.strictEqual(signed.headers['X-Security-Token'], 'STStest'); +}); + +// ---- runtime fail-fast (no network reached) ------------------------------ + +function runRuntime(env) { + return spawnSync(process.execPath, [FETCH], { env, encoding: 'utf8' }); +} + +test('runtime fails fast on missing required env', () => { + const r = runRuntime({ PATH: process.env.PATH }); + assert.notStrictEqual(r.status, 0); + assert.match(r.stderr, /missing required env: VOLC_ACCOUNT_ID/); +}); + +test('runtime rejects an invalid region before any network call', () => { + const r = runRuntime({ + PATH: process.env.PATH, + VOLC_ACCOUNT_ID: 'acc', + VOLC_ROLE_TRN: 'trn', + VOLC_KMS_REGION: 'evil.com#', + }); + assert.notStrictEqual(r.status, 0); + assert.match(r.stderr, /invalid volc-kms-region/); +}); diff --git a/fetch-kms-secrets/scripts/fetch-and-decrypt.js b/fetch-kms-secrets/scripts/fetch-and-decrypt.js new file mode 100644 index 0000000..d9a8b50 --- /dev/null +++ b/fetch-kms-secrets/scripts/fetch-and-decrypt.js @@ -0,0 +1,344 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +'use strict'; + +// Bootstrap CI credentials from Volcengine KMS without storing long-lived +// AK/SK. Flow: GitHub OIDC -> STS AssumeRoleWithOIDC -> temporary creds -> +// KMS GetSecretValue -> hybrid (AES-256-GCM + RSA-OAEP-SHA256) decrypt -> +// JSON k/v -> $GITHUB_ENV. +// +// Runtime env (from action.yml + GitHub): +// VOLC_ACCOUNT_ID, VOLC_ROLE_TRN, VOLC_KMS_REGION, +// KMS_SECRET_NAMES (newline-separated), KMS_DECRYPT_PRIVATE_KEY (PEM), +// ACTIONS_ID_TOKEN_REQUEST_URL / _TOKEN, GITHUB_ENV, GITHUB_OUTPUT. + +const crypto = require('crypto'); +const fs = require('fs'); +const https = require('https'); +const envelope = require('./envelope'); + +function fail(msg) { + process.stderr.write(`::error::fetch-kms-secrets: ${msg}\n`); + process.exit(1); +} + +function requireEnv(name) { + const v = process.env[name]; + if (v === undefined || v === '') fail(`missing required env: ${name}`); + return v; +} + +// GitHub ::add-mask:: is line-oriented: a newline ends the command, so emitting a +// multi-line value as one command would only mask its first line and PRINT the +// rest. Return the lines that must be registered: a single-line value is masked +// whole (any length); a multi-line value (PEM, JSON, ...) is masked per line, +// skipping blank/<4-char lines so masking "{" can't redact unrelated log text. +function maskLines(value) { + const str = String(value); + if (!str.includes('\n') && !str.includes('\r')) { + return str.length > 0 ? [str] : []; + } + return str.split(/\r?\n/).filter((line) => line.trim().length >= 4); +} + +function mask(value) { + for (const line of maskLines(value)) { + process.stdout.write(`::add-mask::${line}\n`); + } +} + +function appendKv(filePath, key, value) { + const delim = `EOF_${crypto.randomBytes(8).toString('hex')}`; + fs.appendFileSync(filePath, `${key}<<${delim}\n${value}\n${delim}\n`); +} + +function httpRequest({ hostname, path, method, headers, body }) { + return new Promise((resolve, reject) => { + const req = https.request( + { hostname, port: 443, path, method, headers }, + (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => + resolve({ + statusCode: res.statusCode, + body: Buffer.concat(chunks).toString('utf8'), + }), + ); + }, + ); + req.on('error', reject); + if (body) req.write(body); + req.end(); + }); +} + +// Parse JSON without surfacing the raw response body — this tool moves secret +// material, so remote bodies must never leak into logs. +function parseJson(body, apiLabel, statusCode) { + try { + return JSON.parse(body); + } catch (e) { + fail(`${apiLabel} returned a non-JSON response (status=${statusCode})`); + return undefined; // unreachable after process.exit + } +} + +async function mintGithubOidcToken(audience) { + const reqUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + const reqToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + if (!reqUrl || !reqToken) { + fail( + "GitHub OIDC env not present. The caller workflow must declare 'permissions.id-token: write'.", + ); + } + const u = new URL(reqUrl); + u.searchParams.set('audience', audience); + const res = await httpRequest({ + hostname: u.hostname, + path: u.pathname + u.search, + method: 'GET', + headers: { Authorization: `bearer ${reqToken}` }, + }); + if (res.statusCode !== 200) { + fail(`GitHub OIDC token request failed (status=${res.statusCode})`); + } + const json = parseJson(res.body, 'GitHub OIDC', res.statusCode); + if (!json.value) fail('GitHub OIDC response missing .value field'); + mask(json.value); + return json.value; +} + +// Volcengine Signature V4 — HMAC-SHA256 derivation chain identical in shape to +// AWS SigV4 (https://www.volcengine.com/docs/6369/67269). Specialized to the +// single call this action makes: POST kms / path "/" with STS session token. +function signKmsRequest({ sts, region, host, queryParams, bodyString }) { + // 20260101T000000Z form, no separators. + const amzDate = new Date() + .toISOString() + .replace(/[:-]/g, '') + .replace(/\.\d{3}/, ''); + const dateStamp = amzDate.slice(0, 8); + + const canonicalQuery = Object.keys(queryParams) + .sort() + .map( + (k) => + `${encodeURIComponent(k)}=${encodeURIComponent(queryParams[k])}`, + ) + .join('&'); + + const payloadHash = crypto + .createHash('sha256') + .update(bodyString || '') + .digest('hex'); + + const headersObj = { + 'content-type': 'application/x-www-form-urlencoded', + host, + 'x-content-sha256': payloadHash, + 'x-date': amzDate, + 'x-security-token': sts.sessionToken, + }; + const sortedNames = Object.keys(headersObj).sort(); + const canonicalHeaders = + sortedNames.map((n) => `${n}:${headersObj[n]}`).join('\n') + '\n'; + const signedHeaders = sortedNames.join(';'); + + const canonicalRequest = [ + 'POST', + '/', + canonicalQuery, + canonicalHeaders, + signedHeaders, + payloadHash, + ].join('\n'); + + const credentialScope = `${dateStamp}/${region}/kms/request`; + const stringToSign = [ + 'HMAC-SHA256', + amzDate, + credentialScope, + crypto.createHash('sha256').update(canonicalRequest).digest('hex'), + ].join('\n'); + + const hmac = (key, data) => + crypto.createHmac('sha256', key).update(data).digest(); + const kDate = hmac(sts.secretAccessKey, dateStamp); + const kRegion = hmac(kDate, region); + const kService = hmac(kRegion, 'kms'); + const kSigning = hmac(kService, 'request'); + const signature = crypto + .createHmac('sha256', kSigning) + .update(stringToSign) + .digest('hex'); + + return { + canonicalQuery, + headers: { + Authorization: + `HMAC-SHA256 Credential=${sts.accessKeyId}/${credentialScope}, ` + + `SignedHeaders=${signedHeaders}, Signature=${signature}`, + 'Content-Type': 'application/x-www-form-urlencoded', + Host: host, + 'X-Date': amzDate, + 'X-Content-Sha256': payloadHash, + 'X-Security-Token': sts.sessionToken, + }, + }; +} + +// STS AssumeRoleWithOIDC is unsigned — the caller has no credentials yet. +// https://www.volcengine.com/docs/6257/64974 +// Action + Version MUST be query params: the gateway reads them from the query +// string to identify this as the anonymous OIDC operation. Putting them only in +// the body makes the gateway demand a V4-signed Authorization header and reject +// with "InvalidCredential". Body carries the operation params only (no +// OIDCTokenType) — matches the proven OneKey backend (utils/secrets/volcengine-oidc). +async function assumeRoleWithOIDC({ roleTrn, oidcToken }) { + const body = new URLSearchParams({ + RoleTrn: roleTrn, + RoleSessionName: `gh-actions-${process.env.GITHUB_RUN_ID || 'unknown'}`, + OIDCToken: oidcToken, + DurationSeconds: '3600', + }).toString(); + + const res = await httpRequest({ + hostname: 'sts.volcengineapi.com', + path: '/?Action=AssumeRoleWithOIDC&Version=2018-01-01', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(body), + }, + body, + }); + + const json = parseJson(res.body, 'STS AssumeRoleWithOIDC', res.statusCode); + if (res.statusCode !== 200 || !json.Result || !json.Result.Credentials) { + const err = json && json.ResponseMetadata && json.ResponseMetadata.Error; + fail( + `STS AssumeRoleWithOIDC failed (${(err && err.Code) || 'unknown'}): ` + + `${(err && err.Message) || `status=${res.statusCode}`}. ` + + `Verify trust policy oidc:sub / oidc:aud / oidc:job_workflow_ref.`, + ); + } + + const c = json.Result.Credentials; + mask(c.AccessKeyId); + mask(c.SecretAccessKey); + mask(c.SessionToken); + return { + accessKeyId: c.AccessKeyId, + secretAccessKey: c.SecretAccessKey, + sessionToken: c.SessionToken, + }; +} + +// KMS GetSecretValue. https://www.volcengine.com/docs/6498 — verify Action / +// Version against the region's current API doc before rollout. +async function getKmsSecretValue({ sts, region, secretName }) { + const host = `kms.${region}.volcengineapi.com`; + const queryParams = { Action: 'GetSecretValue', Version: '2021-02-18' }; + const bodyString = new URLSearchParams({ SecretName: secretName }).toString(); + const signed = signKmsRequest({ sts, region, host, queryParams, bodyString }); + + const res = await httpRequest({ + hostname: host, + path: `/?${signed.canonicalQuery}`, + method: 'POST', + headers: { + ...signed.headers, + 'Content-Length': Buffer.byteLength(bodyString), + }, + body: bodyString, + }); + + const json = parseJson( + res.body, + `KMS GetSecretValue(${secretName})`, + res.statusCode, + ); + if ( + res.statusCode !== 200 || + !json.Result || + json.Result.SecretValue === undefined + ) { + const err = json && json.ResponseMetadata && json.ResponseMetadata.Error; + fail( + `KMS GetSecretValue failed for '${secretName}' (${(err && err.Code) || 'unknown'}): ` + + `${(err && err.Message) || `status=${res.statusCode}`}. ` + + `Check the secret exists in '${region}' and the role has kms:GetSecretValue on it.`, + ); + } + return json.Result.SecretValue; +} + +// Decrypt one KMS SecretValue (the "OK1" hybrid envelope) into its { ENV_KEY: +// value } object. The format lives in ./envelope, shared with scripts/secret-tool.js +// so the action and the ops tool can never drift. envelope.open never echoes +// ciphertext or key material, so it is safe to surface its message into logs. +function decryptSecretBlob(privateKeyPem, secretName, secretValueStr) { + try { + return envelope.open(privateKeyPem, secretValueStr); + } catch (e) { + fail( + `SecretValue of '${secretName}': ${e.message}. ` + + `Confirm it was produced by scripts/secret-tool.js and that ` + + `decrypt-private-key matches the encrypting public key.`, + ); + } + return undefined; // unreachable after process.exit +} + +async function main() { + const accountId = requireEnv('VOLC_ACCOUNT_ID'); + const roleTrn = requireEnv('VOLC_ROLE_TRN'); + const region = requireEnv('VOLC_KMS_REGION'); + // region is interpolated into the KMS hostname (kms..volcengineapi.com) + // and the signed Host header — constrain it so a malformed value can't redirect + // the call (with the live STS session token) to an attacker-controlled host. + if (!/^[a-z0-9-]+$/.test(region)) { + fail(`invalid volc-kms-region '${region}' (allowed: lowercase letters, digits, hyphens)`); + } + const privateKey = requireEnv('KMS_DECRYPT_PRIVATE_KEY'); + const secretNames = requireEnv('KMS_SECRET_NAMES') + .split(/\r?\n/) + .map((s) => s.trim()) + .filter(Boolean); + if (secretNames.length === 0) fail('secret-names is empty after trimming'); + + const githubEnv = requireEnv('GITHUB_ENV'); + const githubOutput = requireEnv('GITHUB_OUTPUT'); + + const oidcToken = await mintGithubOidcToken(accountId); + const sts = await assumeRoleWithOIDC({ roleTrn, oidcToken }); + + const fetchedKeys = []; + for (const secretName of secretNames) { + const data = decryptSecretBlob( + privateKey, + secretName, + await getKmsSecretValue({ sts, region, secretName }), + ); + for (const fieldKey of Object.keys(data)) { + const value = data[fieldKey]; + mask(value); + appendKv(githubEnv, fieldKey, value); + fetchedKeys.push(fieldKey); + } + } + + appendKv(githubOutput, 'fetched-keys', JSON.stringify(fetchedKeys)); + console.log(`[fetch-kms-secrets] fetched ${fetchedKeys.length} key(s)`); +} + +// Run only when invoked directly (the action). Requiring the file as a module +// (tests) exposes the pure functions without triggering the network flow. +if (require.main === module) { + main().catch((err) => { + fail(`unexpected error: ${err && err.message ? err.message : err}`); + }); +} + +module.exports = { decryptSecretBlob, signKmsRequest, maskLines }; diff --git a/fetch-kms-secrets/scripts/secret-tool.js b/fetch-kms-secrets/scripts/secret-tool.js new file mode 100644 index 0000000..b7133ac --- /dev/null +++ b/fetch-kms-secrets/scripts/secret-tool.js @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +'use strict'; + +// Ops CLI for the fetch-kms-secrets "OK1" envelope. The pack/unpack format is +// shared with the action runtime via ./envelope, so what this tool encrypts is +// exactly what the action decrypts. +// +// keygen [out-dir] [--bits N] generate a paired RSA keypair (default 4096) +// encrypt [json] seal { ENV_KEY: "value" } JSON -> OK1 token +// decrypt [token] open an OK1 token -> JSON (LOCAL USE ONLY) +// +// JSON / token are read from the file arg, or from stdin when the arg is omitted. +// +// Flow: +// 1. keygen -> kms-decrypt-private.pem (GitHub Secret) + kms-encrypt-public.pem +// 2. encrypt -> upload the token as the KMS secret value +// 3. decrypt -> offline round-trip check (prints PLAINTEXT — never in CI logs) + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const envelope = require('./envelope'); + +const PRIVATE_NAME = 'kms-decrypt-private.pem'; +const PUBLIC_NAME = 'kms-encrypt-public.pem'; +const MIN_BITS = 2048; + +function die(msg) { + process.stderr.write(`secret-tool: ${msg}\n`); + process.exit(1); +} + +function readInput(fileArg, what) { + // File arg, or stdin (fd 0) when omitted. + try { + return fileArg ? fs.readFileSync(fileArg, 'utf8') : fs.readFileSync(0, 'utf8'); + } catch (e) { + die(`cannot read ${what}: ${e.message}`); + } + return ''; // unreachable +} + +function parseKeygenArgs(args) { + let bits = 4096; + const positional = []; + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--bits') { + bits = parseInt(args[(i += 1)], 10); + } else if (args[i].startsWith('--bits=')) { + bits = parseInt(args[i].slice('--bits='.length), 10); + } else { + positional.push(args[i]); + } + } + if (!Number.isInteger(bits) || bits < MIN_BITS) { + die(`--bits must be an integer >= ${MIN_BITS}`); + } + return { bits, outDir: positional[0] || '.' }; +} + +function keygen(args) { + const { bits, outDir } = parseKeygenArgs(args); + try { + fs.mkdirSync(outDir, { recursive: true }); + } catch (e) { + die(`cannot create out-dir '${outDir}': ${e.message}`); + } + const privPath = path.join(outDir, PRIVATE_NAME); + const pubPath = path.join(outDir, PUBLIC_NAME); + // Refuse to clobber existing keys — overwriting a private key is unrecoverable. + for (const p of [privPath, pubPath]) { + if (fs.existsSync(p)) die(`refusing to overwrite existing ${p}`); + } + + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: bits, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + + fs.writeFileSync(privPath, privateKey, { mode: 0o600 }); + fs.chmodSync(privPath, 0o600); // enforce even if umask widened the create mode + fs.writeFileSync(pubPath, publicKey, { mode: 0o644 }); + + process.stderr.write( + `wrote ${privPath} (mode 600) and ${pubPath} (RSA-${bits})\n` + + ` • put ${PRIVATE_NAME} into GitHub Secret KMS_DECRYPT_PRIVATE_KEY — never commit it\n` + + ` • encrypt KMS values with ${PUBLIC_NAME}\n`, + ); +} + +function encrypt(args) { + const [pubKeyPath, jsonPath] = args; + if (!pubKeyPath) die('usage: secret-tool encrypt [plaintext.json]'); + const publicKeyPem = readInput(pubKeyPath, 'public key'); + const raw = readInput(jsonPath, 'plaintext JSON'); + + let obj; + try { + obj = JSON.parse(raw); + } catch (e) { + die('plaintext is not valid JSON'); + } + let token; + try { + token = envelope.seal(publicKeyPem, obj); + } catch (e) { + die(e.message); + } + process.stdout.write(`${token}\n`); +} + +function decrypt(args) { + const [privKeyPath, tokenPath] = args; + if (!privKeyPath) die('usage: secret-tool decrypt [token.txt]'); + const privateKeyPem = readInput(privKeyPath, 'private key'); + const token = readInput(tokenPath, 'token').trim(); + + let obj; + try { + obj = envelope.open(privateKeyPem, token); + } catch (e) { + die(e.message); + } + // This reveals plaintext secrets — intended for local round-trip checks only. + process.stderr.write('secret-tool: decrypt output is PLAINTEXT — do not run in CI logs\n'); + process.stdout.write(`${JSON.stringify(obj, null, 2)}\n`); +} + +function main() { + const [command, ...rest] = process.argv.slice(2); + switch (command) { + case 'keygen': + return keygen(rest); + case 'encrypt': + return encrypt(rest); + case 'decrypt': + return decrypt(rest); + default: + return die( + 'usage: secret-tool ...\n' + + ' keygen [out-dir] [--bits N]\n' + + ' encrypt [plaintext.json]\n' + + ' decrypt [token.txt]', + ); + } +} + +main(); diff --git a/fetch-kms-secrets/scripts/secret-tool.test.js b/fetch-kms-secrets/scripts/secret-tool.test.js new file mode 100644 index 0000000..2fd2727 --- /dev/null +++ b/fetch-kms-secrets/scripts/secret-tool.test.js @@ -0,0 +1,97 @@ +'use strict'; + +// Tests for the ops CLI (keygen / encrypt / decrypt) by spawning it as a real +// process. Run: node --test scripts/ (or: npm test) + +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const TOOL = path.join(__dirname, 'secret-tool.js'); + +function tool(args, input) { + return spawnSync(process.execPath, [TOOL, ...args], { input, encoding: 'utf8' }); +} +function tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'st-')); +} +const PRIV = 'kms-decrypt-private.pem'; +const PUB = 'kms-encrypt-public.pem'; + +test('keygen writes a 0600 private key and a public key', () => { + const dir = tmpDir(); + const r = tool(['keygen', dir, '--bits', '2048']); + assert.strictEqual(r.status, 0, r.stderr); + assert.strictEqual(fs.statSync(path.join(dir, PRIV)).mode & 0o777, 0o600); + assert.match(fs.readFileSync(path.join(dir, PRIV), 'utf8'), /PRIVATE KEY/); + assert.match(fs.readFileSync(path.join(dir, PUB), 'utf8'), /PUBLIC KEY/); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('keygen creates a missing nested out-dir instead of crashing', () => { + const base = tmpDir(); + const nested = path.join(base, 'a', 'b', 'keys'); + const r = tool(['keygen', nested]); // default 4096 + assert.strictEqual(r.status, 0, r.stderr); + assert.ok(fs.existsSync(path.join(nested, PRIV))); + fs.rmSync(base, { recursive: true, force: true }); +}); + +test('keygen refuses to overwrite existing keys', () => { + const dir = tmpDir(); + assert.strictEqual(tool(['keygen', dir, '--bits', '2048']).status, 0); + const r = tool(['keygen', dir, '--bits', '2048']); + assert.notStrictEqual(r.status, 0); + assert.match(r.stderr, /refusing to overwrite/); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('keygen rejects keys weaker than 2048 bits', () => { + const dir = tmpDir(); + const r = tool(['keygen', dir, '--bits', '1024']); + assert.notStrictEqual(r.status, 0); + assert.match(r.stderr, />= 2048/); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('encrypt | decrypt round-trips via the CLI (stdin)', () => { + const dir = tmpDir(); + tool(['keygen', dir, '--bits', '2048']); + const payload = { CODACY_PROJECT_TOKEN: 'hello-123', OTHER: 'x'.repeat(80) }; + const enc = tool(['encrypt', path.join(dir, PUB)], JSON.stringify(payload)); + assert.strictEqual(enc.status, 0, enc.stderr); + const dec = tool(['decrypt', path.join(dir, PRIV)], enc.stdout.trim()); + assert.strictEqual(dec.status, 0, dec.stderr); + assert.deepStrictEqual(JSON.parse(dec.stdout), payload); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('decrypt rejects a tampered token', () => { + const dir = tmpDir(); + tool(['keygen', dir, '--bits', '2048']); + const token = tool(['encrypt', path.join(dir, PUB)], JSON.stringify({ K: 'v' })).stdout.trim(); + const buf = Buffer.from(token, 'base64'); + buf[buf.length - 1] ^= 0xff; + const dec = tool(['decrypt', path.join(dir, PRIV)], buf.toString('base64')); + assert.notStrictEqual(dec.status, 0); + assert.match(dec.stderr, /AES-256-GCM decrypt\/auth failed/); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('encrypt rejects non-JSON plaintext', () => { + const dir = tmpDir(); + tool(['keygen', dir, '--bits', '2048']); + const r = tool(['encrypt', path.join(dir, PUB)], 'not json'); + assert.notStrictEqual(r.status, 0); + assert.match(r.stderr, /not valid JSON/); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('unknown command prints usage and exits non-zero', () => { + const r = tool(['bogus']); + assert.notStrictEqual(r.status, 0); + assert.match(r.stderr, /usage: secret-tool/); +});