Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 27 additions & 14 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`{MachineName}:{UserName}:{OSVersion}`, so a feature update (e.g. 25H2 → 26H2)
changed `Environment.OSVersion`, rotated the KEK, and left every credential
undecryptable with `AuthenticationTagMismatchException` and no migration path.
The KEK now derives from stable machine/user identity only — `OSVersion` is
dropped — so later OS updates cannot break the store. Machine and user name
still bind the keystore to this machine/user, and the security boundary is
unchanged (filesystem permissions on the credentials directory).
The KEK no longer folds in any ambient identity (see the format change below),
so neither an OS update nor a machine rename can break the store. The security
boundary is unchanged (filesystem permissions on the credentials directory).

- **The Keychain test-retry budget is raised so `macos-15` stops flaking.** The
`RetryHelper` default was 20 attempts × 25 ms ≈ 500 ms, which was too tight on
Expand All @@ -32,16 +31,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Keystore format bumped to version 2** (`.keystore` header). A version-1
keystore — and a legacy headerless one, both sealed under the old
OSVersion-based KEK — is read with the legacy KEK and then **transparently
re-sealed as version 2 on first load**, so an existing, still-readable store
migrates itself on upgrade and becomes immune to future OS updates. The
re-seal is best-effort: it overwrites in place (safe, since the data key is
unchanged) and a persistence failure never fails an otherwise-successful read.
A keystore already broken by an OS update performed *before* this upgrade
cannot be recovered by the library; the integrity-error message now names that
cause.
- **The local keystore KEK no longer folds in machine, user, or OS identity;
format bumped to version 3.** The KEK now derives (PBKDF2) from a random
per-keystore salt stored in the `.keystore` header plus a fixed domain tag.
Ambient identity (`MachineName`/`UserName`, and previously `OSVersion`) was
only ever a tripwire, not a security boundary — it is discoverable — and it
broke the store on a machine rename or OS update, so it is gone. Older
keystores (version 1 and legacy headerless = machine/user/OSVersion KEK;
version 2 = machine/user KEK) are read with their old KEK and **transparently
re-sealed as version 3 on first load**. The re-seal is best-effort: it
overwrites in place (safe, since the data key is unchanged) and a persistence
failure never fails an otherwise-successful read.

**Behaviour change:** a default-mode (no `AdditionalEntropy`) credentials
directory is now **portable** — copied whole to another machine or user it
still decrypts. This drops the "won't open elsewhere" tripwire, which was
never a real control. For genuine binding, supply `AdditionalEntropy` (a
per-machine or per-deployment secret) — now the file backend's only real
cryptographic boundary — or use the DPAPI / platform-keychain backends, which
bind to OS-managed secrets and survive renames and OS updates.

A keystore already broken by an OS update performed *before* the version-1→3
fix was installed cannot be recovered by the library; the integrity-error
message names the likely causes (corruption/tampering, or a wrong
`AdditionalEntropy`).

## [2.0.0] — 2026-08-28

Expand Down
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,12 @@ whenever the providers take a major, so check it against their
`MoveFileEx` raises a sharing violation when the destination is open or two replacements
race. See the remarks on `AtomicFile`.
- **`LocalFileCredentialEncryption`'s security boundary is filesystem permissions**, not
the KEK — it is derived from non-secret machine identifiers unless the caller supplies
`AdditionalEntropy`. Do not describe it as protecting against same-user code.
the KEK — in default mode the KEK is derived from a non-secret random salt stored in the
`.keystore` header (v3), so a default-mode credentials directory is *portable* across
machines/users. The only real cryptographic boundary is `AdditionalEntropy` (or the
DPAPI/keychain backends). Do not describe the file backend as protecting against
same-user code, and do not reintroduce machine/user/OS identity into the KEK — that was
removed because it broke the store on OS updates and renames while never being a boundary.

## Repository baseline

Expand Down
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,12 @@ Every command accepts `-v` / `--verbose` for full stack-trace output when someth

### Moving credentials between machines

Stored credentials are encrypted with a **machine-bound** key (the local KEK is derived from machine/user identity; the Keychain/libsecret/DPAPI backends key off the OS secret store). That means the files on disk can't simply be copied to another machine — they won't decrypt there. `accounts export` / `accounts import` solve this by re-encrypting the whole set under a passphrase you carry:
How portable your on-disk store is depends on the backend:

