Skip to content

Protect any keytab or file; user vaults with kuipa seal - #6

Merged
abbra merged 21 commits into
mainfrom
protect-any-keytab-or-file
Oct 20, 2026
Merged

abbra merged 21 commits into
mainfrom
protect-any-keytab-or-file

Conversation

@abbra

@abbra abbra commented Oct 20, 2026

Copy link
Copy Markdown
Owner

Extends keytab protection from the host keytab to any keytab and any file, and adds user vaults: users seal their own files with kuipa seal, under a policy the realm's servers enforce.

Protecting keytabs and files (DESIGN §7.5)

  • kuipa protect --keytab F seals a service's or user's keytab for its own principal; kuipa protect --file F [--keytab K] seals any file for K's principal (the host by default). Bindings register outside a rekey through POST /ipa/ku/register/{kid}, authenticated by the principal's own ticket.
  • kuipa-unseal --all (now the unit's command) restores everything at boot; --sealed S --out F|-|--memfd unseals one item, which is also the per-service gssproxy loader (Add dynamic keytab loader gssapi/gssproxy#135).
  • --out - writes the plaintext alone to standard output and refuses a terminal; other --out targets must be regular files outside /dev. Before this, --out /dev/stdout run as root replaced the /dev/stdout link.

One recovery path with commitments

  • tang-pqc gains binding commitments (provision_committed, OpenedRequest::verify_commitment). Every recovery request names a principal and a vault, and the server checks the binding was made for them. Before, only the client-side binding context stopped a principal on whose entry someone had registered the same binding hash.
  • Host and service escrow become machine vaults on that request (§7.6.8). Wire formats change in place; nothing is deployed yet.

User vaults (DESIGN §7.6, docs/sealing.md)

  • Vaults live in cn=vaults,cn=kuipa,cn=etc. Each has a policy: factors (device, ticket), expiry, unlock limit, sessions and sealing mode. Users create vaults themselves within realm limits.
  • Files hold two shares, a server share and a device share, so IPA administrators who can reach the escrow keys still cannot read users' files. The server encrypts its answer to the registered device.
  • Sealing works offline against a descriptor signed by a server and verified against the pinned anchors.
  • Devices:
    • a passphrase-protected key file (IPDK: PBKDF2-HMAC-SHA-512 and AES-GCM; native-ossl exports no encrypted PKCS#8 for ML-KEM keys);
    • an ML-KEM key on a PKCS#11 token, used through pkcs11-provider, tested with kryoptic.
  • Re-authentication sessions keep the session token and the unwrapped device key in $XDG_RUNTIME_DIR until the session ends or kuipa seal lock.
  • Offline sessions use per-epoch keys sealed to every server's published escrow key. Session-mode files open with no server at all. They are off by default in each vault and in the realm.
  • Unlock limits are per-server counters on the vault entry.
  • Registered sealing: --sealing registered, with reseal and retire killing old copies.
  • Ticket delegation: a delegated ticket counts as unknown, which only allow accepts, until the PAC delegation info is read. That work is parked on vault-pac-delegation, using pidl-rs-generated NDR.
  • Registrations and vault operations no longer draw on the per-host rate-limit bucket: they are Kerberos-only, and a host's users must not share one allowance.

Docs

  • DESIGN §7.5 and §7.6, the wire formats, and the tests table.
  • docs/sealing.md: user use cases.
  • docs/machines.md: cloud VM use cases.
  • TODO: the FreeIPA schema and ACIs, host-disable/service-disable setting nsAccountLock, and the remaining work.

Not in this PR

  • Passkeys, as an unlock factor and as a key wrapper; to be done separately, since nothing here can test against an authenticator.
  • Attestation for the hardware-wrapped and hardware-bound levels: every device registers at any.
  • PAC delegation checks.
  • Running LdapDirectory against a real FreeIPA server; already in TODO for the rest of kuipad.

Testing

All contrib/ci/local-ci.sh all jobs pass:

  • End-to-end tests: the vault lifecycle and every refusal path, commitments, sessions, limits, offline epochs and registered sealing.
  • Real-KDC test: service keytab and file protection, and users' vaults with real tickets, including a kryoptic token device.
  • Playground: 52 scripted steps, including opening a session-sealed file with both servers down.

abbra added 21 commits October 20, 2026 11:34
`kuipa protect --keytab F` seals a service's or a user's keytab for its
own principal, and `kuipa protect --file F [--keytab K]` seals any file for
K's principal (the host's by default).  Each is sealed into `F.sealed`,
unsealed into tmpfs under /run/ipa/kuipa with the original mode and owner,
and F becomes a symlink to that copy, as /etc/krb5.keytab does in file
mode.  Protecting a path again reseals what is there now, so keys written
by ipa-getkeytab through the symlink can be resealed.  `unprotect` puts the
plaintext back and retires the bindings.

Bindings outside a host rekey travel in a new join-protocol operation,
`POST /ipa/ku/register/{kid}`, authenticated by the principal's own ticket
and answered under its Kerberos binding.  A principal's entry holds two
binding sets: its keytab's, replaced at every seal, and its files',
added to and retired one seal at a time, so a host rekey leaves files
sealed for the host openable.  kuipad finds hosts by FQDN and other
principals by name under cn=accounts, and recovery accepts either set.

Sealed keytabs keep their bytes exactly (a re-encoded keytab changes its
name type); files use a new `IPSF` envelope.  `kuipa-unseal --all` (now the
unit's command) unseals the host keytab and every recorded item, and
`kuipa-unseal --sealed S [--out F | --memfd]` unseals one, which is the
per-service gssproxy loader.  `status`, `check` and `leave` cover the
recorded items.

DESIGN §7.5 describes it; the schema needs `ipaKUEscrowFileBinding` and
ACIs for the escrow attributes on service and user entries (TODO).

Signed-off-by: Alexander Bokovoy <ab@vda.li>
The keytab_loader change is submitted as gssapi/gssproxy#135: the memfd
loader into a MEMORY keytab, SIGHUP reload, retry with backoff, and core
dumps disabled while a loader is configured.  TODO keeps it open until it
merges, and spells out what kuipa protect then does with it.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
`--out -` writes what --sealed holds to standard output, unchanged and
alone (traces stay on standard error), and refuses a terminal unless
--force.  Any other --out is written atomically, so it must be a regular
file or nothing yet, outside /dev: before, `--out /dev/stdout` run as root
replaced the /dev/stdout link with a file and printed nothing.  Both are
checked before a server is contacted.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
DESIGN §7.6: users seal their own files, without root, to a vault whose
policy (within realm limits) names the factors an unlock needs: a device
key of a given level (passphrase file, passkey-wrapped, PKCS#11-wrapped or
PKCS#11-native ML-KEM), the owner's ticket, a passkey assertion.  Sealing
is offline: every binding carries a commitment to its principal and vault
that the server checks, which closes the hole of presenting another
user's binding under one's own vault.  Files hold a server share and a
device share, so IPA administrators cannot read them; answers are
encrypted to a registered device.  Re-authentication and offline
sessions, an unlock counter in the anchor exchange's CRDT, and host and
service escrow as machine vaults on the same request.  Open questions are
listed in §7.6.12.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
The command is `kuipa seal` (the server object stays a vault, distinct
from ipa vault-*); offline-session keys are sealed per server in the vault
entry; registered sealing stays a policy option beside the offline
default; any of the owner's IPA passkeys satisfies the passkey factor.
Passkeys are used without a browser: kuipa talks CTAP through libfido2 as
SSSD's passkey_child does, so the verifier copied from ahdapa's relying
party code takes a raw clientDataHash.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
An offline session hands a key to the client, so its end is no longer the
server's to enforce.  Both the vault policy and the realm limits now
default to off: an administrator opts the realm in, then a user opts a
vault in.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
docs/sealing.md walks through what users would do with the planned
`kuipa seal`: secrets in a home directory, piping into programs, passkey
and token devices, several machines, a lost laptop, working-day and
offline sessions, a break-glass secret, leaving the organisation, what
protects against whom, and what it does not do.

Writing it showed that a device's level must come from its attestation,
not its type: a software PKCS#11 token or an unattested passkey is an
`any` device, which is also how unattended jobs fit.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
docs/machines.md: anchors baked into images, autoscaler joins with
vouchers (bound to the instance where user-data is readable), staged
bundles for instances without network at first boot, sealed host and
service keytabs and application secrets against snapshots, Clevis
tang-pqc volumes against kuipad, termination hooks, and what the planned
vTPM factor and kuipa seal add.  Each use case says whether it is
available, waits for FreeIPA, or is planned.

The guide states the limit of sealing in a cloud: a recent snapshot
restored inside the network opens while the host is enabled.  It also
avoids promising that host-disable retires sealed files: FreeIPA's
host-disable does not set nsAccountLock, which is what kuipad checks.

The gssproxy example now points at the upstream pull request.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
ipa host-disable and service-disable remove keys and certificates but
leave the entry unlocked, and a principal without keys may still get
tickets with PKINIT.  Missing keys are therefore no proof of a disabled
principal; nsAccountLock is, as ipa-kdb refuses every ticket for a locked
entry.  DESIGN §7.2 and §7.5 now say so, the cloud guide no longer
promises that host-disable retires sealed files, and TODO asks FreeIPA's
disable commands to set the lock.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
On IPA-enrolled Fedora and RHEL a PAM login gives the user a ticket, and
sshd obtains one after a public-key login through protocol transition and
optional S4U2Proxy, within the realm's delegation rules.  The cloud guide
now says so instead of suggesting ssh -K, which puts an unrestricted TGT
on a machine whose root may not be trusted.  DESIGN §7.6 notes that a
delegated ticket attests the host's vouch, so the host keytab should be
sealed, and leaves open whether a policy can refuse delegated tickets.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
The PAC's S4U delegation info lists the services a ticket passed
through, so kuipad can tell a delegated ticket from one the owner got
with their own credentials.  ticket.delegation is allow (the default, so
SSH public-key logins work), refuse, or via a list of services the ticket
may have transited.  DESIGN §7.6, the cloud guide and the sealing guide
describe it.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
A server that authorises recovery by data the binding context carries
cannot see that context: it is mixed into the key on the client only.
provision_committed now also returns HKDF(ss, "<domain> commit" ‖ alg ‖
kid ‖ H(ct) ‖ data), and OpenedRequest::verify_commitment checks it after
opening, so a client cannot present someone else's binding under data it
is authorised for.  The Tang wire format is unchanged.

This is phase 1 of the user vault work (DESIGN §7.6); TODO lists the
phases.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
Every slot of a sealed keytab or file now carries a tang-pqc commitment
to IPCV(principal, vault), where the vault is the machine vault derived
from the principal and the kind (DESIGN §7.6.8).  The recovery request
names the vault and carries the commitment; the server checks it after
opening, then the binding registered in that kind's set only.  Before,
only the binding context, mixed in on the client, stopped a principal on
whose entry someone had registered the same binding hash.

This is phase 2 of the vault work: one recovery path that user vaults
will join.  No deployment exists, so IPSK, IPSF and IPEQ change in place.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
kuipa-enroll's vault module holds the policy (factors, ticket delegation,
expiry, sealing, sessions), the realm limits, registered devices, the
vault descriptor signed under the server's delegation, the owner's vault
operations, the two-share file format (IPUF: a server share released
through any escrow key, a device share per registered device), and
answers encrypted to the device the request names (IPVA).

The unified recovery request gains the device and the owner's GSS-API
token, bound to the request.  For a user vault the server checks the
commitment, the vault and its owner, expiry, the ticket (with its
delegation against the policy), and the device and its level, then
answers to the device.  POST /ipa/ku/vault/{kid} runs owner operations
through the join exchange, authenticated by the owner's ticket and
answered with signed descriptors.

Passkeys, unlock limits, sessions and registered sealing are refused
until their phases land.  Staging moves out of EnrollServer::handle into
its own method.  This is phase 3; the file magic is IPUF, as IPSV was
taken.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
kuipad keeps one entry per vault under cn=vaults,cn=kuipa,cn=etc (owner,
name, policy and devices as JSON, state), reads the realm's limits from
cn=vault-limits (defaults when absent), accepts owners' tickets for vault
operations and the ticket factor, and answers whether an owner is locked.
POST /ipa/ku/vault/{kid} is routed.  The KDC-backed test directory keeps
vaults in memory, so the real-KDC test and the playground can use them.

Phase 4 of the vault work.  The schema joins the FreeIPA TODO.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
kuipad does not read the PAC's S4U delegation info yet, so it cannot tell
a delegated ticket from one the owner obtained.  Accepted::transited is
now None in that case, and only ticket.delegation = allow accepts it:
refuse and via refuse rather than mistake every ticket for an
undelegated one.  The PAC work is parked on vault-pac-delegation.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
Runs as the user, with their ticket.  The realm comes from a new
world-readable realm.json written beside the root-only state, the pinned
anchors and the CA; the user's directory holds the device key (the
ML-KEM seed under PBKDF2-HMAC-SHA-512 and AES-GCM, IPDK), each vault's
last signed descriptor and the servers' signed advertisements, so files
seal offline while the descriptor is within the realm's age limit, which
descriptors now carry.

Commands: create, policy, device add|remove, file, open, reseal, show,
list, revoke.  open --out follows kuipa-unseal's rules through a shared
output module, and with --out - kuipa prints nothing else on standard
output.  The real-KDC test and the playground run a user's vault end to
end.  Phase 6 of the vault work.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
Unlock limits: every release is counted on the vault entry, one
'<server> <count>' value per server in ipaKUVaultUnlocks, each server
replacing only its own, so replication never conflicts and the total is
the sum; a server refuses once the sum reaches max_unlocks.

Re-authentication sessions: the vault operation unlock, authenticated by
the owner's ticket, answers with a token for one registered device,
signed under the server's delegation so any server checks it.  A request
carrying it stands in for the ticket.  Sessions need the device factor,
since the answer is encrypted to the device; a token alone opens nothing.
kuipa seal unlock keeps the token and the unwrapped device key in the
user's runtime directory (tmpfs) until the session ends or kuipa seal
lock, so a session spares the passphrase too.

The counter is per-server LDAP values instead of the anchor exchange's
CRDT, as agreed.  Phase 7 of the vault work.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
A vault whose policy and realm allow offline sessions gets a session key
per epoch, made by a server and sealed to every server's escrow key: each
kuipad publishes its escrow public key on its cn=masters entry, and a
server that can open an epoch key seals it to every published key that
has no slot yet, so later replicas catch up.  Descriptors carry each
epoch's public key only.

kuipa seal file --session seals the server share to the current epoch key
(IPUS) instead of through the escrow keys; kuipa seal unlock --offline,
authenticated by the ticket and counted as one unlock, releases every
epoch key so far encrypted to the device (IPEK), which the client keeps
with the unwrapped device key in the runtime directory and uses to open
session-mode files with no server at all.  The playground opens one with
both servers down.

Registration and vault operations no longer draw on the per-host rate
limit bucket: they are only ever Kerberos-authenticated, and a host's
users must not share one allowance.  Phase 8 of the vault work.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
kuipa seal device add VAULT --pkcs11 URI [--module PATH] registers the
public half of an ML-KEM key generated on a token; the private half never
leaves it.  kuipa loads OpenSSL's pkcs11-provider into a library context
of its own (its configuration written to the user's runtime directory,
since native-ossl's helper names the module as other distributions do),
passes the PIN as the URI's pin-value, and decapsulates on the token.
The host remembers the module and URI; the PIN takes the passphrase's
place everywhere, and a session cannot cache a token key, so opening in
a session still asks for it.

No attestation is verified yet, so token devices register at level any.
Tested against a kryoptic token, made with pkcs11-tool, in a unit test
and in the real-KDC test.  Phase 9a of the vault work.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
With sealing = registered, a server releases a file's share only while
the file's binding hashes are registered on the vault (ipaKUVaultBinding,
left out of descriptors, at most 4096).  kuipa seal file registers them
with the new vault operation register after sealing; kuipa seal reseal
retires the old file's, and kuipa seal retire retires a file's outright,
so every copy of it, wherever it is, stops opening.  --sealing registered
sets it on a policy.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
@sourcery-ai

sourcery-ai Bot commented Oct 20, 2026

Copy link
Copy Markdown

Sorry @abbra, your pull request is larger than the review limit of 150,000 diff characters

@abbra
abbra merged commit 2a3dfe6 into main Oct 20, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant