Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
a53e709
refactor(local-encrypt): dispatch varlock() references by scheme
theoephraim Aug 28, 2026
a56a01c
feat(local-encrypt): add identity layer with v2 payloads
theoephraim Aug 28, 2026
03d7423
chore: consolidate bump files for combined local-encrypt release
theoephraim Aug 28, 2026
2d2376f
refactor(local-encrypt): fold migrate into encrypt --upgrade
theoephraim Aug 28, 2026
e7e185c
feat(local-encrypt): daemon-held identity unlock sessions
theoephraim Aug 30, 2026
60bd6d5
feat(local-encrypt): make what ends an unlock session configurable
theoephraim Aug 30, 2026
d064465
feat(local-encrypt): daemon-drawn unlock and approval panel
theoephraim Aug 30, 2026
da563e5
fix(local-encrypt): expire grants on the monotonic clock as well as t…
theoephraim Aug 30, 2026
85b8a7b
feat(local-encrypt): record every authorization before releasing plai…
theoephraim Aug 30, 2026
1d0b1bb
feat(local-encrypt): check the calling process's posture, not just it…
theoephraim Aug 30, 2026
1ae5cf0
feat(local-encrypt): show and manage unlock sessions from the menu bar
theoephraim Aug 30, 2026
2aea45f
test(local-encrypt): pin the session death invariant and stop the pro…
theoephraim Aug 30, 2026
4236ff2
docs(local-encrypt): document the menu bar, the authorization log, an…
theoephraim Aug 30, 2026
4f09423
fix(local-encrypt): arm the daemon's signal handling before anything …
theoephraim Aug 30, 2026
4ecdecf
feat(local-encrypt): speak the identity session protocol from the Rus…
theoephraim Aug 30, 2026
8d62fa8
feat(local-encrypt): open identity-encrypted values on hardware backends
theoephraim Aug 31, 2026
cc3a321
fix(local-encrypt): refuse an unlock that names no key
theoephraim Aug 31, 2026
3b4cc95
test(cli): say when the lazy-loading check is reading a stale bundle
theoephraim Aug 31, 2026
f1beb53
feat(local-encrypt): put the Touch ID prompt inside the approval panel
theoephraim Aug 31, 2026
6176557
fix(local-encrypt): arm the inline Touch ID prompt from inside the ru…
theoephraim Aug 31, 2026
d8c3773
fix(local-encrypt): draw our own Touch ID glyph instead of trusting t…
theoephraim Aug 31, 2026
9ce1081
fix(local-encrypt): arm the panel from the run loop, not the main queue
theoephraim Aug 31, 2026
197164a
feat(local-encrypt): animate the panel's Touch ID glyph with its state
theoephraim Aug 31, 2026
04da1f1
feat(local-encrypt): send what each key opens to the unlock panel
theoephraim Aug 31, 2026
1e15f9a
feat(local-encrypt): rebuild the unlock panel around what it is askin…
theoephraim Aug 31, 2026
586d729
feat(local-encrypt): show the chain of processes behind an unlock
theoephraim Aug 31, 2026
01a8125
feat(local-encrypt): a demo script for the unlock panel, and guard th…
theoephraim Aug 31, 2026
08ef716
feat(local-encrypt): put the agent session in the chain, with real icons
theoephraim Aug 31, 2026
e622055
feat(local-encrypt): give the session root two lines, so its title su…
theoephraim Aug 31, 2026
544006c
fix(local-encrypt): stop the scope control shifting, and make the dur…
theoephraim Aug 31, 2026
311b6bb
feat(local-encrypt): make chain colour mean one thing, and show the c…
theoephraim Aug 31, 2026
cb0e28c
fix(local-encrypt): never let one scan both set up Touch ID and approve
theoephraim Aug 31, 2026
93663c1
fix(local-encrypt): do not hold the session queue while a panel is up
theoephraim Aug 31, 2026
b36b26f
fix(local-encrypt): word the setup reason for the sentence macOS builds
theoephraim Aug 31, 2026
934c03c
fix(local-encrypt): keep the panel readable while the system sheet is up
theoephraim Aug 31, 2026
035c0a4
test(local-encrypt): measure the inline-view hypotheses instead of ar…
theoephraim Aug 31, 2026
518dd24
feat(local-encrypt): the scan happens inside the panel
theoephraim Aug 31, 2026
3d02241
feat(local-encrypt): the approve control is the sensor, and it lights…
theoephraim Aug 31, 2026
6363b83
docs(local-encrypt): the menu icon regressed on this branch, it was f…
theoephraim Sep 1, 2026
f6208ae
fix(local-encrypt): stop the chain bolding a shell, and put the tty o…
theoephraim Sep 1, 2026
2f558ee
feat(local-encrypt): let the sensor be the sensor, at its own size
theoephraim Sep 1, 2026
6fd6988
fix(local-encrypt): make "Use password..." ask for a password
theoephraim Sep 1, 2026
e9786a9
fix(local-encrypt): draw the session the grant attaches to, not just …
theoephraim Sep 1, 2026
8fc99bb
feat(local-encrypt): say plainly how varlock got here, and who receiv…
theoephraim Sep 1, 2026
f75d610
fix(local-encrypt): say the terminal once, and name the app someone l…
theoephraim Sep 1, 2026
9ad9d96
feat(local-encrypt): give every chain row a real icon in a square box
theoephraim Sep 1, 2026
5e32a12
fix(local-encrypt): draw every chain icon on the same square canvas
theoephraim Sep 1, 2026
e5c182a
fix(local-encrypt): give shells a square tile, not a landscape screen
theoephraim Sep 1, 2026
6ef0c07
fix(local-encrypt): lay every chain row out on one grid
theoephraim Sep 1, 2026
c27b4e3
fix(local-encrypt): stop lending bun's signature to varlock's JavaScript
theoephraim Sep 1, 2026
2a88c1d
feat(local-encrypt): say when nobody is watching an agent, and where …
theoephraim Sep 1, 2026
8ee79cf
feat(local-encrypt): draw commands as commands and marks that mean so…
theoephraim Sep 1, 2026
708ad80
feat(local-encrypt): report which build of varlock is asking
theoephraim Sep 1, 2026
536b69b
chore: note the panel's honesty and session changes in the local-encr…
theoephraim Sep 1, 2026
867c04e
fix(local-encrypt): find the dev build a dev checkout actually made
theoephraim Sep 1, 2026
fc88348
fix: run the project's own varlock, not one that happens to be on PATH
theoephraim Sep 1, 2026
e26a519
fix(local-encrypt): name varlock by the package it came out of, not t…
theoephraim Sep 1, 2026
469eb8c
feat(local-encrypt): list the value cache beside the files it shares …
theoephraim Sep 1, 2026
1c40604
fix(local-encrypt): describe the whole grant, not the batch that aske…
theoephraim Sep 1, 2026
166bdd6
feat(local-encrypt): draw a source's size as a badge, not a sentence
theoephraim Sep 1, 2026
8eab673
feat(local-encrypt): let an approval cover only the values it listed
theoephraim Sep 1, 2026
2348bc1
refactor(local-encrypt): read the outside-project signal as a fact, n…
theoephraim Sep 1, 2026
130239f
chore: keep cargo's build output out of the lint
theoephraim Sep 1, 2026
fa815f8
feat(local-encrypt): make breadth one checkbox, bounded by the vault
theoephraim Sep 2, 2026
1471740
feat(local-encrypt): once hides the breadth control and grants narrow
theoephraim Sep 2, 2026
444159c
fix(local-encrypt): stop AppKit greying out every duration option
theoephraim Sep 2, 2026
8bea620
feat(local-encrypt): draw the windows as a row, not a dropdown
theoephraim Sep 2, 2026
9951c41
feat(local-encrypt): one ladder for how long an approval lasts
theoephraim Sep 2, 2026
a753c1f
fix(local-encrypt): keep a growing panel's heading on screen
theoephraim Sep 2, 2026
60f1c32
feat(local-encrypt): let people name the window an approval lasts
theoephraim Sep 2, 2026
cdfe9e1
refactor(local-encrypt): keep the custom rung named, and say what the…
theoephraim Sep 2, 2026
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
17 changes: 17 additions & 0 deletions .bumpy/local-encrypt-identity-layer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
varlock: minor
---

Locally encrypted values now unlock once per session instead of re-prompting every five minutes. Approving an unlock covers every value it names until the session ends, and you choose what ends it: screen lock, sleep (the default), or only an explicit lock. The approval panel is drawn by the encryption daemon, so it can show you which process is actually asking and which keys it wants, before anything is decrypted.

On macOS that panel says what the unlock actually covers: each key with how many values it opens, expanding to everything that key protects. Env files are listed with the value names each one defined, and varlock's value cache is listed alongside them with how many cached values it holds and which plugins and files filled it, since one approval on a key opens all of it. What it lists is the whole of what that approval will open, worked out before the panel appears, so it reads the same however the run happens to reach its first encrypted value. The panel also shows the line of processes leading to whoever is asking, from the app you launched down to the command that ran. A request from a coding-agent session is shown as coming from that session, by name and start time, with everything running inside it marked, and the panel says so when nobody is watching that agent or when it is working outside the project being unlocked. The panel is also explicit about which varlock is asking: the standalone binary, or varlock's JavaScript running under node or bun, which is a different thing with different guarantees. Marks on each step say what was checked and what was not, and hovering one spells it out.

The panel now asks two questions rather than one. Under how long an approval lasts sits one checkbox, "Auto-unlock all items in this vault", ticked by default. Unticking it holds the approval to exactly the values on the panel, and that is enforced by the daemon: it binds the approval to the encrypted values it was shown, and anything else raises a fresh panel instead of being handed over quietly. While the box is ticked the panel says so plainly, describing the grant as covering the vault and the listed values as what it covers right now, so nothing opens later that the panel implied it would not. Choosing "once" hides the checkbox and grants narrow, since "just this, right now" is what the word already means; it is an answer about time, so it never changes what varlock remembers about breadth. A broad approval still stops at the vaults it was shown. The value cache is never narrowed by the checkbox and the panel says so: its entries are rewritten whenever a cached value is renewed, so it is always covered as a whole.

How long an approval lasts is one row, read from least to most permissive: once, 10 minutes, 1 hour, a duration you set, and this session. Picking the custom rung reveals a field and a minutes/hours toggle, bounded by the same 12 hour cap as everything else. The rung keeps its name whatever number you set, since it sits at a fixed place in an ordered row; the number itself is in the field, which is on screen whenever that rung is selected, and in the sentence under the controls. A custom window shorter than the default is a tightening like any other, so it is remembered and preselected the next time you are asked.

Which option is preselected follows the request. A key you have approved before, in its own project, with a person at the keyboard, opens on the broad default. Anything less ordinary starts narrower: a first approval of a key, an agent session, or somebody else's script driving varlock; and narrower still when nobody is watching the session, when it is working outside the project it is unlocking, or when the code asking has no signature macOS accepts. If you tighten an approval, varlock remembers that and preselects it next time, saying on the panel that it did. It only ever remembers tightening, never widening, so ticking the box again forgets it. `varlock lock --forget-preferences` clears what is remembered for the current project, and `--forget-all-preferences` clears the lot.

New commands to see and manage that: `varlock sessions` lists what is currently unlocked, and `varlock lock` now takes `--current` to end just this terminal's session or `--session <id>` to end one you name. On macOS the menu bar shows the same sessions and can lock them individually.

Existing encrypted values keep working with no action required. To move them onto the new model, run `varlock encrypt --upgrade` (try `--dry-run` first to see what would change). Their panel is also honest now about what approving them buys: macOS reuses one scan for up to five minutes on that path, which the panel used to describe as a single read.
6 changes: 6 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export default tseslint.config(
'**/dist-test',
'**/node_modules',
'**/.turbo',
// SwiftPM build output. Generated JSON, and anyone who builds the Swift
// package would otherwise get a red lint they did not cause.
'packages/encryption-binary-swift/swift/.build',
// Cargo build output, for the same reason: it is full of generated JSON,
// and running `cargo test` should not turn the lint red.
'packages/encryption-binary-rust/target',
'packages/eslint-custom-rules',
'packages/env-spec-parser/src/grammar.js',
'packages/varlock-website/.astro',
Expand Down
1 change: 1 addition & 0 deletions packages/encryption-binary-rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 16 additions & 1 deletion packages/encryption-binary-rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@ zeroize = "1"
[target.'cfg(unix)'.dependencies]
libc = "0.2"

# Platform — Linux (nix for peer credentials in IPC, secret-service for keyring storage)
# Platform — Linux (nix for peer credentials in IPC, secret-service for keyring
# storage, zbus for the logind sleep/lock signals that end unlock sessions).
# zbus is already in the tree underneath secret-service; naming it here pins the
# same version rather than adding to the dependency set.
[target.'cfg(target_os = "linux")'.dependencies]
nix = { version = "0.29", features = ["process", "socket", "user", "fs"] }
secret-service = { version = "4", default-features = false, features = ["rt-async-io-crypto-rust"] }
zbus = "4"

# Platform — Windows
[target.'cfg(target_os = "windows")'.dependencies]
Expand All @@ -51,6 +55,17 @@ windows = { version = "0.58", features = [
"Win32_Security_Authorization",
"Win32_System_Threading",
"Win32_System_Memory",
# Peer session scoping: parent process walk + process creation times
"Win32_System_Diagnostics_ToolHelp",
# Sleep-inclusive monotonic clock for grant deadlines (QueryInterruptTime)
"Win32_System_WindowsProgramming",
# Lock events: suspend notifications, and workstation lock via a
# message-only window
"Win32_System_Power",
"Win32_System_RemoteDesktop",
"Win32_System_LibraryLoader",
# WNDCLASSW carries GDI handle fields, so the window class needs this too
"Win32_Graphics_Gdi",
# Windows Hello (UserConsentVerifier)
"Security_Credentials_UI",
"Foundation",
Expand Down
80 changes: 69 additions & 11 deletions packages/encryption-binary-rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,75 @@ Binaries ship uncompressed. Do not reintroduce UPX (or any other executable pack

## Architecture

- `src/main.rs` — CLI interface (generate-key, encrypt, decrypt, status, daemon)
- `src/crypto.rs` — ECIES encryption using pure Rust crates (no OpenSSL)
- `src/key_store/` — Platform-specific key protection:
- `windows_tpm.rs` — NCrypt TPM seal (Platform Crypto Provider)
- `windows.rs` — DPAPI fallback
- `windows_hello.rs` — Windows Hello presence gate (daemon)
- `linux.rs` — TPM2 via tpm2-tools
- `scalar.rs` — shared P-256 scalar ↔ PKCS8 helpers
- `src/daemon.rs` — Long-lived IPC daemon for biometric session caching
- `src/ipc.rs` — IPC server (Unix socket on Linux, named pipe on Windows)
- `src/daemon_client.rs` — Named pipe client for `--via-daemon` mode (WSL2 support)
- `src/main.rs`: CLI interface (generate-key, encrypt, decrypt, status, daemon)
- `src/crypto.rs`: ECIES encryption using pure Rust crates (no OpenSSL)
- `src/key_store/`: Platform-specific key protection:
- `windows_tpm.rs`: NCrypt TPM seal (Platform Crypto Provider)
- `windows.rs`: DPAPI fallback
- `windows_hello.rs`: Windows Hello presence gate (daemon)
- `linux.rs`: TPM2 via tpm2-tools
- `scalar.rs`: shared P-256 scalar ↔ PKCS8 helpers
- `src/identity_sessions/`: identity-backed unlock sessions (see below)
- `src/secure_mem.rs`: locked, dump-excluded, zeroize-on-drop buffers
- `src/daemon.rs`: Long-lived IPC daemon for biometric session caching
- `src/ipc.rs`: IPC server (Unix socket on Linux, named pipe on Windows)
- `src/daemon_client.rs`: Named pipe client for `--via-daemon` mode (WSL2 support)

## Identity sessions

Values are encrypted to an identity key rather than straight to the device key:

```
device key (NCrypt/TPM, DPAPI, TPM2, Secret Service) -> identity key -> values
```

The daemon holds the unwrapped identity key on behalf of one session so a whole
env file resolves without a prompt per value. A grant is what makes that holding
legitimate. The ops are the same ones the macOS (Swift) daemon speaks, so a
client cannot tell the two apart:

- `unlock-session`: open or extend a session's hold on one or more keys
- `decrypt-v2`: decrypt a batch of identity payloads under a live grant
- `list-sessions`: every live grant, with no key material
- `invalidate-session`: drop everything, one session, or one grant

`ping` reports `protocolVersion: 3`.

Rules worth knowing:

- A grant is keyed by (session x key). The session is resolved from the
connecting process, never from anything in the message.
- Scopes are `once`, `session`, and `duration`, all capped at 12 hours.
- Deadlines are held on both the wall clock and a sleep-inclusive monotonic
clock, and whichever runs out first ends the grant.
- Every authorization is appended to `<user varlock dir>/audit/authorizations.jsonl`
and read back off disk before any plaintext is returned. A decrypt whose record
cannot be written is refused.
- Nothing is persisted. A daemon restart loses every session on purpose.

### What ends a session early

`lockOn` is `screenLock`, `sleep` (the default), or `none`, taken from the
unlock, then from `sessions.lockOn` in the user config file, then from the
default. The daemon's ready line reports which triggers it actually wired:

| event | Windows | Linux |
| --- | --- | --- |
| `sleep` | `PowerRegisterSuspendResumeNotification` | logind `PrepareForSleep` |
| `screenLock` | `WTSRegisterSessionNotification` | logind session `Lock` |

Desktop-environment screensaver locks on Linux (GNOME, KDE) do not always reach
logind, and are not yet wired. A machine with no source for an event runs its
sessions to their TTL instead, and says so on stderr at startup.

### Where the key is held

macOS re-wraps the identity key under a per-session Secure Enclave key. Neither
NCrypt nor TPM2 gives a cheap equivalent, so the hold here is guarded memory: a
fixed-size allocation that never grows, `mlock`/`VirtualLock`ed, marked
`MADV_DONTDUMP` on Linux, and zeroized when the session ends. The daemon also
disables core dumps and clears `PR_SET_DUMPABLE` on Linux at startup. A
TPM-resident session key is a later step.

## WSL2 Support

Expand Down
Loading