Skip to content

feat(security): prototype — trusted secrets store + config-declared scope.secrets#509

Draft
dawsontoth wants to merge 4 commits into
feat/env-secret-encryptionfrom
feat/env-secret-key-custody
Draft

feat(security): prototype — trusted secrets store + config-declared scope.secrets#509
dawsontoth wants to merge 4 commits into
feat/env-secret-encryptionfrom
feat/env-secret-key-custody

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Prototype for discussion, stacked on #505. Secrets for components where authority lives in a trusted store Harper controls — never in a component's own config — hardening the private key and env vars against a compromised plugin / SSR JS-RCE.

What changed since the first cut (and why)

The first version used a component's own config.yaml secrets: block as the allow-list. That's not a control: the component (and whoever deploys its config) controls that file, so it authorizes itself. Authority moved to a store the component can't write.

I also grounded this in how Harper actually isolates code (core: server/threads/manageThreads.js, security/jsLoader.ts):

  • One process, worker threads; components run in node:vm contexts hardened by SES / frozen intrinsics — not isolated-vm, not separate processes. So a separate broker process buys ~nothing against a JS-level compromise; keeping the key in host scope is what isolates it. (Broker dropped; KMS kept only as an optional off-box tier.)
  • process is reachable in the sandbox and loadEnv writes decrypted values to process.env, so that path is ambient. scope.secrets never populates process.env.

The authority: system.hdb_secret (trusted, replicated)

A table in Harper's system database — the same replicated store behind hdb_user/hdb_role, so grants + encrypted values are consistent cluster-wide and survive an instance being down. Super_user-writable only.

field meaning
name (PK) cluster-wide secret name
value enc:v1: ciphertext (decryptable only by the host-scope key)
grants components authorized to read it — the authority
metadata description, updatedBy, updatedAt

Operator ops (ciphertext + metadata only; plaintext never passes through the server): set_secret, grant_secret, revoke_secret, list_secrets.

Identity: asserted by Harper, not the component

Grants key off the component name the loader stamps (new ApplicationScope(basename(componentDirectory), …)), so a component can't claim another's identity. Caveat, stated honestly: trust in the name is only as strong as who can deploy under it — operator-controlled in managed/Fabric, a deploy-time assumption on self-hosted.

Resolution

scope.secrets.get('X') from component C → look up X → allow only if C ∈ X.grants (else throw, pointing at grant_secret) → decrypt X.value on the trusted side via KeyCustody → return plaintext. Never process.env; key never enters the sandbox; C can't read another component's secret.

The manifest: non-authoritative

A component may still declare needs in config.yaml — but only as a request:

secrets:
  DATABASE_URL: { required: true, description: Postgres DSN }
  DEBUG_WEBHOOK_URL: { required: false }
# or shorthand:  secrets: [DATABASE_URL, STRIPE_API_KEY]

Used only for ensureRequired() fail-fast (distinguishing not granted from granted but unset) and operator UX. It can never widen access beyond the store's grants — there's a test for exactly that.

Changes

  • security/secretsStore.ts (new) — SecretsStore + InMemorySecretsStore (tested), isGranted, operator ops, and a TableSecretsStore sketch binding to system.hdb_secret.
  • security/secretsAccessor.ts — authority from the store keyed by the Harper-asserted componentName; has/list reflect grants; ensureRequired distinguishes not-granted vs granted-unset; manifest advisory only.
  • security/envSecrets.tscreateScopeSecrets(componentName, store, {manifest}).
  • security/secretsConfig.ts — re-documented as a non-authoritative manifest.
  • security/keyCustody.ts — host-scope framing; broker removed; KMS demoted to optional off-box tier.
  • docs/component-secrets.md — full design.
  • Tests — 36 passing, incl. "a component cannot widen access via its own manifest".

Threat model, honestly

  • Defends: compromised plugin / SSR JS-RCE reading un-granted secrets, scraping process.env, or self-granting via config. No extra process needed.
  • Does not (by itself): host-root / native RCE reading raw memory (→ KMS/HSM or cluster threshold key-shares, optional tiers behind the same interface); a hostile deploy assuming a granted component's name (→ deploy-time trust).

Not wired into core yet (deliberately)

  • Declare system.hdb_secret in core (super_user-writable, replicated); bind TableSecretsStore.
  • Register set_secret/grant_secret/revoke_secret/list_secrets (handlers delegate to the store fns).
  • core loader: build the accessor with the loader-asserted componentName + shared store, bind to per-component scope.secrets (+ harper secrets export), pass the manifest, call ensureRequired() at load.

Related

🤖 Generated with Claude Code

Explores hardening the env-secret private key and env vars against the two
attack surfaces raised in review of #505: a compromised control-plane node, and
compromised customer code (a malicious plugin, or an RCE in e.g. Next SSR).

Two ideas, both leaning on the fact that Harper owns the sandbox boundary:

1. KeyCustody (security/keyCustody.ts) — an interface that exposes only an
   `unwrapKey` operation, never the private key. Backends:
     - FileKeyCustody: key on disk, loaded into the trusted Pro process (never
       into sandboxed component/plugin code). Default; refactored out of the
       existing envSecrets logic. Keeps a sync path for loadEnv → process.env.
     - KmsKeyCustody (sketch): key lives in AWS KMS and never enters the
       process; `unwrapKey` is a KMS.Decrypt (auditable, rate-limitable,
       revocable). Turns key theft into bounded oracle access.
   Same interface leaves room for PKCS#11/TPM later.

2. Per-component secrets accessor (security/secretsAccessor.ts) — the mediated
   `scope.secrets.get(name)` surface. A component may read only the secret names
   it declared; decryption happens on the trusted side via KeyCustody, so the
   key never crosses into sandboxed code and there's no ambient process.env to
   scrape. Least-authority + key isolation.

envSecretCrypto.ts is split into reusable primitives (parseEnvelope /
unwrapWithPrivateKey / openEnvelope) so both the file fast-path and the
custody-agnostic async path share one implementation. envSecrets.ts is rewired
onto KeyCustody with the loadEnv/process.env compat path preserved (file
backend) and createScopeSecrets() added for the accessor path.

Prototype for team discussion — not wired into core/scope yet (see the
INTEGRATION note in envSecrets.ts). 18 unit tests cover the crypto round-trips,
custody load/deny paths, key non-exposure, and the accessor allow-list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

Comment thread security/envSecrets.ts Outdated
@claude

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

Reframes the custody prototype around how Harper actually isolates customer
code, and makes the customer-facing `secrets:` config the centerpiece.

Harper runs component/plugin code in node:vm contexts (hardened by SES / frozen
intrinsics) inside a worker thread — not in separate processes, and not with
isolated-vm. The boundary that contains a compromised plugin or an SSR JS-RCE
is therefore reference-reachability inside the isolate, governed by the curated
globals + `harper` exports Harper hands the context. Two consequences:

  - A separate broker *process* buys ~nothing against that (JS-level) threat and
    can't beat host-root either, so it's dropped. Keeping the key in HOST SCOPE —
    off the sandbox global and the `harper` module — is what isolates it, for
    free. KMS stays only as an optional off-box tier for the host-root/native-RCE
    threat, behind the same interface.
  - The real leak is `process.env`: `process` is reachable in the sandbox and
    loadEnv writes decrypted values there, so encryption is undone at runtime.
    scope.secrets resolves on demand and never populates process.env.

Customer-facing config (the crux): a component declares what it consumes in
`config.yaml`, and that declaration IS the authorization list — same names to
use a secret and to be granted it.

  secrets:
    DATABASE_URL: { required: true, description: Postgres DSN }
    DEBUG_WEBHOOK_URL: { required: false }
  # or shorthand:  secrets: [DATABASE_URL, STRIPE_API_KEY]

Changes:
  - security/secretsConfig.ts (new): parseSecretsConfig — list + object forms,
    required (default true), description, name validation, dedupe.
  - security/secretsAccessor.ts: declarations instead of bare names; adds
    describe() (operator tooling) and ensureRequired() (fail-loud at load).
  - security/envSecrets.ts: createScopeSecrets takes declarations + two-tier
    sources (cluster secret overrides app .env); header + INTEGRATION notes
    rewritten around host-scope key and the loader binding.
  - security/keyCustody.ts: header reframed (host scope; File is the mechanism;
    KMS demoted to optional off-box tier); removed the broker-process backend.
  - docs/component-secrets.md (new): stipulates the config surface and why the
    declaration is, by construction, the protection.
  - tests: secretsConfig + secretsAccessor split out and expanded (28 passing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dawsontoth dawsontoth changed the title feat(security): prototype — KeyCustody + per-component secrets accessor feat(security): prototype — host-scope key custody + config-declared scope.secrets Jul 1, 2026
Comment thread security/envSecrets.ts Outdated
Comment thread security/secretsAccessor.ts Outdated
Fixes the self-granting hole: the previous cut used a component's own
`config.yaml` `secrets:` block as the allow-list, but the component (and whoever
deploys its config) controls that file — so it authorized itself. Authority now
lives in a store the component can't write.

Model (mirrors how Harper already replicates hdb_user/hdb_role):

  - system.hdb_secret — a table in the replicated `system` database:
      name (PK) · value (enc:v1 ciphertext) · grants[] (authorized components) ·
      description/updatedBy/updatedAt. Writable only by super_user.
  - Identity is Harper-asserted: grants key off the component name the loader
    stamps (basename(componentDirectory)); a component can't claim another's name.
  - Resolution: scope.secrets.get('X') from component C → look up X → allow only
    if C ∈ X.grants → decrypt X.value on the trusted side via KeyCustody. Never
    touches process.env; key never enters the sandbox.
  - The component's `secrets:` config is now a NON-AUTHORITATIVE manifest: it
    states needs (for ensureRequired fail-fast + operator UX) and can never widen
    access beyond the store's grants.

Changes:
  - security/secretsStore.ts (new): SecretsStore interface, InMemorySecretsStore
    (tested), isGranted authority check, operator ops (setSecret/grantSecret/
    revokeSecret/listSecrets — ciphertext + metadata only, never plaintext), and
    a TableSecretsStore sketch binding to system.hdb_secret.
  - security/secretsAccessor.ts: authority from the store keyed by the
    Harper-asserted componentName; has/list now reflect grants; ensureRequired
    distinguishes not-granted from granted-but-unset; manifest is advisory only.
  - security/envSecrets.ts: createScopeSecrets(componentName, store, {manifest}).
  - security/secretsConfig.ts: re-documented as a non-authoritative manifest.
  - docs/component-secrets.md: rewritten around the trusted store + grants.
  - tests: secretsStore + store-backed accessor (36 passing) incl. "a component
    cannot widen access via its own manifest".

Chosen with the user: keep the manifest (non-authoritative), embedded grants
array on each record, component-name identity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dawsontoth dawsontoth changed the title feat(security): prototype — host-scope key custody + config-declared scope.secrets feat(security): prototype — trusted secrets store + config-declared scope.secrets Jul 1, 2026
…leton getter)

Addresses review on #509: the `generate` flag flowed through the `getCustody`
singleton getter, so it was silently dropped once `custody` was set. If exported
`createScopeSecrets` (or any accessor path) ran before `startOnMainThread`,
`custody` locked to generate=false and the leader's later `fileCustody(true)`
hit the `if (!custody)` guard and returned the non-generating instance — on a
fresh node, the shared key would never be generated.

Fix: key generation is no longer a construction/getter concern. FileKeyCustody
is load-only by default (accessors throw if the key is absent); the leader calls
an explicit, idempotent `ensureKey()` in startOnMainThread. Dropped `generate`
from getCustody/fileCustody/getKeyCustody/KeyCustodyConfig. Ordering can no
longer skip key creation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread security/keyCustody.ts
Comment on lines +146 to +150
async #kms(): Promise<any> {
const pkg = '@aws-sdk/client-kms';
const { KMSClient } = await import(pkg);
return new KMSClient({ region: this.#region });
}

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.

Suggestion (non-blocking): Cache the KMSClient instance rather than constructing one per call. unwrapKey is called on every scope.secrets.get(), so each resolution currently opens a fresh TCP+TLS connection to KMS. Cache as a field:

Suggested change
async #kms(): Promise<any> {
const pkg = '@aws-sdk/client-kms';
const { KMSClient } = await import(pkg);
return new KMSClient({ region: this.#region });
}
#client?: any;
async #kms(): Promise<any> {
if (this.#client) return this.#client;
const pkg = '@aws-sdk/client-kms';
const { KMSClient } = await import(pkg);
this.#client = new KMSClient({ region: this.#region });
return this.#client;
}

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant