TOTP-authenticated sudo for kanidm-backed hosts
  • Go 64.1%
  • Shell 25.3%
  • Nix 8.6%
  • Makefile 2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Alex Piqueras ce2df38815
All checks were successful
build / check (push) Successful in 1m7s
ci: say which secret is missing, because a silent 401 is how v0.1.0 shipped empty
The tag build for v0.1.0 failed and nobody noticed. The publish step reads
REGISTRY_USER and REGISTRY_TOKEN, this repository has neither -- secrets are
per-repo in this forge, and the workflow was modelled on homelab's and torch's,
which do have them -- so curl was handed an empty user, answered 401, and the
run died naming nothing. The tag exists, the generic registry holds nothing
under oath-enroll, and homelab#100 records that a citation of v0.1.0 now
resolves to an artefact. It does not.

Checking the variables up front turns that into a first-line failure that names
the missing secret and the scope it needs. It does not fix the tag: the secrets
still have to be set, and a release has to be re-cut afterwards, because CI
checks out the tag ref and can only publish what that ref can build.

Verified by reading the run and the registry -- run 156 (ref v0.1.0) failed
where run 155 (main) passed, which is the publish step and nothing else, and
both the releases list and the generic package list are empty. The upload path
itself is unproven either way: an anonymous probe of the registry endpoint is
refused with the same 401, so the URL is still only as good as the two working
examples it was copied from.
2026-09-24 16:38:07 +02:00
.forgejo/workflows ci: say which secret is missing, because a silent 401 is how v0.1.0 shipped empty 2026-09-24 16:38:07 +02:00
oath-enroll feat: admin groups are a list, with drop-in config, so a node can differ from the fleet 2026-09-16 20:07:03 +02:00
sshd fix: acotar la caché de sudo al terminal y restringir la clave por ssh 2026-09-09 12:54:24 +02:00
.gitignore feat: añadir sudo por otp con enrolamiento propio en hosts con kanidm 2026-09-09 11:50:34 +02:00
flake.lock feat: package oath-enroll as a flake, and build it in CI 2026-09-24 16:14:25 +02:00
flake.nix feat: package oath-enroll as a flake, and build it in CI 2026-09-24 16:14:25 +02:00
install-oath-sudo.sh feat: admin groups are a list, with drop-in config, so a node can differ from the fleet 2026-09-16 20:07:03 +02:00
package.nix feat: package oath-enroll as a flake, and build it in CI 2026-09-24 16:14:25 +02:00
README.md feat: package oath-enroll as a flake, and build it in CI 2026-09-24 16:14:25 +02:00

oath-sudo

Sudo authenticated by TOTP instead of a password, on kanidm-backed hosts.

The OS image knows nothing about individual people. kanidm provides identity — NSS, ssh keys, group membership — and this provides the sudo factor. Users provision themselves on first login.

ssh key (kanidm)  ->  login
TOTP (local seed) ->  sudo
admin + password  ->  break-glass, when both of the above are broken

The seed is deliberately local to the host, never stored in kanidm. That is what keeps the two factors independent: sudo survives a kanidm outage, and a kanidm compromise does not hand over root.

Documentation

Lives in the homelab wiki at wiki/sudo/ — auth model, decisions, runbook, traps, verification and the fleet rollout plan. Start at wiki/sudo/README.md.

This repository holds only the code.

Layout

install-oath-sudo.sh    host prep, run once per node as root. Enrols nobody.
oath-enroll/            static Go binary: generates one user's seed, prints it once
sshd/                   drop-in restricting password auth to the break-glass account

install-oath-sudo.sh is pure declarative host state, which is why it maps cleanly onto Ignition. oath-enroll stays imperative because it must generate a secret and show it exactly once.

pam_oath.so is the only package a host needs. The binary does its own TOTP and QR rendering, so oathtool and qrencode are not required — though oathtool remains handy for debugging, since it can generate a code from the hex seed in users.oath.

Quick start

make -C oath-enroll all
scp -r . <host>:oath-sudo/
ssh -t <host> 'sudo ./oath-sudo/install-oath-sudo.sh --admin-group <group>'

--admin-group may be repeated, or given a comma/space-separated list. Membership in any listed group permits self-enrolment — they are alternatives, so one fleet-wide group is the simple case and a node can still be rolled with different auth without a redesign.

Configuration precedence

oath-enroll and the login notice both read /etc/oath-enroll.conf, then every /etc/oath-enroll.conf.d/*.conf in lexical order. Later files replace earlier keys rather than merging them, so a node declaring its own ADMIN_GROUPS gets exactly those.

/etc/oath-enroll.conf              ADMIN_GROUPS="fleet_admin"    # image default
/etc/oath-enroll.conf.d/50-x.conf  ADMIN_GROUPS="x_admin"        # this node only

The drop-in directory exists for image-based hosts. bootc/ostree three-way-merges /etc against the image on every upgrade, so having the image and the per-node layer write the same path is the case you least want to reason about at upgrade time. Separate files never contend.

ADMIN_GROUPS is canonical; the singular ADMIN_GROUP is still honoured as a fallback, so a host installed before this change keeps working with its /etc untouched.

⚠ --admin-group validates that each group resolves (getent group), and that check cannot run at image build time — there is no kanidm-unixd in a build container. Baking these files therefore converts a loud install-time failure into a silent "nobody can enrol", discovered at first login on a real node. An image-based host wants a boot-time equivalent.

Then, as yourself, in your own terminal — the seed prints once:

sudo oath-enroll

That first run works because the sudoers rule is NOPASSWD, which skips the PAM auth stack entirely. Bootstrap therefore succeeds even on a host where OTP is already mandatory.

Full detail in the runbook.

Nix

The package lives here, next to the code it builds, so a change to go.mod and a change to the derivation move in one commit.

nix build .#oath-enroll        # or: nix run .#oath-enroll -- --user alex

Consumers take it as a flake input:

inputs.oath-sudo.url = "git+ssh://git@git.piqueras.me:443/alex/oath-sudo?ref=v0.1.0";
# then: oath-sudo.packages.${system}.oath-enroll
# or:   nixpkgs.overlays = [ oath-sudo.overlays.default ];

⚠ The repository is private, so the flake input is git+ssh — fetched by the nix CLI outside the build sandbox, where ssh keys apply. A build host needs a key that can read it.

⚠ CGO_ENABLED=0 means id(1) must be on PATH. The Makefile explains why the binary is static; the consequence is that os/user cannot see NSS, so group membership is resolved by shelling out. package.nix wraps the binary to guarantee coreutils rather than trusting the caller's environment — this tool decides who may obtain a sudo token, and an empty group list because a helper was missing is not a failure to leave to chance.