I’m working on security.pam and pam.nix. I’d like to preview a new feature and get comments on the design.
Problem: pam.nix isn’t modular
Two big strengths of the NixOS module system are that you can compose modules and merge multiple module configurations. The nginx module configures systemd options, those options are used to configure environment.etc, and so on; this is composition. Many modules can define systemd.services or environment.etc; this is merging.
security.pam has a problem: In practice, you cannot merge different PAM configurations from different client modules. If you use both fprintd and systemd-homed, you want each one to contribute PAM rules using security.pam, just like multiple modules can use environment.etc. But security.pam can’t (practically speaking) do this.
You can define entirely new PAM services using security.pam. For example, desktop managers like gdm and sddm do this. But they are defining the entire rule stack, and in a way that prohibits other modules from defining rules for those same services.
The issue with merging rules into the same service is that PAM rules are ordered. I won’t go into the details here, but suffice to say the ordering of rules in a PAM service file can be really important. Should the fprintd rule go before the systemd-homed rule? What about rssh and gnupg? Where should those go in relation to unix?
The way it works today is that pam.nix defines all of those rules in a big list, and other modules (e.g. services.fprintd) get to toggle those rules on and off. But the ordering is fixed by pam.nix.
But technically…
One of my earlier changes (#255547) made it technically possible for different modules to define rules for the same PAM service. But it doesn’t enable decomposing pam.nix in practice. Why not?
I added an order integer option to each rule. The rules are then sorted by their order value. So if you want to put fprintd before systemd-homed, you can configure:
{
security.pam.services.login.rules.fprintd.order =
config.security.pam.services.login.rules.systemd-homed.order - 1;
}
What if you now want rssh to go between the two? Gah, there’s no room left! And maybe you have multiple other rules you want to put your rule before or after, and you don’t know what order those rules will be in.
So while it’s technically possible with this (hidden, experimental) order option to compose rules from multiple modules, it doesn’t really work out. And not a single NixOS module has been extracted from pam.nix using it.
Topological sorting
What if we let rules define multiple ordering relationships relative to other rules? Something like:
{
security.pam.services.login.rules.auth.securetty = {
control = "required";
modulePath = "pam_securetty.so";
after.rule.rootok = true;
before.rule.unix = true;
before.rule.unix-early = true;
};
}
This isn’t a new idea. But now I’ve implemented it! (PR coming soon.)
The big list of rules in pam.nix remains. But it’s now used to create these before/after relationships between rules in the list. As rules are extracted from pam.nix to separate modules, the idea is that they would explicitly define the essential ordering relationships.
Targets
But, ugh, I wrote that and I still haven’t extracted anything from pam.nix.
Let’s take fprintd for example. Its auth rule sits right before systemd_home-early and right after p9. So if I enable fprintd on my system, where will that rule go? Well, I don’t have either systemd_home-early or p9, so I have no idea! We have to look further up and down the rule stack to find an ordering relationship.
It also comes before unix-early, which I have because I use GDM. That’s probably an important ordering. But what if someone doesn’t have unix-early? Everyone will have unix, right? Well, unless they disabled unixAuth and use ldap. But fprintd should come before that too!
It seems like every new rule would end up defining a huge list of before or after rules referencing all the other rules in NixOS. That’s not very modular.
So I’m prototyping targets for security.pam. Targets are well-known points in a rule stack that we can order rules around, but which otherwise don’t affect PAM behavior. These are similar conceptually to systemd targets, but otherwise unrelated.
We can define targets:
{
security.pam.services.login.targets.auth = {
root = {};
early = {
after.target.root = true;
before.target.main = true;
};
main = {};
};
}
and then order rules with respect to the targets:
{
security.pam.services.login.rules.auth.securetty = {
control = "required";
modulePath = "pam_securetty.so";
before.target.root = true;
};
security.pam.services.sudo.rules.auth.rssh = {
after.target.early = true;
before.target.main = true;
};
}
The idea is that pam.nix will offer an opinionated set of targets. Other NixOS modules can define new rules with respect to those targets. Users can modify those orderings to customize their setups. (To unset an ordering relationship, you can set it to false.)
Some open design questions
Ambiguous ordering
It’s possible to configure rules such that more than one ordering is valid. Should this be allowed?
If yes, then it’s possible that an innocent config change or nixpkgs update could cause the actual ordering of PAM rules for a user to change.
If no, then when we detect an ambiguous ordering, we would fail at evaluation time and tell the user to specify an ordering. This case is easy to detect, but I don’t know yet how to generate a helpful error message.
I’m leaning no.
Built-in targets
pam.nix needs to define a useful set of targets for other modules to build upon. What should those targets be?
I can see some patterns in the auth rules in pam.nix. There’s some local policy enforcement, then various passwordless auth providers, then “early auth” steps, then password-based “early auth” providers, the main password auth rules, a few post-password(?) rules, and the catch-all deny rule.
But it’s squishy. Some of the existing rules probably have to be re-ordered for any set of targets to make sense. (For example, why is oslogin_login the very first rule? Can’t it come after faillock?)
Adopting targets can be done progressively, so we can start with targets that are obvious (like the “early auth” steps). I could use advice here!
Option structure
So we currently define rules like this:
{
security.pam.services.login.rules.auth.securetty = { ... };
security.pam.services.passwd.rules.password.ldap = { ... };
}
auth and password are the “type” of PAM rule. Each type is an attrset with named rules.
If we define a quality target, it might look like:
{
security.pam.services.login.rules.auth.securetty = { ... };
security.pam.services.passwd.rules.password.ldap = { ... };
security.pam.services.passwd.targets.password.quality = { ... };
}
But that’s kind of backwards, right? The rules and targets are both for the password type. What if we swap them?
{
security.pam.services.login.auth.rules.securetty = { ... };
security.pam.services.passwd.password.rules.ldap = { ... };
security.pam.services.passwd.password.targets.quality = { ... };
}
Looks better! But now password (and auth and account and session) are options at the same level as service options like unixAuth, rootOK, startSession, etc. Maybe nest them under types?
{
security.pam.services.login.types.auth.rules.securetty = { ... };
security.pam.services.passwd.types.password.rules.ldap = { ... };
security.pam.services.passwd.types.password.targets.quality = { ... };
}
Now it’s clean in a way only a programmer could like. But these are power-user options, right? Just like how a user sets services.fprintd.enable and doesn’t worry about how it uses systemd.services, they shouldn’t have to worry about how it uses security.pam.services.
What do you think? Is the more structured approach acceptable?
Rough plan
Not all of this is related to the above, but here are some pam.nix changes I’ve prototyped:
- Add a
useDefaultRulesoption. This can be set tofalseto suppress the default rules thatpam.nixusually adds. This allows a module to define a blank-slate PAM service using therulesoptions instead oftext. - Replace the existing usages of
textin nixpkgs withrules. This is a stepping-stone to better integrating modules with the main rule stack. - Introduce topological sorting with
after/before, replacing the experimentalorderproperty. - Introduce targets, also using topological ordering with respect to rules and other targets.
What’s next?
- Some PAM rules are configured per-service, while others are configured globally for all services. I think it would be nice if all rules and settings could be configured per-service (to the extent it makes sense) and global options served only as a convenience mechanism to configure all services.
- A way to configure the default service rules would be nice. Something like
security.pam.defaults, with the same options as a PAM service. These are the settings that would be applied ifuseDefaultRulesis enabled. - Once the ordering and targets design is more settled, I want to try extracting a module or two from
pam.nix. This is the litmus test for any potentialsecurity.pamdesign changes.