Skip to content

[HDX-5179] feat(api): add a token encryption service for stored tokens - #3112

Open
jordan-simonovski wants to merge 3 commits into
mainfrom
jordansimonovski/hdx-5179-token-encryption-service
Open

[HDX-5179] feat(api): add a token encryption service for stored tokens#3112
jordan-simonovski wants to merge 3 commits into
mainfrom
jordansimonovski/hdx-5179-token-encryption-service

Conversation

@jordan-simonovski

Copy link
Copy Markdown
Contributor

Slack bot tokens and OAuth tokens need encrypting at rest before anything stores them. This adds the service that does it. Nothing calls it yet.

  • Set TOKEN_ENCRYPTION_KEY (32 bytes, base64 or hex) and stored tokens are encrypted with AES-256-GCM. Set nothing and they are stored readable, which stays a supported choice.
  • Turning encryption on later does not orphan tokens already stored. Reading a still-unencrypted one warns and increments a counter, so a migration can be finished.
  • Encryption state is in the startup log and alertable on hyperdx.token_encryption.disabled.
  • Adding another encryption method is a new file and one registration call.
  • A malformed key stops the API starting. A missing key stays silent.

Two deviations from the acceptance criteria, both worth a look: there is no base64 provider, since it is readable by anyone with database access and that is the threat it was meant to address; and the test endpoint is a startup check rather than a route, so nobody has to remember to call it.

Rotating the key makes existing rows unreadable. That is accepted for now, and the version in the stored format is how a key id gets added when it matters.

Implementation detail
  • Stored values are v1:<provider>:<payload>, with the version and provider prefix bound into AES-GCM as additional authenticated data, so a payload cannot be relabelled and replayed under another provider sharing the key.
  • Nothing from a stored value reaches an error message before it matches the envelope pattern. A caller handing a raw token to decryptToken would otherwise put that token into the logs.
  • Keys are accepted as base64 or hex, trimmed, then re-encoded and compared, because Node's base64 decoder skips characters outside the alphabet and a quoted key would decode to 32 plausible but wrong bytes.
  • The startup check is bounded at ten seconds so a provider that makes network calls cannot hold up boot, and logs rather than throws for anything but a malformed key.
  • Re-registering a provider name warns, matching how utils/instrumentation.ts handles conflicting instrument definitions.
  • 31 unit tests cover the encryption, tampering, truncation, cross-provider replay, envelope dispatch, key parsing and the disabled path. Verified with yarn ci:unit in packages/api (1021 tests), tsc --noEmit, and eslint at 299 warnings against the 302 ceiling.

Slack bot tokens and OAuth tokens need encrypting at rest, and the
encryption method has to be replaceable without changing call sites. This
adds the provider registry and stored format they share, plus an
AES-256-GCM provider.

Stored values are `v1:<provider>:<payload>` and decryption dispatches on
the provider named in the value, so a deployment can change providers
without orphaning tokens already stored. Setting TOKEN_ENCRYPTION_KEY is
the whole opt-in; without it tokens are stored verbatim under a `none`
provider that is deliberately obvious in the database.

Nothing consumes this yet - it is the library on its own.
@changeset-bot

changeset-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ea1aaa3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hyperdx/api Patch
@hyperdx/app Patch
@hyperdx/otel-collector Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
hyperdx-oss Ready Ready Preview Sep 11, 2026 10:42pm UTC
hyperdx-storybook Ready Ready Preview Sep 11, 2026 10:42pm UTC

Request Review

@github-actions github-actions Bot added the review/tier-4 Critical — deep review + domain expert sign-off label Sep 11, 2026
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

🔴 Tier 4 — Critical

Touches authentication, tenancy data models, the public API or shipped database config — or substantially changes the query rendering engine, background tasks, the OTel pipeline, image build, or release CI.

Why this tier:

  • Critical-path files (1) — tenancy, public API, or shipped database config:
    • packages/api/src/config.ts

Review process: Deep review from a domain expert. Synchronous walkthrough may be required.
SLA: Schedule synchronous review within 2 business days.

Stats
  • Production files changed: 5
  • Production lines changed: 307 (+ 287 in test files, excluded from tier calculation)
  • Critical-path lines changed: 5
  • Branch: jordansimonovski/hdx-5179-token-encryption-service
  • Author: jordan-simonovski

To override this classification, remove the review/tier-4 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-apps Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a pluggable service for encrypting stored third-party tokens, including AES-256-GCM and plaintext providers, configuration, startup verification, observability, and unit coverage.

  • Uses versioned envelopes and provider-based decryption dispatch.
  • Enables local encryption through TOKEN_ENCRYPTION_KEY while retaining plaintext compatibility.
  • Verifies the active provider during API startup and reports disabled or failed encryption.
  • Refines provider activation so writes retain the provider selected at startup.

Confidence Score: 4/5

The implementation is functionally sound, but the explicit observability requirement for provider-replacement events must be satisfied before merging.

Provider replacement is logged as a countable event without a corresponding metric, contrary to the repository’s observability requirement; no blocking encryption-integrity or runtime failure was established.

Files Needing Attention: packages/api/src/utils/tokenEncryption.ts

Important Files Changed

Filename Overview
packages/api/src/utils/tokenEncryption.ts Implements provider registration, versioned token envelopes, AES-256-GCM encryption, plaintext compatibility, metrics, and startup verification; provider replacement warnings lack a corresponding metric.
packages/api/src/utils/tests/tokenEncryption.test.ts Covers encryption round trips, tampering, provider dispatch, key parsing, plaintext migration behavior, and startup checks.
packages/api/src/server.ts Runs token-encryption verification during API startup before database connection retries.
packages/api/src/config.ts Exposes the optional token-encryption key from the environment.
docker-compose.yml Passes the optional token-encryption key into the API container.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  C[Token caller] --> E[encryptToken]
  E --> A{Active provider}
  A -->|none| P[v1:none:plaintext]
  A -->|local| G[AES-256-GCM]
  G --> V[v1:local:payload]
  P --> S[(Stored token)]
  V --> S
  S --> D[decryptToken]
  D --> R{Provider in envelope}
  R -->|none| N[Plaintext and migration warning]
  R -->|local| X[AES-GCM verification and decryption]
Loading

Fix all with Greploop Fix All in Claude Code Fix All in Conductor Fix All in Cursor Fix All in Codex

Reviews (3): Last reviewed commit: "refactor(api): hold the resolved token e..." | Re-trigger Greptile

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. The AES-256-GCM implementation is sound — per-message random IV, auth tag, AAD binding, length-checked base64/hex key parsing with a canonicalization guard, and errors that avoid interpolating secrets. Because encryptToken/decryptToken have no callers in this diff, the integrity concerns below are forward-looking design lock-ins (the envelope/AAD format becomes irreversible once rows exist) rather than concrete failures, and are graded accordingly.