- **Default file backend** (no `AdditionalEntropy`): the credentials directory is self-contained and **portable** — copy the whole directory, `.keystore` included, to another machine or user and it still decrypts. Convenient, but it means the files are only as protected as their filesystem permissions (see [Security model](#security-model)).
- **Hardened file backend** (`AdditionalEntropy` set) or the **Keychain / libsecret / DPAPI** backends: the store is bound to a caller secret or the OS secret store, so a raw copy won't decrypt elsewhere.

For a portable transfer that works regardless of backend — and is safer than copying raw files, since it's protected by a passphrase rather than just filesystem permissions — use `accounts export` / `accounts import`, which re-encrypt the whole set under a passphrase you carry:

```console
# On the old machine — you'll be prompted for a passphrase (and to confirm it):
Expand All @@ -146,30 +151,29 @@ $ my-cli accounts import credentials.bundle
- **Passphrase-only.** The archive is AES-256-GCM encrypted with a PBKDF2-derived key (600,000 iterations, random per-export salt). There is no plaintext export. For scripting, read the passphrase from an environment variable with `--passphrase-env MY_VAR` instead of being prompted. On Unix the archive file is written `0600`.
- **Conflicts.** An imported credential is matched to an existing one on *(provider, account name, environment)*. On a match you're prompted to skip or overwrite; pass `--on-conflict skip` or `--on-conflict overwrite` to decide up front (a non-interactive run with no flag skips).
- **Fidelity.** Account IDs and which credential is selected are preserved. Original creation timestamps are preserved on the file and libsecret backends; the macOS Keychain assigns its own, so imported items show a fresh timestamp there.
- **A credential that cannot be read is left out, not exported blank.** If the store holds an entry this machine's keystore can no longer decrypt — a credentials directory copied from another machine, say — `accounts export` skips it and tells you how many it skipped. The archive is then genuinely incomplete, which is why it says so: writing an empty payload instead would restore *over* a real secret at the far end. `accounts list` marks the same entries `(unreadable)` so you can see which they are.
- **A credential that cannot be read is left out, not exported blank.** If the store holds an entry the keystore can no longer decrypt — after the `AdditionalEntropy` value changed, say, or a file corrupted on disk — `accounts export` skips it and tells you how many it skipped. The archive is then genuinely incomplete, which is why it says so: writing an empty payload instead would restore *over* a real secret at the far end. `accounts list` marks the same entries `(unreadable)` so you can see which they are.

> The archive contains **every stored secret**, protected only by your passphrase. Choose a strong one and treat the file as sensitive.

---

## Security model

Credentials are encrypted with **AES-GCM** (authenticated — tampering is detected on decrypt). The data-encryption key is itself encrypted and stored in a `.keystore` file inside your credentials directory. The key-encryption key (KEK) is derived from machine + user identifiers via PBKDF2-HMAC-SHA256 (600,000 iterations).
Credentials are encrypted with **AES-GCM** (authenticated — tampering is detected on decrypt). The data-encryption key is itself encrypted and stored in a `.keystore` file inside your credentials directory. The key-encryption key (KEK) is derived via PBKDF2-HMAC-SHA256 (600,000 iterations) from a random salt stored in the `.keystore` header — with **no machine, user, or OS input**. (Earlier versions folded machine/user/OS identity into the KEK; that broke the store on a machine rename or OS update and was never a real security boundary, so it was removed.)

**What this protects against:**

- Other users on the same machine reading your credentials (filesystem permissions on the credentials directory enforce this).
- A casual attacker who ends up with a copy of the `.keystore` file but lacks knowledge of the originating machine and user.
- Undetected tampering of credential files (AES-GCM's authentication tag refuses decryption on any modification).
- Undetected tampering of the keystore or credential files (AES-GCM's authentication tag refuses decryption on any modification — a tampered header salt just yields a wrong key, which the tag then rejects).

**What it does *not* protect against** (in default mode):

- A local attacker who has read access to the credentials directory **and** knows the machine hostname + username — the KEK is deterministic given those inputs. Close this gap either by supplying `AdditionalEntropy` (see below) or by using DPAPI / a platform keychain.
- **Anyone who obtains a copy of the credentials directory.** The KEK is derived from the (non-secret, stored) header salt and a fixed constant, so the `.keystore` is self-describing: whoever has the files can derive the KEK and decrypt. In default mode the store is portable *by design*, and the boundary is purely the filesystem permissions. Close this gap by supplying `AdditionalEntropy` (see below) or by using DPAPI / a platform keychain.
- A compromised running process: once your CLI has decrypted a credential in memory, it's in memory.

**Hardening with `AdditionalEntropy`:**

The default KEK is derived purely from machine state, so anyone who copies the `.keystore` file plus the machine's hostname/username can decrypt. Pass a secret into `CredentialStoreOptions.AdditionalEntropy` to close that gap:
The default KEK has no secret input, so anyone who copies the `.keystore` file can derive it and decrypt. Pass a secret into `CredentialStoreOptions.AdditionalEntropy` to make the KEK depend on something that is *not* in the file:

```csharp
services.AddCredentialStore(opts =>
Expand All @@ -181,7 +185,7 @@ services.AddCredentialStore(opts =>
});
```

The entropy is mixed into the PBKDF2 password so the KEK now depends on both the machine AND this value. An attacker with the keystore file but without the entropy can't decrypt. Common sources: a per-deployment secret from env / HSM, a value from a secret manager, a user-entered passphrase.
The entropy is mixed into the PBKDF2 password so the KEK depends on this value, which is never stored in the keystore. An attacker with the keystore file but without the entropy can't decrypt. Common sources: a per-deployment secret from env / HSM, a value from a secret manager, a user-entered passphrase. If you specifically want machine binding, make the entropy a per-machine secret — that puts the binding on a value you control rather than on a discoverable identifier.

Caveats:

Expand Down
13 changes: 8 additions & 5 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@ pre-1.0 libraries and there are no long-term support branches.
These libraries store credentials on the local filesystem or in an OS secret store.
Two things are explicitly **not** claimed:

- `LocalFileCredentialEncryption` derives its key-encryption key from non-secret
machine and user identifiers. Its real security boundary is filesystem permissions.
It protects credentials at rest against another *user*; it does not protect them
against code running as the same user. Supply `AdditionalEntropy` for a KEK that
depends on a caller-held secret as well as the machine.
- `LocalFileCredentialEncryption` derives its key-encryption key from a non-secret
random salt stored in the keystore header — no machine, user, or OS input — so in
default mode the credentials directory is portable and its real security boundary is
filesystem permissions. It protects credentials at rest against another *user*; it
does not protect them against anyone who obtains a copy of the files, nor against code
running as the same user. Supply `AdditionalEntropy` (or use the DPAPI / platform
keychain backends) for a KEK that depends on a caller-held secret not present in the
keystore.
- Nothing here defends against a compromised host, a debugger attached to the
process, or a heap dump taken while credentials are decrypted in memory.

Expand Down
Loading
Loading