Repository navigation
feat(dgw): add an encrypted in-memory secret store - #2058
irvingouj@Devolutions (irvingoujAtDevolution) wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Caller-controlled timestamps can bypass task-secret expiry enforcement.
1 open finding
What changed in this PR
Adds encrypted, in-memory storage for provisioner task secrets.
Changes:
- Adds task-secret insertion, retrieval, removal, expiry, and cleanup.
- Exposes credential encryption helpers for arbitrary secrets.
- Adds lifecycle and isolation tests.
| File | Description |
|---|---|
devolutions-gateway/src/provisioning.rs |
Implements the task-secret store and tests. |
devolutions-gateway/src/credential/mod.rs |
Adds secret encryption and decryption helpers. |
🧠 Review effort: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
f0c996e to
e5fd7ff
Compare
1fcf695 to
da6b63b
Compare
001325b to
25c06b4
Compare
77d2604 to
f1efa30
Compare
f1efa30 to
bad34c1
Compare
The provisioning store gains a third kind of provisioned material, next to the injected credentials and the connection options: the secrets of provisioner tasks, such as the API key of an AI analysis. A provisioner task needs them while it runs, but they must never be written to disk. Task secrets are keyed by Task ID and follow their own policy, as the credential intent asks for each kind of provisioning: a new secret replaces the old one, and a secret goes away when its Task ends or at its expiry, through the existing cleanup task. Like the injected credentials, they are encrypted on the way in with the in-memory master key, and decrypted into a zeroizing value only when read. The store treats a secret as an opaque string, so each task decides what it holds. The session halves are unchanged.
task_secret took the current time from its caller, so a caller passing an earlier time could still read an expired secret. It now reads the clock itself.
Keep the existing `ProvisioningStore` paragraphs as they were and only add the task-secrets paragraph, and describe the task secret methods by what they guarantee rather than by what the AI analysis caller stores. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The store keeps an entry until it is removed or expires; removing it when the Task ends is the caller's job. - `insert_task_secret` takes the secret in clear and encrypts it. - The tests are named after what they check: the Debug output never shows the secret, and the cleanup pass, not the cleanup task, removes it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bfb3261 to
40bd3da
Compare
There was a problem hiding this comment.
Thank you Irving.
- I have doubts about this change being in
ProvisioningStoreat all, becauseProvisioningStorewas intended to be for keyed by session ID (JTI), and this PR is adding task ID keyed data. - Also, I think the new code is over-fitting the specific use case you had in mind. It seems to me that the mechanism itself does not need to be task-specific; almost nothing in
TaskSecretEntryis specific enough to be a task thing. The mechanism also sounds generally useful enough that we may use it again in the future.
Here is the AI-powered review based on these observations:
1. Should this live in
ProvisioningStoreat all?
credential/INTENT.mddefines provisioning around a logical session: material keyed by the association-token JTI, staged then checked out, and ejected once the session can no longer connect or reconnect. Decision 2's "per-kind policy", quoted in the description, is about kinds of provisioning for the same JTI.These secrets are keyed by a non-JTI ID, aren't tied to a session or connection, and have a caller-managed lifecycle. As
task_secrets_are_independent_from_session_provisioningshows, they share no state or rules with the session halves, only the master key and the expiry cleanup. Adding them here turnsProvisioningStorefrom "what a session needs" into "anything kept in memory with an expiry". INTENT.md would then no longer describe everything the store does.I'd rather put this in a small standalone store (e.g.
SecretStore) that:
- reuses
credential::encrypt_secret/decrypt_secret(that part is fine as is)- has its own expiry cleanup, or shares a generic expiring-map helper with
ProvisioningStore- is owned by the #2044 caller, which already manages the task's lifecycle
If these secrets are meant to be tied to a session, please say so, and then keying by JTI here would make sense.
2. Name it for its lifecycle, not its first consumer
What this adds is generic: an opaque, in-memory-only secret, keyed by an ID, replaced on re-insert, removed explicitly or at expiry. The store never relies on anything task-specific. "Task" also collides with
devolutions_gateway_task::Task, whichprovisioning.rsimports, and which the store's own cleanup runs as.
insert_task_secret/task_secret/remove_task_secret→insert/get/removeon the new store (orinsert_secret/secret/remove_secretif it stays here)TaskSecretEntry→SecretEntry- Key it with a newtype (e.g.
SecretId) rather than a bareUuid, so callers say which ID space they're using and IDs can't be confused with JTIs.- Nit:
encrypt_secret/decrypt_secretreturnEncryptedPassword, so an API key ends up stored as a "password". AnEncryptedSecretalias or rename would match the new entry points.3. Keep the AI analysis out of the store's docs
The store docs should describe the policy only, for example:
/// Opaque secrets that must stay in memory only and never be written to disk. /// Each is keyed by a caller-chosen ID, encrypted with the in-memory master key, replaced on /// re-insert, and kept until it is removed or reaches its expiry.Why the AI analysis needs it, and what it puts in the secret, belongs at the call site in #2044. The tests can use neutral values too (
"secret-a"rather than"sk-…").
I think SecretId could be namespaced if reusing the same store later for another feature is a concern.
The provisioning store is keyed by the association-token JTI and follows the lifecycle of a session. These secrets have neither: a caller picks their ID and decides when they go. A separate store keeps the provisioning store about sessions only. The new store is generic: an opaque secret, encrypted with the in-memory master key, keyed by a `SecretId`, replaced on re-insert, and removed explicitly or at its expiry by its own cleanup task. `EncryptedSecret` names the encrypted value for what it holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Done in bfd3ecc. It's a standalone Note Human-tuned, LLM-assisted content. |

Adds
SecretStore, a place for secrets that must stay in memory and never hit the disk, like the API key of an AI analysis (#2044).SecretId, not a JTI, so it stays out ofProvisioningStore, which is about sessionsSecretStringonly on readcredentialgetsencrypt_secret/decrypt_secretover the existing master key, plus anEncryptedSecretalias forEncryptedPassword.🤖 Generated with Claude Code