You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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-79 — deriveSubkey(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 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:
exporttypeEncryptionConfig=|{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:
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
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.
Warn-on-default test: register an encrypting plugin without info; verify a log warning is emitted on init.
Migration test: write 10 records under oldInfo; run reKeyObjectStorageInfo to newInfo; verify all 10 decrypt under the new config and fail under the old.
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.
Severity / Size
masterKeyfor production. Today: sameinfostring + same master key + same pid → same subkey → staging key decrypts production bodies.Affected files
src/persistence/object-storage/Encryption.ts:54-79—deriveSubkey(masterKey, persistenceId, info='actor-ts/snapshot/v1'). Theinfois a free parameter but defaults to a single static literal across every deployment.src/persistence/object-storage/PluginConfig.ts— whereEncryptionConfig.infoflows 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), andinfo(a context-binding string). Differentinfovalues 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:pid: 'user-42'withsubkey = HKDF(stagingKey, salt=user-42, info=actor-ts/snapshot/v1)pid: 'user-42'withsubkey = 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
infoparameter 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
stagingandproductionclusters. The platform team accidentally reuses the samemasterKey: 0xAA...across both, intending the per-deployment HKDF info to provide separation — except no one setinfoso it defaults to'actor-ts/snapshot/v1'on both.Step 1 — staging compromise: a dev's laptop is breached; the staging
masterKeyis 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
infodefault is a single string. The defense costs us nothing except API surface.How the 8 already-landed security fixes inform this
infoparameter sits next to it conceptually — both bind subkeys to a context. This finding generalises the same idea to "deployment context".65a2bc5): used aassertSafe...validator at the API boundary. Similar pattern here: validateinfois set + meaningful at config-load.Fix design
Two-track.
Track 1 — encourage explicit
infovia documentation + validation.Make
infoa required field inEncryptionConfigwhen client-side encryption is selected:The default
'actor-ts/snapshot/v1'literal is removed fromderiveSubkey(); the call site is required to pass it. Plugin-config validation refuses to register an encrypting plugin without an explicitinfo.Track 2 — recommend a deployment-context prefix.
Documentation + JSDoc on
EncryptionConfig.info: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
infoand rewrite the corpus. Pattern matchesreEncryptObjectStoragebut with the info-rotation knob:This is optional — operators on existing static-info corpora can either accept the legacy state or run the migration.
API surface
Backward compatibility
Breaking:
infowas optional with a default; making it required will fail existing user code at compile-time. Two mitigation paths:infowith a warn-on-default-use that logs once at plugin-init. Deprecate the default in a follow-up minor version.Recommend the soft-deprecation path for v0.x: log warn, document the recommendation in the rolling-migration doc.
Test plan
Exploit-equivalence test (
tests/unit/persistence/object-storage/HkdfInfo.test.ts): derive subkeys from two(masterKey, pid)pairs with the same vs differentinfo; assert differentinfo→ different subkeys → ciphertext from one cannot decrypt with the other.Warn-on-default test: register an encrypting plugin without
info; verify a log warning is emitted on init.Migration test: write 10 records under
oldInfo; runreKeyObjectStorageInfotonewInfo; verify all 10 decrypt under the new config and fail under the old.Regression: existing
KeyRotation.test.ts+ObjectStorageSnapshotStore.test.tspass (with explicitinfoin their fixtures).Acceptance criteria
EncryptionConfig.infodocumented as deployment-context-binding; warn-on-default at plugin-init.reKeyObjectStorageInfohelper exposed + tested.docs/operations/rolling-migration.mdadds aninfo-rotation section.