diff --git a/.github/workflows/test-key-value-store.yml b/.github/workflows/test-key-value-store.yml new file mode 100644 index 0000000..7f2058d --- /dev/null +++ b/.github/workflows/test-key-value-store.yml @@ -0,0 +1,35 @@ +name: Test key-value store actions + +on: + pull_request: + paths: + - key-value-store/** + - .github/workflows/test-key-value-store.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Node 24 (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: '24' + package-manager-cache: false + - name: Test client and action entrypoints + run: node --test key-value-store/tests/client.test.cjs key-value-store/tests/entrypoints.test.cjs diff --git a/README.md b/README.md index 5fc7d8d..74520e1 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Shared GitHub Actions for Astral projects. +- [Key-value store](key-value-store/): share workflow state within a GitHub Actions + OIDC namespace. - [Rust workspace cache](rust-workspace-cache/): cache workspace crates and prune superseded artifacts before saving. - [Create release pull request](create-release-pr/): commit prepared release files diff --git a/key-value-store/README.md b/key-value-store/README.md new file mode 100644 index 0000000..176d1de --- /dev/null +++ b/key-value-store/README.md @@ -0,0 +1,168 @@ +# Key-value store + +Store workflow state with `key-value-store/put`, retrieve it with +`key-value-store/get`, and remove it with `key-value-store/delete`. Each action +requests a fresh GitHub Actions OIDC token and exchanges it for a short-lived +session with the configured service. No static secret or separate login step is +required. + +The service derives the namespace from the signed OIDC context. Jobs and workflow +runs for the same pull request and environment can share keys. Different pull +requests receive separate namespaces, as do different repositories, events, +refs, and environments. A branch push and a pull request do not share keys, even +for the same commit. There is no input to select another namespace. + +## Usage + +Configure `KEY_VALUE_SERVICE_URL` as a repository variable containing the HTTPS +origin of a compatible service, such as `https://kv.example.com`. Grant +`id-token: write` to each job that uses these actions. Pin all three actions to +the same full commit SHA. + +```yaml +name: Workflow state +on: pull_request + +permissions: + id-token: write + +jobs: + state: + runs-on: ubuntu-latest + steps: + - uses: astral-sh/github-actions/key-value-store/put@ + with: + service-url: ${{ vars.KEY_VALUE_SERVICE_URL }} + key: build-sha + value: ${{ github.sha }} + + - uses: astral-sh/github-actions/key-value-store/get@ + id: stored + with: + service-url: ${{ vars.KEY_VALUE_SERVICE_URL }} + key: build-sha + + - name: Compare the stored commit + if: steps.stored.outputs.found == 'true' + env: + STORED_SHA: ${{ steps.stored.outputs.value }} + run: test "$STORED_SHA" = "$GITHUB_SHA" + + - uses: astral-sh/github-actions/key-value-store/delete@ + with: + service-url: ${{ vars.KEY_VALUE_SERVICE_URL }} + key: build-sha +``` + +The actions run on Linux, macOS, and Windows with a runner that supports Node 24 +actions. If the runner uses `HTTP_PROXY` or `HTTPS_PROXY`, set +`NODE_USE_ENV_PROXY: '1'` in the job environment. + +## Inputs and outputs + +All three actions accept these inputs: + +| Input | Default | Description | +| --- | --- | --- | +| `service-url` | Required | HTTPS origin, without credentials, a path, a query, or a fragment. | +| `key` | Required | Case-sensitive key: 1–256 ASCII letters, digits, `.`, `_`, or `-`; cannot be `.` or `..`. | +| `audience` | The `service-url` origin | Audience requested from GitHub. Must match the service configuration; at most 2,048 UTF-8 bytes without whitespace. | + +`put` also accepts: + +| Input | Default | Description | +| --- | --- | --- | +| `value` | Empty | UTF-8 text to store, including its whitespace and newlines. | +| `path` | Empty | Regular file whose raw bytes are stored instead of `value`. Cannot be combined with a nonempty `value`. | + +Values may contain up to 65,536 bytes. Omitting both `value` and `path` stores an +empty value. Writing an existing key replaces its value and resets its expiry. +Concurrent writes are last-write-wins; their order is not guaranteed. The service +sets the retention period. These actions do not choose a TTL. + +`get` also accepts: + +| Input | Default | Description | +| --- | --- | --- | +| `path` | Empty | New file for the raw bytes. The file must not already exist; parent directories are created as needed. | +| `mask-value` | `true` | Mask a retrieved text value in runner logs. Set `false` only for nonsensitive values. Does not apply to file downloads. | + +`get` produces these outputs: + +| Output | Description | +| --- | --- | +| `found` | `true` for an unexpired value, including an empty value; `false` for a missing or expired key. | +| `value` | UTF-8 text when `path` is omitted; otherwise empty. | +| `path` | Absolute path of the file created; empty when no file was written. | +| `expires-at` | Value expiry in Unix seconds; empty when the key was not found. | + +A missing key succeeds with `found: 'false'`, empty remaining outputs, and no +file. Text mode rejects invalid UTF-8 and NUL bytes; use `path` for binary data. +GitHub may suppress masked values passed through job outputs, so use +`mask-value: 'false'` only when a nonsensitive value must cross that boundary. +Masking does not encrypt a value or prevent a later step from accessing it. + +`put` and `delete` have no outputs. Deleting a missing key succeeds. Other service +errors fail the action. Requests have a 15-second timeout and are not retried; +after a network failure, a write may already have reached the service. + +## Files + +Use `path` to transfer raw bytes without putting the value in an action output: + +```yaml +- uses: astral-sh/github-actions/key-value-store/put@ + with: + service-url: ${{ vars.KEY_VALUE_SERVICE_URL }} + key: build-result + path: ${{ runner.temp }}/build-result.bin + +- uses: astral-sh/github-actions/key-value-store/get@ + id: restored + with: + service-url: ${{ vars.KEY_VALUE_SERVICE_URL }} + key: build-result + path: ${{ runner.temp }}/restored-result.bin +``` + +Paths may be absolute or relative to the job's working directory. The input file +must already exist and cannot itself be a symlink. The output file is created +exclusively, with owner-only permissions where supported. Paths and their parent +directories must be trusted: parent symlinks are followed, and these actions do +not sandbox filesystem access. Prefer a fresh path under `runner.temp` for +downloads, outside directories controlled by pull request contents. + +## Service API + +| Request | Authentication | Successful response | +| --- | --- | --- | +| `POST /exchange`, empty body | GitHub OIDC bearer token | JSON with `access_token`, `token_type: "Bearer"`, and `expires_at` in Unix seconds. | +| `PUT /kv/`, raw bytes | Session bearer token | `204`. | +| `GET /kv/` | Session bearer token | `200` with raw bytes and `X-Expires-At` in Unix seconds, or `404`. | +| `DELETE /kv/` | Session bearer token | `204`, including for an absent key. | + +Session tokens use the format `aghs_kv_` followed by 64 lowercase hexadecimal +characters. They remain internal to the action and are never action outputs. +Each invocation requests a new OIDC token because the service allows each OIDC +token to be exchanged once. No service deployment is included with these actions. + +## Trust boundaries + +The configured service receives the OIDC token and stored values. Keep +`service-url`, `audience`, and file paths in trusted workflow configuration; +do not derive them from pull request content. HTTPS certificate verification is +enabled by default, redirects are rejected, and response bodies are bounded. +OIDC and session tokens are masked before further requests, and error responses +are not printed. + +Namespace isolation is enforced by the service. All actors, jobs, and workflows +within the same accepted OIDC context share read, write, and delete access. This +is not a secret store for mutually untrusted jobs in one pull request. The +service supports `pull_request`, `push`, `workflow_dispatch`, and `schedule` +contexts; it rejects `pull_request_target` and `workflow_run`. Adding an +environment changes the namespace. Sessions are bearer credentials: anyone +holding one can access its namespace until the session expires. + +Treat retrieved values as untrusted data. Pass text through `env` and quote shell +variables, as above, or consume downloaded files as data. Do not interpolate a +retrieved `value` directly into a `run` script or execute downloaded contents. diff --git a/key-value-store/client.cjs b/key-value-store/client.cjs new file mode 100644 index 0000000..c806cbd --- /dev/null +++ b/key-value-store/client.cjs @@ -0,0 +1,276 @@ +"use strict"; + +const { randomUUID } = require("node:crypto"); +const fs = require("node:fs/promises"); +const { constants } = require("node:fs"); +const path = require("node:path"); + +const MAX_VALUE_BYTES = 64 * 1024; +const MAX_JSON_BYTES = 32 * 1024; +const REQUEST_TIMEOUT_MS = 15_000; + +class ActionError extends Error {} + +function command(name, value, write) { + const escaped = value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A"); + write(`::${name}::${escaped}\n`); +} + +function httpsUrl(value, name, allowQuery = false) { + let url; + try { + url = new URL(value); + } catch { + throw new ActionError(`${name} must be an HTTPS URL`); + } + if (url.protocol !== "https:" || url.username || url.password || url.hash || (!allowQuery && url.search)) { + throw new ActionError(`${name} must use HTTPS without credentials, a fragment, or unexpected query parameters`); + } + return url; +} + +function optionsFromEnvironment(operation, env) { + const url = httpsUrl(env["INPUT_SERVICE-URL"], "service-url"); + if (url.pathname !== "/") { + throw new ActionError("service-url must be an HTTPS origin without a path"); + } + const audience = env.INPUT_AUDIENCE || url.origin; + if (Buffer.byteLength(audience, "utf8") > 2048 || /\s/u.test(audience)) { + throw new ActionError("audience must be at most 2048 UTF-8 bytes without whitespace"); + } + const key = env.INPUT_KEY || ""; + if (key.length < 1 || key.length > 256 || /[^A-Za-z0-9._-]/u.test(key) || key === "." || key === "..") { + throw new ActionError("key must contain 1-256 ASCII letters, digits, dots, underscores, or hyphens, and cannot be . or .."); + } + const file = env.INPUT_PATH || ""; + const value = env.INPUT_VALUE || ""; + if (operation === "put" && file && value) { + throw new ActionError("path and a nonempty value are mutually exclusive"); + } + const maskValue = env["INPUT_MASK-VALUE"] || "true"; + if (operation === "get" && !["true", "false"].includes(maskValue)) { + throw new ActionError("mask-value must be true or false"); + } + if (operation === "get" && !env.GITHUB_OUTPUT) { + throw new ActionError("GITHUB_OUTPUT is required"); + } + return { url, audience, key, file, value, maskValue: maskValue === "true" }; +} + +async function readValueFile(file) { + let handle; + try { + if (!(await fs.lstat(file)).isFile()) { + throw new ActionError("path must name a regular file, not a symbolic link"); + } + handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0)); + const stat = await handle.stat(); + if (!stat.isFile()) throw new ActionError("path must name a regular file"); + if (stat.size > MAX_VALUE_BYTES) throw new ActionError("value exceeds 65536 bytes"); + const buffer = Buffer.alloc(MAX_VALUE_BYTES + 1); + let length = 0; + while (length < buffer.length) { + const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null); + if (bytesRead === 0) break; + length += bytesRead; + } + if (length > MAX_VALUE_BYTES) throw new ActionError("value exceeds 65536 bytes"); + return buffer.subarray(0, length); + } catch (error) { + if (error instanceof ActionError) throw error; + throw new ActionError("could not read the value file"); + } finally { + await handle?.close(); + } +} + +async function requireNewFile(file) { + try { + await fs.lstat(file); + } catch (error) { + if (error.code === "ENOENT") return; + throw new ActionError("could not inspect the output path"); + } + throw new ActionError("the output path must not already exist"); +} + +async function writeValueFile(file, bytes) { + let handle; + try { + await fs.mkdir(path.dirname(file), { recursive: true }); + handle = await fs.open(file, "wx", 0o600); + await handle.writeFile(bytes); + } catch { + if (handle) { + await handle.close(); + handle = undefined; + await fs.unlink(file).catch(() => {}); + } + throw new ActionError("could not create the output file; it must not already exist"); + } finally { + await handle?.close(); + } +} + +async function request(fetchImpl, url, options, description) { + try { + return await fetchImpl(url, { + ...options, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch { + throw new ActionError(`${description} request failed`); + } +} + +async function readBody(response, limit, description) { + const chunks = []; + let length = 0; + try { + const declared = response.headers.get("content-length"); + if (declared !== null && Number(declared) > limit) { + await response.body?.cancel(); + throw new ActionError(`${description} response exceeds its size limit`); + } + if (response.body) { + for await (const chunk of response.body) { + length += chunk.length; + if (length > limit) { + throw new ActionError(`${description} response exceeds its size limit`); + } + chunks.push(Buffer.from(chunk)); + } + } + } catch (error) { + if (error instanceof ActionError) throw error; + throw new ActionError(`${description} response could not be read`); + } + return Buffer.concat(chunks, length); +} + +async function expectStatus(response, status, description) { + if (response.status !== status) { + await response.body?.cancel().catch(() => {}); + throw new ActionError(`${description} failed (HTTP ${response.status})`); + } +} + +async function requestJson(fetchImpl, url, options, description) { + const response = await request(fetchImpl, url, options, description); + await expectStatus(response, 200, description); + const body = await readBody(response, MAX_JSON_BYTES, description); + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)); + } catch { + throw new ActionError(`${description} response was not valid JSON`); + } +} + +async function exchange(options, env, fetchImpl, write) { + if (!env.ACTIONS_ID_TOKEN_REQUEST_URL || !env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) { + throw new ActionError("id-token: write is required to request a GitHub OIDC token"); + } + if (["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"].some((name) => env[name]) && env.NODE_USE_ENV_PROXY !== "1") { + throw new ActionError("set NODE_USE_ENV_PROXY=1 when an HTTP proxy is configured"); + } + const oidcUrl = httpsUrl(env.ACTIONS_ID_TOKEN_REQUEST_URL, "ACTIONS_ID_TOKEN_REQUEST_URL", true); + oidcUrl.searchParams.set("audience", options.audience); + const oidc = await requestJson(fetchImpl, oidcUrl, { + headers: { authorization: `Bearer ${env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}` }, + }, "OIDC token"); + if (typeof oidc?.value !== "string" || oidc.value.length > 16 * 1024 || /\s/u.test(oidc.value) || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u.test(oidc.value)) { + throw new ActionError("OIDC response did not contain a valid encoded token"); + } + command("add-mask", oidc.value, write); + const session = await requestJson(fetchImpl, new URL("/exchange", options.url), { + method: "POST", + headers: { authorization: `Bearer ${oidc.value}` }, + }, "token exchange"); + if (typeof session?.access_token !== "string" || session.access_token.length !== 72 || !/^aghs_kv_[0-9a-f]{64}$/u.test(session.access_token)) { + throw new ActionError("exchange response did not contain a valid session token"); + } + command("add-mask", session.access_token, write); + if (session.token_type !== "Bearer" || !Number.isSafeInteger(session.expires_at) || session.expires_at <= Math.floor(Date.now() / 1000)) { + throw new ActionError("exchange response contained an invalid or expired session"); + } + return session.access_token; +} + +async function outputs(file, entries, uuid = randomUUID) { + let content = ""; + for (const [name, value] of entries) { + const delimiter = `kv_${uuid()}`; + if (value.includes(delimiter)) throw new ActionError("output contains its generated delimiter"); + content += `${name}<<${delimiter}\n${value}\n${delimiter}\n`; + } + try { + await fs.appendFile(file, content, "utf8"); + } catch { + throw new ActionError("could not write action outputs"); + } +} + +async function run(operation, { + env = process.env, + fetchImpl = globalThis.fetch, + write = process.stdout.write.bind(process.stdout), + uuid, +} = {}) { + if (!["get", "put", "delete"].includes(operation)) throw new ActionError("unsupported key-value operation"); + const options = optionsFromEnvironment(operation, env); + let bytes; + if (operation === "put") { + bytes = options.file ? await readValueFile(options.file) : Buffer.from(options.value, "utf8"); + if (bytes.length > MAX_VALUE_BYTES) throw new ActionError("value exceeds 65536 bytes"); + } else if (operation === "get" && options.file) { + await requireNewFile(options.file); + } + const token = await exchange(options, env, fetchImpl, write); + const response = await request(fetchImpl, new URL(`/kv/${options.key}`, options.url), { + method: operation.toUpperCase(), + headers: { authorization: `Bearer ${token}`, ...(operation === "put" && { "content-type": "application/octet-stream" }) }, + ...(operation === "put" && { body: bytes }), + }, `value ${operation}`); + if (operation !== "get") { + await expectStatus(response, 204, `value ${operation}`); + return; + } + if (response.status === 404) { + await response.body?.cancel(); + await outputs(env.GITHUB_OUTPUT, [["found", "false"], ["value", ""], ["path", ""], ["expires-at", ""]], uuid); + return; + } + await expectStatus(response, 200, "value get"); + const expiresAt = response.headers.get("x-expires-at"); + if (!/^[0-9]+$/u.test(expiresAt || "") || !Number.isSafeInteger(Number(expiresAt)) || Number(expiresAt) <= 0) { + await response.body?.cancel(); + throw new ActionError("value response did not contain a valid expiry"); + } + bytes = await readBody(response, MAX_VALUE_BYTES, "value get"); + let value = ""; + if (options.file) { + await writeValueFile(options.file, bytes); + } else { + try { + value = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); + } catch { + throw new ActionError("value is not UTF-8; use path to retrieve binary values"); + } + if (value.includes("\0")) throw new ActionError("value contains NUL bytes; use path to retrieve binary values"); + if (options.maskValue && value) command("add-mask", value, write); + } + await outputs(env.GITHUB_OUTPUT, [ + ["found", "true"], ["value", value], ["path", options.file ? path.resolve(options.file) : ""], ["expires-at", expiresAt], + ], uuid); +} + +function main(operation) { + run(operation).catch((error) => { + const message = error instanceof ActionError ? error.message : "key-value action failed"; + command("error", message, process.stdout.write.bind(process.stdout)); + process.exitCode = 1; + }); +} + +module.exports = { run, main, ActionError, MAX_VALUE_BYTES }; diff --git a/key-value-store/delete/action.yml b/key-value-store/delete/action.yml new file mode 100644 index 0000000..36f9584 --- /dev/null +++ b/key-value-store/delete/action.yml @@ -0,0 +1,17 @@ +name: Delete a key-value entry +description: Delete workflow state from the current GitHub Actions OIDC namespace. + +inputs: + service-url: + description: HTTPS origin of the key-value service. + required: true + audience: + description: OIDC audience; defaults to the service-url origin. + default: "" + key: + description: Key within the namespace derived from the job's OIDC context. + required: true + +runs: + using: node24 + main: main.cjs diff --git a/key-value-store/delete/main.cjs b/key-value-store/delete/main.cjs new file mode 100644 index 0000000..e07e31a --- /dev/null +++ b/key-value-store/delete/main.cjs @@ -0,0 +1 @@ +require("../client.cjs").main("delete"); diff --git a/key-value-store/get/action.yml b/key-value-store/get/action.yml new file mode 100644 index 0000000..379b77c --- /dev/null +++ b/key-value-store/get/action.yml @@ -0,0 +1,33 @@ +name: Read a key-value entry +description: Read workflow state from the current GitHub Actions OIDC namespace. + +inputs: + service-url: + description: HTTPS origin of the key-value service. + required: true + audience: + description: OIDC audience; defaults to the service-url origin. + default: "" + key: + description: Key within the namespace derived from the job's OIDC context. + required: true + path: + description: New output file for raw bytes. Must not already exist. Omit for a UTF-8 value output. + default: "" + mask-value: + description: Mask the text value in runner logs. Set false only for nonsensitive values. + default: "true" + +outputs: + found: + description: Whether an unexpired value was found (true or false). + value: + description: UTF-8 value when path is omitted; empty if absent or written to a file. + path: + description: Absolute output file path, or empty if no file was written. + expires-at: + description: Value expiry in Unix seconds, or empty if absent. + +runs: + using: node24 + main: main.cjs diff --git a/key-value-store/get/main.cjs b/key-value-store/get/main.cjs new file mode 100644 index 0000000..13be71c --- /dev/null +++ b/key-value-store/get/main.cjs @@ -0,0 +1 @@ +require("../client.cjs").main("get"); diff --git a/key-value-store/put/action.yml b/key-value-store/put/action.yml new file mode 100644 index 0000000..556f74a --- /dev/null +++ b/key-value-store/put/action.yml @@ -0,0 +1,23 @@ +name: Write a key-value entry +description: Create or replace workflow state in the current GitHub Actions OIDC namespace. + +inputs: + service-url: + description: HTTPS origin of the key-value service. + required: true + audience: + description: OIDC audience; defaults to the service-url origin. + default: "" + key: + description: Key within the namespace derived from the job's OIDC context. + required: true + value: + description: UTF-8 value, including whitespace. Defaults to an empty value. Do not combine a nonempty value with path. + default: "" + path: + description: Regular file containing raw value bytes; mutually exclusive with a nonempty value. + default: "" + +runs: + using: node24 + main: main.cjs diff --git a/key-value-store/put/main.cjs b/key-value-store/put/main.cjs new file mode 100644 index 0000000..8d2e561 --- /dev/null +++ b/key-value-store/put/main.cjs @@ -0,0 +1 @@ +require("../client.cjs").main("put"); diff --git a/key-value-store/tests/client.test.cjs b/key-value-store/tests/client.test.cjs new file mode 100644 index 0000000..e6b23ac --- /dev/null +++ b/key-value-store/tests/client.test.cjs @@ -0,0 +1,258 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const { test } = require("node:test"); +const { run, MAX_VALUE_BYTES } = require("../client.cjs"); + +const token = `aghs_kv_${"a".repeat(64)}`; +const oidc = "header.payload.signature"; + +function environment(overrides = {}) { + return { + "INPUT_SERVICE-URL": "https://kv.example/", + INPUT_KEY: "build.result", + ACTIONS_ID_TOKEN_REQUEST_URL: "https://issuer.example/token?job=123&audience=old", + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "runner-token", + ...overrides, + }; +} + +function session() { + return new Response(JSON.stringify({ + access_token: token, token_type: "Bearer", expires_at: Math.floor(Date.now() / 1000) + 300, + })); +} + +function mock(operationResponse, overrides = {}) { + const calls = []; + const masks = []; + const responses = [new Response(JSON.stringify({ value: oidc })), session(), operationResponse]; + return { + calls, masks, + options: { + env: environment(overrides), + write: (line) => masks.push(line), + fetchImpl: async (url, options) => { + calls.push({ url: String(url), ...options }); + assert.ok(responses.length, "unexpected request or retry"); + return responses.shift(); + }, + }, + }; +} + +async function outputFile(context) { + const directory = await fs.mkdtemp(path.join(process.env.RUNNER_TEMP || os.tmpdir(), "kv-test-")); + context.after(() => fs.rm(directory, { recursive: true, force: true })); + return { directory, output: path.join(directory, "outputs") }; +} + +async function outputs(file) { + const text = await fs.readFile(file, "utf8"); + const result = {}; + let offset = 0; + while (offset < text.length) { + const end = text.indexOf("\n", offset); + const [name, delimiter] = text.slice(offset, end).split("<<"); + const boundary = `\n${delimiter}\n`; + const next = text.indexOf(boundary, end + 1); + assert.ok(next >= 0, "output delimiter is present"); + result[name] = text.slice(end + 1, next); + offset = next + boundary.length; + } + return result; +} + +function valueResponse(body, expiresAt = 2000000000) { + return new Response(body, { headers: { "x-expires-at": String(expiresAt) } }); +} + +test("put exchanges a fresh OIDC token and preserves UTF-8 whitespace and empty values", async () => { + for (const value of ["", " \nĪ€\r\n "]) { + const fixture = mock(new Response(null, { status: 204 }), { INPUT_VALUE: value }); + await run("put", fixture.options); + assert.equal(fixture.calls.length, 3); + const url = new URL(fixture.calls[0].url); + assert.equal(url.searchParams.get("job"), "123"); + assert.equal(url.searchParams.get("audience"), "https://kv.example"); + assert.equal(url.searchParams.getAll("audience").length, 1); + assert.equal(fixture.calls[0].headers.authorization, "Bearer runner-token"); + assert.equal(fixture.calls[1].url, "https://kv.example/exchange"); + assert.equal(fixture.calls[1].headers.authorization, `Bearer ${oidc}`); + assert.equal(fixture.calls[1].body, undefined); + assert.equal(fixture.calls[2].url, "https://kv.example/kv/build.result"); + assert.equal(fixture.calls[2].method, "PUT"); + assert.equal(fixture.calls[2].headers.authorization, `Bearer ${token}`); + assert.deepEqual(fixture.calls[2].body, Buffer.from(value)); + for (const call of fixture.calls) { + assert.equal(call.redirect, "error"); + assert.ok(call.signal instanceof AbortSignal); + } + assert.deepEqual(fixture.masks, [`::add-mask::${oidc}\n`, `::add-mask::${token}\n`]); + } +}); + +test("custom audience is sent only to the OIDC issuer", async () => { + const fixture = mock(new Response(null, { status: 204 }), { INPUT_AUDIENCE: "urn:astral:kv" }); + await run("delete", fixture.options); + assert.equal(new URL(fixture.calls[0].url).searchParams.get("audience"), "urn:astral:kv"); + assert.equal(fixture.calls[2].method, "DELETE"); + assert.equal(fixture.calls[2].body, undefined); +}); + +test("invalid endpoints, namespace selectors, keys, and options fail before credentials are requested", async () => { + for (const overrides of [ + { "INPUT_SERVICE-URL": "http://kv.example" }, + { "INPUT_SERVICE-URL": "https://user:password@kv.example" }, + { "INPUT_SERVICE-URL": "https://kv.example/prefix" }, + { "INPUT_SERVICE-URL": "https://kv.example?namespace=other" }, + { "INPUT_SERVICE-URL": "https://kv.example/#fragment" }, + { INPUT_KEY: ".." }, { INPUT_KEY: "other/key" }, { INPUT_KEY: "a%2Fb" }, + { INPUT_KEY: "key\n" }, { INPUT_KEY: "key?namespace=other" }, { INPUT_KEY: "a".repeat(257) }, + { INPUT_AUDIENCE: "has spaces" }, { INPUT_AUDIENCE: "🚀".repeat(513) }, + { INPUT_VALUE: "value", INPUT_PATH: "file" }, + ]) { + const fixture = mock(undefined, overrides); + await assert.rejects(run("put", fixture.options)); + assert.equal(fixture.calls.length, 0); + } +}); + +test("missing OIDC permission and unsafe issuer URLs fail before network access", async () => { + for (const overrides of [ + { ACTIONS_ID_TOKEN_REQUEST_TOKEN: "" }, + { ACTIONS_ID_TOKEN_REQUEST_URL: "http://issuer.example/token" }, + { ACTIONS_ID_TOKEN_REQUEST_URL: "https://user:pass@issuer.example/token" }, + { HTTPS_PROXY: "http://proxy.example" }, + ]) { + const fixture = mock(undefined, overrides); + await assert.rejects(run("delete", fixture.options)); + assert.equal(fixture.calls.length, 0); + } +}); + +test("writes reject oversized text and files before requesting credentials", async (context) => { + const { directory } = await outputFile(context); + const file = path.join(directory, "large"); + await fs.writeFile(file, Buffer.alloc(MAX_VALUE_BYTES + 1)); + for (const overrides of [{ INPUT_VALUE: "Ī€".repeat(MAX_VALUE_BYTES) }, { INPUT_PATH: file }, { INPUT_PATH: directory }]) { + const fixture = mock(undefined, overrides); + await assert.rejects(run("put", fixture.options)); + assert.equal(fixture.calls.length, 0); + } + await fs.writeFile(file, Buffer.alloc(MAX_VALUE_BYTES, 255)); + const fixture = mock(new Response(null, { status: 204 }), { INPUT_PATH: file }); + await run("put", fixture.options); + assert.deepEqual(fixture.calls[2].body, Buffer.alloc(MAX_VALUE_BYTES, 255)); +}); + +test("get preserves multiline values and escapes workflow commands while masking", async (context) => { + const { output } = await outputFile(context); + const value = "\uFEFF \r\n::error::untrusted\n100%\n"; + const fixture = mock(valueResponse(value), { GITHUB_OUTPUT: output }); + await run("get", fixture.options); + assert.deepEqual(await outputs(output), { found: "true", value, path: "", "expires-at": "2000000000" }); + assert.ok(fixture.masks[2].includes("%0D%0A::error::untrusted%0A100%25%0A")); + assert.equal(fixture.masks[2].split("\n").length, 2); + const text = await fs.readFile(output, "utf8"); + assert.ok(!text.includes(token)); + assert.ok(!text.includes(oidc)); +}); + +test("get distinguishes a missing key from an empty value without writing a missing file", async (context) => { + const { directory, output } = await outputFile(context); + const file = path.join(directory, "not-created"); + await run("get", mock(new Response("not found", { status: 404 }), { GITHUB_OUTPUT: output, INPUT_PATH: file }).options); + assert.deepEqual(await outputs(output), { found: "false", value: "", path: "", "expires-at": "" }); + await assert.rejects(fs.stat(file), { code: "ENOENT" }); + await fs.unlink(output); + await run("get", mock(valueResponse(""), { GITHUB_OUTPUT: output }).options); + assert.equal((await outputs(output)).found, "true"); +}); + +test("binary reads require a new file and never produce a text value", async (context) => { + const { directory, output } = await outputFile(context); + const file = path.join(directory, "nested", "value"); + const bytes = Buffer.from([0, 255, 13, 10, 42]); + await run("get", mock(valueResponse(bytes), { GITHUB_OUTPUT: output, INPUT_PATH: file }).options); + assert.deepEqual(await fs.readFile(file), bytes); + assert.deepEqual(await outputs(output), { found: "true", value: "", path: file, "expires-at": "2000000000" }); + const fixture = mock(undefined, { GITHUB_OUTPUT: output, INPUT_PATH: file }); + await assert.rejects(run("get", fixture.options), /must not already exist/); + assert.equal(fixture.calls.length, 0); + assert.deepEqual(await fs.readFile(file), bytes); +}); + +test("binary or NUL-containing values cannot silently corrupt text outputs", async (context) => { + const { output } = await outputFile(context); + for (const bytes of [Buffer.from([255]), Buffer.from([0]), Buffer.from([0xc3])]) { + await assert.rejects(run("get", mock(valueResponse(bytes), { GITHUB_OUTPUT: output }).options), /use path/); + await assert.rejects(fs.stat(output), { code: "ENOENT" }); + } +}); + +test("masking can be explicitly disabled for nonsensitive text outputs", async (context) => { + const { output } = await outputFile(context); + const fixture = mock(valueResponse("visible"), { GITHUB_OUTPUT: output, "INPUT_MASK-VALUE": "false" }); + await run("get", fixture.options); + assert.equal(fixture.masks.length, 2); + assert.equal((await outputs(output)).value, "visible"); +}); + +test("oversized and malformed responses fail without emitting values or response bodies", async (context) => { + const { output } = await outputFile(context); + for (const response of [ + valueResponse(Buffer.alloc(MAX_VALUE_BYTES + 1)), + new Response("private response", { headers: { "x-expires-at": "bad" } }), + new Response("private response", { status: 500 }), + new Response("private response", { headers: { "x-expires-at": "1", "content-length": String(MAX_VALUE_BYTES + 1) } }), + ]) { + const fixture = mock(response, { GITHUB_OUTPUT: output }); + await assert.rejects(run("get", fixture.options), (error) => !error.message.includes("private response")); + await assert.rejects(fs.stat(output), { code: "ENOENT" }); + assert.equal(fixture.calls.length, 3); + } +}); + +test("malformed or expired exchange responses never reach the value API", async () => { + for (const body of [ + null, + { access_token: "untrusted\n::error::injection" }, + { access_token: `${token}\n` }, + { access_token: token, token_type: "other", expires_at: 2000000000 }, + { access_token: token, token_type: "Bearer", expires_at: 1 }, + ]) { + let calls = 0; + await assert.rejects(run("delete", { + env: environment(), write: () => {}, + fetchImpl: async () => ++calls === 1 + ? new Response(JSON.stringify({ value: oidc })) + : new Response(JSON.stringify(body)), + })); + assert.equal(calls, 2); + } +}); + +test("transport errors are sanitized and ambiguous writes are not retried", async () => { + let calls = 0; + await assert.rejects(run("put", { + env: environment(), write: () => {}, + fetchImpl: async () => { + calls += 1; + if (calls === 1) return new Response(JSON.stringify({ value: oidc })); + if (calls === 2) return session(); + throw new Error(`private ${token}`); + }, + }), { message: "value put request failed" }); + assert.equal(calls, 3); +}); + +test("output delimiter collisions fail instead of injecting another output", async (context) => { + const { output } = await outputFile(context); + const fixture = mock(valueResponse("\nkv_fixed\nforged=true"), { GITHUB_OUTPUT: output }); + await assert.rejects(run("get", { ...fixture.options, uuid: () => "fixed" }), /delimiter/); + await assert.rejects(fs.stat(output), { code: "ENOENT" }); +}); diff --git a/key-value-store/tests/entrypoints.test.cjs b/key-value-store/tests/entrypoints.test.cjs new file mode 100644 index 0000000..237b6a4 --- /dev/null +++ b/key-value-store/tests/entrypoints.test.cjs @@ -0,0 +1,177 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { spawn } = require("node:child_process"); +const fs = require("node:fs/promises"); +const https = require("node:https"); +const os = require("node:os"); +const path = require("node:path"); +const { test } = require("node:test"); + +const certificate = path.join(__dirname, "fixtures", "localhost-cert.pem"); + +async function fixture(context) { + const directory = await fs.mkdtemp(path.join(process.env.RUNNER_TEMP || os.tmpdir(), "kv-entrypoint-")); + context.after(() => fs.rm(directory, { recursive: true, force: true })); + const values = new Map(); + const issued = new Set(); + const sessions = new Set(); + const requests = []; + const errors = []; + let count = 0; + let redirect; + let origin; + const server = https.createServer({ + key: await fs.readFile(path.join(__dirname, "fixtures", "localhost-key.pem")), + cert: await fs.readFile(certificate), + }, async (request, response) => { + try { + const url = new URL(request.url, origin); + requests.push({ path: url.pathname, authorization: request.headers.authorization }); + if (url.pathname === redirect) { + response.writeHead(307, { location: `${origin}/credential-leak` }).end(); + return; + } + if (url.pathname === "/token") { + assert.equal(request.method, "GET"); + assert.equal(request.headers.authorization, "Bearer runner-request-token"); + assert.equal(url.searchParams.get("audience"), origin); + const oidc = `header.${Buffer.from(String(++count)).toString("base64url")}.signature`; + issued.add(oidc); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ value: oidc })); + return; + } + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const body = Buffer.concat(chunks); + const bearer = request.headers.authorization?.replace(/^Bearer /u, ""); + if (url.pathname === "/exchange") { + assert.equal(request.method, "POST"); + assert.equal(body.length, 0); + assert.ok(issued.delete(bearer), "each exchange consumes a fresh OIDC token"); + const session = `aghs_kv_${count.toString(16).padStart(64, "0")}`; + sessions.add(session); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ + access_token: session, token_type: "Bearer", expires_at: Math.floor(Date.now() / 1000) + 300, + })); + return; + } + assert.ok(url.pathname.startsWith("/kv/"), "requests stay on the expected endpoints"); + assert.ok(sessions.has(bearer), "value requests use the session credential"); + const key = url.pathname.slice(4); + if (request.method === "PUT") { + assert.equal(request.headers["content-type"], "application/octet-stream"); + values.set(key, body); + response.writeHead(204).end(); + } else if (request.method === "DELETE") { + values.delete(key); + response.writeHead(204).end(); + } else { + assert.equal(request.method, "GET"); + if (!values.has(key)) { + response.writeHead(404).end(); + } else { + response.writeHead(200, { "content-type": "application/octet-stream", "x-expires-at": "2000000000" }); + response.end(values.get(key)); + } + } + } catch (error) { + errors.push(error); + response.writeHead(500).end(); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + origin = `https://127.0.0.1:${server.address().port}`; + context.after(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + assert.deepEqual(errors, []); + }); + + async function action(operation, inputs = {}) { + const output = path.join(directory, `outputs-${count}-${operation}`); + const env = { ...process.env }; + for (const name of Object.keys(env)) { + if (/^(INPUT_|ACTIONS_ID_TOKEN_)/iu.test(name) || /^(https?_proxy|all_proxy|no_proxy|node_options)$/iu.test(name)) delete env[name]; + } + Object.assign(env, { + NODE_USE_ENV_PROXY: "0", + NODE_EXTRA_CA_CERTS: certificate, + ACTIONS_ID_TOKEN_REQUEST_URL: `${origin}/token`, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "runner-request-token", + "INPUT_SERVICE-URL": origin, + INPUT_KEY: "build.result", + GITHUB_OUTPUT: output, + ...inputs, + }); + const child = spawn(process.execPath, [path.join(__dirname, "..", operation, "main.cjs")], { env }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + const code = await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", resolve); + }); + return { code, stdout, stderr, output }; + } + return { directory, requests, values, action, redirect: (pathname) => { redirect = pathname; } }; +} + +async function outputs(file) { + const text = await fs.readFile(file, "utf8"); + return Object.fromEntries([...text.matchAll(/([^\n]+)<<([^\n]+)\n([\s\S]*?)\n\2\n/gu)].map((match) => [match[1], match[3]])); +} + +function succeeded(result) { + assert.equal(result.code, 0, result.stdout + result.stderr); + assert.ok(!result.stdout.includes("runner-request-token")); + assert.ok(result.stdout.split("\n").every((line) => !line || line.startsWith("::add-mask::"))); +} + +test("real HTTPS action entrypoints round-trip text, binary, empty and missing values", { timeout: 30_000 }, async (context) => { + const server = await fixture(context); + const text = " \nĪ€\r\n::error::stored text\n"; + succeeded(await server.action("put", { INPUT_VALUE: text })); + const get = await server.action("get"); + succeeded(get); + assert.deepEqual(await outputs(get.output), { found: "true", value: text, path: "", "expires-at": "2000000000" }); + assert.ok(get.stdout.includes("%0A::error::stored text%0A")); + + const input = path.join(server.directory, "input bytes"); + const output = path.join(server.directory, "nested", "output bytes"); + const binary = Buffer.from([0, 255, 13, 10, 128]); + await fs.writeFile(input, binary); + succeeded(await server.action("put", { INPUT_PATH: input })); + const binaryGet = await server.action("get", { INPUT_PATH: output }); + succeeded(binaryGet); + assert.deepEqual(await fs.readFile(output), binary); + assert.equal((await outputs(binaryGet.output)).path, output); + + succeeded(await server.action("put", { INPUT_VALUE: "" })); + const empty = await server.action("get"); + succeeded(empty); + assert.equal((await outputs(empty.output)).found, "true"); + assert.equal((await outputs(empty.output)).value, ""); + succeeded(await server.action("delete")); + const missing = await server.action("get"); + succeeded(missing); + assert.deepEqual(await outputs(missing.output), { found: "false", value: "", path: "", "expires-at": "" }); + succeeded(await server.action("delete")); + assert.equal(server.requests.filter((request) => request.path === "/token").length, 9); + assert.equal(server.requests.filter((request) => request.path === "/exchange").length, 9); +}); + +test("real HTTPS redirects never forward runner, OIDC or session credentials", { timeout: 30_000 }, async (context) => { + const server = await fixture(context); + for (const stage of ["/token", "/exchange", "/kv/build.result"]) { + server.redirect(stage); + const result = await server.action("delete"); + assert.equal(result.code, 1); + assert.match(result.stdout, /::error::.* request failed/u); + assert.ok(!result.stdout.includes("runner-request-token")); + } + assert.ok(server.requests.every((request) => request.path !== "/credential-leak")); +}); diff --git a/key-value-store/tests/fixtures/README.md b/key-value-store/tests/fixtures/README.md new file mode 100644 index 0000000..8feea7c --- /dev/null +++ b/key-value-store/tests/fixtures/README.md @@ -0,0 +1,4 @@ +The certificate and private key in this directory are public test fixtures for +the local HTTPS server. They do not authenticate any deployed service. The +entrypoint tests trust this certificate only in their child processes through +`NODE_EXTRA_CA_CERTS`. diff --git a/key-value-store/tests/fixtures/localhost-cert.pem b/key-value-store/tests/fixtures/localhost-cert.pem new file mode 100644 index 0000000..b7b7a53 --- /dev/null +++ b/key-value-store/tests/fixtures/localhost-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJzCCAg+gAwIBAgIUZ/Gr0IhKQDnGVeka3CR4h9shj9gwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MTAyMjAwMzI1MVoYDzIxMjYw +OTI4MDAzMjUxWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDK40YaZB4MSrA60hLAnamaEcvPJ4cNh9yc0fM85ktO +kEo1FMVuXCgYbF/zqON9OX4wmLkDZ2TsFDrCPVmIoOzDHZRR6eIWWqm8olhI2xMV +fVKGkzGFcEI+JXQmJqnG5FZMTJmAL1bwhR/wiCh7tbsjaMolrIGTEHxm8y3/WXO0 +k7qjX5b8LECLO3qJmwF2xPOwq7vff/h4FF6tRY8LGAU15ymaerU6QsaX6D4w9U3F ++wiBbYVx7RIL29wG08LURLlfSQmuyE6LWHpo44lHXK6GjadBVgBLdnk3DfP702Wl +tBJE+3BdBzl1SE34nBUNxHZRaMdFZn4f40ENI4frTZxFAgMBAAGjbzBtMB0GA1Ud +DgQWBBQXRC4zPt1taXiY1YQDHWs3/jhTszAfBgNVHSMEGDAWgBQXRC4zPt1taXiY +1YQDHWs3/jhTszAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9z +dIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAQzyDoTj8/vjlZFIbb5TJoEEuBNwT +EeBdUZpZEj5+c86eUUlHLla7z41OSuSwSprzSvzFg2uwa/vB0jAIsJuyQiAPXVCB +iDOqHQ09il9mTQ+vHgBO3Cia5edpmXHj/g9//Sdm3tgjk+YCn1HjVsfnfSnBcBvp +6jAc6NUABlMMw2028Wi0jTvdBL8uptr7PXhkgEIonwiWAIJii62zOmJXR0tFlyLl +Gv7YccGSSp556TSAofrbfQVYyy4zQfL6mQMdwA8xOuXkvB+Gtp+uIUuVdTNXG3Ip +RVeCwJOH8xXaDMZa3SL6+zMpWW7DGJJTGhPP8mfshHmtRcF6zNz+NgrOYg== +-----END CERTIFICATE----- diff --git a/key-value-store/tests/fixtures/localhost-key.pem b/key-value-store/tests/fixtures/localhost-key.pem new file mode 100644 index 0000000..9fe3969 --- /dev/null +++ b/key-value-store/tests/fixtures/localhost-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDK40YaZB4MSrA6 +0hLAnamaEcvPJ4cNh9yc0fM85ktOkEo1FMVuXCgYbF/zqON9OX4wmLkDZ2TsFDrC +PVmIoOzDHZRR6eIWWqm8olhI2xMVfVKGkzGFcEI+JXQmJqnG5FZMTJmAL1bwhR/w +iCh7tbsjaMolrIGTEHxm8y3/WXO0k7qjX5b8LECLO3qJmwF2xPOwq7vff/h4FF6t +RY8LGAU15ymaerU6QsaX6D4w9U3F+wiBbYVx7RIL29wG08LURLlfSQmuyE6LWHpo +44lHXK6GjadBVgBLdnk3DfP702WltBJE+3BdBzl1SE34nBUNxHZRaMdFZn4f40EN +I4frTZxFAgMBAAECggEAAU54lhBHWEH9lgmrf5oddapJLFED9HN1a6GJxhC7KesI +dHJEi+1IgBpVtEcYQV4CKg+X//yBJUQxFWQQZEoHlEqDJsEs2wLs9Bzqz44nmyDJ +FShGScIEWFUJV3rWwmiX84hVdkucUa4gEmwkVJkLG1yEpHOJi6XLWiY50pGa3S4R +2BZCVd5gVTR9MZEOMniKMINFD/dKuFO01r0I+BPCBmbdenpAJbNkwshUkY4aDPou +9yVis0Hw0jTnJ9wDvJanlr+QN5SjTYCtFxxY7EF30mdShBXRn+8rBs7L09vuqbZ8 +dKYtZF+Kt1N+DbLvskSmbVr7s+7kOrYHtgyOy1fbKQKBgQD5cgpYsp6gfGQJ25BN +HF+YzjlqXHU8BaAP2SjZdNhk+vruZI9flEhDOKzI5ZV+xaMSwefFwh0wV3FsjIcb +U5CuQZrRInNwL9coHHCOr5L/rWC8fFrQjY/KyD2cY0x01BqGTcQlEKR7A0IoKUTU +rE0RnnsN7uhucxRH2CLh8mcpvQKBgQDQOA0V1CMy/zM5yyiMedZ+cjW+k1tBbve/ +1pfkrkQR6TsHo1r6hFp2nRH0RRjGLfoargspFJ1K4Rof/HA2DWj1L6ffbErM/AvI +aJApQk3T7YRQXQJLeC7YY3CHNH/znKvsdd41q0T9IjJfDzCfpFlFSIGABelDUvaf +NhiNP8HxKQKBgA9O9l+XJrEGsUUBmouA8ET6cv+aCP/UAIy4cQ9XYmhgFsJGnZ3q +Bb1KXyA9Jr2O94ERMV4rmYr78tQMk8YTjM+E6zFmgdsitHubszSuhcD3LPakUxZ1 +eZuiSlWsqmaJFc+kOiNkMBP0kTzRu42pvVkVdsvWG6GbomT+WLWxIK+pAoGAbPwZ +pUZPUj8Pkf2LcNPI+JBRHzpD9qDdnpT1FiY8zN82HY5cbWnfa9xU7HVKPVkNYu+c +OdlnZy0hnoJkPXaRbFyQUrp9AIQgqrpIUsA4umfE7MdyDwyOAIcz/CD3pAxqVK9x +KshFqPVARk9OFRFSBJip59ewO6PuIHGRMkWf/tkCgYEAnFDSo+6lUVytrN7DKh9p +5IjB8+aWw3s7AJ2hbAgyGtGa/WrJM58t1g74+dPGsGzAoGayJA033fCz7giuEc5h +cL/QGqz05hJmSss9obFiLAUSjLAXOvjk62g/B/upsPu5+npPVoWdlKTKzuYD1UKx +LH1XamcvL1EiZn1NHUVY8UI= +-----END PRIVATE KEY-----