Skip to content

feat(dgw): add an encrypted in-memory secret store - #2058

Open
irvingouj@Devolutions (irvingoujAtDevolution) wants to merge 6 commits into
masterfrom
feat/provisioning-task-secrets
Open

irvingouj@Devolutions (irvingoujAtDevolution) wants to merge 6 commits into
masterfrom
feat/provisioning-task-secrets

Conversation

@irvingoujAtDevolution

@irvingoujAtDevolution irvingouj@Devolutions (irvingoujAtDevolution) commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

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).

  • keyed by a caller-picked SecretId, not a JTI, so it stays out of ProvisioningStore, which is about sessions
  • encrypted with the same in-memory master key as injected credentials, and decrypted into a zeroizing SecretString only on read
  • a new secret under the same ID replaces the old one
  • gone when the caller removes it, or at its expiry through the store's own cleanup task
  • opaque: the caller decides what the secret holds

credential gets encrypt_secret / decrypt_secret over the existing master key, plus an EncryptedSecret alias for EncryptedPassword.

🤖 Generated with Claude Code

Copilot AI left a comment

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.

🟡 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.

Comment thread devolutions-gateway/src/provisioning.rs Outdated
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>

Copilot AI left a comment

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.

🟢 Approval recommended

The scoped changes reuse established encryption and cleanup mechanisms, with no unresolved findings.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@CBenoit Benoît Cortier (CBenoit) 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.

Thank you Irving.

  • I have doubts about this change being in ProvisioningStore at all, because ProvisioningStore was 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 TaskSecretEntry is 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 ProvisioningStore at all?

credential/INTENT.md defines 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_provisioning shows, they share no state or rules with the session halves, only the master key and the expiry cleanup. Adding them here turns ProvisioningStore from "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, which provisioning.rs imports, and which the store's own cleanup runs as.

  • insert_task_secret / task_secret / remove_task_secret → insert / get / remove on the new store (or insert_secret / secret / remove_secret if it stays here)
  • TaskSecretEntry → SecretEntry
  • Key it with a newtype (e.g. SecretId) rather than a bare Uuid, so callers say which ID space they're using and IDs can't be confused with JTIs.
  • Nit: encrypt_secret / decrypt_secret return EncryptedPassword, so an API key ends up stored as a "password". An EncryptedSecret alias 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>
@irvingoujAtDevolution irvingouj@Devolutions (irvingoujAtDevolution) changed the title feat(dgw): keep provisioner task secrets in the provisioning store feat(dgw): add an encrypted in-memory secret store Oct 21, 2026
@irvingoujAtDevolution

Copy link
Copy Markdown
Contributor Author

Done in bfd3ecc. It's a standalone SecretStore now, keyed by a SecretId newtype, with insert/get/remove, its own cleanup task, and docs that only state the policy. I went with an EncryptedSecret alias instead of renaming EncryptedPassword, to keep this PR small. No namespace on SecretId for now: each user gets its own store. #2044 keeps the AI access in it now (a10bbd6).

Note

Human-tuned, LLM-assisted content.

This branch has not been deployed

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants