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
useDefaultRules
option. This can be set tofalse
to suppress the default rules thatpam.nix
usually adds. This allows a module to define a blank-slate PAM service using therules
options instead oftext
. - Replace the existing usages of
text
in nixpkgs withrules
. This is a stepping-stone to better integrating modules with the main rule stack. - Introduce topological sorting with
after
/before
, replacing the experimentalorder
property. - 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 ifuseDefaultRules
is 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.pam
design changes.