Skip to content

Private dotfiles, encrypted

The public dotfiles repository must never carry PII: git identities, ssh host blocks, known_hosts, allowed_signers — anything that says who you are or what your machines are called. dotty private manages a second repository for exactly that content, storing every secret as age ciphertext encrypted to your YubiKeys. The repository holds ciphertext only, so even a leak of the repo (or the GitHub account) exposes nothing.

The model

dotfiles.private/
├── .dotty-private                  # marker; keeps dotfiles verbs from misrouting
└── profiles/
    ├── personal/
    │   ├── age/
    │   │   ├── recipients.txt      # encrypt targets: this profile's keys only
    │   │   └── identity-<serial>.txt   # non-secret stubs, committed
    │   └── home/                   # mirrors $HOME; <path>.age = encrypted
    │       ├── .config/private/git/config.age
    │       ├── .ssh/config.d/personal.conf.age
    │       └── .ssh/known_hosts.age
    └── work/…

Private profiles mirror the public repository's profiles by name, and each is encrypted only to its own keys — the work profile's YubiKey cannot open the personal profile's files. A profile with several enrolled keys (a backup key) encrypts to all of them, and whichever is plugged in decrypts.

Decryption happens at link time, into ~/.local/share/dotty/private/<profile>/ (0700, files 0600). $HOME symlinks route through ~/.local/share/dotty/private/active-profile, so dotty profile activate retargets one symlink and your whole private identity — git author included — swaps atomically with the profile. Plaintext never enters the repository working tree: encryption reads stdin, edits happen in the 0700 data area, and a scaffolded pre-commit hook runs dotty private verify against accidents.

Setup

dotty private init ~/Repos/dotfiles.private   # scaffold + record in the profile
dotty private enroll                          # age identity on the plugged-in YubiKey
dotty private encrypt ~/.ssh/known_hosts      # adopt files, one by one
dotty private link                            # decrypt + symlink into $HOME

enroll uses age-plugin-yubikey (both come from the security-keys Brewfile fragment): the identity lands in a PIV retired slot, so smart-card login and your SSH signing keys are untouched. Two caveats worth knowing:

  • The PIV PIN and its retry counter are shared with smart-card login. Three wrong PINs at decrypt time lock PIV login too.
  • The default policies (--pin-policy=once, --touch-policy=cached) make a whole-tree decrypt cost one PIN and one touch. The PIN session ends when another applet is used — an ssh signature (FIDO2) between decrypts brings the prompt back.

Enroll a second key onto the same profile for redundancy, then run dotty private rekey so existing ciphertext learns the new recipient. The identity stubs are not secrets — the plugin regenerates them from the token — which is why they commit with the repo.

Day to day

dotty private status              # ok / stale / drifted / conflict / missing
dotty private edit .config/private/git/config
dotty private link                # after a git pull; only changed files decrypt

Decryption is incremental: a manifest of content hashes keeps steady-state relinks from ever touching the hardware, and local edits are never overwritten — a drifted file wants dotty private encrypt, a conflict wants a human. With no YubiKey plugged in, link keeps the previous plaintext and warns (--strict to fail instead), so a work machine without the personal key simply skips the personal profile.

dotty init asks for the private repository (stored per profile as privateRepo) and links it automatically right before the git-identity step — a machine restored from scratch needs the public repo, the private repo, and one YubiKey.

Splitting identities

The private git config is per profile, so what used to be one includeIf monolith splits along machine classes: the personal profile's .config/private/git/config.age carries the personal [user] block and its host routing, the work profile's carries the corporate identity. Verify a swap with:

dotty profile activate --name=work && git var GIT_COMMITTER_IDENT

For ssh, the public template's ~/.ssh/config now starts with Include ~/.ssh/config.d/*.conf; private host blocks belong in profiles/<name>/home/.ssh/config.d/<name>.conf.age. First match wins in ssh, so the drop-ins override the public defaults, and the glob matching nothing is fine on machines without private files.