Skip to content

Coordinate single-use, metered, and interactive credential acquisition across processes #1047

Description

@theoephraim

Several plugins, and varlock's own local encryption, acquire something that is single-use, metered, or user-visible before secrets can be read: an interactive unlock, a login that mints a server-side token, a one-time code. Today each varlock process does that acquisition on its own, and a failed acquisition is retried independently by each of them.

Ordinary secret reads do not have this problem. They are idempotent and cheap to retry. The distinction worth drawing is whether redoing the work costs something: a second fingerprint prompt, a second token against a quota, a second attempt with a code the provider has already rejected.

getOrSet already holds a cross-process lock around its producer (withDirLock in packages/varlock/src/lib/cache/cache-store.ts), and since #1068 it can take a TTL derived from the produced value, which is what a server-decided lifetime needs. The primitives are in place; what is missing is using them for auth acquisition, and deciding what happens when the acquisition fails.

Cases

Interactive unlock

varlock's own local encryption is probably the most widely hit, since it is a core feature rather than a plugin opt-in. packages/varlock/src/lib/local-encrypt/builtin-resolver.ts:16-21 batches concurrent varlock() prompts and biometric decrypts into one queue and rejects the rest of the batch when the user cancels. That is a good model for what this issue asks for, and it is in-process only.

1Password documents the same problem at packages/plugins/1password/src/plugin.ts:87-98: without coordination "in a big project this is super awkward because you may need to scan your finger over and over again." Its workaround is a deferred-promise mutex, also in-process, so a Turborepo build or several dev servers can still produce a prompt each. The same comment notes "We don't currently do anything special to handle if the user denies the login" - that denial is the failure case below.

Bitwarden, Dashlane, KeePass have master-password or biometric unlock with the same shape.

Logins that mint a token with a server-decided lifetime

Each of these caches a token with an expiry on the plugin instance, in memory, so every process logs in again:

  • packages/plugins/hashicorp-vault/src/plugin.ts:126,208-215 - client_token with lease_duration. Vault meters tokens and leases.
  • packages/plugins/akeyless/src/plugin.ts:137,194 - cached token with expiresIn from the response.
  • packages/plugins/azure-key-vault/src/plugin.ts:37-44 - access_token with expires_in.
  • packages/plugins/infisical/src/plugin.ts:123 - OIDC login returning an accessToken.

A lifetime the server decides and you only learn from the response is exactly the shape #1068's TTL callback was added for.

Single-use codes

AWS MFA (origin of this issue). An AssumeRole with a single-use TOTP code. Built in #1044, now closed and parked with no confirmed user. That branch coordinates the success path through getOrSet but its failure cooldown is per-instance, which is what first surfaced the general problem.

Note the core generateOtp() resolver makes this reachable outside any plugin: a code fed to a CLI via exec() is spent the same way.

The failure path is the subtle part

Do not coordinate failures with a blanket "someone failed, everyone hold off" marker. If the first attempt failed for a transient reason and never reached the provider, nothing was consumed and the next caller would have succeeded; blocking it converts a blip into a failure across a whole parallel build.

Hold others off only when the provider actually responded rejecting the attempt - a code it says is invalid or already used, a denied unlock - because then a retry with the same input fails too. Leave transient and network errors free to retry.

Constraints on any solution

  • A shared store is required. Cross-process coordination rides on the disk cache. Under @cache=memory, @cache=disabled, or --skip-cache there is no shared store and each process is on its own. The auto policy also selects memory in CI and when local encryption is on its file fallback, so the coordination is unavailable in exactly those environments. Whatever is built has to degrade to today's behavior there rather than assume the cache is present.
  • Possible circularity, worth checking early. The disk cache decrypts through the same local-encryption backend as biometric decrypts (cache-store.ts:352-354). Using it to avoid biometric prompts may route through the enclave itself. Whether that actually prompts depends on the backend's session behavior, and should be established before designing around it.

Acceptance criteria

  • Two concurrent varlock processes needing the same credential perform the acquisition once, and the second reuses it.
  • When the acquisition fails because the provider rejected it, other callers in the same window do not repeat it.
  • When it fails transiently, the next caller may still attempt.
  • Behavior is unchanged when no shared cache is available.
  • Covered by a test with real child processes. packages/varlock/src/lib/cache/cache-lock-crossproc.test.ts already spawns them and has the temp-HOME harness; its existing tests cover lock lifecycle (steal on dead owner, release on SIGINT, respect a live owner) but not the sharing outcome.

Worth confirming first

The cross-process prompt storm is inferred from the code, not from a user report. Running two varlock loads concurrently against a local-encrypted or 1Password-backed schema and counting prompts would establish whether this is a real annoyance or a theoretical one, and is worth doing before investing in the fix.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions