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
@@ -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.
|
||||
|
||||
---
|
||||
@@ -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?
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -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.
|
||||
|
||||
---
|
||||