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
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,105 @@
import QtQuick
import qs.Commons
import qs.Ui
import "BitwardenModel.js" as Model
// One labelled, copyable field on the detail screen.
//
// The detail view drew each of these longhand -- a PanelSectionHeader, a
// BorderSurface, a Text and one or two PanelActionButtons, forty lines at a
// time. That was tolerable while only a login had fields worth showing.
// Cards and identities together add sixteen more, and sixteen more copies of
// the same forty lines is how the surfaces drift apart: one row elides and
// the next does not, one masks and the next forgets to.
//
// Empty is not a state worth drawing. `visible` is false when there is no
// value, so a caller can declare every field a type can carry and let the
// sparse ones -- most of an identity, most of the time -- take themselves off
// the screen rather than leaving labelled blanks behind.
Column {
id: root
required property string label
required property string value
required property color foreground
required property string fontFamily
// A value that should not sit in plain sight on a shared screen: a card
// number, a security code, a social security number. Masked until revealed,
// and the reveal is per-field rather than a screen-wide switch.
property bool sensitive: false
property bool revealed: false
// What the flash message calls this once it is on the clipboard.
property string copyLabel: label
// Appended to the copy button's tooltip, e.g. "(n)". Empty when the field
// has no key bound to it.
property string shortcutHint: ""
// The same, for the reveal button. Separate because only one field per item
// is reachable by `v` -- promising it on the others would be a lie, and the
// reveal on each field is independent of every other.
property string revealHint: ""
// The copy button's glyph. Defaults to a plain copy icon; callers pass a
// semantic one where the detail view already had it, so a converted row
// keeps the icon it has always drawn.
property string copyIcon: "󰈙"
signal copyRequested()
signal revealToggled()
readonly property bool masked: root.sensitive && !root.revealed
visible: root.value !== ""
width: parent ? parent.width : 0
spacing: Style.space(4)
PanelSectionHeader { text: root.label.toUpperCase() }
BorderSurface {
width: parent.width
implicitHeight: Style.space(34)
radius: Style.cornerRadius
color: Style.hoverFillFor(root.foreground, Color.accent)
borderSpec: Border.controlSpec("normal", root.foreground, Color.accent)
Row {
anchors.fill: parent
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(6)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: root.masked ? Model.maskString(root.value) : root.value
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
width: parent.width - fieldActions.width - Style.space(10)
}
Row {
id: fieldActions
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(4)
PanelActionButton {
visible: root.sensitive
iconText: root.revealed ? "󰈉" : "󰈈"
tooltipText: (root.revealed ? "Hide " : "Reveal ") + root.copyLabel.toLowerCase()
+ (root.revealHint === "" ? "" : " (" + root.revealHint + ")")
fontFamily: root.fontFamily
onClicked: root.revealToggled()
}
PanelActionButton {
iconText: root.copyIcon
tooltipText: "Copy " + root.copyLabel.toLowerCase()
+ (root.shortcutHint === "" ? "" : " (" + root.shortcutHint + ")")
fontFamily: root.fontFamily
onClicked: root.copyRequested()
}
}
}
}
}
@@ -0,0 +1,73 @@
import QtQuick
import qs.Commons
import qs.Ui
// One row in an item-form picker: folder, organization, or collection.
//
// `multi` distinguishes the two behaviours. A folder or organization is a
// single choice, so its mark is a tick; a collection is one of several an item
// may belong to, so its mark is a checkbox that reads as toggleable.
BorderSurface {
id: row
property string label: ""
property string glyph: ""
property bool picked: false
property bool multi: false
property color foreground: Color.foreground
property string fontFamily: Style.font.family
signal activated()
implicitHeight: Style.space(28)
radius: Style.cornerRadius
color: picked ? Style.selectedFillFor(foreground, Color.accent)
: (mouse.containsMouse ? Style.hoverFillFor(foreground, Color.accent) : "transparent")
borderSpec: Border.surfaceSpec("menu", "border", picked ? Color.accent : "transparent", picked ? 1 : 0)
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: row.activated()
}
Row {
anchors.fill: parent
anchors.leftMargin: Style.space(9)
anchors.rightMargin: Style.space(9)
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: row.glyph
color: row.picked ? Color.accent : Qt.darker(row.foreground, 1.5)
font.family: row.fontFamily
font.pixelSize: Style.font.bodySmall
}
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
width: parent.width - Style.space(46)
text: row.label
color: row.picked ? Color.accent : row.foreground
font.family: row.fontFamily
font.pixelSize: Style.font.bodySmall
font.bold: row.picked
elide: Text.ElideRight
}
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
visible: row.multi || row.picked
text: row.multi ? (row.picked ? "󰄲" : "󰄱") : "󰄬"
color: row.picked ? Color.accent : Qt.darker(row.foreground, 1.6)
font.family: row.fontFamily
font.pixelSize: Style.font.bodySmall
}
}
}
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 David Spencer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,253 @@
import QtQuick
import qs.Commons
import qs.Ui
import "BitwardenModel.js" as Model
// The SSH agent's own settings sections, lifted out of Panel.qml so that file
// is not the only place this feature can be read.
//
// Two separate things, deliberately drawn apart. The top half is what the
// feature is doing; the bottom half is whether the user's terminals will
// reach it. Neither one gates the other. The approval screen lives with the
// other screens in Panel.qml, because that is what it is.
//
// `panel` is the Panel root: this section reads its vault and agent state and
// calls back into it for every action. Nothing here holds state of its own.
Column {
id: section
required property var panel
// The bar's foreground and font family, not the global theme's -- the same
// values the rest of the panel draws with. PanelSectionHeader and Text both
// default to the globals, so every text element here states them.
component SshSectionHeader: PanelSectionHeader {
textFormat: Text.PlainText
foreground: section.panel.fg
fontFamily: section.panel.fontFamily
}
component SshCaption: Text {
textFormat: Text.PlainText
width: parent ? parent.width : 0
color: section.panel.dim
font.family: section.panel.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
visible: panel.sshUiAvailable
width: parent.width
spacing: Style.space(6)
Item { width: parent.width; height: Style.space(10) }
SshSectionHeader {
text: "SSH AGENT STATUS"
}
Row {
width: parent.width
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: panel.sshAgentSetup.state === "enabled"
? (panel.sshAgentSetup.busy ? "󰔟" : "󰄬")
: (panel.sshAgentSetup.state === "error" ? "󰀪" : "󰅘")
color: panel.sshAgentSetup.state === "error"
? panel.urgent
: (panel.sshAgentSetup.state === "enabled" && !panel.sshAgentSetup.busy ? Color.accent : panel.dim)
font.family: panel.fontFamily
font.pixelSize: Style.font.body
}
SshCaption {
width: parent.width - Style.space(30)
text: panel.sshAgentSetup.message
color: panel.sshAgentSetup.state === "error" ? panel.urgent : panel.dim
}
}
// Which helper is running. A developer with a local build and a
// user on a release see the same panel otherwise, and confusing
// the two wastes an afternoon.
SshCaption {
visible: panel.sshAgentHelper.source !== ""
text: "Using " + Model.sshAgentHelperSourceLabel(panel.sshAgentHelper.source)
+ (panel.sshAgentHelper.checksum === "match" ? " (checksum verified)" : "")
color: panel.sshAgentHelper.source === "development" ? panel.urgent : panel.dim
}
// Why the feature is unavailable, when it is. These are the
// failures a real clone produces: a stale binary, a dropped file
// mode, an LFS placeholder.
SshCaption {
visible: panel.sshAgentEnabled && panel.sshAgentHelper.message !== ""
text: panel.sshAgentHelper.message
color: panel.urgent
}
// The helper's own version, once it has said hello. Non-secret,
// and the quickest way to tell a stale bundled binary apart from
// a working one.
SshCaption {
visible: panel.sshAgentVersion !== ""
text: "Helper version " + panel.sshAgentVersion
}
// Routing is the thing most likely to be missing when the agent looks
// healthy and SSH still does not use it. Said here because this is the
// block a user reads first, and decided by the routing file rather than by
// this session's SSH_AUTH_SOCK -- see sshAgentRoutingNotice for why.
SshCaption {
visible: panel.sshAgentSetup.state === "enabled" && !panel.sshAgentSetup.busy
&& panel.sshRoutingNotice.text !== ""
text: panel.sshRoutingNotice.text
color: panel.sshRoutingNotice.urgent ? panel.urgent : panel.dim
}
Item { width: parent.width; height: Style.space(10) }
SshSectionHeader {
text: "CLIENT ROUTING"
}
SshCaption {
text: panel.sshRouting.message
color: panel.sshRouting.state === "matches" ? panel.dim : panel.fg
}
// The check the user runs in the terminal they actually use --
// which is the only place the answer is authoritative.
Text {
textFormat: Text.PlainText
width: parent.width
text: " " + panel.sshRouting.terminalCheck
color: Color.accent
font.family: panel.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WrapAnywhere
}
SshCaption {
text: panel.uwsmFragment.message
}
// Replacing the session's primary agent is a real decision, so the
// conflict is stated and confirmed rather than absorbed by the
// first click.
SshCaption {
visible: panel.uwsmConfirmPending
text: "This will make Bitwarden your session's SSH agent at the next login, replacing "
+ (panel.sshRouting.owner !== "" ? panel.sshRouting.owner : "the one you have now")
+ ". Continue?"
color: panel.urgent
}
SshCaption {
visible: panel.uwsmFlash !== ""
text: panel.uwsmFlash
color: panel.fg
}
// A Flow, because which of these four are showing is decided by the routing
// state: the idle pair and the confirming pair are each narrow enough, but
// nothing in a Row enforces that, and a Row answers a set that is too wide by
// laying the last button out past the panel edge rather than wrapping it.
Flow {
width: parent.width
spacing: Style.space(8)
Button {
visible: !panel.uwsmConfirmPending && panel.uwsmFragment.state !== "managed"
text: "Route SSH Clients Here"
iconText: "󰌘"
tooltipText: "Write " + Model.uwsmFragmentDisplayPath() + " so the next login points SSH clients at this agent"
fontFamily: panel.fontFamily
fontSize: Style.font.bodySmall
enabled: !panel.uwsmBusy
onClicked: panel.beginUwsmSetup()
}
Button {
visible: panel.uwsmConfirmPending
text: "Yes, Replace It"
iconText: "󰄬"
fontFamily: panel.fontFamily
fontSize: Style.font.bodySmall
enabled: !panel.uwsmBusy
onClicked: panel.beginUwsmSetup()
}
Button {
visible: panel.uwsmConfirmPending
text: "Cancel"
iconText: "󰅘"
fontFamily: panel.fontFamily
fontSize: Style.font.bodySmall
onClicked: panel.cancelUwsmSetup()
}
Button {
visible: !panel.uwsmConfirmPending && panel.uwsmFragment.removable
text: "Remove Routing File"
iconText: "󰩹"
tooltipText: "Delete " + Model.uwsmFragmentDisplayPath()
fontFamily: panel.fontFamily
fontSize: Style.font.bodySmall
enabled: !panel.uwsmBusy
onClicked: panel.removeUwsmFragment()
}
}
Item {
visible: panel.sshGrants.length > 0
width: parent.width
height: visible ? Style.space(10) : 0
}
SshSectionHeader {
visible: panel.sshGrants.length > 0
text: "ACTIVE APPROVALS"
}
// Every live grant, with the process it belongs to and what is
// left of it. A grant is a window in which signing happens with
// no prompt, so it has to be visible and revocable while it runs.
Repeater {
model: panel.sshGrants
delegate: Row {
required property var modelData
width: parent.width
spacing: Style.space(8)
SshCaption {
width: parent.width - Style.space(110)
text: modelData.keyName + " · "
+ modelData.processName
+ " · " + modelData.remainingLabel
}
Button {
anchors.verticalCenter: parent.verticalCenter
text: "Revoke"
iconText: "󰩹"
fontFamily: panel.fontFamily
fontSize: Style.font.caption
onClicked: panel.revokeSshGrant(modelData.grantId)
}
}
}
Button {
visible: panel.sshGrants.length > 1
text: "Revoke All Approvals"
iconText: "󰩹"
tooltipText: "Drop every live approval; the next signature asks again"
fontFamily: panel.fontFamily
fontSize: Style.font.bodySmall
onClicked: panel.revokeAllSshGrants()
}
}
@@ -0,0 +1,163 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Ui
// A transient, centered SSH authorization surface. The full-screen layer
// window supplies the scrim, outside-click denial, and keyboard focus; only
// the compact card is visible. It is tied to the bar widget's screen but not
// positioned relative to the bar, so the plugin otherwise stays out of sight.
PanelWindow {
id: popup
required property var panel
required property Item anchorItem
readonly property bool open: panel.sshAgentApprovalPopup && (panel.sshPrompt !== null || panel.sshUnlockRequest !== null)
property bool focusPrimed: false
readonly property var anchorWindow: anchorItem ? anchorItem.QsWindow.window : null
readonly property int cardWidth: Math.max(1, Math.min(Style.space(460), width - Style.gapsOut * 2))
readonly property int cardHeight: Math.max(1, Math.min(
content.implicitHeight + card.contentTopInset + card.contentBottomInset,
height - Style.gapsOut * 2))
function beginFocusPrime() {
if (open && backingWindowVisible) focusPrimeTimer.restart()
}
function refocus() {
if (!open) return
Qt.callLater(function() {
if (!popup.open) return
if (popup.panel.sshPrompt) approvalScreen.focusDefault()
else unlockScreen.focusDefault()
})
}
screen: anchorItem && anchorItem.QsWindow.window ? anchorItem.QsWindow.window.screen : null
visible: open
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "qs-bitwarden-ssh-approval"
WlrLayershell.layer: WlrLayer.Overlay
// Prime focus briefly so keyboard-summoned requests reliably receive it,
// then settle to OnDemand so another monitor is not pointer-blocked.
WlrLayershell.keyboardFocus: open
? (focusPrimed ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive)
: WlrKeyboardFocus.None
anchors {
top: true
bottom: true
left: true
right: true
}
onBackingWindowVisibleChanged: beginFocusPrime()
onOpenChanged: {
if (open) {
focusPrimed = false
beginFocusPrime()
refocus()
} else {
focusPrimeTimer.stop()
focusPrimed = false
}
}
Connections {
target: popup.panel
function onSshPromptChanged() { popup.refocus() }
function onSshUnlockRequestChanged() { popup.refocus() }
function onStatusChanged() { popup.refocus() }
}
Timer {
id: focusPrimeTimer
interval: 75
repeat: false
onTriggered: {
popup.focusPrimed = true
popup.refocus()
}
}
Rectangle {
anchors.fill: parent
color: Color.menu.scrim
}
MouseArea {
anchors.fill: parent
onClicked: popup.panel.denySshRequest()
}
BorderSurface {
id: card
width: popup.cardWidth
height: popup.cardHeight
anchors.centerIn: parent
radius: Style.cornerRadius
color: Color.popups.background
borderSpec: Border.surfaceSpec("popups", "border", Color.popups.border,
Math.max(1, Style.space(2)))
padding: Style.spacing.panelPadding
// Swallow clicks on unused card space; only a click outside the card is a
// denial. Interactive children declared below remain above this catcher.
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
id: keyScope
anchors.fill: parent
anchors.topMargin: card.contentTopInset
anchors.rightMargin: card.contentRightInset
anchors.bottomMargin: card.contentBottomInset
anchors.leftMargin: card.contentLeftInset
focus: popup.open
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
if (!(event.modifiers & ~Qt.KeypadModifier)) {
popup.panel.denySshRequest()
event.accepted = true
} else if (event.modifiers & Qt.ShiftModifier) {
popup.panel.denyAllSshRequests()
event.accepted = true
}
}
}
Flickable {
id: scroller
anchors.fill: parent
contentWidth: width
contentHeight: content.implicitHeight
clip: true
flickableDirection: Flickable.VerticalFlick
boundsBehavior: Flickable.StopAtBounds
interactive: contentHeight > height
Column {
id: content
width: scroller.width
SshUnlockScreen {
id: unlockScreen
panel: popup.panel
active: popup.open && popup.panel.sshPrompt === null
}
SshApprovalScreen {
id: approvalScreen
panel: popup.panel
active: popup.open && popup.panel.sshPrompt !== null
}
}
}
}
}
}
@@ -0,0 +1,210 @@
import QtQuick
import qs.Commons
import qs.Ui
import "BitwardenModel.js" as Model
// SCREEN: SSH signing approval.
//
// The one place a signature is authorised. It states what the companion
// verified -- the requesting user -- and is explicit that everything else
// about the process is context rather than identity.
//
// `panel` is the Panel root. This screen holds no state: it draws the pending
// request and calls back for the answer.
Column {
id: screen
required property var panel
property bool active: panel.activeScreen === "sshApproval"
// A signing decision should never open with an affirmative action focused.
// Both the anchored panel and the centered popup can call this after their
// window receives keyboard focus.
function focusDefault() {
if (screen.active && screen.visible) denyButton.forceActiveFocus()
}
// The bar's foreground and font family rather than the global theme's, the
// same as every other text element in this panel. Text defaults to AutoText,
// so the format is stated even where the string is constant today.
component SshSectionHeader: PanelSectionHeader {
textFormat: Text.PlainText
foreground: screen.panel.fg
fontFamily: screen.panel.fontFamily
}
component SshCaption: Text {
textFormat: Text.PlainText
width: parent ? parent.width : 0
color: screen.panel.dim
font.family: screen.panel.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
visible: active && panel.sshPrompt !== null
width: parent.width
spacing: Style.space(12)
PanelSeparator {
visible: !screen.panel.sshAgentApprovalPopup
width: parent.width
}
Row {
width: parent.width
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: "󰌆"
color: Color.accent
font.family: panel.fontFamily
font.pixelSize: Style.font.body
}
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: "SSH signing request"
color: panel.fg
font.family: panel.fontFamily
font.pixelSize: Style.font.body
}
Item { width: Math.max(0, parent.width - Style.space(panel.sshPendingCount > 1 ? 290 : 230)); height: 1 }
Text {
textFormat: Text.PlainText
visible: panel.sshPendingCount > 1
anchors.verticalCenter: parent.verticalCenter
text: "1 of " + panel.sshPendingCount
color: Color.accent
font.family: panel.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: panel.sshPromptRemainingSec + "s left"
color: panel.sshPromptRemainingSec <= 5 ? panel.urgent : panel.dim
font.family: panel.fontFamily
font.pixelSize: Style.font.caption
}
}
// Forwarding is rejected in v1. If one ever reaches here it is
// called out rather than shown as ordinary context, because the
// process named would not be the one using the signature.
SshCaption {
visible: panel.sshPrompt && panel.sshPrompt.forwardedWarning !== ""
text: panel.sshPrompt ? panel.sshPrompt.forwardedWarning : ""
color: panel.urgent
}
SshCaption {
visible: panel.sshAgentLoadActive
text: Model.sshAgentLoadingNote()
}
SshSectionHeader {
text: "KEY"
}
Text {
textFormat: Text.PlainText
width: parent.width
text: panel.sshPrompt ? panel.sshPrompt.keyName : ""
color: panel.fg
font.family: panel.fontFamily
font.pixelSize: Style.font.body
wrapMode: Text.WordWrap
}
// The fingerprint is the value worth checking, so it is shown whole
// rather than elided.
SshCaption {
text: panel.sshPrompt ? panel.sshPrompt.fingerprint : ""
wrapMode: Text.WrapAnywhere
}
SshSectionHeader {
text: "REQUESTED BY"
}
Text {
textFormat: Text.PlainText
width: parent.width
text: panel.sshPrompt
? panel.sshPrompt.processName
: ""
color: panel.fg
font.family: panel.fontFamily
font.pixelSize: Style.font.body
wrapMode: Text.WordWrap
}
SshCaption {
text: panel.sshPrompt ? panel.sshPrompt.processPath : ""
wrapMode: Text.WrapAnywhere
}
SshCaption {
text: panel.sshPrompt ? panel.sshPrompt.provenanceNote : ""
}
PanelSeparator {
visible: !screen.panel.sshAgentApprovalPopup
width: parent.width
}
// Deny leads, and nothing is activated by a bare Enter: a stray
// keypress must not be able to sign.
Row {
width: parent.width
spacing: Style.space(8)
Button {
id: denyButton
text: "Deny (Esc)"
iconText: "󰅘"
fontFamily: panel.fontFamily
fontSize: Style.font.bodySmall
focusable: true
onClicked: panel.denySshRequest()
}
Button {
visible: panel.sshPendingCount > 1
text: "Deny all (" + panel.sshPendingCount + ")"
iconText: "󰅙"
fontFamily: panel.fontFamily
fontSize: Style.font.bodySmall
focusable: true
onClicked: panel.denyAllSshRequests()
}
Button {
text: "Approve once"
iconText: "󰄬"
fontFamily: panel.fontFamily
fontSize: Style.font.bodySmall
focusable: true
onClicked: panel.approveSshRequest(0)
}
}
Button {
visible: panel.sshPrompt && panel.sshPrompt.grantOffered
text: panel.sshPrompt ? panel.sshPrompt.grantLabel : ""
iconText: "󰔟"
tooltipText: "Sign further requests from this same program with this key, without asking again, until the window expires"
fontFamily: panel.fontFamily
fontSize: Style.font.bodySmall
focusable: true
onClicked: panel.approveSshRequest(panel.sshPrompt ? panel.sshPrompt.grantSeconds : 0)
}
}
@@ -0,0 +1,332 @@
import QtQuick
import qs.Commons
import qs.Ui
import "BitwardenModel.js" as Model
// The first step of an SSH request when the vault is locked. Uses the same
// layout, pulsing fingerprint animation, and unlock controls as the panel's
// unlock screen, while keeping the SSH request context visible.
Column {
id: screen
required property var panel
property bool active: false
visible: active && panel.sshUnlockRequest !== null
width: parent ? parent.width : 0
spacing: Style.space(12)
onVisibleChanged: if (!visible && eyeBtnUnlock) eyeBtnUnlock.revealed = false
onActiveChanged: if (!active && eyeBtnUnlock) eyeBtnUnlock.revealed = false
component UnlockCaption: Text {
textFormat: Text.PlainText
width: parent ? parent.width : 0
color: screen.panel.dim
font.family: screen.panel.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
function focusDefault() {
if (!screen.active || !screen.visible) return
screen.panel.prepareUnlock()
if (screen.panel.fingerprintReady) screen.panel.startFingerprintUnlock()
Qt.callLater(function() {
if (!screen.active || screen.panel.status !== "locked") return
if (screen.panel.pinReady) pinField.forceActiveFocus()
else passwordField.forceActiveFocus()
})
}
// Centered header matching Panel.qml Screen 2
Column {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(6)
Text {
id: fingerprintIcon
textFormat: Text.PlainText
anchors.horizontalCenter: parent.horizontalCenter
text: screen.panel.fingerprintScanning ? "󰈷" : "󰌋"
color: screen.panel.fingerprintScanning ? Color.accent : screen.panel.fg
opacity: 0.85
font.family: screen.panel.fontFamily
font.pixelSize: Style.space(38)
SequentialAnimation on opacity {
running: screen.panel.fingerprintScanning
loops: Animation.Infinite
NumberAnimation { to: 0.35; duration: 700; easing.type: Easing.InOutQuad }
NumberAnimation { to: 0.95; duration: 700; easing.type: Easing.InOutQuad }
onStopped: fingerprintIcon.opacity = 0.85
}
}
Text {
textFormat: Text.PlainText
anchors.horizontalCenter: parent.horizontalCenter
text: screen.panel.status === "unlocked"
? "Loading SSH keys"
: (screen.panel.fingerprintReady ? "Unlock Vault" : "Enter Master Password")
color: screen.panel.fg
font.family: screen.panel.fontFamily
font.pixelSize: Style.font.title
font.bold: true
}
Text {
textFormat: Text.PlainText
visible: screen.panel.userEmail !== ""
anchors.horizontalCenter: parent.horizontalCenter
text: screen.panel.userEmail
color: screen.panel.dim
font.family: screen.panel.fontFamily
font.pixelSize: Style.font.bodySmall
}
}
UnlockCaption {
text: {
var request = screen.panel.sshUnlockRequest
var prefix = "Vault needs to be unlocked first: "
if (!request) return "Vault needs to be unlocked first."
if (request.keyName !== "") {
return prefix + request.keyName + " is needed by " + request.processName + "."
}
return prefix + request.processName + " is asking which SSH keys are available."
}
horizontalAlignment: Text.AlignHCenter
color: screen.panel.fg
}
UnlockCaption {
text: "Unlocking only loads the key. You will still approve the signing request separately."
horizontalAlignment: Text.AlignHCenter
}
// Fingerprint status / prompt
Text {
textFormat: Text.PlainText
visible: screen.panel.fingerprintMessage !== ""
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: screen.panel.fingerprintMessage
color: screen.panel.fingerprintScanning ? Color.accent : screen.panel.dim
font.family: screen.panel.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
// Offered when fingerprint unlock is on but nothing is stored yet
Text {
textFormat: Text.PlainText
visible: screen.panel.fingerprintUnlock && screen.panel.fingerprintAvailable && !screen.panel.fingerprintStored
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: "󰈷 Unlock once with your master password to enable fingerprint unlock."
color: screen.panel.dim
font.family: screen.panel.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
// Checking / keys loading into helper indicator
Rectangle {
visible: screen.panel.status === "checking"
|| (screen.panel.status === "unlocked" && screen.panel.sshAgentLoadActive)
width: parent.width
height: loadingText.implicitHeight + Style.space(20)
radius: Style.cornerRadius
color: Util.alpha(Color.popups.text, 0.06)
Text {
id: loadingText
textFormat: Text.PlainText
anchors.centerIn: parent
width: parent.width - Style.space(24)
text: screen.panel.status === "checking"
? "Checking vault status..."
: Model.sshAgentLoadingNote()
color: screen.panel.fg
font.family: screen.panel.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
}
}
// PIN entry, offered above the password field when one is set
Column {
visible: screen.panel.status === "locked" && screen.panel.pinReady
width: parent.width
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
text: "PIN"
color: screen.panel.dim
font.family: screen.panel.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
Row {
width: parent.width
spacing: Style.space(8)
TextField {
id: pinField
width: parent.width - pinUnlockBtn.width - Style.space(8)
placeholderText: "Enter your PIN..."
password: true
text: screen.panel.pinEntry
onTextChanged: screen.panel.pinEntry = text.replace(/[^0-9]/g, "")
onAccepted: screen.panel.submitPinUnlock()
enabled: !screen.panel.pinBusy && !screen.panel.isUnlocking
}
Button {
id: pinUnlockBtn
text: screen.panel.pinBusy ? "Checking..." : "Unlock"
iconText: screen.panel.pinBusy ? "󰑐" : "󰌿"
iconSpinning: screen.panel.pinBusy
selected: true
accent: Color.accent
fontFamily: screen.panel.fontFamily
focusable: true
enabled: !screen.panel.pinBusy && !screen.panel.isUnlocking
onClicked: screen.panel.submitPinUnlock()
}
}
Text {
textFormat: Text.PlainText
visible: screen.panel.pinError !== ""
width: parent.width
text: screen.panel.pinError
color: screen.panel.urgent
font.family: screen.panel.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
Text {
textFormat: Text.PlainText
text: "or use your master password below"
color: screen.panel.dim
font.family: screen.panel.fontFamily
font.pixelSize: Style.font.caption
}
}
// Fingerprint / Password column matching Panel.qml
Column {
visible: screen.panel.status === "locked"
width: parent.width
spacing: Style.space(10)
Button {
visible: screen.panel.fingerprintReady
width: parent.width
text: screen.panel.fingerprintScanning ? "Waiting for fingerprint..." : "Unlock with Fingerprint"
iconText: "󰈷"
selected: true
accent: Color.accent
fontFamily: screen.panel.fontFamily
focusable: true
enabled: !screen.panel.isUnlocking && !screen.panel.fingerprintScanning
onClicked: screen.panel.startFingerprintUnlock()
}
Row {
width: parent.width
spacing: Style.space(8)
TextField {
id: passwordField
width: parent.width - eyeBtnUnlock.width - Style.space(8)
placeholderText: "Master password..."
password: !eyeBtnUnlock.revealed
text: screen.panel.masterPassword
onTextChanged: screen.panel.masterPassword = text
onActiveFocusChanged: if (activeFocus) screen.panel.prepareUnlock()
onAccepted: screen.panel.unlockVault()
enabled: !screen.panel.isUnlocking
}
Button {
id: eyeBtnUnlock
property bool revealed: false
iconText: revealed ? "󰈉" : "󰈈"
tooltipText: revealed ? "Hide password" : "Show password"
fontFamily: screen.panel.fontFamily
focusable: true
onClicked: revealed = !revealed
}
}
Button {
width: parent.width
text: screen.panel.isUnlocking ? "Unlocking..." : "Unlock Vault"
iconText: screen.panel.isUnlocking ? "󰑐" : "󰌋"
iconSpinning: screen.panel.isUnlocking
selected: true
accent: Color.accent
fontFamily: screen.panel.fontFamily
focusable: true
enabled: !screen.panel.isUnlocking
onClicked: screen.panel.unlockVault()
}
}
UnlockCaption {
visible: screen.panel.errorMessage !== ""
text: screen.panel.errorMessage
color: screen.panel.urgent
horizontalAlignment: Text.AlignHCenter
}
UnlockCaption {
visible: screen.panel.status === "unauthenticated"
text: "Sign in from the Bitwarden panel before using vault SSH keys."
color: screen.panel.urgent
horizontalAlignment: Text.AlignHCenter
}
Row {
width: parent.width
spacing: Style.space(8)
Button {
text: "Not now (Esc)"
iconText: "󰅘"
fontFamily: screen.panel.fontFamily
fontSize: Style.font.bodySmall
focusable: true
onClicked: screen.panel.denySshRequest()
}
Button {
visible: screen.panel.sshUnlockPendingCount > 1
text: "Deny all (" + screen.panel.sshUnlockPendingCount + ")"
iconText: "󰅙"
fontFamily: screen.panel.fontFamily
fontSize: Style.font.bodySmall
focusable: true
onClicked: screen.panel.denyAllSshRequests()
}
Item { width: Math.max(0, parent.width - Style.space(screen.panel.sshUnlockPendingCount > 1 ? 280 : 160)); height: 1 }
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: screen.panel.sshPromptRemainingSec + "s left"
color: screen.panel.sshPromptRemainingSec <= 5
? screen.panel.urgent : screen.panel.dim
font.family: screen.panel.fontFamily
font.pixelSize: Style.font.caption
}
}
}
@@ -0,0 +1,127 @@
import QtQuick
import qs.Commons
import qs.Ui
// A transient panel-local notice. It anchors to its parent as an overlay and
// deliberately reports no height to the parent's content layout.
BorderSurface {
id: root
required property string statusMessage
required property string errorMessage
required property bool statusSuppressed
required property color foreground
required property color surfaceColor
required property color accentColor
required property color urgentColor
required property string fontFamily
readonly property bool showsError: root.errorMessage !== ""
readonly property bool showsStatus: root.statusMessage !== "" && !root.statusSuppressed
readonly property bool shown: showsError || showsStatus
readonly property color tone: showsError ? root.urgentColor : root.accentColor
// Optional recovery offered alongside an error. Empty means none.
property string actionLabel: ""
signal errorDismissed()
signal actionRequested()
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: Style.space(10)
width: Math.min(parent.width - Style.space(20), Style.space(390))
implicitHeight: noticeRow.implicitHeight + Style.space(16)
z: 20
visible: opacity > 0
enabled: shown
opacity: shown ? 1 : 0
color: root.surfaceColor
radius: Style.cornerRadius
borderSpec: Border.surfaceSpec("menu", "border", root.tone, 1)
Accessible.role: Accessible.AlertMessage
Accessible.name: (root.showsError ? "Needs attention: " : "Status: ") + noticeMessage.text
Accessible.ignored: !root.shown
Behavior on opacity {
NumberAnimation { duration: 140; easing.type: Easing.OutQuad }
}
// Consume pointer presses on the floating surface so covered controls
// cannot be activated through it.
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.AllButtons
}
Row {
id: noticeRow
anchors.fill: parent
anchors.margins: Style.space(8)
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
id: noticeIcon
anchors.verticalCenter: parent.verticalCenter
text: root.showsError ? "󰅚" : "󰋼"
color: root.tone
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - noticeIcon.implicitWidth - Style.space(8)
- (dismissNoticeButton.visible
? dismissNoticeButton.implicitWidth + Style.space(8)
: 0)
spacing: Style.space(2)
Text {
textFormat: Text.PlainText
width: parent.width
text: root.showsError ? "NEEDS ATTENTION" : "STATUS"
color: root.tone
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
Text {
textFormat: Text.PlainText
id: noticeMessage
width: parent.width
text: root.showsError ? root.errorMessage : root.statusMessage
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.Wrap
}
}
// An error the user can do something about carries the doing with it. A
// failed save is the case this exists for: the message says the vault
// refused it, and the button is the way back to what was typed.
PanelActionButton {
id: noticeActionButton
visible: root.showsError && root.actionLabel !== ""
anchors.verticalCenter: parent.verticalCenter
iconText: "󰑌"
tooltipText: root.actionLabel
fontFamily: root.fontFamily
onClicked: root.actionRequested()
}
PanelActionButton {
id: dismissNoticeButton
visible: root.showsError
anchors.verticalCenter: parent.verticalCenter
iconText: "󰅖"
tooltipText: "Dismiss message"
fontFamily: root.fontFamily
onClicked: root.errorDismissed()
}
}
}
@@ -0,0 +1,37 @@
import QtQuick
// Wheel scrolling for a Flickable, at a rate chosen here rather than inherited.
//
// Qt moves a Flickable by the platform's wheel-scroll-lines, which is tuned for
// a full-screen document and is a crawl in a panel three hundred pixels tall:
// several turns of the wheel to cross one screen of settings. The step below is
// a multiple of that, applied identically everywhere so no two views in the
// panel scroll at different speeds.
//
// Placed inside the Flickable it drives, which it takes as its target. It
// accepts the event, so the Flickable's own slower handling does not also run.
WheelHandler {
id: root
required property Flickable view
// Pixels per wheel notch. A notch is 120 eighths-of-a-degree. Roughly twice
// what Qt would move on its own -- enough that a screenful is a couple of
// turns rather than half a dozen, and not so much that a notch overshoots
// the thing being scrolled to. One number, one place; every view reads it.
property real step: 90
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onWheel: function(event) {
var notches = event.angleDelta.y / 120
if (notches === 0) return
// A touchpad sends many small deltas rather than whole notches, and
// multiplying those the same way overshoots wildly. Scale the fractional
// part as it comes; only whole notches get the full step.
var limit = Math.max(0, root.view.contentHeight - root.view.height)
var next = root.view.contentY - notches * root.step
root.view.contentY = Math.max(0, Math.min(limit, next))
event.accepted = true
}
}
@@ -0,0 +1,707 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mio"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
dependencies = [
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand",
"smallvec",
"zeroize",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "qs-bitwarden-ssh-agent"
version = "0.1.0"
dependencies = [
"ed25519-dalek",
"rand_core",
"rsa",
"rustix",
"serde",
"serde_json",
"sha2",
"signature",
"ssh-encoding",
"ssh-key",
"tokio",
"zeroize",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "rsa"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid",
"digest",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"sha2",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core",
]
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "ssh-cipher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
dependencies = [
"cipher",
"ssh-encoding",
]
[[package]]
name = "ssh-encoding"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
dependencies = [
"base64ct",
"pem-rfc7468",
"sha2",
]
[[package]]
name = "ssh-key"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
dependencies = [
"ed25519-dalek",
"num-bigint-dig",
"rand_core",
"rsa",
"sha2",
"signature",
"ssh-cipher",
"ssh-encoding",
"subtle",
"zeroize",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,52 @@
[package]
name = "qs-bitwarden-ssh-agent"
version = "0.1.0"
edition = "2021"
rust-version = "1.85"
license = "MIT"
publish = false
description = "Headless SSH-agent companion for the qs-bitwarden-cli Quickshell panel"
# Why each dependency is here, and why its features are cut this far down, is
# recorded in docs/decisions/0001-ssh-agent-dependencies.md. Anything added
# here needs the same review: this process holds decrypted private keys.
[dependencies]
# Key parsing, public blobs, fingerprints, and the signing primitives. Default
# features are off so ECDSA, DSA, and OpenSSH key encryption never compile in:
# v1 signs with Ed25519 and RSA SHA-2 only.
ssh-key = { version = "0.6.7", default-features = false, features = ["alloc", "ed25519", "rsa"] }
# Wire primitives for the allowlisted agent frame decoder (Task 5). Same
# version ssh-key uses, declared directly because this crate encodes and
# decodes frames itself rather than through an agent framework.
ssh-encoding = { version = "0.2", default-features = false, features = ["alloc"] }
# Declared only to turn ed25519-dalek's zeroize-on-drop impl on for the whole
# graph. ssh-key depends on dalek with default features off and does not ask
# for `zeroize`, so without this line the transient SigningKey built for each
# signature leaves its 32 secret bytes in freed memory.
ed25519-dalek = { version = "2.2", default-features = false, features = ["zeroize"] }
# Same reasoning in reverse: rsa zeroizes unconditionally, and pinning it here
# keeps the version that does so under this crate's own review.
rsa = { version = "0.9.10", default-features = false, features = ["sha2"] }
# Zeroizing<Vec<u8>> for PEM text and FIFO payloads, from the first byte read.
zeroize = { version = "1.9", default-features = false, features = ["alloc"] }
# Current-thread async runtime: independent socket tasks with bounded channels,
# no thread pool. `net` carries UnixListener and SO_PEERCRED via peer_cred().
tokio = { version = "1.53", default-features = false, features = ["rt", "net", "io-util", "io-std", "sync", "time", "macros"] }
# RLIMIT_CORE=0 and PR_SET_DUMPABLE=0 before the first secret is read.
rustix = { version = "1.1", default-features = false, features = ["std", "fs", "process", "thread"] }
# The NDJSON control channel the panel speaks on stdin/stdout.
serde = { version = "1", default-features = false, features = ["derive", "alloc"] }
serde_json = { version = "1", default-features = false, features = ["alloc"] }
signature = { version = "2", default-features = false, features = ["alloc"] }
sha2 = { version = "0.10", default-features = false }
[dev-dependencies]
# Test-only key generation, so no private key material is committed.
rand_core = { version = "0.6.4", features = ["getrandom"] }
[profile.release]
# A key-holding process should not leave a core file or unwind through
# arbitrary Drop impls on panic; abort keeps secret memory out of a longer
# unwind path and out of a dumpable child.
panic = "abort"
strip = "symbols"
@@ -0,0 +1,9 @@
# The release helper ships as bytes in this repository, so the toolchain that
# produced them is part of the artifact. rustup honours this file; a distro
# cargo ignores it, which is why the reproducible build (Task 16) runs under
# rustup in a pinned container and compares output byte for byte.
[toolchain]
channel = "1.98.0"
components = ["rustfmt", "clippy"]
targets = ["x86_64-unknown-linux-gnu"]
profile = "minimal"
@@ -0,0 +1,234 @@
//! Bounded signature requests, single-use approvals, and process grants.
use crate::keystore::{AuthorizationPermit, KeyStore};
use crate::peer::PeerContext;
const MAX_PENDING: usize = 4;
/// How long a person has to answer a prompt before the request is abandoned.
///
/// This is a human deadline, not a machine one: the panel has to open, the
/// user has to notice it, read a fingerprint, and decide. Thirty seconds --
/// the figure the original design carried -- turned out to be shorter than
/// that takes in practice, and expired prompts under a user who was simply
/// reading them. See docs/decisions/0003-request-deadline.md.
///
/// The bound that actually reclaims resources promptly is the client
/// disconnect, which the server watches for while a request is pending.
pub const REQUEST_LIFETIME_MS: u64 = 120_000;
const MAX_GRANT_SECONDS: u64 = 900;
/// Stable authorization failures.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ApprovalError {
WrongUid,
QueueFull,
UnknownRequest,
IdExhausted,
}
/// Unique process-lifetime request identifier.
pub type RequestId = u64;
/// Unique process-lifetime grant identifier.
pub type GrantId = u64;
/// Result of submitting a sign request.
#[derive(Debug, Eq, PartialEq)]
pub enum Submit {
Pending(RequestId),
Granted(Authorization),
}
/// Public-only authorization which must still pass the keystore's final gate.
#[derive(Debug, Eq, PartialEq)]
pub struct Authorization {
epoch: u64,
public_blob: Vec<u8>,
}
impl Authorization {
/// Recheck epoch, lock state, and key identity at the final signing point.
pub fn finalize(self, store: &KeyStore) -> Option<AuthorizationPermit> {
let permit = store.authorize(&self.public_blob)?;
// `authorize` is current-state authoritative. The explicit epoch check
// keeps a token from a previous unlock from crossing after a reload.
(store.epoch() == self.epoch).then_some(permit)
}
}
struct Pending {
id: RequestId,
epoch: u64,
public_blob: Vec<u8>,
peer: PeerContext,
deadline_ms: u64,
}
/// Public grant projection safe for panel status.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Grant {
pub id: GrantId,
pub public_blob: Vec<u8>,
pub peer: PeerContext,
pub epoch: u64,
pub expires_at_ms: u64,
}
/// Single-owner authorization state.
pub struct ApprovalManager {
expected_uid: u32,
next_id: RequestId,
next_grant_id: GrantId,
pending: Vec<Pending>,
grants: Vec<Grant>,
}
impl ApprovalManager {
pub fn new(expected_uid: u32) -> Self {
Self {
expected_uid,
next_id: 1,
next_grant_id: 1,
pending: Vec::new(),
grants: Vec::new(),
}
}
pub fn submit(
&mut self,
epoch: u64,
public_blob: &[u8],
peer: PeerContext,
now_ms: u64,
) -> Result<Submit, ApprovalError> {
if peer.uid != self.expected_uid {
return Err(ApprovalError::WrongUid);
}
self.expire(now_ms);
if self.grants.iter().any(|grant| {
grant.epoch == epoch
&& grant.public_blob == public_blob
&& grant.peer.shares_grant_scope(&peer)
}) {
return Ok(Submit::Granted(Authorization {
epoch,
public_blob: public_blob.to_vec(),
}));
}
if self.pending.len() >= MAX_PENDING {
return Err(ApprovalError::QueueFull);
}
let id = self.next_id;
self.next_id = self
.next_id
.checked_add(1)
.ok_or(ApprovalError::IdExhausted)?;
self.pending.push(Pending {
id,
epoch,
public_blob: public_blob.to_vec(),
peer,
deadline_ms: now_ms.saturating_add(REQUEST_LIFETIME_MS),
});
Ok(Submit::Pending(id))
}
pub fn approve(
&mut self,
id: RequestId,
grant_seconds: u64,
now_ms: u64,
) -> Result<Authorization, ApprovalError> {
self.expire(now_ms);
let index = self
.pending
.iter()
.position(|request| request.id == id)
.ok_or(ApprovalError::UnknownRequest)?;
let request = self.pending.remove(index);
if grant_seconds > 0 {
let grant_id = self.next_grant_id;
self.next_grant_id = self
.next_grant_id
.checked_add(1)
.ok_or(ApprovalError::IdExhausted)?;
let duration_ms = grant_seconds.min(MAX_GRANT_SECONDS).saturating_mul(1_000);
self.grants.push(Grant {
id: grant_id,
public_blob: request.public_blob.clone(),
peer: request.peer,
epoch: request.epoch,
expires_at_ms: now_ms.saturating_add(duration_ms),
});
}
Ok(Authorization {
epoch: request.epoch,
public_blob: request.public_blob,
})
}
pub fn disconnect(&mut self, id: RequestId) {
self.pending.retain(|request| request.id != id);
}
pub fn expire(&mut self, now_ms: u64) {
self.pending.retain(|request| request.deadline_ms > now_ms);
self.grants.retain(|grant| grant.expires_at_ms > now_ms);
}
/// Lock, logout, account change, suspend, screen lock, disable, and epoch
/// change all use this same deny/cancel operation.
pub fn invalidate_all(&mut self) {
self.pending.clear();
self.grants.clear();
}
pub fn revoke_grant(&mut self, id: GrantId) {
self.grants.retain(|grant| grant.id != id);
}
pub fn revoke_all_grants(&mut self) {
self.grants.clear();
}
pub fn revoke_peer(&mut self, peer: &PeerContext) {
self.grants
.retain(|grant| !grant.peer.shares_grant_scope(peer));
}
/// Reserve an identifier for a request the caller holds itself -- one
/// waiting on an unlock rather than on an approval. Drawn from the same
/// sequence, so no two live requests can ever share an id.
pub fn reserve_request_id(&mut self) -> Result<RequestId, ApprovalError> {
let id = self.next_id;
self.next_id = self
.next_id
.checked_add(1)
.ok_or(ApprovalError::IdExhausted)?;
Ok(id)
}
/// Same-UID enforcement, for requests the caller holds itself rather than
/// registering as pending.
pub fn expects_uid(&self, uid: u32) -> bool {
uid == self.expected_uid
}
/// How many more requests may exist across both the pending set and any
/// the caller is holding. The four-request bound covers them together.
pub fn capacity_remaining(&self, held: usize) -> usize {
MAX_PENDING.saturating_sub(self.pending.len() + held)
}
pub fn pending_count(&self) -> usize {
self.pending.len()
}
pub fn is_pending(&self, id: RequestId) -> bool {
self.pending.iter().any(|request| request.id == id)
}
pub fn grants(&self) -> &[Grant] {
&self.grants
}
}
@@ -0,0 +1,119 @@
//! Strict, bounded panel-to-companion control messages.
use serde::Deserialize;
pub const CONTROL_VERSION: u8 = 1;
pub const MAX_CONTROL_LINE: usize = 64 * 1024;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum ControlMessage {
Hello {
v: u8,
},
KeyLoadBegin {
v: u8,
epoch: u64,
#[serde(rename = "loadId")]
load_id: String,
},
KeyLoadEnd {
v: u8,
epoch: u64,
status: LoadStatus,
},
VaultLocked {
v: u8,
epoch: u64,
},
VaultLoggedOut {
v: u8,
},
Approve {
v: u8,
#[serde(rename = "requestId")]
request_id: u64,
#[serde(rename = "grantSeconds")]
grant_seconds: u64,
},
Deny {
v: u8,
#[serde(rename = "requestId")]
request_id: u64,
},
UnlockCancelled {
v: u8,
#[serde(rename = "requestId")]
request_id: u64,
reason: String,
},
/// Panel settings the companion needs to act on. Sent after the
/// handshake and whenever they change.
Options {
v: u8,
#[serde(rename = "unlockOnDemand")]
unlock_on_demand: bool,
},
RevokeGrants {
v: u8,
},
RevokeGrant {
v: u8,
#[serde(rename = "grantId")]
grant_id: u64,
},
Shutdown {
v: u8,
},
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum LoadStatus {
Ok,
Failed,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ControlError {
Empty,
TooLong,
Malformed,
WrongVersion,
}
impl ControlMessage {
pub fn version(&self) -> u8 {
match self {
Self::Hello { v }
| Self::VaultLoggedOut { v }
| Self::RevokeGrants { v }
| Self::Shutdown { v } => *v,
Self::KeyLoadBegin { v, .. }
| Self::Options { v, .. }
| Self::RevokeGrant { v, .. }
| Self::KeyLoadEnd { v, .. }
| Self::VaultLocked { v, .. }
| Self::Approve { v, .. }
| Self::Deny { v, .. }
| Self::UnlockCancelled { v, .. } => *v,
}
}
}
pub fn parse_control_line(line: &[u8]) -> Result<ControlMessage, ControlError> {
let line = line.strip_suffix(b"\n").unwrap_or(line);
let line = line.strip_suffix(b"\r").unwrap_or(line);
if line.is_empty() {
return Err(ControlError::Empty);
}
if line.len() > MAX_CONTROL_LINE {
return Err(ControlError::TooLong);
}
let message: ControlMessage =
serde_json::from_slice(line).map_err(|_| ControlError::Malformed)?;
if message.version() != CONTROL_VERSION {
return Err(ControlError::WrongVersion);
}
Ok(message)
}
@@ -0,0 +1,331 @@
//! Bounded candidate loading and epoch-authoritative private-key ownership.
use crate::signing;
use crate::state::{StateTracker, VaultState};
use ssh_key::{HashAlg, PrivateKey, PublicKey, Signature};
use std::fmt;
use zeroize::Zeroizing;
/// Maximum number of SSH items accepted in one candidate load.
pub const MAX_KEYS: usize = 128;
/// Maximum OpenSSH PEM bytes accepted for one item.
pub const MAX_PEM_BYTES: usize = 64 * 1024;
/// Public vault metadata retained in memory or emitted on the bounded control
/// channel. Measured in UTF-8 bytes, matching the protocol ceiling. An item id
/// past it fails the load; a name past it is truncated to it.
pub const MAX_METADATA_BYTES: usize = 256;
/// Maximum filtered FIFO payload accepted for one load.
pub const MAX_FILTERED_BYTES: usize = 8 * 1024 * 1024;
/// One allowlisted item from the bounded FIFO decoder.
pub struct CandidateItem {
pub item_id: String,
pub name: String,
pub private_key_pem: Zeroizing<Vec<u8>>,
pub public_key: String,
pub fingerprint: String,
pub requires_reprompt: bool,
}
/// Stable, non-secret reason why one item was not loaded.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SkipCode {
MalformedPrivateKey,
InvalidPrivateKey,
UnsupportedKeyType,
MalformedPublicKey,
PublicKeyMismatch,
FingerprintMismatch,
RequiresReprompt,
Duplicate,
}
/// A skipped item safe to report over the control channel.
#[derive(Debug, Eq, PartialEq)]
pub struct SkippedItem {
pub item_id: String,
pub code: SkipCode,
}
/// Successful candidate publication summary.
#[derive(Debug, Eq, PartialEq)]
pub struct LoadReport {
pub loaded: usize,
pub skipped: Vec<SkippedItem>,
}
/// Whole-candidate failures. These never include parser input or errors.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LoadError {
StaleEpoch,
FilteredPayloadTooLarge,
TooManyKeys,
PemTooLarge,
MetadataTooLarge,
FailedCandidate,
}
/// Public values retained across lock.
#[derive(Debug, Eq, PartialEq)]
pub struct PublicIdentity {
pub item_id: String,
pub name: String,
pub fingerprint: String,
/// The OpenSSH one-line form, derived from the parsed private key rather
/// than copied from vault metadata. Git signing needs this on disk as a
/// file, and the panel writes it; deriving it here means only material
/// this keystore actually validated can ever be exported.
pub public_key_openssh: String,
public_blob: Vec<u8>,
}
impl PublicIdentity {
pub fn public_blob(&self) -> &[u8] {
&self.public_blob
}
}
struct PrivateIdentity {
key: PrivateKey,
public_index: usize,
}
/// A public-only authorization result. It cannot keep a private key alive.
pub struct AuthorizationPermit {
epoch: u64,
public_blob: Vec<u8>,
}
/// Candidate storage, separate from the live keystore until publication.
pub struct CandidateLoad {
epoch: u64,
seen_items: usize,
failed: bool,
public: Vec<PublicIdentity>,
private: Vec<PrivateIdentity>,
skipped: Vec<SkippedItem>,
}
// Debug output is intentionally redacted because this value owns private keys.
impl fmt::Debug for CandidateLoad {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("CandidateLoad { private material redacted }")
}
}
impl CandidateLoad {
/// Validate and add one item. Individual key defects are reported as skips;
/// a hard resource limit poisons the entire candidate.
pub fn add(&mut self, item: CandidateItem) -> Result<Option<SkipCode>, LoadError> {
self.seen_items = self.seen_items.saturating_add(1);
if self.seen_items > MAX_KEYS {
self.failed = true;
return Err(LoadError::TooManyKeys);
}
if item.private_key_pem.len() > MAX_PEM_BYTES {
self.failed = true;
return Err(LoadError::PemTooLarge);
}
// An item id that long is malformed rather than unusual -- Bitwarden's
// are 36-character UUIDs -- and it identifies the key, so it cannot be
// shortened without changing what it names.
if item.item_id.len() > MAX_METADATA_BYTES {
self.failed = true;
return Err(LoadError::MetadataTooLarge);
}
// A long *name* is ordinary. Bitwarden allows them, and 256 bytes is
// around 85 CJK characters, so failing the load here would take the
// whole feature down over one item somebody named descriptively. The
// name is display and comment text, so it is bounded by truncation
// instead -- on a character boundary, because a String cut mid-sequence
// is not one.
let mut item = item;
if item.name.len() > MAX_METADATA_BYTES {
let mut end = MAX_METADATA_BYTES;
while end > 0 && !item.name.is_char_boundary(end) {
end -= 1;
}
item.name.truncate(end);
}
if item.requires_reprompt {
return Ok(self.skip(item.item_id, SkipCode::RequiresReprompt));
}
let key = match PrivateKey::from_openssh(item.private_key_pem.as_slice()) {
Ok(key) => key,
Err(_) => return Ok(self.skip(item.item_id, SkipCode::MalformedPrivateKey)),
};
if !matches!(
key.algorithm(),
ssh_key::Algorithm::Ed25519 | ssh_key::Algorithm::Rsa { .. }
) {
return Ok(self.skip(item.item_id, SkipCode::UnsupportedKeyType));
}
if let Some(rsa) = key.key_data().rsa() {
if crate::rsa_keys::private_key(rsa).is_err() {
return Ok(self.skip(item.item_id, SkipCode::InvalidPrivateKey));
}
}
let metadata_key = match PublicKey::from_openssh(&item.public_key) {
Ok(key) => key,
Err(_) => return Ok(self.skip(item.item_id, SkipCode::MalformedPublicKey)),
};
let public_blob = match key.public_key().to_bytes() {
Ok(blob) => blob,
Err(_) => return Ok(self.skip(item.item_id, SkipCode::MalformedPrivateKey)),
};
if metadata_key.to_bytes().ok().as_deref() != Some(public_blob.as_slice()) {
return Ok(self.skip(item.item_id, SkipCode::PublicKeyMismatch));
}
let fingerprint = key.public_key().fingerprint(HashAlg::Sha256).to_string();
if fingerprint != item.fingerprint {
return Ok(self.skip(item.item_id, SkipCode::FingerprintMismatch));
}
if self
.public
.iter()
.any(|identity| identity.public_blob == public_blob)
{
return Ok(self.skip(item.item_id, SkipCode::Duplicate));
}
// Derived from the key that was just validated, not from the vault's
// copy: the export on disk must be material this keystore vouched for.
let public_key_openssh = match key.public_key().to_openssh() {
Ok(text) => text,
Err(_) => return Ok(self.skip(item.item_id, SkipCode::MalformedPublicKey)),
};
let public_index = self.public.len();
self.public.push(PublicIdentity {
item_id: item.item_id,
name: item.name,
fingerprint,
public_key_openssh,
public_blob,
});
self.private.push(PrivateIdentity { key, public_index });
Ok(None)
}
fn skip(&mut self, item_id: String, code: SkipCode) -> Option<SkipCode> {
self.skipped.push(SkippedItem { item_id, code });
Some(code)
}
}
/// The only owner of live private keys.
pub struct KeyStore {
state: StateTracker,
public: Vec<PublicIdentity>,
private: Vec<PrivateIdentity>,
}
impl Default for KeyStore {
fn default() -> Self {
Self::new()
}
}
impl KeyStore {
pub fn new() -> Self {
Self {
state: StateTracker::new(),
public: Vec::new(),
private: Vec::new(),
}
}
/// Enter loading and drop the previous private set before validation.
pub fn begin_load(
&mut self,
epoch: u64,
filtered_bytes: usize,
) -> Result<CandidateLoad, LoadError> {
if !self.state.begin_load(epoch) {
return Err(LoadError::StaleEpoch);
}
self.private.clear();
if filtered_bytes > MAX_FILTERED_BYTES {
return Err(LoadError::FilteredPayloadTooLarge);
}
Ok(CandidateLoad {
epoch,
seen_items: 0,
failed: false,
public: Vec::new(),
private: Vec::new(),
skipped: Vec::new(),
})
}
/// Atomically replace both public and private sets with one validated load.
pub fn publish(&mut self, load: CandidateLoad) -> Result<LoadReport, LoadError> {
if load.failed {
return Err(LoadError::FailedCandidate);
}
if !self.state.publish(load.epoch) {
return Err(LoadError::StaleEpoch);
}
self.public = load.public;
self.private = load.private;
Ok(LoadReport {
loaded: self.private.len(),
skipped: load.skipped,
})
}
/// Deny first, then erase the live private set while retaining public data.
pub fn lock(&mut self, epoch: u64) {
self.state.lock(epoch, !self.public.is_empty());
self.private.clear();
}
/// Clear both caches for logout or account change.
pub fn logout(&mut self, epoch: u64) {
self.state.logout(epoch);
self.private.clear();
self.public.clear();
}
pub fn state(&self) -> VaultState {
self.state.state()
}
/// Current vault epoch for final authorization correlation.
pub fn epoch(&self) -> u64 {
self.state.epoch()
}
pub fn public_identities(&self) -> &[PublicIdentity] {
&self.public
}
pub fn authorize(&self, public_blob: &[u8]) -> Option<AuthorizationPermit> {
if !self.state.allows(self.state.epoch()) {
return None;
}
self.private.iter().find_map(|identity| {
let public = &self.public[identity.public_index];
(public.public_blob == public_blob).then(|| AuthorizationPermit {
epoch: self.state.epoch(),
public_blob: public_blob.to_vec(),
})
})
}
/// Final epoch/state/key check immediately before the signing primitive.
pub fn sign(
&self,
permit: &AuthorizationPermit,
message: &[u8],
flags: u32,
) -> Option<Signature> {
if !self.state.allows(permit.epoch) {
return None;
}
let identity = self.private.iter().find(|identity| {
self.public[identity.public_index].public_blob == permit.public_blob
})?;
signing::sign(&identity.key, message, flags)
}
}
@@ -0,0 +1,230 @@
//! Headless SSH-agent companion for the qs-bitwarden-cli Quickshell panel.
//!
//! The panel owns `bw` and `BW_SESSION`; this process never sees either. It
//! receives already-decrypted private keys on a private FIFO, holds them only
//! while the vault is unlocked, and signs only against a live approval. The
//! full design is in `docs/ideas/ssh-agent.md`, and the dependency set below is
//! justified in `docs/decisions/0001-ssh-agent-dependencies.md`.
//!
//! At this stage the crate is the dependency spike itself: it pins the crates
//! the agent will be built from and proves, in tests that need no vault, no
//! network, and no socket, that they can do the two things the design cannot
//! compromise on -- sign what v1 promises to sign, and wipe private key memory
//! when it is dropped.
use zeroize::ZeroizeOnDrop;
pub mod approvals;
pub mod control;
pub mod keystore;
pub mod lifecycle;
pub mod load;
pub mod peer;
pub mod protocol;
pub mod runtime;
pub mod selftest;
pub mod server;
mod signing;
pub mod state;
/// Compile-time proof that a private-key representation wipes its own memory
/// when dropped.
///
/// Rust drops the value either way; what this asserts is that the drop is a
/// zeroizing one. It is a function rather than a comment because the property
/// depends on Cargo features resolved across the whole dependency graph -- one
/// crate anywhere in the tree can turn a wipe into a plain deallocation, and
/// nothing in the source of this crate would look any different afterwards.
/// If a call to this stops compiling, the keystore's lock semantics are no
/// longer true, whatever the documentation says.
pub fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
/// RSA signing keys, built here rather than through ssh-key.
///
/// ssh-key 0.6.7 -- the newest release; the 0.7 line has been in release
/// candidates since 2025 -- cannot produce a usable RSA private key. Its
/// `TryFrom<&RsaKeypair> for rsa::RsaPrivateKey` passes `p` twice where
/// `from_components` expects `p` and `q`, so the key fails validation and
/// every RSA signature returns an opaque error. The fix is on the project's
/// master branch and unreleased.
///
/// That leaves three options: ship a release candidate of a security
/// dependency, drop RSA from v1, or build the private key here from the same
/// components. This crate takes the third: it is a dozen lines against a
/// stable API, it needs no fork or patch section in Cargo.toml, and it drops
/// out the day a fixed 0.6.x or 0.7.0 is released. See
/// `docs/decisions/0001-ssh-agent-dependencies.md`.
pub mod rsa_keys {
use rsa::pkcs1v15;
use rsa::traits::PublicKeyParts;
use rsa::BigUint;
use ssh_key::private::RsaKeypair;
use ssh_key::{Error, HashAlg, Result};
/// The RSA private key for `keypair`, with p and q the right way round.
///
/// The returned key zeroizes its own components on drop; the caller is
/// responsible for not cloning it out of the keystore.
pub fn private_key(keypair: &RsaKeypair) -> Result<rsa::RsaPrivateKey> {
let key = rsa::RsaPrivateKey::from_components(
BigUint::try_from(&keypair.public.n)?,
BigUint::try_from(&keypair.public.e)?,
BigUint::try_from(&keypair.private.d)?,
vec![
BigUint::try_from(&keypair.private.p)?,
BigUint::try_from(&keypair.private.q)?,
],
)
.map_err(|_| Error::Crypto)?;
// OpenSSH refuses RSA below 2048 bits and so does this agent; a
// shorter key is a failed load, not a weaker signature.
if key.size().saturating_mul(8) < MIN_RSA_KEY_BITS {
return Err(Error::Crypto);
}
Ok(key)
}
/// Smallest RSA modulus this agent will sign with, in bits.
pub const MIN_RSA_KEY_BITS: usize = 2048;
/// A PKCS#1 v1.5 signing key for one of the two RSA SHA-2 algorithms.
///
/// The hash is not a detail the agent gets to choose: `rsa-sha2-256` and
/// `rsa-sha2-512` are distinct signature algorithms on the wire, selected
/// by flags on the sign request, and answering with the other one is a
/// failed authentication.
pub enum Sha2SigningKey {
Sha256(pkcs1v15::SigningKey<sha2::Sha256>),
Sha512(pkcs1v15::SigningKey<sha2::Sha512>),
}
/// Build the signing key the requested flag asks for.
pub fn sha2_signing_key(keypair: &RsaKeypair, hash: HashAlg) -> Result<Sha2SigningKey> {
let key = private_key(keypair)?;
Ok(match hash {
HashAlg::Sha256 => Sha2SigningKey::Sha256(pkcs1v15::SigningKey::new(key)),
HashAlg::Sha512 => Sha2SigningKey::Sha512(pkcs1v15::SigningKey::new(key)),
// ssh-key's HashAlg is non-exhaustive; anything else is not an
// algorithm this agent advertises.
_ => return Err(Error::Crypto),
})
}
}
#[cfg(test)]
mod tests {
use super::{assert_zeroize_on_drop, rsa_keys};
use rand_core::OsRng;
use signature::{SignatureEncoding, Signer, Verifier};
use ssh_key::private::RsaKeypair;
use ssh_key::{Algorithm, HashAlg, PrivateKey, Signature};
use zeroize::Zeroizing;
/// The two secret representations that exist while the vault is unlocked:
/// the transient dalek signing key ssh-key builds for each Ed25519
/// signature, and the RSA private key it converts into for each RSA one.
///
/// dalek implements this only behind its `zeroize` feature, and ssh-key
/// depends on dalek with default features off without asking for it. This
/// crate names dalek as a direct dependency for that feature alone; drop
/// that line from Cargo.toml and this test stops compiling rather than
/// silently leaving 32 secret bytes in freed memory.
#[test]
fn every_private_key_representation_wipes_itself_on_drop() {
assert_zeroize_on_drop::<ed25519_dalek::SigningKey>();
assert_zeroize_on_drop::<rsa::RsaPrivateKey>();
}
/// Ed25519: the algorithm nearly every Bitwarden SSH key will use.
#[test]
fn ed25519_keys_parse_sign_and_verify() {
let generated = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
// Private keys reach this process as OpenSSH PEM text on the FIFO, so
// the test takes the same route in -- and holds the text the way the
// loader will, in a buffer that wipes itself.
let pem = Zeroizing::new(
generated
.to_openssh(Default::default())
.unwrap()
.to_string(),
);
let key = PrivateKey::from_openssh(pem.as_bytes()).unwrap();
let signature = key.try_sign(b"agent sign request").unwrap();
assert_eq!(signature.algorithm(), Algorithm::Ed25519);
// PublicKey's inherent `verify` is the namespaced SSHSIG one; the
// agent path is the Verifier trait, named explicitly here so the test
// exercises what the signing gate will call.
Verifier::verify(key.public_key(), b"agent sign request", &signature)
.expect("a signature this agent produced must verify under the key it advertises");
assert!(Verifier::verify(key.public_key(), b"a different payload", &signature).is_err());
}
/// RSA SHA-2, both flags, through this crate's own key construction.
///
/// The generated key is 2048 bits rather than ssh-key's 4096-bit default
/// because this test runs on every build and key generation dominates it.
#[test]
fn rsa_keys_sign_under_both_sha2_flags() {
let keypair = RsaKeypair::random(&mut OsRng, rsa_keys::MIN_RSA_KEY_BITS).unwrap();
let key = PrivateKey::from(keypair.clone());
let mut signatures = Vec::new();
for hash in [HashAlg::Sha256, HashAlg::Sha512] {
let signature = match rsa_keys::sha2_signing_key(&keypair, hash).unwrap() {
rsa_keys::Sha2SigningKey::Sha256(signing) => {
signing.try_sign(b"agent sign request").unwrap().to_vec()
}
rsa_keys::Sha2SigningKey::Sha512(signing) => {
signing.try_sign(b"agent sign request").unwrap().to_vec()
}
};
let signature = Signature::new(Algorithm::Rsa { hash: Some(hash) }, signature).unwrap();
Verifier::verify(key.public_key(), b"agent sign request", &signature).unwrap_or_else(
|_| panic!("an rsa-sha2 signature must verify under the advertised key: {hash:?}"),
);
assert!(
Verifier::verify(key.public_key(), b"a different payload", &signature).is_err()
);
signatures.push(signature);
}
assert_ne!(
signatures[0].as_bytes(),
signatures[1].as_bytes(),
"the two RSA SHA-2 algorithms must not produce the same signature"
);
}
/// The reason `rsa_keys` exists at all. ssh-key 0.6.7 builds its RSA
/// private key from `p` twice instead of `p` and `q`, so its own signing
/// path cannot sign anything. This test pins that failure: when it starts
/// passing, a fixed ssh-key has been released and `rsa_keys` can go.
#[test]
fn ssh_key_0_6_7_still_cannot_sign_with_rsa_itself() {
let keypair = RsaKeypair::random(&mut OsRng, rsa_keys::MIN_RSA_KEY_BITS).unwrap();
let key = PrivateKey::from(keypair);
assert!(
key.try_sign(b"agent sign request").is_err(),
"ssh-key can sign RSA again: drop the rsa_keys module and its ADR entry"
);
}
/// v1 signs Ed25519 and RSA SHA-2 and nothing else, and that promise is
/// kept by what compiles in rather than by a runtime check someone can
/// forget: with ssh-key's default features off, an ECDSA key has no
/// signing implementation to reach.
#[test]
fn algorithms_outside_v1_have_no_signing_path() {
let unsupported = PrivateKey::random(
&mut OsRng,
Algorithm::Ecdsa {
curve: ssh_key::EcdsaCurve::NistP256,
},
);
assert!(
unsupported.is_err(),
"an algorithm v1 does not support must fail closed at key construction"
);
}
}
@@ -0,0 +1,21 @@
//! Process hardening applied before runtime paths or secret-bearing inputs open.
use rustix::process::{self, DumpableBehavior, Resource, Rlimit};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HardenError {
CoreLimit,
Dumpable,
}
pub fn harden_process() -> Result<(), HardenError> {
process::setrlimit(
Resource::Core,
Rlimit {
current: Some(0),
maximum: Some(0),
},
)
.map_err(|_| HardenError::CoreLimit)?;
process::set_dumpable_behavior(DumpableBehavior::NotDumpable).map_err(|_| HardenError::Dumpable)
}
@@ -0,0 +1,109 @@
//! One-shot nonce-framed candidate payload decoding.
use crate::keystore::{CandidateItem, CandidateLoad, KeyStore, LoadError};
use serde::Deserialize;
use std::fmt;
use zeroize::Zeroizing;
/// Sanitized whole-payload failures.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PayloadError {
InvalidNonce,
Closed,
NonceMismatch,
Malformed,
Load(LoadError),
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct Envelope {
load_id: String,
items: Vec<Item>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct Item {
item_id: String,
name: String,
private_key: String,
public_key: String,
fingerprint: String,
requires_reprompt: bool,
}
/// A single armed load nonce. Every decode attempt consumes the window.
pub struct LoadWindow {
epoch: u64,
nonce: Option<[u8; 32]>,
}
impl fmt::Debug for LoadWindow {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("LoadWindow { nonce redacted }")
}
}
impl LoadWindow {
pub fn new(epoch: u64, nonce: &str) -> Result<Self, PayloadError> {
let nonce = parse_nonce(nonce)?;
Ok(Self {
epoch,
nonce: Some(nonce),
})
}
/// Decode one complete bounded JSON payload and build an unpublished
/// candidate. Raw JSON and each moved PEM allocation wipe on drop.
pub fn decode(
&mut self,
bytes: Zeroizing<Vec<u8>>,
store: &mut KeyStore,
) -> Result<CandidateLoad, PayloadError> {
let expected = self.nonce.take().ok_or(PayloadError::Closed)?;
let mut candidate = store
.begin_load(self.epoch, bytes.len())
.map_err(PayloadError::Load)?;
let envelope: Envelope =
serde_json::from_slice(bytes.as_slice()).map_err(|_| PayloadError::Malformed)?;
let supplied = parse_nonce(&envelope.load_id).map_err(|_| PayloadError::NonceMismatch)?;
if !constant_time_eq(&supplied, &expected) {
return Err(PayloadError::NonceMismatch);
}
for item in envelope.items {
candidate
.add(CandidateItem {
item_id: item.item_id,
name: item.name,
private_key_pem: Zeroizing::new(item.private_key.into_bytes()),
public_key: item.public_key,
fingerprint: item.fingerprint,
requires_reprompt: item.requires_reprompt,
})
.map_err(PayloadError::Load)?;
}
Ok(candidate)
}
}
fn constant_time_eq(left: &[u8; 32], right: &[u8; 32]) -> bool {
left.iter()
.zip(right)
.fold(0_u8, |difference, (left, right)| {
difference | (left ^ right)
})
== 0
}
fn parse_nonce(nonce: &str) -> Result<[u8; 32], PayloadError> {
let bytes: [u8; 32] = nonce
.as_bytes()
.try_into()
.map_err(|_| PayloadError::InvalidNonce)?;
if bytes.iter().all(u8::is_ascii_hexdigit) {
Ok(bytes)
} else {
Err(PayloadError::InvalidNonce)
}
}
@@ -0,0 +1,895 @@
use qs_bitwarden_ssh_agent::approvals::{ApprovalManager, RequestId, Submit};
use qs_bitwarden_ssh_agent::control::{
parse_control_line, ControlMessage, LoadStatus, MAX_CONTROL_LINE,
};
use qs_bitwarden_ssh_agent::keystore::KeyStore;
use qs_bitwarden_ssh_agent::lifecycle::harden_process;
use qs_bitwarden_ssh_agent::load::LoadWindow;
use qs_bitwarden_ssh_agent::protocol::{self, AgentRequest};
use qs_bitwarden_ssh_agent::runtime::{read_payload_async, RuntimeError, ServiceRuntime};
use qs_bitwarden_ssh_agent::server::{self, ClientEvent};
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Instant;
use tokio::io::{AsyncReadExt, AsyncWriteExt, Stdin};
use tokio::sync::{mpsc, oneshot};
use zeroize::Zeroizing;
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Output {
Ready {
v: u8,
#[serde(rename = "socketPath")]
socket_path: String,
#[serde(rename = "fifoPath")]
fifo_path: String,
#[serde(rename = "agentVersion")]
agent_version: String,
},
ApprovalRequired {
v: u8,
#[serde(rename = "requestId")]
request_id: u64,
#[serde(rename = "keyId")]
key_id: String,
#[serde(rename = "keyName")]
key_name: String,
fingerprint: String,
pid: u32,
#[serde(rename = "processPath")]
process_path: String,
operation: &'static str,
forwarded: bool,
#[serde(rename = "grantOffered")]
grant_offered: bool,
},
Locked {
v: u8,
epoch: u64,
},
KeysLoaded {
v: u8,
epoch: u64,
#[serde(rename = "keyCount")]
key_count: usize,
},
/// A signature was asked for against a locked vault whose public cache
/// still knows the key. The request is held, not failed, until the panel
/// either unlocks or cancels.
UnlockRequired {
v: u8,
#[serde(rename = "requestId")]
request_id: u64,
reason: &'static str,
#[serde(rename = "keyName")]
key_name: String,
fingerprint: String,
pid: u32,
#[serde(rename = "processPath")]
process_path: String,
/// Whether approving this request may also open a grant. Stated by
/// the companion so the panel never has to assume it.
#[serde(rename = "grantOffered")]
grant_offered: bool,
},
/// A request the panel may still be prompting for has gone: the client
/// disconnected, the deadline passed, or a lock cancelled it. Without
/// this the prompt would sit there asking about something that no longer
/// exists, which is how people learn to click prompts away.
RequestCancelled {
v: u8,
#[serde(rename = "requestId")]
request_id: u64,
reason: &'static str,
},
/// One validated public identity, in the OpenSSH one-line form. Sent per
/// key rather than as a list: at the documented 128-key limit a single
/// message would exceed the 64 KiB control-line ceiling. The panel
/// accumulates them for an epoch and writes the projection when the
/// matching `keys_loaded` arrives.
PublicKey {
v: u8,
epoch: u64,
#[serde(rename = "itemId")]
item_id: String,
name: String,
fingerprint: String,
#[serde(rename = "publicKey")]
public_key: String,
},
/// The live grant set, whenever it changes. Public metadata only.
GrantsChanged {
v: u8,
grants: Vec<GrantView>,
},
}
#[derive(Serialize)]
struct GrantView {
#[serde(rename = "grantId")]
grant_id: u64,
#[serde(rename = "keyName")]
key_name: String,
fingerprint: String,
pid: u32,
#[serde(rename = "processPath")]
process_path: String,
#[serde(rename = "expiresInSec")]
expires_in_sec: u64,
}
struct PendingSign {
reply: oneshot::Sender<Vec<u8>>,
message: Vec<u8>,
flags: u32,
}
/// A signature asked for while the vault was locked. It is kept whole rather
/// than failed, so the unlock the panel is being asked for can release the
/// very request that triggered it.
struct HeldSign {
reply: oneshot::Sender<Vec<u8>>,
public_blob: Vec<u8>,
message: Vec<u8>,
flags: u32,
peer: qs_bitwarden_ssh_agent::peer::PeerContext,
deadline_ms: u64,
/// Set when the user approved before the load finished, carrying the
/// grant window they chose. Approving needs the key's identity and the
/// requesting program, both of which come from the public cache -- none
/// of it depends on the vault read, so making the user wait for that read
/// and only then asking is pure delay. The approval still decides
/// nothing: the load must produce the very key that was approved, and the
/// final epoch/state/key check runs immediately before signing.
approved: Option<u64>,
}
/// Identity listings waiting on an unlock, and the one request id that was
/// raised for all of them. A fresh companion has no public cache, so the very
/// first `ssh` of a session lists nothing and would never produce a sign
/// request to unlock from. Coalesced deliberately: several clients starting
/// at once is normal, and each must not cost its own prompt.
struct HeldIdentities {
request_id: RequestId,
deadline_ms: u64,
waiting: Vec<oneshot::Sender<Vec<u8>>>,
}
/// How long a held request waits for an unlock before giving up. The same
/// bound the approval path uses, for the same reason -- and unlocking asks
/// more of the user than approving does, so it certainly needs no less.
const HELD_LIFETIME_MS: u64 = qs_bitwarden_ssh_agent::approvals::REQUEST_LIFETIME_MS;
struct ActiveLoad {
epoch: u64,
window: LoadWindow,
payload: Option<Result<Zeroizing<Vec<u8>>, RuntimeError>>,
end_received: bool,
task: tokio::task::JoinHandle<()>,
}
struct ControlReader {
stdin: Stdin,
buffered: Vec<u8>,
}
impl ControlReader {
fn new() -> Self {
Self {
stdin: tokio::io::stdin(),
buffered: Vec::new(),
}
}
async fn next_line(&mut self) -> Result<Option<Vec<u8>>, ()> {
loop {
if let Some(newline) = self.buffered.iter().position(|byte| *byte == b'\n') {
let remainder = self.buffered.split_off(newline + 1);
let line = std::mem::replace(&mut self.buffered, remainder);
return Ok(Some(line));
}
if self.buffered.len() > MAX_CONTROL_LINE {
return Err(());
}
let mut chunk = [0_u8; 4096];
let count = self.stdin.read(&mut chunk).await.map_err(|_| ())?;
if count == 0 {
if self.buffered.is_empty() {
return Ok(None);
}
return Err(());
}
self.buffered.extend_from_slice(&chunk[..count]);
if self.buffered.len() > MAX_CONTROL_LINE + 1 {
return Err(());
}
}
}
}
fn emit(output: &mpsc::Sender<Output>, message: Output) -> Result<(), ()> {
output.try_send(message).map_err(|_| ())
}
async fn write_output(mut messages: mpsc::Receiver<Output>) {
let mut stdout = tokio::io::stdout();
while let Some(message) = messages.recv().await {
let Ok(mut bytes) = serde_json::to_vec(&message) else {
return;
};
bytes.push(b'\n');
if stdout.write_all(&bytes).await.is_err() || stdout.flush().await.is_err() {
return;
}
}
}
/// What the panel asks this binary before it trusts it.
///
/// Argument handling is deliberately exhaustive: the panel launches the helper
/// with no arguments, so anything else is a mistake, and silently starting a
/// key-holding daemon in response to a typo is the wrong answer.
fn dispatch_arguments() -> Option<i32> {
let mut args = std::env::args().skip(1);
let first = args.next()?;
if args.next().is_some() {
eprintln!("qs-bitwarden-ssh-agent: expected at most one argument");
return Some(2);
}
match first.as_str() {
"--version" => {
println!(
"qs-bitwarden-ssh-agent {} (control protocol {})",
env!("CARGO_PKG_VERSION"),
qs_bitwarden_ssh_agent::control::CONTROL_VERSION
);
Some(0)
}
"--self-test" => Some(qs_bitwarden_ssh_agent::selftest::run()),
"--help" | "-h" => {
println!("qs-bitwarden-ssh-agent [--version | --self-test]");
println!();
println!("With no arguments, serves the SSH agent protocol and speaks the");
println!("panel's control protocol on stdin and stdout. It is launched by the");
println!("Bitwarden Quickshell panel and is not useful on its own.");
Some(0)
}
other => {
eprintln!("qs-bitwarden-ssh-agent: unknown argument '{other}'");
Some(2)
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
// Before the runtime does anything: these modes answer and exit, and must
// not depend on a runtime directory, a socket, or any of the setup below.
if let Some(code) = dispatch_arguments() {
std::process::exit(code);
}
if run().await.is_err() {
std::process::exit(1);
}
}
async fn run() -> Result<(), ()> {
harden_process().map_err(|_| ())?;
let runtime_root = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.ok_or(())?;
let runtime = ServiceRuntime::acquire(&runtime_root).map_err(|_| ())?;
let listener = runtime.bind_socket().map_err(|_| ())?;
let (output_tx, output_rx) = mpsc::channel(16);
let output_task = tokio::spawn(write_output(output_rx));
let (events_tx, mut events_rx) = mpsc::channel::<ClientEvent>(8);
let (load_tx, mut load_rx) = mpsc::channel(1);
let server = tokio::spawn(server::run(listener, events_tx));
let socket = runtime.socket_path().to_string_lossy().into_owned();
let fifo = runtime.runtime().fifo_path().to_string_lossy().into_owned();
let mut control = ControlReader::new();
let mut store = KeyStore::new();
let mut approvals = ApprovalManager::new(rustix::process::geteuid().as_raw());
let mut pending = HashMap::<RequestId, PendingSign>::new();
let mut held = HashMap::<RequestId, HeldSign>::new();
let mut held_identities: Option<HeldIdentities> = None;
let mut unlock_on_demand = false;
let mut grant_snapshot = Vec::<u64>::new();
let mut active_load: Option<ActiveLoad> = None;
let started = Instant::now();
let mut gate_open = false;
let mut handshake_complete = false;
let mut tick = tokio::time::interval(std::time::Duration::from_millis(100));
loop {
tokio::select! {
line = control.next_line() => {
let Some(line) = line? else { break };
let message = parse_control_line(&line).map_err(|_| ())?;
match message {
ControlMessage::Hello { .. } if !handshake_complete => {
handshake_complete = true;
gate_open = true;
emit(&output_tx, Output::Ready { v: 1, socket_path: socket.clone(), fifo_path: fifo.clone(), agent_version: env!("CARGO_PKG_VERSION").to_owned() })?;
}
ControlMessage::Hello { .. } => return Err(()),
ControlMessage::VaultLocked { epoch, .. } => {
gate_open = false;
cancel_load(&mut active_load);
store.lock(epoch);
approvals.invalidate_all();
fail_pending(&mut pending);
cancel_held(&mut held, "locked", &output_tx)?;
release_held_identities(&mut held_identities, &store, &output_tx, "locked")?;
emit(&output_tx, Output::Locked { v: 1, epoch })?;
}
ControlMessage::VaultLoggedOut { .. } => {
gate_open = false;
cancel_load(&mut active_load);
store.logout(store.epoch().saturating_add(1));
approvals.invalidate_all();
fail_pending(&mut pending);
cancel_held(&mut held, "logged-out", &output_tx)?;
release_held_identities(&mut held_identities, &store, &output_tx, "logged-out")?;
}
ControlMessage::Approve { request_id, grant_seconds, .. } => {
// A held request is one still waiting on a load. The
// approval is recorded now and applied the moment the
// keys arrive, so the user is not made to wait out the
// vault read before being asked.
if let Some(request) = held.get_mut(&request_id) {
request.approved = Some(grant_seconds);
continue;
}
let Some(sign) = pending.remove(&request_id) else { continue };
let response = approvals.approve(request_id, grant_seconds, elapsed_ms(started)).ok()
.and_then(|authorization| authorization.finalize(&store))
.and_then(|permit| store.sign(&permit, &sign.message, sign.flags))
.and_then(protocol::signature_response)
.unwrap_or_else(protocol::failure_response);
let _ = sign.reply.send(response);
}
ControlMessage::Deny { request_id, .. } | ControlMessage::UnlockCancelled { request_id, .. } => {
approvals.disconnect(request_id);
if let Some(sign) = pending.remove(&request_id) { let _ = sign.reply.send(protocol::failure_response()); }
// The panel asked, so it needs no request_cancelled
// back: it already knows this one is over.
if let Some(request) = held.remove(&request_id) { let _ = request.reply.send(protocol::failure_response()); }
if held_identities.as_ref().is_some_and(|w| w.request_id == request_id) {
release_held_identities(&mut held_identities, &store, &output_tx, "cancelled")?;
}
}
ControlMessage::Options { unlock_on_demand: on, .. } => unlock_on_demand = on,
ControlMessage::RevokeGrants { .. } => approvals.revoke_all_grants(),
ControlMessage::RevokeGrant { grant_id, .. } => approvals.revoke_grant(grant_id),
ControlMessage::Shutdown { .. } => break,
ControlMessage::KeyLoadBegin { epoch, load_id, .. } => {
if active_load.is_some() { return Err(()); }
gate_open = false;
approvals.invalidate_all();
fail_pending(&mut pending);
let window = LoadWindow::new(epoch, &load_id).map_err(|_| ())?;
let fifo = runtime.runtime().fifo_reader().map_err(|_| ())?;
let sender = load_tx.clone();
let task = tokio::spawn(async move {
let result = read_payload_async(fifo, std::time::Duration::from_secs(30)).await;
let _ = sender.send((epoch, result)).await;
});
active_load = Some(ActiveLoad { epoch, window, payload: None, end_received: false, task });
}
ControlMessage::KeyLoadEnd { epoch, status, .. } => {
let Some(load) = active_load.as_mut() else { return Err(()) };
if load.epoch != epoch { return Err(()); }
if status != LoadStatus::Ok {
cancel_load(&mut active_load);
store.lock(epoch);
gate_open = false;
cancel_held(&mut held, "load-failed", &output_tx)?;
release_held_identities(&mut held_identities, &store, &output_tx, "load-failed")?;
} else {
load.end_received = true;
finish_load_if_ready(&mut active_load, &mut store, &mut gate_open, &output_tx)?;
if gate_open {
release_held(&mut held, &store, &mut approvals, &mut pending, started, &output_tx)?;
release_held_identities(&mut held_identities, &store, &output_tx, "released")?;
}
}
}
}
}
Some(event) = events_rx.recv() => handle_client(event, gate_open, &store, &mut approvals, &mut pending, &mut held, &mut held_identities, unlock_on_demand, started, &output_tx)?,
Some((epoch, result)) = load_rx.recv() => {
let Some(load) = active_load.as_mut() else { continue };
if load.epoch != epoch { continue; }
load.payload = Some(result);
finish_load_if_ready(&mut active_load, &mut store, &mut gate_open, &output_tx)?;
if gate_open {
release_held(&mut held, &store, &mut approvals, &mut pending, started, &output_tx)?;
release_held_identities(&mut held_identities, &store, &output_tx, "released")?;
}
}
_ = tick.tick() => {
let now = elapsed_ms(started);
approvals.expire(now);
let expired: Vec<_> = pending.iter().filter_map(|(id, sign)| (sign.reply.is_closed() || !approvals.is_pending(*id)).then_some(*id)).collect();
for id in expired {
approvals.disconnect(id);
if let Some(sign) = pending.remove(&id) { let _ = sign.reply.send(protocol::failure_response()); }
// Whatever ended it -- a client that walked away or a
// deadline that passed -- the panel may still be prompting.
emit(&output_tx, Output::RequestCancelled { v: 1, request_id: id, reason: "withdrawn" })?;
}
let stale: Vec<_> = held.iter().filter_map(|(id, request)| (request.reply.is_closed() || request.deadline_ms <= now).then_some(*id)).collect();
for id in stale {
if let Some(request) = held.remove(&id) { let _ = request.reply.send(protocol::failure_response()); }
emit(&output_tx, Output::RequestCancelled { v: 1, request_id: id, reason: "withdrawn" })?;
}
if held_identities.as_ref().is_some_and(|w| w.deadline_ms <= now) {
release_held_identities(&mut held_identities, &store, &output_tx, "withdrawn")?;
}
emit_grants_if_changed(&mut grant_snapshot, &approvals, &store, now, &output_tx)?;
}
}
}
approvals.invalidate_all();
cancel_load(&mut active_load);
fail_pending(&mut pending);
let _ = cancel_held(&mut held, "shutdown", &output_tx);
let _ = release_held_identities(&mut held_identities, &store, &output_tx, "shutdown");
store.logout(store.epoch().saturating_add(1));
server.abort();
drop(output_tx);
let _ = output_task.await;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn handle_client(
event: ClientEvent,
gate_open: bool,
store: &KeyStore,
approvals: &mut ApprovalManager,
pending: &mut HashMap<RequestId, PendingSign>,
held: &mut HashMap<RequestId, HeldSign>,
held_identities: &mut Option<HeldIdentities>,
unlock_on_demand: bool,
started: Instant,
output: &mpsc::Sender<Output>,
) -> Result<(), ()> {
match event.request {
// Deliberately not behind `gate_open`. Public keys are not secret, and
// a locked vault that still lists them is what stops every `ssh` after
// a lock from raising an unlock prompt for a connection that may have
// nothing to do with the vault. The cache is empty when logged out or
// locked before any load, so those answer with an empty list, and a
// lock clears the private set that signing needs regardless.
AgentRequest::Identities => {
// An empty cache with unlock-on-demand on is the one case where a
// listing may raise UI: without it the first client of a session
// sees nothing and no sign request can ever follow to ask.
if store.public_identities().is_empty()
&& unlock_on_demand
&& approvals.expects_uid(event.peer.uid)
{
if let Some(waiters) = held_identities.as_mut() {
waiters.waiting.push(event.reply);
return Ok(());
}
if let Ok(id) = approvals.reserve_request_id() {
emit(
output,
Output::UnlockRequired {
v: 1,
request_id: id,
reason: "list-identities",
key_name: String::new(),
fingerprint: String::new(),
pid: event.peer.pid,
process_path: event.peer.executable.to_string_lossy().into_owned(),
grant_offered: false,
},
)?;
*held_identities = Some(HeldIdentities {
request_id: id,
deadline_ms: elapsed_ms(started).saturating_add(HELD_LIFETIME_MS),
waiting: vec![event.reply],
});
return Ok(());
}
}
let identities: Vec<_> = store
.public_identities()
.iter()
.map(|key| (key.public_blob(), key.name.as_str()))
.collect();
let _ = event.reply.send(protocol::identities_response(&identities));
}
AgentRequest::Sign {
public_blob,
message,
flags,
} => {
if !gate_open {
// A locked vault that still knows this key asks the panel to
// unlock and keeps the request, rather than failing a client
// that has no way to retry. A key the cache does not know is
// simply not ours to sign for.
let Some(key) = store
.public_identities()
.iter()
.find(|key| key.public_blob() == public_blob)
else {
let _ = event.reply.send(protocol::failure_response());
return Ok(());
};
if !approvals.expects_uid(event.peer.uid)
|| approvals.capacity_remaining(held.len()) == 0
{
let _ = event.reply.send(protocol::failure_response());
return Ok(());
}
let Ok(id) = approvals.reserve_request_id() else {
let _ = event.reply.send(protocol::failure_response());
return Ok(());
};
emit(
output,
Output::UnlockRequired {
v: 1,
request_id: id,
reason: "sign",
key_name: key.name.clone(),
fingerprint: key.fingerprint.clone(),
pid: event.peer.pid,
process_path: event.peer.executable.to_string_lossy().into_owned(),
grant_offered: true,
},
)?;
held.insert(
id,
HeldSign {
reply: event.reply,
public_blob,
message,
flags,
peer: event.peer,
deadline_ms: elapsed_ms(started).saturating_add(HELD_LIFETIME_MS),
approved: None,
},
);
return Ok(());
}
if store.authorize(&public_blob).is_none() {
let _ = event.reply.send(protocol::failure_response());
return Ok(());
}
match approvals.submit(
store.epoch(),
&public_blob,
event.peer.clone(),
elapsed_ms(started),
) {
Ok(Submit::Granted(authorization)) => {
let response = authorization
.finalize(store)
.and_then(|permit| store.sign(&permit, &message, flags))
.and_then(protocol::signature_response)
.unwrap_or_else(protocol::failure_response);
let _ = event.reply.send(response);
}
Ok(Submit::Pending(id)) => {
let Some(key) = store
.public_identities()
.iter()
.find(|key| key.public_blob() == public_blob)
else {
approvals.disconnect(id);
let _ = event.reply.send(protocol::failure_response());
return Ok(());
};
let process_path = event.peer.executable.to_string_lossy().into_owned();
emit(
output,
Output::ApprovalRequired {
v: 1,
request_id: id,
key_id: key.item_id.clone(),
key_name: key.name.clone(),
fingerprint: key.fingerprint.clone(),
pid: event.peer.pid,
process_path,
operation: "ssh-sign",
forwarded: false,
grant_offered: true,
},
)?;
pending.insert(
id,
PendingSign {
reply: event.reply,
message,
flags,
},
);
}
Err(_) => {
let _ = event.reply.send(protocol::failure_response());
}
}
}
}
Ok(())
}
/// Release every request that was waiting on an unlock, now that one has
/// happened. Each goes through the ordinary approval path at the *new* epoch,
/// so an unlock authorises nothing by itself -- it only gets the request back
/// to the point where the user can be asked.
fn release_held(
held: &mut HashMap<RequestId, HeldSign>,
store: &KeyStore,
approvals: &mut ApprovalManager,
pending: &mut HashMap<RequestId, PendingSign>,
started: Instant,
output: &mpsc::Sender<Output>,
) -> Result<(), ()> {
for (old_id, request) in held.drain().collect::<Vec<_>>() {
// The prompt the panel is showing is about to be replaced by an
// approval prompt with its own id, so withdraw the old one first.
emit(
output,
Output::RequestCancelled {
v: 1,
request_id: old_id,
reason: "released",
},
)?;
if store.authorize(&request.public_blob).is_none() {
let _ = request.reply.send(protocol::failure_response());
continue;
}
// Already approved while the load was running: submit at the new
// epoch and consume the approval straight away. Every check the
// ordinary path makes still runs -- the key must be present, the
// vault unlocked, and the epoch current at the signing primitive.
if let Some(grant_seconds) = request.approved {
let response = match approvals.submit(
store.epoch(),
&request.public_blob,
request.peer.clone(),
elapsed_ms(started),
) {
Ok(Submit::Granted(authorization)) => authorization
.finalize(store)
.and_then(|permit| store.sign(&permit, &request.message, request.flags))
.and_then(protocol::signature_response)
.unwrap_or_else(protocol::failure_response),
Ok(Submit::Pending(id)) => approvals
.approve(id, grant_seconds, elapsed_ms(started))
.ok()
.and_then(|authorization| authorization.finalize(store))
.and_then(|permit| store.sign(&permit, &request.message, request.flags))
.and_then(protocol::signature_response)
.unwrap_or_else(protocol::failure_response),
Err(_) => protocol::failure_response(),
};
let _ = request.reply.send(response);
continue;
}
match approvals.submit(
store.epoch(),
&request.public_blob,
request.peer.clone(),
elapsed_ms(started),
) {
Ok(Submit::Granted(authorization)) => {
let response = authorization
.finalize(store)
.and_then(|permit| store.sign(&permit, &request.message, request.flags))
.and_then(protocol::signature_response)
.unwrap_or_else(protocol::failure_response);
let _ = request.reply.send(response);
}
Ok(Submit::Pending(id)) => {
let Some(key) = store
.public_identities()
.iter()
.find(|key| key.public_blob() == request.public_blob)
else {
approvals.disconnect(id);
let _ = request.reply.send(protocol::failure_response());
continue;
};
emit(
output,
Output::ApprovalRequired {
v: 1,
request_id: id,
key_id: key.item_id.clone(),
key_name: key.name.clone(),
fingerprint: key.fingerprint.clone(),
pid: request.peer.pid,
process_path: request.peer.executable.to_string_lossy().into_owned(),
operation: "ssh-sign",
forwarded: false,
grant_offered: true,
},
)?;
pending.insert(
id,
PendingSign {
reply: request.reply,
message: request.message,
flags: request.flags,
},
);
}
Err(_) => {
let _ = request.reply.send(protocol::failure_response());
}
}
}
Ok(())
}
/// Answer every identity listing that was waiting on an unlock. On success
/// that is the real cache; otherwise it is the empty list a locked companion
/// would have returned anyway, which is a normal answer rather than a failure.
fn release_held_identities(
held_identities: &mut Option<HeldIdentities>,
store: &KeyStore,
output: &mpsc::Sender<Output>,
reason: &'static str,
) -> Result<(), ()> {
let Some(waiters) = held_identities.take() else {
return Ok(());
};
let identities: Vec<_> = store
.public_identities()
.iter()
.map(|key| (key.public_blob(), key.name.as_str()))
.collect();
let response = protocol::identities_response(&identities);
for reply in waiters.waiting {
let _ = reply.send(response.clone());
}
emit(
output,
Output::RequestCancelled {
v: 1,
request_id: waiters.request_id,
reason,
},
)
}
/// Fail every held request and tell the panel to take its prompts down.
fn cancel_held(
held: &mut HashMap<RequestId, HeldSign>,
reason: &'static str,
output: &mpsc::Sender<Output>,
) -> Result<(), ()> {
for (id, request) in held.drain().collect::<Vec<_>>() {
let _ = request.reply.send(protocol::failure_response());
emit(
output,
Output::RequestCancelled {
v: 1,
request_id: id,
reason,
},
)?;
}
Ok(())
}
/// Announce the live grant set, but only when it has actually changed --
/// otherwise the hundred-millisecond tick would narrate it forever.
fn emit_grants_if_changed(
snapshot: &mut Vec<u64>,
approvals: &ApprovalManager,
store: &KeyStore,
now_ms: u64,
output: &mpsc::Sender<Output>,
) -> Result<(), ()> {
let current: Vec<u64> = approvals.grants().iter().map(|grant| grant.id).collect();
if current == *snapshot {
return Ok(());
}
*snapshot = current;
let grants = approvals
.grants()
.iter()
.map(|grant| {
let key = store
.public_identities()
.iter()
.find(|key| key.public_blob() == grant.public_blob);
GrantView {
grant_id: grant.id,
key_name: key.map(|key| key.name.clone()).unwrap_or_default(),
fingerprint: key.map(|key| key.fingerprint.clone()).unwrap_or_default(),
pid: grant.peer.pid,
process_path: grant.peer.executable.to_string_lossy().into_owned(),
expires_in_sec: grant.expires_at_ms.saturating_sub(now_ms) / 1000,
}
})
.collect();
emit(output, Output::GrantsChanged { v: 1, grants })
}
fn fail_pending(pending: &mut HashMap<RequestId, PendingSign>) {
for (_, sign) in pending.drain() {
let _ = sign.reply.send(protocol::failure_response());
}
}
fn cancel_load(active: &mut Option<ActiveLoad>) {
if let Some(load) = active.take() {
load.task.abort();
}
}
fn finish_load_if_ready(
active: &mut Option<ActiveLoad>,
store: &mut KeyStore,
gate_open: &mut bool,
output: &mpsc::Sender<Output>,
) -> Result<(), ()> {
let ready = active
.as_ref()
.is_some_and(|load| load.end_received && load.payload.is_some());
if !ready {
return Ok(());
}
let mut load = active.take().ok_or(())?;
load.task.abort();
let result = load
.payload
.take()
.ok_or(())?
.map_err(|_| ())
.and_then(|payload| load.window.decode(payload, store).map_err(|_| ()))
.and_then(|candidate| store.publish(candidate).map_err(|_| ()));
match result {
Ok(report) => {
*gate_open = true;
// Ahead of keys_loaded, so the panel has the whole set by the
// time it is told the load finished.
for identity in store.public_identities() {
emit(
output,
Output::PublicKey {
v: 1,
epoch: load.epoch,
item_id: identity.item_id.clone(),
name: identity.name.clone(),
fingerprint: identity.fingerprint.clone(),
public_key: identity.public_key_openssh.clone(),
},
)?;
}
emit(
output,
Output::KeysLoaded {
v: 1,
epoch: load.epoch,
key_count: report.loaded,
},
)
}
Err(()) => {
store.lock(load.epoch);
*gate_open = false;
Err(())
}
}
}
fn elapsed_ms(started: Instant) -> u64 {
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
}
@@ -0,0 +1,77 @@
//! Verified peer snapshots used to scope approvals and grants.
use std::path::{Path, PathBuf};
/// Sanitized proc-snapshot failures.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PeerError {
Unavailable,
Malformed,
}
/// Process context captured from kernel-owned peer/proc data.
///
/// UID is the socket admission boundary. PID, start time, and executable path
/// are prompt context and grant-scoping inputs, not proof of user identity.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerContext {
pub uid: u32,
pub pid: u32,
pub start_time_ticks: u64,
pub executable: PathBuf,
}
impl PeerContext {
pub fn new(
uid: u32,
pid: u32,
start_time_ticks: u64,
executable: impl AsRef<Path>,
) -> Option<Self> {
let executable = executable.as_ref();
if pid == 0 || start_time_ticks == 0 || !executable.is_absolute() {
return None;
}
Some(Self {
uid,
pid,
start_time_ticks,
executable: executable.to_owned(),
})
}
/// Whether a grant taken for `self` covers a request from `other`.
///
/// A grant is scoped to one user and one program, deliberately not to one
/// process. Git runs a fresh `ssh-keygen` for every commit it signs, so a
/// PID-scoped grant never matches the workflow grants exist to serve --
/// a twenty-commit rebase would prompt twenty times either way. The
/// exposure this accepts is that any process at the same path benefits
/// during the window; on an unlocked desktop a hostile same-UID process
/// could simply run that program itself, which the threat model already
/// declines to defend against. The UID check is not relaxed: that is the
/// one property the companion actually verifies.
pub fn shares_grant_scope(&self, other: &Self) -> bool {
self.uid == other.uid && self.executable == other.executable
}
/// Capture grant-scoping context for a PID supplied by `SO_PEERCRED`.
pub fn capture(uid: u32, pid: u32) -> Result<Self, PeerError> {
if pid == 0 {
return Err(PeerError::Malformed);
}
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat"))
.map_err(|_| PeerError::Unavailable)?;
let close = stat.rfind(')').ok_or(PeerError::Malformed)?;
let fields: Vec<&str> = stat[close + 1..].split_whitespace().collect();
// The remainder begins at field 3; starttime is field 22.
let start_time_ticks = fields
.get(19)
.ok_or(PeerError::Malformed)?
.parse()
.map_err(|_| PeerError::Malformed)?;
let executable =
std::fs::read_link(format!("/proc/{pid}/exe")).map_err(|_| PeerError::Unavailable)?;
Self::new(uid, pid, start_time_ticks, executable).ok_or(PeerError::Malformed)
}
}
@@ -0,0 +1,210 @@
//! Bounded, allowlisted SSH-agent protocol handling.
//!
//! Wire values follow RFC 9987. The handler answers only identity listing and
//! signing; every malformed, mutation, forwarding, extension, or unknown
//! request receives the same one-byte failure and no diagnostic data.
use crate::signing;
use ssh_encoding::{Decode, Encode};
use ssh_key::{Algorithm, PrivateKey, PublicKey};
use std::fmt;
/// Largest accepted agent message body, excluding its four-byte prefix.
pub const MAX_FRAME_LEN: usize = 256 * 1024;
const FAILURE: u8 = 5;
const REQUEST_IDENTITIES: u8 = 11;
const IDENTITIES_ANSWER: u8 = 12;
const SIGN_REQUEST: u8 = 13;
const SIGN_RESPONSE: u8 = 14;
/// Parsed allowlisted request. It contains public key selection and the
/// payload to be signed, but never private material.
#[derive(Debug, Eq, PartialEq)]
pub enum AgentRequest {
Identities,
Sign {
public_blob: Vec<u8>,
message: Vec<u8>,
flags: u32,
},
}
/// A private identity and the bounded public values advertised for it.
pub struct Identity {
key: PrivateKey,
public_blob: Vec<u8>,
comment: String,
}
impl Identity {
/// Construct an identity for one of the two v1 key algorithms.
pub fn new(key: PrivateKey, comment: impl Into<String>) -> Result<Self, ProtocolError> {
if !matches!(key.algorithm(), Algorithm::Ed25519 | Algorithm::Rsa { .. }) {
return Err(ProtocolError);
}
let comment = comment.into();
if comment.len() > MAX_FRAME_LEN {
return Err(ProtocolError);
}
let public_blob = key.public_key().to_bytes().map_err(|_| ProtocolError)?;
Ok(Self {
key,
public_blob,
comment,
})
}
/// OpenSSH public-key blob used to select and advertise this identity.
pub fn public_blob(&self) -> &[u8] {
&self.public_blob
}
/// Human-readable identity comment.
pub fn comment(&self) -> &str {
&self.comment
}
/// Public half of this identity.
pub fn public_key(&self) -> &PublicKey {
self.key.public_key()
}
}
/// An intentionally opaque construction error.
pub struct ProtocolError;
impl fmt::Debug for ProtocolError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("invalid SSH identity")
}
}
/// Handle exactly one length-prefixed agent frame.
///
/// The length is checked before the body is sliced or any request field is
/// allocated. The returned frame is always small enough for the configured
/// cap; otherwise it is the normal agent failure frame.
pub fn handle_frame(frame: &[u8], identities: &[Identity]) -> Vec<u8> {
response(handle(frame, identities).unwrap_or_else(failure_payload))
}
fn handle(frame: &[u8], identities: &[Identity]) -> Option<Vec<u8>> {
match decode_request(frame)? {
AgentRequest::Identities => identities_answer(identities),
AgentRequest::Sign {
public_blob,
message,
flags,
} => sign_response_fields(&public_blob, &message, flags, identities),
}
}
pub fn decode_request(frame: &[u8]) -> Option<AgentRequest> {
let header: [u8; 4] = frame.get(..4)?.try_into().ok()?;
let declared = usize::try_from(u32::from_be_bytes(header)).ok()?;
if declared == 0 || declared > MAX_FRAME_LEN || frame.len() != declared.checked_add(4)? {
return None;
}
let payload = &frame[4..];
match payload.first().copied()? {
REQUEST_IDENTITIES if payload.len() == 1 => Some(AgentRequest::Identities),
SIGN_REQUEST => decode_sign_request(&payload[1..]),
_ => None,
}
}
fn identities_answer(identities: &[Identity]) -> Option<Vec<u8>> {
let mut payload = vec![IDENTITIES_ANSWER];
u32::try_from(identities.len())
.ok()?
.encode(&mut payload)
.ok()?;
for identity in identities {
identity.public_blob.encode(&mut payload).ok()?;
identity.comment.encode(&mut payload).ok()?;
if payload.len() > MAX_FRAME_LEN {
return None;
}
}
Some(payload)
}
fn decode_sign_request(mut fields: &[u8]) -> Option<AgentRequest> {
let key_blob = Vec::<u8>::decode(&mut fields).ok()?;
let message = Vec::<u8>::decode(&mut fields).ok()?;
let flags = u32::decode(&mut fields).ok()?;
if !fields.is_empty() {
return None;
}
Some(AgentRequest::Sign {
public_blob: key_blob,
message,
flags,
})
}
fn sign_response_fields(
key_blob: &[u8],
message: &[u8],
flags: u32,
identities: &[Identity],
) -> Option<Vec<u8>> {
let identity = identities
.iter()
.find(|identity| identity.public_blob == key_blob)?;
let signature = signing::sign(&identity.key, message, flags)?;
signature_payload(signature)
}
pub fn signature_response(signature: ssh_key::Signature) -> Option<Vec<u8>> {
signature_payload(signature).map(response)
}
fn signature_payload(signature: ssh_key::Signature) -> Option<Vec<u8>> {
let signature_bytes = Vec::<u8>::try_from(signature).ok()?;
let mut payload = vec![SIGN_RESPONSE];
signature_bytes.encode(&mut payload).ok()?;
(payload.len() <= MAX_FRAME_LEN).then_some(payload)
}
pub fn identities_response(public: &[(&[u8], &str)]) -> Vec<u8> {
let mut payload = vec![IDENTITIES_ANSWER];
let Some(count) = u32::try_from(public.len()).ok() else {
return failure_response();
};
if count.encode(&mut payload).is_err() {
return failure_response();
}
for (blob, comment) in public {
if blob.encode(&mut payload).is_err()
|| comment.encode(&mut payload).is_err()
|| payload.len() > MAX_FRAME_LEN
{
return failure_response();
}
}
response(payload)
}
pub fn failure_response() -> Vec<u8> {
response(failure_payload())
}
fn failure_payload() -> Vec<u8> {
vec![FAILURE]
}
fn response(payload: Vec<u8>) -> Vec<u8> {
if payload.len() > MAX_FRAME_LEN {
return vec![0, 0, 0, 1, FAILURE];
}
let mut frame = Vec::with_capacity(payload.len() + 4);
let Ok(length) = u32::try_from(payload.len()) else {
return vec![0, 0, 0, 1, FAILURE];
};
frame.extend_from_slice(&length.to_be_bytes());
frame.extend_from_slice(&payload);
frame
}
@@ -0,0 +1,324 @@
//! Private runtime-directory and key-load FIFO creation.
use rustix::fs::{self, FlockOperation, Mode, OFlags, CWD};
use std::fmt;
use std::fs::File;
use std::io::Read;
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use zeroize::Zeroizing;
use crate::keystore::MAX_FILTERED_BYTES;
const RUNTIME_NAME: &str = "qs-bitwarden-cli";
const FIFO_NAME: &str = "ssh-keys.fifo";
const LOCK_NAME: &str = "ssh-agent.lock";
const SOCKET_NAME: &str = "ssh-agent.sock";
/// Sanitized runtime setup failures.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RuntimeError {
Io,
UnsafeDirectory,
UnsafeFifo,
UnsafeLock,
UnsafeSocket,
AlreadyRunning,
PayloadTooLarge,
MultiplePayloads,
ReadTimeout,
}
/// Open private runtime paths. The FIFO descriptor remains open read/write so
/// writers do not observe transient EOF or SIGPIPE between loads.
pub struct Runtime {
directory: PathBuf,
fifo_path: PathBuf,
fifo: File,
}
impl fmt::Debug for Runtime {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("Runtime { verified private paths }")
}
}
/// Accumulator for newline-delimited, byte-bounded FIFO payload framing.
struct PayloadAccumulator {
payload: Zeroizing<Vec<u8>>,
}
impl PayloadAccumulator {
fn new() -> Self {
Self {
payload: Zeroizing::new(Vec::new()),
}
}
fn push(&mut self, chunk: &[u8]) -> Result<Option<Zeroizing<Vec<u8>>>, RuntimeError> {
self.payload.extend_from_slice(chunk);
if self.payload.len() > MAX_FILTERED_BYTES + 1 {
return Err(RuntimeError::PayloadTooLarge);
}
if let Some(newline) = self.payload.iter().position(|byte| *byte == b'\n') {
if self.payload[newline + 1..]
.iter()
.any(|byte| !byte.is_ascii_whitespace())
{
return Err(RuntimeError::MultiplePayloads);
}
self.payload.truncate(newline);
return Ok(Some(std::mem::take(&mut self.payload)));
}
Ok(None)
}
}
impl Runtime {
/// Create a fresh FIFO below `runtime_root`, refusing every existing FIFO
/// path and every directory that is not a same-owner real `0700` directory.
pub fn create(runtime_root: &Path) -> Result<Self, RuntimeError> {
let directory = ensure_runtime_directory(runtime_root)?;
Self::create_in(directory)
}
fn create_in(directory: PathBuf) -> Result<Self, RuntimeError> {
let fifo_path = directory.join(FIFO_NAME);
if std::fs::symlink_metadata(&fifo_path).is_ok() {
return Err(RuntimeError::UnsafeFifo);
}
fs::mkfifoat(CWD, &fifo_path, Mode::RUSR | Mode::WUSR).map_err(|_| RuntimeError::Io)?;
let fd = fs::open(
&fifo_path,
OFlags::RDWR | OFlags::NONBLOCK | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(|_| RuntimeError::UnsafeFifo)?;
let fifo = File::from(fd);
let metadata = fifo.metadata().map_err(|_| RuntimeError::Io)?;
if !metadata.file_type().is_fifo()
|| metadata.uid() != rustix::process::geteuid().as_raw()
|| metadata.mode() & 0o777 != 0o600
{
return Err(RuntimeError::UnsafeFifo);
}
Ok(Self {
directory,
fifo_path,
fifo,
})
}
pub fn directory(&self) -> &Path {
&self.directory
}
pub fn fifo_path(&self) -> &Path {
&self.fifo_path
}
pub fn fifo(&self) -> &File {
&self.fifo
}
pub fn fifo_reader(&self) -> Result<File, RuntimeError> {
self.fifo.try_clone().map_err(|_| RuntimeError::Io)
}
/// Drain one newline-delimited `jq -c` payload under hard byte/time bounds.
pub fn read_payload(&mut self, timeout: Duration) -> Result<Zeroizing<Vec<u8>>, RuntimeError> {
let deadline = Instant::now() + timeout;
let mut accumulator = PayloadAccumulator::new();
let mut chunk = [0_u8; 8192];
loop {
let idle = match self.fifo.read(&mut chunk) {
Ok(0) => true,
Ok(count) => match accumulator.push(&chunk[..count])? {
Some(payload) => return Ok(payload),
None => false,
},
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => true,
Err(_) => return Err(RuntimeError::Io),
};
if Instant::now() >= deadline {
return Err(RuntimeError::ReadTimeout);
}
if idle {
std::thread::sleep(Duration::from_millis(1));
}
}
}
}
/// Async FIFO drain used by the current-thread companion. `AsyncFd` waits for
/// readiness without a blocking worker thread, so control/lock messages remain
/// serviceable while a producer is slow.
pub async fn read_payload_async(
fifo: File,
timeout: Duration,
) -> Result<Zeroizing<Vec<u8>>, RuntimeError> {
let fifo = tokio::io::unix::AsyncFd::new(fifo).map_err(|_| RuntimeError::Io)?;
tokio::time::timeout(timeout, async {
let mut accumulator = PayloadAccumulator::new();
let mut chunk = [0_u8; 8192];
loop {
let mut ready = fifo.readable().await.map_err(|_| RuntimeError::Io)?;
match ready.try_io(|inner| {
let mut file = inner.get_ref();
file.read(&mut chunk)
}) {
// EOF, not a spurious wakeup. `try_io` only clears readiness
// on `WouldBlock`, so a producer that closed without a newline
// leaves this readable for good: continuing straight back would
// spin a core flat out until the timeout. Paced the same way
// the blocking twin above paces its idle reads.
Ok(Ok(0)) => tokio::time::sleep(Duration::from_millis(1)).await,
Ok(Ok(count)) => {
if let Some(payload) = accumulator.push(&chunk[..count])? {
return Ok(payload);
}
}
Ok(Err(_)) => return Err(RuntimeError::Io),
Err(_) => continue,
}
}
})
.await
.map_err(|_| RuntimeError::ReadTimeout)?
}
/// Singleton-owned runtime. The lock is acquired before stale paths are ever
/// inspected or removed, closing the restart race between two companions.
pub struct ServiceRuntime {
runtime: Runtime,
socket_path: PathBuf,
lock_path: PathBuf,
_lock: File,
}
impl ServiceRuntime {
pub fn acquire(runtime_root: &Path) -> Result<Self, RuntimeError> {
let directory = ensure_runtime_directory(runtime_root)?;
let lock_path = directory.join(LOCK_NAME);
let lock = File::from(
fs::open(
&lock_path,
OFlags::CREATE | OFlags::RDWR | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::RUSR | Mode::WUSR,
)
.map_err(|_| RuntimeError::UnsafeLock)?,
);
let metadata = lock.metadata().map_err(|_| RuntimeError::Io)?;
if !metadata.file_type().is_file()
|| metadata.uid() != rustix::process::geteuid().as_raw()
|| metadata.mode() & 0o777 != 0o600
{
return Err(RuntimeError::UnsafeLock);
}
fs::flock(&lock, FlockOperation::NonBlockingLockExclusive)
.map_err(|_| RuntimeError::AlreadyRunning)?;
remove_stale(&directory.join(FIFO_NAME), StaleKind::Fifo)?;
let socket_path = directory.join(SOCKET_NAME);
remove_stale(&socket_path, StaleKind::Socket)?;
let runtime = Runtime::create_in(directory)?;
Ok(Self {
runtime,
socket_path,
lock_path,
_lock: lock,
})
}
pub fn runtime(&self) -> &Runtime {
&self.runtime
}
pub fn runtime_mut(&mut self) -> &mut Runtime {
&mut self.runtime
}
pub fn socket_path(&self) -> &Path {
&self.socket_path
}
pub fn bind_socket(&self) -> Result<tokio::net::UnixListener, RuntimeError> {
let listener = std::os::unix::net::UnixListener::bind(&self.socket_path)
.map_err(|_| RuntimeError::Io)?;
listener
.set_nonblocking(true)
.map_err(|_| RuntimeError::Io)?;
std::fs::set_permissions(&self.socket_path, std::fs::Permissions::from_mode(0o600))
.map_err(|_| RuntimeError::Io)?;
let metadata =
std::fs::symlink_metadata(&self.socket_path).map_err(|_| RuntimeError::Io)?;
if !metadata.file_type().is_socket()
|| metadata.uid() != rustix::process::geteuid().as_raw()
|| metadata.mode() & 0o777 != 0o600
{
return Err(RuntimeError::UnsafeSocket);
}
tokio::net::UnixListener::from_std(listener).map_err(|_| RuntimeError::Io)
}
}
impl Drop for ServiceRuntime {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.socket_path);
let _ = std::fs::remove_file(self.runtime.fifo_path());
let _ = std::fs::remove_file(&self.lock_path);
let _ = std::fs::remove_dir(self.runtime.directory());
}
}
fn ensure_runtime_directory(runtime_root: &Path) -> Result<PathBuf, RuntimeError> {
let directory = runtime_root.join(RUNTIME_NAME);
match std::fs::create_dir(&directory) {
Ok(()) => std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700))
.map_err(|_| RuntimeError::Io)?,
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(_) => return Err(RuntimeError::Io),
}
let metadata = std::fs::symlink_metadata(&directory).map_err(|_| RuntimeError::Io)?;
if !metadata.file_type().is_dir()
|| metadata.file_type().is_symlink()
|| metadata.uid() != rustix::process::geteuid().as_raw()
|| metadata.mode() & 0o777 != 0o700
{
return Err(RuntimeError::UnsafeDirectory);
}
Ok(directory)
}
enum StaleKind {
Fifo,
Socket,
}
fn remove_stale(path: &Path, kind: StaleKind) -> Result<(), RuntimeError> {
let metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(_) => return Err(RuntimeError::Io),
};
let expected = match kind {
StaleKind::Fifo => metadata.file_type().is_fifo(),
StaleKind::Socket => metadata.file_type().is_socket(),
};
if !expected
|| metadata.file_type().is_symlink()
|| metadata.uid() != rustix::process::geteuid().as_raw()
{
return Err(match kind {
StaleKind::Fifo => RuntimeError::UnsafeFifo,
StaleKind::Socket => RuntimeError::UnsafeSocket,
});
}
std::fs::remove_file(path).map_err(|_| RuntimeError::Io)
}
impl Drop for Runtime {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.fifo_path);
}
}
@@ -0,0 +1,159 @@
//! A launch-time smoke test the panel can run before it trusts this binary.
//!
//! What this proves: the binary executes on this machine, its crypto library
//! loads and computes correctly, its frame and control parsers work and reject
//! what they should, and the kernel supports the process hardening the rest of
//! the design depends on.
//!
//! What it deliberately does not prove: that signing produces a correct
//! signature. Doing that needs a private key, and 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.
//! The verification path below exercises the same crypto backend; the signing
//! path is covered by the test suite, where generating a disposable key costs
//! nothing.
//!
//! It touches no filesystem, opens no socket, and needs no runtime directory,
//! because it runs before any of those exist.
use crate::control::{parse_control_line, ControlError, ControlMessage, MAX_CONTROL_LINE};
use crate::protocol::{self, AgentRequest, MAX_FRAME_LEN};
use ssh_encoding::Encode;
use ssh_key::{HashAlg, PublicKey};
/// A disposable public key, generated for this check and belonging to nobody.
/// Public material only -- there is no private counterpart anywhere.
const FIXTURE_PUBLIC_KEY: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDgSTquIEW1Ui0iRAQcZZAjS1OIA/D6Q+Arq/JfoVLkh";
/// One named check and whether it held.
struct Check {
name: &'static str,
ok: bool,
}
pub fn run() -> i32 {
let checks = vec![
Check {
name: "process hardening (RLIMIT_CORE=0, PR_SET_DUMPABLE=0)",
ok: hardening_available(),
},
Check {
name: "public key parsing and SHA256 fingerprint",
ok: public_key_math(),
},
Check {
name: "agent frame encode and decode",
ok: frame_round_trip(),
},
Check {
name: "oversized frames rejected before allocation",
ok: frame_bounds(),
},
Check {
name: "control protocol v1 accepted, other versions refused",
ok: control_versions(),
},
];
let failed = checks.iter().filter(|check| !check.ok).count();
for check in &checks {
println!("{} {}", if check.ok { "ok " } else { "FAIL" }, check.name);
}
if failed == 0 {
println!("ok: {} checks passed", checks.len());
println!("note: signing is exercised by the test suite, not here -- see selftest.rs");
0
} else {
println!("FAILED: {failed} of {} checks", checks.len());
1
}
}
/// The hardening is applied for real, then read back. A kernel that refuses
/// either of these is one where the design's assumptions about core dumps and
/// same-UID inspection do not hold, and the panel should know before it hands
/// this process any keys.
fn hardening_available() -> bool {
if crate::lifecycle::harden_process().is_err() {
return false;
}
let core = rustix::process::getrlimit(rustix::process::Resource::Core);
let dumpable = rustix::process::dumpable_behavior();
core.current == Some(0)
&& matches!(dumpable, Ok(rustix::process::DumpableBehavior::NotDumpable))
}
/// Parses a real public key and derives its fingerprint, which exercises the
/// same ssh-key backend the signing path uses.
fn public_key_math() -> bool {
let Ok(key) = PublicKey::from_openssh(FIXTURE_PUBLIC_KEY) else {
return false;
};
if !matches!(key.algorithm(), ssh_key::Algorithm::Ed25519) {
return false;
}
let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
// A fingerprint is base64 of a SHA-256 digest, so its shape is fixed.
fingerprint.starts_with("SHA256:") && fingerprint.len() > 20 && key.to_bytes().is_ok()
}
/// A sign request built here, decoded by the real decoder, and an identities
/// response encoded by the real encoder.
fn frame_round_trip() -> bool {
let Ok(key) = PublicKey::from_openssh(FIXTURE_PUBLIC_KEY) else {
return false;
};
let Ok(blob) = key.to_bytes() else {
return false;
};
let mut body = Vec::new();
if 13_u8.encode(&mut body).is_err()
|| blob.as_slice().encode(&mut body).is_err()
|| b"self-test".as_slice().encode(&mut body).is_err()
|| 0_u32.encode(&mut body).is_err()
{
return false;
}
let mut frame = match u32::try_from(body.len()) {
Ok(length) => length.to_be_bytes().to_vec(),
Err(_) => return false,
};
frame.extend_from_slice(&body);
let decoded = matches!(
protocol::decode_request(&frame),
Some(AgentRequest::Sign { .. })
);
let listed = protocol::identities_response(&[(blob.as_slice(), "self-test")]);
decoded && listed.len() > 4
}
/// The ceiling is checked before anything is allocated, so a claimed length
/// beyond it must be refused rather than believed.
fn frame_bounds() -> bool {
let mut oversized = u32::try_from(MAX_FRAME_LEN + 1)
.unwrap_or(u32::MAX)
.to_be_bytes()
.to_vec();
oversized.push(11);
let empty = [0_u8, 0, 0, 0];
protocol::decode_request(&oversized).is_none() && protocol::decode_request(&empty).is_none()
}
/// The control channel is versioned so an old bundled binary fails clearly
/// after a plugin update rather than misreading a newer panel.
fn control_versions() -> bool {
let hello = parse_control_line(br#"{"v":1,"type":"hello"}"#);
let wrong_version = parse_control_line(br#"{"v":2,"type":"hello"}"#);
let unknown = parse_control_line(br#"{"v":1,"type":"exec"}"#);
let mut overlong = vec![b'{'; MAX_CONTROL_LINE + 1];
overlong.push(b'}');
matches!(hello, Ok(ControlMessage::Hello { .. }))
&& matches!(wrong_version, Err(ControlError::WrongVersion))
&& matches!(unknown, Err(ControlError::Malformed))
&& matches!(parse_control_line(&overlong), Err(ControlError::TooLong))
}
@@ -0,0 +1,142 @@
//! Bounded Unix-socket client transport for the single-owner state loop.
use crate::peer::PeerContext;
use crate::protocol::{self, AgentRequest, MAX_FRAME_LEN};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{mpsc, oneshot, Semaphore};
use tokio::time::{timeout, Duration};
pub const MAX_CLIENTS: usize = 8;
/// Socket read and write timeouts. These are machine-speed operations, so
/// they stay short regardless of how long a person may take to answer.
pub const CLIENT_IO_TIMEOUT: Duration = Duration::from_secs(30);
/// How long a client blocks waiting for the state loop's answer. It must
/// exceed `approvals::REQUEST_LIFETIME_MS`, or a client would give up before
/// the request it is waiting on expires and the human deadline would be
/// decorative -- which it was when both were thirty seconds.
pub const RESPONSE_TIMEOUT: Duration = Duration::from_secs(150);
const ACCEPT_ERROR_DELAY: Duration = Duration::from_millis(100);
pub struct ClientEvent {
pub peer: PeerContext,
pub request: AgentRequest,
pub reply: oneshot::Sender<Vec<u8>>,
}
pub async fn run(listener: UnixListener, events: mpsc::Sender<ClientEvent>) {
let permits = Arc::new(Semaphore::new(MAX_CLIENTS));
loop {
let stream = match listener.accept().await {
Ok((stream, _)) => stream,
Err(_) => {
// accept(2) can surface connection and resource errors which
// do not invalidate the listener. There is no portable error
// taxonomy that proves this descriptor has become unusable,
// so keep serving and pace persistent failures; shutdown
// aborts this task with the rest of the companion.
tokio::time::sleep(ACCEPT_ERROR_DELAY).await;
continue;
}
};
let Ok(permit) = permits.clone().try_acquire_owned() else {
drop(stream);
continue;
};
let events = events.clone();
tokio::spawn(async move {
let _permit = permit;
serve_client(stream, events).await;
});
}
}
async fn serve_client(mut stream: UnixStream, events: mpsc::Sender<ClientEvent>) {
let Ok(credentials) = stream.peer_cred() else {
return;
};
let Some(pid) = credentials.pid() else { return };
let Ok(pid) = u32::try_from(pid) else { return };
let Ok(peer) = PeerContext::capture(credentials.uid(), pid) else {
return;
};
loop {
let Some(frame) = read_frame(&mut stream).await else {
return;
};
let Some(request) = protocol::decode_request(&frame) else {
if write_response(&mut stream, protocol::failure_response())
.await
.is_err()
{
return;
}
continue;
};
let (reply, response) = oneshot::channel();
if events
.try_send(ClientEvent {
peer: peer.clone(),
request,
reply,
})
.is_err()
{
if write_response(&mut stream, protocol::failure_response())
.await
.is_err()
{
return;
}
continue;
}
// Watch the socket while the request is pending. Awaiting only the
// reply would leave a client that walked away undetected until the
// deadline -- and a prompt on screen for a signature nobody is
// waiting for any more. Returning here drops the reply channel, which
// is what tells the state loop to withdraw the request.
//
// Anything that actually arrives is either EOF or a pipelined frame,
// which this protocol does not use; both end the connection.
let mut probe = [0_u8; 1];
let bytes = tokio::select! {
result = timeout(RESPONSE_TIMEOUT, response) => match result {
Ok(Ok(bytes)) => bytes,
_ => protocol::failure_response(),
},
_ = stream.read(&mut probe) => return,
};
if write_response(&mut stream, bytes).await.is_err() {
return;
}
}
}
async fn read_frame(stream: &mut UnixStream) -> Option<Vec<u8>> {
let mut header = [0_u8; 4];
timeout(CLIENT_IO_TIMEOUT, stream.read_exact(&mut header))
.await
.ok()?
.ok()?;
let length = usize::try_from(u32::from_be_bytes(header)).ok()?;
if length == 0 || length > MAX_FRAME_LEN {
return None;
}
let mut frame = Vec::with_capacity(length + 4);
frame.extend_from_slice(&header);
frame.resize(length + 4, 0);
timeout(CLIENT_IO_TIMEOUT, stream.read_exact(&mut frame[4..]))
.await
.ok()?
.ok()?;
Some(frame)
}
async fn write_response(stream: &mut UnixStream, response: Vec<u8>) -> std::io::Result<()> {
timeout(CLIENT_IO_TIMEOUT, stream.write_all(&response))
.await
.map_err(|_| std::io::ErrorKind::TimedOut)??;
Ok(())
}
@@ -0,0 +1,32 @@
//! The two signing paths allowed by the v1 agent protocol.
use crate::rsa_keys;
use signature::{SignatureEncoding, Signer};
use ssh_key::{private::KeypairData, Algorithm, HashAlg, PrivateKey, Signature};
/// Sign `message` with the exact algorithm selected by agent-protocol flags.
///
/// Callers deliberately receive no underlying crypto error: errors can carry
/// parser or key context and the wire protocol has only a generic failure.
pub(crate) fn sign(key: &PrivateKey, message: &[u8], flags: u32) -> Option<Signature> {
match key.key_data() {
KeypairData::Ed25519(_) if flags == 0 => key.try_sign(message).ok(),
KeypairData::Rsa(keypair) => {
let hash = match flags {
2 => HashAlg::Sha256,
4 => HashAlg::Sha512,
_ => return None,
};
let bytes = match rsa_keys::sha2_signing_key(keypair, hash).ok()? {
rsa_keys::Sha2SigningKey::Sha256(signing) => {
signing.try_sign(message).ok()?.to_vec()
}
rsa_keys::Sha2SigningKey::Sha512(signing) => {
signing.try_sign(message).ok()?.to_vec()
}
};
Signature::new(Algorithm::Rsa { hash: Some(hash) }, bytes).ok()
}
_ => None,
}
}
@@ -0,0 +1,77 @@
//! Explicit vault state and epoch tracking for the signing worker.
/// State relevant to identity visibility and signing authorization.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VaultState {
/// No account/public cache is available.
LoggedOut,
/// A candidate is being validated; private signing is denied.
Loading,
/// A validated private set is available for the current epoch.
Unlocked,
/// Only the last validated public cache remains.
LockedCached,
/// Locked before any public cache has been loaded.
LockedEmpty,
}
/// Single-owner state tracker. Mutating methods are the authorization
/// linearization points used by the keystore actor.
pub(crate) struct StateTracker {
epoch: u64,
state: VaultState,
}
impl StateTracker {
pub(crate) fn new() -> Self {
Self {
epoch: 0,
state: VaultState::LoggedOut,
}
}
pub(crate) fn begin_load(&mut self, epoch: u64) -> bool {
if epoch <= self.epoch {
return false;
}
self.epoch = epoch;
self.state = VaultState::Loading;
true
}
pub(crate) fn publish(&mut self, epoch: u64) -> bool {
if self.epoch != epoch || self.state != VaultState::Loading {
return false;
}
self.state = VaultState::Unlocked;
true
}
pub(crate) fn lock(&mut self, epoch: u64, has_public_cache: bool) {
// This assignment is the deny-signing linearization point. Private
// values are dropped by the owner only after this returns.
self.epoch = self.epoch.max(epoch);
self.state = if has_public_cache {
VaultState::LockedCached
} else {
VaultState::LockedEmpty
};
}
pub(crate) fn logout(&mut self, epoch: u64) {
self.epoch = self.epoch.max(epoch);
self.state = VaultState::LoggedOut;
}
pub(crate) fn allows(&self, epoch: u64) -> bool {
self.epoch == epoch && self.state == VaultState::Unlocked
}
pub(crate) fn epoch(&self) -> u64 {
self.epoch
}
pub(crate) fn state(&self) -> VaultState {
self.state
}
}
@@ -0,0 +1,246 @@
use qs_bitwarden_ssh_agent::approvals::{ApprovalError, ApprovalManager, Submit};
use qs_bitwarden_ssh_agent::keystore::{CandidateItem, KeyStore};
use qs_bitwarden_ssh_agent::peer::PeerContext;
use rand_core::OsRng;
use ssh_key::{Algorithm, HashAlg, PrivateKey};
use zeroize::Zeroizing;
fn peer(pid: u32, start: u64, executable: &str) -> PeerContext {
PeerContext::new(rustix::process::geteuid().as_raw(), pid, start, executable).unwrap()
}
fn loaded_store(epoch: u64) -> (KeyStore, Vec<u8>) {
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
let blob = key.public_key().to_bytes().unwrap();
let mut store = KeyStore::new();
let mut load = store.begin_load(epoch, 4096).unwrap();
load.add(CandidateItem {
item_id: "item".into(),
name: "Work".into(),
private_key_pem: Zeroizing::new(
key.to_openssh(Default::default())
.unwrap()
.as_bytes()
.to_vec(),
),
public_key: key.public_key().to_openssh().unwrap(),
fingerprint: key.public_key().fingerprint(HashAlg::Sha256).to_string(),
requires_reprompt: false,
})
.unwrap();
store.publish(load).unwrap();
(store, blob)
}
/// Two clocks bound one wait, and they are not independent. The companion's
/// request deadline is the human's time to answer; the server's reply wait is
/// how long a client blocks for that answer. If the second is shorter, the
/// first is decorative -- which it was, with both set to thirty seconds.
#[test]
fn a_client_waits_longer_than_the_human_is_given_to_answer() {
assert!(
qs_bitwarden_ssh_agent::server::RESPONSE_TIMEOUT
> std::time::Duration::from_millis(
qs_bitwarden_ssh_agent::approvals::REQUEST_LIFETIME_MS
),
"a client must not give up before the request it is waiting on expires"
);
// Reading a frame or writing a reply is machine-speed and stays short;
// only the wait on a person is long.
assert!(
qs_bitwarden_ssh_agent::server::CLIENT_IO_TIMEOUT
< qs_bitwarden_ssh_agent::server::RESPONSE_TIMEOUT,
"socket I/O should not inherit the human-scale timeout"
);
// The number itself, so raising it stays a deliberate act.
assert_eq!(
qs_bitwarden_ssh_agent::approvals::REQUEST_LIFETIME_MS,
120_000,
"see docs/decisions/0003-request-deadline.md"
);
}
#[test]
fn queue_is_bounded_expires_and_disconnect_cancels() {
let (_, key) = loaded_store(1);
let mut approvals = ApprovalManager::new(rustix::process::geteuid().as_raw());
let client = peer(100, 10, "/usr/bin/ssh");
let mut ids = Vec::new();
for _ in 0..4 {
match approvals.submit(1, &key, client.clone(), 1_000).unwrap() {
Submit::Pending(id) => ids.push(id),
Submit::Granted(_) => panic!("no grant exists"),
}
}
assert_eq!(
approvals.submit(1, &key, client.clone(), 1_000),
Err(ApprovalError::QueueFull)
);
approvals.disconnect(ids[0]);
assert_eq!(
approvals.approve(ids[0], 0, 1_001),
Err(ApprovalError::UnknownRequest)
);
// Derived from the lifetime rather than hardcoded, so changing the
// deadline cannot leave this test asserting the old one.
let past_deadline = qs_bitwarden_ssh_agent::approvals::REQUEST_LIFETIME_MS + 1_001;
approvals.expire(past_deadline - 1_001 - 1);
assert_ne!(
approvals.pending_count(),
0,
"a request must survive right up to its deadline"
);
approvals.expire(past_deadline);
assert_eq!(approvals.pending_count(), 0);
assert_eq!(
approvals.approve(ids[1], 0, past_deadline),
Err(ApprovalError::UnknownRequest)
);
}
#[test]
fn approval_is_single_use_and_old_epoch_fails_at_final_check() {
let (mut store, key) = loaded_store(7);
let mut approvals = ApprovalManager::new(rustix::process::geteuid().as_raw());
let id = match approvals
.submit(7, &key, peer(101, 20, "/usr/bin/ssh"), 0)
.unwrap()
{
Submit::Pending(id) => id,
_ => unreachable!(),
};
let authorization = approvals.approve(id, 0, 1).unwrap();
assert_eq!(
approvals.approve(id, 0, 1),
Err(ApprovalError::UnknownRequest)
);
assert!(authorization.finalize(&store).is_some());
let second = match approvals
.submit(7, &key, peer(101, 20, "/usr/bin/ssh"), 2)
.unwrap()
{
Submit::Pending(id) => approvals.approve(id, 0, 2).unwrap(),
_ => unreachable!(),
};
store.lock(8);
assert!(second.finalize(&store).is_none());
}
/// A grant covers one key and one program, not one process. Git spawns a
/// fresh `ssh-keygen` for every commit it signs, so a grant tied to a PID
/// never matches the case grants exist for -- a rebase would prompt once per
/// commit regardless. Scoping to the executable path is what makes the
/// feature do its job; see docs/decisions/0002-grant-scope.md for the
/// exposure this accepts.
#[test]
fn grants_are_capped_and_bound_to_key_and_executable() {
let (_, key) = loaded_store(3);
let mut approvals = ApprovalManager::new(rustix::process::geteuid().as_raw());
let original = peer(200, 50, "/usr/bin/git");
let id = match approvals.submit(3, &key, original.clone(), 0).unwrap() {
Submit::Pending(id) => id,
_ => unreachable!(),
};
approvals.approve(id, 10_000, 10).unwrap();
assert_eq!(approvals.grants()[0].expires_at_ms, 900_010);
assert!(matches!(
approvals.submit(3, &key, original.clone(), 20).unwrap(),
Submit::Granted(_)
));
// The case that matters: a different process, same program. Every commit
// in a rebase looks like this.
assert!(
matches!(
approvals
.submit(3, &key, peer(9001, 7777, "/usr/bin/git"), 20)
.unwrap(),
Submit::Granted(_)
),
"a fresh process running the same program must ride the grant"
);
// A different program does not, even from the same process identity.
assert!(matches!(
approvals
.submit(3, &key, peer(200, 50, "/usr/bin/ssh"), 20)
.unwrap(),
Submit::Pending(_)
));
// Nor does a different key.
assert!(matches!(
approvals.submit(3, b"different key", original, 20).unwrap(),
Submit::Pending(_)
));
}
/// Widening the scope to a program must not widen it across users. The peer
/// UID is the one thing the companion actually verifies.
#[test]
fn a_grant_never_crosses_to_another_user() {
let (_, key) = loaded_store(3);
let expected = rustix::process::geteuid().as_raw();
let mut approvals = ApprovalManager::new(expected);
let mine = peer(200, 50, "/usr/bin/git");
let id = match approvals.submit(3, &key, mine, 0).unwrap() {
Submit::Pending(id) => id,
_ => unreachable!(),
};
approvals.approve(id, 120, 10).unwrap();
let theirs = PeerContext::new(expected.wrapping_add(1), 201, 51, "/usr/bin/git").unwrap();
assert!(
approvals.submit(3, &key, theirs, 20).is_err(),
"another user must not reach a grant, whatever program they run"
);
}
#[test]
fn wrong_uid_and_lifecycle_revocation_fail_closed() {
let (_, key) = loaded_store(5);
let expected = rustix::process::geteuid().as_raw();
let mut approvals = ApprovalManager::new(expected);
let wrong = PeerContext::new(expected.wrapping_add(1), 1, 1, "/usr/bin/ssh").unwrap();
assert_eq!(
approvals.submit(5, &key, wrong, 0),
Err(ApprovalError::WrongUid)
);
let p = peer(300, 60, "/usr/bin/ssh");
let id = match approvals.submit(5, &key, p.clone(), 0).unwrap() {
Submit::Pending(id) => id,
_ => unreachable!(),
};
approvals.approve(id, 120, 0).unwrap();
let grant_id = approvals.grants()[0].id;
approvals.revoke_grant(grant_id);
assert!(approvals.grants().is_empty());
let id = match approvals.submit(5, &key, p.clone(), 0).unwrap() {
Submit::Pending(id) => id,
_ => unreachable!(),
};
approvals.approve(id, 120, 0).unwrap();
approvals.revoke_peer(&p);
assert!(approvals.grants().is_empty());
let id = match approvals.submit(5, &key, p.clone(), 0).unwrap() {
Submit::Pending(id) => id,
_ => unreachable!(),
};
approvals.approve(id, 120, 0).unwrap();
approvals.invalidate_all();
assert!(approvals.grants().is_empty());
assert_eq!(approvals.pending_count(), 0);
assert!(matches!(
approvals.submit(5, &key, p, 1).unwrap(),
Submit::Pending(_)
));
}
#[test]
fn peer_snapshot_comes_from_proc_without_trusting_display_metadata() {
let pid = std::process::id();
let snapshot = PeerContext::capture(rustix::process::geteuid().as_raw(), pid).unwrap();
assert_eq!(snapshot.pid, pid);
assert!(snapshot.start_time_ticks > 0);
assert!(snapshot.executable.is_absolute());
}
@@ -0,0 +1,202 @@
use qs_bitwarden_ssh_agent::keystore::{
CandidateItem, KeyStore, LoadError, SkipCode, MAX_FILTERED_BYTES, MAX_KEYS, MAX_PEM_BYTES,
};
use qs_bitwarden_ssh_agent::state::VaultState;
use rand_core::OsRng;
use signature::Verifier;
use ssh_key::private::RsaKeypair;
use ssh_key::{Algorithm, HashAlg, PrivateKey};
use zeroize::Zeroizing;
fn item(id: &str, key: &PrivateKey) -> CandidateItem {
CandidateItem {
item_id: id.to_owned(),
name: format!("key {id}"),
private_key_pem: Zeroizing::new(
key.to_openssh(Default::default())
.unwrap()
.as_bytes()
.to_vec(),
),
public_key: key.public_key().to_openssh().unwrap(),
fingerprint: key.public_key().fingerprint(HashAlg::Sha256).to_string(),
requires_reprompt: false,
}
}
fn ed25519() -> PrivateKey {
PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap()
}
#[test]
fn candidate_skips_bad_mismatched_reprompt_and_duplicate_items() {
let valid = ed25519();
let other = ed25519();
let mut store = KeyStore::new();
let mut load = store.begin_load(1, 4096).unwrap();
assert_eq!(load.add(item("valid", &valid)).unwrap(), None);
assert_eq!(
load.add(CandidateItem {
private_key_pem: Zeroizing::new(b"not a private key".to_vec()),
..item("malformed", &other)
})
.unwrap(),
Some(SkipCode::MalformedPrivateKey)
);
assert_eq!(
load.add(CandidateItem {
public_key: other.public_key().to_openssh().unwrap(),
..item("public-mismatch", &valid)
})
.unwrap(),
Some(SkipCode::PublicKeyMismatch)
);
assert_eq!(
load.add(CandidateItem {
fingerprint: "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned(),
..item("fingerprint-mismatch", &valid)
})
.unwrap(),
Some(SkipCode::FingerprintMismatch)
);
assert_eq!(
load.add(CandidateItem {
requires_reprompt: true,
..item("reprompt", &other)
})
.unwrap(),
Some(SkipCode::RequiresReprompt)
);
assert_eq!(
load.add(item("duplicate", &valid)).unwrap(),
Some(SkipCode::Duplicate)
);
let report = store.publish(load).unwrap();
assert_eq!(report.loaded, 1);
assert_eq!(report.skipped.len(), 5);
assert_eq!(store.state(), VaultState::Unlocked);
assert_eq!(store.public_identities().len(), 1);
assert_eq!(store.public_identities()[0].item_id, "valid");
}
#[test]
fn global_limits_reject_the_whole_candidate_and_leave_no_private_set() {
let key = ed25519();
let mut store = KeyStore::new();
let mut initial = store.begin_load(1, 4096).unwrap();
initial.add(item("old", &key)).unwrap();
store.publish(initial).unwrap();
assert!(store
.authorize(store.public_identities()[0].public_blob())
.is_some());
assert_eq!(
store.begin_load(2, MAX_FILTERED_BYTES + 1).unwrap_err(),
LoadError::FilteredPayloadTooLarge
);
assert_eq!(store.state(), VaultState::Loading);
assert!(store
.authorize(key.public_key().to_bytes().unwrap().as_slice())
.is_none());
let mut too_many = store.begin_load(3, 4096).unwrap();
for index in 0..MAX_KEYS {
let unique = ed25519();
assert_eq!(
too_many.add(item(&index.to_string(), &unique)).unwrap(),
None
);
}
assert_eq!(
too_many.add(item("overflow", &ed25519())).unwrap_err(),
LoadError::TooManyKeys
);
let mut oversized = store.begin_load(4, MAX_PEM_BYTES).unwrap();
let mut huge = item("huge", &key);
huge.private_key_pem = Zeroizing::new(vec![b'x'; MAX_PEM_BYTES + 1]);
assert_eq!(oversized.add(huge).unwrap_err(), LoadError::PemTooLarge);
}
#[test]
fn publish_is_atomic_and_stale_or_failed_loads_cannot_mix_epochs() {
let first = ed25519();
let second = ed25519();
let mut store = KeyStore::new();
let mut load = store.begin_load(7, 4096).unwrap();
load.add(item("first", &first)).unwrap();
store.lock(8);
assert_eq!(store.publish(load).unwrap_err(), LoadError::StaleEpoch);
assert!(store.public_identities().is_empty());
let mut replacement = store.begin_load(9, 4096).unwrap();
replacement.add(item("second", &second)).unwrap();
store.publish(replacement).unwrap();
assert_eq!(store.public_identities().len(), 1);
assert_eq!(store.public_identities()[0].item_id, "second");
}
#[test]
fn lock_invalidates_authorization_before_dropping_keys_and_keeps_public_cache() {
let key = ed25519();
let public_blob = key.public_key().to_bytes().unwrap();
let mut store = KeyStore::new();
let mut load = store.begin_load(11, 4096).unwrap();
load.add(item("work", &key)).unwrap();
store.publish(load).unwrap();
let permit = store.authorize(&public_blob).unwrap();
store.lock(12);
assert_eq!(store.state(), VaultState::LockedCached);
assert_eq!(store.public_identities().len(), 1);
assert!(store.sign(&permit, b"must not sign", 0).is_none());
assert!(store.authorize(&public_blob).is_none());
}
#[test]
fn current_epoch_permit_signs_without_cloning_private_keys() {
let key = ed25519();
let public_blob = key.public_key().to_bytes().unwrap();
let mut store = KeyStore::new();
let mut load = store.begin_load(21, 4096).unwrap();
load.add(item("work", &key)).unwrap();
store.publish(load).unwrap();
let permit = store.authorize(&public_blob).unwrap();
let signature = store.sign(&permit, b"authorized payload", 0).unwrap();
Verifier::verify(key.public_key(), b"authorized payload", &signature).unwrap();
}
#[test]
fn undersized_rsa_is_rejected_during_load() {
let weak_rsa = rsa::RsaPrivateKey::new(&mut OsRng, 1024).unwrap();
let weak = PrivateKey::from(RsaKeypair::try_from(weak_rsa).unwrap());
let mut store = KeyStore::new();
let mut load = store.begin_load(31, 4096).unwrap();
assert_eq!(
load.add(item("weak-rsa", &weak)).unwrap(),
Some(SkipCode::InvalidPrivateKey)
);
assert_eq!(store.publish(load).unwrap().loaded, 0);
}
#[test]
fn logout_invalidates_permits_and_clears_public_and_private_sets() {
let key = ed25519();
let public_blob = key.public_key().to_bytes().unwrap();
let mut store = KeyStore::new();
let mut load = store.begin_load(41, 4096).unwrap();
load.add(item("work", &key)).unwrap();
store.publish(load).unwrap();
let permit = store.authorize(&public_blob).unwrap();
store.logout(42);
assert_eq!(store.state(), VaultState::LoggedOut);
assert!(store.public_identities().is_empty());
assert!(store.sign(&permit, b"must not sign", 0).is_none());
}
@@ -0,0 +1,983 @@
use qs_bitwarden_ssh_agent::control::{
parse_control_line, ControlError, ControlMessage, LoadStatus, MAX_CONTROL_LINE,
};
use qs_bitwarden_ssh_agent::runtime::{RuntimeError, ServiceRuntime};
use rand_core::{OsRng, RngCore};
use signature::Verifier;
use ssh_encoding::{Decode, Encode};
use ssh_key::{Algorithm, HashAlg, PrivateKey, Signature};
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::process::{Command, Stdio};
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"qsbw-lifecycle-{}-{}",
std::process::id(),
OsRng.next_u64()
));
fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn control_contract_accepts_every_allowlisted_message() {
let messages = [
r#"{"v":1,"type":"hello"}"#,
r#"{"v":1,"type":"key_load_begin","epoch":7,"loadId":"00112233445566778899aabbccddeeff"}"#,
r#"{"v":1,"type":"key_load_end","epoch":7,"status":"ok"}"#,
r#"{"v":1,"type":"vault_locked","epoch":8}"#,
r#"{"v":1,"type":"vault_logged_out"}"#,
r#"{"v":1,"type":"approve","requestId":42,"grantSeconds":120}"#,
r#"{"v":1,"type":"deny","requestId":42}"#,
r#"{"v":1,"type":"unlock_cancelled","requestId":41,"reason":"user-cancelled"}"#,
r#"{"v":1,"type":"revoke_grants"}"#,
r#"{"v":1,"type":"shutdown"}"#,
];
for message in messages {
parse_control_line(message.as_bytes()).unwrap();
}
assert!(matches!(
parse_control_line(messages[2].as_bytes()),
Ok(ControlMessage::KeyLoadEnd {
status: LoadStatus::Ok,
..
})
));
}
#[test]
fn control_contract_rejects_untrusted_shapes_and_versions() {
assert_eq!(parse_control_line(b""), Err(ControlError::Empty));
assert_eq!(
parse_control_line(b"{not json}"),
Err(ControlError::Malformed)
);
assert_eq!(
parse_control_line(br#"{"v":2,"type":"hello"}"#),
Err(ControlError::WrongVersion)
);
assert_eq!(
parse_control_line(br#"{"v":1,"type":"unknown"}"#),
Err(ControlError::Malformed)
);
assert_eq!(
parse_control_line(br#"{"v":1,"type":"hello","extra":true}"#),
Err(ControlError::Malformed)
);
let oversized = vec![b'x'; MAX_CONTROL_LINE + 1];
assert_eq!(parse_control_line(&oversized), Err(ControlError::TooLong));
}
#[tokio::test(flavor = "current_thread")]
async fn singleton_owns_private_socket_and_cleans_runtime_paths() {
let temp = TempDir::new();
let owner = ServiceRuntime::acquire(&temp.0).unwrap();
let listener = owner.bind_socket().unwrap();
let socket_path = owner.socket_path().to_path_buf();
let fifo_path = owner.runtime().fifo_path().to_path_buf();
let metadata = fs::symlink_metadata(&socket_path).unwrap();
assert!(metadata.file_type().is_socket());
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
assert!(matches!(
ServiceRuntime::acquire(&temp.0),
Err(RuntimeError::AlreadyRunning)
));
drop(listener);
drop(owner);
assert!(!socket_path.exists());
assert!(!fifo_path.exists());
let restarted = ServiceRuntime::acquire(&temp.0).unwrap();
assert!(restarted.runtime().fifo_path().exists());
}
#[test]
fn executable_handshake_is_private_singleton_and_eof_supervised() {
let temp = TempDir::new();
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
let mut child = Command::new(executable)
.env_clear()
.env("XDG_RUNTIME_DIR", &temp.0)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
child
.stdin
.as_mut()
.unwrap()
.write_all(b"{\"v\":1,\"type\":\"hello\"}\n")
.unwrap();
let mut ready_line = String::new();
BufReader::new(child.stdout.take().unwrap())
.read_line(&mut ready_line)
.unwrap();
let ready: serde_json::Value = serde_json::from_str(&ready_line).unwrap();
assert_eq!(ready["v"], 1);
assert_eq!(ready["type"], "ready");
let socket = PathBuf::from(ready["socketPath"].as_str().unwrap());
let fifo = PathBuf::from(ready["fifoPath"].as_str().unwrap());
assert_eq!(
fs::symlink_metadata(&socket).unwrap().permissions().mode() & 0o777,
0o600
);
let status = Command::new(executable)
.env_clear()
.env("XDG_RUNTIME_DIR", &temp.0)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
assert!(!status.success());
drop(child.stdin.take());
assert!(child.wait().unwrap().success());
assert!(!socket.exists());
assert!(!fifo.exists());
assert!(!temp.0.join("qs-bitwarden-cli").exists());
}
#[test]
fn hello_is_a_one_time_handshake_not_a_signing_gate_command() {
let temp = TempDir::new();
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
let mut child = Command::new(executable)
.env_clear()
.env("XDG_RUNTIME_DIR", &temp.0)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut input = child.stdin.take().unwrap();
let mut output = BufReader::new(child.stdout.take().unwrap());
input.write_all(b"{\"v\":1,\"type\":\"hello\"}\n").unwrap();
input.flush().unwrap();
assert_eq!(read_json_line(&mut output)["type"], "ready");
input
.write_all(b"{\"v\":1,\"type\":\"vault_locked\",\"epoch\":1}\n")
.unwrap();
input.flush().unwrap();
assert_eq!(read_json_line(&mut output)["type"], "locked");
input.write_all(b"{\"v\":1,\"type\":\"hello\"}\n").unwrap();
input.flush().unwrap();
assert!(!child.wait().unwrap().success());
}
#[test]
fn disposable_key_load_identity_and_approved_sign_cross_the_real_socket() {
let temp = TempDir::new();
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
let mut child = Command::new(executable)
.env_clear()
.env("XDG_RUNTIME_DIR", &temp.0)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut input = child.stdin.take().unwrap();
let mut output = BufReader::new(child.stdout.take().unwrap());
input.write_all(b"{\"v\":1,\"type\":\"hello\"}\n").unwrap();
input.flush().unwrap();
let ready = read_json_line(&mut output);
let socket = PathBuf::from(ready["socketPath"].as_str().unwrap());
let fifo = PathBuf::from(ready["fifoPath"].as_str().unwrap());
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
let public_blob = key.public_key().to_bytes().unwrap();
let nonce = "0123456789abcdef0123456789abcdef";
writeln!(
input,
"{{\"v\":1,\"type\":\"key_load_begin\",\"epoch\":1,\"loadId\":\"{nonce}\"}}"
)
.unwrap();
input.flush().unwrap();
let payload = serde_json::json!({"loadId": nonce, "items": [{
"itemId": "disposable", "name": "Disposable test key",
"privateKey": key.to_openssh(Default::default()).unwrap().as_str(),
"publicKey": key.public_key().to_openssh().unwrap(),
"fingerprint": key.public_key().fingerprint(HashAlg::Sha256).to_string(),
"requiresReprompt": false
}]});
let mut writer = fs::OpenOptions::new().write(true).open(&fifo).unwrap();
writer
.write_all(&serde_json::to_vec(&payload).unwrap())
.unwrap();
writer.write_all(b"\n").unwrap();
drop(writer);
input
.write_all(b"{\"v\":1,\"type\":\"key_load_end\",\"epoch\":1,\"status\":\"ok\"}\n")
.unwrap();
input.flush().unwrap();
let mut loaded = read_json_line(&mut output);
while loaded["type"] == "public_key" {
loaded = read_json_line(&mut output);
}
assert_eq!(loaded["type"], "keys_loaded");
assert_eq!(loaded["keyCount"], 1);
let mut client = UnixStream::connect(&socket).unwrap();
let mut slow_client = UnixStream::connect(&socket).unwrap();
slow_client.write_all(&100_u32.to_be_bytes()).unwrap();
client.write_all(&[0, 0, 0, 1, 11]).unwrap();
let identities = read_agent_frame(&mut client);
assert_eq!(identities[4], 12);
assert!(identities
.windows(public_blob.len())
.any(|part| part == public_blob));
let message = b"task nine approved signing";
let mut request = vec![13];
public_blob.encode(&mut request).unwrap();
message.as_slice().encode(&mut request).unwrap();
0_u32.encode(&mut request).unwrap();
let mut frame = Vec::new();
u32::try_from(request.len())
.unwrap()
.encode(&mut frame)
.unwrap();
frame.extend_from_slice(&request);
client.write_all(&frame).unwrap();
let approval = read_json_line(&mut output);
assert_eq!(approval["type"], "approval_required");
let request_id = approval["requestId"].as_u64().unwrap();
writeln!(
input,
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{request_id},\"grantSeconds\":0}}"
)
.unwrap();
input.flush().unwrap();
let response = read_agent_frame(&mut client);
assert_eq!(response[4], 14);
let mut fields = &response[5..];
let encoded = Vec::<u8>::decode(&mut fields).unwrap();
let signature = Signature::try_from(encoded.as_slice()).unwrap();
Verifier::verify(key.public_key(), message, &signature).unwrap();
// Exercise the real OpenSSH signing client with only a public key file;
// the disposable private key remains solely in the helper keystore.
let public_path = temp.0.join("disposable.pub");
let message_path = temp.0.join("commit.txt");
fs::write(&public_path, key.public_key().to_openssh().unwrap()).unwrap();
fs::write(&message_path, b"disposable commit object").unwrap();
let mut ssh_keygen = Command::new("/usr/bin/ssh-keygen")
.env_clear()
.env("SSH_AUTH_SOCK", &socket)
.args(["-Y", "sign", "-f"])
.arg(&public_path)
.args(["-n", "git"])
.arg(&message_path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let approval = read_json_line(&mut output);
let request_id = approval["requestId"].as_u64().unwrap();
writeln!(
input,
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{request_id},\"grantSeconds\":0}}"
)
.unwrap();
input.flush().unwrap();
assert!(ssh_keygen.wait().unwrap().success());
assert!(message_path.with_extension("txt.sig").exists());
input
.write_all(b"{\"v\":1,\"type\":\"shutdown\"}\n")
.unwrap();
input.flush().unwrap();
assert!(child.wait().unwrap().success());
}
/// A lock drops the private set but keeps the public projection, and the
/// design's state table says a locked-with-cache agent still lists identities:
/// otherwise every `ssh` after a lock raises an unlock prompt, including the
/// ones authenticating with an on-disk key. Signing is what the lock denies.
#[test]
fn a_locked_vault_still_lists_identities_but_refuses_to_sign() {
let temp = TempDir::new();
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
let mut child = Command::new(executable)
.env_clear()
.env("XDG_RUNTIME_DIR", &temp.0)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut input = child.stdin.take().unwrap();
let mut output = BufReader::new(child.stdout.take().unwrap());
input.write_all(b"{\"v\":1,\"type\":\"hello\"}\n").unwrap();
input.flush().unwrap();
let ready = read_json_line(&mut output);
let socket = PathBuf::from(ready["socketPath"].as_str().unwrap());
let fifo = PathBuf::from(ready["fifoPath"].as_str().unwrap());
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
let public_blob = key.public_key().to_bytes().unwrap();
let nonce = "0123456789abcdef0123456789abcdef";
writeln!(
input,
"{{\"v\":1,\"type\":\"key_load_begin\",\"epoch\":1,\"loadId\":\"{nonce}\"}}"
)
.unwrap();
input.flush().unwrap();
let payload = serde_json::json!({"loadId": nonce, "items": [{
"itemId": "disposable", "name": "Disposable test key",
"privateKey": key.to_openssh(Default::default()).unwrap().as_str(),
"publicKey": key.public_key().to_openssh().unwrap(),
"fingerprint": key.public_key().fingerprint(HashAlg::Sha256).to_string(),
"requiresReprompt": false
}]});
let mut writer = fs::OpenOptions::new().write(true).open(&fifo).unwrap();
writer
.write_all(&serde_json::to_vec(&payload).unwrap())
.unwrap();
writer.write_all(b"\n").unwrap();
drop(writer);
input
.write_all(b"{\"v\":1,\"type\":\"key_load_end\",\"epoch\":1,\"status\":\"ok\"}\n")
.unwrap();
input.flush().unwrap();
let mut loaded = read_json_line(&mut output);
while loaded["type"] == "public_key" {
loaded = read_json_line(&mut output);
}
assert_eq!(loaded["type"], "keys_loaded");
assert_eq!(loaded["keyCount"], 1);
// Unlocked: the identity is offered.
assert_eq!(identity_count(&socket), 1);
input
.write_all(b"{\"v\":1,\"type\":\"vault_locked\",\"epoch\":1}\n")
.unwrap();
input.flush().unwrap();
let locked = read_json_line(&mut output);
assert_eq!(locked["type"], "locked");
// Locked with a cache: still listed, because public keys are not secret.
assert_eq!(identity_count(&socket), 1);
// The private set is gone, so signing cannot proceed. The request is held
// and an unlock is asked for rather than failed outright -- refusing a
// client that has no way to retry is worse than asking. Dismissing that
// unlock is what turns it into a refusal, and it must do so at once
// rather than leaving the client to wait out the deadline.
let socket_for_client = socket.clone();
let blob = public_blob.clone();
let client = std::thread::spawn(move || {
let mut stream = UnixStream::connect(&socket_for_client).unwrap();
let mut request = Vec::new();
13_u8.encode(&mut request).unwrap();
blob.as_slice().encode(&mut request).unwrap();
b"payload".as_slice().encode(&mut request).unwrap();
0_u32.encode(&mut request).unwrap();
let mut framed = u32::try_from(request.len()).unwrap().to_be_bytes().to_vec();
framed.extend_from_slice(&request);
stream.write_all(&framed).unwrap();
read_agent_frame(&mut stream)
});
let unlock = read_json_line(&mut output);
assert_eq!(unlock["type"], "unlock_required");
let request_id = unlock["requestId"].as_u64().unwrap();
let started = std::time::Instant::now();
writeln!(
input,
"{{\"v\":1,\"type\":\"unlock_cancelled\",\"requestId\":{request_id},\"reason\":\"user-cancelled\"}}"
)
.unwrap();
input.flush().unwrap();
let response = client.join().unwrap();
assert_eq!(
response[4], 5,
"a dismissed unlock must refuse the signature"
);
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"a dismissed unlock must refuse at once, not at the deadline"
);
// Logout takes the public projection with it.
input
.write_all(b"{\"v\":1,\"type\":\"vault_logged_out\"}\n")
.unwrap();
input.flush().unwrap();
assert_eq!(identity_count(&socket), 0);
input
.write_all(b"{\"v\":1,\"type\":\"shutdown\"}\n")
.unwrap();
input.flush().unwrap();
assert!(child.wait().unwrap().success());
}
/// A sign request against a locked-but-cached vault must not simply fail: the
/// design has it raise an unlock, hold the request across the load, and then
/// ask for approval. The unlock and the approval carry different request ids,
/// because they are different decisions.
#[test]
fn a_locked_sign_request_raises_unlock_then_approval() {
let mut agent = TestAgent::start();
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
let public_blob = key.public_key().to_bytes().unwrap();
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
assert_eq!(identity_count(&agent.socket), 1);
agent.send("{\"v\":1,\"type\":\"vault_locked\",\"epoch\":1}");
assert_eq!(agent.read()["type"], "locked");
// The client blocks on its request while the panel is asked to unlock.
let socket = agent.socket.clone();
let blob = public_blob.clone();
let client = std::thread::spawn(move || {
let mut stream = UnixStream::connect(&socket).unwrap();
stream.write_all(&sign_request(&blob)).unwrap();
read_agent_frame(&mut stream)
});
let unlock = agent.read();
assert_eq!(unlock["type"], "unlock_required");
assert_eq!(unlock["reason"], "sign");
let unlock_id = unlock["requestId"].as_u64().unwrap();
// Unlocking is a fresh load at a new epoch, and it releases the request.
agent.load_key(&key, 2, "fedcba9876543210fedcba9876543210");
// The unlock prompt is withdrawn before the approval prompt replaces it,
// so the panel is never left showing a question that has been answered.
let withdrawn = agent.read();
assert_eq!(withdrawn["type"], "request_cancelled");
assert_eq!(withdrawn["requestId"].as_u64().unwrap(), unlock_id);
assert_eq!(withdrawn["reason"], "released");
let approval = agent.read();
assert_eq!(approval["type"], "approval_required");
let approval_id = approval["requestId"].as_u64().unwrap();
assert_ne!(
unlock_id, approval_id,
"unlock and approval are separate decisions"
);
agent.send(&format!(
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{approval_id},\"grantSeconds\":0}}"
));
let response = client.join().unwrap();
assert_eq!(
response[4], 14,
"an approved request must return a signature"
);
agent.shutdown();
}
/// The approval decision needs the key's identity and the requesting program,
/// both of which come from the public cache. None of it depends on the vault
/// read finishing, so a user may approve while keys are still loading and the
/// signature is produced the moment they arrive -- rather than being made to
/// wait several seconds and only then be asked.
#[test]
fn an_approval_given_during_a_load_is_honoured_when_keys_arrive() {
let mut agent = TestAgent::start();
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
let public_blob = key.public_key().to_bytes().unwrap();
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
agent.send("{\"v\":1,\"type\":\"vault_locked\",\"epoch\":1}");
assert_eq!(agent.read()["type"], "locked");
let socket = agent.socket.clone();
let blob = public_blob.clone();
let client = std::thread::spawn(move || {
let mut stream = UnixStream::connect(&socket).unwrap();
stream.write_all(&sign_request(&blob)).unwrap();
read_agent_frame(&mut stream)
});
let unlock = agent.read();
assert_eq!(unlock["type"], "unlock_required");
let request_id = unlock["requestId"].as_u64().unwrap();
// Approved against the held request, before any load has been started.
agent.send(&format!(
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{request_id},\"grantSeconds\":0}}"
));
// The load lands afterwards and releases the request without asking again.
agent.load_key(&key, 2, "fedcba9876543210fedcba9876543210");
let response = client.join().unwrap();
assert_eq!(
response[4], 14,
"an approval given while keys were loading must produce a signature"
);
agent.shutdown();
}
/// The same path must still refuse when the key that comes back is not the
/// one that was approved. The approval names a key; the load decides whether
/// that key is actually present.
#[test]
fn an_approval_given_during_a_load_still_requires_the_approved_key() {
let mut agent = TestAgent::start();
let approved = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
let other = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
let public_blob = approved.public_key().to_bytes().unwrap();
agent.load_key(&approved, 1, "0123456789abcdef0123456789abcdef");
agent.send("{\"v\":1,\"type\":\"vault_locked\",\"epoch\":1}");
assert_eq!(agent.read()["type"], "locked");
let socket = agent.socket.clone();
let blob = public_blob.clone();
let client = std::thread::spawn(move || {
let mut stream = UnixStream::connect(&socket).unwrap();
stream.write_all(&sign_request(&blob)).unwrap();
read_agent_frame(&mut stream)
});
let unlock = agent.read();
let request_id = unlock["requestId"].as_u64().unwrap();
agent.send(&format!(
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{request_id},\"grantSeconds\":0}}"
));
// A vault that now holds a different key entirely.
agent.load_key(&other, 2, "fedcba9876543210fedcba9876543210");
let response = client.join().unwrap();
assert_eq!(
response[4], 5,
"the approved key is gone, so the signature must fail closed"
);
agent.shutdown();
}
/// A freshly started companion has no public cache, so `ssh-add -L` is empty
/// and no client will ever offer a vault key -- which means no sign request,
/// and no way to ask for an unlock. Unlock-on-demand exists for exactly that
/// cliff, and it has to begin at the identity listing rather than at signing.
#[test]
fn unlock_on_demand_raises_an_unlock_for_an_empty_identity_listing() {
let mut agent = TestAgent::start();
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
// Off by default: an empty cache answers empty and asks for nothing.
assert_eq!(identity_count(&agent.socket), 0);
agent.send("{\"v\":1,\"type\":\"options\",\"unlockOnDemand\":true}");
agent.drain_control();
let socket = agent.socket.clone();
let client = std::thread::spawn(move || {
let mut stream = UnixStream::connect(&socket).unwrap();
stream.write_all(&[0_u8, 0, 0, 1, 11]).unwrap();
read_agent_frame(&mut stream)
});
let unlock = agent.read();
assert_eq!(unlock["type"], "unlock_required");
assert_eq!(unlock["reason"], "list-identities");
// The load releases the waiting listing with the real identities.
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
let frame = client.join().unwrap();
assert_eq!(frame[4], 12, "expected an identities answer");
let mut body = &frame[5..];
assert_eq!(u32::decode(&mut body).unwrap(), 1);
agent.shutdown();
}
/// Several clients starting at once must not produce several unlock prompts.
#[test]
fn concurrent_identity_listings_coalesce_into_one_unlock() {
let mut agent = TestAgent::start();
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
agent.send("{\"v\":1,\"type\":\"options\",\"unlockOnDemand\":true}");
agent.drain_control();
let mut clients = Vec::new();
for _ in 0..3 {
let socket = agent.socket.clone();
clients.push(std::thread::spawn(move || {
let mut stream = UnixStream::connect(&socket).unwrap();
stream.write_all(&[0_u8, 0, 0, 1, 11]).unwrap();
read_agent_frame(&mut stream)
}));
std::thread::sleep(std::time::Duration::from_millis(120));
}
let unlock = agent.read();
assert_eq!(unlock["type"], "unlock_required");
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
for client in clients {
let frame = client.join().unwrap();
assert_eq!(frame[4], 12, "every waiting listing gets its answer");
}
// Exactly one unlock was asked for; the next line is the keys_loaded that
// load_key already consumed, so nothing else is queued behind it.
agent.shutdown();
}
/// A client that walks away leaves a prompt on screen with nothing behind it.
/// The companion says so rather than letting it sit until its deadline.
#[test]
fn a_disconnected_client_withdraws_its_prompt() {
let mut agent = TestAgent::start();
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
let public_blob = key.public_key().to_bytes().unwrap();
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
let mut stream = UnixStream::connect(&agent.socket).unwrap();
stream.write_all(&sign_request(&public_blob)).unwrap();
let approval = agent.read();
assert_eq!(approval["type"], "approval_required");
let request_id = approval["requestId"].as_u64().unwrap();
drop(stream);
let cancelled = agent.read();
assert_eq!(cancelled["type"], "request_cancelled");
assert_eq!(cancelled["requestId"], request_id);
agent.shutdown();
}
/// Grants are only useful if the panel can see and revoke them, so every
/// change to the set is announced with its remaining time.
#[test]
fn granting_and_revoking_announce_the_live_set() {
let mut agent = TestAgent::start();
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
let public_blob = key.public_key().to_bytes().unwrap();
agent.load_key(&key, 1, "0123456789abcdef0123456789abcdef");
let socket = agent.socket.clone();
let blob = public_blob.clone();
let client = std::thread::spawn(move || {
let mut stream = UnixStream::connect(&socket).unwrap();
stream.write_all(&sign_request(&blob)).unwrap();
let first = read_agent_frame(&mut stream);
// A second signature on the same connection rides the grant, with no
// further prompt -- which is the whole point of offering one.
stream.write_all(&sign_request(&blob)).unwrap();
(first, read_agent_frame(&mut stream))
});
let approval = agent.read();
let request_id = approval["requestId"].as_u64().unwrap();
assert_eq!(approval["grantOffered"], true);
agent.send(&format!(
"{{\"v\":1,\"type\":\"approve\",\"requestId\":{request_id},\"grantSeconds\":120}}"
));
let changed = agent.read();
assert_eq!(changed["type"], "grants_changed");
let grants = changed["grants"].as_array().unwrap();
assert_eq!(grants.len(), 1);
assert!(grants[0]["expiresInSec"].as_u64().unwrap() <= 120);
assert!(grants[0]["expiresInSec"].as_u64().unwrap() > 0);
let grant_id = grants[0]["grantId"].as_u64().unwrap();
assert!(
grants[0].get("privateKey").is_none(),
"a grant must carry no key material"
);
let (first, second) = client.join().unwrap();
assert_eq!(first[4], 14);
assert_eq!(second[4], 14, "a live grant signs without prompting again");
agent.send(&format!(
"{{\"v\":1,\"type\":\"revoke_grant\",\"grantId\":{grant_id}}}"
));
let revoked = agent.read();
assert_eq!(revoked["type"], "grants_changed");
assert_eq!(revoked["grants"].as_array().unwrap().len(), 0);
agent.shutdown();
}
/// The panel validates the bundled helper before it trusts it, and needs the
/// helper's own answers to do that: what version it is, what protocol it
/// speaks, and whether its crypto actually works on this machine. Both must
/// answer without touching the filesystem, opening a socket, or needing a
/// runtime directory -- they run before any of that exists.
#[test]
fn version_and_self_test_answer_without_touching_the_system() {
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
let temp = TempDir::new();
let version = Command::new(executable)
.arg("--version")
.env_clear()
.output()
.unwrap();
assert!(version.status.success(), "--version must succeed");
let text = String::from_utf8(version.stdout).unwrap();
assert!(
text.contains(env!("CARGO_PKG_VERSION")),
"--version must report the crate version, got {text:?}"
);
assert!(
text.contains("protocol 1"),
"--version must report the control protocol version, got {text:?}"
);
// No XDG_RUNTIME_DIR at all: neither mode may depend on one.
let selftest = Command::new(executable)
.arg("--self-test")
.env_clear()
.output()
.unwrap();
assert!(
selftest.status.success(),
"--self-test failed: {}",
String::from_utf8_lossy(&selftest.stderr)
);
let report = String::from_utf8(selftest.stdout).unwrap();
assert!(
report.contains("ok"),
"self-test should say so, got {report:?}"
);
// Nothing was created anywhere it could have been.
let runtime = std::path::Path::new(&temp.0).join("qs-bitwarden-cli");
assert!(
!runtime.exists(),
"a self-test must not create a runtime directory"
);
// Neither mode may leak key material to either stream.
let combined = format!("{report}{}", String::from_utf8_lossy(&selftest.stderr));
assert!(
!combined.contains("PRIVATE"),
"the self-test must not print key material"
);
}
/// An unknown flag must not be mistaken for "run as the agent". The panel
/// launches this binary with no arguments; anything else is a mistake worth
/// reporting rather than silently starting a key-holding daemon.
#[test]
fn an_unknown_argument_is_refused() {
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
let out = Command::new(executable)
.arg("--not-a-real-flag")
.env_clear()
.output()
.unwrap();
assert!(
!out.status.success(),
"an unknown flag must not start the agent"
);
}
/// A running agent with its control channel, for tests that drive several
/// messages in sequence.
struct TestAgent {
child: std::process::Child,
input: std::process::ChildStdin,
output: BufReader<std::process::ChildStdout>,
socket: PathBuf,
fifo: PathBuf,
alive: std::sync::Arc<std::sync::atomic::AtomicBool>,
_temp: TempDir,
}
impl TestAgent {
fn start() -> Self {
let temp = TempDir::new();
let executable = env!("CARGO_BIN_EXE_qs-bitwarden-ssh-agent");
let mut child = Command::new(executable)
.env_clear()
.env("XDG_RUNTIME_DIR", &temp.0)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut input = child.stdin.take().unwrap();
let mut output = BufReader::new(child.stdout.take().unwrap());
input.write_all(b"{\"v\":1,\"type\":\"hello\"}\n").unwrap();
input.flush().unwrap();
let ready = read_json_line(&mut output);
let socket = PathBuf::from(ready["socketPath"].as_str().unwrap());
let fifo = PathBuf::from(ready["fifoPath"].as_str().unwrap());
// Every read below blocks on the agent's stdout, so a message the
// agent never sends would hang the whole suite instead of failing it.
// The watchdog kills the child, which closes stdout and turns that
// hang into an EOF the assertions report.
let alive = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
let watching = alive.clone();
let pid = child.id();
std::thread::spawn(move || {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
while std::time::Instant::now() < deadline {
if !watching.load(std::sync::atomic::Ordering::Relaxed) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status();
});
Self {
child,
input,
output,
socket,
fifo,
alive,
_temp: temp,
}
}
fn send(&mut self, line: &str) {
writeln!(self.input, "{line}").unwrap();
self.input.flush().unwrap();
}
/// Wait until the control loop has processed everything sent so far.
///
/// Control messages are read in order on one channel, so a message whose
/// effect is observable acts as a barrier for every message before it.
/// `vault_locked` is that message: it answers with `locked`, and locking
/// an empty store changes nothing a test then depends on.
///
/// Needed because a test that sends `options` and then connects a client
/// is racing the control loop. That race is invisible on a fast machine
/// and cost a CI run: the client's listing arrived first, was answered
/// with an empty list instead of raising an unlock, and the test waited
/// for a message that was never going to come.
fn drain_control(&mut self) {
self.send("{\"v\":1,\"type\":\"vault_locked\",\"epoch\":0}");
let acknowledged = self.read();
assert_eq!(
acknowledged["type"], "locked",
"expected a lock acknowledgement"
);
}
fn read(&mut self) -> serde_json::Value {
let mut line = String::new();
self.output.read_line(&mut line).unwrap();
assert!(
!line.is_empty(),
"the agent closed its control channel without answering"
);
serde_json::from_str(&line).unwrap()
}
fn load_key(&mut self, key: &PrivateKey, epoch: u64, nonce: &str) {
self.send(&format!(
"{{\"v\":1,\"type\":\"key_load_begin\",\"epoch\":{epoch},\"loadId\":\"{nonce}\"}}"
));
let payload = serde_json::json!({"loadId": nonce, "items": [{
"itemId": "disposable", "name": "Disposable test key",
"privateKey": key.to_openssh(Default::default()).unwrap().as_str(),
"publicKey": key.public_key().to_openssh().unwrap(),
"fingerprint": key.public_key().fingerprint(HashAlg::Sha256).to_string(),
"requiresReprompt": false
}]});
let mut writer = fs::OpenOptions::new().write(true).open(&self.fifo).unwrap();
writer
.write_all(&serde_json::to_vec(&payload).unwrap())
.unwrap();
writer.write_all(b"\n").unwrap();
drop(writer);
self.send(&format!(
"{{\"v\":1,\"type\":\"key_load_end\",\"epoch\":{epoch},\"status\":\"ok\"}}"
));
// The validated public set arrives one message per key ahead of
// keys_loaded, so the panel holds the whole projection before it is
// told the load finished. Skip past them to the completion.
loop {
let message = self.read();
if message["type"] == "keys_loaded" {
break;
}
assert_eq!(
message["type"], "public_key",
"only public keys may precede keys_loaded"
);
assert!(
!message["publicKey"]
.as_str()
.unwrap_or_default()
.contains("PRIVATE"),
"a public_key message must never carry private material"
);
}
}
fn shutdown(&mut self) {
self.send("{\"v\":1,\"type\":\"shutdown\"}");
let status = self.child.wait().unwrap();
self.alive
.store(false, std::sync::atomic::Ordering::Relaxed);
assert!(status.success());
}
}
impl Drop for TestAgent {
fn drop(&mut self) {
self.alive
.store(false, std::sync::atomic::Ordering::Relaxed);
let _ = self.child.kill();
}
}
/// A framed SSH_AGENTC_SIGN_REQUEST for one public blob.
fn sign_request(public_blob: &[u8]) -> Vec<u8> {
let mut request = Vec::new();
13_u8.encode(&mut request).unwrap();
public_blob.encode(&mut request).unwrap();
b"payload".as_slice().encode(&mut request).unwrap();
0_u32.encode(&mut request).unwrap();
let mut framed = u32::try_from(request.len()).unwrap().to_be_bytes().to_vec();
framed.extend_from_slice(&request);
framed
}
/// Number of identities the agent offers over its real socket.
fn identity_count(socket: &PathBuf) -> usize {
let mut stream = UnixStream::connect(socket).unwrap();
let request = [0_u8, 0, 0, 1, 11];
stream.write_all(&request).unwrap();
let frame = read_agent_frame(&mut stream);
assert_eq!(frame[4], 12, "expected an identities answer, not a failure");
let mut body = &frame[5..];
usize::try_from(u32::decode(&mut body).unwrap()).unwrap()
}
fn read_json_line(reader: &mut BufReader<std::process::ChildStdout>) -> serde_json::Value {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
serde_json::from_str(&line).unwrap()
}
fn read_agent_frame(stream: &mut UnixStream) -> Vec<u8> {
let mut header = [0_u8; 4];
stream.read_exact(&mut header).unwrap();
let length = usize::try_from(u32::from_be_bytes(header)).unwrap();
let mut frame = header.to_vec();
frame.resize(length + 4, 0);
stream.read_exact(&mut frame[4..]).unwrap();
frame
}
@@ -0,0 +1,283 @@
use qs_bitwarden_ssh_agent::keystore::{
KeyStore, LoadError, MAX_FILTERED_BYTES, MAX_METADATA_BYTES,
};
use qs_bitwarden_ssh_agent::load::{LoadWindow, PayloadError};
use qs_bitwarden_ssh_agent::runtime::{read_payload_async, Runtime, RuntimeError};
use rand_core::OsRng;
use ssh_key::{Algorithm, HashAlg, PrivateKey};
use std::fs;
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
use std::path::PathBuf;
use std::time::Duration;
use zeroize::Zeroizing;
const NONCE: &str = "0123456789abcdef0123456789abcdef";
struct TempDir(PathBuf);
impl TempDir {
fn new(label: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"qsbw-{label}-{}-{}",
std::process::id(),
rand_core::RngCore::next_u64(&mut OsRng)
));
fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn item_json(id: &str, key: &PrivateKey) -> serde_json::Value {
serde_json::json!({
"itemId": id,
"name": format!("key {id}"),
"privateKey": key.to_openssh(Default::default()).unwrap().as_str(),
"publicKey": key.public_key().to_openssh().unwrap(),
"fingerprint": key.public_key().fingerprint(HashAlg::Sha256).to_string(),
"requiresReprompt": false
})
}
fn payload(nonce: &str, items: Vec<serde_json::Value>) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({"loadId": nonce, "items": items})).unwrap()
}
#[test]
fn creates_private_runtime_and_fifo_and_holds_both_fifo_ends() {
let temp = TempDir::new("runtime");
let runtime = Runtime::create(&temp.0).unwrap();
let dir = fs::metadata(runtime.directory()).unwrap();
let fifo = fs::symlink_metadata(runtime.fifo_path()).unwrap();
assert_eq!(dir.mode() & 0o777, 0o700);
assert_eq!(fifo.mode() & 0o777, 0o600);
assert!(fifo.file_type().is_fifo());
assert_eq!(dir.uid(), rustix::process::geteuid().as_raw());
assert_eq!(fifo.uid(), rustix::process::geteuid().as_raw());
assert!(runtime.fifo().metadata().unwrap().file_type().is_fifo());
}
#[test]
fn refuses_stale_wrong_type_symlink_and_insecure_directory() {
let stale = TempDir::new("stale");
let runtime_dir = stale.0.join("qs-bitwarden-cli");
fs::create_dir(&runtime_dir).unwrap();
fs::set_permissions(&runtime_dir, fs::Permissions::from_mode(0o700)).unwrap();
fs::write(runtime_dir.join("ssh-keys.fifo"), b"stale").unwrap();
assert_eq!(
Runtime::create(&stale.0).unwrap_err(),
RuntimeError::UnsafeFifo
);
let insecure = TempDir::new("insecure");
let dir = insecure.0.join("qs-bitwarden-cli");
fs::create_dir(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(
Runtime::create(&insecure.0).unwrap_err(),
RuntimeError::UnsafeDirectory
);
let linked = TempDir::new("linked");
let target = linked.0.join("target");
fs::create_dir(&target).unwrap();
std::os::unix::fs::symlink(&target, linked.0.join("qs-bitwarden-cli")).unwrap();
assert_eq!(
Runtime::create(&linked.0).unwrap_err(),
RuntimeError::UnsafeDirectory
);
}
#[test]
fn valid_nonce_payload_publishes_disposable_keys_once() {
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
let bytes = payload(NONCE, vec![item_json("one", &key)]);
let mut window = LoadWindow::new(7, NONCE).unwrap();
let mut store = KeyStore::new();
let candidate = window.decode(Zeroizing::new(bytes), &mut store).unwrap();
assert_eq!(store.publish(candidate).unwrap().loaded, 1);
assert_eq!(store.public_identities().len(), 1);
assert_eq!(
window
.decode(Zeroizing::new(payload(NONCE, vec![])), &mut store)
.unwrap_err(),
PayloadError::Closed
);
}
#[test]
fn nonce_schema_truncation_and_size_fail_the_whole_load() {
let mut store = KeyStore::new();
for (index, (nonce, bytes, expected)) in [
(
NONCE,
payload("ffffffffffffffffffffffffffffffff", vec![]),
PayloadError::NonceMismatch,
),
(
NONCE,
br#"{"loadId":"0123456789abcdef0123456789abcdef","items":["#.to_vec(),
PayloadError::Malformed,
),
(
NONCE,
br#"{"loadId":"0123456789abcdef0123456789abcdef","items":[],"extra":1}"#.to_vec(),
PayloadError::Malformed,
),
]
.into_iter()
.enumerate()
{
let mut window = LoadWindow::new(10 + index as u64, nonce).unwrap();
assert_eq!(
window
.decode(Zeroizing::new(bytes), &mut store)
.unwrap_err(),
expected
);
}
let mut window = LoadWindow::new(20, NONCE).unwrap();
assert_eq!(
window
.decode(
Zeroizing::new(vec![b'x'; MAX_FILTERED_BYTES + 1]),
&mut store
)
.unwrap_err(),
PayloadError::Load(LoadError::FilteredPayloadTooLarge)
);
}
#[test]
fn fifo_drain_is_newline_framed_and_deadline_limited() {
use std::io::Write;
let temp = TempDir::new("drain");
let mut runtime = Runtime::create(&temp.0).unwrap();
let mut writer = fs::OpenOptions::new()
.write(true)
.open(runtime.fifo_path())
.unwrap();
writer.write_all(b"{\"loadId\":\"ok\"}\n").unwrap();
assert_eq!(
runtime
.read_payload(Duration::from_secs(1))
.unwrap()
.as_slice(),
b"{\"loadId\":\"ok\"}"
);
writer.write_all(b"{}\n{}\n").unwrap();
assert_eq!(
runtime.read_payload(Duration::from_secs(1)).unwrap_err(),
RuntimeError::MultiplePayloads
);
assert_eq!(
runtime.read_payload(Duration::from_millis(10)).unwrap_err(),
RuntimeError::ReadTimeout
);
}
#[test]
fn fifo_drain_rejects_a_stream_beyond_the_full_eight_mibibyte_cap() {
use std::io::Write;
let temp = TempDir::new("full-cap");
let mut runtime = Runtime::create(&temp.0).unwrap();
let fifo_path = runtime.fifo_path().to_owned();
let writer = std::thread::spawn(move || {
let mut fifo = fs::OpenOptions::new().write(true).open(fifo_path).unwrap();
let oversized = vec![b'x'; MAX_FILTERED_BYTES + 2];
let _ = fifo.write_all(&oversized);
});
assert_eq!(
runtime.read_payload(Duration::from_secs(30)).unwrap_err(),
RuntimeError::PayloadTooLarge
);
writer.join().unwrap();
}
#[test]
fn invalid_nonce_is_never_armed() {
assert_eq!(
LoadWindow::new(1, "short").unwrap_err(),
PayloadError::InvalidNonce
);
assert_eq!(
LoadWindow::new(1, "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").unwrap_err(),
PayloadError::InvalidNonce
);
}
#[test]
fn an_item_id_past_the_metadata_cap_fails_the_candidate() {
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
let mut item = item_json("one", &key);
// Real ones are 36-character UUIDs, and this is what the key is known by,
// so it cannot be shortened to fit the way a display name can.
item["itemId"] = serde_json::Value::String("i".repeat(65 * 1024));
let mut window = LoadWindow::new(30, NONCE).unwrap();
let mut store = KeyStore::new();
assert_eq!(
window
.decode(Zeroizing::new(payload(NONCE, vec![item])), &mut store)
.unwrap_err(),
PayloadError::Load(LoadError::MetadataTooLarge)
);
}
#[test]
fn a_long_item_name_is_truncated_rather_than_losing_the_whole_load() {
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
let mut item = item_json("one", &key);
// Multibyte on purpose. 200 of these is 400 bytes, so the cut lands in the
// middle of a character unless the boundary is respected -- and a name is
// a String, which cannot hold half of one.
let name = "é".repeat(200);
item["name"] = serde_json::Value::String(name.clone());
let mut window = LoadWindow::new(30, NONCE).unwrap();
let mut store = KeyStore::new();
let candidate = window
.decode(Zeroizing::new(payload(NONCE, vec![item])), &mut store)
.expect("a descriptively named key is an ordinary key");
store.publish(candidate).unwrap();
let identities = store.public_identities();
assert_eq!(identities.len(), 1, "the key still loaded");
let stored = &identities[0].name;
assert!(stored.len() <= MAX_METADATA_BYTES);
assert!(name.starts_with(stored.as_str()));
assert!(!stored.is_empty());
}
#[tokio::test(flavor = "current_thread")]
async fn a_producer_that_closes_without_a_newline_times_out() {
use std::io::Write;
let temp = TempDir::new("eof");
let runtime = Runtime::create(&temp.0).unwrap();
let mut writer = fs::OpenOptions::new()
.write(true)
.open(runtime.fifo_path())
.unwrap();
writer.write_all(b"{\"loadId\":\"unfinished\"").unwrap();
drop(writer);
// The reader keeps its own write end open, so this is a producer that gave
// up rather than a true end-of-stream -- but the read still has to end at
// its deadline rather than spinning on a descriptor that stays readable.
assert_eq!(
read_payload_async(runtime.fifo_reader().unwrap(), Duration::from_millis(150))
.await
.unwrap_err(),
RuntimeError::ReadTimeout
);
}
@@ -0,0 +1,177 @@
use qs_bitwarden_ssh_agent::protocol::{handle_frame, Identity, MAX_FRAME_LEN};
use rand_core::OsRng;
use signature::Verifier;
use ssh_encoding::{Decode, Encode};
use ssh_key::private::RsaKeypair;
use ssh_key::{Algorithm, HashAlg, PrivateKey, Signature};
const FAILURE: u8 = 5;
const REQUEST_IDENTITIES: u8 = 11;
const IDENTITIES_ANSWER: u8 = 12;
const SIGN_REQUEST: u8 = 13;
const SIGN_RESPONSE: u8 = 14;
const RSA_SHA2_256: u32 = 2;
const RSA_SHA2_512: u32 = 4;
fn frame(payload: &[u8]) -> Vec<u8> {
let mut encoded = Vec::with_capacity(payload.len() + 4);
u32::try_from(payload.len())
.unwrap()
.encode(&mut encoded)
.unwrap();
encoded.extend_from_slice(payload);
encoded
}
fn string(value: &[u8], out: &mut Vec<u8>) {
value.encode(out).unwrap();
}
fn response_payload(response: &[u8]) -> &[u8] {
let declared = u32::from_be_bytes(response[..4].try_into().unwrap()) as usize;
assert_eq!(declared, response.len() - 4);
&response[4..]
}
fn sign_request(key_blob: &[u8], message: &[u8], flags: u32) -> Vec<u8> {
let mut payload = vec![SIGN_REQUEST];
string(key_blob, &mut payload);
string(message, &mut payload);
flags.encode(&mut payload).unwrap();
frame(&payload)
}
fn signature(response: &[u8]) -> Signature {
let payload = response_payload(response);
assert_eq!(payload[0], SIGN_RESPONSE);
let mut encoded = &payload[1..];
let signature_bytes = Vec::<u8>::decode(&mut encoded).unwrap();
assert!(encoded.is_empty());
Signature::try_from(signature_bytes.as_slice()).unwrap()
}
#[test]
fn lists_openssh_encoded_identities() {
let ed25519 = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
let rsa = PrivateKey::from(RsaKeypair::random(&mut OsRng, 2048).unwrap());
let identities = [
Identity::new(ed25519, "vault ed25519").unwrap(),
Identity::new(rsa, "vault rsa").unwrap(),
];
let response = handle_frame(&frame(&[REQUEST_IDENTITIES]), &identities);
let payload = response_payload(&response);
assert_eq!(payload[0], IDENTITIES_ANSWER);
let mut fields = &payload[1..];
assert_eq!(u32::decode(&mut fields).unwrap(), 2);
for identity in identities.iter() {
assert_eq!(
Vec::<u8>::decode(&mut fields).unwrap(),
identity.public_blob()
);
assert_eq!(String::decode(&mut fields).unwrap(), identity.comment());
}
assert!(fields.is_empty());
}
#[test]
fn signs_ed25519_requests_and_rejects_nonzero_flags() {
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
let identity = Identity::new(key, "ed25519").unwrap();
let message = b"bounded agent protocol vector";
let signed = signature(&handle_frame(
&sign_request(identity.public_blob(), message, 0),
std::slice::from_ref(&identity),
));
assert_eq!(signed.algorithm(), Algorithm::Ed25519);
Verifier::verify(identity.public_key(), message, &signed).unwrap();
let rejected = handle_frame(
&sign_request(identity.public_blob(), message, RSA_SHA2_256),
&[identity],
);
assert_eq!(response_payload(&rejected), &[FAILURE]);
}
#[test]
fn signs_rsa_with_exactly_the_requested_sha2_algorithm() {
let key = PrivateKey::from(RsaKeypair::random(&mut OsRng, 2048).unwrap());
let identity = Identity::new(key, "rsa").unwrap();
let message = b"rsa protocol vector";
for (flags, hash) in [
(RSA_SHA2_256, HashAlg::Sha256),
(RSA_SHA2_512, HashAlg::Sha512),
] {
let signed = signature(&handle_frame(
&sign_request(identity.public_blob(), message, flags),
std::slice::from_ref(&identity),
));
assert_eq!(signed.algorithm(), Algorithm::Rsa { hash: Some(hash) });
Verifier::verify(identity.public_key(), message, &signed).unwrap();
}
for flags in [0, RSA_SHA2_256 | RSA_SHA2_512, 8] {
let rejected = handle_frame(
&sign_request(identity.public_blob(), message, flags),
std::slice::from_ref(&identity),
);
assert_eq!(response_payload(&rejected), &[FAILURE]);
}
}
#[test]
fn malformed_and_disallowed_requests_receive_only_bounded_failure() {
let cases = [
Vec::new(),
vec![0, 0, 0, 2, REQUEST_IDENTITIES],
frame(&[REQUEST_IDENTITIES, 0]),
frame(&[17]),
frame(&[18]),
frame(&[19]),
frame(&[20]),
frame(&[21]),
frame(&[22]),
frame(&[23]),
frame(&[25]),
frame(&[26]),
frame(&[27, 0, 0, 0, 1, 0xff]),
frame(&[255]),
];
for request in cases {
assert_eq!(response_payload(&handle_frame(&request, &[])), &[FAILURE]);
}
}
#[test]
fn lengths_are_rejected_before_body_allocation_or_parsing() {
let oversized_header = u32::try_from(MAX_FRAME_LEN + 1).unwrap().to_be_bytes();
assert_eq!(
response_payload(&handle_frame(&oversized_header, &[])),
&[FAILURE]
);
let mut invalid_string = vec![SIGN_REQUEST];
invalid_string.extend_from_slice(&u32::MAX.to_be_bytes());
assert_eq!(
response_payload(&handle_frame(&frame(&invalid_string), &[])),
&[FAILURE]
);
let mut unknown_key = vec![SIGN_REQUEST];
string(b"not an advertised public key", &mut unknown_key);
string(b"message", &mut unknown_key);
0_u32.encode(&mut unknown_key).unwrap();
assert_eq!(
response_payload(&handle_frame(&frame(&unknown_key), &[])),
&[FAILURE]
);
unknown_key.push(0);
assert_eq!(
response_payload(&handle_frame(&frame(&unknown_key), &[])),
&[FAILURE]
);
}
@@ -0,0 +1 @@
3b36e17fcbca8b5925b417b36c75157fc96502391720d5fcf0edac65a6abfbe3 x86_64-linux/qs-bitwarden-ssh-agent
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# A stand-in for the Bitwarden CLI, used only to capture screenshots.
#
# The plugin resolves `bw` from PATH, so putting this earlier on PATH points it
# at a fixture vault instead of a real one. Nothing here talks to Bitwarden,
# reads a keyring, or touches the network -- which is the point: the README
# screenshots can show a populated vault without showing anyone's credentials.
set -uo pipefail
FIXTURES="$(dirname "$(readlink -f "$0")")/../fixtures.json"
j() { python3 -c "
import json,sys
d=json.load(open('$FIXTURES'))
sys.stdout.write(json.dumps(d[sys.argv[1]]))" "$1"; }
# The vault state the fixture reports. `unlocked` for the populated shots;
# `unauthenticated` drives the login screen, `locked` the unlock screen.
DEMO_STATUS="${QSBW_DEMO_STATUS:-unlocked}"
case "${1:-}" in
--version) echo "2026.2.0-demo" ;;
status) python3 -c "
import json,sys
d=json.load(open('$FIXTURES'))
st=dict(d['status']); st['status']=sys.argv[1]
if sys.argv[1]=='unauthenticated':
st['userEmail']=''; st['userId']=''
sys.stdout.write(json.dumps(st))" "$DEMO_STATUS" ;;
# The new generator reaches for `bw serve` first. Exiting immediately is the
# bind-failure path, which is exactly the fallback we want exercised here.
serve) exit 1 ;;
sync) echo "Syncing complete." ;;
lock) echo "Your vault is locked." ;;
unlock) echo "demo-session-token-not-real" ;;
generate)
# Roughly honour --passphrase so the generator screenshot looks right.
if [[ " $* " == *" --passphrase "* ]]; then echo "Correct-Horse-Battery-Staple"
else echo "Xq7X2mFk9TbW4e"; fi ;;
list)
case "${2:-}" in
items) j items ;;
folders) j folders ;;
organizations) j organizations ;;
*) echo "[]" ;;
esac ;;
get)
case "${2:-}" in
totp) echo "418 623" | tr -d ' ' ;;
password) echo "placeholder" ;;
# Attachment bytes never come from the fixture file -- the panel only
# needs a file to appear where it asked for one.
attachment)
out=""
while [ $# -gt 0 ]; do
if [ "$1" = "--output" ]; then out="${2:-}"; fi
shift
done
[ -n "$out" ] || exit 1
printf 'placeholder attachment, not real vault data\n' > "$out"
echo "Saved $out" ;;
item) python3 -c "
import json,sys
d=json.load(open('$FIXTURES'))
want=sys.argv[1]
for it in d['items']:
if it['id']==want: print(json.dumps(it)); break
else: print(json.dumps(d['items'][0]))" "${3:-i1}" ;;
*) echo "{}" ;;
esac ;;
send)
case "${2:-}" in
list) j sends ;;
create) echo '{"object":"send","id":"s3","name":"New Send","type":0,"accessUrl":"https://vault.bitwarden.com/#/send/demo3","accessCount":0,"maxAccessCount":null,"deletionDate":"2026-08-28T10:00:00.000Z","passwordSet":false,"disabled":false,"text":{"text":"placeholder","hidden":false}}' ;;
delete) echo "Send deleted." ;;
*) echo "[]" ;;
esac ;;
encode) cat ;;
create|edit|delete) echo '{"object":"item","id":"new"}' ;;
*) echo "{}" ;;
esac
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# A stand-in for libsecret's secret-tool, used only to capture screenshots.
#
# Without this the fixture shell talks to the real OS keyring: it would read
# the operator's actual Bitwarden session into the demo panel, and the shots
# would depend on whether that entry happened to exist. Neither belongs in a
# screenshot harness, so the keyring is faked too.
#
# A session lookup succeeds with an obvious placeholder -- the panel needs a
# non-empty session before it will load any items -- and every other lookup
# reports "not stored", so PIN and fingerprint unlock show as unconfigured.
#
# The placeholder carries the running boot id, because that is the shape the
# panel now demands of a remembered session: a token from another boot is
# refused and cleared. Without the prefix the fixture shell would come up on
# the lock screen and there would be nothing to photograph.
set -uo pipefail
account=""
prev=""
for arg in "$@"; do
[[ "$prev" == "account" ]] && account="$arg"
prev="$arg"
done
case "${1:-}" in
lookup)
if [[ "$account" == "session" ]]; then
echo "$(cat /proc/sys/kernel/random/boot_id) demo-session-token-not-real"
exit 0
fi
exit 1 ;;
store) cat >/dev/null; exit 0 ;;
clear) exit 0 ;;
*) exit 1 ;;
esac
@@ -0,0 +1,213 @@
#!/usr/bin/env bash
# Capture README screenshots against a fixture vault.
#
# Restarts the Omarchy shell with demo/bin ahead of it on PATH, so the plugin
# resolves `bw` to the shim and shows made-up data. Your real vault is never
# read, and the shell is restored on the way out -- including if this script
# is interrupted.
#
# ./demo/capture.sh [output-dir] (default: docs/screenshots)
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT="${1:-$REPO/docs/screenshots}"
IPC=(qs -p /usr/share/omarchy/shell/shell.qml ipc call io.github.elevate08.qs-bitwarden-cli)
for tool in grim magick wtype hyprctl quickshell /usr/bin/python3; do
command -v "$tool" >/dev/null || { echo "missing required tool: $tool" >&2; exit 1; }
done
mkdir -p "$OUT"
restore() {
echo "restoring the real shell..."
# The fixture vault's SSH key gets projected to the same directory the real
# one uses. The real shell rewrites its own keys on the next load but will
# not remove a file it never wrote, so a demo key would sit there for good.
rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/qs-bitwarden-cli/ssh/Demo Deploy Key.pub"
pkill -f "quickshell -n -p /usr/share/omarchy/shell" 2>/dev/null || true
sleep 1
omarchy restart shell >/dev/null 2>&1 || true
}
trap restore EXIT INT TERM
# start_shell <vault-state>
#
# The fixture shell is restarted per vault state rather than driven between
# them: the panel reads `bw status` once on open, so the logged-out screen
# cannot be reached from a running unlocked instance.
start_shell() {
echo "starting shell with the fixture vault ($1)..."
pkill -f "quickshell -n -p /usr/share/omarchy/shell" 2>/dev/null || true
sleep 1
PATH="$REPO/demo/bin:$PATH" QSBW_DEMO_STATUS="$1" \
nohup quickshell -n -p /usr/share/omarchy/shell >/dev/null 2>&1 &
sleep 6
# Park the pointer so hover states and tooltips stay out of the shots.
hyprctl dispatch movecursor 100 100 >/dev/null 2>&1 || true
}
# Crop to the panel itself rather than a fixed box. The panel resizes with its
# content, and -- more importantly -- a loose crop would put whatever is behind
# it (windows, filenames, terminal scrollback) into the published image.
shot() { # shot <name>
sleep 1
grim "$OUT/.raw.png"
local box err
# Keep the locator's own reason. Swallowing it turned a theme change into
# six identical "could not locate the panel border" lines and no clue why.
if box="$(/usr/bin/python3 "$REPO/demo/find_panel.py" "$OUT/.raw.png" 2>/tmp/find_panel.err)"; then
magick "$OUT/.raw.png" -crop "$box" +repage "$OUT/$1.png"
echo " wrote $1.png ($box)"
else
err="$(cat /tmp/find_panel.err)"
echo " SKIPPED $1: ${err:-could not locate the panel border}" >&2
fi
rm -f /tmp/find_panel.err
if [ -n "${QSBW_KEEP_RAW:-}" ]; then mv -f "$OUT/.raw.png" "$OUT/.raw-$1.png" 2>/dev/null || true
else rm -f "$OUT/.raw.png"; fi
}
# open_detail <search text>
#
# Narrows the list to one item and opens it. Typing the name is steadier than
# counting Down presses: the list is sorted favourites-first and then by name,
# so an added fixture would silently shift every offset.
#
# The Down is what hands focus back from the search field to the key catcher --
# with one result it clamps to the only row -- and `e` on the list opens the
# detail. (`e` again, on the detail, opens the form.)
open_detail() {
wtype "/" 2>/dev/null; sleep 1
wtype "$1" 2>/dev/null; sleep 2
wtype -k Down 2>/dev/null; sleep 1
wtype "e" 2>/dev/null; sleep 2
}
# Back to an empty list from wherever open_detail left us.
clear_search() {
wtype -k Escape 2>/dev/null; sleep 1
wtype "/" 2>/dev/null; sleep 1
wtype -M ctrl -k a -m ctrl 2>/dev/null
wtype -k BackSpace 2>/dev/null; sleep 1
wtype -k Down 2>/dev/null; sleep 1
}
# --- SSH signing approval ---------------------------------------------------
#
# The approval screen exists only while a real signing request is waiting, so
# it cannot be navigated to -- it has to be raised. `ssh-add -T` asks the agent
# to sign one challenge with one key and nothing else, which is the smallest
# request that produces this prompt.
#
# Everything here is the fixture vault: the key is the throwaway pair in
# fixtures.json, and the socket belongs to the fixture shell started above.
# The request is denied rather than approved, so no signature is ever made.
capture_ssh_approval() {
local sock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/qs-bitwarden-cli/ssh-agent.sock"
local pub="${XDG_DATA_HOME:-$HOME/.local/share}/qs-bitwarden-cli/ssh/Demo Deploy Key.pub"
# The helper starts with the panel and projects its public keys after the
# vault loads, so wait for the file rather than guessing at a delay.
local waited=0
while [ ! -S "$sock" ] || [ ! -f "$pub" ]; do
sleep 1; waited=$((waited + 1))
if [ "$waited" -ge 30 ]; then
echo " skipped 13-ssh-approval: no agent socket or projected key after ${waited}s" >&2
echo " (is 'Act as your SSH agent' enabled in shell.json?)" >&2
return 0
fi
done
"${IPC[@]}" open >/dev/null 2>&1; sleep 2
# In the background: it blocks until the prompt is answered, which is the
# point -- the prompt has to still be on screen when the shot is taken.
SSH_AUTH_SOCK="$sock" ssh-add -T "$pub" >/dev/null 2>&1 &
local asker=$!
sleep "${QSBW_SSH_SETTLE:-4}"
shot 13-ssh-approval
# Deny it. Escape is the approval screen's own deny, so nothing is signed.
wtype -k Escape 2>/dev/null; sleep 1
wait "$asker" 2>/dev/null || true
"${IPC[@]}" close >/dev/null 2>&1 || true
}
# QSBW_ONLY_SSH=1 captures the approval shot alone. It is the only shot that
# depends on timing rather than on a keystroke, so it is the one that gets
# iterated on, and re-running the whole sequence to retake it costs a minute
# and five shells.
if [ -n "${QSBW_ONLY_SSH:-}" ]; then
start_shell unlocked
capture_ssh_approval
echo "done -> $OUT"
exit 0
fi
# --- logged out -------------------------------------------------------------
# No setup shot. The wizard appears only while a required tool is missing, and
# it watches for the install and moves on by itself the moment one lands. The
# fixture environment has every dependency -- that is what makes the rest of
# these shots work -- so the screen correctly advances straight past itself.
# Photographing it means deliberately hiding `jq` or `bw` from the shell's
# PATH, which breaks the vault reads every other shot depends on.
start_shell unauthenticated
"${IPC[@]}" open >/dev/null 2>&1; sleep 4
shot 12-login
"${IPC[@]}" close >/dev/null 2>&1 || true
# --- locked -----------------------------------------------------------------
start_shell locked
"${IPC[@]}" open >/dev/null 2>&1; sleep 4
shot 11-locked
"${IPC[@]}" close >/dev/null 2>&1 || true
# --- unlocked, populated vault ----------------------------------------------
start_shell unlocked
"${IPC[@]}" open >/dev/null 2>&1; sleep 4
shot 01-vault-list
# One of each item type the panel draws differently. A login has credentials
# and a TOTP; a card and an identity have the field blocks added in 1.7.0, and
# an identity is the one that shows the address as a single copyable block.
open_detail "GitHub"
shot 02-login-detail
wtype "e" 2>/dev/null; sleep 2
shot 03-edit-item
wtype -k Escape 2>/dev/null; sleep 1
clear_search
open_detail "Demo Card"
shot 04-card-detail
clear_search
open_detail "Dana Demo"
shot 05-identity-detail
clear_search
wtype "f" 2>/dev/null; sleep 2
shot 06-folder-drawer
wtype -k Escape 2>/dev/null; sleep 1
wtype "t" 2>/dev/null; sleep 2
shot 07-type-filter
wtype -k Escape 2>/dev/null; sleep 1
wtype "g" 2>/dev/null; sleep 5
shot 08-generator
wtype -k Escape 2>/dev/null; sleep 1
wtype -M alt -k s -m alt 2>/dev/null; sleep 3
shot 09-sends
wtype -k Escape 2>/dev/null; sleep 1
wtype -M alt -k comma -m alt 2>/dev/null; sleep 3
shot 10-settings
"${IPC[@]}" close >/dev/null 2>&1 || true
capture_ssh_approval
echo "done -> $OUT"
@@ -0,0 +1,198 @@
#!/usr/bin/env bash
# Compose preview.png from the captured screenshots.
#
# The preview used to be assembled by hand, which meant adding a panel to it
# was an image-editing job and nobody could tell which screenshots a given
# preview.png was built from. This script is the recipe: run demo/capture.sh
# first, then this.
#
# ./demo/compose-preview.sh [screenshot-dir] [output]
# ./demo/compose-preview.sh --badge-only (reuse the cached base)
#
# The base -- panels and title, everything that comes from screenshots -- is
# cached beside the shots. Only the callout changes when a release wants a
# different phrase, and rebuilding six panels to redraw one banner made trying
# wordings slower than it needed to be.
#
# Everything it reads is fixture data by construction -- capture.sh only ever
# runs against demo/bin/bw and demo/fixtures.json -- so nothing here can put a
# real vault into a published image.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BADGE_ONLY=0
args=()
for a in "$@"; do
case "$a" in
--badge-only) BADGE_ONLY=1 ;;
*) args+=("$a") ;;
esac
done
SHOTS="${args[0]:-$REPO/docs/screenshots}"
OUT="${args[1]:-$REPO/preview.png}"
BASE="$SHOTS/.preview-base.png"
command -v magick >/dev/null || { echo "missing required tool: magick" >&2; exit 1; }
# Sampled from the preview this replaces, so the rebuild is the same design
# rather than an approximation of it.
BG='#060400'
ACCENT='#F2A502'
TITLE='Bitwarden Vault Plugin'
# ImageMagick's own name for the face, which is not the fontconfig family
# name. `magick -list font` is the list it will accept; override if your
# machine names it differently.
FONT="${QSBW_PREVIEW_FONT:-CaskaydiaMono-NF-Bold}"
FONT_BODY="${QSBW_PREVIEW_FONT_BODY:-CaskaydiaMono-NF-Regular}"
# The callout that fills the empty corner under the first column. It is the
# one element here that is not a screenshot, so it borrows the panels' own
# vocabulary -- same accent, same square border, same black ground -- and
# earns its place by naming what the release added.
# Set either from the environment to try a phrase without touching the file:
# QSBW_BADGE_TITLE='SSH Agent Support' ./demo/compose-preview.sh --badge-only
BADGE_KICKER="${QSBW_BADGE_KICKER:-NEW}"
BADGE_TITLE="${QSBW_BADGE_TITLE:-Cards & Identities}"
# Filled with the accent and lettered in the page's own black, rather than
# outlined like the panels. A seventh accent-bordered rectangle read as one
# more screenshot; this cannot be mistaken for one.
BADGE_FG="$BG"
BADGE_BG="$ACCENT"
# Width kept clear at the right of the title band for the callout. The page
# title is centred in what is left rather than in the whole width, so the gap
# between the two does not depend on how long the badge phrase happens to be
# -- and a badge that outgrows this is told to shrink rather than silently
# shunting into the title.
BADGE_ZONE=640
GAP=28 # between panels, and around the whole thing
TITLE_SIZE=96
# Three columns, top to bottom. The vault list carries the folder drawer
# because that shot shows both at once; the plain list would be redundant
# beside it.
# A selection, not the whole set. capture.sh takes a shot of every screen so
# the documentation has one; this picks the handful that say what the plugin
# is at a glance, and leaves out the ones that look like every other panel's
# equivalent (login, setup, locked, the filter drawers).
#
# Grouped to keep the three columns near the same height -- the page is as tall
# as its tallest column, and an unbalanced one leaves a corner of empty black.
COL1=(01-vault-list 08-generator)
COL2=(04-card-detail 09-sends)
COL3=(10-settings 13-ssh-approval)
# Not `magick ... | grep -q`: grep exits on the first match, magick takes a
# SIGPIPE for it, and `set -o pipefail` reports the successful match as a
# failed pipeline.
grep -qx " Font: $FONT" <<<"$(magick -list font)" || {
echo "magick does not know the font '$FONT'." >&2
echo "Pick one from \`magick -list font\` and set QSBW_PREVIEW_FONT." >&2
exit 1
}
column() { # column <name>...
local files=() f
for f in "$@"; do
if [ -f "$SHOTS/$f.png" ]; then files+=("$SHOTS/$f.png")
else echo " missing $f.png, leaving it out" >&2; fi
done
[ ${#files[@]} -gt 0 ] || { echo "no screenshots for a column" >&2; exit 1; }
# -background so the gap between stacked panels is the page colour, not white.
magick "${files[@]}" -background "$BG" -gravity north -splice "0x${GAP}" \
-append -chop "0x${GAP}" miff:-
}
work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT
if [ "$BADGE_ONLY" = 1 ]; then
[ -f "$BASE" ] || { echo "no cached base at $BASE; run without --badge-only first" >&2; exit 1; }
cp "$BASE" "$work/page.png"
else
column "${COL1[@]}" > "$work/c1.miff"
column "${COL2[@]}" > "$work/c2.miff"
column "${COL3[@]}" > "$work/c3.miff"
# Columns side by side, each hung from the top. The gravity has to be north
# for the append itself: +append centres shorter images vertically unless told
# otherwise, which left the third column floating in the middle of the page.
magick "$work/c1.miff" "$work/c2.miff" "$work/c3.miff" \
-background "$BG" -gravity west -splice "${GAP}x0" \
-gravity north +append -chop "${GAP}x0" "$work/panels.png"
magick "$work/panels.png" \
-background "$BG" \
-gravity south -splice "0x${GAP}" \
-gravity west -splice "${GAP}x0" \
-gravity east -splice "${GAP}x0" \
"$work/framed.png"
# The title as its own image, the width of the page, rather than annotated on
# to it. `-annotate` with a gravity places from an origin this composition
# keeps moving, and the text ended up off the left edge; a label of a known
# size cannot land anywhere but where it is appended.
WIDTH="$(magick identify -format '%w' "$work/framed.png")"
magick -background "$BG" -fill "$ACCENT" -font "$FONT" \
-pointsize "$TITLE_SIZE" label:"$TITLE" "$work/title-text.png"
magick "$work/title-text.png" -background "$BG" -gravity center \
-extent "$((WIDTH - BADGE_ZONE))x$((TITLE_SIZE * 2))" \
-gravity east -splice "${BADGE_ZONE}x0" "$work/title.png"
# -depth 8: the label pushes the pipeline to 16-bit, which triples the file
# size of a flat-colour image for nothing a viewer can see.
magick "$work/title.png" "$work/framed.png" -background "$BG" -append \
"$work/page.png"
cp "$work/page.png" "$BASE"
# The callout straddles the bottom of the title band, which a badge-only run
# has no way to measure -- the pieces are gone by then. Record it.
magick identify -format '%h' "$work/title.png" > "$BASE.anchor"
fi
[ -f "$BASE.anchor" ] || { echo "no anchor beside $BASE; rebuild the base" >&2; exit 1; }
TITLE_H="$(cat "$BASE.anchor")"
# --- the callout ------------------------------------------------------------
#
# Drawn as its own image and composited, rather than annotated on to the page:
# it overlaps the panel above it by design, and a composite is the only way to
# put something over a region that already has pixels in it.
# No -size here: `label:` with an explicit size scales the text to fill it,
# which turned a 34pt kicker into a 500pt "NEW" across the whole badge. The
# point size governs, and the padding is added afterwards.
magick -background "$BADGE_BG" -fill "$BADGE_FG" \
-font "$FONT" -pointsize 26 -interword-spacing 10 \
label:"$BADGE_KICKER" "$work/kicker.png"
magick -background "$BADGE_BG" -fill "$BADGE_FG" \
-font "$FONT" -pointsize 44 label:"$BADGE_TITLE" "$work/badge-title.png"
# A solid block, not an outline: the panels are all accent-bordered rectangles
# on black, so one more of those disappears among them. Inverting it -- accent
# ground, black letters -- is what makes it read as a label on the image
# rather than a part of it.
magick "$work/kicker.png" "$work/badge-title.png" \
-background "$BADGE_BG" -gravity west -append \
-bordercolor "$BADGE_BG" -border 22 \
"$work/badge.png"
# Top right: the title is centred, so the corner beside it is empty, and
# hanging the badge below the band's edge lets it clip the first panel of the
# third column -- which is what keeps it looking placed rather than laid out.
PAGE_W="$(magick identify -format '%w' "$work/page.png")"
BADGE_BW="$(magick identify -format '%w' "$work/badge.png")"
BADGE_BH="$(magick identify -format '%h' "$work/badge.png")"
BADGE_X=$((PAGE_W - BADGE_BW - GAP))
# Centred in the title band rather than overlapping anything. The two
# neighbours here are both load-bearing -- the page title to its left and the
# panel header below it -- and dipping into either one cost more than the
# overlap was worth.
BADGE_Y=$(((TITLE_H - BADGE_BH) / 2))
if [ "$BADGE_BW" -gt "$((BADGE_ZONE - GAP))" ]; then
echo " warning: the badge is wider than the ${BADGE_ZONE}px reserved for it" >&2
echo " and will crowd the page title. Shorten the phrase or raise BADGE_ZONE." >&2
fi
magick "$work/page.png" "$work/badge.png" -geometry "+${BADGE_X}+${BADGE_Y}" \
-composite -depth 8 -strip "$OUT"
magick identify "$OUT"
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Locate the plugin panel in a screenshot by its accent border.
Trimming to "any accent-coloured pixel" is not enough: the compositor draws the
focused window's border in the same accent, so a trim swallows whatever sits
behind the panel. The panel is instead found as a filled rectangle -- four
borders enclosing a region -- and the largest such rectangle is returned.
The accent itself is read out of the image rather than hardcoded. It is a theme
colour, so a hardcoded value silently stops matching the day the operator
changes themes -- which is exactly how this last failed, with every shot
reported as "could not locate the panel border" and no hint as to why.
find_panel.py <image> [--accent RRGGBB] -> "WxH+X+Y" on stdout
"""
import sys
from collections import Counter
from PIL import Image
# Enough slack for antialiasing and the border's own gradient, not enough to
# merge two distinct theme colours.
TOLERANCE = 26
# The panel is wider than any window border is thick.
MIN_RUN = 260
def close(px, target, tol=TOLERANCE):
return all(abs(px[i] - target[i]) <= tol for i in range(3))
def candidate_accents(img, limit=6):
"""Saturated colours in the image, most common first.
The panel border is a solid run of one theme colour, so it is always among
the most common saturated pixels. Returning several candidates means a
highlighted row or a colourful wallpaper cannot derail the search.
"""
w, h = img.size
px = img.load()
counts = Counter()
for y in range(0, h, 2):
for x in range(0, w, 2):
r, g, b = px[x, y]
if max(r, g, b) > 110 and (max(r, g, b) - min(r, g, b)) > 60:
counts[(r, g, b)] += 1
accents = []
for colour, _ in counts.most_common():
# Skip anything already covered by a candidate we kept.
if any(close(colour, kept) for kept in accents):
continue
accents.append(colour)
if len(accents) >= limit:
break
return accents
def find_box(img, accent):
w, h = img.size
px = img.load()
# Rows that contain a long horizontal run of accent pixels are candidate
# top/bottom borders.
runs = {} # row -> (start, end) of its longest accent run
for y in range(h):
best = (0, 0, 0)
run_start, run_len = None, 0
for x in range(w):
if close(px[x, y], accent):
if run_start is None:
run_start = x
run_len += 1
else:
if run_len > best[0]:
best = (run_len, run_start, x - 1)
run_start, run_len = None, 0
if run_len > best[0]:
best = (run_len, run_start, w - 1)
if best[0] >= MIN_RUN:
runs[y] = (best[1], best[2])
if not runs:
return None
# Pair each top edge with the furthest bottom edge sharing its extent, and
# keep the tallest rectangle: that is the panel, not a window border.
best_box = None
ys = sorted(runs)
for i, top in enumerate(ys):
x0, x1 = runs[top]
for bottom in reversed(ys[i + 1:]):
bx0, bx1 = runs[bottom]
if abs(bx0 - x0) <= 4 and abs(bx1 - x1) <= 4 and bottom - top > 120:
box = (x1 - x0 + 1, bottom - top + 1, x0, top)
if best_box is None or box[0] * box[1] > best_box[0] * best_box[1]:
best_box = box
break
return best_box
def main():
path = sys.argv[1]
img = Image.open(path).convert("RGB")
if "--accent" in sys.argv:
h = sys.argv[sys.argv.index("--accent") + 1].lstrip("#")
accents = [tuple(int(h[i:i+2], 16) for i in (0, 2, 4))]
else:
accents = candidate_accents(img)
if not accents:
sys.exit("no saturated colour in the image to use as an accent")
for accent in accents:
box = find_box(img, accent)
if box:
print("%dx%d+%d+%d" % box)
return
tried = ", ".join("#%02X%02X%02X" % a for a in accents)
sys.exit("no enclosed rectangle found (tried %s)" % tried)
if __name__ == "__main__":
main()
@@ -0,0 +1,290 @@
{
"status": {
"serverUrl": "https://vault.bitwarden.com",
"lastSync": "2026-08-21T10:00:00.000Z",
"userEmail": "demo@example.com",
"userId": "00000000-0000-0000-0000-000000000000",
"status": "unlocked"
},
"folders": [
{
"object": "folder",
"id": "f-infra",
"name": "Infrastructure"
},
{
"object": "folder",
"id": "f-fin",
"name": "Finance"
},
{
"object": "folder",
"id": "f-social",
"name": "Social"
}
],
"organizations": [
{
"object": "organization",
"id": "org-acme",
"name": "Acme Corp",
"status": 2
}
],
"items": [
{
"object": "item",
"id": "i1",
"organizationId": null,
"folderId": "f-social",
"type": 1,
"name": "GitHub",
"favorite": true,
"login": {
"username": "demo-user",
"password": "hunter2-not-real",
"totp": "otpauth://totp/demo",
"uris": [
{
"uri": "https://github.com"
}
]
}
},
{
"object": "item",
"id": "i2",
"organizationId": null,
"folderId": "f-social",
"type": 1,
"name": "Reddit",
"favorite": false,
"login": {
"username": "demo-user",
"password": "placeholder",
"totp": null,
"uris": [
{
"uri": "https://reddit.com"
}
]
}
},
{
"object": "item",
"id": "i3",
"organizationId": "org-acme",
"folderId": "f-infra",
"type": 1,
"name": "Acme VPN",
"favorite": false,
"login": {
"username": "d.demo@example.com",
"password": "placeholder",
"totp": "otpauth://totp/demo",
"uris": [
{
"uri": "https://vpn.acme.example"
}
]
},
"attachments": [
{
"object": "attachment",
"id": "a3",
"fileName": "acme-vpn.ovpn",
"size": "8192",
"sizeName": "8 KB",
"url": "https://example.invalid/a3"
}
]
},
{
"object": "item",
"id": "i4",
"organizationId": null,
"folderId": "f-infra",
"type": 1,
"name": "Home Assistant",
"favorite": true,
"login": {
"username": "admin",
"password": "placeholder",
"totp": null,
"uris": [
{
"uri": "https://homeassistant.local:8123"
}
]
}
},
{
"object": "item",
"id": "i5",
"organizationId": null,
"folderId": "f-fin",
"type": 1,
"name": "Example Bank",
"favorite": false,
"login": {
"username": "demo-user",
"password": "placeholder",
"totp": "otpauth://totp/demo",
"uris": [
{
"uri": "https://bank.example.com"
}
]
}
},
{
"object": "item",
"id": "i6",
"organizationId": null,
"folderId": null,
"type": 2,
"name": "Recovery Codes",
"favorite": false,
"notes": "Placeholder note. No real data here.",
"secureNote": {
"type": 0
},
"attachments": [
{
"object": "attachment",
"id": "a1",
"fileName": "recovery-codes.txt",
"size": "412",
"sizeName": "412 B",
"url": "https://example.invalid/a1"
},
{
"object": "attachment",
"id": "a2",
"fileName": "backup-key.pem",
"size": "3204",
"sizeName": "3.13 KB",
"url": "https://example.invalid/a2"
}
]
},
{
"object": "item",
"id": "i7",
"organizationId": null,
"folderId": "f-fin",
"type": 3,
"name": "Demo Card",
"favorite": false,
"card": {
"cardholderName": "D. Demo",
"brand": "Visa",
"number": "4111111111111111",
"expMonth": "12",
"expYear": "2030",
"code": "123"
}
},
{
"object": "item",
"id": "i8",
"organizationId": "org-acme",
"folderId": "f-infra",
"type": 1,
"name": "Acme Grafana",
"favorite": false,
"login": {
"username": "d.demo@example.com",
"password": "placeholder",
"totp": null,
"uris": [
{
"uri": "https://grafana.acme.example"
}
]
}
},
{
"object": "item",
"id": "i9",
"organizationId": null,
"folderId": null,
"type": 4,
"name": "Dana Demo",
"favorite": false,
"identity": {
"title": "Ms",
"firstName": "Dana",
"middleName": "R",
"lastName": "Demo",
"username": "danademo",
"company": "Example Industries",
"email": "demo@example.com",
"phone": "+1 555 0100",
"ssn": "000-00-0000",
"passportNumber": "X1234567",
"licenseNumber": "D-0000-0000",
"address1": "1 Example Way",
"address2": "Suite 200",
"address3": "",
"city": "Springfield",
"state": "IL",
"postalCode": "62701",
"country": "US"
}
},
{
"object": "item",
"id": "i-ssh-1",
"organizationId": null,
"folderId": "f-infra",
"type": 5,
"name": "Demo Deploy Key",
"notes": null,
"favorite": false,
"collectionIds": [],
"reprompt": 0,
"revisionDate": "2026-08-20T10:00:00.000Z",
"creationDate": "2026-08-20T10:00:00.000Z",
"sshKey": {
"privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBGTamxk2fLE7NZekQoaoQkLjAbzaNDSJrO8clPlNnoXAAAAJhQ/dXxUP3V\n8QAAAAtzc2gtZWQyNTUxOQAAACBGTamxk2fLE7NZekQoaoQkLjAbzaNDSJrO8clPlNnoXA\nAAAEAqEDoJ+o48bC8qt9agrYLugGkX0IjZdM/iT5kK0Q0BGUZNqbGTZ8sTs1l6RChqhCQu\nMBvNo0NIms7xyU+U2ehcAAAAEGRlbW9AZXhhbXBsZS5jb20BAgMEBQ==\n-----END OPENSSH PRIVATE KEY-----\n",
"publicKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEZNqbGTZ8sTs1l6RChqhCQuMBvNo0NIms7xyU+U2ehc demo@example.com",
"keyFingerprint": "SHA256:WJN+07USrkd8tFp3vVJBXAjwolWfskqqzl+UPp5GH30"
}
}
],
"sends": [
{
"object": "send",
"id": "s1",
"name": "Wifi password for guests",
"type": 0,
"accessUrl": "https://vault.bitwarden.com/#/send/demo1",
"accessCount": 1,
"maxAccessCount": 5,
"deletionDate": "2026-08-24T10:00:00.000Z",
"passwordSet": true,
"disabled": false,
"text": {
"text": "placeholder",
"hidden": true
}
},
{
"object": "send",
"id": "s2",
"name": "Onboarding checklist",
"type": 0,
"accessUrl": "https://vault.bitwarden.com/#/send/demo2",
"accessCount": 0,
"maxAccessCount": null,
"deletionDate": "2026-08-28T10:00:00.000Z",
"passwordSet": false,
"disabled": false,
"text": {
"text": "placeholder",
"hidden": false
}
}
]
}
@@ -0,0 +1,65 @@
# Dependency policy for the SSH agent helper.
#
# This binary holds decrypted private keys. Its dependency tree was reviewed
# once, deliberately and in writing, in docs/decisions/0001-ssh-agent-dependencies.md;
# this file is what stops that review going stale between releases. Anything
# it rejects is a prompt to think, not a rule to route around.
[graph]
targets = ["x86_64-unknown-linux-gnu"]
# Only what the release actually compiles. Auditing features this build never
# enables produces findings nobody can act on.
all-features = false
[advisories]
# Every RustSec advisory is a build failure, with one documented exception.
yanked = "deny"
ignore = [
# RUSTSEC-2023-0071: Marvin, a timing sidechannel in `rsa`'s private-key
# operations. Unpatched upstream -- there is no fixed release to move to.
#
# Accepted for the reasons recorded in the Task 4 ADR: the attack needs
# accurate timing of many private-key operations from the attacker's own
# observations, and this helper signs only after an explicit human approval
# or inside a short grant, over a same-UID socket. An attacker positioned to
# farm timings from it is already a same-UID process on an unlocked desktop,
# which the threat model does not defend against by design.
#
# Revisit when `rsa` publishes a fix, or if Ed25519-only becomes acceptable.
{ id = "RUSTSEC-2023-0071", reason = "no upstream fix; see docs/decisions/0001-ssh-agent-dependencies.md" },
]
[licenses]
# Permissive only. A copyleft dependency in a binary this repository ships
# would change the plugin's own distribution terms, which is a decision for a
# human rather than a dependency bump.
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-3.0",
"Zlib",
]
confidence-threshold = 0.9
[bans]
# Duplicate major versions of one crate mean two copies compiled in. For a
# crypto dependency that also means two implementations, one of which nobody
# audited. Warn rather than deny: transitive duplicates are common and often
# outside our control, but they should be visible in the log.
multiple-versions = "warn"
wildcards = "deny"
# Nothing here should reach for the network or spawn processes; both would
# contradict the design's claim that the helper has exactly three inputs.
deny = []
[sources]
# Only crates.io. A git dependency is a moving target that no lockfile review
# can meaningfully cover, and this binary is committed as bytes.
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = []
@@ -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

Some files were not shown because too many files have changed in this diff Show More