Skip to content

[Security] HKDF info-parameter is static across deployments #108

Description

@pathosDev

Severity / Size

  • Severity: MEDIUM — operator-error finding rather than a code defect. Damage requires a misconfiguration (same master key reused across environments) AND the threat actor must obtain one environment's key. The fix raises the cost of that mistake by making cross-environment subkey-collision impossible.
  • Size: S (~1d).
  • Threat model: insider / leaked-credential scenario where a key from staging or dev is exposed and the operator has reused the same masterKey for production. Today: same info string + same master key + same pid → same subkey → staging key decrypts production bodies.

Affected files

  • src/persistence/object-storage/Encryption.ts:54-79deriveSubkey(masterKey, persistenceId, info='actor-ts/snapshot/v1'). The info is a free parameter but defaults to a single static literal across every deployment.
  • src/persistence/object-storage/PluginConfig.ts — where EncryptionConfig.info flows in from user config; today there's no guidance to set it per-deployment.

Background

HKDF (RFC 5869) takes three inputs: input-keying-material (the master key), salt (we use persistenceId), and info (a context-binding string). Different info values produce independent subkeys from the same input key. The framework's default 'actor-ts/snapshot/v1' is fine for one deployment but identical across all of them, so:

  • staging encrypts pid: 'user-42' with subkey = HKDF(stagingKey, salt=user-42, info=actor-ts/snapshot/v1)
  • production encrypts pid: 'user-42' with subkey = HKDF(prodKey, salt=user-42, info=actor-ts/snapshot/v1)

If staging and production accidentally share masterKey, the subkey is identical — staging operators (or anyone with the staging key) can read production snapshots.

The info parameter is the standard HKDF defense: bind a derived key to its usage context. RFC 5869 calls this "context and application specific information".

Exploit walkthrough

Setup: organisation operates staging and production clusters. The platform team accidentally reuses the same masterKey: 0xAA... across both, intending the per-deployment HKDF info to provide separation — except no one set info so it defaults to 'actor-ts/snapshot/v1' on both.

Step 1 — staging compromise: a dev's laptop is breached; the staging masterKey is on it. Attacker also pulls a snapshot blob from production (separate breach — the bucket is differently-permissioned).

Step 2 — attacker derives the subkey: subkey = HKDF(stagingKey, salt=<pid-from-blob>, info='actor-ts/snapshot/v1').

Step 3 — attacker decrypts: AES-GCM with that subkey on the production blob → plaintext.

Today there is nothing stopping the operator from this mistake; the framework's info default is a single string. The defense costs us nothing except API surface.

How the 8 already-landed security fixes inform this

  • Re-encryption sweep (Re-encryption sweep helper for master-key rotation #70): established that key-versioning + rotation is a first-class concept. The info parameter sits next to it conceptually — both bind subkeys to a context. This finding generalises the same idea to "deployment context".
  • MemcachedCache CRLF block (65a2bc5): used a assertSafe... validator at the API boundary. Similar pattern here: validate info is set + meaningful at config-load.

Fix design

Two-track.

Track 1 — encourage explicit info via documentation + validation.

Make info a required field in EncryptionConfig when client-side encryption is selected:

export type EncryptionConfig =
  | { mode: 'none' }
  | { mode: 'sse-s3' /* ... */ }
  | { mode: 'client-aes256-gcm';
      masterKey | masterKeys: ...;
      info: string;            // ← now required (was optional with default)
    };

The default 'actor-ts/snapshot/v1' literal is removed from deriveSubkey(); the call site is required to pass it. Plugin-config validation refuses to register an encrypting plugin without an explicit info.

Track 2 — recommend a deployment-context prefix.

Documentation + JSDoc on EncryptionConfig.info:

Bind the derived subkey to a deployment-context string. Recommended: include environment + cluster name, e.g. 'mycorp/prod/actors-cluster-a/snapshot/v1'. Two deployments that share the same masterKey but use different info strings produce independent subkeys; a key leak in one environment cannot decrypt blobs from another.

Track 3 — migration helper (parallel to #109's sweep).

For deployments already running with the static default, provide a helper to re-derive subkeys under a new info and rewrite the corpus. Pattern matches reEncryptObjectStorage but with the info-rotation knob:

await reKeyObjectStorageInfo(backend, {
  keyPrefix: 'snapshots/',
  keyring,
  oldInfo: 'actor-ts/snapshot/v1',
  newInfo: 'mycorp/prod/snapshot/v1',
});

This is optional — operators on existing static-info corpora can either accept the legacy state or run the migration.

API surface

// src/persistence/PersistenceOptions.ts
// EncryptionConfig.info becomes required (breaking change — see Backward compat).

// src/persistence/object-storage/infoRotationSweep.ts (new file)
export async function reKeyObjectStorageInfo(
  backend: ObjectStorageBackend,
  opts: {
    keyPrefix: string;
    keyring: MasterKeyRing;
    oldInfo: string;
    newInfo: string;
    onProgress?: (...) => void;
  },
): Promise<{ scanned: number; rewrote: number }>;

Backward compatibility

Breaking: info was optional with a default; making it required will fail existing user code at compile-time. Two mitigation paths:

  • Soft-deprecation: keep the optional info with a warn-on-default-use that logs once at plugin-init. Deprecate the default in a follow-up minor version.
  • Hard cut: ship as part of a version bump (we're pre-1.0, so allowed).

Recommend the soft-deprecation path for v0.x: log warn, document the recommendation in the rolling-migration doc.

Test plan

  1. Exploit-equivalence test (tests/unit/persistence/object-storage/HkdfInfo.test.ts): derive subkeys from two (masterKey, pid) pairs with the same vs different info; assert different info → different subkeys → ciphertext from one cannot decrypt with the other.

  2. Warn-on-default test: register an encrypting plugin without info; verify a log warning is emitted on init.

  3. Migration test: write 10 records under oldInfo; run reKeyObjectStorageInfo to newInfo; verify all 10 decrypt under the new config and fail under the old.

  4. Regression: existing KeyRotation.test.ts + ObjectStorageSnapshotStore.test.ts pass (with explicit info in their fixtures).

Acceptance criteria

  • EncryptionConfig.info documented as deployment-context-binding; warn-on-default at plugin-init.
  • reKeyObjectStorageInfo helper exposed + tested.
  • docs/operations/rolling-migration.md adds an info-rotation section.
  • Three new tests pass; existing crypto tests still green.
  • Plan-doc + README "Known security caveats" updated on land.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: mediumUseful, not urgentsecuritySecurity-relevant — see severity label for impact tierseverity: mediumModerate impact or requires specific conditions

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions