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.