Phase 7: Dotfiles & Stow

Phase 7: Secrets Bootstrap & Dotfiles

The P16g has no SSH keys, no GPG keys, no gopass, no secrets. You can’t clone anything from GitHub without bootstrapping credentials first. Everything comes from the Razer via rsync.

Bootstrap Order

Step What Why

1

~/.ssh/

SSH keys + known_hosts → can authenticate to GitHub/GitLab/Gitea

2

~/.gnupg/

GPG private keys + trust → YubiKey works, gopass can decrypt

3

~/.age/

age identities + recipients → can decrypt age-encrypted files (SSH config in dots-quantum)

4

gopass stores

~/.local/share/gopass/stores/ → credential access (SSH key passphrases, dsource, etc.)

5

~/.secrets/

Certs, LUKS headers, email config, environment scripts, shell-security

6

ssh-add

Load SSH keys with passphrases from gopass

7

git clone

Now everything works

Step 1: rsync from Razer

Run these from the Razer (not the P16g). Replace the P16g IP if it changed after reboot.

Prerequisites on P16g

rsync must be installed, and password auth must be temporarily enabled (the Razer’s SSH config blocks password auth by default — the Host * block overrides -o flags).

# On the P16g — install rsync
sudo pacman -S rsync
# On the P16g — temporarily enable password auth for rsync bootstrap
sudo sed -i 's/^#PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
sudo sed -i 's/^KbdInteractiveAuthentication no/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config.d/99-archlinux.conf
sudo systemctl restart sshd

Create target directories on P16g

rsync can create the final directory but not parent paths. Create them first:

# On the P16g
mkdir -p ~/.local/share/gopass
mkdir -p ~/.config/gopass
mkdir -p ~/.ssh ~/.gnupg ~/.age ~/.secrets

rsync from Razer

-F /dev/null bypasses the Razer’s SSH config (which has a Host * block that forces pubkey-only). Without this flag, rsync won’t prompt for a password.

P16G_IP="10.50.40.166"
# SSH keys (16 keypairs + known_hosts)
rsync -avz --chmod=D700,F600 -e "ssh -F /dev/null" ~/.ssh/ evanusmodestus@${P16G_IP}:~/.ssh/
# GPG keys (private keys, trust db, agent config)
rsync -avz --chmod=D700,F600 -e "ssh -F /dev/null" ~/.gnupg/ evanusmodestus@${P16G_IP}:~/.gnupg/
# age keys (identities + recipients)
rsync -avz --chmod=D700,F600 -e "ssh -F /dev/null" ~/.age/ evanusmodestus@${P16G_IP}:~/.age/
# gopass stores (v2 + v3)
rsync -avz -e "ssh -F /dev/null" ~/.local/share/gopass/ evanusmodestus@${P16G_IP}:~/.local/share/gopass/
# Secrets repository (certs, LUKS headers, email config, scripts)
rsync -avz -e "ssh -F /dev/null" ~/.secrets/ evanusmodestus@${P16G_IP}:~/.secrets/
# gopass configuration (store paths, settings — without this, gopass doesn't find the stores)
rsync -avz -e "ssh -F /dev/null" ~/.config/gopass/ evanusmodestus@${P16G_IP}:~/.config/gopass/

If you already locked down sshd after the first rsync batch, you’ll need to temporarily re-enable password auth on the P16g for additional transfers:

# On P16g — enable
sudo sed -i 's/^PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
sudo systemctl restart sshd

Run the rsync, then lock down again:

# On P16g — disable
sudo sed -i 's/^PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo systemctl restart sshd
sudo sshd -T | grep 'passwordauthentication'

Lock Down sshd (after rsync complete)

Revert the temporary password auth — back to pubkey-only. Use the verify-before/apply/verify-after pattern.

PasswordAuthentication:

# BEFORE: find the actual line (may be commented with #)
grep 'PasswordAuthentication' /etc/ssh/sshd_config | grep -v PAM | grep -v Depending
# APPLY: handle both commented and uncommented states
sudo sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
# VERIFY
grep '^PasswordAuthentication' /etc/ssh/sshd_config

KbdInteractiveAuthentication:

# BEFORE
grep 'KbdInteractiveAuthentication' /etc/ssh/sshd_config.d/99-archlinux.conf
# APPLY
sudo sed -i 's/^KbdInteractiveAuthentication.*/KbdInteractiveAuthentication no/' /etc/ssh/sshd_config.d/99-archlinux.conf
# VERIFY
grep 'KbdInteractiveAuthentication' /etc/ssh/sshd_config.d/99-archlinux.conf

Restart and confirm runtime config:

sudo systemctl restart sshd
sudo sshd -T | grep -i 'passwordauthentication\|kbdinteractive'
Expected (locked down)
passwordauthentication no
kbdinteractiveauthentication no
^\? in sed matches lines starting with optional — handles both commented and uncommented states in one command.

Verify rsync (from P16g)

ls ~/.ssh/id_ed25519_github*
ls ~/.gnupg/private-keys-v1.d/
ls ~/.age/identities/
ls ~/.local/share/gopass/stores/
ls ~/.secrets/

Step 2: Install Required Packages (P16g)

Check before installing

Always verify what’s already installed and what’s available before running pacman -S:

# Check if a package is already installed
pacman -Q <package-name>
# Search repos for a package (fuzzy match)
pacman -Ss <search-term>
# Find which package provides a missing library
pacman -F <library-name>.so
# Check what package owns an installed file (on a working machine)
pacman -Qo /usr/lib/<library-name>.so

Install

sudo pacman -S gnupg gopass pcsc-tools ccid yubikey-manager gocryptfs age rsync stow pinentry kwindowsystem
Package Why

gnupg

GPG encryption — gopass backend, YubiKey smartcard

gopass

Password store — SSH key passphrases, dsource credentials

pcsc-tools ccid

Smartcard reader drivers for YubiKey

yubikey-manager

YubiKey management CLI (ykman)

gocryptfs

Encrypted filesystem — Claude Code credentials, sensitive configs

age

Modern file encryption — SSH config in dots-quantum is age-encrypted

rsync

File sync — bootstrap secrets from Razer

stow

Symlink farm manager — dots-quantum package deployment

pinentry

GPG passphrase prompt — without it, gopass show fails with "No pinentry"

kwindowsystem

Required by pinentry-qt — missing shared library libKF6WindowSystem.so.6 causes silent GPG decryption failure

sudo systemctl enable --now pcscd

Step 3: Verify GPG + YubiKey

Clear stale GPG locks

rsync’d GPG databases may have stale lock files from the source machine. Clear them first:

find ~/.gnupg -name "*.lock" -delete 2>/dev/null
find ~/.gnupg -name ".#*" -delete 2>/dev/null
gpgconf --kill all

Verify keys loaded

gpg -K

Should show your key with sec and ssb entries. If gpg -K hangs with "waiting for lock", repeat the lock cleanup above.

Verify YubiKey (SSH FIDO2, not GPG)

The YubiKey is used for SSH FIDO2 keys (id_ed25519_sk_*), not for GPG encryption. GPG private keys are stored locally.

# Verify YubiKey is detected
ykman list
Expected output
YubiKey 5C NFC (5.7.1) [OTP+FIDO+CCID] Serial: 31311804
# Verify GPG decryption works with LOCAL keys (no YubiKey touch needed)
export GPG_TTY=$(tty)
echo "test" | gpg --encrypt --recipient A5DF5F8EC04A5EE15E91188228A3183647525597 | gpg --decrypt

Step 4: Verify gopass

Fix gopass root store path

The rsync’d gopass config may reference ~/.password-store (legacy path) but the actual root store is at ~/.local/share/gopass/stores/root. Fix it:

# BEFORE
grep 'path.*password-store' ~/.config/gopass/config
# FIX (only needed if it points to ~/.password-store)
sed -i 's|path = /home/evanusmodestus/.password-store|path = /home/evanusmodestus/.local/share/gopass/stores/root|' ~/.config/gopass/config
# VERIFY
cat ~/.config/gopass/config

Set GPG TTY

GPG needs to know the terminal for passphrase prompts. Without this, decryption fails with "Inappropriate ioctl for device":

export GPG_TTY=$(tty)

Test gopass

gopass ls | head -10
# Test decryption — GPG prompts for passphrase (local key, NOT YubiKey)
gopass show v3/domains/d000/identity/ssh/github
gopass decrypts with local GPG private keys, NOT the YubiKey. The YubiKey is used for SSH FIDO2 authentication (sk keys), not GPG/gopass in this setup.

Step 5: Load SSH Keys

# Start ssh-agent if not running
eval "$(ssh-agent -s)"
# Get GitHub key passphrase from gopass
# Use -f (not -c) over SSH — no clipboard available without wl-clipboard
gopass show -f v3/domains/d000/identity/ssh/github
# Copy the passphrase output, then add the key
ssh-add ~/.ssh/id_ed25519_github
# Paste passphrase when prompted
# Verify
ssh-add -l

Test Git Remote Access

The iPSK VLAN (DOMUS-IoT) blocks outbound port 22. GitHub supports SSH over port 443 via ssh.github.com. You MUST specify -l git (user=git) — without it, SSH sends your local username and GitHub rejects it.

# Test GitHub — port 443 (port 22 blocked on iPSK VLAN)
# -F /dev/null = bypass SSH config (not stowed yet)
# -l git = login as "git" (GitHub requires this, not your username)
# -p 443 = SSH over HTTPS port
ssh -F /dev/null -i ~/.ssh/id_ed25519_github -T -p 443 -l git ssh.github.com
Expected output
Hi EvanusModestus! You've successfully authenticated, but GitHub does not provide shell access.
Once you move to EAP-TLS (Phase 8b) on the DOMUS-Secure VLAN, port 22 will be open and the normal ssh -T git@github.com will work. The port 443 workaround is only needed on the iPSK VLAN.
# Test GitLab (also port 443 if blocked)
ssh -F /dev/null -i ~/.ssh/id_ed25519_gitlab -T -p 443 -l git altssh.gitlab.com 2>/dev/null \
    || ssh -F /dev/null -i ~/.ssh/id_ed25519_gitlab -T git@gitlab.com
# Test Gitea (internal — port 2222, should work on iPSK VLAN)
ssh -F /dev/null -i ~/.ssh/id_ed25519_gitea -p 2222 git@gitea-01.inside.domusdigitalis.dev

Step 6: Create Directory Structure

mkdir -p ~/atelier/{_bibliotheca,_projects/personal}

Step 7: Clone dots-quantum

# Use SSH over port 443 (port 22 blocked on iPSK VLAN)
GIT_SSH_COMMAND="ssh -F /dev/null -i ~/.ssh/id_ed25519_github -p 443 -l git" \
    git clone ssh://ssh.github.com:443/EvanusModestus/dots-quantum.git \
    ~/atelier/_projects/personal/dots-quantum
The ssh:// URL format with :443 is required for non-standard ports. The usual git@github.com:user/repo.git syntax doesn’t support port specification.

Step 8: Install CLI Tools (stow dependencies)

sudo pacman -S fzf fd ripgrep bat eza jq yq lazygit tmux stow
yay -S oh-my-posh-bin tmuxinator

Step 9: Stow Tier 1 (Essential)

Remove default shell configs (conflict with stow)

Arch creates default .bashrc and .bash_profile during install. stow can’t overwrite real files with symlinks — remove them first:

rm -f ~/.bashrc ~/.bash_profile ~/.zshrc
cd ~/atelier/_projects/personal/dots-quantum
stow -t ~ \
    zsh bash shell git \
    hyprland waybar wofi mako \
    kitty oh-my-posh \
    bin share \
    btop fastfetch fzf fd ripgrep \
    claude

Verify Stow

ls -la ~/.zshrc
ls -la ~/.config/hypr/hyprland.conf
ls -la ~/.config/waybar/config.jsonc

Step 10: Decrypt SSH Config from dots-quantum

The SSH config is age-encrypted in dots-quantum. Now that age keys are available:

cd ~/atelier/_projects/personal/dots-quantum
age -d -i ~/.age/identities/personal.key ssh/.ssh/config.age >| ssh/.ssh/config
# Stow SSH package (overwrites the rsync'd config with the stow symlink)
stow -t ~ ssh
# Verify config is symlinked
ls -la ~/.ssh/config

Step 11: Neovim

Install Mason dependencies

Mason auto-installs LSP servers, formatters, linters, and DAP adapters. These need language runtimes present:

sudo pacman -S python curl wget lua luarocks go
# Python package management (Mason uses pip internally)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Rust toolchain (Mason installs rust_analyzer)
rustup default stable
Runtime What Mason installs with it

python

pyright (LSP), ruff (formatter), debugpy (DAP), pylint

nodejs npm

ts_ls, eslint_d, prettier, jsonls, yamlls, bashls, marksman

lua luarocks

lua_ls (LSP), stylua (formatter), luacheck (linter)

go

gopls (LSP), golangci-lint

rust (rustup)

rust_analyzer (LSP), codelldb (DAP)

curl wget

Mason downloads binaries

Clone domus-nvim

# If on DOMUS-Secure VLAN (EAP-TLS), port 22 works directly
git clone git@github.com:EvanusModestus/domus-nvim.git ~/atelier/_projects/personal/domus-nvim

# If still on iPSK VLAN (port 22 blocked), use port 443 workaround
# GIT_SSH_COMMAND="ssh -F /dev/null -i ~/.ssh/id_ed25519_github -p 443 -l git" \
#     git clone ssh://ssh.github.com:443/EvanusModestus/domus-nvim.git \
#     ~/atelier/_projects/personal/domus-nvim

Do NOT symlink to ~/.config/nvim — it won’t work.

The dots-quantum .zshrc exports NVIM_APPNAME="nvim-domus" globally. This tells neovim to look for its config at ~/.config/nvim-domus/ instead of the default ~/.config/nvim/. This is by design — it allows multiple nvim configs to coexist (domus-nvim, instrumentum-nvim, etc.) using the NVIM_APPNAME mechanism.

The symlink MUST match the NVIM_APPNAME:

NVIM_APPNAME = "nvim-domus"
    → nvim looks at ~/.config/nvim-domus/
    → symlink:     ~/.config/nvim-domus → domus-nvim repo
    → data dir:    ~/.local/share/nvim-domus/ (lazy.nvim, Mason, etc.)

If you symlink to ~/.config/nvim instead, nvim opens with a blank default config because it’s not looking there.

# Symlink to nvim-domus (matches NVIM_APPNAME in .zshrc)
ln -sf ~/atelier/_projects/personal/domus-nvim ~/.config/nvim-domus
# Verify — should show the NVIM_APPNAME and matching symlink
echo "NVIM_APPNAME: $NVIM_APPNAME"
ls -la ~/.config/nvim-domus
# First launch — lazy.nvim installs plugins, Mason installs LSP servers +
# formatters + linters + DAP adapters, Treesitter downloads parsers.
# Takes 2-3 minutes. Wait for it to finish before quitting.
nvim
The aliases v (domus-nvim), ncore, nmod, nfidus in .zshrc and aliases.sh switch between configs by changing NVIM_APPNAME per-invocation. nvim without an alias uses the global NVIM_APPNAME="nvim-domus" — which is domus-nvim.

Step 12: Stow Tier 2

cd ~/atelier/_projects/personal/dots-quantum
stow -t ~ \
    tmux vim lazygit \
    vscodium zathura thunar \
    libvirt ghostty \
    gpg aider opencode

Step 13: Source New Shell

# Reload zsh with stowed config
exec zsh