# `secrets` on Windows
Status: **works** — vault, CLI, inbox, and the session broker. Two capabilities
are unavailable (`--strict`, and **project leases**) and one trust boundary is
genuinely weaker than macOS. All three are described here rather than left to
be discovered.
Build → install → verify:
```powershell
cargo build --release
copy target\release\secrets.exe %LOCALAPPDATA%\Programs\baton\bin\
secrets --version
```
No signing step. The macOS build must be re-signed after every rebuild because
the Keychain item is bound to the code signature; Windows has no equivalent
binding (see "Trust boundary" below), so a rebuilt binary reaches the same
stored key.
---
## 1. Master key at rest: DPAPI + Windows Hello
`src/keychain_win.rs` implements the same interface as the macOS
`src/keychain.rs`, so `main.rs`, `lease.rs` and `inbox.rs` are platform-blind.
| At rest | Keychain item, Secure Enclave | `CryptProtectData` (DPAPI, user scope) |
| Location | login keychain | `%LOCALAPPDATA%\quantum-encoding\secrets\keyring\` |
| Presence | Touch ID | Windows Hello (`UserConsentVerifier`) |
| Reuse window | yes (non-strict) | none — every gated read prompts |
| Bound to | the **binary's code signature** | the **user account** |
`secrets unlock` stores the master key; every read of it raises a Hello prompt.
Storing needs no prompt, matching macOS.
### Trust boundary — the real difference
macOS binds the Keychain item to the binary's signature via the
`keychain-access-groups` entitlement: another program running as the same user
gets `errSecMissingEntitlement` and nothing else.
**DPAPI user scope has no equivalent.** Any process in your logon session can
call `CryptUnprotectData` on these blobs. The optional entropy this module
mixes in is derived from the account name, so it binds a ciphertext to its slot
— it does *not* authenticate the caller.
So on Windows, **Windows Hello is the boundary that matters** for the master
key, and it is why gated reads prompt every single time with no reuse grace.
Files are additionally protected by an explicit owner-only ACL (`src/winacl.rs`)
— a protected, inheritance-blocking DACL granting full control to your SID
alone. That is the `0600`/`0700` replacement. Administrators and SYSTEM can
still take ownership; no file ACL prevents that, just as `root` is unbounded on
Unix.
### `--strict` is not available
macOS `--strict` selects `BiometryCurrentSet`: enrolled biometry only, no
passcode fallback, self-invalidating when the fingerprint set changes.
`UserConsentVerifier` has **no biometry-only mode** — it accepts any enrolled
Hello credential including the PIN, and reports no enrollment changes. Passing
`--strict` on Windows prints a note saying so rather than pretending.
### `--no-presence` (know what you are choosing)
If Hello is unavailable, `secrets unlock` **refuses** rather than silently
falling back to DPAPI alone. `secrets unlock --no-presence` is the explicit
opt-in to the weaker mode, so it shows up in the transcript instead of
happening by accident.
---
## 2. DPAPI does not work over ssh public-key logon
**This is the constraint most likely to bite an agent.**
DPAPI unlocks its user master key from the credential in your logon session. An
ssh session authenticated by a **public key** has no such credential, so both
`CryptProtectData` and `CryptUnprotectData` fail there. This is the mechanism
working, not a bug. (The same constraint is visible in `gh`: its DPAPI-sealed
token authenticates fine interactively and is rejected over ssh.)
Consequences:
* The Windows master-key path serves the **interactive desktop user**.
* An agent arriving **over ssh** must use `SECRETS_PASSPHRASE`, a TTY
passphrase prompt, or a lease handed over by the interactive side:
```powershell
# interactive side, once:
secrets lease create myproj --ttl 2h
# the ssh-side agent then reads tap-free for the TTL:
secrets get DATABASE_URL -p myproj
```
* Hello would be doubly wrong over ssh anyway — nobody is at that desktop to
answer the prompt.
`secrets` turns the raw DPAPI failure into a message saying exactly this, so
the operator switches credential paths instead of suspecting a corrupt vault.
---
## 3. The session broker — a named pipe (`src/session_win.rs`)
`secrets session` works on Windows. The transport is a named pipe; the protocol
and the *entire enforcement pipeline* are the shared, platform-independent code
in `src/session.rs`. A transport's only job is to satisfy two requirements and
hand up a caller identity — it decides nothing:
**R1 — only the owning user may connect.** Unix gets this from the filesystem
(a `0600` socket in a `0700` directory). The Windows pipe namespace is
**machine-global** with no parent directory to hide behind, so R1 needs three
measures instead of one:
1. a **protected owner-only DACL** (`D:P(A;;GA;;;<sid>)`) on the pipe;
2. `FILE_FLAG_FIRST_PIPE_INSTANCE`, so the broker **refuses to start** rather
than silently becoming a second instance of someone else's pipe;
3. a **client-side owner check** before anything is sent. This is the one with
no Unix counterpart, and it is the important one: a hostile local process can
create a pipe with our name *first*, and would then receive our requests
(which carry project and key *names*) and could answer with forged values.
Measures 1 and 2 cannot detect that — only the client checking the pipe's
owner can. A mismatch is reported loudly, not treated as a broker miss.
The pipe name carries the user SID and a digest of the secrets dir, so two users
(or two vaults) never contend for one name to begin with.
**R2 — caller identity comes from the kernel.** `GetNamedPipeClientProcessId` is
the analogue of `LOCAL_PEERTOKEN`. macOS pairs the pid with a *pidversion* to
close the pid-reuse race; Windows has no pidversion, so the same guarantee comes
from immediately opening a **handle to the peer process** and holding it while
the identity is in use — a pid is not recycled while a handle to its process
object is open. The same-user check reads the peer's token SID through that
handle, so it needs no impersonation and happens *before* a byte the peer
controls has been read, matching the Unix ordering.
### Honest limits
* **Same-user squatting is not defeated, and cannot be.** The owner check
detects a *different user* taking the name. A process running as **you** that
grabs the name first owns a pipe whose owner legitimately is you. On Windows
that process could read the vault by other means anyway (§1), so this is not a
new exposure — but it is not a boundary either, and the tests say so rather
than implying coverage they do not have.
* **Detachment is the parent's job.** A console process is bound to its console
at creation, so there is no post-hoc `setsid` equivalent; `secrets session`
spawns the broker with `DETACHED_PROCESS` and clears `HANDLE_FLAG_INHERIT` on
its own std handles first — otherwise the broker inherits the launching
shell's pipe handles and `secrets session | tee log` hangs the shell until the
broker expires.
* `secrets session` **confirms the broker actually bound** before reporting
success, by reading back the child's own audit line. "Something is listening"
is not sufficient: a process already holding the name answers that just as
well, and the launcher would report a running key server while its own child
died. The failure reason is printed, not just a log path.
A project lease (`secrets lease create <project> --ttl <t>`) remains the simpler
option when you just want a tap-free window.
---
## 4. Agent identity (process ancestry) — works
`registry::resolve_agent` walks the parent-process chain with a Toolhelp
snapshot, the analogue of the macOS `proc_pidinfo` walk. This matters: it was
previously a `None`-returning stub on every non-macOS platform, and `exec`
reads `None` as "a human is driving this" and **skips grant enforcement
entirely**. Every `secrets exec` on Windows used to walk past the registry
unchecked.
Limits, same as macOS: process names are same-user spoofable, and an agent
running under a generic host image (`node.exe`, `python.exe`) resolves to
`None` and is treated as a human. Where the snapshot itself fails, it warns
loudly rather than returning a `None` that silently waives enforcement.
---
## 5. Project leases — NOT available
`secrets lease` **does not work on Windows**, and cannot be made to with the
primitives the platform offers to an unelevated binary. Running any `lease`
subcommand without `SECRETS_LEASE_KEYSTORE=file` fails closed:
```
Error: leases anchor their key in the macOS Keychain; on this platform set
SECRETS_LEASE_KEYSTORE=file (test/portability mode — LEASE_DESIGN.md §7)
```
That escape hatch is exactly what it says. `CLAUDE.md` calls file mode
"test/portability mode only, **never for real use**" — the lease key sits in a
0600 file beside the lease, offline-decryptable by any same-uid reader. It
makes the tests run; it is not the feature.
### Why, precisely
A lease is **tap-free reads that the agent still cannot decrypt**. Both halves
at once are the point — drop either and there is no feature:
| tap-free | no prompt on read | Keychain item, no biometry | — |
| agent cannot decrypt | key reachable by **this binary only** | `keychain-access-groups`, bound to the **code signature** | **nothing** |
Windows has no code-identity-bound keystore available here, so the two
requirements are mutually exclusive:
* **DPAPI** binds to the **user**, not the caller — see §1. Any process in your
logon session can `CryptUnprotectData` the blob, and the agent is such a
process. Tap-free, no protection.
* **DPAPI + Hello** restores the boundary and destroys tap-free, which *is* the
feature. That combination already exists; it is called `secrets get`.
* **CNG non-exportable / TPM-backed keys** are the trap worth naming. They stop
the key being **extracted**, but any same-user process can open it by name
and **use** it — and using it to decrypt is the whole attack. It looks like
the answer and is not.
* **TPM sealed to PCRs** measures boot state, not which user-mode process is
calling.
### What would fix it
**VBS enclaves.** `EnclaveSealData` with `ENCLAVE_IDENTITY_POLICY_SEAL_SAME_-
PRIMARY_CODE` seals to *code identity* and unseals tap-free — genuinely both
halves. It needs **Windows 11 Build 26100.2314+ / Server 2025** (this dev box
is Windows 10 Home 19045, so it is absent, not merely unconfigured) and an
**Azure Trusted Signing** account with the VBS enclave certificate profile.
The design when that day comes: the enclave holds the lease key *and* performs
the TTL check, so patching the host binary cannot skip expiry. Full working
notes, including everything already ruled out, are in
`baton research read windows-code-identity-keystore`.
Until then leases are macOS-only. Do not accept a patch that "adds Windows
lease support" via DPAPI — it would be a label, not a lock.
---
## 6. Windows-specific traps worth knowing
* **`bash` on the Windows PATH is WSL**, not git-bash — `C:\Windows\System32\bash.exe`.
A `.secrets.toml` naming `bash` will invoke WSL. Use an absolute path to
`C:\Program Files\Git\bin\bash.exe`.
* **`python3` is usually the Microsoft Store stub**, which prints an ad and
exits non-zero. `python` may be the real interpreter.
* **`:` in a path is an NTFS alternate data stream.** The lease keystore's
account names are `lease:<project>`; written raw, that silently creates a
stream on a file named `lease` rather than the file you meant. `safe_name`
percent-encodes them.
* From git-bash, **MSYS rewrites `/c`-style arguments into paths**. Set
`MSYS2_ARG_CONV_EXCL='*'` when passing Windows-style switches through.
* Debugging a broker that will not connect: set `SECRETS_DEBUG_BROKER=1` to
print the pipe name the client computes and the raw open error. The broker
records the name it bound, and any bind failure, in `session.log`. Comparing
those two is usually the whole diagnosis. It prints names only, never values.
---
## 7. Test status on Windows
`cargo test --features cli` — 22/22 pass, including DPAPI round-trip (skipping
rather than failing where DPAPI has no credential), ADS-colon escaping, a real
ACL applied to a real file, and the ancestry walk.
`bash tests/v2_e2e.sh` under git-bash — **24 checks pass**, the same count as
macOS. The broker sections run here too, against the named-pipe transport
(the script keeps a separate Windows block so the Unix path is untouched).
Only v1 → v2 migration is skipped, which needs the v1 binary, absent on this box.