🟡 P2 -- recommended

  • packages/api/src/utils/tokenEncryption.ts:231 -- selectDefaultProvider tests the raw key for truthiness while parseEncryptionKey trims first, so a whitespace-only key (e.g. a secret file read as $(cat file) yielding "\n") selects local, then getLocalKey() throws outside the try in verifyTokenEncryption and aborts boot with a misleading is not set message.
    • Fix: Return key?.trim() ? LOCAL_PROVIDER : NONE_PROVIDER so both functions share one definition of "unset" and a whitespace value degrades to plaintext like an empty string does.
    • correctness, adversarial, previous-comments
  • packages/api/src/utils/tokenEncryption.ts:178 -- The AAD is only v1:<provider>, so a v1:local: ciphertext authenticates in any row; an actor with DB write access but no key could relocate one record's encrypted token into another and have it decrypt, and the AAD cannot be tightened later without invalidating every stored row.
    • Fix: Thread an optional per-row context (owner/tenant/document id) into the encrypt/decrypt signature and fold it into the AAD before any caller is wired up.
    • security, adversarial, previous-comments
  • packages/api/src/utils/tokenEncryption.ts:139 -- With an encrypting provider active, decryptToken still dispatches v1:none: envelopes to the pass-through none provider, so a DB-write actor can inject a fully attacker-chosen token as v1:none:<value> and have the app decrypt and replay it with only a warning logged.
    • Fix: Gate acceptance of none envelopes behind an explicit one-shot migration flag once an encrypting provider is active, rather than honoring them unconditionally.
    • adversarial
  • packages/api/src/utils/__tests__/tokenEncryption.test.ts:265 -- Both verifyTokenEncryption tests assert only resolves.toBeUndefined(), but that path always resolves undefined, so deleting the success log, swallowing the error, or dropping the sentinel comparison keeps the suite green.
    • Fix: Spy on logger.info/logger.error and assert the verified and verification failed calls fire on the respective paths.
    • testing, previous-comments
  • packages/api/src/utils/tokenEncryption.ts:258 -- The suite only activates its own test-aes* providers, so getLocalKey, the local registration, and the malformed-key fail-fast branch are all uncovered, leaving the "a malformed key stops the API starting" behavior untested.
    • Fix: Add a test that activates local with a bad key via module re-require and asserts verifyTokenEncryption() rejects, plus a companion valid-key v1:local: round trip.
    • testing, previous-comments
  • packages/api/src/utils/tokenEncryption.ts:285 -- A non-parse verification failure for an encrypting provider is logged at error level and swallowed, after which readiness still flips healthy once Mongo connects, with no dedicated failure counter (only the disabled/plaintext counters exist), so a broken encryption subsystem surfaces only at first token use.
    • Fix: Emit an alertable failure metric on verify failure and/or reflect verification state in the readiness probe for must-encrypt deployments.
    • reliability
🔵 P3 nitpicks (4)
  • packages/api/src/utils/tokenEncryption.ts:217 -- Provider registration and active-provider resolution run as import-time side effects that read config.TOKEN_ENCRYPTION_KEY and mutate module-level state, coupling load order to config and forcing the suite to manage a shared singleton.
    • Fix: Move registration and active-provider selection into an explicit initTokenEncryption() called from server bootstrap.
    • maintainability, kieran-typescript
  • packages/api/src/utils/tokenEncryption.ts:276 -- The Promise.race timeout stops waiting but does not cancel the underlying work, so a future network-backed provider could emit an unhandled rejection after the timeout wins.
    • Fix: Attach a no-op .catch() to the raced work promise and/or thread an AbortSignal into the provider contract.
    • reliability
  • packages/api/src/utils/tokenEncryption.ts:110 -- isTokenEnvelope matches the loose grammar /^v\d+:[a-z0-9-]+:/, so a legitimate plaintext token shaped like v1:abc:... would be rejected by encryptToken or misparsed by decryptToken.
    • Fix: Restrict the double-encryption guard to the exact supported version and registered provider names.
    • security, adversarial
  • packages/api/src/utils/tokenEncryption.ts:159 -- The canonicalization guard compares against standard base64, so a valid 32-byte base64url key (-/_) decodes correctly but is then rejected.
    • Fix: Accept base64url in the round-trip comparison or document that only standard base64 and hex are supported.
    • kieran-typescript

Reviewers (8): correctness, testing, maintainability, security, reliability, adversarial, kieran-typescript, previous-comments.

Testing gaps:

  • No test for a whitespace-only TOKEN_ENCRYPTION_KEY (only undefined and "" are covered), which is the untested boundary behind the boot-crash finding.
  • No test exercises the VERIFY_TIMEOUT_MS race/timeout branch in verifyTokenEncryption.
  • No test relocates a valid v1:local: ciphertext to a different logical row to confirm the absence of cross-row AAD binding.

await expect(verifyTokenEncryption()).resolves.toBeUndefined();
});

it('passes with a working provider', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Both verifyTokenEncryption outcome tests assert only resolves.toBeUndefined(), so they cannot tell success from failure

verifyTokenEncryption always resolves to undefined on the non-none path (tokenEncryption.ts:257-286 catches everything), so it('passes with a working provider') at line 271 and it('does not throw when the active provider is broken') at line 265 assert the identical, unconditionally-true thing. The only observable output is the log line, so assert it: spy on logger.info and expect the 'Token encryption verified' call in the success test, and spy on logger.error and expect the 'verification failed' call in the broken-provider test (plus expect(error).not.toHaveBeenCalled() in the success one). As written, deleting the logger.info call, swallowing the error without logging, or dropping the decrypted !== VERIFY_SENTINEL check all keep the suite green.

);
return;
}
if (activeProviderName === LOCAL_PROVIDER) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — The local provider and the boot fail-fast branch — the only path that can stop the API starting — have no test

The suite registers its own test-aes* providers and never activates local, so getLocalKey() (line 199), the local registration (line 201), and the if (activeProviderName === LOCAL_PROVIDER) getLocalKey() fail-fast at line 254 are all uncovered; the PR's headline claim "a malformed key stops the API starting" is untested, and removing line 254-256 (or making parseEncryptionKey non-throwing) breaks nothing. Add a test that sets process.env.TOKEN_ENCRYPTION_KEY to a bad value inside jest.isolateModules/after jest.resetModules, re-requires the module, and expects verifyTokenEncryption() to reject — and a companion with a valid key asserting a v1:local: round trip.

name: string,
getKey: () => Buffer,
): TokenEncryptionProvider {
const aad = Buffer.from(`${ENVELOPE_VERSION}:${name}`, 'utf8');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Ciphertext is not bound to the row it belongs to, and adding that binding later invalidates every stored token

The AAD is only v1:<provider>, so any v1:local:… payload decrypts in any row: an attacker with Mongo write access (but no key) can copy team A's encrypted Slack token into team B's webhook document and the app will decrypt and replay it. Add an optional context argument now — encryptToken(plaintext, context?) / decryptToken(envelope, context?), with the context concatenated into the AAD alongside ${ENVELOPE_VERSION}:${name} so callers can pass e.g. webhook:<id> — because once rows exist, changing the AAD makes every stored ciphertext fail to authenticate, which makes this effectively a one-shot decision.

export function selectDefaultProvider(
key: string | undefined,
): BuiltInProviderName {
return key ? LOCAL_PROVIDER : NONE_PROVIDER;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — A whitespace-only TOKEN_ENCRYPTION_KEY selects local and then blocks boot with "is not set"

selectDefaultProvider tests key untrimmed while parseEncryptionKey (line 128) trims, so TOKEN_ENCRYPTION_KEY=$(cat /run/secrets/key) on an empty secret file (a lone \n) activates local, then getLocalKey() at line 255 throws TOKEN_ENCRYPTION_KEY is not set and index.ts exits 1 — a hard boot failure for what the module's own parser classifies as unset, contradicting "a missing key stays silent". Use return key?.trim() ? LOCAL_PROVIDER : NONE_PROVIDER; so both functions share one definition of unset.

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

PR Review

4 finding(s): 🔴 0 critical · 🟠 0 major · 🔵 4 minor

4 posted as inline comment(s) on the changed lines.


Severity is the reviewer's own estimate and is used for ordering, not filtering.

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 355 passed • 1 skipped • 1510s

Status Count
✅ Passed 355
❌ Failed 0
⚠️ Flaky 3
⏭️ Skipped 1

Tests ran across 4 shards in parallel.

View full report →

if (isTokenEnvelope(plaintext)) {
throw new Error('Value is already a token encryption envelope');
}
const provider = getProvider(activeProviderName);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The runtime shouldn't be changing besides on startup so it might make sense to even do provider at the app level instead of storing activeProviderName and needing to call getProivder everywhere.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call! I've updated this in the PR. Seemed easier than a follow-up

@brandon-pereira brandon-pereira left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - besides some non-blocking feedback on init

The active provider is set once at startup, so storing the resolved
provider instead of its name drops a lookup on every write and keeps a
late re-registration from swapping the implementation out from under it.

Reads still resolve by name: decryptToken follows the provider recorded
in each stored value, which varies per row.
Comment on lines +85 to +89
}
providers.set(provider.name, provider);
}

