Skip to main content

max / alloy

Move gopass to the client block, where the person it is for sits A personal password store is for the person at the machine, and a headless build host has no use for one. It belongs beside gnome-keyring, which is already client-only for the same kind of reason, and STACK.md's own framing pairs them: gnome-keyring is the API programs call, gopass is where a person keeps their logins. It takes fish with it. Fedora's gopass hard-requires /usr/bin/fish for one completion file, 39.3 MiB of a shell nothing in the image runs, and no dependency flag touches a hard requirement. Two Terra completion packages then follow fish in by conditional dependency. The client pays it because gopass is worth it there; the server no longer pays it at all. MOVING IT BROKE THE BUILD, which is the useful part. Two assertions ran unconditionally: `command -v gopass` and the age-backend probe. Both were written when every profile carried gopass, and both failed the first server build after the move. That is the third instance of the shape subtask 825465e0 named on 2026-08-03: not a conditional with a silent half, but an assertion with no conditional at all. Both are conditional now, client proving presence and server proving absence, and the server branch also asserts fish is gone, which is the whole point of the move. The asserting else of the client package block gains gopass and fish as sentinels for the same reason. Also corrects two things that had quietly become false: the server branch of the secret-service assertion told headless images "gopass is the store here", and the gopass comment said it "needs no gpg on the age backend" -- true of the backend, false of the package, which requires gnupg2 and gets it. Verified on both built images. Server: gopass, fish and gnome-keyring all absent, 675 packages, 3.95 GB. Client: gopass 1.16.1, fish 4.2.0, gnome-keyring 48.0, the age backend answering, 1216 packages, 6.19 GB. docs/STACK.md records the password-manager decision re-taken on the question of what a house GUI would be built over, why syncing the store through SyncKit was considered and deferred, and that a Make Creative password manager is a separate product rather than this. Design: wiki `password-manager-direction`.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 17:45 UTC
Signed with PGP, not checked
Commit: e5cc3ea265f7de7f98b63756fbb3b7f8f7a542d5
Parent: ae92964
3 files changed, +84 insertions, -28 deletions
M Cargo.lock +8 -8
@@ -2643,6 +2643,14 @@
2643 2643 "winnow",
2644 2644 ]
2645 2645
2646 + [[patch.unused]]
2647 + name = "docengine"
2648 + version = "0.7.0"
2649 +
2650 + [[patch.unused]]
2651 + name = "quasi-type"
2652 + version = "0.1.0"
2653 +
2646 2654 [[patch.unused]]
2647 2655 name = "quasi-axum"
2648 2656 version = "0.18.0"
@@ -2675,14 +2683,6 @@
2675 2683 name = "quasi-webview"
2676 2684 version = "0.18.0"
2677 2685
2678 - [[patch.unused]]
2679 - name = "docengine"
2680 - version = "0.7.0"
2681 -
2682 - [[patch.unused]]
2683 - name = "quasi-type"
2684 - version = "0.1.0"
2685 -
2686 2686 [[patch.unused]]
2687 2687 name = "kberg"
2688 2688 version = "0.1.0"
M Containerfile +68 -20
@@ -967,15 +967,13 @@
967 967 # `host`. flatpak is the third and is client-only, since `sandboxed`
968 968 # exists to run desktop applications through portals.
969 969 podman \
970 - # gopass is the password manager, on its age backend, synced by git
971 - # (decided 2026-07-30). Fedora main carries it at 1.16.1; no COPR.
972 - # It needs no gpg on the age backend, and the git half is the `git`
973 - # already named below. A CLI with no graphical half, so it is base.
970 + # gopass is NOT here. It moved to the client block on 2026-08-17,
971 + # with gnome-keyring, which it always should have sat beside: a
972 + # personal password store is for the person at the machine, and a
973 + # headless build host has no use for one. It took `fish` with it,
974 + # which is 39.3 MiB gopass hard-requires and nothing else in the
975 + # image wants — see the note beside it there.
974 976 #
975 - # gnome-keyring, its Secret Service counterpart, is client-only: it
976 - # is the D-Bus provider desktop applications resolve to, and there is
977 - # no session bus to hold it on a headless box.
978 - gopass \
979 977 # git. Not in the base. aliases.nu ships six git aliases, helix's
980 978 # diff gutters need it, docs/STACK.md sells EDITOR=hx on git commit
981 979 # messages, and the stated audience is developers. The full package
@@ -1170,6 +1168,29 @@
1170 1168 # credentials for daemons, no interactive component.
1171 1169 gnome-keyring \
1172 1170 gnome-keyring-pam \
1171 + # gopass is the other half of that sentence: gnome-keyring is the
1172 + # API programs call, gopass is where a person keeps their own
1173 + # logins (docs/STACK.md#secrets). age backend, synced by git,
1174 + # decided 2026-07-30 and re-taken 2026-08-17 on the question of
1175 + # what to build a house GUI over: a directory of age-encrypted
1176 + # files is git-native, so a concurrent edit is a conflict on one
1177 + # secret with a diff, and it reuses the age identity the sops
1178 + # migration already requires. A single-blob vault (KDBX) syncs as
1179 + # two silent copies, which is the failure this stack keeps
1180 + # rejecting.
1181 + #
1182 + # HERE RATHER THAN IN THE BASE, moved 2026-08-17. It is a personal
1183 + # store for the person at the machine, and it costs `fish`: Fedora's
1184 + # gopass hard-requires /usr/bin/fish for one completion file, 39.3
1185 + # MiB of a shell nothing in this image runs, and no flag touches a
1186 + # hard dependency. Two Terra completion packages then follow fish in
1187 + # by conditional dependency. Client pays it because gopass is worth
1188 + # it there; server no longer pays it at all.
1189 + #
1190 + # The comment this replaced said gopass "needs no gpg on the age
1191 + # backend". True of the backend and false of the package: it
1192 + # requires gnupg2 regardless, and gnupg2 is installed.
1193 + gopass \
1173 1194 # -----------------------------------------------------------------
1174 1195 # Session prerequisites — not curated picks. See the header above.
1175 1196 # fedora-bootc is a server base; everything in this group is
@@ -1203,12 +1224,18 @@
1203 1224 wl-clipboard \
1204 1225 && dnf clean all; \
1205 1226 else \
1206 - # The asserting else. Three sentinels, one per reason a package could
1227 + # The asserting else. Five sentinels, one per reason a package could
1207 1228 # have landed here anyway: sway is a direct name in the `then` branch,
1208 1229 # pipewire is the group most likely to arrive as somebody's weak
1209 1230 # dependency, and greetd is what would silently turn a headless box
1210 1231 # into one waiting at a login prompt on a VT nobody can see.
1211 - for unwanted in sway pipewire greetd; do \
1232 + #
1233 + # gopass and fish joined them 2026-08-17, when gopass moved out of the
1234 + # base block. fish is the interesting one: nothing in the image runs
1235 + # it, it is here only as gopass's hard dependency, and it is 39.3 MiB.
1236 + # If either reappears on a server image, the move has been undone by
1237 + # something and the 64 MiB is back without anyone deciding it.
1238 + for unwanted in sway pipewire greetd gopass fish; do \
1212 1239 rpm -q "$unwanted" >/dev/null 2>&1 \
1213 1240 && { echo "profile=server but $unwanted is installed; the client branch ran or something pulled it in" >&2; exit 1; }; \
1214 1241 done; \
@@ -1829,7 +1856,7 @@
1829 1856 else \
1830 1857 [ ! -f "$service" ] \
1831 1858 || { echo "profile=server but something claims org.freedesktop.secrets; gnome-keyring got installed" >&2; exit 1; }; \
1832 - echo "secret service: none on this profile; gopass is the store here"; \
1859 + echo "secret service: none on this profile, and no gopass either since 2026-08-17"; \
1833 1860 fi
1834 1861
1835 1862 # gopass, and the one thing about it that can be wrong without saying so.
@@ -1847,15 +1874,36 @@
1847 1874 # an account with no store, authored by a build step, sitting where the
1848 1875 # real one would go the first time anyone runs gopass as root. A guard
1849 1876 # that leaves state behind is a guard that changes what it was checking.
1850 - RUN command -v gopass >/dev/null \
1851 - || { echo "gopass is missing; docs/STACK.md names it as the password manager" >&2; exit 1; }
1852 - RUN set -eux; \
1853 - probe=$(mktemp -d); \
1854 - HOME="$probe" gopass age --help >/dev/null 2>&1 \
1855 - || { rm -rf "$probe"; echo "this gopass build has no age backend; the whole reason it was picked over pass is gone" >&2; exit 1; }; \
1856 - rm -rf "$probe"; \
1857 - [ ! -e /root/.config/gopass ] \
1858 - || { echo "the gopass probe wrote into /root anyway; it would ship in the image" >&2; exit 1; }
1877 + # CONDITIONAL SINCE 2026-08-17, when gopass moved to the client block. Both
1878 + # of these ran unconditionally and both failed the first server build after
1879 + # the move, which is the third instance of the shape subtask 825465e0 named:
1880 + # not a conditional with a silent half, but an assertion with no conditional
1881 + # at all, written when every profile carried the thing it checks. Kept as two
1882 + # RUNs rather than merged, because the second is the expensive one and the
1883 + # first is what makes its failure legible.
1884 + RUN if [ "$PROFILE" = client ]; then \
1885 + command -v gopass >/dev/null \
1886 + || { echo "gopass is missing; docs/STACK.md names it as the password manager" >&2; exit 1; }; \
1887 + else \
1888 + ! command -v gopass >/dev/null \
1889 + || { echo "profile=server but gopass is installed; it moved to the client block and takes fish with it" >&2; exit 1; }; \
1890 + ! command -v fish >/dev/null \
1891 + || { echo "profile=server but fish is installed; nothing here runs it and only gopass wanted it" >&2; exit 1; }; \
1892 + echo "password store: none on this profile, and no fish behind it"; \
1893 + fi
1894 + RUN if [ "$PROFILE" = client ]; then \
1895 + set -eux; \
1896 + probe=$(mktemp -d); \
1897 + HOME="$probe" gopass age --help >/dev/null 2>&1 \
1898 + || { rm -rf "$probe"; echo "this gopass build has no age backend; the whole reason it was picked over pass is gone" >&2; exit 1; }; \
1899 + rm -rf "$probe"; \
1900 + [ ! -e /root/.config/gopass ] \
1901 + || { echo "the gopass probe wrote into /root anyway; it would ship in the image" >&2; exit 1; }; \
1902 + else \
1903 + [ ! -e /root/.config/gopass ] \
1904 + || { echo "profile=server has a gopass config; something ran it during the build" >&2; exit 1; }; \
1905 + echo "no gopass on this profile, so no age-backend probe and nothing to leave behind"; \
1906 + fi
1859 1907
1860 1908 # =====================================================================
1861 1909 # The screenshot helper — assert the binds reach it.
@@ -244,6 +244,14 @@
244 244
245 245 ### Password manager: **gopass**, age backend, synced by git
246 246
247 + **Re-taken 2026-08-17 and unchanged, on a question the first pass did not ask: what would a house GUI be built over?** The answer decides the format, and the format decides the tool. A directory of age-encrypted files is git-native, so a concurrent edit is a conflict on one secret with a diff to resolve, and the Rust side is one crate (`age`) over a directory walk. A single-blob vault gives interop with phone clients and browser extensions and costs the sync model this section already rejected Syncthing for: over git a KDBX file is a binary conflict with no diff, over Syncthing it is two silent copies. Fedora also packages no lean KDBX CLI — `keepassxc-cli` ships only inside the full `keepassxc` package, so the CLI drags Qt5 onto a GTK image, and the alternative `kpcli` is 103 Perl packages.
248 +
249 + **Syncing the store through SyncKit instead of git was considered and deferred.** It fits better than it looks: SyncKit is E2E by construction (the server never sees row contents), `SyncStore` supplies the schema, changelog, HLC ledger and a content-addressed blob engine, group scopes already model a shared vault, and a custom `ConflictResolver` could keep both versions rather than dropping one. What it costs is independence: git syncs through any host, a USB stick, or nothing, while SyncKit syncs through the MNW server, and a password manager is judged on exactly that. Alloy ships the tool that needs no service.
250 +
251 + **A Make Creative password manager is a separate product, not this.** Max, 2026-08-17: the intended shape is the Bitwarden/Vaultwarden model with the UX taken seriously, built on `quasi`, and it is not shipped with Alloy. That is a client-plus-server design, which is the shape SyncKit already is, so the SyncKit question returns there rather than here. What Alloy ships is a trusted CLI with git compatibility, and the useful property is that gopass's age backend keeps one encrypted file per secret, so a future house client could read a store users already have.
252 +
253 + **Client-only since 2026-08-17.** It sits beside `gnome-keyring` for the same reason: a personal store is for the person at the machine, and a headless build host has no use for one. It costs `fish` on the client, 39.3 MiB that Fedora's gopass hard-requires for a single completion file, which no dependency flag can remove; the server profile no longer pays it.
254 +
247 255 **The deciding factor is key-hierarchy sprawl, not features.** `_private/` is migrating to sops plus age, so an age-backed store reuses an identity that migration already requires. **rbw** against a self-hosted Vaultwarden was the runner-up and is the better answer on mobile and in a browser, but it introduces a second master secret with its own recovery story, and the failure mode of two hierarchies is that backup discipline silently covers one and not the other. **KeePassXC** was rejected for Qt, as above. **`pass`** was rejected for being GPG-centric when the whole direction is away from GPG toward age.
248 256
249 257 **git, not Syncthing, even though the store is encrypted.** Syncthing replicating age ciphertext is technically sound, and the confidentiality objection does disappear once everything is encrypted at rest. It is still wrong here: Syncthing resolves a concurrent edit by writing `secret.age.sync-conflict-<timestamp>-<id>` beside the original and carrying on. For a credential store that is two versions of a password, no signal about which is current, and nothing forcing you to look. git turns the same situation into a merge conflict that blocks until resolved. Loud beats silent when the file is what you are about to authenticate with. gopass is also git-native, so Syncthing would mean disabling the mechanism the tool is built around.