Sync config from arch

- hypr/apps.lua
- hypr/autostart.lua
- hypr/envs.lua
- hypr/hyprland.lua
- hypr/hyprsunset.conf
- hypr/input.lua
- hypr/looknfeel.lua
- hypr/omasettings.lua
- hypr/xdph.conf
- omarchy/branding/about.txt
- omarchy/branding/screensaver.txt
- omarchy/extensions/omarchy-menu.jsonc
- omarchy/hooks/battery-low.d/play-warning-sound.sample
- omarchy/hooks/font-set.d/show-font-notification.sample
- omarchy/hooks/post-boot.d/weather.sample
- omarchy/hooks/post-update.d/install-voxtype.hook
- omarchy/hooks/post-update.d/setup-agent.hook
- omarchy/hooks/post-update.d/setup-fingerprint.hook
- omarchy/hooks/post-update.d/show-update-notification.sample
- omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample
- omarchy/hooks/theme-set.d/show-theme-notification.sample
- omarchy/shell.json
- omarchy/shell.toml
- omarchy/theme.name
- omarchy/themes/azure-glow/README.md
- omarchy/themes/azure-glow/alacritty.toml
- omarchy/themes/azure-glow/btop.theme
- omarchy/themes/azure-glow/hyprland.conf
- omarchy/themes/azure-glow/hyprlock.conf
- omarchy/themes/azure-glow/icons.theme
- … 269 more
This commit is contained in:
asepharyana
2026-09-23 15:19:12 +07:00
commit 1cdb82a76f
300 changed files with 78143 additions and 0 deletions
@@ -0,0 +1,242 @@
# 0001: Rust dependencies for the SSH-agent companion
- Status: accepted
- Date: 2026-08-27
- Task: 4 of `tasks/todo.md`
- Supersedes: the dependency assumptions in `docs/ideas/ssh-agent.md`
## Context
The companion holds decrypted SSH private keys for as long as the vault is
unlocked and signs with them on request. Every crate in its dependency tree is
therefore in the blast radius of a private-key compromise, and the crate set is
also what the reproducible build (Task 16) pins byte for byte.
`docs/ideas/ssh-agent.md` named RustCrypto's `ssh-key` as a candidate and told
this task to re-verify every claim at spike time rather than trust the idea
document's review date. That was the right instruction: two of its assumptions
did not survive contact with the released crates.
Sources were checked on 2026-08-27 against crates.io, the published crate
sources in the local registry, the projects' own repositories, and the RustSec
advisory database.
## Decision
The companion is built from the following crates, all pinned in
`agent/Cargo.lock` and all MIT or Apache-2.0 except where noted.
| Crate | Version | Role | Why this one |
| --- | --- | --- | --- |
| `ssh-key` | 0.6.7 | Key parsing, public blobs, fingerprints, Ed25519 signing | Maintained by RustCrypto, ~4M recent downloads, no advisories. Default features off, so ECDSA, DSA, and OpenSSH key encryption never compile in. |
| `ssh-encoding` | 0.2 | Wire primitives for the frame decoder | Same project, the version `ssh-key` already uses. |
| `ed25519-dalek` | 2.2 | Ed25519 backend | Not called directly. Declared to enable its `zeroize` feature — see below. BSD-3-Clause. |
| `rsa` | 0.9.10 | RSA private keys and PKCS#1 v1.5 signing | Required directly for both RSA SHA-2 algorithms — see below. |
| `sha2` | 0.10 | SHA-256/512 for the two RSA algorithms | Already in the tree via `ssh-key`. |
| `signature` | 2 | `Signer`/`Verifier` traits | The traits `ssh-key` and `rsa` sign through. |
| `zeroize` | 1.9 | `Zeroizing<Vec<u8>>` for PEM text and FIFO payloads | The standard, and what every crypto crate here already zeroizes through. |
| `tokio` | 1.53 | Current-thread runtime, `UnixListener`, timers, bounded channels | `net` also carries `peer_cred()`, which is how the same-UID check is made — no separate crate needed for `SO_PEERCRED`. |
| `rustix` | 1.1 | `RLIMIT_CORE=0`, `PR_SET_DUMPABLE=0` | Maintained, no advisories, and avoids a bare `libc` unsafe block for the two calls that must happen before the first secret is read. |
| `serde`, `serde_json` | 1 | The NDJSON control channel on stdin/stdout | The format the panel already speaks. |
That is 63 crates in the runtime graph. `cargo test --locked`, `cargo build
--locked`, `cargo fmt --check`, and `cargo clippy --all-targets -D warnings`
all pass on `x86_64-unknown-linux-gnu` with no vault, no network, and no
socket involved.
### Ed25519 secret memory needs a feature `ssh-key` does not ask for
The spike's pass/fail criterion was whether private components can be erased
on drop. For the representations `ssh-key` owns, they are: `Ed25519PrivateKey`
and `RsaPrivateKey` both zeroize their fields in `Drop`, and `Mpint` zeroizes
its backing `Vec`.
The gap is one level down. `ssh-key` builds a transient
`ed25519_dalek::SigningKey` for each Ed25519 signature, and it depends on
`ed25519-dalek` with `default-features = false` without requesting `zeroize`.
Dalek implements `ZeroizeOnDrop` for `SigningKey` only behind that feature, so
as `ssh-key` configures it, each signature leaves 32 secret bytes in freed
memory.
This crate therefore names `ed25519-dalek` as a direct dependency for that
feature alone. Cargo's feature unification turns the impl on for every copy in
the graph, including the ones `ssh-key` constructs. `rsa` needs no equivalent:
its `RsaPrivateKey` zeroizes unconditionally, and it enables `num-bigint-dig`'s
`zeroize` feature itself.
Because this property comes from feature resolution rather than from anything
visible in this crate's source, it is asserted at compile time —
`assert_zeroize_on_drop::<T>()` in `src/lib.rs`, called on both types in a
test. Removing the dalek dependency does not weaken the build quietly; it
stops it.
Declaring a dependency to enable one of its features is a normal use of Cargo's
feature unification: it changes configuration, not code. Bitwarden's desktop
agent solves the same problem far more heavily, with a `secure_memory` crate
that keeps key material in `memsec` locked allocations, encrypted under AES-GCM
with the key held in the Linux kernel keyring (DPAPI on Windows) and decrypted
only for use. That is a stronger guarantee and a much larger surface; it is
worth revisiting at Task 6 if the keystore review finds zeroize-on-drop
insufficient, not before.
### `ssh-key` 0.6.7 cannot sign with RSA at all
`ssh-key` 0.6.7's `TryFrom<&RsaKeypair> for rsa::RsaPrivateKey` passes
`key.private.p` twice where `from_components` expects `p` and `q`
(`ssh-key-0.6.7/src/private/rsa.rs:192`). The resulting key fails validation,
so every RSA signature through `ssh-key` returns an opaque error. The fix is on
the project's master branch; it is not in any release. 0.6.7 is from October
2024 and the 0.7 line has been in release candidates since 2025 — rc.11 landed
in June 2026 — so there is no stable release with a working RSA path.
Three options: ship a release candidate of a security dependency, drop RSA from
v1, or construct the private key here. This crate constructs it here
(`rsa_keys::private_key`): a dozen lines against `rsa`'s stable API, no fork
and no `[patch]` section, deleted the day a fixed release exists. A test
asserts that `ssh-key`'s own RSA signing still fails, so the workaround cannot
outlive its reason without someone noticing.
The same module owns SHA-2 algorithm selection, which is needed regardless of
that bug: `ssh-key`'s `Signer` impl for RSA hardcodes SHA-512, while
`rsa-sha2-256` and `rsa-sha2-512` are distinct signature algorithms chosen by
flags on the sign request. Answering with the wrong one is a failed
authentication, not a fallback.
Nothing here is a modified, forked, vendored, or pre-release dependency. Both
crates are current stable releases from crates.io, and `rsa_keys::private_key`
is ordinary code in this crate calling `rsa::RsaPrivateKey::from_components` —
the same public API `ssh-key` calls internally, with `q` where `ssh-key`
repeats `p`.
#### Why not let ssh-key build the key, as Bitwarden does
Bitwarden's own desktop agent (`apps/desktop/desktop_native/ssh_agent`, the v2
rebuild that replaced the deprecated `bitwarden-russh` fork) reaches the same
two conclusions this decision does: it pins `ssh-key` at exactly 0.6.7 with no
`[patch]` section, implements the agent protocol itself rather than taking a
protocol crate, and depends on `rsa` directly to build the SHA-256 signing key
`ssh-key` cannot produce.
Where it differs is that it lets `ssh-key` perform the keypair conversion, and
that works only because its lockfile pins `rsa` **0.9.6**. In 0.9.6,
`from_components` validates only when it had to recover the primes itself, so a
key with `p` supplied twice is accepted. Verified against both releases: on
0.9.6 the resulting key holds two identical primes, CRT precomputation fails
silently, `validate()` returns `InvalidModulus`, and signatures still verify
because signing falls back to the plain `d`/`n` path. From 0.9.7 onward
validation is unconditional and the same call returns an error — which in
Bitwarden's `sign_rsa` is an `.expect()`.
So the alternative to `rsa_keys` is to pin an older `rsa` and sign with a
private key that fails its own `validate()`. This crate would rather hold a
correct key on the current release.
### The agent protocol is implemented here, not taken from a crate
`ssh-agent-lib` 0.6.0 (May 2026) is maintained and well used, and it was the
obvious candidate. Its framing codec does not bound the frame length: it reads
a `u32` and waits for that many bytes, returning `Ok(None)` until they arrive
(`ssh-agent-lib-0.6.0/src/codec.rs`). A same-UID client can therefore make the
agent buffer toward 4 GiB, which is the opposite of this design's requirement
that frames over 256 KiB be rejected outright.
Its `Session` trait also spans the whole message set, while this agent
answers exactly two requests and refuses everything else, and it wraps the
listener in a way that puts `SO_PEERCRED` and the approval gate further from
the accept path than a security review wants them.
So Task 5 implements an allowlisted decoder directly on `ssh-encoding`, as
`tasks/todo.md` already assumed. RFC 9987 (Standards Track, May 2026) is now
the normative reference for the wire format, which is a better position than
this project would have been in a year ago. `ssh-agent-lib` remains a useful
cross-check for message encoding during Task 5.
`bitwarden-russh` is confirmed deprecated by its own README, and Bitwarden's
v2 agent is an in-progress rebuild that has itself moved to upstream crates
plus a hand-written protocol layer. Neither is a dependency here, and nothing
in this decision depends on when v2 lands.
### RSA timing: RUSTSEC-2023-0071 is present and unpatched
`rsa` 0.9.10 is affected by the Marvin attack advisory (medium, no patched
version, constant-time work still in progress upstream). It is the only
advisory that applies to this tree; `curve25519-dalek` (RUSTSEC-2024-0344),
`ed25519-dalek` (RUSTSEC-2022-0093), `tokio` (RUSTSEC-2025-0023), and `sha2`
(RUSTSEC-2021-0100) are all patched at the pinned versions.
It is accepted for v1, for reasons that should be stated plainly rather than
waved through:
- The advisory describes key recovery from timing measurements of many private
key operations. The attacker in this design is a local process running as the
same UID, which the threat model already treats as able to read the panel's
`bw` child and its decrypted vault directly. It does not need a timing oracle.
- Every signature requires a live human approval or an unexpired process grant,
and at most four sign requests exist at a time. That is not a rate an
adaptive timing attack can work with.
- The alternative backends are worse trades: `rsa` 0.10 is a release candidate,
and binding OpenSSL or `ring` for RSA-only signing adds a native build and a
larger attack surface than the advisory it would retire.
This is recorded so it is reviewed again rather than inherited silently: if
`rsa` publishes a constant-time release, take it. Ed25519 keys — what most
Bitwarden SSH items will be — are not affected either way.
### Public-key file export moves to the panel
`docs/ideas/ssh-agent.md` planned for the companion to own the `.pub` file
projection from its validated keystore, and the plan asked this task to
confirm or correct that. It is corrected: the **panel** writes the files, from
the validated public set the companion reports over the control channel.
The companion is deliberately a process with no `PATH`, no `HOME`, no vault
credentials, and no children. Giving it a writable directory adds filesystem
surface to the one process holding private keys, to write data that is not
secret. The panel, by contrast, already writes files, already knows
`XDG_DATA_HOME`, and already has the hostile-filename sanitizer the attachment
path uses — which is the part of this that is actually delicate, since an item
name is decrypted vault content about to become a path. Duplicating that
sanitizer in Rust to serve a non-secret projection is the wrong division of
labour.
The companion stays authoritative about *which* keys are valid; the panel only
writes down what it is told. Task 15 implements it on that basis.
## Consequences
- `agent/rust-toolchain.toml` pins 1.98.0 with `rustfmt` and `clippy`. A distro
cargo ignores that file, so the reproducible build (Task 16) must run under
rustup in a pinned container and compare bytes — the toolchain is part of the
artifact, not a local preference.
- `panic = "abort"` and `strip = "symbols"` in the release profile: a
key-holding process should not unwind through arbitrary `Drop` impls or ship
symbols. CI keeps debug symbols as a separate artifact if they are ever
needed.
- Two workarounds are load-bearing and both are pinned by tests: the dalek
`zeroize` feature and `rsa_keys`. Neither can be removed silently.
- The licence set is MIT/Apache-2.0 plus BSD-3-Clause (`ed25519-dalek`,
`curve25519-dalek`, `subtle`) and BSD-2-Clause/Unlicense options elsewhere.
All are compatible with this plugin's MIT licence; the release must ship the
attribution notices (Task 19).
- `cargo-deny` (Task 16) gets an explicit allowlist for those licences and an
exception entry for RUSTSEC-2023-0071 with a link back to this decision, so
the advisory has to be re-approved rather than ignored.
## Verification
```bash
cargo test --manifest-path agent/Cargo.toml --locked
cargo build --manifest-path agent/Cargo.toml --locked
cargo fmt --manifest-path agent/Cargo.toml --check
cargo clippy --manifest-path agent/Cargo.toml --locked --all-targets -- -D warnings
```
## Sources
- [crates.io: ssh-key](https://crates.io/crates/ssh-key), [ssh-agent-lib](https://crates.io/crates/ssh-agent-lib), [rsa](https://crates.io/crates/rsa), [tokio](https://crates.io/crates/tokio), [rustix](https://crates.io/crates/rustix)
- [RustCrypto/SSH `ssh-key/src/private/rsa.rs` on master](https://github.com/RustCrypto/SSH/blob/master/ssh-key/src/private/rsa.rs) — the released 0.6.7 source in the local registry is the other half of that comparison
- [wiktor-k/ssh-agent-lib `src/codec.rs`](https://github.com/wiktor-k/ssh-agent-lib/blob/main/src/codec.rs)
- [RFC 9987: Secure Shell (SSH) Agent Protocol](https://www.rfc-editor.org/rfc/rfc9987.html)
- [bitwarden/bitwarden-russh](https://github.com/bitwarden/bitwarden-russh) — deprecation notice
- [bitwarden/clients `apps/desktop/desktop_native/ssh_agent`](https://github.com/bitwarden/clients/tree/main/apps/desktop/desktop_native/ssh_agent), and the `rsa` 0.9.6 pin in its `Cargo.lock`
- [RUSTSEC-2023-0071](https://rustsec.org/advisories/RUSTSEC-2023-0071.html), [RUSTSEC-2024-0344](https://rustsec.org/advisories/RUSTSEC-2024-0344.html), [RUSTSEC-2022-0093](https://rustsec.org/advisories/RUSTSEC-2022-0093.html), [RUSTSEC-2025-0023](https://rustsec.org/advisories/RUSTSEC-2025-0023.html)
@@ -0,0 +1,101 @@
# 2. Approval grants are scoped to a program, not a process
Date: 2026-08-27
## Status
Accepted. Supersedes the grant-scoping rule in `docs/ideas/ssh-agent.md`
("Bounded approval grants"), which this decision deliberately relaxes.
## Context
The design specified that an approval grant is "scoped to **one key and one
live client process**", keyed on the peer PID together with that PID's start
time and the executable path captured at grant time. PID reuse therefore
cannot inherit a grant, and a re-`exec` invalidates it.
That rule was justified by a usability argument:
> Approval strictly per signature is unusable for the workflows this feature
> exists to serve. A `git rebase` over twenty commits with `gpg.format=ssh` is
> twenty modal prompts; a fetch followed by a push is two.
Live testing against a real vault showed the rule does not achieve that. Git
does not hold a connection to the agent across commits: it spawns a **fresh
`ssh-keygen -Y sign` process for every commit it signs**. Every one of those
has a different PID and a different start time, so a PID-scoped grant never
matches. Approving "for this process" and then making a second signed commit
prompted again, and a twenty-commit rebase would prompt twenty times whether a
grant was taken or not.
So the grant, as specified, was close to inert: it helped only a single
long-lived process making repeated signature requests, which is not a workflow
this feature was built for. The button existed and did nothing useful.
## Decision
A grant is scoped to **one key and one program, for one user**: it matches on
the peer UID, the executable path captured at grant time, and the public key.
PID and process start time are still captured and shown in the prompt, but no
longer participate in matching.
The approval button says "Approve for this program", not "for this process",
because that is what it now does.
Unchanged:
- The peer UID must equal the companion's effective UID. That is the one
property the companion actually verifies, and it is not relaxed here.
- The window is still bounded by `sshAgentApprovalWindowSec` (default 120s,
maximum 900s, `0` disables grants entirely).
- Grants still live only in the companion's memory, never touch disk, and
never survive a restart.
- Every lifecycle event that dropped a grant before still drops it: expiry,
lock, logout, account change, epoch change, disabling the feature, screen
lock, suspend, `revoke_grants`, and per-grant revocation.
- A grant still replaces the prompt, not the final check. Epoch, lock state
and key identity are rechecked immediately before the signing primitive.
## Consequences
**What this accepts.** During an open window, *any* process running the same
executable path, as the same user, can obtain a signature with that key
without a prompt. Under PID scoping that was limited to one process.
**Why that is tolerable here.** The threat model this feature works within
already states it "does not claim to protect an unlocked desktop from
arbitrary code already running as the same user". A hostile same-UID process
that wanted a signature under the old rule could simply execute
`/usr/bin/ssh-keygen` itself and request one in its own right — it would face
a prompt, but so would any first request under either rule. The scope change
does not hand an attacker a capability they could not otherwise reach; it
removes a distinction that cost the user twenty prompts and bought a boundary
that a same-UID attacker was never obstructed by.
**What genuinely widens.** The window is now shared. If the user approves
`/usr/bin/ssh-keygen` for two minutes to sign a rebase, a concurrent hostile
invocation of that same binary during those two minutes signs without asking.
Under PID scoping it would have prompted. This is a real reduction, and it is
the price of the feature working at all.
**Mitigations retained.** The window defaults to 120 seconds rather than the
900-second maximum; grants are visible in the panel with their remaining time
and revocable individually or all at once; and every live grant is destroyed
by a lock, a screen lock, or a suspend.
**If this proves too wide**, the narrower option is to keep program scoping but
additionally require that the requesting process's parent match the one that
was approved — which would cover Git's per-commit children while excluding
unrelated invocations. That was not done here because parent PIDs are as
forgeable as any other `/proc` metadata and would add a check that reads as a
security boundary without being one.
## Alternatives considered
- **Key-only grants for the window.** Simplest, and matches what `ssh-agent`'s
own confirm timeout does. Rejected as wider than necessary: the program is
cheap to match on and excludes unrelated binaries.
- **Keep PID scoping and document the limit.** Honest, but leaves a button in
the UI that almost never does anything, which is its own kind of dishonesty.
- **Drop grants from v1.** Removes the machinery, but returns the twenty-prompt
rebase the design explicitly called unusable.
@@ -0,0 +1,94 @@
# 3. A signing request gives the user two minutes, not thirty seconds
Date: 2026-08-27
## Status
Accepted. Adjusts the request-deadline figure in `docs/ideas/ssh-agent.md`
("Panel-to-Companion Contract"), which specified thirty seconds.
## Context
The control contract said:
> Reject overflow, give each request a 30-second deadline, and cancel it when
> its client disconnects.
Thirty seconds is ample for a machine and short for a person. A signing
request has to travel further than the socket: the panel opens or takes focus,
the user notices it, reads a key name and a `SHA256:` fingerprint, considers
which program is asking, and decides. During live testing against a real vault
that budget expired twice under a user who was doing nothing more unusual than
reading the prompt he had been asked to read. Both expiries were recorded as
refusals, which then fed the denial cooldown and suppressed further prompts —
so a deadline that was merely tight cascaded into signing being disabled.
A second problem was found at the same time and is the more serious of the
two. The socket server bounded its wait for the state loop's answer with the
same `CLIENT_IO_TIMEOUT` it used for reading a frame and writing a reply:
```rust
let bytes = match timeout(CLIENT_IO_TIMEOUT, response).await { .. };
```
Both were thirty seconds, so the two clocks expired together by coincidence
rather than by design. Raising only the approval deadline would have left the
client giving up first and the new deadline doing nothing — the human bound
would have been decorative. The two values were coupled without ever being
related.
## Decision
Three separate bounds, each sized for what it actually waits on.
| Bound | Value | Waits on |
|---|---:|---|
| `approvals::REQUEST_LIFETIME_MS` | 120s | a person deciding |
| `server::RESPONSE_TIMEOUT` | 150s | the state loop's answer, on behalf of a blocked client |
| `server::CLIENT_IO_TIMEOUT` | 30s | a socket read or write |
`RESPONSE_TIMEOUT` deliberately exceeds `REQUEST_LIFETIME_MS`, so the
companion's deadline is always what fires first and there is one authority on
when a request is over. A test asserts that ordering, and asserts the
literal 120s figure so that changing it stays a deliberate act rather than a
side effect.
The held-request deadline used while a vault unlock is pending is derived from
`REQUEST_LIFETIME_MS` rather than repeated, because unlocking asks more of the
user than approving does and certainly needs no less time.
## Consequences
**A blocked client may now wait up to two minutes.** In practice it will not:
the mechanism that actually reclaims a request promptly is the client
disconnect, which the server watches for while a request is pending. Pressing
Ctrl-C on a `git push` ends the request immediately, and the panel's prompt is
withdrawn with it. The deadline is the backstop for a client that neither
answers nor leaves.
**The four-request bound is unchanged**, so at most four requests can be
waiting at once regardless of how long each may wait. A same-UID process can
still occupy those slots with junk requests and delay a legitimate one; that
was already inside the threat model this feature does not defend against, and
a longer deadline widens the window without changing the conclusion.
**Two minutes is still a deadline.** A request that nobody answers is refused,
the client is told, and the prompt comes down. Removing the bound entirely
would leave prompts and blocked clients accumulating with nothing to clear
them.
## Alternatives considered
- **Keep 30s and make the panel more attention-grabbing.** Rejected: the
design explicitly forbids desktop notifications in v1, and the remaining
levers (opening and focusing the panel) are already used.
- **Restart the clock when the prompt is first displayed.** Fairer in
principle, since the wait should start when the user could first act. It
needs the panel to report display state back to the companion, which adds a
control message and a way for a wrong answer to extend a deadline. Not worth
the surface for the benefit.
- **Make it configurable.** Another setting for something almost nobody would
tune, and a badly chosen value degrades either usability or the bound. The
figure is documented here instead.
- **Remove the deadline while the panel is open and focused.** Attractive, but
it makes the bound depend on window state the companion cannot verify.
@@ -0,0 +1,153 @@
# 4. SSH key creation stays out of the plugin, on private-key grounds
Date: 2026-09-03
## Status
Accepted. Confirms two entries in `docs/ideas/ssh-agent.md` ("Not Doing
Initially") — *Key generation or import* and *SSH item creation, editing, or
cloning in QML* — and replaces the reason recorded for one of them.
## Context
The question keeps coming back: the panel lists SSH keys, serves them to `ssh`
and Git, and can create every other item type. Why not create one?
The design deferred it twice, and the second entry gives a reason that is worth
re-reading:
> **SSH item creation, editing, or cloning in QML**: the CLI edit contract
> round-trips the complete cipher, so these need an opaque metadata-patch design
> that never exposes the existing private key to QML.
That reason is correct about **editing** and does not apply to **creation**.
`buildEditPayload` starts from a deep clone of `rawObject`, so editing a type-5
item would put the stored private key in QML. A key being created has no stored
private material to expose — whatever it holds, this plugin generated a moment
ago. The two cases were filed together and are not the same case.
So creation was re-examined on its own.
### What the CLI can actually do
The obvious first question was whether the vault boundary this plugin refuses to
cross can write a type-5 item at all. The first evidence said no:
```
$ bw get template item.sshKey
Unknown template object.
```
and the CLI's own template switch (`@bitwarden/cli` 2026.2.0, `build/bw.js`)
confirms the gap is deliberate — it has cases for `item.card`, `item.field`,
`item.identity`, `item.login`, `item.login.uri` and `item.securenote`, and no
case for `item.sshKey`.
That reading was wrong, and it is recorded here because it is the wrong
conclusion a reader is most likely to reach independently. The base item
template carries the field:
```
$ bw get template item
{... "card":null,"identity":null,"sshKey":null,"reprompt":0}
```
and the encrypt path — the one a create actually goes through — handles the
type in full:
```js
case CipherType.SshKey:
cipher.sshKey = new SshKey();
yield this.encryptObjProperty(model.sshKey, cipher.sshKey,
{ privateKey: null, publicKey: null, keyFingerprint: null }, key);
return;
```
**The CLI can encrypt and create a type-5 item.** The missing template is a
convenience gap, not a capability gap, and a hand-built payload carrying
`privateKey`, `publicKey` and `keyFingerprint` is very likely to be accepted.
This decision therefore cannot rest on "the CLI will not let us", because it
will.
## Decision
The plugin does not create SSH keys. The reason is private-key custody, not CLI
capability.
Every design that puts a new key in the vault has to answer where the private
key is generated and what it touches on the way. There are two candidates and
each one gives up a property the SSH work was built around.
**Generate in the helper.** It already holds private keys, in memory, never on
disk — that is the entire point of it being a separate process. But it has no
random-number generator, and that is deliberate. `rand_core` is a
dev-dependency, commented *"Test-only key generation, so no private key material
is committed"*, and `selftest.rs` explains the refusal directly:
> the only honest ways to get one are to generate it — which would put a
> random-number generator into a key-holding binary's dependency tree for the
> sake of a smoke test — or to embed one, which is exactly what this project
> refuses to do anywhere else.
Reversing that means a new release dependency in the audited supply chain, a
rebuilt binary, new committed bytes, a new `SHA256SUMS`, and a fresh provenance
attestation. `/agent/` and `/bin/` are CODEOWNERS-flagged for precisely this
class of change. It is not prohibitive — but it is a supply-chain decision, not
a feature decision, and it should be taken as one.
**Generate with `ssh-keygen`.** No helper change, no new dependency. It writes
the private key to disk, and "never on disk" is a property this feature states
plainly. A FIFO does not rescue it: `ssh-keygen` wants to write two real files.
**And either way**, the private key must reach `bw` through the panel's
`QSBW_ITEM` environment payload. That route is already trusted with passwords,
so it is not new machinery — but it is new for SSH private keys, which this
design has kept exclusively inside the helper, and it would mean QML briefly
holds the one class of secret it has never held.
None of that is unsolvable. It is a security design with a threat model to
revisit, and it does not belong in a release that is about card items and a
settings screen.
## Consequences
**Users create SSH keys in the web vault or the browser extension**, where the
feature shipped in 2025.1.0. The panel lists, serves and signs with them the
moment they exist. This is a gap in convenience, not in function.
**The type-5 read-only guards stay.** `createItemCommand`, `editItemCommand`,
`deleteItemCommand`, `buildCreatePayload`, `buildEditPayload` and
`startEditItem` all refuse type 5, and the sanitizing filter continues to reduce
type-5 items to public metadata before QML sees them. Those guards now have a
decision behind them rather than an unexamined default.
**A hazard for anyone tempted to test this quickly.** Confirming the create path
empirically means writing a real type-5 item, and the failure mode is not
symmetric. Bitwarden CLI below `2026.8.0` fails to decrypt SSH key items with a
null public key or fingerprint, and *one such item breaks the entire vault
list* — in this plugin and in every other client on that CLI, until the item is
removed. A malformed write is therefore not a harmless experiment on a vault
someone depends on. Test against a throwaway account or a local Vaultwarden.
**If this is revisited**, helper-side generation is the design to start from.
The dependency cost is real and reviewable; the alternative trades away two
properties — private keys never on disk, private keys never in QML — that are
harder to win back than a line in `Cargo.toml`.
## Alternatives considered
- **Import an existing key rather than generate one.** Avoids the RNG question
entirely, and the user already has the private key in a file. It still routes
private material through QML and `bw`, so it clears the smaller obstacle and
leaves the larger one, and it is a strictly less useful feature.
- **Create the item with only public material and fill the private key in
later.** This is the malformed-item shape named above. It would break vault
listing for every client on a CLI below 2026.8.0.
- **Have the helper write the item to `bw` itself**, keeping the private key out
of QML entirely. The most promising variant, and the one worth designing if
this is picked up: it would need the helper to hold a session token, which is
a widening of its role from "signs with keys it was given" to "acts on the
vault", and that deserves its own review.
- **Wait for a CLI template.** The absence of `item.sshKey` suggests Bitwarden
does not consider CLI creation a supported path yet. Waiting costs nothing and
may produce a supported shape to build against.
@@ -0,0 +1,100 @@
# Development
Linting and the test suite.
Omarchy plugins are Qt6/Quickshell, so lint with the **Qt6** `qmllint` --
`/usr/bin/qmllint` on Arch is the Qt5 binary from `qt5-declarative` and exits
255 with no diagnostics on this file. The `qs.*` modules resolve only when the
import path contains a directory named `qs`:
```bash
mkdir -p /tmp/qs-imports && ln -sfn /usr/share/omarchy/shell /tmp/qs-imports/qs
/usr/lib/qt6/bin/qmllint -I /tmp/qs-imports Panel.qml FormPickerRow.qml
```
Remaining `unqualified` and `missing-property` warnings are baseline Quickshell
noise -- the stock Omarchy plugins report the same categories -- as are the
`signal-handler-parameters` warnings on `Process.onExited`, whose
`QProcess::ExitStatus` argument qmllint cannot see.
Validate the manifest against the schema the shell enforces:
```bash
omarchy plugin validate .
```
---
## Tests
Regression suites require Node; the SSH-items boundary suite also exercises jq:
```bash
node tests/auth.test.js # unlock/login commands, and that no credential reaches argv
node tests/auth-prewarm.test.js # private FIFO lifecycle, byte-exact password delivery, and cancellation
node tests/context-match.test.js # window-title matching and learned suggestions
node tests/setup-settings.test.js # dependency probe, settings writer, PIN crypto
node tests/ssh-items.test.js # bounded out-of-process vault sanitization and SSH private-key exclusion
node tests/first-run.test.js # a fresh install with no `bw` yet: the setup gate, the
# sequence that follows the install, and what the
# in-panel install button asks for
node tests/generator.test.js # generator option clamping and strength
node tests/folders.test.js # folder parsing, filtering and assignment
node tests/sends.test.js # Send payloads, parsing, and argv-safety
node tests/collections.test.js # organization collections and item ownership
node tests/items.test.js # item parsing, and that a list entry can build the detail view
node tests/attachments.test.js # attachment metadata, that a vault file name cannot escape ~/Downloads,
# that a symlink cannot redirect a download, and the transfer ceilings
node tests/handoff-urls.test.js # session-handoff file path, and which URI schemes may be opened
node tests/rich-text.test.js # vault text is drawn as text, never parsed as markup
node tests/session-boot.test.js # a remembered session dies with the boot that minted it
node tests/stream-limits.test.js # every stream the shell reads is capped by its producer
node tests/lock-state.test.js # the auto-lock survives a suspend, the timings are clamped
# on the way in, and a read of a vault that has since closed
# is refused rather than rendered
node tests/lock-triggers.test.js # locking on screen lock and on suspend, and the window in
# which a terminal login's session key is accepted
node tests/hardening.test.js # `--` before every server-chosen id, the custom-server check,
# and that logging out takes the learned suggestions with it
node tests/buffer-scrub.test.js # emptying the pipe buffers a lock used to leave full, and the
# deadline and size ceiling on every generator-port request
node tests/initial-load.test.js # items render before folders, organizations and status refresh
node tests/performance.test.js # deterministic small/typical/large/stress vault guardrails
```
The performance suite generates invented 100-item/0.25 MiB, 500-item/1 MiB,
2,000-item/5 MiB and 5,000-item/14 MiB vaults. It reports p95 JSON parsing,
filtering and contextual-match times over 20 warm samples and fails on broad
regressions. It measures only in-process work after `bw` returns, so network,
server and CLI startup latency should be measured separately on the target
machine.
The 2026-08-24 auth benchmark used Bitwarden CLI 2026.2.0 and three runs with a
deliberately invalid password. A normal unlock took 2,641 ms median from submit
to result; after a three-second prewarm while the password screen was already
open, it took 1,026 ms -- a 1,615 ms / 61.1% reduction. These figures are a
same-machine comparison, not a universal latency promise.
Some suites need Qt rather than Node -- which any machine running the plugin
already has. They cover the things only a real Qt can answer: that Escape
reaches the panel from inside a text field, how Qt itself decides to draw a
string (which is what makes a vault value markup or text), and how wide the
kit's Button actually renders a given label in the shell's font.
That last one, `tst_row_widths.qml`, reads the panel's own QML and measures
every row of buttons against the width of the panel they sit in. It needs to
read those files from inside QML, which Qt gates behind an env var:
```bash
QML_XHR_ALLOW_FILE_READ=1 QT_QPA_PLATFORM=offscreen \
/usr/lib/qt6/bin/qmltestrunner -input tests/qml
```
Note the **Qt6** binary. A bare `qmltestrunner` on Arch is the Qt5 one from
`qt5-declarative`; it reports `Library import requires a version` and exits 1
with no test output at all. If a run prints nothing whatsoever, that is why.
`QT_ASSUME_STDERR_HAS_CONSOLE=1` is worth adding while debugging a QML test --
without it `console.log()` from inside QML is silently dropped.
---
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
# Colorized Menu-Bar Icon
Status: refined design proposal, implementation approved
## Problem Statement
How might we let users who run a colorized desktop theme make the Bitwarden
menu-bar icon feel native to that theme without introducing arbitrary color
configuration or weakening the icon's status indicators?
## Recommended Direction
Add a single **Colorize menu-bar icon** toggle to the existing General settings
screen. The setting is off by default, preserving the current appearance. When
enabled, the primary shield glyph uses Omarchy's live `Color.accent` value;
the preference stores only the boolean choice, so changing the desktop theme
automatically changes the icon color.
Only the primary shield is colorized. The locked-state padlock, missing-tool
badge, and error/setup indicators retain their existing foreground and urgent
colors. This keeps the accent color as personalization while preserving the
meaning of exceptional states.
The feature fits the existing settings path: schema and manifest metadata,
`omarchy bar set` persistence, live shell reload, and the current settings-row
keyboard interaction. No custom RGB picker or new dependency is needed.
## Key Assumptions to Validate
- [ ] Users want the active theme accent specifically, rather than an
independently chosen RGB value — validate with the first implementation and
feedback from users who requested colorization.
- [ ] Keeping urgent/error badges independent is sufficient to preserve state
recognition — verify visually in locked, setup-required, and error states.
- [ ] A boolean toggle is discoverable enough in the existing General section
— confirm the label and description are clear in the settings screenshot or
runtime review.
## MVP Scope
- Add `colorizeIcon` as a boolean setting, defaulting to `false`.
- Add a General settings row labeled **Colorize menu-bar icon** with a
description explaining that it follows the active theme accent.
- Bind the primary menu-bar shield color to `Color.accent` when enabled and to
the existing bar foreground when disabled.
- Leave status badges and urgent/error colors unchanged.
- Add model, manifest, persistence, malformed-value, and QML wiring tests.
- Verify that theme changes are reflected after the shell reloads the panel.
## Not Doing (and Why)
- Arbitrary color picker or hex input — conflicts with the theme-derived goal
and adds validation and contrast problems.
- Multiple palette choices — the accent is the one semantic theme color users
are most likely asking for; broader palettes can follow if demand appears.
- Per-state color customization — risks making locked and error states harder
to recognize.
- Changing the panel's internal icon, controls, or status badges — the request
is specifically about the menu-bar icon's primary glyph.
## Open Questions
- Should the setting be called **Colorize menu-bar icon** or **Use theme accent
for icon**? The former is more approachable; the latter is more explicit.
- Does the active accent maintain adequate contrast across the supported Omarchy
themes, especially in light themes?
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,115 @@
# Spec: Centered SSH Approval Popup
Status: implementation approved by the feature request
## Objective
Add an opt-in SSH authorization surface that appears in the center of the
active bar output instead of opening the Bitwarden panel. A locked vault first
shows a clear unlock-required state and the configured unlock controls; after
unlocking, the same transient surface changes to the existing SSH signing
approval. The surface disappears as soon as the request is answered,
cancelled, or expires.
## Tech Stack
- QML/Qt 6 with Quickshell 0.3.1 and Omarchy 4.0.2.
- The existing `Panel` root remains the owner of vault, SSH-helper, request,
deadline, cooldown, and unlock state.
- A Quickshell `PanelWindow` on the Wayland overlay layer presents the centered
surface on the bar widget's output.
- No new dependency or helper protocol message is introduced.
## Commands
```bash
node tests/ssh-agent-setup.test.js
node tests/ssh-agent-ui.test.js
for test_file in tests/*.test.js; do node "$test_file" || exit 1; done
env -u DISPLAY -u WAYLAND_DISPLAY -u QT_QPA_PLATFORMTHEME \
QT_QPA_PLATFORM=offscreen /usr/lib/qt6/bin/qmltestrunner -input tests/qml
mkdir -p /tmp/qs-imports
ln -sfn /usr/share/omarchy/shell /tmp/qs-imports/qs
/usr/lib/qt6/bin/qmllint -I /tmp/qs-imports Panel.qml SshApprovalPopup.qml SshApprovalScreen.qml SshUnlockScreen.qml
omarchy plugin validate .
```
## Project Structure
- `Panel.qml`: owns request routing, vault state, unlock actions, and setting
values.
- `SshApprovalPopup.qml`: owns only the centered window, focus, and dismissal.
- `SshApprovalScreen.qml`: reusable authorization content for panel and popup.
- `SshUnlockScreen.qml`: popup unlock-required status and unlock controls.
- `BitwardenModel.js` / `manifest.json`: setting contract and safe default.
- `tests/`: static integration and pure model tests; `tests/qml/`: QML tests.
## Code Style
Keep presentation declarative and authorization imperative in `Panel.qml`:
```qml
SshApprovalPopup {
panel: root
anchorItem: button
}
```
All request-derived text uses `Text.PlainText`. Use Omarchy spacing, color,
border, typography, input, and button components rather than custom values.
## Testing Strategy
- Add failing contract tests for the new setting in both manifest and model.
- Add failing wiring tests proving popup mode does not call `root.open()`, the
legacy mode still does, and unlock transitions reuse the same pending
request.
- Lint every new QML component against the installed Omarchy imports.
- Run the full JavaScript and QML suites before handoff.
- Runtime acceptance requires an enabled development plugin, a locked vault,
and a real SSH signing request; no real credentials belong in tests or logs.
## Boundaries
- Always: keep the setting false by default; deny on Escape, outside click,
cancellation, and timeout; render request metadata as plain text; clear
transient unlock input when the popup closes.
- Ask first: changing helper authorization, request deadlines, cooldown rules,
credential storage, or the SSH control protocol.
- Never: put session tokens, passwords, private keys, payloads, or signatures
in popup-local persistent state, command arguments, logs, or fixtures.
## Threat Model
- Request key/process labels cross from a same-UID client through the helper
and are untrusted display data. Plain-text rendering prevents markup from
becoming UI.
- The overlay is presentation, not an authorization boundary. Existing helper
request IDs, epochs, deadlines, peer checks, and final authorization checks
remain authoritative.
- The desktop lock-state gate remains ahead of both panel and popup prompts.
- The master password and recovered PIN/fingerprint password continue through
the existing bounded private-FIFO path and are scrubbed by existing process
cleanup.
- A popup must never approve from a bare Enter key; denial is the default
focused action.
## Success Criteria
- `sshAgentApprovalPopup` is a boolean setting, disabled by default.
- With it disabled, SSH unlock and approval requests behave exactly as before.
- With it enabled, an SSH request does not open or navigate the anchored panel.
- A locked vault shows why it must be unlocked and offers configured PIN,
fingerprint, and master-password paths without duplicating auth logic.
- A successful unlock changes the same centered surface to the signing prompt,
including key, fingerprint, requesting program, deadline, and grant option.
- Deny, approve, cancellation, timeout, and outside click remove the surface;
a panel the user already opened remains where it was.
- The centered window uses the bar widget's output, stays within the output at
narrow sizes, follows the Omarchy theme, and is fully keyboard operable.
## Open Questions
None for implementation. Full account login remains a panel workflow; the
popup is intentionally limited to a signed-in but locked vault and SSH signing
authorization.
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,63 @@
# Uninstall
**Turn the SSH agent off first, if you had it on**, and press **Remove Plugin
Data** on the settings screen. Between them those clear everything this plugin
put outside its own folder: the helper stops cleanly and takes its socket,
FIFO and routing file with it, and the button clears the keyring entries, the
learned suggestions and the exported public keys. Both have to happen before
the next step, because `omarchy plugin remove` has no uninstall hook -- once
the folder is gone there is no code left to run.
```bash
omarchy plugin remove io.github.elevate08.qs-bitwarden-cli
```
That removes the plugin folder and its bar entry. If you skipped the two steps
above, or you are cleaning up after a plugin that is already gone, this is the
same work by hand:
```bash
# Session key, and the master password stored for PIN/fingerprint unlock
secret-tool clear service qs-bitwarden-cli
# Learned window-title -> vault item suggestions
rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/qs-bitwarden-cli"
# Settings block: already gone. `omarchy plugin remove` takes the bar entry
# and its settings with it. If a stale one is left -- from a plugin removed
# some other way -- clear it through the shell, never by editing the file:
# omarchy plugin disable io.github.elevate08.qs-bitwarden-cli
# SSH agent, if you used it: the exported public keys and the routing file
rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/qs-bitwarden-cli/ssh"
rm -f ~/.config/uwsm/env.d/50-qs-bitwarden-ssh-agent
```
**Do not hand-edit `~/.config/omarchy/shell.json`.** On many setups it is not a
regular file: Omarchy configs are commonly managed with `stow` or another
dotfile manager, which puts a symlink there pointing into a repository. Deleting
"the file" then deletes the link, the shell falls back to its built-in defaults,
and every plugin you had configured disappears at once -- not just this one. The
config itself is unharmed, sitting in the repository the link pointed at, but
working that out from an empty bar is not a pleasant few minutes. Every command
above goes through the shell or touches only this plugin's own paths.
The agent's socket, FIFO and lock under `$XDG_RUNTIME_DIR` are removed when the
helper shuts down, which is what turning the agent off does. Removing the
plugin while the agent is still running kills the helper instead, so those
three files are left until you log out and the tmpfs goes with the session; a
stale socket at the routed path is harmless but answers nothing. Deleting the
directory by hand is safe once no helper is running.
Two more paths are written but need no cleaning up, because neither outlives
the moment it is used: the session handoff file under `$XDG_RUNTIME_DIR`, which
is read once and deleted and is on a tmpfs that goes with the login session,
and a `.qsbw-` staging directory inside your download folder, which exists only
for the length of an attachment download and is removed however that download
ends. Saved attachments themselves stay where you saved them, mode `600`.
Beyond those, the plugin writes nothing outside the paths above and your
`shell.json` entry, and it never modifies your Bitwarden vault on removal. Your vault is untouched -- log
out of the `bw` CLI separately with `bw logout` if you also want that cleared.
---