/** Selects the provider `encryptToken` writes with. Decryption is unaffected. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Replacement warning lacks metric

Replacing a registered provider emits a warning but does not record a corresponding metric. This violates the repository directive that countable log events must also emit metrics and prevents operators from alerting on or measuring replacements that may make existing envelopes unreadable. This repository requirement must be satisfied before merging.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

export function selectDefaultProvider(
key: string | undefined,
): BuiltInProviderName {
return key ? LOCAL_PROVIDER : NONE_PROVIDER;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — A whitespace-only TOKEN_ENCRYPTION_KEY selects local and then kills the boot with "is not set"

selectDefaultProvider tests the raw value for truthiness while parseEncryptionKey trims it, so TOKEN_ENCRYPTION_KEY="\n" (a secret file/kubectl create secret --from-file that carries a newline — the exact case the tolerates surrounding whitespace from a piped secret test anticipates) picks local, then verifyTokenEncryptiongetLocalKey() throws TOKEN_ENCRYPTION_KEY is not set and index.ts:38 exits the process. Trim before the check: return key?.trim() ? LOCAL_PROVIDER : NONE_PROVIDER;, and add a selectDefaultProvider(' ') case next to the '' one.

});

describe('verifyTokenEncryption', () => {
it('does not throw when the active provider is broken', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — The verifyTokenEncryption tests pass even if the function does nothing

does not throw when the active provider is broken and passes with a working provider only assert resolves.toBeUndefined(), so replacing the whole body with return; keeps both green — neither asserts the logger.error/logger.info that is the entire output of the check. Assert logger.error was called (with provider: 'broken') on the failure path and logger.info on the success path, and add the one behaviour the PR description headlines — a malformed key makes verifyTokenEncryption() reject, so Server.start() fails — which currently has no test at all (mock @/config under jest.isolateModules to reach the local branch).

);
return;
}
if (activeProvider.name === LOCAL_PROVIDER) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Only the built-in local provider gets a fatal config check; a plugged-in provider's misconfiguration is swallowed

if (activeProvider.name === LOCAL_PROVIDER) getLocalKey(); is what makes a bad key fatal; every other provider's configuration error (bad KMS key ARN, wrong region) is raised inside the try and only logged, so the process boots "healthy" and fails on the first token write — which undercuts the "a new file and one registration call" pluggability claim. Move the check onto the interface as an optional verify?(): Promise<void> (or a preflight that is allowed to throw) that verifyTokenEncryption awaits outside the try, so any provider can declare a fatal misconfiguration instead of hardcoding one name here.


// Checked before Mongo so a bad encryption key or KMS policy is reported
// even while the Mongo connect below is still retrying.
await verifyTokenEncryption();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — The scheduled-task process, which is the one that will read stored Slack tokens, never runs the startup check

verifyTokenEncryption() is wired only into Server.start(); packages/api/src/tasks/index.ts is a separate entrypoint (the deployment split behind RUN_SCHEDULED_TASKS_EXTERNALLY) and it is where Slack notifications are sent from (packages/api/src/tasks/checkAlerts/transports/slack.ts), so a TOKEN_ENCRYPTION_KEY that is missing or malformed in the task container passes unnoticed until an alert fires. Await verifyTokenEncryption() in the task entrypoint before instrumentedMain(argv) as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/tier-4 Critical — deep review + domain expert sign-off

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants